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/
|
||||
*.tgz
|
||||
kcef-bundle/
|
||||
kcef-bundle-linux-x64/
|
||||
cache/
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appNavigation_whenPptxSelected_navigatesToPdfViewer() {
|
||||
fakeUiState.value = ReaderScreenState(
|
||||
selectedFileType = FileType.PPTX,
|
||||
selectedPdfUri = Uri.parse("content://dummy.pptx")
|
||||
)
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appNavigation_whenEpubSelected_navigatesToEpubReader() {
|
||||
// Trigger state change
|
||||
|
|
@ -115,4 +128,19 @@ class AppNavigationTest {
|
|||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||
assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
|
||||
}
|
||||
}
|
||||
|
||||
@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.")
|
||||
}
|
||||
|
||||
@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
|
||||
fun htmlToSemanticBlocks_imageWithRootRelativePath_resolvesCorrectly() {
|
||||
// SETUP
|
||||
|
|
@ -372,4 +405,4 @@ class HtmlParserTest {
|
|||
val imageBlock = block as SemanticImage
|
||||
assertThat(imageBlock.path).isEqualTo(imageFile.absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,6 +164,16 @@
|
|||
<data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
|
||||
</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 -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
|
@ -240,4 +250,4 @@
|
|||
</provider>
|
||||
</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;
|
||||
}
|
||||
|
||||
#content-container a[href],
|
||||
#content-container a[href]:link,
|
||||
#content-container a[href]:visited,
|
||||
body a[href],
|
||||
body a[href]:link,
|
||||
body a[href]:visited,
|
||||
a[href],
|
||||
a[href]:link,
|
||||
a[href]:visited {
|
||||
color: var(--reader-link, #005FCC) !important;
|
||||
cursor: pointer;
|
||||
text-decoration-line: underline !important;
|
||||
text-decoration-color: var(--reader-link-decoration, var(--reader-link, #005FCC)) !important;
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-decoration-thickness: max(1px, 0.08em);
|
||||
text-underline-offset: 0.14em;
|
||||
text-decoration-skip-ink: auto;
|
||||
background-image: linear-gradient(transparent 62%, var(--reader-link-bg, rgba(0, 95, 204, 0.16)) 62%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
#content-container a[href] *,
|
||||
body a[href] *,
|
||||
a[href] * {
|
||||
color: var(--reader-link, #005FCC) !important;
|
||||
text-decoration-color: var(--reader-link-decoration, var(--reader-link, #005FCC)) !important;
|
||||
}
|
||||
|
||||
figure img {
|
||||
height: auto !important;
|
||||
}
|
||||
|
|
@ -206,6 +234,7 @@
|
|||
|
||||
var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF');
|
||||
var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000');
|
||||
var linkPalette = getReaderLinkPalette(isDark, effectiveBg, effectiveText);
|
||||
|
||||
var effectiveTextureAlpha = Math.max(0, Math.min(1, textureAlpha == null ? 0.55 : textureAlpha));
|
||||
var bgMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(effectiveBg);
|
||||
|
|
@ -221,6 +250,9 @@
|
|||
:root {
|
||||
--reader-bg: ${effectiveBg};
|
||||
--reader-text: ${effectiveText};
|
||||
--reader-link: ${linkPalette.color};
|
||||
--reader-link-decoration: ${linkPalette.color};
|
||||
--reader-link-bg: ${linkPalette.background};
|
||||
}
|
||||
html.${themeClassName}, html.${themeClassName} body {
|
||||
background-color: var(--reader-bg) !important;
|
||||
|
|
@ -228,21 +260,46 @@
|
|||
${textureCss}
|
||||
}
|
||||
|
||||
html.${themeClassName} a {
|
||||
color: ${isDark ? '#BB86FC' : '#1A0DAB'} !important;
|
||||
html.${themeClassName} body a[href],
|
||||
html.${themeClassName} body a[href]:link,
|
||||
html.${themeClassName} body a[href]:visited,
|
||||
html.${themeClassName} a[href],
|
||||
html.${themeClassName} a[href]:link,
|
||||
html.${themeClassName} a[href]:visited {
|
||||
color: var(--reader-link) !important;
|
||||
cursor: pointer;
|
||||
text-decoration-line: underline !important;
|
||||
text-decoration-color: var(--reader-link-decoration) !important;
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-decoration-thickness: max(1px, 0.08em);
|
||||
text-underline-offset: 0.14em;
|
||||
text-decoration-skip-ink: auto;
|
||||
background-image: linear-gradient(transparent 62%, var(--reader-link-bg) 62%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
html.${themeClassName} a p,
|
||||
html.${themeClassName} a div,
|
||||
html.${themeClassName} a span,
|
||||
html.${themeClassName} a li,
|
||||
html.${themeClassName} a h1,
|
||||
html.${themeClassName} a h2,
|
||||
html.${themeClassName} a h3,
|
||||
html.${themeClassName} a h4,
|
||||
html.${themeClassName} a h5,
|
||||
html.${themeClassName} a h6 {
|
||||
color: var(--reader-text) !important;
|
||||
html.${themeClassName} body a[href] p,
|
||||
html.${themeClassName} body a[href] div,
|
||||
html.${themeClassName} body a[href] span,
|
||||
html.${themeClassName} body a[href] li,
|
||||
html.${themeClassName} body a[href] h1,
|
||||
html.${themeClassName} body a[href] h2,
|
||||
html.${themeClassName} body a[href] h3,
|
||||
html.${themeClassName} body a[href] h4,
|
||||
html.${themeClassName} body a[href] h5,
|
||||
html.${themeClassName} body a[href] h6,
|
||||
html.${themeClassName} a[href] p,
|
||||
html.${themeClassName} a[href] div,
|
||||
html.${themeClassName} a[href] span,
|
||||
html.${themeClassName} a[href] li,
|
||||
html.${themeClassName} a[href] h1,
|
||||
html.${themeClassName} a[href] h2,
|
||||
html.${themeClassName} a[href] h3,
|
||||
html.${themeClassName} a[href] h4,
|
||||
html.${themeClassName} a[href] h5,
|
||||
html.${themeClassName} a[href] h6 {
|
||||
color: var(--reader-link) !important;
|
||||
text-decoration-color: var(--reader-link-decoration) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
|
|
@ -290,6 +347,67 @@
|
|||
} : {r:255,g:255,b:255};
|
||||
}
|
||||
|
||||
function rgbToHex(rgb) {
|
||||
function channel(value) {
|
||||
var hex = Math.max(0, Math.min(255, value)).toString(16);
|
||||
return hex.length < 2 ? '0' + hex : hex;
|
||||
}
|
||||
return '#' + channel(rgb.r) + channel(rgb.g) + channel(rgb.b);
|
||||
}
|
||||
|
||||
function contrastRatio(first, second) {
|
||||
var firstLum = getLuminance(first.r, first.g, first.b);
|
||||
var secondLum = getLuminance(second.r, second.g, second.b);
|
||||
var lighter = Math.max(firstLum, secondLum);
|
||||
var darker = Math.min(firstLum, secondLum);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function getReaderLinkPalette(isDark, bgHex, textHex) {
|
||||
var bg = hexToRgb(bgHex);
|
||||
var text = hexToRgb(textHex);
|
||||
var bgLum = getLuminance(bg.r, bg.g, bg.b);
|
||||
var textLum = getLuminance(text.r, text.g, text.b);
|
||||
var candidates = (isDark || bgLum < 0.45)
|
||||
? [
|
||||
{ r: 125, g: 211, b: 252 },
|
||||
{ r: 94, g: 234, b: 212 },
|
||||
{ r: 165, g: 180, b: 252 },
|
||||
{ r: 253, g: 230, b: 138 },
|
||||
{ r: 255, g: 255, b: 255 }
|
||||
]
|
||||
: [
|
||||
{ r: 0, g: 95, b: 204 },
|
||||
{ r: 0, g: 109, b: 117 },
|
||||
{ r: 122, g: 30, b: 82 },
|
||||
{ r: 74, g: 20, b: 140 },
|
||||
{ r: 17, g: 24, b: 39 }
|
||||
];
|
||||
|
||||
var best = candidates[0];
|
||||
var bestScore = -1;
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
var candidate = candidates[i];
|
||||
var contrast = contrastRatio(candidate, bg);
|
||||
var separation = Math.abs(getLuminance(candidate.r, candidate.g, candidate.b) - textLum);
|
||||
if (contrast >= 4.5 && separation >= 0.08) {
|
||||
best = candidate;
|
||||
break;
|
||||
}
|
||||
var score = contrast * 10 + separation;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
var alpha = bgLum < 0.45 ? 0.24 : 0.16;
|
||||
return {
|
||||
color: rgbToHex(best),
|
||||
background: `rgba(${best.r}, ${best.g}, ${best.b}, ${alpha})`
|
||||
};
|
||||
}
|
||||
|
||||
function rgbStringToRgb(rgbStr) {
|
||||
var parts = rgbStr.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
||||
if (parts) {
|
||||
|
|
@ -346,6 +464,7 @@
|
|||
|
||||
var elements = document.querySelectorAll('[style*="color"]');
|
||||
elements.forEach(function(el) {
|
||||
if (el.closest && el.closest('a[href]')) return;
|
||||
var style = window.getComputedStyle(el);
|
||||
var colorStr = style.color;
|
||||
var rgb = rgbStringToRgb(colorStr);
|
||||
|
|
|
|||
|
|
@ -333,6 +333,58 @@ static bool init_pdfium() {
|
|||
return get_annot_count_func != nullptr;
|
||||
}
|
||||
|
||||
static constexpr int kMaxSafeAnnotCount = 100000;
|
||||
|
||||
static int get_safe_annot_count(void* page) {
|
||||
if (!get_annot_count_func || page == nullptr) return 0;
|
||||
int count = get_annot_count_func(page);
|
||||
if (count < 0 || count > kMaxSafeAnnotCount) {
|
||||
LOGE("Ignoring invalid annotation count: %d", count);
|
||||
return 0;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
class ScopedPdfAnnot {
|
||||
public:
|
||||
explicit ScopedPdfAnnot(void* annot) : annot_(annot) {}
|
||||
~ScopedPdfAnnot() {
|
||||
if (annot_ && close_annot_func) {
|
||||
close_annot_func(annot_);
|
||||
}
|
||||
}
|
||||
|
||||
ScopedPdfAnnot(const ScopedPdfAnnot&) = delete;
|
||||
ScopedPdfAnnot& operator=(const ScopedPdfAnnot&) = delete;
|
||||
|
||||
ScopedPdfAnnot(ScopedPdfAnnot&& other) noexcept : annot_(other.annot_) {
|
||||
other.annot_ = nullptr;
|
||||
}
|
||||
|
||||
ScopedPdfAnnot& operator=(ScopedPdfAnnot&& other) noexcept {
|
||||
if (this != &other) {
|
||||
if (annot_ && close_annot_func) {
|
||||
close_annot_func(annot_);
|
||||
}
|
||||
annot_ = other.annot_;
|
||||
other.annot_ = nullptr;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
void* get() const { return annot_; }
|
||||
|
||||
private:
|
||||
void* annot_;
|
||||
};
|
||||
|
||||
static ScopedPdfAnnot get_annot_checked(void* page, jint index) {
|
||||
if (!get_annot_func || page == nullptr || index < 0) return ScopedPdfAnnot(nullptr);
|
||||
int count = get_safe_annot_count(page);
|
||||
if (index >= count) return ScopedPdfAnnot(nullptr);
|
||||
return ScopedPdfAnnot(get_annot_func(page, index));
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jdouble JNICALL
|
||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
|
||||
std::lock_guard<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
|
||||
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);
|
||||
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* annot = get_annot_func(page, index);
|
||||
if (!annot) return nullptr;
|
||||
ScopedPdfAnnot annot = get_annot_checked(page, index);
|
||||
if (!annot.get()) return nullptr;
|
||||
|
||||
const char* nativeKey = env->GetStringUTFChars(key, nullptr);
|
||||
if (!nativeKey) return nullptr;
|
||||
|
||||
if (strcmp(nativeKey, "IRT") == 0) {
|
||||
if (get_linked_annot_func && close_annot_func) {
|
||||
void* parentAnnot = get_linked_annot_func(annot, "IRT");
|
||||
void* parentAnnot = get_linked_annot_func(annot.get(), "IRT");
|
||||
if (parentAnnot) {
|
||||
unsigned long len = get_annot_string_func(parentAnnot, "NM", nullptr, 0);
|
||||
jstring result = nullptr;
|
||||
|
|
@ -448,7 +501,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass
|
|||
return nullptr;
|
||||
}
|
||||
|
||||
unsigned long len = get_annot_string_func(annot, nativeKey, nullptr, 0);
|
||||
unsigned long len = get_annot_string_func(annot.get(), nativeKey, nullptr, 0);
|
||||
|
||||
if (len <= 2) {
|
||||
env->ReleaseStringUTFChars(key, nativeKey);
|
||||
|
|
@ -456,7 +509,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass
|
|||
}
|
||||
|
||||
std::vector<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));
|
||||
|
||||
|
|
@ -1592,15 +1645,17 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jcl
|
|||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
||||
std::lock_guard<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);
|
||||
int count = get_annot_count_func(page);
|
||||
int count = get_safe_annot_count(page);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
void* annot = get_annot_func(page, i);
|
||||
ScopedPdfAnnot annot = get_annot_checked(page, i);
|
||||
if (!annot.get()) continue;
|
||||
|
||||
float r[4]; // L, B, R, T
|
||||
if (get_annot_rect_func(annot, r)) {
|
||||
if (get_annot_rect_func(annot.get(), r)) {
|
||||
// FIX: Use min/max to handle inverted PDF rectangles
|
||||
float minX = fmin(r[0], r[2]);
|
||||
float maxX = fmax(r[0], r[2]);
|
||||
|
|
@ -1608,8 +1663,9 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env,
|
|||
float maxY = fmax(r[1], r[3]);
|
||||
|
||||
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
|
||||
LOGI("PdfInteraction: MATCH FOUND! Index=%d, Type=%d", i, get_annot_subtype_func(annot));
|
||||
return get_annot_subtype_func(annot);
|
||||
int subtype = get_annot_subtype_func(annot.get());
|
||||
LOGI("PdfInteraction: MATCH FOUND! Index=%d, Type=%d", i, subtype);
|
||||
return subtype;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1619,15 +1675,22 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env,
|
|||
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
||||
std::lock_guard<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);
|
||||
int count = get_annot_count_func(page);
|
||||
int count = get_safe_annot_count(page);
|
||||
for (int i = 0; i < count; i++) {
|
||||
void* annot = get_annot_func(page, i);
|
||||
ScopedPdfAnnot annot = get_annot_checked(page, i);
|
||||
if (!annot.get()) continue;
|
||||
|
||||
float rect[4];
|
||||
if (get_annot_rect_func(annot, rect)) {
|
||||
if (x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]) {
|
||||
if (get_annot_rect_func(annot.get(), rect)) {
|
||||
float minX = fminf(rect[0], rect[2]);
|
||||
float maxX = fmaxf(rect[0], rect[2]);
|
||||
float minY = fminf(rect[1], rect[3]);
|
||||
float maxY = fmaxf(rect[1], rect[3]);
|
||||
|
||||
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
|
||||
jfloatArray result = env->NewFloatArray(4);
|
||||
env->SetFloatArrayRegion(result, 0, 4, rect);
|
||||
return result;
|
||||
|
|
@ -1641,26 +1704,26 @@ extern "C" JNIEXPORT jint JNICALL
|
|||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||
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
|
||||
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);
|
||||
if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func || pagePtr == 0) return 0;
|
||||
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
|
||||
return annot ? get_annot_subtype_func(annot) : 0;
|
||||
if (!init_pdfium() || !get_annot_subtype_func || pagePtr == 0) return 0;
|
||||
ScopedPdfAnnot annot = get_annot_checked(reinterpret_cast<void*>(pagePtr), index);
|
||||
return annot.get() ? get_annot_subtype_func(annot.get()) : 0;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||
if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
|
||||
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
|
||||
if (!annot) return nullptr;
|
||||
if (!init_pdfium() || !get_annot_rect_func || pagePtr == 0) return nullptr;
|
||||
ScopedPdfAnnot annot = get_annot_checked(reinterpret_cast<void*>(pagePtr), index);
|
||||
if (!annot.get()) return nullptr;
|
||||
|
||||
float rect[4];
|
||||
if (!get_annot_rect_func(annot, rect)) return nullptr;
|
||||
if (!get_annot_rect_func(annot.get(), rect)) return nullptr;
|
||||
|
||||
jfloatArray result = env->NewFloatArray(4);
|
||||
env->SetFloatArrayRegion(result, 0, 4, rect);
|
||||
|
|
@ -1670,31 +1733,30 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass cl
|
|||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
||||
std::lock_guard<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);
|
||||
int count = get_annot_count_func(page);
|
||||
void* hitAnnot = nullptr;
|
||||
int count = get_safe_annot_count(page);
|
||||
int hitSubtype = 0;
|
||||
|
||||
LOGI("PdfLinkDiagnostic: [C++] performClick at x=%f, y=%f (Total annots: %d)", x, y, count);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
void* annot = get_annot_func(page, i);
|
||||
if (!annot) continue;
|
||||
ScopedPdfAnnot annot = get_annot_checked(page, i);
|
||||
if (!annot.get()) continue;
|
||||
|
||||
float r[4];
|
||||
if (get_annot_rect_func(annot, r)) {
|
||||
if (get_annot_rect_func(annot.get(), r)) {
|
||||
float minX = fminf(r[0], r[2]);
|
||||
float maxX = fmaxf(r[0], r[2]);
|
||||
float minY = fminf(r[1], r[3]);
|
||||
float maxY = fmaxf(r[1], r[3]);
|
||||
|
||||
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
|
||||
hitAnnot = annot;
|
||||
int subtype = get_annot_subtype_func(hitAnnot);
|
||||
LOGI("PdfLinkDiagnostic: [C++] HIT! Annot Index %d, Subtype %d", i, subtype);
|
||||
hitSubtype = get_annot_subtype_func(annot.get());
|
||||
LOGI("PdfLinkDiagnostic: [C++] HIT! Annot Index %d, Subtype %d", i, hitSubtype);
|
||||
if (get_annot_flags_func) {
|
||||
int flags = get_annot_flags_func(hitAnnot);
|
||||
int flags = get_annot_flags_func(annot.get());
|
||||
LOGI("PdfLinkDiagnostic: [C++] Flags for hit annot: %d", flags);
|
||||
}
|
||||
break;
|
||||
|
|
@ -1702,25 +1764,23 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass cl
|
|||
}
|
||||
}
|
||||
|
||||
if (hitAnnot) {
|
||||
int subtype = get_annot_subtype_func(hitAnnot);
|
||||
|
||||
if (subtype == 19 || subtype == 20) {
|
||||
LOGI("PdfInteraction: Button clicked (Subtype %d). Performing Blanket Reveal.", subtype);
|
||||
if (hitSubtype != 0) {
|
||||
if (hitSubtype == 19 || hitSubtype == 20) {
|
||||
LOGI("PdfInteraction: Button clicked (Subtype %d). Performing Blanket Reveal.", hitSubtype);
|
||||
bool anyChanged = false;
|
||||
|
||||
for (int j = 0; j < count; j++) {
|
||||
void* target = get_annot_func(page, j);
|
||||
if (!target || !get_annot_flags_func || !set_annot_flags_func) continue;
|
||||
ScopedPdfAnnot target = get_annot_checked(page, j);
|
||||
if (!target.get() || !get_annot_flags_func || !set_annot_flags_func) continue;
|
||||
|
||||
int flags = get_annot_flags_func(target);
|
||||
int flags = get_annot_flags_func(target.get());
|
||||
|
||||
// We check for: Invisible (1), Hidden (2), or NoView (32)
|
||||
if (flags & (1 | 2 | 32)) {
|
||||
LOGD("PdfInteraction: Unhiding element at index %d (Flags were 0x%X)", j, flags);
|
||||
|
||||
// Clear bits 1, 2, and 6 (1 + 2 + 32 = 35)
|
||||
set_annot_flags_func(target, flags & ~35);
|
||||
set_annot_flags_func(target.get(), flags & ~35);
|
||||
anyChanged = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 FONTS_SCREEN_ROUTE = "fonts_screen_route"
|
||||
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
|
||||
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
|
||||
}
|
||||
|
||||
private fun NavHostController.isReadyForBackStackChange(): Boolean {
|
||||
|
|
@ -146,7 +147,7 @@ fun AppNavigation(
|
|||
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
||||
if (!uiState.isLoading) {
|
||||
when (uiState.selectedFileType) {
|
||||
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
|
||||
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.PPTX -> {
|
||||
if (uiState.selectedPdfUri != null) {
|
||||
if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) {
|
||||
navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE)
|
||||
|
|
@ -165,6 +166,11 @@ fun AppNavigation(
|
|||
navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
|
||||
}
|
||||
}
|
||||
FileType.UNKNOWN -> {
|
||||
if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) {
|
||||
navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -225,6 +231,8 @@ fun AppNavigation(
|
|||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
||||
}
|
||||
} else if (uiState.isLoading) {
|
||||
Timber.d("PDF URI is null but loading is in progress. Showing loading indicator.")
|
||||
|
|
@ -301,6 +309,8 @@ fun AppNavigation(
|
|||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
||||
}
|
||||
}
|
||||
isLoading -> {
|
||||
|
|
@ -361,5 +371,13 @@ fun AppNavigation(
|
|||
onBackClick = { navController.popBackStackIfReady() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = AppDestinations.SETTINGS_SCREEN_ROUTE) {
|
||||
SettingsScreen(
|
||||
viewModel = viewModel,
|
||||
navController = navController,
|
||||
onBackClick = { navController.popBackStackIfReady() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ import com.aryan.reader.epub.EpubBook
|
|||
import com.aryan.reader.paginatedreader.Locator
|
||||
import java.util.Date
|
||||
|
||||
data class BannerMessage(val message: String, val isError: Boolean = false, val isPersistent: Boolean = false)
|
||||
typealias BannerMessage = com.aryan.reader.shared.BannerMessage
|
||||
typealias UserData = com.aryan.reader.shared.UserData
|
||||
typealias AppThemeMode = com.aryan.reader.shared.AppThemeMode
|
||||
typealias AppContrastOption = com.aryan.reader.shared.AppContrastOption
|
||||
typealias CustomAppTheme = com.aryan.reader.shared.CustomAppTheme
|
||||
|
||||
data class ImportResult(
|
||||
val internalUri: Uri,
|
||||
|
|
@ -17,37 +21,12 @@ data class ImportResult(
|
|||
val bundleResult: CalibreBundleResult? = null
|
||||
)
|
||||
|
||||
data class UserData(
|
||||
val uid: String,
|
||||
val displayName: String?,
|
||||
val photoUrl: String?,
|
||||
val email: String?
|
||||
)
|
||||
|
||||
data class NavigationEvent(
|
||||
val route: String,
|
||||
val bookId: String? = null,
|
||||
val uri: Uri? = null
|
||||
)
|
||||
|
||||
enum class AppThemeMode {
|
||||
SYSTEM,
|
||||
LIGHT,
|
||||
DARK
|
||||
}
|
||||
|
||||
enum class AppContrastOption(val value: Double) {
|
||||
STANDARD(0.0),
|
||||
MEDIUM(0.5),
|
||||
HIGH(1.0)
|
||||
}
|
||||
|
||||
data class CustomAppTheme(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val seedColor: androidx.compose.ui.graphics.Color
|
||||
)
|
||||
|
||||
data class DeviceItem(val deviceId: String, val deviceName: String, val lastSeen: Date?)
|
||||
|
||||
data class DeviceLimitReachedState(
|
||||
|
|
@ -109,7 +88,7 @@ data class ReaderScreenState(
|
|||
val pinnedLibraryBookIds: Set<String> = emptySet(),
|
||||
val libraryFilters: LibraryFilters = LibraryFilters(),
|
||||
val recentFilesLimit: Int = 0,
|
||||
val isTabsEnabled: Boolean = false,
|
||||
val isTabsEnabled: Boolean = true,
|
||||
val openTabIds: List<String> = emptyList(),
|
||||
val openTabs: List<RecentFileItem> = emptyList(),
|
||||
val activeTabBookId: String? = null,
|
||||
|
|
|
|||
|
|
@ -109,8 +109,9 @@ class BookImporter(private val context: Context) {
|
|||
when (context.contentResolver.getType(uri)) {
|
||||
"application/pdf" -> "pdf"
|
||||
"application/epub+zip" -> "epub"
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation" -> "pptx"
|
||||
else -> "tmp"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
|
|
@ -209,6 +210,7 @@ import javax.crypto.spec.GCMParameterSpec
|
|||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
|
||||
const val summarizeEndpoint = "/summarize"
|
||||
|
|
@ -2487,11 +2489,75 @@ fun importReaderTexture(context: Context, uri: Uri): String? {
|
|||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_CANVAS_SAFE_BITMAP_BYTES = 64L * 1024L * 1024L
|
||||
private const val DEFAULT_CANVAS_SAFE_BITMAP_DIMENSION = 4096
|
||||
private const val MAX_READER_TEXTURE_DIMENSION_PX = 1024
|
||||
|
||||
fun Bitmap.safeAllocationByteCount(): Long {
|
||||
return try {
|
||||
allocationByteCount.toLong()
|
||||
} catch (_: Exception) {
|
||||
width.toLong() * height.toLong() * 4L
|
||||
}
|
||||
}
|
||||
|
||||
fun Bitmap.isCanvasSafeBitmap(
|
||||
maxBytes: Long = DEFAULT_CANVAS_SAFE_BITMAP_BYTES,
|
||||
maxDimension: Int = DEFAULT_CANVAS_SAFE_BITMAP_DIMENSION
|
||||
): Boolean {
|
||||
return !isRecycled &&
|
||||
width > 0 &&
|
||||
height > 0 &&
|
||||
width <= maxDimension &&
|
||||
height <= maxDimension &&
|
||||
safeAllocationByteCount() <= maxBytes
|
||||
}
|
||||
|
||||
fun Bitmap.scaledToCanvasLimit(
|
||||
maxBytes: Long = DEFAULT_CANVAS_SAFE_BITMAP_BYTES,
|
||||
maxDimension: Int = DEFAULT_CANVAS_SAFE_BITMAP_DIMENSION
|
||||
): Bitmap {
|
||||
if (isCanvasSafeBitmap(maxBytes, maxDimension)) return this
|
||||
|
||||
val byteScale = sqrt(maxBytes.toDouble() / safeAllocationByteCount().coerceAtLeast(1L).toDouble())
|
||||
val dimensionScale = maxDimension.toDouble() / max(width, height).coerceAtLeast(1).toDouble()
|
||||
val scale = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01)
|
||||
val targetWidth = (width * scale).roundToInt().coerceAtLeast(1)
|
||||
val targetHeight = (height * scale).roundToInt().coerceAtLeast(1)
|
||||
return Bitmap.createScaledBitmap(this, targetWidth, targetHeight, true)
|
||||
}
|
||||
|
||||
private fun decodeSampledBitmapFile(path: String, maxDimension: Int): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = calculateBitmapSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
|
||||
}
|
||||
return BitmapFactory.decodeFile(path, options)
|
||||
}
|
||||
|
||||
private fun calculateBitmapSampleSize(width: Int, height: Int, maxDimension: Int): Int {
|
||||
var sampleSize = 1
|
||||
var sampledWidth = width
|
||||
var sampledHeight = height
|
||||
while (sampledWidth / 2 >= maxDimension || sampledHeight / 2 >= maxDimension) {
|
||||
sampleSize *= 2
|
||||
sampledWidth /= 2
|
||||
sampledHeight /= 2
|
||||
}
|
||||
return sampleSize.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap? {
|
||||
if (textureId == null) return null
|
||||
return try {
|
||||
val bitmap = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) {
|
||||
BitmapFactory.decodeFile(textureId.removePrefix(TEXTURE_FILE_PREFIX))
|
||||
decodeSampledBitmapFile(
|
||||
path = textureId.removePrefix(TEXTURE_FILE_PREFIX),
|
||||
maxDimension = MAX_READER_TEXTURE_DIMENSION_PX
|
||||
)
|
||||
} else {
|
||||
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
|
||||
when {
|
||||
|
|
@ -2500,7 +2566,14 @@ fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap?
|
|||
else -> null
|
||||
}
|
||||
}
|
||||
bitmap?.asImageBitmap()
|
||||
val safeBitmap = bitmap?.scaledToCanvasLimit(
|
||||
maxBytes = DEFAULT_CANVAS_SAFE_BITMAP_BYTES,
|
||||
maxDimension = MAX_READER_TEXTURE_DIMENSION_PX
|
||||
)
|
||||
if (bitmap != null && safeBitmap !== bitmap && !bitmap.isRecycled) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
safeBitmap?.asImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to load reader texture bitmap: $textureId")
|
||||
null
|
||||
|
|
@ -2513,8 +2586,24 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? {
|
|||
var mimeType = "image/png"
|
||||
val bytes = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) {
|
||||
val file = File(textureId.removePrefix(TEXTURE_FILE_PREFIX))
|
||||
mimeType = imageMimeTypeForExtension(file.extension)
|
||||
file.readBytes()
|
||||
mimeType = "image/png"
|
||||
val decodedBitmap = decodeSampledBitmapFile(file.absolutePath, MAX_READER_TEXTURE_DIMENSION_PX)
|
||||
?: return null
|
||||
val bitmap = decodedBitmap.scaledToCanvasLimit(
|
||||
maxBytes = DEFAULT_CANVAS_SAFE_BITMAP_BYTES,
|
||||
maxDimension = MAX_READER_TEXTURE_DIMENSION_PX
|
||||
)
|
||||
try {
|
||||
ByteArrayOutputStream().use { out ->
|
||||
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 90, out)
|
||||
out.toByteArray()
|
||||
}
|
||||
} finally {
|
||||
if (bitmap !== decodedBitmap && !decodedBitmap.isRecycled) {
|
||||
decodedBitmap.recycle()
|
||||
}
|
||||
bitmap.recycle()
|
||||
}
|
||||
} else {
|
||||
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
|
||||
when {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
private val codeOrDataExtensions = setOf(
|
||||
"csv",
|
||||
"tsv",
|
||||
"json",
|
||||
"xml",
|
||||
"log",
|
||||
"java",
|
||||
"kt",
|
||||
"py",
|
||||
"js",
|
||||
"cpp",
|
||||
"c",
|
||||
"cs",
|
||||
"rb",
|
||||
"go"
|
||||
)
|
||||
|
||||
private val manualOnlyReaderMimeTypes = setOf(
|
||||
"text/csv",
|
||||
"text/comma-separated-values",
|
||||
"text/tab-separated-values",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"text/xml",
|
||||
"text/x-java-source",
|
||||
"text/x-python",
|
||||
"text/x-kotlin",
|
||||
"text/javascript",
|
||||
"application/javascript",
|
||||
"text/x-c",
|
||||
"text/x-c++",
|
||||
"text/x-csharp",
|
||||
"text/x-ruby",
|
||||
"text/x-go",
|
||||
"text/x-log"
|
||||
)
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
|
||||
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
|
||||
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val effectiveName = lowerName.withTransparentTextSuffix()
|
||||
return SharedFileCapabilities.resolveFileTypeForName(fileName)
|
||||
}
|
||||
|
||||
return when {
|
||||
effectiveName.endsWith(".cbz") -> FileType.CBZ
|
||||
effectiveName.endsWith(".cbr") -> FileType.CBR
|
||||
effectiveName.endsWith(".cb7") -> FileType.CB7
|
||||
effectiveName.endsWith(".pdf") -> FileType.PDF
|
||||
effectiveName.endsWith(".epub") -> FileType.EPUB
|
||||
effectiveName.endsWith(".mobi") || effectiveName.endsWith(".azw3") || effectiveName.endsWith(".prc") -> FileType.MOBI
|
||||
effectiveName.endsWith(".fb2") || effectiveName.endsWith(".fb2.zip") -> FileType.FB2
|
||||
effectiveName.endsWith(".md") || effectiveName.endsWith(".markdown") -> FileType.MD
|
||||
effectiveName.endsWith(".html") || effectiveName.endsWith(".xhtml") || effectiveName.endsWith(".htm") -> FileType.HTML
|
||||
effectiveName.endsWith(".docx") -> FileType.DOCX
|
||||
effectiveName.endsWith(".odt") -> FileType.ODT
|
||||
effectiveName.endsWith(".fodt") -> FileType.FODT
|
||||
effectiveName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML
|
||||
effectiveName.endsWith(".txt") -> FileType.TXT
|
||||
else -> null
|
||||
internal fun resolveFileTypeFromMetadata(fileName: String?, mimeType: String?): FileType? {
|
||||
val normalizedMimeType = mimeType
|
||||
?.substringBefore(';')
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
return when (normalizedMimeType) {
|
||||
"application/vnd.oasis.opendocument.text" -> FileType.ODT
|
||||
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation" -> FileType.PPTX
|
||||
"application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
|
||||
when {
|
||||
fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ
|
||||
fileName?.endsWith(".fb2.zip", ignoreCase = true) == true -> FileType.FB2
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
"application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed" -> {
|
||||
if (fileName?.endsWith(".cbr", ignoreCase = true) == true) FileType.CBR else null
|
||||
}
|
||||
"application/x-cb7", "application/x-7z-compressed" -> {
|
||||
if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null
|
||||
}
|
||||
"application/pdf" -> FileType.PDF
|
||||
"application/epub+zip" -> FileType.EPUB
|
||||
"application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2
|
||||
"application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
|
||||
"text/markdown", "text/x-markdown" -> FileType.MD
|
||||
"text/html", "application/xhtml+xml" -> FileType.HTML
|
||||
"text/csv", "text/comma-separated-values", "text/tab-separated-values",
|
||||
"application/json", "application/xml", "text/xml",
|
||||
"text/x-java-source", "text/x-python", "text/x-kotlin",
|
||||
"text/javascript", "application/javascript",
|
||||
"text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML
|
||||
"text/plain" -> resolveFileTypeFromName(fileName) ?: FileType.TXT
|
||||
else -> resolveFileTypeFromName(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isCodeOrDataFileName(fileName: String): Boolean {
|
||||
return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
|
||||
return SharedFileCapabilities.isCodeOrDataFileName(fileName)
|
||||
}
|
||||
|
||||
internal fun isManualOnlyReaderFileName(fileName: String?): Boolean {
|
||||
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return false
|
||||
return lowerName.withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
|
||||
return SharedFileCapabilities.isManualOnlyReaderFileName(fileName)
|
||||
}
|
||||
|
||||
internal fun isManualOnlyReaderMimeType(mimeType: String?): Boolean {
|
||||
val normalized = mimeType?.lowercase() ?: return false
|
||||
return normalized in manualOnlyReaderMimeTypes
|
||||
return SharedFileCapabilities.isManualOnlyReaderMimeType(mimeType)
|
||||
}
|
||||
|
||||
internal fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean {
|
||||
if (isManualOnlyReaderFileName(name)) return false
|
||||
if (resolveFileTypeFromName(name) != null) return true
|
||||
return !isManualOnlyReaderMimeType(mimeType)
|
||||
return SharedFileCapabilities.isLocalFolderSyncEligibleFile(name, mimeType)
|
||||
}
|
||||
|
||||
internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? {
|
||||
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val effectiveName = lowerName.withTransparentTextSuffix()
|
||||
val effectiveSuffix = when {
|
||||
effectiveName.endsWith(".fb2.zip") -> ".fb2.zip"
|
||||
effectiveName.endsWith(".markdown") -> ".markdown"
|
||||
effectiveName.endsWith(".xhtml") -> ".xhtml"
|
||||
effectiveName.extensionAfterLastDot() != null && resolveFileTypeFromName(effectiveName) != null -> ".${effectiveName.extensionAfterLastDot()}"
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
return if (effectiveName != lowerName && lowerName.endsWith(".txt")) {
|
||||
"$effectiveSuffix.txt"
|
||||
} else {
|
||||
effectiveSuffix
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.withTransparentTextSuffix(): String {
|
||||
if (!endsWith(".txt")) return this
|
||||
val innerName = removeSuffix(".txt")
|
||||
if (innerName.isBlank() || !innerName.contains('.')) return this
|
||||
return if (resolveFileTypeFromNameWithoutTransparentText(innerName) != null) innerName else this
|
||||
}
|
||||
|
||||
private fun resolveFileTypeFromNameWithoutTransparentText(fileName: String): FileType? {
|
||||
return when {
|
||||
fileName.endsWith(".cbz") -> FileType.CBZ
|
||||
fileName.endsWith(".cbr") -> FileType.CBR
|
||||
fileName.endsWith(".cb7") -> FileType.CB7
|
||||
fileName.endsWith(".pdf") -> FileType.PDF
|
||||
fileName.endsWith(".epub") -> FileType.EPUB
|
||||
fileName.endsWith(".mobi") || fileName.endsWith(".azw3") || fileName.endsWith(".prc") -> FileType.MOBI
|
||||
fileName.endsWith(".fb2") || fileName.endsWith(".fb2.zip") -> FileType.FB2
|
||||
fileName.endsWith(".md") || fileName.endsWith(".markdown") -> FileType.MD
|
||||
fileName.endsWith(".html") || fileName.endsWith(".xhtml") || fileName.endsWith(".htm") -> FileType.HTML
|
||||
fileName.endsWith(".docx") -> FileType.DOCX
|
||||
fileName.endsWith(".odt") -> FileType.ODT
|
||||
fileName.endsWith(".fodt") -> FileType.FODT
|
||||
fileName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.extensionAfterLastDot(): String? {
|
||||
val dotIndex = lastIndexOf('.')
|
||||
return if (dotIndex in 0..<lastIndex) substring(dotIndex + 1) else null
|
||||
return SharedFileCapabilities.fileExtensionSuffixForName(fileName)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,14 +79,14 @@ class FolderSyncWorker(
|
|||
try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
|
||||
}
|
||||
} 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) }
|
||||
} else {
|
||||
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()) {
|
||||
|
|
@ -380,7 +380,7 @@ class FolderSyncWorker(
|
|||
timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
||||
lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
||||
coverImagePath = null,
|
||||
title = remoteMeta?.title ?: name,
|
||||
title = name.substringBeforeLast('.', name),
|
||||
author = remoteMeta?.author,
|
||||
isAvailable = true,
|
||||
isDeleted = false,
|
||||
|
|
@ -395,7 +395,8 @@ class FolderSyncWorker(
|
|||
customName = remoteMeta?.customName,
|
||||
locatorBlockIndex = remoteMeta?.locatorBlockIndex,
|
||||
locatorCharOffset = remoteMeta?.locatorCharOffset,
|
||||
fileSize = size
|
||||
fileSize = size,
|
||||
fileContentModifiedTimestamp = lastModified
|
||||
)
|
||||
newOrUpdatedItems.add(newItem)
|
||||
newBooks++
|
||||
|
|
@ -403,13 +404,28 @@ class FolderSyncWorker(
|
|||
var needsUpdate = false
|
||||
var updatedItem = existingItem
|
||||
|
||||
if (existingItem.fileSize > 0L && size > 0L && existingItem.fileSize != size) {
|
||||
Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).")
|
||||
val modifiedChanged = lastModified > 0L &&
|
||||
existingItem.fileContentModifiedTimestamp != lastModified
|
||||
if ((size > 0L && existingItem.fileSize != size) || modifiedChanged) {
|
||||
Timber.tag("FolderSync").i("File content changed for $name; refreshing extracted metadata.")
|
||||
recentFilesRepository.clearLocalCachesForBook(stableId)
|
||||
updatedItem = updatedItem.copy(
|
||||
fileSize = size,
|
||||
fileContentModifiedTimestamp = lastModified,
|
||||
lastModifiedTimestamp = lastModified,
|
||||
folderTextMetadataParsed = false
|
||||
coverImagePath = null,
|
||||
title = name.substringBeforeLast('.', name),
|
||||
author = null,
|
||||
seriesName = null,
|
||||
seriesIndex = null,
|
||||
description = null,
|
||||
originalTitle = null,
|
||||
originalAuthor = null,
|
||||
originalSeriesName = null,
|
||||
originalSeriesIndex = null,
|
||||
originalDescription = null,
|
||||
folderTextMetadataParsed = false,
|
||||
folderCoverMetadataParsed = false
|
||||
)
|
||||
needsUpdate = true
|
||||
}
|
||||
|
|
@ -500,7 +516,7 @@ class FolderSyncWorker(
|
|||
|
||||
if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
|
||||
if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
|
||||
ReaderPerfLog.i("FolderSync enqueue text metadata extraction folder=$folderUriString")
|
||||
ReaderPerfLog.i("FolderSync enqueue metadata extraction folder=$folderUriString")
|
||||
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>()
|
||||
.setInputData(
|
||||
androidx.work.Data.Builder()
|
||||
|
|
@ -514,7 +530,7 @@ class FolderSyncWorker(
|
|||
metaRequest
|
||||
)
|
||||
} else {
|
||||
ReaderPerfLog.d("FolderSync text metadata extraction skipped: no pending books folder=$folderUriString")
|
||||
ReaderPerfLog.d("FolderSync metadata extraction skipped: no pending books folder=$folderUriString")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -602,15 +618,7 @@ class FolderSyncWorker(
|
|||
}
|
||||
|
||||
private fun getFileType(name: String, mimeType: String?): FileType? {
|
||||
return when (mimeType) {
|
||||
"application/pdf" -> FileType.PDF
|
||||
"application/epub+zip" -> FileType.EPUB
|
||||
"application/vnd.oasis.opendocument.text" -> FileType.ODT
|
||||
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
|
||||
"text/html", "application/xhtml+xml" -> FileType.HTML
|
||||
else -> resolveFileTypeFromName(name)
|
||||
}
|
||||
return resolveFileTypeFromMetadata(name, mimeType)
|
||||
}
|
||||
|
||||
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ import androidx.compose.material.icons.filled.Info
|
|||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.VerifiedUser
|
||||
import androidx.compose.material.icons.outlined.AccountCircle
|
||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
|
|
@ -140,10 +141,8 @@ import androidx.navigation.NavHostController
|
|||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
|
|
@ -171,15 +170,16 @@ fun HomeScreen(
|
|||
|
||||
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val screenModel = remember(uiState) { uiState.toHomeScreenModel() }
|
||||
val recentFilesForHome = screenModel.recentFiles
|
||||
val openTabs = screenModel.openTabs
|
||||
val selectedContextItems = screenModel.selectedItems
|
||||
val isContextualModeActive = screenModel.isContextualModeActive
|
||||
val recentFilesForHome = uiState.recentFiles
|
||||
val openTabs = uiState.openTabs
|
||||
val selectedContextItems = uiState.contextualActionItems
|
||||
val isContextualModeActive = selectedContextItems.isNotEmpty()
|
||||
val isHomeEmpty = recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty())
|
||||
val isLibraryEmpty = uiState.rawLibraryFiles.isEmpty()
|
||||
val scope = rememberCoroutineScope()
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val deviceLimitState = screenModel.deviceLimitState
|
||||
val deviceLimitState = uiState.deviceLimitState
|
||||
|
||||
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
||||
var showClearCloudDataDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -224,15 +224,6 @@ fun HomeScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.bannerMessage) {
|
||||
uiState.bannerMessage?.let { msg ->
|
||||
if (!msg.isPersistent) {
|
||||
delay(3000L)
|
||||
viewModel.bannerMessageShown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.errorMessage) {
|
||||
uiState.errorMessage?.let { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
|
|
@ -319,6 +310,12 @@ fun HomeScreen(
|
|||
navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE)
|
||||
}
|
||||
},
|
||||
onSettingsClick = {
|
||||
scope.launch {
|
||||
drawerState.close()
|
||||
navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE)
|
||||
}
|
||||
},
|
||||
navController = navController,
|
||||
onFolderSyncToggle = viewModel::setFolderSyncEnabled
|
||||
)
|
||||
|
|
@ -353,6 +350,9 @@ fun HomeScreen(
|
|||
}
|
||||
},
|
||||
onAppThemeClick = { showAppThemePanel = true },
|
||||
onSettingsClick = {
|
||||
navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE)
|
||||
},
|
||||
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
|
||||
onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
|
||||
onLanguageClick = { showLanguageDialog = true },
|
||||
|
|
@ -394,8 +394,8 @@ fun HomeScreen(
|
|||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
if (screenModel.isEmpty) {
|
||||
if (screenModel.isLibraryEmpty) {
|
||||
if (isHomeEmpty) {
|
||||
if (isLibraryEmpty) {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.your_library_empty),
|
||||
message = stringResource(R.string.your_library_empty_desc),
|
||||
|
|
@ -502,8 +502,14 @@ fun HomeScreen(
|
|||
showInfoDialog = false
|
||||
itemForInfoDialog = null
|
||||
},
|
||||
onUpdateName = { newName ->
|
||||
viewModel.updateCustomName(item.bookId, newName)
|
||||
onSaveMetadata = { metadata ->
|
||||
viewModel.updateBookMetadata(item.bookId, metadata)
|
||||
},
|
||||
onSaveDisplayName = { name ->
|
||||
viewModel.updateCustomName(item.bookId, name)
|
||||
},
|
||||
onRestoreMetadata = {
|
||||
viewModel.restoreOriginalBookMetadata(item.bookId)
|
||||
},
|
||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||
)
|
||||
|
|
@ -793,16 +799,8 @@ fun RecentFileCard(
|
|||
onLongClick: () -> Unit,
|
||||
isDownloading: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt()
|
||||
val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " "
|
||||
val placeholder = when (item.type) {
|
||||
FileType.PDF -> R.drawable.pdf_placeholder
|
||||
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder
|
||||
}
|
||||
val imageModel = remember(item.coverImagePath) {
|
||||
item.coverImagePath?.let { File(it) } ?: placeholder
|
||||
}
|
||||
|
||||
androidx.compose.material3.ElevatedCard(
|
||||
modifier = modifier
|
||||
|
|
@ -827,24 +825,25 @@ fun RecentFileCard(
|
|||
.fillMaxWidth()
|
||||
.aspectRatio(0.74f)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context).data(imageModel).error(placeholder)
|
||||
.fallback(placeholder).crossfade(true).build(),
|
||||
ThemedBookCover(
|
||||
item = item,
|
||||
contentDescription = item.displayName,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||
0f to Color.Black.copy(alpha = 0.15f),
|
||||
0.3f to Color.Transparent,
|
||||
0.6f to Color.Transparent,
|
||||
1f to Color.Black.copy(alpha = 0.5f)
|
||||
if (!item.coverImagePath.isNullOrBlank()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||
0f to Color.Black.copy(alpha = 0.15f),
|
||||
0.3f to Color.Transparent,
|
||||
0.6f to Color.Transparent,
|
||||
1f to Color.Black.copy(alpha = 0.5f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) {
|
||||
FileStatusBadges(
|
||||
|
|
@ -1024,6 +1023,7 @@ fun DefaultTopAppBar(
|
|||
onExternalFileBehaviorClick: () -> Unit,
|
||||
onStrictFilterToggleClick: () -> Unit,
|
||||
onAppThemeClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onTestPanelDetectionClick: () -> Unit,
|
||||
onTestSpeechBubbleDetectionClick: () -> Unit,
|
||||
onLanguageClick: () -> Unit,
|
||||
|
|
@ -1048,6 +1048,9 @@ fun DefaultTopAppBar(
|
|||
}
|
||||
}
|
||||
}, actions = {
|
||||
IconButton(onClick = onSettingsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = onAppThemeClick) {
|
||||
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.content_desc_app_theme))
|
||||
|
|
@ -1134,19 +1137,21 @@ fun DefaultTopAppBar(
|
|||
showOptionsMenu = false
|
||||
})
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") },
|
||||
onClick = {
|
||||
onToggleHideReaderAi()
|
||||
hideReaderAiFeatures = !hideReaderAiFeatures
|
||||
showOptionsMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (hideReaderAiFeatures) {
|
||||
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
|
||||
if (!BuildConfig.IS_OFFLINE) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") },
|
||||
onClick = {
|
||||
onToggleHideReaderAi()
|
||||
hideReaderAiFeatures = !hideReaderAiFeatures
|
||||
showOptionsMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (hideReaderAiFeatures) {
|
||||
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
|
||||
|
|
@ -1204,6 +1209,7 @@ private fun AppDrawerContent(
|
|||
onSyncUpsellClick: () -> Unit,
|
||||
onFontsClick: () -> Unit,
|
||||
onAiSettingsClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
navController: NavHostController,
|
||||
onFolderSyncToggle: (Boolean) -> Unit
|
||||
) {
|
||||
|
|
@ -1366,6 +1372,14 @@ private fun AppDrawerContent(
|
|||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
}
|
||||
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||
label = { Text("Settings") },
|
||||
selected = false,
|
||||
onClick = onSettingsClick,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
)
|
||||
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(painterResource(id = R.drawable.fonts), contentDescription = null) },
|
||||
label = { Text(stringResource(R.string.drawer_custom_fonts)) },
|
||||
|
|
|
|||
|
|
@ -1,40 +1,21 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
|
||||
enum class AddBooksSource {
|
||||
UNSHELVED,
|
||||
ALL_BOOKS
|
||||
}
|
||||
typealias AddBooksSource = com.aryan.reader.shared.AddBooksSource
|
||||
typealias FileType = com.aryan.reader.shared.FileType
|
||||
typealias RenderMode = com.aryan.reader.shared.RenderMode
|
||||
typealias SortOrder = com.aryan.reader.shared.SortOrder
|
||||
typealias ReadStatusFilter = com.aryan.reader.shared.ReadStatusFilter
|
||||
typealias LibraryFilters = com.aryan.reader.shared.LibraryFilters
|
||||
typealias SyncedFolder = com.aryan.reader.shared.SyncedFolder
|
||||
|
||||
enum class FileType {
|
||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT
|
||||
}
|
||||
|
||||
internal val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7)
|
||||
|
||||
internal val EPUB_READER_FILE_TYPES = setOf(
|
||||
FileType.EPUB,
|
||||
FileType.MOBI,
|
||||
FileType.MD,
|
||||
FileType.TXT,
|
||||
FileType.HTML,
|
||||
FileType.FB2,
|
||||
FileType.DOCX,
|
||||
FileType.ODT,
|
||||
FileType.FODT
|
||||
)
|
||||
|
||||
enum class RenderMode {
|
||||
VERTICAL_SCROLL, PAGINATED
|
||||
}
|
||||
|
||||
data class SyncedFolder(
|
||||
val uriString: String,
|
||||
val name: String,
|
||||
val lastScanTime: Long,
|
||||
val allowedFileTypes: Set<FileType> = FileType.entries.toSet()
|
||||
)
|
||||
internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
|
||||
internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID)
|
||||
internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES
|
||||
internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES
|
||||
|
||||
enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER }
|
||||
|
||||
|
|
@ -54,33 +35,3 @@ data class Shelf(
|
|||
val directBookCount: Int get() = directBooks.size
|
||||
val childShelfCount: Int get() = childShelfIds.size
|
||||
}
|
||||
|
||||
enum class SortOrder {
|
||||
RECENT,
|
||||
TITLE_ASC,
|
||||
AUTHOR_ASC,
|
||||
PERCENT_ASC,
|
||||
PERCENT_DESC,
|
||||
SIZE_ASC,
|
||||
SIZE_DESC
|
||||
}
|
||||
|
||||
enum class ReadStatusFilter {
|
||||
ALL,
|
||||
UNREAD,
|
||||
IN_PROGRESS,
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
data class LibraryFilters(
|
||||
val fileTypes: Set<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.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
|
|
@ -133,6 +134,7 @@ import androidx.compose.ui.unit.sp
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
|
|
@ -148,7 +150,6 @@ import kotlinx.coroutines.flow.drop
|
|||
import kotlinx.coroutines.launch
|
||||
import org.jsoup.Jsoup
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
|
@ -163,6 +164,7 @@ private fun getBookCountString(count: Int): String {
|
|||
@Composable
|
||||
fun LibraryScreen(
|
||||
viewModel: MainViewModel,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
val compStart = remember { System.currentTimeMillis() }
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -170,14 +172,13 @@ fun LibraryScreen(
|
|||
}
|
||||
val context = LocalContext.current
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val screenModel = remember(uiState) { uiState.toLibraryScreenModel() }
|
||||
val selectedItems = screenModel.selectedItems
|
||||
val isContextualModeActive = screenModel.isContextualModeActive
|
||||
val selectedShelves = screenModel.selectedShelves
|
||||
val isShelfContextualModeActive = screenModel.isShelfContextualModeActive
|
||||
val sortOrder = screenModel.sortOrder
|
||||
val shelves = screenModel.shelves
|
||||
val rawLibraryFiles = screenModel.rawLibraryFiles
|
||||
val selectedItems = uiState.contextualActionItems
|
||||
val isContextualModeActive = selectedItems.isNotEmpty()
|
||||
val selectedShelves = uiState.contextualActionShelfIds
|
||||
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
||||
val sortOrder = uiState.sortOrder
|
||||
val shelves = uiState.shelves
|
||||
val rawLibraryFiles = uiState.rawLibraryFiles
|
||||
val tabTitles = remember {
|
||||
buildList {
|
||||
add(context.getString(R.string.tab_all_books))
|
||||
|
|
@ -193,13 +194,13 @@ fun LibraryScreen(
|
|||
pageCount = { tabTitles.size }
|
||||
)
|
||||
|
||||
val containsFolderItems = screenModel.containsFolderItemsInSelection
|
||||
val containsFolderItems = selectedItems.any { it.sourceFolderUri != null }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var showFilterSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val isSearchActive = screenModel.isSearchActive
|
||||
val searchQuery = screenModel.searchQuery
|
||||
val isSearchActive = uiState.isSearchActive
|
||||
val searchQuery = uiState.searchQuery
|
||||
|
||||
val pickFolderLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree()
|
||||
|
|
@ -341,7 +342,8 @@ fun LibraryScreen(
|
|||
catalogId = catalog?.id
|
||||
)
|
||||
},
|
||||
onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog
|
||||
onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog,
|
||||
onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) }
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -394,8 +396,14 @@ fun LibraryScreen(
|
|||
showInfoDialog = false
|
||||
itemForInfoDialog = null
|
||||
},
|
||||
onUpdateName = { newName ->
|
||||
viewModel.updateCustomName(item.bookId, newName)
|
||||
onSaveMetadata = { metadata ->
|
||||
viewModel.updateBookMetadata(item.bookId, metadata)
|
||||
},
|
||||
onSaveDisplayName = { name ->
|
||||
viewModel.updateCustomName(item.bookId, name)
|
||||
},
|
||||
onRestoreMetadata = {
|
||||
viewModel.restoreOriginalBookMetadata(item.bookId)
|
||||
},
|
||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||
)
|
||||
|
|
@ -516,7 +524,9 @@ fun ShelfScreen(
|
|||
FileInfoDialog(
|
||||
item = item,
|
||||
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
|
||||
onUpdateName = { newName -> viewModel.updateCustomName(item.bookId, newName) },
|
||||
onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) },
|
||||
onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
|
||||
onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) },
|
||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||
)
|
||||
}
|
||||
|
|
@ -577,6 +587,7 @@ fun LibraryScreenContent(
|
|||
onOpdsBookDownloaded: (Uri, String) -> Unit,
|
||||
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
||||
onDeleteCatalogStreams: (String) -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
) {
|
||||
val isBookContextualModeActive = selectedItems.isNotEmpty()
|
||||
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
||||
|
|
@ -711,6 +722,9 @@ fun LibraryScreenContent(
|
|||
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onSettingsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
}
|
||||
}
|
||||
)
|
||||
TabRow(selectedTabIndex = pagerState.currentPage) {
|
||||
|
|
@ -1442,8 +1456,6 @@ private fun AddBooksModeScreen(
|
|||
|
||||
@Composable
|
||||
private fun ShelfCover(shelf: Shelf) {
|
||||
val context = LocalContext.current
|
||||
val placeholder = R.drawable.epub_placeholder
|
||||
val booksForCovers = shelf.books.take(4).reversed()
|
||||
val coverWidth = 52.dp
|
||||
val coverHeight = 75.dp
|
||||
|
|
@ -1457,22 +1469,24 @@ private fun ShelfCover(shelf: Shelf) {
|
|||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
if (booksForCovers.size <= 1) {
|
||||
val imageModel = remember(shelf.topBook?.coverImagePath) {
|
||||
shelf.topBook?.coverImagePath?.let { File(it) } ?: placeholder
|
||||
val topBook = shelf.topBook
|
||||
if (topBook != null) {
|
||||
ThemedBookCover(
|
||||
item = topBook,
|
||||
contentDescription = stringResource(R.string.content_desc_shelf_cover, shelf.name),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(width = coverWidth, height = coverHeight)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
)
|
||||
} else {
|
||||
EmptyShelfCover(
|
||||
shelfName = shelf.name,
|
||||
modifier = Modifier
|
||||
.size(width = coverWidth, height = coverHeight)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
)
|
||||
}
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(imageModel)
|
||||
.error(placeholder)
|
||||
.fallback(placeholder)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = stringResource(R.string.content_desc_shelf_cover, shelf.name),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(width = coverWidth, height = coverHeight)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -1480,9 +1494,6 @@ private fun ShelfCover(shelf: Shelf) {
|
|||
.height(coverHeight)
|
||||
) {
|
||||
booksForCovers.forEachIndexed { index, book ->
|
||||
val imageModel = remember(book.coverImagePath) {
|
||||
book.coverImagePath?.let { File(it) } ?: placeholder
|
||||
}
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
shadowElevation = 4.dp,
|
||||
|
|
@ -1491,13 +1502,8 @@ private fun ShelfCover(shelf: Shelf) {
|
|||
.align(Alignment.CenterEnd)
|
||||
.offset(x = -horizontalOffset * index)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(imageModel)
|
||||
.error(placeholder)
|
||||
.fallback(placeholder)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
ThemedBookCover(
|
||||
item = book,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
|
@ -1508,6 +1514,36 @@ private fun ShelfCover(shelf: Shelf) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyShelfCover(
|
||||
shelfName: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(
|
||||
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.secondaryContainer,
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
)
|
||||
)
|
||||
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = shelfName.takeIf { it.isNotBlank() } ?: stringResource(R.string.tab_shelves),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ShelfListItem(
|
||||
|
|
@ -1596,15 +1632,6 @@ private fun LibraryListItem(
|
|||
onItemLongClick: () -> Unit,
|
||||
isDownloading: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val placeholder = when (item.type) {
|
||||
FileType.PDF -> R.drawable.pdf_placeholder
|
||||
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder
|
||||
}
|
||||
val imageModel = remember(item.coverImagePath) {
|
||||
item.coverImagePath?.let { File(it) } ?: placeholder
|
||||
}
|
||||
|
||||
androidx.compose.material3.ElevatedCard(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
||||
|
|
@ -1641,13 +1668,8 @@ private fun LibraryListItem(
|
|||
.clip(MaterialTheme.shapes.medium)
|
||||
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(imageModel)
|
||||
.error(placeholder)
|
||||
.fallback(placeholder)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
ThemedBookCover(
|
||||
item = item,
|
||||
contentDescription = item.displayName,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
|
|
@ -2148,7 +2170,7 @@ private fun EditFolderFiltersDialog(
|
|||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FileType.entries.forEach { type ->
|
||||
ANDROID_SYNCABLE_FILE_TYPES.forEach { type ->
|
||||
val isSelected = type in selectedTypes
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
|
|
@ -2226,7 +2248,7 @@ fun LibraryFilterSheet(
|
|||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FileType.entries.forEach { type ->
|
||||
ANDROID_READABLE_FILE_TYPES.forEach { type ->
|
||||
FilterChip(
|
||||
selected = type in currentFilters.fileTypes,
|
||||
onClick = {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import com.aryan.reader.data.BookTagCrossRef
|
|||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.ShelfEntity
|
||||
import com.aryan.reader.data.TagEntity
|
||||
import com.aryan.reader.shared.SmartCollectionEngine
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.applyLibraryFilters as sharedApplyLibraryFilters
|
||||
import com.aryan.reader.shared.filterBySearch as sharedFilterBySearch
|
||||
import com.aryan.reader.shared.sortBooks as sharedSortBooks
|
||||
|
||||
fun interface FolderPathResolver {
|
||||
fun relativeFolderSegments(item: RecentFileItem): List<String>
|
||||
|
|
@ -32,17 +35,28 @@ class LibraryStateProjector(
|
|||
fun project(input: LibraryProjectionInput): ReaderScreenState {
|
||||
val start = ReaderPerfLog.nowNanos()
|
||||
val internalState = input.state
|
||||
val bridgeContext = AndroidSharedStateBridge.prepareLibraryProjection(input, folderPathResolver)
|
||||
val cacheKey = ProjectionCacheKey(
|
||||
recentFilesFromDb = input.recentFilesFromDb,
|
||||
dbShelves = input.dbShelves,
|
||||
shelfRefs = input.shelfRefs,
|
||||
dbTags = input.dbTags,
|
||||
tagRefs = input.tagRefs,
|
||||
folderKeys = internalState.syncedFolders.map { SyncedFolderProjectionKey(it.uriString, it.name) },
|
||||
folderKeys = bridgeContext.folderKeys,
|
||||
sortOrder = internalState.sortOrder,
|
||||
searchQuery = internalState.searchQuery,
|
||||
libraryFilters = internalState.libraryFilters,
|
||||
recentFilesLimit = internalState.recentFilesLimit
|
||||
recentFilesLimit = internalState.recentFilesLimit,
|
||||
openTabIds = internalState.openTabIds,
|
||||
activeTabBookId = internalState.activeTabBookId,
|
||||
selectedBookIds = internalState.contextualActionItems.mapTo(mutableSetOf()) { it.bookId },
|
||||
selectedShelfIds = internalState.contextualActionShelfIds,
|
||||
viewingShelfId = internalState.viewingShelfId,
|
||||
isAddingBooksToShelf = internalState.isAddingBooksToShelf,
|
||||
addBooksSource = internalState.addBooksSource,
|
||||
booksSelectedForAdding = internalState.booksSelectedForAdding,
|
||||
pinnedHomeBookIds = internalState.pinnedHomeBookIds,
|
||||
pinnedLibraryBookIds = internalState.pinnedLibraryBookIds
|
||||
)
|
||||
|
||||
cachedProjection?.takeIf { it.key == cacheKey }?.let { cache ->
|
||||
|
|
@ -50,71 +64,26 @@ class LibraryStateProjector(
|
|||
val elapsed = ReaderPerfLog.elapsedMs(start)
|
||||
if (elapsed >= 8L) {
|
||||
ReaderPerfLog.d(
|
||||
"LibraryProject cache-hit took ${elapsed}ms books=${cache.allLibraryFiles.size} shelves=${cache.shelfProjection.shelves.size}"
|
||||
"LibraryProject cache-hit took ${elapsed}ms books=${cache.androidBooksById.size} shelves=${cache.projected.shelves.size}"
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
val tagsById = input.dbTags.associateBy { it.id }
|
||||
val bookTagsMap = input.tagRefs.groupBy { it.bookId }.mapValues { entry ->
|
||||
entry.value.mapNotNull { tagsById[it.tagId] }
|
||||
}
|
||||
|
||||
val allLibraryFiles = input.recentFilesFromDb
|
||||
.filterNot { it.bookId.endsWith("_reflow") }
|
||||
.map { item ->
|
||||
item.copy(tags = bookTagsMap[item.bookId] ?: emptyList())
|
||||
}
|
||||
val allLibraryFilesById = allLibraryFiles.associateBy { it.bookId }
|
||||
|
||||
val rawFilteredByQuery = filterBySearch(allLibraryFiles, internalState.searchQuery)
|
||||
val libraryFiltered = applyLibraryFilters(rawFilteredByQuery, internalState.libraryFilters)
|
||||
val sortedLibraryFiles = if (internalState.sortOrder == SortOrder.RECENT) {
|
||||
libraryFiltered
|
||||
} else {
|
||||
sortFiles(libraryFiltered, internalState.sortOrder)
|
||||
}
|
||||
val recentLimit = if (internalState.recentFilesLimit > 0) internalState.recentFilesLimit else Int.MAX_VALUE
|
||||
val visibleRecentFiles = if (internalState.sortOrder == SortOrder.RECENT) {
|
||||
allLibraryFiles
|
||||
.asSequence()
|
||||
.filter { it.isRecent }
|
||||
.take(recentLimit)
|
||||
.toList()
|
||||
} else {
|
||||
sortFiles(
|
||||
allLibraryFiles.filter { it.isRecent },
|
||||
internalState.sortOrder
|
||||
).take(recentLimit)
|
||||
}
|
||||
|
||||
val shelfProjection = buildShelves(
|
||||
allLibraryFiles = allLibraryFiles,
|
||||
dbShelves = input.dbShelves,
|
||||
shelfRefs = input.shelfRefs,
|
||||
dbTags = input.dbTags,
|
||||
sortOrder = internalState.sortOrder,
|
||||
syncedFolders = internalState.syncedFolders
|
||||
)
|
||||
|
||||
val projected = AndroidSharedStateBridge.projectLibrary(bridgeContext)
|
||||
val cache = CachedProjection(
|
||||
key = cacheKey,
|
||||
allLibraryFiles = allLibraryFiles,
|
||||
allLibraryFilesById = allLibraryFilesById,
|
||||
sortedLibraryFiles = sortedLibraryFiles,
|
||||
visibleRecentFiles = visibleRecentFiles,
|
||||
shelfProjection = shelfProjection,
|
||||
validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id },
|
||||
dbTags = input.dbTags
|
||||
projected = projected,
|
||||
androidBooksById = bridgeContext.androidBooksById,
|
||||
tagEntitiesById = bridgeContext.tagEntitiesById
|
||||
)
|
||||
cachedProjection = cache
|
||||
|
||||
val elapsed = ReaderPerfLog.elapsedMs(start)
|
||||
if (elapsed >= 16L || allLibraryFiles.size >= 500) {
|
||||
if (elapsed >= 16L || bridgeContext.androidBooksById.size >= 500) {
|
||||
ReaderPerfLog.d(
|
||||
"LibraryProject recompute took ${elapsed}ms books=${allLibraryFiles.size} " +
|
||||
"visible=${sortedLibraryFiles.size} shelves=${shelfProjection.shelves.size} " +
|
||||
"LibraryProject shared recompute took ${elapsed}ms books=${bridgeContext.androidBooksById.size} " +
|
||||
"visible=${projected.libraryBooks.size} shelves=${projected.shelves.size} " +
|
||||
"tags=${input.dbTags.size} shelfRefs=${input.shelfRefs.size} tagRefs=${input.tagRefs.size}"
|
||||
)
|
||||
}
|
||||
|
|
@ -126,310 +95,63 @@ class LibraryStateProjector(
|
|||
internalState: ReaderScreenState,
|
||||
cache: CachedProjection
|
||||
): ReaderScreenState {
|
||||
val viewingShelfId = internalState.viewingShelfId?.takeIf { it in cache.validShelfIds }
|
||||
val selectedShelfIds = internalState.contextualActionShelfIds.filterTo(mutableSetOf()) { it in cache.validShelfIds }
|
||||
val booksAvailableForAdding = if (internalState.isAddingBooksToShelf && viewingShelfId != null) {
|
||||
val currentShelfBookIds = cache.shelfProjection.shelves
|
||||
.find { it.id == viewingShelfId }
|
||||
?.books
|
||||
?.mapTo(mutableSetOf()) { it.bookId }
|
||||
?: emptySet()
|
||||
when (internalState.addBooksSource) {
|
||||
AddBooksSource.UNSHELVED -> cache.shelfProjection.unshelvedBooks
|
||||
AddBooksSource.ALL_BOOKS -> cache.allLibraryFiles.filter { it.bookId !in currentShelfBookIds }
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return internalState.copy(
|
||||
recentFiles = cache.visibleRecentFiles,
|
||||
allRecentFiles = cache.sortedLibraryFiles,
|
||||
rawLibraryFiles = cache.allLibraryFiles,
|
||||
viewingShelfId = viewingShelfId,
|
||||
isAddingBooksToShelf = internalState.isAddingBooksToShelf && viewingShelfId != null,
|
||||
contextualActionShelfIds = selectedShelfIds,
|
||||
contextualActionItems = internalState.contextualActionItems
|
||||
.mapNotNull { ctx -> cache.allLibraryFilesById[ctx.bookId] }
|
||||
.toSet(),
|
||||
shelves = cache.shelfProjection.shelves,
|
||||
openTabs = internalState.openTabIds.mapNotNull { tabId -> cache.allLibraryFilesById[tabId] },
|
||||
booksAvailableForAdding = booksAvailableForAdding,
|
||||
allTags = cache.dbTags
|
||||
return AndroidSharedStateBridge.toAndroidState(
|
||||
base = internalState,
|
||||
sharedState = cache.projected,
|
||||
androidBooksById = cache.androidBooksById,
|
||||
tagEntitiesById = cache.tagEntitiesById
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildShelves(
|
||||
allLibraryFiles: List<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(
|
||||
val recentFilesFromDb: List<RecentFileItem>,
|
||||
val dbShelves: List<ShelfEntity>,
|
||||
val shelfRefs: List<BookShelfCrossRef>,
|
||||
val dbTags: List<TagEntity>,
|
||||
val tagRefs: List<BookTagCrossRef>,
|
||||
val folderKeys: List<SyncedFolderProjectionKey>,
|
||||
val folderKeys: List<AndroidSharedFolderProjectionKey>,
|
||||
val sortOrder: SortOrder,
|
||||
val searchQuery: String,
|
||||
val libraryFilters: LibraryFilters,
|
||||
val recentFilesLimit: Int
|
||||
)
|
||||
|
||||
private data class SyncedFolderProjectionKey(
|
||||
val uriString: String,
|
||||
val name: String
|
||||
val recentFilesLimit: Int,
|
||||
val openTabIds: List<String>,
|
||||
val activeTabBookId: String?,
|
||||
val selectedBookIds: Set<String>,
|
||||
val selectedShelfIds: Set<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(
|
||||
val key: ProjectionCacheKey,
|
||||
val allLibraryFiles: List<RecentFileItem>,
|
||||
val allLibraryFilesById: Map<String, RecentFileItem>,
|
||||
val sortedLibraryFiles: List<RecentFileItem>,
|
||||
val visibleRecentFiles: List<RecentFileItem>,
|
||||
val shelfProjection: ShelfProjection,
|
||||
val validShelfIds: Set<String>,
|
||||
val dbTags: List<TagEntity>
|
||||
val projected: SharedReaderScreenState,
|
||||
val androidBooksById: Map<String, RecentFileItem>,
|
||||
val tagEntitiesById: Map<String, TagEntity>
|
||||
)
|
||||
}
|
||||
|
||||
fun filterBySearch(files: List<RecentFileItem>, searchQuery: String): List<RecentFileItem> {
|
||||
val query = searchQuery.trim()
|
||||
return if (query.isBlank()) {
|
||||
files
|
||||
} else {
|
||||
files.filter { item ->
|
||||
item.displayName.contains(query, ignoreCase = true) ||
|
||||
item.title?.contains(query, ignoreCase = true) == true ||
|
||||
item.author?.contains(query, ignoreCase = true) == true ||
|
||||
item.tags.any { tag -> tag.name.contains(query, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
return files.mapSharedResults(sharedFilterBySearch(files.map { it.toSharedProjectionBookItem() }, searchQuery))
|
||||
}
|
||||
|
||||
fun applyLibraryFilters(files: List<RecentFileItem>, filters: LibraryFilters): List<RecentFileItem> {
|
||||
return files.filter { item ->
|
||||
val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true
|
||||
val matchFolder = if (filters.sourceFolders.isNotEmpty()) {
|
||||
val matchesInApp = filters.sourceFolders.contains("IN_APP_STORAGE") &&
|
||||
item.sourceFolderUri == null &&
|
||||
item.uriString?.startsWith("opds-pse") != true
|
||||
val matchesSynced = item.sourceFolderUri in filters.sourceFolders
|
||||
matchesInApp || matchesSynced
|
||||
} else {
|
||||
true
|
||||
}
|
||||
val progress = item.progressPercentage ?: 0f
|
||||
val matchStatus = when (filters.readStatus) {
|
||||
ReadStatusFilter.ALL -> true
|
||||
ReadStatusFilter.UNREAD -> progress == 0f
|
||||
ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f
|
||||
ReadStatusFilter.COMPLETED -> progress >= 100f
|
||||
}
|
||||
val matchTags = if (filters.tagIds.isNotEmpty()) {
|
||||
item.tags.any { it.id in filters.tagIds }
|
||||
} else {
|
||||
true
|
||||
}
|
||||
matchType && matchFolder && matchStatus && matchTags
|
||||
}
|
||||
return files.mapSharedResults(
|
||||
sharedApplyLibraryFilters(
|
||||
books = files.map { it.toSharedProjectionBookItem() },
|
||||
filters = filters.toSharedLibraryFilters()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFileItem> {
|
||||
return when (sortOrder) {
|
||||
SortOrder.RECENT -> files.sortedByDescending { it.timestamp }
|
||||
SortOrder.TITLE_ASC -> files.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
|
||||
SortOrder.AUTHOR_ASC -> files.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
|
||||
SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f }
|
||||
SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f }
|
||||
SortOrder.SIZE_ASC -> files.sortedBy { it.fileSize }
|
||||
SortOrder.SIZE_DESC -> files.sortedByDescending { it.fileSize }
|
||||
}
|
||||
return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder.toSharedSortOrder()))
|
||||
}
|
||||
|
||||
private fun List<RecentFileItem>.mapSharedResults(sharedBooks: List<com.aryan.reader.shared.BookItem>): List<RecentFileItem> {
|
||||
val byId = associateBy { it.bookId }
|
||||
return sharedBooks.mapNotNull { byId[it.id] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,9 +121,6 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
WebView.setWebContentsDebuggingEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
|
|
|
|||
|
|
@ -110,7 +110,10 @@ fun MainScreen(
|
|||
windowSizeClass = windowSizeClass,
|
||||
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 androidx.core.net.toUri
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.WorkManager
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.RecentFilesRepository
|
||||
import io.legere.pdfiumandroid.PdfiumCore
|
||||
|
|
@ -28,7 +31,17 @@ class MetadataExtractionWorker(
|
|||
const val WORK_NAME = "MetadataExtractionWorker"
|
||||
const val KEY_SOURCE_FOLDER_URI = "key_source_folder_uri"
|
||||
private const val METADATA_DB_BATCH_SIZE = 100
|
||||
private const val METADATA_WORKER_BOOK_BATCH_SIZE = 300
|
||||
private const val METADATA_PROGRESS_LOG_EVERY = 250
|
||||
private val TEXT_METADATA_TYPES = setOf(
|
||||
FileType.PDF,
|
||||
FileType.EPUB,
|
||||
FileType.MOBI,
|
||||
FileType.FB2,
|
||||
FileType.ODT,
|
||||
FileType.FODT,
|
||||
FileType.DOCX
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
|
|
@ -45,20 +58,25 @@ class MetadataExtractionWorker(
|
|||
}
|
||||
|
||||
try {
|
||||
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(sourceFolderUri)
|
||||
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
|
||||
sourceFolderUri = sourceFolderUri,
|
||||
limit = METADATA_WORKER_BOOK_BATCH_SIZE
|
||||
)
|
||||
|
||||
if (filesToProcess.isEmpty()) {
|
||||
ReaderPerfLog.d("MetadataWorker skipped: no text metadata pending folder=${sourceFolderUri ?: "ALL"}")
|
||||
ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
ReaderPerfLog.i(
|
||||
"MetadataWorker start mode=text-only books=${filesToProcess.size} folder=${sourceFolderUri ?: "ALL"}"
|
||||
"MetadataWorker start mode=metadata books=${filesToProcess.size} " +
|
||||
"batchLimit=$METADATA_WORKER_BOOK_BATCH_SIZE folder=${sourceFolderUri ?: "ALL"}"
|
||||
)
|
||||
|
||||
val pendingUpdates = mutableListOf<RecentFileItem>()
|
||||
var processed = 0
|
||||
var updated = 0
|
||||
var coversUpdated = 0
|
||||
var failed = 0
|
||||
|
||||
suspend fun flushUpdates() {
|
||||
|
|
@ -76,36 +94,80 @@ class MetadataExtractionWorker(
|
|||
|
||||
if (item.sourceFolderUri == null) return@forEach
|
||||
|
||||
var needsTextMetadata = item.type in TEXT_METADATA_TYPES && !item.folderTextMetadataParsed
|
||||
var needsEmbeddedCover = false
|
||||
|
||||
try {
|
||||
val uri = item.uriString?.toUri() ?: return@forEach
|
||||
val fileSize = item.fileSize.takeIf { it > 0L } ?: queryFileSize(uri)
|
||||
val existingCoverIsAvailable = item.coverImagePath?.let { File(it).isFile } == true
|
||||
needsEmbeddedCover = EmbeddedEbookMetadataExtractor.canExtractEmbeddedCover(item.type) &&
|
||||
!item.folderCoverMetadataParsed &&
|
||||
!existingCoverIsAvailable
|
||||
|
||||
val metadata = when (item.type) {
|
||||
FileType.EPUB -> parseEpubTextMetadata(uri)
|
||||
FileType.EPUB,
|
||||
FileType.MOBI,
|
||||
FileType.FB2 -> {
|
||||
if (needsTextMetadata || needsEmbeddedCover) {
|
||||
EmbeddedEbookMetadataExtractor.extract(
|
||||
type = item.type,
|
||||
displayName = item.displayName,
|
||||
openStream = { appContext.contentResolver.openInputStream(uri) },
|
||||
extractCover = needsEmbeddedCover
|
||||
).toTextMetadata()
|
||||
} else {
|
||||
TextMetadata()
|
||||
}
|
||||
}
|
||||
FileType.PDF -> parsePdfTextMetadata(uri)
|
||||
FileType.ODT -> parseZipTextMetadata(uri, "meta.xml")
|
||||
FileType.FODT -> parseFlatXmlTextMetadata(uri)
|
||||
FileType.DOCX -> parseZipTextMetadata(uri, "docProps/core.xml")
|
||||
FileType.PPTX -> parseZipTextMetadata(uri, "docProps/core.xml")
|
||||
else -> TextMetadata()
|
||||
}
|
||||
|
||||
val title = sanitizeTitle(metadata.title)
|
||||
val author = sanitizeAuthor(metadata.author)
|
||||
val description = metadata.description?.trim()?.takeIf { it.isNotBlank() }
|
||||
val seriesName = metadata.seriesName?.trim()?.takeIf { it.isNotBlank() }
|
||||
val seriesIndex = metadata.seriesIndex?.takeIf { it > 0.0 }
|
||||
val sizeChanged = fileSize > 0L && fileSize != item.fileSize
|
||||
val titleChanged = title != null && title != item.title
|
||||
val authorChanged = author != null && author != item.author
|
||||
val descriptionChanged = description != null && description != item.description
|
||||
val seriesChanged = seriesName != null && seriesName != item.seriesName
|
||||
val seriesIndexChanged = seriesIndex != null && seriesIndex != item.seriesIndex
|
||||
val coverPath = if (needsEmbeddedCover) {
|
||||
metadata.cover?.let { cover ->
|
||||
recentFilesRepository.saveEmbeddedCoverToCache(cover.bytes, uri, cover.extension)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val coverChanged = coverPath != null && coverPath != item.coverImagePath
|
||||
val coverMetadataParsed = item.folderCoverMetadataParsed || needsEmbeddedCover
|
||||
val textMetadataParsed = item.folderTextMetadataParsed || needsTextMetadata
|
||||
|
||||
if (!item.folderTextMetadataParsed || sizeChanged || titleChanged || authorChanged) {
|
||||
if (needsTextMetadata || needsEmbeddedCover || sizeChanged || titleChanged || authorChanged || descriptionChanged || seriesChanged || seriesIndexChanged || coverChanged) {
|
||||
pendingUpdates.add(
|
||||
item.copy(
|
||||
coverImagePath = coverPath ?: item.coverImagePath,
|
||||
title = title ?: item.title ?: item.displayName,
|
||||
author = author ?: item.author,
|
||||
description = description ?: item.description,
|
||||
seriesName = seriesName ?: item.seriesName,
|
||||
seriesIndex = seriesIndex ?: item.seriesIndex,
|
||||
fileSize = if (fileSize > 0L) fileSize else item.fileSize,
|
||||
folderTextMetadataParsed = true
|
||||
folderTextMetadataParsed = textMetadataParsed,
|
||||
folderCoverMetadataParsed = coverMetadataParsed
|
||||
)
|
||||
)
|
||||
if (sizeChanged || titleChanged || authorChanged) {
|
||||
if (sizeChanged || titleChanged || authorChanged || descriptionChanged || seriesChanged || seriesIndexChanged || coverChanged) {
|
||||
updated++
|
||||
}
|
||||
if (coverChanged) coversUpdated++
|
||||
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
|
||||
flushUpdates()
|
||||
}
|
||||
|
|
@ -114,29 +176,64 @@ class MetadataExtractionWorker(
|
|||
processed++
|
||||
if (processed % METADATA_PROGRESS_LOG_EVERY == 0) {
|
||||
ReaderPerfLog.d(
|
||||
"MetadataWorker progress mode=text-only processed=$processed updated=$updated failed=$failed"
|
||||
"MetadataWorker progress mode=metadata processed=$processed updated=$updated covers=$coversUpdated failed=$failed"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failed++
|
||||
Timber.tag("MetadataWorker").e(e, "Failed text metadata extraction for ${item.displayName}")
|
||||
Timber.tag("MetadataWorker").e(e, "Failed metadata extraction for ${item.displayName}")
|
||||
if (needsTextMetadata || needsEmbeddedCover) {
|
||||
pendingUpdates.add(
|
||||
item.copy(
|
||||
folderTextMetadataParsed = item.folderTextMetadataParsed || needsTextMetadata,
|
||||
folderCoverMetadataParsed = item.folderCoverMetadataParsed || needsEmbeddedCover
|
||||
)
|
||||
)
|
||||
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
|
||||
flushUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushUpdates()
|
||||
|
||||
val nextBatchEnqueued = !isStopped &&
|
||||
filesToProcess.size >= METADATA_WORKER_BOOK_BATCH_SIZE &&
|
||||
recentFilesRepository.hasFolderBooksNeedingTextMetadata(sourceFolderUri)
|
||||
if (nextBatchEnqueued) {
|
||||
enqueueNextBatch(sourceFolderUri)
|
||||
}
|
||||
|
||||
ReaderPerfLog.i(
|
||||
"MetadataWorker finished mode=text-only processed=$processed updated=$updated failed=$failed " +
|
||||
"elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}"
|
||||
"MetadataWorker finished mode=metadata processed=$processed updated=$updated covers=$coversUpdated failed=$failed " +
|
||||
"nextBatch=$nextBatchEnqueued elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}"
|
||||
)
|
||||
|
||||
return@withContext Result.success()
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MetadataWorker").e(e, "Text metadata extraction failed")
|
||||
Timber.tag("MetadataWorker").e(e, "Metadata extraction failed")
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun enqueueNextBatch(sourceFolderUri: String?) {
|
||||
val data = androidx.work.Data.Builder().apply {
|
||||
if (!sourceFolderUri.isNullOrBlank()) {
|
||||
putString(KEY_SOURCE_FOLDER_URI, sourceFolderUri)
|
||||
}
|
||||
}.build()
|
||||
val request = OneTimeWorkRequestBuilder<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 {
|
||||
return try {
|
||||
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 {
|
||||
appContext.contentResolver.openInputStream(uri)?.use { input ->
|
||||
ZipInputStream(input.buffered()).use { zip ->
|
||||
|
|
@ -222,21 +295,6 @@ class MetadataExtractionWorker(
|
|||
}
|
||||
}
|
||||
|
||||
private fun parseEpubRootfilePath(containerXml: String): String? {
|
||||
val parser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
parser.setInput(containerXml.reader())
|
||||
|
||||
var event = parser.eventType
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
if (event == XmlPullParser.START_TAG && parser.name.equals("rootfile", ignoreCase = true)) {
|
||||
return parser.getAttributeValue(null, "full-path")?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseXmlTextMetadata(xml: String): TextMetadata {
|
||||
val parser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
|
|
@ -286,8 +344,23 @@ class MetadataExtractionWorker(
|
|||
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun EmbeddedEbookMetadata.toTextMetadata(): TextMetadata {
|
||||
return TextMetadata(
|
||||
title = title,
|
||||
author = author,
|
||||
description = description,
|
||||
seriesName = seriesName,
|
||||
seriesIndex = seriesIndex,
|
||||
cover = cover
|
||||
)
|
||||
}
|
||||
|
||||
private data class TextMetadata(
|
||||
val title: String? = null,
|
||||
val author: String? = null
|
||||
val author: String? = null,
|
||||
val description: String? = null,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val cover: EmbeddedEbookCover? = null
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 billingClientReady = proUpgradeState.billingClientReady
|
||||
val localPurchaseExistsForOtherAccount = !isProUser && proUpgradeState.hasValidPurchase
|
||||
val localPurchaseExistsForOtherAccount = !isProUser && proUpgradeState.hasAccountConflict
|
||||
|
||||
var originalFormattedPrice by remember { mutableStateOf("$9.99") }
|
||||
|
||||
|
|
@ -467,23 +467,6 @@ private fun ProTierCard(
|
|||
Text(stringResource(R.string.verifying_purchase))
|
||||
}
|
||||
}
|
||||
localPurchaseExistsForOtherAccount -> {
|
||||
OutlinedButton(
|
||||
onClick = onShowExistingPurchaseDialog,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = stringResource(R.string.info),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
AutoSizeText(stringResource(R.string.existing_purchase_found))
|
||||
}
|
||||
}
|
||||
productDetails != null -> {
|
||||
Button(
|
||||
onClick = {
|
||||
|
|
@ -538,6 +521,17 @@ private fun ProTierCard(
|
|||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
localPurchaseExistsForOtherAccount -> {
|
||||
TextButton(onClick = onShowExistingPurchaseDialog) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = stringResource(R.string.info),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
AutoSizeText(stringResource(R.string.existing_purchase_found))
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LegalText(prefixText = stringResource(R.string.legal_by_purchasing))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.ReadStatusFilter as SharedReadStatusFilter
|
||||
import com.aryan.reader.shared.RenderMode as SharedRenderMode
|
||||
import com.aryan.reader.shared.SharedLibraryProjectionInput
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.Shelf as SharedShelf
|
||||
import com.aryan.reader.shared.ShelfRecord
|
||||
import com.aryan.reader.shared.ShelfType as SharedShelfType
|
||||
import com.aryan.reader.shared.SortOrder as SharedSortOrder
|
||||
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
|
||||
import com.aryan.reader.shared.Tag as SharedTag
|
||||
|
|
@ -34,18 +35,88 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem {
|
|||
coverImagePath = coverImagePath,
|
||||
title = title,
|
||||
author = author,
|
||||
description = description,
|
||||
originalTitle = originalTitle,
|
||||
originalAuthor = originalAuthor,
|
||||
originalSeriesName = originalSeriesName,
|
||||
originalSeriesIndex = originalSeriesIndex,
|
||||
originalDescription = originalDescription,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent,
|
||||
fileSize = fileSize,
|
||||
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||
sourceFolder = sourceFolderUri,
|
||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||
seriesName = seriesName,
|
||||
seriesIndex = seriesIndex,
|
||||
lastPageIndex = lastPage,
|
||||
tags = tags.map { it.toSharedTag() },
|
||||
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
|
||||
)
|
||||
}
|
||||
|
||||
fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem {
|
||||
return toSharedBookItem().copy(displayName = displayName)
|
||||
}
|
||||
|
||||
fun SharedBookItem.toRecentFileItem(
|
||||
androidBooksById: Map<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 {
|
||||
return SharedTag(
|
||||
id = id,
|
||||
|
|
@ -153,92 +224,147 @@ fun ReaderScreenState.toSharedReaderScreenState(
|
|||
)
|
||||
}
|
||||
|
||||
fun ReaderScreenState.toSharedLibraryProjectionInput(
|
||||
recentFilesFromDb: List<RecentFileItem>,
|
||||
dbShelves: List<ShelfEntity>,
|
||||
shelfRefs: List<BookShelfCrossRef>,
|
||||
fun List<RecentFileItem>.withResolvedTags(
|
||||
dbTags: List<TagEntity>,
|
||||
tagRefs: List<BookTagCrossRef>
|
||||
): SharedLibraryProjectionInput {
|
||||
): List<RecentFileItem> {
|
||||
val tagsById = dbTags.associateBy { it.id }
|
||||
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
|
||||
entry.value.mapNotNull { tagsById[it.tagId] }
|
||||
}
|
||||
val taggedBooks = recentFilesFromDb.map { item ->
|
||||
item.copy(tags = bookTagsMap[item.bookId].orEmpty())
|
||||
return map { item -> item.copy(tags = bookTagsMap[item.bookId].orEmpty()) }
|
||||
}
|
||||
|
||||
fun SharedReaderScreenState.toAndroidReaderScreenState(
|
||||
base: ReaderScreenState,
|
||||
androidBooksById: Map<String, RecentFileItem>,
|
||||
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
||||
): ReaderScreenState {
|
||||
val fallbackBooksById = rawLibraryBooks.associateBy { it.id }
|
||||
fun SharedBookItem.toAndroidBook(): RecentFileItem {
|
||||
return toRecentFileItem(androidBooksById, tagEntitiesById)
|
||||
}
|
||||
return SharedLibraryProjectionInput(
|
||||
state = toSharedReaderScreenState(
|
||||
rawBooks = taggedBooks,
|
||||
dbTags = dbTags
|
||||
),
|
||||
booksFromStore = taggedBooks
|
||||
.filterNot { it.bookId.endsWith("_reflow") }
|
||||
.map { it.toSharedBookItem() },
|
||||
shelfRecords = dbShelves.map { it.toSharedShelfRecord() },
|
||||
shelfRefs = shelfRefs.map { it.toSharedBookShelfRef() },
|
||||
tags = dbTags.map { it.toSharedTag() }
|
||||
fun bookById(bookId: String): RecentFileItem? {
|
||||
return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook()
|
||||
}
|
||||
return base.copy(
|
||||
recentFiles = recentBooks.map { it.toAndroidBook() },
|
||||
allRecentFiles = libraryBooks.map { it.toAndroidBook() },
|
||||
rawLibraryFiles = rawLibraryBooks.map { it.toAndroidBook() },
|
||||
viewingShelfId = viewingShelfId,
|
||||
isAddingBooksToShelf = isAddingBooksToShelf,
|
||||
contextualActionShelfIds = selectedShelfIds,
|
||||
contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) },
|
||||
shelves = shelves.map { it.toAndroidShelf(androidBooksById, tagEntitiesById) },
|
||||
openTabs = openTabs.map { it.toAndroidBook() },
|
||||
openTabIds = openTabIds,
|
||||
activeTabBookId = activeTabBookId,
|
||||
booksAvailableForAdding = booksAvailableForAdding.map { it.toAndroidBook() },
|
||||
allTags = allTags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedShelf.toAndroidShelf(
|
||||
androidBooksById: Map<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 {
|
||||
return runCatching { SharedFileType.valueOf(name) }.getOrDefault(SharedFileType.UNKNOWN)
|
||||
return this
|
||||
}
|
||||
|
||||
private fun RenderMode.toSharedRenderMode(): SharedRenderMode {
|
||||
return SharedRenderMode.valueOf(name)
|
||||
fun SharedFileType.toAndroidFileType(): FileType {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource {
|
||||
return SharedAddBooksSource.valueOf(name)
|
||||
fun RenderMode.toSharedRenderMode(): SharedRenderMode {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun SortOrder.toSharedSortOrder(): SharedSortOrder {
|
||||
return SharedSortOrder.valueOf(name)
|
||||
fun SharedRenderMode.toAndroidRenderMode(): RenderMode {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter {
|
||||
return SharedReadStatusFilter.valueOf(name)
|
||||
fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters {
|
||||
return SharedLibraryFilters(
|
||||
fileTypes = fileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() },
|
||||
sourceFolders = sourceFolders,
|
||||
readStatus = readStatus.toSharedReadStatusFilter(),
|
||||
tagIds = tagIds
|
||||
)
|
||||
fun SharedAddBooksSource.toAndroidAddBooksSource(): AddBooksSource {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder {
|
||||
return SharedSyncedFolder(
|
||||
uriString = uriString,
|
||||
name = name,
|
||||
lastScanTime = lastScanTime,
|
||||
allowedFileTypes = allowedFileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() }
|
||||
)
|
||||
fun SortOrder.toSharedSortOrder(): SharedSortOrder {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage {
|
||||
return SharedBannerMessage(
|
||||
message = message,
|
||||
isError = isError,
|
||||
isPersistent = isPersistent
|
||||
)
|
||||
fun SharedSortOrder.toAndroidSortOrder(): SortOrder {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode {
|
||||
return SharedAppThemeMode.valueOf(name)
|
||||
fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption {
|
||||
return SharedAppContrastOption.valueOf(name)
|
||||
fun SharedReadStatusFilter.toAndroidReadStatusFilter(): ReadStatusFilter {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme {
|
||||
return SharedCustomAppTheme(
|
||||
id = id,
|
||||
name = name,
|
||||
seedColor = seedColor
|
||||
)
|
||||
fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedLibraryFilters.toAndroidLibraryFilters(): LibraryFilters {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun SharedShelfType.toAndroidShelfType(): ShelfType {
|
||||
return ShelfType.valueOf(name)
|
||||
}
|
||||
|
||||
fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage {
|
||||
return this
|
||||
}
|
||||
|
||||
fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedAppThemeMode.toAndroidAppThemeMode(): AppThemeMode {
|
||||
return this
|
||||
}
|
||||
|
||||
fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedAppContrastOption.toAndroidAppContrastOption(): AppContrastOption {
|
||||
return this
|
||||
}
|
||||
|
||||
fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedCustomAppTheme.toAndroidCustomAppTheme(): CustomAppTheme {
|
||||
return this
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
BookTagCrossRef::class
|
||||
],
|
||||
version = 19,
|
||||
version = 22,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(FileTypeConverter::class)
|
||||
|
|
@ -257,6 +257,37 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
}
|
||||
}
|
||||
|
||||
val MIGRATION_19_20 = object : Migration(19, 20) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE recent_files ADD COLUMN folderCoverMetadataParsed INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_20_21 = object : Migration(20, 21) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalTitle TEXT DEFAULT NULL")
|
||||
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalAuthor TEXT DEFAULT NULL")
|
||||
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalSeriesName TEXT DEFAULT NULL")
|
||||
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalSeriesIndex REAL DEFAULT NULL")
|
||||
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalDescription TEXT DEFAULT NULL")
|
||||
db.execSQL("""
|
||||
UPDATE recent_files
|
||||
SET
|
||||
originalTitle = title,
|
||||
originalAuthor = author,
|
||||
originalSeriesName = seriesName,
|
||||
originalSeriesIndex = seriesIndex,
|
||||
originalDescription = description
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_21_22 = object : Migration(21, 22) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE recent_files ADD COLUMN fileContentModifiedTimestamp INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
fun getDatabase(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
|
|
@ -269,7 +300,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
|
||||
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
||||
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
|
||||
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19
|
||||
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20,
|
||||
MIGRATION_20_21, MIGRATION_21_22
|
||||
)
|
||||
.fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
|
|
|
|||
|
|
@ -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 locatorCharOffset: Int?,
|
||||
val customName: String?,
|
||||
val highlightsJson: String?
|
||||
val highlightsJson: String?,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val description: String? = null,
|
||||
val originalTitle: String? = null,
|
||||
val originalAuthor: String? = null,
|
||||
val originalSeriesName: String? = null,
|
||||
val originalSeriesIndex: Double? = null,
|
||||
val originalDescription: String? = null
|
||||
) {
|
||||
fun toJsonString(): String {
|
||||
val json = JSONObject()
|
||||
json.put("bookId", bookId)
|
||||
json.put("title", title)
|
||||
json.put("author", author)
|
||||
json.put("displayName", displayName)
|
||||
json.put("type", type)
|
||||
json.put("lastChapterIndex", lastChapterIndex ?: -1)
|
||||
|
|
@ -58,8 +64,8 @@ data class FolderBookMetadata(
|
|||
|
||||
return FolderBookMetadata(
|
||||
bookId = json.getString("bookId"),
|
||||
title = json.optStringNull("title"),
|
||||
author = json.optStringNull("author"),
|
||||
title = null,
|
||||
author = null,
|
||||
displayName = json.optString("displayName", "Unknown"),
|
||||
type = json.optString("type", "PDF"),
|
||||
lastChapterIndex = json.optIntNull("lastChapterIndex"),
|
||||
|
|
@ -72,7 +78,15 @@ data class FolderBookMetadata(
|
|||
locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
|
||||
locatorCharOffset = json.optIntNull("locatorCharOffset"),
|
||||
customName = json.optStringNull("customName"),
|
||||
highlightsJson = json.optStringNull("highlightsJson")
|
||||
highlightsJson = json.optStringNull("highlightsJson"),
|
||||
seriesName = null,
|
||||
seriesIndex = null,
|
||||
description = null,
|
||||
originalTitle = null,
|
||||
originalAuthor = null,
|
||||
originalSeriesName = null,
|
||||
originalSeriesIndex = null,
|
||||
originalDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -86,8 +100,8 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?,
|
|||
displayName = this.displayName,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
coverImagePath = coverPath,
|
||||
title = this.title,
|
||||
author = this.author,
|
||||
title = this.displayName.substringBeforeLast('.', this.displayName),
|
||||
author = null,
|
||||
lastChapterIndex = this.lastChapterIndex,
|
||||
lastPage = this.lastPage,
|
||||
lastPositionCfi = this.lastPositionCfi,
|
||||
|
|
@ -101,6 +115,14 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?,
|
|||
bookmarksJson = this.bookmarksJson,
|
||||
sourceFolderUri = sourceFolderUri,
|
||||
customName = this.customName,
|
||||
highlightsJson = this.highlightsJson
|
||||
highlightsJson = this.highlightsJson,
|
||||
seriesName = null,
|
||||
seriesIndex = null,
|
||||
description = null,
|
||||
originalTitle = null,
|
||||
originalAuthor = null,
|
||||
originalSeriesName = null,
|
||||
originalSeriesIndex = null,
|
||||
originalDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import android.os.Environment
|
|||
import android.provider.DocumentsContract
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.aryan.reader.ReaderPerfLog
|
||||
import com.aryan.reader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX
|
||||
import com.aryan.reader.shared.localFolderSyncAnnotationFileName
|
||||
import com.aryan.reader.shared.localFolderSyncAnnotationTempFileName
|
||||
import com.aryan.reader.shared.localFolderSyncMetadataFileName
|
||||
import com.aryan.reader.shared.localFolderSyncMetadataTempFileName
|
||||
import com.aryan.reader.shared.localFolderSyncSidecarStem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
|
@ -22,6 +28,12 @@ object LocalSyncUtils {
|
|||
val uri: Uri
|
||||
)
|
||||
|
||||
private data class ParsedAnnotationSidecar(
|
||||
val bookId: String,
|
||||
val timestamp: Long,
|
||||
val data: String
|
||||
)
|
||||
|
||||
private fun syncSubfolderDocId(rootDocId: String): String {
|
||||
return if (rootDocId.endsWith("/$SYNC_SUBFOLDER_NAME")) {
|
||||
rootDocId
|
||||
|
|
@ -182,15 +194,14 @@ object LocalSyncUtils {
|
|||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
||||
val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext
|
||||
|
||||
val syncFileName = ".${metadata.bookId}.json"
|
||||
val syncFileName = localFolderSyncMetadataFileName(metadata.bookId)
|
||||
val existingMeta = resolveAndCleanMetadataConflicts(context, syncDir, metadata.bookId)
|
||||
if (existingMeta != null && existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
|
||||
Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val tempFileName = ".${metadata.bookId}.tmp"
|
||||
syncDir.findFile(tempFileName)?.delete()
|
||||
val tempFileName = uniqueFolderSyncTempName(localFolderSyncMetadataTempFileName(metadata.bookId))
|
||||
val tempFile = syncDir.createFile("application/json", tempFileName)
|
||||
if (tempFile == null) {
|
||||
Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}")
|
||||
|
|
@ -258,10 +269,8 @@ object LocalSyncUtils {
|
|||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
||||
val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext
|
||||
val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
|
||||
val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json"
|
||||
val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp"
|
||||
syncDir.findFile(tempName)?.delete()
|
||||
val tempFile = syncDir.createFile("application/json", tempName)
|
||||
val targetName = localFolderSyncAnnotationFileName(bookId)
|
||||
val tempName = uniqueFolderSyncTempName(localFolderSyncAnnotationTempFileName(bookId))
|
||||
|
||||
if (currentBest != null) {
|
||||
val (remoteTs, _) = currentBest
|
||||
|
|
@ -273,10 +282,12 @@ object LocalSyncUtils {
|
|||
|
||||
val wrapper = JSONObject()
|
||||
wrapper.put("version", 1)
|
||||
wrapper.put("bookId", bookId)
|
||||
wrapper.put("timestamp", timestamp)
|
||||
wrapper.put("data", JSONObject(jsonPayload))
|
||||
val contentBytes = wrapper.toString().toByteArray()
|
||||
|
||||
val tempFile = syncDir.createFile("application/json", tempName)
|
||||
if (tempFile == null) {
|
||||
Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.")
|
||||
return@withContext
|
||||
|
|
@ -325,17 +336,21 @@ object LocalSyncUtils {
|
|||
val results = mutableMapOf<String, Pair<Long, String>>()
|
||||
|
||||
try {
|
||||
val groupedFiles = querySyncSubfolderFiles(context, sourceFolderUri)
|
||||
.filter { file ->
|
||||
val name = file.name
|
||||
extractAnnotationBookId(name) != null &&
|
||||
!name.contains(".syncthing.")
|
||||
val parsedSidecars = querySyncSubfolderFiles(context, sourceFolderUri)
|
||||
.asSequence()
|
||||
.filter { isAnnotationSidecarCandidateName(it.name) }
|
||||
.mapNotNull { file ->
|
||||
parseAnnotationSidecar(
|
||||
context = context,
|
||||
file = file,
|
||||
fallbackBookId = extractLegacyAnnotationBookId(file.name)
|
||||
)
|
||||
}
|
||||
.groupBy { file -> extractAnnotationBookId(file.name).orEmpty() }
|
||||
.groupBy { it.bookId }
|
||||
|
||||
for ((bookId, files) in groupedFiles) {
|
||||
val best = resolveAnnotationConflictsReadOnly(context, bookId, files)
|
||||
if (best != null) results[bookId] = best
|
||||
for ((bookId, sidecars) in parsedSidecars) {
|
||||
val best = sidecars.maxByOrNull { it.timestamp }
|
||||
if (best != null) results[bookId] = best.timestamp to best.data
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars")
|
||||
|
|
@ -344,44 +359,6 @@ object LocalSyncUtils {
|
|||
return@withContext results
|
||||
}
|
||||
|
||||
private fun resolveAnnotationConflictsReadOnly(
|
||||
context: Context,
|
||||
bookId: String,
|
||||
files: List<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(
|
||||
context: Context,
|
||||
sourceFolderUri: Uri,
|
||||
|
|
@ -405,17 +382,16 @@ object LocalSyncUtils {
|
|||
bookId: String,
|
||||
knownFiles: List<DocumentFile>? = null
|
||||
): Pair<Long, String>? {
|
||||
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
|
||||
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
|
||||
|
||||
val allFiles = knownFiles ?: syncDir.listFiles().asList()
|
||||
|
||||
val candidates = allFiles.filter { file ->
|
||||
val name = file.name ?: ""
|
||||
(name.startsWith(basePattern) || name.startsWith(legacyPattern)) &&
|
||||
name.endsWith(".json") &&
|
||||
!name.endsWith(".tmp") &&
|
||||
!name.contains(".syncthing.")
|
||||
val candidates = allFiles.mapNotNull { file ->
|
||||
val name = file.name ?: return@mapNotNull null
|
||||
if (!isAnnotationSidecarCandidateName(name)) return@mapNotNull null
|
||||
parseAnnotationSidecar(
|
||||
context = context,
|
||||
file = SyncFileEntry(name = name, uri = file.uri),
|
||||
fallbackBookId = extractLegacyAnnotationBookId(name)
|
||||
)?.takeIf { it.bookId == bookId }?.let { file to it }
|
||||
}
|
||||
|
||||
if (candidates.isEmpty()) return null
|
||||
|
|
@ -425,31 +401,16 @@ object LocalSyncUtils {
|
|||
var bestFile: DocumentFile? = null
|
||||
val filesToDelete = mutableListOf<DocumentFile>()
|
||||
|
||||
for (file in candidates) {
|
||||
try {
|
||||
val content = context.contentResolver.openInputStream(file.uri)?.use {
|
||||
it.bufferedReader().readText()
|
||||
} ?: continue
|
||||
|
||||
val json = JSONObject(content)
|
||||
val ts = json.optLong("timestamp", 0L)
|
||||
val data = json.optJSONObject("data")?.toString()
|
||||
|
||||
if (data != null) {
|
||||
if (ts > bestTs) {
|
||||
if (bestFile != null) filesToDelete.add(bestFile)
|
||||
|
||||
bestTs = ts
|
||||
bestData = data
|
||||
bestFile = file
|
||||
} else {
|
||||
filesToDelete.add(file)
|
||||
}
|
||||
} else {
|
||||
filesToDelete.add(file)
|
||||
for ((file, sidecar) in candidates) {
|
||||
if (sidecar.timestamp > bestTs) {
|
||||
if (bestFile != null) {
|
||||
filesToDelete.add(bestFile)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("FolderAnnotationSync").e(e, "Error parsing candidate file: ${file.name}")
|
||||
bestTs = sidecar.timestamp
|
||||
bestData = sidecar.data
|
||||
bestFile = file
|
||||
} else {
|
||||
filesToDelete.add(file)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -464,7 +425,7 @@ object LocalSyncUtils {
|
|||
}
|
||||
|
||||
if (bestFile != null) {
|
||||
val correctName = "${basePattern}.json"
|
||||
val correctName = localFolderSyncAnnotationFileName(bookId)
|
||||
if (bestFile.name != correctName) {
|
||||
Timber.tag("FolderAnnotationSync").i("Renaming winner ${bestFile.name} to $correctName")
|
||||
val existingTarget = syncDir.findFile(correctName)
|
||||
|
|
@ -548,7 +509,7 @@ object LocalSyncUtils {
|
|||
}
|
||||
}
|
||||
|
||||
val correctName = ".${bookId}.json"
|
||||
val correctName = localFolderSyncMetadataFileName(bookId)
|
||||
if (bestFile.name != correctName) {
|
||||
Timber.tag(TAG).i("Renaming metadata winner ${bestFile.name} to $correctName")
|
||||
bestFile.renameTo(correctName)
|
||||
|
|
@ -563,21 +524,24 @@ object LocalSyncUtils {
|
|||
syncDir: DocumentFile,
|
||||
bookId: String
|
||||
): FolderBookMetadata? {
|
||||
val hashedStem = localFolderSyncSidecarStem(bookId)
|
||||
val candidates = syncDir.listFiles().filter { file ->
|
||||
val name = file.name ?: ""
|
||||
val normalizedName = if (name.startsWith(".")) name.substring(1) else name
|
||||
normalizedName == "$bookId.json" ||
|
||||
normalizedName.startsWith("$bookId.sync-conflict") ||
|
||||
normalizedName.startsWith("$bookId.json.sync-conflict")
|
||||
val normalizedName = name.normalizedSidecarName()
|
||||
isMetadataSidecarCandidateName(name) &&
|
||||
(
|
||||
normalizedName.matchesJsonSidecarStem(hashedStem) ||
|
||||
normalizedName.matchesJsonSidecarStem(bookId)
|
||||
)
|
||||
}
|
||||
if (candidates.isEmpty()) return null
|
||||
return resolveAndCleanConflicts(context, candidates, bookId)
|
||||
}
|
||||
|
||||
private fun extractAnnotationBookId(name: String?): String? {
|
||||
private fun extractLegacyAnnotationBookId(name: String?): String? {
|
||||
if (name.isNullOrBlank()) return null
|
||||
var temp = name
|
||||
if (!temp.contains(ANNOTATION_SUFFIX) || !temp.endsWith(".json") || temp.endsWith(".tmp")) return null
|
||||
if (!isAnnotationSidecarCandidateName(temp)) return null
|
||||
if (temp.contains(".sync-conflict")) {
|
||||
temp = temp.substringBefore(".sync-conflict")
|
||||
}
|
||||
|
|
@ -588,9 +552,63 @@ object LocalSyncUtils {
|
|||
if (temp.startsWith(".")) {
|
||||
temp = temp.substring(1)
|
||||
}
|
||||
if (temp.startsWith(LOCAL_FOLDER_SIDECAR_HASH_PREFIX)) return null
|
||||
return temp.ifBlank { null }
|
||||
}
|
||||
|
||||
private fun isMetadataSidecarCandidateName(name: String): Boolean {
|
||||
if (name.contains(ANNOTATION_SUFFIX)) return false
|
||||
if (name.contains(".tmp") || name.contains(".syncthing.")) return false
|
||||
return name.endsWith(".json") || name.contains(".sync-conflict")
|
||||
}
|
||||
|
||||
private fun isAnnotationSidecarCandidateName(name: String): Boolean {
|
||||
if (!name.contains(ANNOTATION_SUFFIX)) return false
|
||||
if (name.contains(".tmp") || name.contains(".syncthing.")) return false
|
||||
return name.endsWith(".json") || name.contains(".sync-conflict")
|
||||
}
|
||||
|
||||
private fun String.normalizedSidecarName(): String {
|
||||
return removePrefix(".")
|
||||
}
|
||||
|
||||
private fun String.matchesJsonSidecarStem(stem: String): Boolean {
|
||||
return this == "$stem.json" ||
|
||||
startsWith("$stem.sync-conflict") ||
|
||||
startsWith("$stem.json.sync-conflict")
|
||||
}
|
||||
|
||||
private fun uniqueFolderSyncTempName(baseName: String): String {
|
||||
val stem = baseName.removeSuffix(".tmp")
|
||||
val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}"
|
||||
return "$stem.$nonce.tmp"
|
||||
}
|
||||
|
||||
private fun parseAnnotationSidecar(
|
||||
context: Context,
|
||||
file: SyncFileEntry,
|
||||
fallbackBookId: String?
|
||||
): ParsedAnnotationSidecar? {
|
||||
return try {
|
||||
val content = context.contentResolver.openInputStream(file.uri)?.use {
|
||||
it.bufferedReader().readText()
|
||||
} ?: return null
|
||||
val json = JSONObject(content)
|
||||
val bookId = json.optString("bookId").takeIf { it.isNotBlank() }
|
||||
?: fallbackBookId
|
||||
?: return null
|
||||
val data = json.optJSONObject("data")?.toString() ?: return null
|
||||
ParsedAnnotationSidecar(
|
||||
bookId = bookId,
|
||||
timestamp = json.optLong("timestamp", 0L),
|
||||
data = data
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("FolderAnnotationSync").e(e, "Error parsing annotation sidecar: ${file.name}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteBookSidecars(
|
||||
context: Context,
|
||||
sourceFolderUri: Uri,
|
||||
|
|
@ -599,13 +617,15 @@ object LocalSyncUtils {
|
|||
try {
|
||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
||||
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext
|
||||
val hashedStem = localFolderSyncSidecarStem(bookId)
|
||||
val hashedAnnotationStem = "$hashedStem$ANNOTATION_SUFFIX"
|
||||
val targets = syncDir.listFiles().filter { file ->
|
||||
val name = file.name ?: return@filter false
|
||||
val normalized = if (name.startsWith(".")) name.substring(1) else name
|
||||
normalized == "$bookId.json" ||
|
||||
normalized.startsWith("$bookId.sync-conflict") ||
|
||||
normalized.startsWith("$bookId.json.sync-conflict") ||
|
||||
normalized.startsWith("$bookId${ANNOTATION_SUFFIX}")
|
||||
val normalized = name.normalizedSidecarName()
|
||||
normalized.matchesJsonSidecarStem(hashedStem) ||
|
||||
normalized.matchesJsonSidecarStem(bookId) ||
|
||||
normalized.matchesJsonSidecarStem(hashedAnnotationStem) ||
|
||||
normalized.matchesJsonSidecarStem("$bookId$ANNOTATION_SUFFIX")
|
||||
}
|
||||
targets.forEach {
|
||||
try {
|
||||
|
|
@ -626,34 +646,32 @@ object LocalSyncUtils {
|
|||
|
||||
try {
|
||||
val allFiles = querySyncSubfolderFiles(context, sourceFolderUri)
|
||||
val groupedFiles = allFiles
|
||||
.filter {
|
||||
val name = it.name
|
||||
(name.endsWith(".json") || name.contains(".sync-conflict")) &&
|
||||
!name.contains(ANNOTATION_SUFFIX) &&
|
||||
!name.endsWith(".tmp") &&
|
||||
!name.contains(".syncthing.")
|
||||
}
|
||||
.groupBy { file ->
|
||||
var name = file.name
|
||||
if (name.startsWith(".")) name = name.substring(1)
|
||||
if (name.contains(".sync-conflict")) {
|
||||
name.substringBefore(".sync-conflict")
|
||||
} else {
|
||||
name.substringBefore(".json")
|
||||
val groupedMetadata = allFiles
|
||||
.asSequence()
|
||||
.filter { isMetadataSidecarCandidateName(it.name) }
|
||||
.mapNotNull { file ->
|
||||
try {
|
||||
val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input ->
|
||||
input.bufferedReader().use { it.readText() }
|
||||
}
|
||||
jsonString?.let(FolderBookMetadata::fromJsonString)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Failed to parse metadata sidecar: ${file.name}")
|
||||
null
|
||||
}
|
||||
}
|
||||
.groupBy { it.bookId }
|
||||
|
||||
groupedFiles.forEach { (bookId, files) ->
|
||||
val winner = resolveMetadataConflictsReadOnly(context, files, bookId)
|
||||
groupedMetadata.forEach { (bookId, metadataRecords) ->
|
||||
val winner = metadataRecords.maxByOrNull { it.lastModifiedTimestamp }
|
||||
if (winner != null) {
|
||||
finalResults[bookId] = winner
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag(TAG).d("getAllFolderMetadata: Read ${finalResults.size}/${groupedFiles.size} book records from sync data.")
|
||||
Timber.tag(TAG).d("getAllFolderMetadata: Read ${finalResults.size}/${groupedMetadata.size} book records from sync data.")
|
||||
ReaderPerfLog.d(
|
||||
"LocalSync metadata read files=${allFiles.size} groups=${groupedFiles.size} records=${finalResults.size}"
|
||||
"LocalSync metadata read files=${allFiles.size} groups=${groupedMetadata.size} records=${finalResults.size}"
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -663,29 +681,4 @@ object LocalSyncUtils {
|
|||
return@withContext finalResults
|
||||
}
|
||||
|
||||
private fun resolveMetadataConflictsReadOnly(
|
||||
context: Context,
|
||||
files: List<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 purchaseTime: Long,
|
||||
val isAcknowledged: Boolean,
|
||||
val isAutoRenewing: Boolean
|
||||
val isAutoRenewing: Boolean,
|
||||
val obfuscatedAccountId: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -41,4 +42,4 @@ data class ProductDetailsEntity(
|
|||
val formattedPrice: String,
|
||||
val currencyCode: String,
|
||||
val priceAmountMicros: Long
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ interface RecentFileDao {
|
|||
@Upsert
|
||||
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>>
|
||||
|
||||
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
|
||||
|
|
@ -45,7 +45,7 @@ interface RecentFileDao {
|
|||
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
|
||||
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
|
||||
|
||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, seriesName, seriesIndex, description FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
|
||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
|
||||
fun getRecentFilesList(limit: Int): List<RecentFileSummary>
|
||||
|
||||
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
|
||||
|
|
@ -94,26 +94,36 @@ interface RecentFileDao {
|
|||
SELECT * FROM recent_files
|
||||
WHERE sourceFolderUri IS NOT NULL
|
||||
AND isDeleted = 0
|
||||
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
|
||||
AND folderTextMetadataParsed = 0
|
||||
AND (
|
||||
(type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0)
|
||||
OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = ''))
|
||||
)
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit
|
||||
""")
|
||||
suspend fun getFolderBooksNeedingTextMetadata(): List<RecentFileEntity>
|
||||
suspend fun getFolderBooksNeedingTextMetadata(limit: Int): List<RecentFileEntity>
|
||||
|
||||
@Query("""
|
||||
SELECT * FROM recent_files
|
||||
WHERE sourceFolderUri = :sourceFolderUri
|
||||
AND isDeleted = 0
|
||||
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
|
||||
AND folderTextMetadataParsed = 0
|
||||
AND (
|
||||
(type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0)
|
||||
OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = ''))
|
||||
)
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit
|
||||
""")
|
||||
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String): List<RecentFileEntity>
|
||||
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String, limit: Int): List<RecentFileEntity>
|
||||
|
||||
@Query("""
|
||||
SELECT COUNT(*) FROM recent_files
|
||||
WHERE sourceFolderUri IS NOT NULL
|
||||
AND isDeleted = 0
|
||||
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
|
||||
AND folderTextMetadataParsed = 0
|
||||
AND (
|
||||
(type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0)
|
||||
OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = ''))
|
||||
)
|
||||
""")
|
||||
suspend fun countFolderBooksNeedingTextMetadata(): Int
|
||||
|
||||
|
|
@ -121,8 +131,10 @@ interface RecentFileDao {
|
|||
SELECT COUNT(*) FROM recent_files
|
||||
WHERE sourceFolderUri = :sourceFolderUri
|
||||
AND isDeleted = 0
|
||||
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
|
||||
AND folderTextMetadataParsed = 0
|
||||
AND (
|
||||
(type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0)
|
||||
OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = ''))
|
||||
)
|
||||
""")
|
||||
suspend fun countFolderBooksNeedingTextMetadata(sourceFolderUri: String): Int
|
||||
|
||||
|
|
@ -130,10 +142,60 @@ interface RecentFileDao {
|
|||
UPDATE recent_files
|
||||
SET
|
||||
coverImagePath = COALESCE(:coverImagePath, coverImagePath),
|
||||
title = COALESCE(:title, title),
|
||||
author = COALESCE(:author, author),
|
||||
title = CASE
|
||||
WHEN :title IS NOT NULL AND (originalTitle IS NULL OR title IS NULL OR title = originalTitle OR title = displayName)
|
||||
THEN :title
|
||||
ELSE title
|
||||
END,
|
||||
author = CASE
|
||||
WHEN :author IS NOT NULL AND (originalAuthor IS NULL OR author IS NULL OR author = originalAuthor)
|
||||
THEN :author
|
||||
ELSE author
|
||||
END,
|
||||
seriesName = CASE
|
||||
WHEN :seriesName IS NOT NULL AND (originalSeriesName IS NULL OR seriesName IS NULL OR seriesName = originalSeriesName)
|
||||
THEN :seriesName
|
||||
ELSE seriesName
|
||||
END,
|
||||
seriesIndex = CASE
|
||||
WHEN :seriesIndex IS NOT NULL AND (originalSeriesIndex IS NULL OR seriesIndex IS NULL OR seriesIndex = originalSeriesIndex)
|
||||
THEN :seriesIndex
|
||||
ELSE seriesIndex
|
||||
END,
|
||||
description = CASE
|
||||
WHEN :description IS NOT NULL AND (originalDescription IS NULL OR description IS NULL OR description = originalDescription)
|
||||
THEN :description
|
||||
ELSE description
|
||||
END,
|
||||
originalTitle = CASE
|
||||
WHEN :title IS NOT NULL AND (originalTitle IS NULL OR originalTitle = title OR originalTitle = displayName)
|
||||
THEN :title
|
||||
ELSE originalTitle
|
||||
END,
|
||||
originalAuthor = CASE
|
||||
WHEN :author IS NOT NULL AND (originalAuthor IS NULL OR originalAuthor = author)
|
||||
THEN :author
|
||||
ELSE originalAuthor
|
||||
END,
|
||||
originalSeriesName = CASE
|
||||
WHEN :seriesName IS NOT NULL AND (originalSeriesName IS NULL OR originalSeriesName = seriesName)
|
||||
THEN :seriesName
|
||||
ELSE originalSeriesName
|
||||
END,
|
||||
originalSeriesIndex = CASE
|
||||
WHEN :seriesIndex IS NOT NULL AND (originalSeriesIndex IS NULL OR originalSeriesIndex = seriesIndex)
|
||||
THEN :seriesIndex
|
||||
ELSE originalSeriesIndex
|
||||
END,
|
||||
originalDescription = CASE
|
||||
WHEN :description IS NOT NULL AND (originalDescription IS NULL OR originalDescription = description)
|
||||
THEN :description
|
||||
ELSE originalDescription
|
||||
END,
|
||||
fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END,
|
||||
folderTextMetadataParsed = 1
|
||||
fileContentModifiedTimestamp = CASE WHEN :fileContentModifiedTimestamp > 0 THEN :fileContentModifiedTimestamp ELSE fileContentModifiedTimestamp END,
|
||||
folderTextMetadataParsed = CASE WHEN :textMetadataParsed = 1 THEN 1 ELSE folderTextMetadataParsed END,
|
||||
folderCoverMetadataParsed = CASE WHEN :coverMetadataParsed = 1 THEN 1 ELSE folderCoverMetadataParsed END
|
||||
WHERE bookId = :bookId
|
||||
""")
|
||||
suspend fun updateExtractedMetadata(
|
||||
|
|
@ -141,7 +203,13 @@ interface RecentFileDao {
|
|||
coverImagePath: String?,
|
||||
title: String?,
|
||||
author: String?,
|
||||
fileSize: Long
|
||||
seriesName: String?,
|
||||
seriesIndex: Double?,
|
||||
description: String?,
|
||||
fileSize: Long,
|
||||
fileContentModifiedTimestamp: Long,
|
||||
textMetadataParsed: Boolean,
|
||||
coverMetadataParsed: Boolean
|
||||
)
|
||||
|
||||
@Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL")
|
||||
|
|
@ -149,4 +217,58 @@ interface RecentFileDao {
|
|||
|
||||
@Query("UPDATE recent_files SET highlights = :highlightsJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
|
||||
suspend fun updateHighlights(bookId: String, highlightsJson: String, timestamp: Long)
|
||||
|
||||
@Query("""
|
||||
UPDATE recent_files
|
||||
SET
|
||||
title = :title,
|
||||
author = :author,
|
||||
seriesName = :seriesName,
|
||||
seriesIndex = :seriesIndex,
|
||||
description = :description,
|
||||
customName = NULL,
|
||||
originalTitle = COALESCE(originalTitle, title),
|
||||
originalAuthor = COALESCE(originalAuthor, author),
|
||||
originalSeriesName = COALESCE(originalSeriesName, seriesName),
|
||||
originalSeriesIndex = COALESCE(originalSeriesIndex, seriesIndex),
|
||||
originalDescription = COALESCE(originalDescription, description),
|
||||
fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END,
|
||||
fileContentModifiedTimestamp = CASE WHEN :fileContentModifiedTimestamp > 0 THEN :fileContentModifiedTimestamp ELSE fileContentModifiedTimestamp END,
|
||||
folderTextMetadataParsed = 1,
|
||||
lastModifiedTimestamp = :timestamp
|
||||
WHERE bookId = :bookId
|
||||
""")
|
||||
suspend fun updateUserEditableMetadata(
|
||||
bookId: String,
|
||||
title: String?,
|
||||
author: String?,
|
||||
seriesName: String?,
|
||||
seriesIndex: Double?,
|
||||
description: String?,
|
||||
fileSize: Long,
|
||||
fileContentModifiedTimestamp: Long,
|
||||
timestamp: Long
|
||||
)
|
||||
|
||||
@Query("""
|
||||
UPDATE recent_files
|
||||
SET
|
||||
title = COALESCE(originalTitle, displayName),
|
||||
author = originalAuthor,
|
||||
seriesName = originalSeriesName,
|
||||
seriesIndex = originalSeriesIndex,
|
||||
description = originalDescription,
|
||||
customName = NULL,
|
||||
fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END,
|
||||
fileContentModifiedTimestamp = CASE WHEN :fileContentModifiedTimestamp > 0 THEN :fileContentModifiedTimestamp ELSE fileContentModifiedTimestamp END,
|
||||
folderTextMetadataParsed = 1,
|
||||
lastModifiedTimestamp = :timestamp
|
||||
WHERE bookId = :bookId
|
||||
""")
|
||||
suspend fun restoreOriginalMetadata(
|
||||
bookId: String,
|
||||
fileSize: Long,
|
||||
fileContentModifiedTimestamp: Long,
|
||||
timestamp: Long
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,10 +52,17 @@ data class RecentFileEntity(
|
|||
@ColumnInfo(defaultValue = "NULL") val customName: String?,
|
||||
@ColumnInfo(defaultValue = "NULL") val highlights: String?,
|
||||
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
|
||||
@ColumnInfo(defaultValue = "0") val fileContentModifiedTimestamp: Long = 0L,
|
||||
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
|
||||
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
|
||||
@ColumnInfo(defaultValue = "NULL") val description: String?,
|
||||
@ColumnInfo(defaultValue = "0") val folderTextMetadataParsed: Boolean
|
||||
@ColumnInfo(defaultValue = "0") val folderTextMetadataParsed: Boolean,
|
||||
@ColumnInfo(defaultValue = "0") val folderCoverMetadataParsed: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "NULL") val originalTitle: String? = null,
|
||||
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
|
||||
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
|
||||
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
|
||||
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
|
||||
)
|
||||
|
||||
data class RecentFileSummary(
|
||||
|
|
@ -81,7 +88,13 @@ data class RecentFileSummary(
|
|||
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
|
||||
@ColumnInfo(defaultValue = "NULL") val customName: String?,
|
||||
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
|
||||
@ColumnInfo(defaultValue = "0") val fileContentModifiedTimestamp: Long = 0L,
|
||||
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
|
||||
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
|
||||
@ColumnInfo(defaultValue = "NULL") val description: String?
|
||||
@ColumnInfo(defaultValue = "NULL") val description: String?,
|
||||
@ColumnInfo(defaultValue = "NULL") val originalTitle: String? = null,
|
||||
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
|
||||
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
|
||||
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
|
||||
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -46,10 +46,17 @@ data class RecentFileItem(
|
|||
val customName: String? = null,
|
||||
val highlightsJson: String? = null,
|
||||
val fileSize: Long = 0L,
|
||||
val fileContentModifiedTimestamp: Long = 0L,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val description: String? = null,
|
||||
val originalTitle: String? = null,
|
||||
val originalAuthor: String? = null,
|
||||
val originalSeriesName: String? = null,
|
||||
val originalSeriesIndex: Double? = null,
|
||||
val originalDescription: String? = null,
|
||||
val folderTextMetadataParsed: Boolean = false,
|
||||
val folderCoverMetadataParsed: Boolean = false,
|
||||
val tags: List<TagEntity> = emptyList()
|
||||
)
|
||||
|
||||
|
|
@ -79,10 +86,17 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
|
|||
customName = this.customName,
|
||||
highlightsJson = this.highlights,
|
||||
fileSize = this.fileSize,
|
||||
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
|
||||
seriesName = this.seriesName,
|
||||
seriesIndex = this.seriesIndex,
|
||||
description = this.description,
|
||||
folderTextMetadataParsed = this.folderTextMetadataParsed
|
||||
originalTitle = this.originalTitle,
|
||||
originalAuthor = this.originalAuthor,
|
||||
originalSeriesName = this.originalSeriesName,
|
||||
originalSeriesIndex = this.originalSeriesIndex,
|
||||
originalDescription = this.originalDescription,
|
||||
folderTextMetadataParsed = this.folderTextMetadataParsed,
|
||||
folderCoverMetadataParsed = this.folderCoverMetadataParsed
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -112,10 +126,17 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
|
|||
customName = this.customName,
|
||||
highlights = this.highlightsJson,
|
||||
fileSize = this.fileSize,
|
||||
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
|
||||
seriesName = this.seriesName,
|
||||
seriesIndex = this.seriesIndex,
|
||||
description = this.description,
|
||||
folderTextMetadataParsed = this.folderTextMetadataParsed
|
||||
originalTitle = this.originalTitle ?: this.title,
|
||||
originalAuthor = this.originalAuthor ?: this.author,
|
||||
originalSeriesName = this.originalSeriesName ?: this.seriesName,
|
||||
originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex,
|
||||
originalDescription = this.originalDescription ?: this.description,
|
||||
folderTextMetadataParsed = this.folderTextMetadataParsed,
|
||||
folderCoverMetadataParsed = this.folderCoverMetadataParsed
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +159,16 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
|
|||
bookmarksJson = this.bookmarksJson,
|
||||
hasAnnotations = false,
|
||||
customName = this.customName,
|
||||
highlightsJson = this.highlightsJson
|
||||
highlightsJson = this.highlightsJson,
|
||||
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
|
||||
seriesName = this.seriesName,
|
||||
seriesIndex = this.seriesIndex,
|
||||
description = this.description,
|
||||
originalTitle = this.originalTitle ?: this.title,
|
||||
originalAuthor = this.originalAuthor ?: this.author,
|
||||
originalSeriesName = this.originalSeriesName ?: this.seriesName,
|
||||
originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex,
|
||||
originalDescription = this.originalDescription ?: this.description
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -164,7 +194,16 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem {
|
|||
isDeleted = this.isDeleted,
|
||||
bookmarksJson = this.bookmarksJson,
|
||||
customName = this.customName,
|
||||
highlightsJson = this.highlightsJson
|
||||
highlightsJson = this.highlightsJson,
|
||||
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
|
||||
seriesName = this.seriesName,
|
||||
seriesIndex = this.seriesIndex,
|
||||
description = this.description,
|
||||
originalTitle = this.originalTitle,
|
||||
originalAuthor = this.originalAuthor,
|
||||
originalSeriesName = this.originalSeriesName,
|
||||
originalSeriesIndex = this.originalSeriesIndex,
|
||||
originalDescription = this.originalDescription
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -194,8 +233,14 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem {
|
|||
customName = this.customName,
|
||||
highlightsJson = null,
|
||||
fileSize = this.fileSize,
|
||||
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
|
||||
seriesName = this.seriesName,
|
||||
seriesIndex = this.seriesIndex,
|
||||
description = this.description
|
||||
description = this.description,
|
||||
originalTitle = this.originalTitle,
|
||||
originalAuthor = this.originalAuthor,
|
||||
originalSeriesName = this.originalSeriesName,
|
||||
originalSeriesIndex = this.originalSeriesIndex,
|
||||
originalDescription = this.originalDescription
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,12 @@ package com.aryan.reader.data
|
|||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.aryan.reader.FileType
|
||||
import com.aryan.reader.ReaderPerfLog
|
||||
import com.aryan.reader.scaledToCanvasLimit
|
||||
import timber.log.Timber
|
||||
import com.aryan.reader.BookImporter
|
||||
import com.aryan.reader.paginatedreader.Locator
|
||||
|
|
@ -40,7 +43,6 @@ import java.io.FileOutputStream
|
|||
import com.aryan.reader.pdf.data.PdfAnnotationRepository
|
||||
import com.aryan.reader.pdf.data.PageLayoutRepository
|
||||
import com.aryan.reader.pdf.data.PdfTextBoxRepository
|
||||
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONArray
|
||||
|
|
@ -48,6 +50,9 @@ import java.util.UUID
|
|||
import androidx.core.content.edit
|
||||
|
||||
private const val COVER_CACHE_DIR = "cover_cache"
|
||||
private const val DIRECT_EMBEDDED_COVER_MAX_BYTES = 8L * 1024L * 1024L
|
||||
private const val EMBEDDED_COVER_MAX_DIMENSION = 1200
|
||||
private val EMBEDDED_COVER_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
||||
|
||||
class RecentFilesRepository(private val context: Context) {
|
||||
|
||||
|
|
@ -169,12 +174,43 @@ class RecentFilesRepository(private val context: Context) {
|
|||
}
|
||||
|
||||
val entityToInsert = if (existingItem != null) {
|
||||
val folderFileChanged = item.sourceFolderUri != null &&
|
||||
existingItem.sourceFolderUri == item.sourceFolderUri &&
|
||||
((item.fileSize > 0L && item.fileSize != existingItem.fileSize) ||
|
||||
(item.fileContentModifiedTimestamp > 0L &&
|
||||
item.fileContentModifiedTimestamp != existingItem.fileContentModifiedTimestamp))
|
||||
val embeddedMetadataFileChanged =
|
||||
(item.fileSize > 0L && existingItem.fileSize > 0L && item.fileSize != existingItem.fileSize) ||
|
||||
(item.fileContentModifiedTimestamp > 0L &&
|
||||
existingItem.fileContentModifiedTimestamp > 0L &&
|
||||
item.fileContentModifiedTimestamp != existingItem.fileContentModifiedTimestamp)
|
||||
val keepExistingEmbeddedMetadata = item.type == FileType.EPUB &&
|
||||
existingItem.type == FileType.EPUB &&
|
||||
!embeddedMetadataFileChanged &&
|
||||
existingItem.hasEmbeddedMetadataChanges()
|
||||
|
||||
item.toRecentFileEntity().copy(
|
||||
uriString = existingItem.uriString ?: item.uriString,
|
||||
isAvailable = existingItem.isAvailable || item.isAvailable,
|
||||
coverImagePath = item.coverImagePath ?: existingItem.coverImagePath,
|
||||
title = item.title ?: existingItem.title,
|
||||
author = item.author ?: existingItem.author,
|
||||
coverImagePath = if (folderFileChanged) {
|
||||
item.coverImagePath
|
||||
} else {
|
||||
item.coverImagePath ?: existingItem.coverImagePath
|
||||
},
|
||||
title = if (folderFileChanged) {
|
||||
item.title ?: item.displayName.substringBeforeLast('.', item.displayName)
|
||||
} else if (keepExistingEmbeddedMetadata) {
|
||||
existingItem.title
|
||||
} else {
|
||||
item.title ?: existingItem.title
|
||||
},
|
||||
author = if (folderFileChanged) {
|
||||
item.author
|
||||
} else if (keepExistingEmbeddedMetadata) {
|
||||
existingItem.author
|
||||
} else {
|
||||
item.author ?: existingItem.author
|
||||
},
|
||||
lastChapterIndex = item.lastChapterIndex ?: existingItem.lastChapterIndex,
|
||||
lastPage = item.lastPage ?: existingItem.lastPage,
|
||||
lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi,
|
||||
|
|
@ -187,10 +223,43 @@ class RecentFilesRepository(private val context: Context) {
|
|||
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
|
||||
highlights = item.highlightsJson ?: existingItem.highlights,
|
||||
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize,
|
||||
seriesName = item.seriesName ?: existingItem.seriesName,
|
||||
seriesIndex = item.seriesIndex ?: existingItem.seriesIndex,
|
||||
description = item.description ?: existingItem.description,
|
||||
folderTextMetadataParsed = item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed
|
||||
fileContentModifiedTimestamp = if (item.fileContentModifiedTimestamp > 0) item.fileContentModifiedTimestamp else existingItem.fileContentModifiedTimestamp,
|
||||
seriesName = if (folderFileChanged) {
|
||||
item.seriesName
|
||||
} else if (keepExistingEmbeddedMetadata) {
|
||||
existingItem.seriesName
|
||||
} else {
|
||||
item.seriesName ?: existingItem.seriesName
|
||||
},
|
||||
seriesIndex = if (folderFileChanged) {
|
||||
item.seriesIndex
|
||||
} else if (keepExistingEmbeddedMetadata) {
|
||||
existingItem.seriesIndex
|
||||
} else {
|
||||
item.seriesIndex ?: existingItem.seriesIndex
|
||||
},
|
||||
description = if (folderFileChanged) {
|
||||
item.description
|
||||
} else if (keepExistingEmbeddedMetadata) {
|
||||
existingItem.description
|
||||
} else {
|
||||
item.description ?: existingItem.description
|
||||
},
|
||||
originalTitle = if (folderFileChanged) item.originalTitle ?: item.title else existingItem.originalTitle ?: item.originalTitle ?: item.title,
|
||||
originalAuthor = if (folderFileChanged) item.originalAuthor ?: item.author else existingItem.originalAuthor ?: item.originalAuthor ?: item.author,
|
||||
originalSeriesName = if (folderFileChanged) item.originalSeriesName ?: item.seriesName else existingItem.originalSeriesName ?: item.originalSeriesName ?: item.seriesName,
|
||||
originalSeriesIndex = if (folderFileChanged) item.originalSeriesIndex ?: item.seriesIndex else existingItem.originalSeriesIndex ?: item.originalSeriesIndex ?: item.seriesIndex,
|
||||
originalDescription = if (folderFileChanged) item.originalDescription ?: item.description else existingItem.originalDescription ?: item.originalDescription ?: item.description,
|
||||
folderTextMetadataParsed = if (folderFileChanged) {
|
||||
item.folderTextMetadataParsed
|
||||
} else {
|
||||
item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed
|
||||
},
|
||||
folderCoverMetadataParsed = if (folderFileChanged) {
|
||||
item.folderCoverMetadataParsed
|
||||
} else {
|
||||
item.folderCoverMetadataParsed || existingItem.folderCoverMetadataParsed
|
||||
}
|
||||
)
|
||||
} else {
|
||||
item.toRecentFileEntity()
|
||||
|
|
@ -201,13 +270,60 @@ class RecentFilesRepository(private val context: Context) {
|
|||
Timber.d("Added/Updated recent file in DB: ${item.displayName}")
|
||||
}
|
||||
|
||||
private fun RecentFileEntity.hasEmbeddedMetadataChanges(): Boolean {
|
||||
val hasOriginalMetadata = listOf(originalTitle, originalAuthor, originalSeriesName, originalDescription)
|
||||
.any { !it.isNullOrBlank() } || originalSeriesIndex != null
|
||||
return (hasOriginalMetadata && (
|
||||
metadataValueChanged(title, originalTitle) ||
|
||||
metadataValueChanged(author, originalAuthor) ||
|
||||
metadataValueChanged(seriesName, originalSeriesName) ||
|
||||
seriesIndex != originalSeriesIndex ||
|
||||
metadataValueChanged(description, originalDescription)
|
||||
))
|
||||
}
|
||||
|
||||
private fun metadataValueChanged(current: String?, original: String?): Boolean {
|
||||
return current.orEmpty().trim() != original.orEmpty().trim()
|
||||
}
|
||||
|
||||
suspend fun updateUserEditableMetadata(
|
||||
bookId: String,
|
||||
metadata: BookMetadataEdit,
|
||||
fileSize: Long = 0L,
|
||||
fileContentModifiedTimestamp: Long = 0L
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
recentFileDao.updateUserEditableMetadata(
|
||||
bookId = bookId,
|
||||
title = metadata.title,
|
||||
author = metadata.author,
|
||||
seriesName = metadata.seriesName,
|
||||
seriesIndex = metadata.seriesIndex,
|
||||
description = metadata.description,
|
||||
fileSize = fileSize,
|
||||
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||
timestamp = currentTime
|
||||
)
|
||||
Timber.d("Updated user-editable metadata for $bookId")
|
||||
}
|
||||
|
||||
suspend fun restoreOriginalMetadata(
|
||||
bookId: String,
|
||||
fileSize: Long = 0L,
|
||||
fileContentModifiedTimestamp: Long = 0L
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
recentFileDao.restoreOriginalMetadata(bookId, fileSize, fileContentModifiedTimestamp, currentTime)
|
||||
Timber.d("Restored original metadata for $bookId")
|
||||
}
|
||||
|
||||
suspend fun updateHighlights(bookId: String, highlightsJson: String) = withContext(Dispatchers.IO) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
recentFileDao.updateHighlights(bookId, highlightsJson, currentTime)
|
||||
Timber.d("Updated highlights for $bookId")
|
||||
}
|
||||
|
||||
suspend fun syncLocalMetadataToFolder(bookId: String) = withContext(Dispatchers.IO) {
|
||||
suspend fun syncLocalMetadataToFolder(bookId: String, force: Boolean = false) = withContext(Dispatchers.IO) {
|
||||
val entity = recentFileDao.getFileByBookId(bookId) ?: return@withContext
|
||||
val folderUriString = entity.sourceFolderUri
|
||||
|
||||
|
|
@ -217,7 +333,7 @@ class RecentFilesRepository(private val context: Context) {
|
|||
val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]"
|
||||
val isDirty = entity.isRecent || hasProgress || hasBookmarks || hasHighlights
|
||||
|
||||
if (!isDirty) {
|
||||
if (!force && !isDirty) {
|
||||
Timber.d("SyncDebug: Book $bookId is 'Clean' (Unread/Not Recent). Skipping JSON creation.")
|
||||
return@withContext
|
||||
}
|
||||
|
|
@ -240,7 +356,15 @@ class RecentFilesRepository(private val context: Context) {
|
|||
locatorBlockIndex = entity.locatorBlockIndex,
|
||||
locatorCharOffset = entity.locatorCharOffset,
|
||||
customName = entity.customName,
|
||||
highlightsJson = entity.highlights
|
||||
highlightsJson = entity.highlights,
|
||||
seriesName = entity.seriesName,
|
||||
seriesIndex = entity.seriesIndex,
|
||||
description = entity.description,
|
||||
originalTitle = entity.originalTitle,
|
||||
originalAuthor = entity.originalAuthor,
|
||||
originalSeriesName = entity.originalSeriesName,
|
||||
originalSeriesIndex = entity.originalSeriesIndex,
|
||||
originalDescription = entity.originalDescription
|
||||
)
|
||||
|
||||
LocalSyncUtils.saveMetadataToFolder(
|
||||
|
|
@ -275,7 +399,7 @@ class RecentFilesRepository(private val context: Context) {
|
|||
val hasHighlights = highlightFile.exists()
|
||||
|
||||
Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes, hasHighlights: $hasHighlights")
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.folder.export candidates book=$bookId hasRichText=$hasRichText " +
|
||||
"richBytes=${if (hasRichText) richTextFile.length() else 0L} folder=$folderUriString"
|
||||
)
|
||||
|
|
@ -291,7 +415,7 @@ class RecentFilesRepository(private val context: Context) {
|
|||
try {
|
||||
val content = file.readText().trim()
|
||||
if (key == "text") {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.folder.export.readRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}"
|
||||
)
|
||||
}
|
||||
|
|
@ -302,7 +426,7 @@ class RecentFilesRepository(private val context: Context) {
|
|||
}
|
||||
} catch (e: Exception) {
|
||||
if (key == "text") {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
|
||||
Timber
|
||||
.e(e, "android.folder.export.richTextParseFailed book=$bookId")
|
||||
}
|
||||
Timber.tag("FolderAnnotationSync").e(e, "Error parsing $key file")
|
||||
|
|
@ -328,7 +452,7 @@ class RecentFilesRepository(private val context: Context) {
|
|||
|
||||
val canonicalBundleJson = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString())
|
||||
if (hasRichText) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.folder.export.saveSidecar book=$bookId timestamp=$finalTs canonicalLen=${canonicalBundleJson.length}"
|
||||
)
|
||||
}
|
||||
|
|
@ -348,7 +472,7 @@ class RecentFilesRepository(private val context: Context) {
|
|||
val bundle = JSONObject(
|
||||
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
|
||||
)
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " +
|
||||
"hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
|
||||
)
|
||||
|
|
@ -359,7 +483,7 @@ class RecentFilesRepository(private val context: Context) {
|
|||
val contentStr = bundle.get(key).toString()
|
||||
file.writeText(contentStr)
|
||||
if (key == "text") {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}"
|
||||
)
|
||||
}
|
||||
|
|
@ -431,11 +555,15 @@ class RecentFilesRepository(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): List<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()) {
|
||||
recentFileDao.getFolderBooksNeedingTextMetadata()
|
||||
recentFileDao.getFolderBooksNeedingTextMetadata(queryLimit)
|
||||
} else {
|
||||
recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri)
|
||||
recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri, queryLimit)
|
||||
}
|
||||
return@withContext entities.map { it.toRecentFileItem() }
|
||||
}
|
||||
|
|
@ -459,7 +587,13 @@ class RecentFilesRepository(private val context: Context) {
|
|||
coverImagePath = item.coverImagePath,
|
||||
title = item.title,
|
||||
author = item.author,
|
||||
fileSize = item.fileSize
|
||||
seriesName = item.seriesName,
|
||||
seriesIndex = item.seriesIndex,
|
||||
description = item.description,
|
||||
fileSize = item.fileSize,
|
||||
fileContentModifiedTimestamp = item.fileContentModifiedTimestamp,
|
||||
textMetadataParsed = item.folderTextMetadataParsed,
|
||||
coverMetadataParsed = item.folderCoverMetadataParsed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -562,9 +696,17 @@ class RecentFilesRepository(private val context: Context) {
|
|||
val filename = "cover_${uri.toString().hashCode()}.png"
|
||||
val file = File(cacheDir, filename)
|
||||
var fos: FileOutputStream? = null
|
||||
var scaledCopy: Bitmap? = null
|
||||
try {
|
||||
deleteCoverCacheVariants(uri)
|
||||
val bitmapToSave = bitmap.scaledToCanvasLimit(
|
||||
maxBytes = 8L * 1024L * 1024L,
|
||||
maxDimension = EMBEDDED_COVER_MAX_DIMENSION
|
||||
).also {
|
||||
if (it !== bitmap) scaledCopy = it
|
||||
}
|
||||
fos = FileOutputStream(file)
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 90, fos)
|
||||
bitmapToSave.compress(Bitmap.CompressFormat.PNG, 90, fos)
|
||||
Timber.d("Saved cover image to: ${file.absolutePath}")
|
||||
return@withContext file.absolutePath
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -573,9 +715,70 @@ class RecentFilesRepository(private val context: Context) {
|
|||
return@withContext null
|
||||
} finally {
|
||||
fos?.close()
|
||||
scaledCopy?.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveEmbeddedCoverToCache(bytes: ByteArray, uri: Uri, extension: String): String? = withContext(Dispatchers.IO) {
|
||||
if (bytes.isEmpty()) return@withContext null
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
|
||||
|
||||
val safeExtension = extension.lowercase().takeIf { it in EMBEDDED_COVER_EXTENSIONS } ?: "png"
|
||||
if (
|
||||
bytes.size.toLong() <= DIRECT_EMBEDDED_COVER_MAX_BYTES &&
|
||||
bounds.outWidth <= EMBEDDED_COVER_MAX_DIMENSION &&
|
||||
bounds.outHeight <= EMBEDDED_COVER_MAX_DIMENSION
|
||||
) {
|
||||
return@withContext saveEmbeddedCoverBytesToCache(bytes, uri, safeExtension)
|
||||
}
|
||||
|
||||
var sampleSize = 1
|
||||
while (
|
||||
(bounds.outWidth / sampleSize) > EMBEDDED_COVER_MAX_DIMENSION ||
|
||||
(bounds.outHeight / sampleSize) > EMBEDDED_COVER_MAX_DIMENSION
|
||||
) {
|
||||
sampleSize *= 2
|
||||
}
|
||||
|
||||
val decoded = BitmapFactory.decodeByteArray(
|
||||
bytes,
|
||||
0,
|
||||
bytes.size,
|
||||
BitmapFactory.Options().apply { inSampleSize = sampleSize }
|
||||
) ?: return@withContext null
|
||||
|
||||
try {
|
||||
return@withContext saveCoverToCache(decoded, uri)
|
||||
} finally {
|
||||
decoded.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveEmbeddedCoverBytesToCache(bytes: ByteArray, uri: Uri, extension: String): String? {
|
||||
val cacheDir = getCoverCacheDirInternal()
|
||||
val filename = "cover_${uri.toString().hashCode()}.$extension"
|
||||
val file = File(cacheDir, filename)
|
||||
return try {
|
||||
deleteCoverCacheVariants(uri)
|
||||
FileOutputStream(file).use { output -> output.write(bytes) }
|
||||
Timber.d("Saved embedded cover image to: ${file.absolutePath}")
|
||||
file.absolutePath
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to save embedded cover image to cache for $uri")
|
||||
file.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteCoverCacheVariants(uri: Uri) {
|
||||
val prefix = "cover_${uri.toString().hashCode()}."
|
||||
getCoverCacheDirInternal().listFiles()
|
||||
?.filter { it.isFile && it.name.startsWith(prefix) }
|
||||
?.forEach { runCatching { it.delete() } }
|
||||
}
|
||||
|
||||
private fun deleteCachedCover(filePath: String): Boolean {
|
||||
val file = File(filePath)
|
||||
val deleted = file.delete()
|
||||
|
|
@ -622,10 +825,11 @@ class RecentFilesRepository(private val context: Context) {
|
|||
|
||||
suspend fun clearLocalCachesForBook(bookId: String) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
recentFileDao.getFileByBookId(bookId)?.coverImagePath?.let { deleteCachedCover(it) }
|
||||
pdfRichTextRepository.getFileForSync(bookId).delete()
|
||||
pageLayoutRepository.getLayoutFile(bookId).delete()
|
||||
ImportedFileCache.clearBookCache(context, bookId)
|
||||
Timber.d("Cleared layout and text caches for modified book: $bookId")
|
||||
Timber.d("Cleared local caches for modified book: $bookId")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error clearing caches for $bookId")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,9 +35,11 @@ import org.w3c.dom.Node
|
|||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.URLDecoder
|
||||
import java.nio.file.Paths
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
|
|
@ -78,7 +80,8 @@ class EpubParser(private val context: Context) {
|
|||
val originalBookNameHint: String,
|
||||
val parserVersion: Int,
|
||||
val parseContent: Boolean,
|
||||
val shouldUseToc: Boolean
|
||||
val shouldUseToc: Boolean,
|
||||
val sourceFingerprint: String? = null
|
||||
)
|
||||
|
||||
// EpubFile can still represent in-memory file data during initial parsing before extraction
|
||||
|
|
@ -109,6 +112,9 @@ class EpubParser(private val context: Context) {
|
|||
private const val BOOK_METADATA_FILE = "book_metadata.json"
|
||||
private const val CACHE_MANIFEST_FILE = "epub_cache_manifest.json"
|
||||
private const val EPUB_EXTRACTION_CACHE_VERSION = 1
|
||||
private const val MAX_METADATA_ENTRY_BYTES = 4 * 1024 * 1024
|
||||
private const val EPUB_COVER_MAX_DIMENSION = 1024
|
||||
private val EPUB_IMAGE_EXTENSIONS = setOf(".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")
|
||||
}
|
||||
|
||||
internal val String.decodedURL: String
|
||||
|
|
@ -185,7 +191,8 @@ class EpubParser(private val context: Context) {
|
|||
shouldUseToc: Boolean = true,
|
||||
originalBookNameHint: String = "streamed_book",
|
||||
parseContent: Boolean = true,
|
||||
extractionDirOverride: File? = null
|
||||
extractionDirOverride: File? = null,
|
||||
sourceFingerprint: String? = null
|
||||
): EpubBook {
|
||||
return withContext(Dispatchers.IO) {
|
||||
Timber.d("Parsing EPUB input stream for bookId: $bookId")
|
||||
|
|
@ -201,7 +208,8 @@ class EpubParser(private val context: Context) {
|
|||
extractionDir = activeDir,
|
||||
bookId = bookId,
|
||||
originalBookNameHint = originalBookNameHint,
|
||||
shouldUseToc = shouldUseToc
|
||||
shouldUseToc = shouldUseToc,
|
||||
sourceFingerprint = sourceFingerprint
|
||||
)?.let { cachedBook ->
|
||||
Timber.tag("FileOpenPerf").d("[EPUB] Loaded extracted book from cache | bookId=$bookId")
|
||||
return@withContext cachedBook
|
||||
|
|
@ -215,7 +223,12 @@ class EpubParser(private val context: Context) {
|
|||
tempFile.outputStream().use { output ->
|
||||
inputStream.copyTo(output)
|
||||
}
|
||||
filesMap = extractEpubContents(ZipFile(tempFile), extractionDir, parseContent)
|
||||
filesMap = extractEpubContents(
|
||||
zipFile = ZipFile(tempFile),
|
||||
extractionDir = extractionDir,
|
||||
parseContent = parseContent,
|
||||
extractImagesForMetadata = shouldDeleteExtractionDir
|
||||
)
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
|
|
@ -229,6 +242,7 @@ class EpubParser(private val context: Context) {
|
|||
bookId = bookId,
|
||||
originalBookNameHint = originalBookNameHint,
|
||||
shouldUseToc = shouldUseToc,
|
||||
sourceFingerprint = sourceFingerprint,
|
||||
book = book
|
||||
)
|
||||
}
|
||||
|
|
@ -243,7 +257,8 @@ class EpubParser(private val context: Context) {
|
|||
extractionDir: File,
|
||||
bookId: String,
|
||||
originalBookNameHint: String,
|
||||
shouldUseToc: Boolean
|
||||
shouldUseToc: Boolean,
|
||||
sourceFingerprint: String?
|
||||
): EpubBook? {
|
||||
val metadataFile = File(extractionDir, BOOK_METADATA_FILE)
|
||||
val manifestFile = File(extractionDir, CACHE_MANIFEST_FILE)
|
||||
|
|
@ -255,7 +270,8 @@ class EpubParser(private val context: Context) {
|
|||
manifest.originalBookNameHint == originalBookNameHint &&
|
||||
manifest.parserVersion == EPUB_EXTRACTION_CACHE_VERSION &&
|
||||
manifest.parseContent &&
|
||||
manifest.shouldUseToc == shouldUseToc
|
||||
manifest.shouldUseToc == shouldUseToc &&
|
||||
manifest.sourceFingerprint == sourceFingerprint
|
||||
|
||||
if (!isCompatible) {
|
||||
Timber.d("EPUB extraction cache manifest is stale for bookId=$bookId")
|
||||
|
|
@ -277,6 +293,7 @@ class EpubParser(private val context: Context) {
|
|||
bookId: String,
|
||||
originalBookNameHint: String,
|
||||
shouldUseToc: Boolean,
|
||||
sourceFingerprint: String?,
|
||||
book: EpubBook
|
||||
) {
|
||||
try {
|
||||
|
|
@ -288,7 +305,8 @@ class EpubParser(private val context: Context) {
|
|||
originalBookNameHint = originalBookNameHint,
|
||||
parserVersion = EPUB_EXTRACTION_CACHE_VERSION,
|
||||
parseContent = true,
|
||||
shouldUseToc = shouldUseToc
|
||||
shouldUseToc = shouldUseToc,
|
||||
sourceFingerprint = sourceFingerprint
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -297,22 +315,40 @@ class EpubParser(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun extractEpubContents(zipFile: ZipFile, extractionDir: File, parseContent: Boolean): Map<String, EpubFile> {
|
||||
internal fun extractEpubContents(
|
||||
zipFile: ZipFile,
|
||||
extractionDir: File,
|
||||
parseContent: Boolean,
|
||||
extractImagesForMetadata: Boolean
|
||||
): Map<String, EpubFile> {
|
||||
val filesMap = mutableMapOf<String, EpubFile>()
|
||||
zipFile.use { zf ->
|
||||
zf.entries().asSequence().filterNot { it.isDirectory }.forEach { entry ->
|
||||
val isEssential = isEssentialFile(entry.name, parseContent)
|
||||
val isImage = entry.name.matches(Regex(".*\\.(png|jpg|jpeg|gif|webp|svg)$", RegexOption.IGNORE_CASE))
|
||||
val isImage = isEpubImageFile(entry.name)
|
||||
|
||||
if (!parseContent) {
|
||||
if (isEssential || isImage) {
|
||||
val data = zf.getInputStream(entry).readBytes()
|
||||
filesMap[entry.name] = EpubFile(absPath = entry.name, data = data)
|
||||
when {
|
||||
isEssential -> {
|
||||
val data = zf.readSmallEntryBytes(entry) ?: return@forEach
|
||||
filesMap[entry.name] = EpubFile(absPath = entry.name, data = data)
|
||||
}
|
||||
isImage && extractImagesForMetadata -> {
|
||||
val outputFile = safeExtractionFile(extractionDir, entry.name)
|
||||
?: return@forEach
|
||||
outputFile.parentFile?.mkdirs()
|
||||
zf.getInputStream(entry).use { input ->
|
||||
FileOutputStream(outputFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
filesMap[entry.name] = EpubFile(absPath = entry.name, data = ByteArray(0))
|
||||
}
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val outputFile = File(extractionDir, entry.name)
|
||||
val outputFile = safeExtractionFile(extractionDir, entry.name) ?: return@forEach
|
||||
outputFile.parentFile?.mkdirs()
|
||||
zf.getInputStream(entry).use { input ->
|
||||
FileOutputStream(outputFile).use { output ->
|
||||
|
|
@ -329,6 +365,59 @@ class EpubParser(private val context: Context) {
|
|||
return filesMap
|
||||
}
|
||||
|
||||
private fun isEpubImageFile(fileName: String): Boolean {
|
||||
val lowerName = fileName.lowercase()
|
||||
return EPUB_IMAGE_EXTENSIONS.any { lowerName.endsWith(it) }
|
||||
}
|
||||
|
||||
private fun safeExtractionFile(extractionDir: File, entryName: String): File? {
|
||||
val outputFile = File(extractionDir, entryName)
|
||||
val root = extractionDir.canonicalFile
|
||||
val target = outputFile.canonicalFile
|
||||
val rootPath = root.path
|
||||
val targetPath = target.path
|
||||
val isInsideRoot = targetPath == rootPath || targetPath.startsWith(rootPath + File.separator)
|
||||
|
||||
if (!isInsideRoot) {
|
||||
Timber.w("Skipping unsafe EPUB entry outside extraction root: $entryName")
|
||||
return null
|
||||
}
|
||||
|
||||
return outputFile
|
||||
}
|
||||
|
||||
private fun ZipFile.readSmallEntryBytes(entry: ZipEntry): ByteArray? {
|
||||
if (entry.size > MAX_METADATA_ENTRY_BYTES.toLong()) {
|
||||
Timber.w("Skipping oversized EPUB metadata entry: ${entry.name} (${entry.size} bytes)")
|
||||
return null
|
||||
}
|
||||
|
||||
val initialSize = entry.size
|
||||
.takeIf { it in 0..MAX_METADATA_ENTRY_BYTES.toLong() }
|
||||
?.toInt()
|
||||
?: DEFAULT_BUFFER_SIZE
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var totalBytes = 0
|
||||
|
||||
return getInputStream(entry).use { input ->
|
||||
ByteArrayOutputStream(initialSize).use { output ->
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) break
|
||||
|
||||
totalBytes += read
|
||||
if (totalBytes > MAX_METADATA_ENTRY_BYTES) {
|
||||
Timber.w("Skipping oversized EPUB metadata entry while reading: ${entry.name}")
|
||||
return null
|
||||
}
|
||||
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
output.toByteArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun parseAndCreateEbook(
|
||||
filesContentMap: Map<String, EpubFile>,
|
||||
document: EpubDocument,
|
||||
|
|
@ -712,8 +801,6 @@ class EpubParser(private val context: Context) {
|
|||
filesContentMap: Map<String, EpubFile>,
|
||||
@Suppress("UNUSED_PARAMETER") extractionRoot: File
|
||||
): List<EpubImage> {
|
||||
val imageExtensions = setOf(".png", ".gif", ".jpg", ".jpeg", ".webp", ".svg")
|
||||
|
||||
val listedImages = manifestItems.values
|
||||
.filter { it.mediaType.startsWith("image/") }
|
||||
.map { manifestItem ->
|
||||
|
|
@ -725,7 +812,7 @@ class EpubParser(private val context: Context) {
|
|||
val unlistedImages = filesContentMap.keys
|
||||
.filter { path ->
|
||||
val lowerPath = path.lowercase()
|
||||
imageExtensions.any { lowerPath.endsWith(it) } && !listedPaths.contains(path)
|
||||
EPUB_IMAGE_EXTENSIONS.any { lowerPath.endsWith(it) } && !listedPaths.contains(path)
|
||||
}
|
||||
.map { path ->
|
||||
EpubImage(absPath = path)
|
||||
|
|
@ -743,11 +830,9 @@ class EpubParser(private val context: Context) {
|
|||
): Bitmap? {
|
||||
val coverManifestItem = manifestItems[metadataCoverId]
|
||||
if (coverManifestItem != null) {
|
||||
val coverImageBytes = filesContentMap[coverManifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
|
||||
?: File(extractionRoot, coverManifestItem.absPath).takeIf { it.exists() }?.readBytes()
|
||||
|
||||
if (coverImageBytes != null) {
|
||||
return BitmapFactory.decodeByteArray(coverImageBytes, 0, coverImageBytes.size)
|
||||
val coverImage = decodeEpubImage(coverManifestItem.absPath, filesContentMap, extractionRoot)
|
||||
if (coverImage != null) {
|
||||
return coverImage
|
||||
} else {
|
||||
Timber.e("Cover image file content not found for path: ${coverManifestItem.absPath}")
|
||||
}
|
||||
|
|
@ -768,21 +853,15 @@ class EpubParser(private val context: Context) {
|
|||
)
|
||||
for (path in possiblePaths) {
|
||||
if (filesContentMap.containsKey(path)) {
|
||||
val bytes = filesContentMap[path]?.data?.takeIf { it.isNotEmpty() }
|
||||
?: File(extractionRoot, path).takeIf { it.exists() }?.readBytes()
|
||||
|
||||
bytes?.let {
|
||||
decodeEpubImage(path, filesContentMap, extractionRoot)?.let {
|
||||
Timber.d("Found fallback cover image at $path")
|
||||
return BitmapFactory.decodeByteArray(it, 0, it.size)
|
||||
return it
|
||||
}
|
||||
}
|
||||
manifestItems.values.find { item -> item.absPath.equals(path, ignoreCase = true) && item.mediaType.startsWith("image/") }?.let { manifestItem ->
|
||||
val bytes = filesContentMap[manifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
|
||||
?: File(extractionRoot, manifestItem.absPath).takeIf { it.exists() }?.readBytes()
|
||||
|
||||
bytes?.let {
|
||||
decodeEpubImage(manifestItem.absPath, filesContentMap, extractionRoot)?.let {
|
||||
Timber.d("Found fallback cover image via manifest item (case-insensitive) at ${manifestItem.absPath}")
|
||||
return BitmapFactory.decodeByteArray(it, 0, it.size)
|
||||
return it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -791,6 +870,53 @@ class EpubParser(private val context: Context) {
|
|||
return null
|
||||
}
|
||||
|
||||
private fun decodeEpubImage(
|
||||
path: String,
|
||||
filesContentMap: Map<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 {
|
||||
val lowerName = fileName.lowercase()
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import android.content.Intent
|
|||
import android.graphics.Color
|
||||
import android.graphics.Rect
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
|
|
@ -90,6 +91,34 @@ import java.io.BufferedReader
|
|||
import java.io.InputStreamReader
|
||||
|
||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||
private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
|
||||
"PageInfoReporter",
|
||||
"ProgressReporter",
|
||||
"ContentBridge",
|
||||
"HighlightBridge",
|
||||
"AutoScrollBridge",
|
||||
"CfiBridge",
|
||||
"SnippetBridge",
|
||||
"TtsBridge",
|
||||
"AiBridge",
|
||||
"FootnoteBridge",
|
||||
"LinkNavBridge"
|
||||
)
|
||||
|
||||
private fun WebView.releaseReaderResources() {
|
||||
try {
|
||||
stopLoading()
|
||||
READER_WEB_VIEW_JS_INTERFACES.forEach { removeJavascriptInterface(it) }
|
||||
webChromeClient = null
|
||||
webViewClient = WebViewClient()
|
||||
loadDataWithBaseURL(null, "", "text/html", "UTF-8", null)
|
||||
clearHistory()
|
||||
removeAllViews()
|
||||
destroy()
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Failed to fully release EPUB WebView resources")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFontCssInjection(): String {
|
||||
return """
|
||||
|
|
@ -314,12 +343,14 @@ class FootnoteJsBridge(
|
|||
|
||||
@Suppress("unused")
|
||||
class LinkNavJsBridge(
|
||||
private val currentChapterTitle: String
|
||||
private val currentChapterTitle: String,
|
||||
private val onInternalLinkClick: (String) -> Unit
|
||||
) {
|
||||
@JavascriptInterface
|
||||
fun onLinkClicked(href: String, epubType: String, linkText: String) {
|
||||
Timber.tag(TAG_LINK_NAV)
|
||||
.d("[JS-CLICK] href='$href', epub:type='$epubType', label='$linkText' | currentChapter='$currentChapterTitle'")
|
||||
onInternalLinkClick(href)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -384,6 +415,7 @@ fun ChapterWebView(
|
|||
activeHighlightPalette: List<HighlightColor>,
|
||||
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
||||
onInternalLinkClick: (String) -> Unit,
|
||||
onWebViewDisposed: (WebView) -> Unit = {},
|
||||
activeTextureId: String? = null,
|
||||
activeTextureAlpha: Float = 0.55f
|
||||
) {
|
||||
|
|
@ -554,7 +586,7 @@ fun ChapterWebView(
|
|||
}, "AutoScrollBridge"
|
||||
)
|
||||
|
||||
webChromeClient = object : android.webkit.WebChromeClient() {
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
|
||||
consoleMessage?.let {
|
||||
val message = it.message()
|
||||
|
|
@ -668,7 +700,9 @@ fun ChapterWebView(
|
|||
)
|
||||
|
||||
addJavascriptInterface(
|
||||
LinkNavJsBridge(chapterTitle), "LinkNavBridge"
|
||||
LinkNavJsBridge(chapterTitle) { href ->
|
||||
this.post { onInternalLinkClick(href) }
|
||||
}, "LinkNavBridge"
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
|
|
@ -744,6 +778,37 @@ fun ChapterWebView(
|
|||
null
|
||||
)
|
||||
|
||||
view?.evaluateJavascript(
|
||||
"""
|
||||
javascript:(function() {
|
||||
if (window.__readerInternalLinkBridgeInstalled) return;
|
||||
window.__readerInternalLinkBridgeInstalled = true;
|
||||
document.addEventListener('click', function(event) {
|
||||
var target = event.target;
|
||||
var anchor = target && target.closest ? target.closest('a[href]') : null;
|
||||
if (!anchor && target && target.parentElement && target.parentElement.closest) {
|
||||
anchor = target.parentElement.closest('a[href]');
|
||||
}
|
||||
if (!anchor) return;
|
||||
var rawHref = anchor.getAttribute('href') || '';
|
||||
if (!rawHref) return;
|
||||
if (/^(https?:|mailto:|tel:|javascript:)/i.test(rawHref)) return;
|
||||
if (/^\/\//.test(rawHref)) return;
|
||||
event.preventDefault();
|
||||
var resolvedHref = anchor.href || rawHref;
|
||||
if (window.LinkNavBridge && window.LinkNavBridge.onLinkClicked) {
|
||||
window.LinkNavBridge.onLinkClicked(
|
||||
resolvedHref,
|
||||
anchor.getAttribute('epub:type') || '',
|
||||
anchor.textContent || ''
|
||||
);
|
||||
}
|
||||
}, true);
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null
|
||||
)
|
||||
|
||||
view?.evaluateJavascript(
|
||||
"javascript:window.HighlightBridgeHelper.restoreHighlights('${
|
||||
escapeJsString(
|
||||
|
|
@ -907,10 +972,19 @@ fun ChapterWebView(
|
|||
loadDataWithBaseURL(baseUrl, initialHtmlContent, "text/html", "UTF-8", null)
|
||||
}
|
||||
webView
|
||||
}, update = { webView ->
|
||||
Timber.d(
|
||||
"WebView update. Setting Font: ${currentFontFamily.fontFamilyName}"
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onRelease = { releasedWebView ->
|
||||
if (localWebViewRef === releasedWebView) {
|
||||
localWebViewRef = null
|
||||
}
|
||||
customMenuState?.finishActionModeCallback?.invoke()
|
||||
customMenuState = null
|
||||
onWebViewDisposed(releasedWebView)
|
||||
releasedWebView.releaseReaderResources()
|
||||
},
|
||||
update = { webView ->
|
||||
Timber.d("WebView update. Setting Font: ${currentFontFamily.fontFamilyName}")
|
||||
localWebViewRef = webView
|
||||
onWebViewInstanceCreated(webView)
|
||||
val fontCss = getFontCssInjection().replace("\n", " ")
|
||||
|
|
@ -946,7 +1020,7 @@ fun ChapterWebView(
|
|||
"javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);",
|
||||
null
|
||||
)
|
||||
}, modifier = Modifier.fillMaxSize()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import androidx.compose.foundation.shape.CircleShape
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
|
|
@ -88,6 +89,7 @@ import androidx.compose.material.icons.filled.PlayArrow
|
|||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.ScreenRotation
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.SwapHoriz
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
|
|
@ -104,6 +106,7 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
|
|
@ -166,8 +169,9 @@ enum class ReaderTool(val title: String, val category: String) {
|
|||
PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"),
|
||||
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
||||
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
||||
SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
|
||||
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
||||
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
|
||||
TTS_SETTINGS("TTS Settings", "Overflow Menu"),
|
||||
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu")
|
||||
}
|
||||
|
||||
|
|
@ -258,7 +262,8 @@ private val epubToolbarTools = setOf(
|
|||
ReaderTool.FORMAT,
|
||||
ReaderTool.SEARCH,
|
||||
ReaderTool.AI_FEATURES,
|
||||
ReaderTool.TTS_CONTROLS
|
||||
ReaderTool.TTS_CONTROLS,
|
||||
ReaderTool.SCREEN_ORIENTATION
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
@ -272,6 +277,7 @@ fun EpubReaderTopBar(
|
|||
tapToNavigateEnabled: Boolean,
|
||||
volumeScrollEnabled: Boolean,
|
||||
isPageTurnAnimationEnabled: Boolean,
|
||||
isRightToLeftPagination: Boolean,
|
||||
onNavigateBack: () -> Unit,
|
||||
isKeepScreenOn: Boolean,
|
||||
onToggleKeepScreenOn: (Boolean) -> Unit,
|
||||
|
|
@ -281,12 +287,14 @@ fun EpubReaderTopBar(
|
|||
onToggleTapToNavigate: (Boolean) -> Unit,
|
||||
onToggleVolumeScroll: (Boolean) -> Unit,
|
||||
onTogglePageTurnAnimation: (Boolean) -> Unit,
|
||||
onSetRightToLeftPagination: (Boolean) -> Unit,
|
||||
onStartAutoScroll: () -> Unit,
|
||||
onOpenTtsSettings: () -> Unit,
|
||||
onOpenTtsReplacements: () -> Unit,
|
||||
onOpenDictionarySettings: () -> Unit,
|
||||
onOpenThemeSettings: () -> Unit,
|
||||
onOpenVisualOptions: () -> Unit,
|
||||
onOpenScreenOrientation: () -> Unit,
|
||||
onOpenSlider: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onToggleFormat: () -> Unit,
|
||||
|
|
@ -414,17 +422,32 @@ fun EpubReaderTopBar(
|
|||
tint = if (isTtsActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
ReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
||||
text = stringResource(R.string.menu_screen_orientation),
|
||||
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
||||
onClick = onOpenScreenOrientation
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ScreenRotation,
|
||||
contentDescription = stringResource(R.string.menu_screen_orientation),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
Box {
|
||||
var showMoreMenu by remember { mutableStateOf(false) }
|
||||
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
|
||||
var showReadingModeExpanded by remember { mutableStateOf(false) }
|
||||
var showTtsSettingsExpanded by remember { mutableStateOf(false) }
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_more_options),
|
||||
description = stringResource(R.string.tooltip_more_options_desc),
|
||||
onClick = {
|
||||
showHiddenToolsExpanded = false
|
||||
showReadingModeExpanded = false
|
||||
showTtsSettingsExpanded = false
|
||||
showMoreMenu = true
|
||||
}
|
||||
) {
|
||||
|
|
@ -435,6 +458,8 @@ fun EpubReaderTopBar(
|
|||
expanded = showMoreMenu,
|
||||
onDismissRequest = {
|
||||
showHiddenToolsExpanded = false
|
||||
showReadingModeExpanded = false
|
||||
showTtsSettingsExpanded = false
|
||||
showMoreMenu = false
|
||||
}
|
||||
) {
|
||||
|
|
@ -480,7 +505,8 @@ fun EpubReaderTopBar(
|
|||
onToggleFormat = onToggleFormat,
|
||||
onToggleSearch = onToggleSearch,
|
||||
onOpenAiHub = onOpenAiHub,
|
||||
onToggleTts = onToggleTts
|
||||
onToggleTts = onToggleTts,
|
||||
onOpenScreenOrientation = onOpenScreenOrientation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -528,31 +554,63 @@ fun EpubReaderTopBar(
|
|||
|
||||
if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||
enabled = !isTtsActive,
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
|
||||
},
|
||||
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
|
||||
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
|
||||
trailingIcon = {
|
||||
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = stringResource(R.string.content_desc_selected)
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
|
||||
)
|
||||
})
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||
enabled = !isTtsActive,
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
onChangeRenderMode(RenderMode.PAGINATED)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (currentRenderMode == RenderMode.PAGINATED) Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = stringResource(R.string.content_desc_selected)
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
if (showReadingModeExpanded) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||
enabled = !isTtsActive,
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = stringResource(R.string.content_desc_selected)
|
||||
)
|
||||
})
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||
enabled = !isTtsActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(false)
|
||||
showMoreMenu = false
|
||||
onChangeRenderMode(RenderMode.PAGINATED)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (currentRenderMode == RenderMode.PAGINATED && !isRightToLeftPagination) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = stringResource(R.string.content_desc_selected)
|
||||
)
|
||||
}
|
||||
})
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
|
||||
enabled = !isTtsActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(true)
|
||||
showMoreMenu = false
|
||||
onChangeRenderMode(RenderMode.PAGINATED)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (currentRenderMode == RenderMode.PAGINATED && isRightToLeftPagination) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = stringResource(R.string.content_desc_selected)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) {
|
||||
|
|
@ -664,39 +722,62 @@ fun EpubReaderTopBar(
|
|||
})
|
||||
HorizontalDivider()
|
||||
}
|
||||
if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) {
|
||||
val showTtsVoiceSettings = !hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)
|
||||
val showTtsReplacements = !hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)
|
||||
if (showTtsVoiceSettings || showTtsReplacements) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||
enabled = !isTtsActive,
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
onOpenTtsSettings()
|
||||
},
|
||||
text = { Text(stringResource(R.string.menu_tts_settings)) },
|
||||
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.GraphicEq,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
if (!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
onOpenTtsReplacements()
|
||||
},
|
||||
leadingIcon = {
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.GraphicEq,
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showTtsSettingsExpanded) {
|
||||
if (showTtsVoiceSettings) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||
enabled = !isTtsActive,
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
onOpenTtsSettings()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.GraphicEq,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
if (showTtsReplacements) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
onOpenTtsReplacements()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.GraphicEq,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -706,6 +787,89 @@ fun EpubReaderTopBar(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EpubJumpHistoryBar(
|
||||
modifier: Modifier = Modifier,
|
||||
showStandardBars: Boolean,
|
||||
searchStateActive: Boolean,
|
||||
backLabel: String?,
|
||||
forwardLabel: String?,
|
||||
onBack: () -> Unit,
|
||||
onForward: () -> Unit,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = showStandardBars && !searchStateActive && (backLabel != null || forwardLabel != null),
|
||||
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
|
||||
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
|
||||
modifier = modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
tonalElevation = 3.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onBack,
|
||||
enabled = backLabel != null,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.content_desc_jump_back),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = backLabel.orEmpty(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onClear,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.action_clear),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.action_clear), maxLines = 1)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onForward,
|
||||
enabled = forwardLabel != null,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = forwardLabel.orEmpty(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = stringResource(R.string.content_desc_jump_forward),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun EpubReaderBottomBar(
|
||||
|
|
@ -723,6 +887,7 @@ fun EpubReaderBottomBar(
|
|||
onOpenDictionarySettings: () -> Unit,
|
||||
onOpenThemeSettings: () -> Unit,
|
||||
onToggleTts: () -> Unit,
|
||||
onOpenScreenOrientation: () -> Unit,
|
||||
hiddenTools: Set<String>,
|
||||
toolOrder: List<ReaderTool>,
|
||||
bottomTools: Set<String>,
|
||||
|
|
@ -835,6 +1000,16 @@ fun EpubReaderBottomBar(
|
|||
tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
ReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
||||
text = stringResource(R.string.menu_screen_orientation),
|
||||
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
||||
onClick = onOpenScreenOrientation
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ScreenRotation,
|
||||
contentDescription = stringResource(R.string.menu_screen_orientation)
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
|
@ -1849,6 +2024,7 @@ private fun ToolPreviewIcon(tool: ReaderTool) {
|
|||
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
|
@ -1866,7 +2042,8 @@ private fun HiddenEpubToolMenuItem(
|
|||
onToggleFormat: () -> Unit,
|
||||
onToggleSearch: () -> Unit,
|
||||
onOpenAiHub: () -> Unit,
|
||||
onToggleTts: () -> Unit
|
||||
onToggleTts: () -> Unit,
|
||||
onOpenScreenOrientation: () -> Unit
|
||||
) {
|
||||
val enabled = when (tool) {
|
||||
ReaderTool.SLIDER -> currentRenderMode != RenderMode.VERTICAL_SCROLL
|
||||
|
|
@ -1886,6 +2063,7 @@ private fun HiddenEpubToolMenuItem(
|
|||
ReaderTool.SEARCH -> onToggleSearch()
|
||||
ReaderTool.AI_FEATURES -> onOpenAiHub()
|
||||
ReaderTool.TTS_CONTROLS -> onToggleTts()
|
||||
ReaderTool.SCREEN_ORIENTATION -> onOpenScreenOrientation()
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
|
|
|
|||
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.ActionMode
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.MenuInflater
|
||||
import android.webkit.WebView
|
||||
import android.graphics.Rect
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.widget.PopupMenu
|
||||
import org.json.JSONObject
|
||||
|
||||
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
|
||||
|
|
@ -59,7 +60,143 @@ class InteractiveWebView(
|
|||
|
||||
private val scrollStopHandler = Handler(Looper.getMainLooper())
|
||||
private var scrollStopRunnable: Runnable? = null
|
||||
private var mCustomCallback: ActionMode.Callback? = null
|
||||
private var activeSelectionActionMode: ActionMode? = null
|
||||
|
||||
private fun clearPendingSelectionWork() {
|
||||
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
||||
scrollStopRunnable = null
|
||||
}
|
||||
|
||||
private fun startLocalSelectionActionMode(): ActionMode {
|
||||
activeSelectionActionMode?.let { existingMode ->
|
||||
showCustomSelectionMenuFromCurrentSelection(existingMode)
|
||||
return existingMode
|
||||
}
|
||||
|
||||
lateinit var localMode: ActionMode
|
||||
localMode = LocalSelectionActionMode(this) {
|
||||
if (activeSelectionActionMode === localMode) {
|
||||
activeSelectionActionMode = null
|
||||
}
|
||||
onHideCustomSelectionMenu()
|
||||
}
|
||||
activeSelectionActionMode = localMode
|
||||
showCustomSelectionMenuFromCurrentSelection(localMode)
|
||||
return localMode
|
||||
}
|
||||
|
||||
private fun finishLocalSelectionActionMode() {
|
||||
activeSelectionActionMode?.finish()
|
||||
activeSelectionActionMode = null
|
||||
}
|
||||
|
||||
private fun showCustomSelectionMenuFromCurrentSelection(mode: ActionMode) {
|
||||
val jsToGetSelectionDetails = """
|
||||
(function() {
|
||||
var selection = window.getSelection();
|
||||
var selectedText = selection.toString().trim();
|
||||
if (selectedText.length === 0 || selection.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
var range = selection.getRangeAt(0);
|
||||
var rect = range.getBoundingClientRect();
|
||||
|
||||
// If getBoundingClientRect returns all zeros, try getClientRects()
|
||||
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
|
||||
var clientRects = range.getClientRects();
|
||||
if (clientRects.length > 0) {
|
||||
rect = clientRects[0]; // Use the first rect
|
||||
} else {
|
||||
return null; // No valid rect found
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the rect has some dimension
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
text: selectedText,
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
});
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
|
||||
if (activeSelectionActionMode !== mode) {
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
|
||||
Timber.d("CustomSelection: JS returned null or invalid for selection details.")
|
||||
mode.finish()
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
try {
|
||||
val unquotedJsonResult = jsonResult.removeSurrounding("\"")
|
||||
.replace("\\\"", "\"")
|
||||
.replace("\\\\", "\\")
|
||||
|
||||
val selectionDetails = JSONObject(unquotedJsonResult)
|
||||
val selectedText = selectionDetails.getString("text")
|
||||
|
||||
if (selectedText.isBlank()) {
|
||||
Timber.d("CustomSelection: Selected text is blank after JS processing.")
|
||||
mode.finish()
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
val jsLeft = selectionDetails.getDouble("left")
|
||||
val jsTop = selectionDetails.getDouble("top")
|
||||
val jsRight = selectionDetails.getDouble("right")
|
||||
val jsBottom = selectionDetails.getDouble("bottom")
|
||||
val jsWidth = selectionDetails.getDouble("width")
|
||||
val jsHeight = selectionDetails.getDouble("height")
|
||||
|
||||
if (jsWidth == 0.0 && jsHeight == 0.0) {
|
||||
Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop")
|
||||
mode.finish()
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
val density = context.resources.displayMetrics.density
|
||||
|
||||
val webViewLocation = IntArray(2)
|
||||
getLocationOnScreen(webViewLocation)
|
||||
val webViewX = webViewLocation[0]
|
||||
val webViewY = webViewLocation[1]
|
||||
|
||||
val selectionRectScreen = Rect(
|
||||
(webViewX + jsLeft * density).toInt(),
|
||||
(webViewY + jsTop * density).toInt(),
|
||||
(webViewX + jsRight * density).toInt(),
|
||||
(webViewY + jsBottom * density).toInt()
|
||||
)
|
||||
|
||||
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
|
||||
Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
|
||||
mode.finish()
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
|
||||
|
||||
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
|
||||
mode.finish()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
|
||||
mode.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val gestureDetector =
|
||||
GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||
|
|
@ -167,148 +304,12 @@ class InteractiveWebView(
|
|||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
// MIUI can crash inside FloatingToolbar when WindowInsets are null, so WebView
|
||||
// selections use the app's Compose popup without starting the platform toolbar.
|
||||
override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? {
|
||||
if (type == ActionMode.TYPE_FLOATING) {
|
||||
if (mCustomCallback == null) {
|
||||
mCustomCallback = object : ActionMode.Callback2() {
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
Timber.d("CustomSelection: onCreateActionMode")
|
||||
menu.clear()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
Timber.d("CustomSelection: onPrepareActionMode")
|
||||
menu.clear()
|
||||
|
||||
val jsToGetSelectionDetails = """
|
||||
(function() {
|
||||
var selection = window.getSelection();
|
||||
var selectedText = selection.toString().trim();
|
||||
if (selectedText.length === 0 || selection.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
var range = selection.getRangeAt(0);
|
||||
var rect = range.getBoundingClientRect();
|
||||
|
||||
// If getBoundingClientRect returns all zeros, try getClientRects()
|
||||
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
|
||||
var clientRects = range.getClientRects();
|
||||
if (clientRects.length > 0) {
|
||||
rect = clientRects[0]; // Use the first rect
|
||||
} else {
|
||||
return null; // No valid rect found
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the rect has some dimension
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
text: selectedText,
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
});
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
this@InteractiveWebView.evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
|
||||
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
|
||||
Timber.d("CustomSelection: JS returned null or invalid for selection details.")
|
||||
onHideCustomSelectionMenu()
|
||||
mode.finish()
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
try {
|
||||
val unquotedJsonResult = jsonResult.removeSurrounding("\"")
|
||||
.replace("\\\"", "\"")
|
||||
.replace("\\\\", "\\")
|
||||
|
||||
val selectionDetails = JSONObject(unquotedJsonResult)
|
||||
val selectedText = selectionDetails.getString("text")
|
||||
|
||||
if (selectedText.isBlank()) {
|
||||
Timber.d("CustomSelection: Selected text is blank after JS processing.")
|
||||
onHideCustomSelectionMenu()
|
||||
mode.finish()
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
val jsLeft = selectionDetails.getDouble("left")
|
||||
val jsTop = selectionDetails.getDouble("top")
|
||||
val jsRight = selectionDetails.getDouble("right")
|
||||
val jsBottom = selectionDetails.getDouble("bottom")
|
||||
val jsWidth = selectionDetails.getDouble("width")
|
||||
val jsHeight = selectionDetails.getDouble("height")
|
||||
|
||||
if (jsWidth == 0.0 && jsHeight == 0.0) {
|
||||
Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop")
|
||||
onHideCustomSelectionMenu()
|
||||
mode.finish()
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
val density = context.resources.displayMetrics.density
|
||||
|
||||
val webViewLocation = IntArray(2)
|
||||
this@InteractiveWebView.getLocationOnScreen(webViewLocation)
|
||||
val webViewX = webViewLocation[0]
|
||||
val webViewY = webViewLocation[1]
|
||||
|
||||
val selectionRectScreen = Rect(
|
||||
(webViewX + jsLeft * density).toInt(),
|
||||
(webViewY + jsTop * density).toInt(),
|
||||
(webViewX + jsRight * density).toInt(),
|
||||
(webViewY + jsBottom * density).toInt()
|
||||
)
|
||||
|
||||
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
|
||||
Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
|
||||
onHideCustomSelectionMenu()
|
||||
mode.finish()
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
|
||||
|
||||
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
|
||||
mode.finish()
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
|
||||
onHideCustomSelectionMenu()
|
||||
mode.finish()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||
Timber.d("CustomSelection: onActionItemClicked (should not be called as menu is empty)")
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode) {
|
||||
Timber.d("CustomSelection: onDestroyActionMode for mode: $mode")
|
||||
onHideCustomSelectionMenu()
|
||||
}
|
||||
|
||||
override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) {
|
||||
super.onGetContentRect(mode, view, outRect)
|
||||
Timber.d("CustomSelection: onGetContentRect called by system. outRect: $outRect")
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.startActionMode(mCustomCallback, type)
|
||||
Timber.d("CustomSelection: handling floating action mode locally.")
|
||||
return startLocalSelectionActionMode()
|
||||
}
|
||||
return super.startActionMode(originalCallback, type)
|
||||
}
|
||||
|
|
@ -316,18 +317,79 @@ class InteractiveWebView(
|
|||
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
|
||||
super.onScrollChanged(l, t, oldl, oldt)
|
||||
|
||||
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
||||
clearPendingSelectionWork()
|
||||
scrollStopRunnable = Runnable {
|
||||
evaluateJavascript("(function() { return window.getSelection().toString(); })();") { result ->
|
||||
val selectedText = result?.removeSurrounding("\"")
|
||||
if (!selectedText.isNullOrBlank()) {
|
||||
Timber.d("Selection exists after scroll. Restarting action mode.")
|
||||
mCustomCallback?.let {
|
||||
startActionMode(it, ActionMode.TYPE_FLOATING)
|
||||
}
|
||||
startLocalSelectionActionMode()
|
||||
}
|
||||
}
|
||||
}
|
||||
scrollStopRunnable?.let { scrollStopHandler.postDelayed(it, 250) }
|
||||
}
|
||||
}
|
||||
|
||||
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.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
|
|
@ -76,6 +78,7 @@ private const val PRIORITY_HIGHEST = 0
|
|||
private const val PRIORITY_HIGH = 1
|
||||
private const val PRIORITY_MEDIUM = 2
|
||||
private const val PRIORITY_LOW = 3
|
||||
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
|
||||
|
||||
data class TimedWord(val word: String, val startTime: Double, val startOffset: Int)
|
||||
|
||||
|
|
@ -187,6 +190,7 @@ class BookPaginator(
|
|||
|
||||
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
|
||||
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
|
||||
private val chapterPaginationLocks = ConcurrentHashMap<Int, Mutex>()
|
||||
private val navigationCallbacks = ConcurrentHashMap<Int, MutableList<(List<Page>) -> Unit>>()
|
||||
private var paginationWorker: Job? = null
|
||||
|
||||
|
|
@ -430,9 +434,9 @@ class BookPaginator(
|
|||
Timber.w("Page cache count mismatch for chapter $chapterIndex. Ignoring cached pages.")
|
||||
null
|
||||
} else {
|
||||
pageCache.put(chapterIndex, pages)
|
||||
applyPageRuntimeIndexes(chapterIndex, pages)
|
||||
updatePageCountsOnMain(chapterIndex, pages.size)
|
||||
pageCache.put(chapterIndex, pages)
|
||||
Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.")
|
||||
pages
|
||||
}
|
||||
|
|
@ -574,8 +578,86 @@ class BookPaginator(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureChapterPaginated(chapterIndex: Int): List<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>? {
|
||||
val pages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
|
||||
val pages = ensureChapterPaginated(chapterIndex)
|
||||
if (pages.isNullOrEmpty()) {
|
||||
Timber.w("PAGINATOR: Chapter $chapterIndex has no pages or could not be paginated.")
|
||||
return null
|
||||
|
|
@ -794,7 +876,7 @@ class BookPaginator(
|
|||
}
|
||||
|
||||
Timber.i("Worker: Starting pagination for chapter $chapterIndex.")
|
||||
val pages = paginateChapter(chapterIndex)
|
||||
val pages = ensureChapterPaginated(chapterIndex)
|
||||
|
||||
if (pages != null) {
|
||||
Timber.i("Worker: Successfully finished pagination for chapter $chapterIndex.")
|
||||
|
|
@ -836,6 +918,9 @@ class BookPaginator(
|
|||
val difference = actualPageCount - estimatedPageCount
|
||||
|
||||
if (difference == 0) {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"page_count_noop chapter=$chapterIndex count=$actualPageCount currentUserChapter=${currentUserChapterIndex.value}"
|
||||
)
|
||||
if (!pageCountsAreAccurate && finalizedChapterCounts.add(chapterIndex)) {
|
||||
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
|
||||
}
|
||||
|
|
@ -854,8 +939,16 @@ class BookPaginator(
|
|||
}
|
||||
rebuildChapterStartSnapshot()
|
||||
|
||||
if (chapterIndex < currentUserChapterIndex.value) {
|
||||
pageShiftRequest.tryEmit(difference)
|
||||
val currentUserChapter = currentUserChapterIndex.value
|
||||
val shouldShiftCurrentPage = chapterIndex < currentUserChapter
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"page_count_update chapter=$chapterIndex estimated=$estimatedPageCount actual=$actualPageCount diff=$difference currentUserChapter=$currentUserChapter shiftCurrent=$shouldShiftCurrentPage total=$totalPageCount"
|
||||
)
|
||||
if (shouldShiftCurrentPage) {
|
||||
val emitted = pageShiftRequest.tryEmit(difference)
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"page_shift_emit chapter=$chapterIndex diff=$difference currentUserChapter=$currentUserChapter emitted=$emitted"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1028,13 +1121,12 @@ class BookPaginator(
|
|||
)
|
||||
Timber.d("paginateChapter: PaginatorLogic returned ${pages.size} pages for chapter $chapterIndex.")
|
||||
|
||||
pageCache.put(chapterIndex, pages)
|
||||
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
|
||||
|
||||
applyPageRuntimeIndexes(chapterIndex, pages)
|
||||
savePageCacheAsync(chapter, chapterIndex, pages)
|
||||
|
||||
updatePageCountsOnMain(chapterIndex, pages.size)
|
||||
pageCache.put(chapterIndex, pages)
|
||||
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
|
||||
return pages
|
||||
}
|
||||
|
||||
|
|
@ -1105,12 +1197,68 @@ class BookPaginator(
|
|||
return Jsoup.parse(htmlToParse).body().text()
|
||||
}
|
||||
|
||||
private fun calculateAccurateStartIndex(targetChapterIndex: Int): Int {
|
||||
if (targetChapterIndex <= 0) {
|
||||
return 0
|
||||
suspend fun findStablePageForAnchor(chapterIndex: Int, anchor: String?): Int? = withContext(Dispatchers.IO) {
|
||||
Timber.tag("TOC_NAV_DEBUG").d("Stable precision nav request for anchor: '$anchor'")
|
||||
|
||||
if (anchor.isNullOrBlank()) {
|
||||
return@withContext findStableChapterStartPage(chapterIndex)
|
||||
}
|
||||
val startIndex = chapterStartPageIndices[targetChapterIndex] ?: 0
|
||||
return startIndex
|
||||
|
||||
// 1. QUICK LOOKUP: Check the Anchor Index first
|
||||
val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor)
|
||||
|
||||
val (targetChapter, targetBlock) = if (indexEntry != null) {
|
||||
Timber.tag("TOC_NAV_DEBUG").i("Index HIT: Anchor '$anchor' is in Chapter ${indexEntry.chapterIndex}, Block ${indexEntry.blockIndex}")
|
||||
indexEntry.chapterIndex to indexEntry.blockIndex
|
||||
} else {
|
||||
Timber.tag("TOC_NAV_DEBUG").w("Index MISS: Falling back to linear scan for '$anchor' in Chapter $chapterIndex")
|
||||
chapterIndex to null
|
||||
}
|
||||
|
||||
// 2. ENSURE PAGINATION: Get pages for the determined chapter
|
||||
val chapterPages = ensureChapterPaginated(targetChapter)
|
||||
val chapterStartPage = ensureStableStartPageForChapter(targetChapter)
|
||||
|
||||
if (chapterPages == null || chapterStartPage == null) {
|
||||
Timber.e("Anchor navigation failed: Could not stabilize target chapter $targetChapter.")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val indexedPageInChapter = targetBlock?.let { blockIndex ->
|
||||
chapterPageNavigationIndex[targetChapter]
|
||||
?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex }
|
||||
?.pageInChapter
|
||||
} ?: chapterAnchorPageIndex[targetChapter]?.get(anchor)
|
||||
|
||||
if (indexedPageInChapter != null) {
|
||||
val finalPage = chapterStartPage + indexedPageInChapter
|
||||
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage")
|
||||
return@withContext finalPage
|
||||
}
|
||||
|
||||
// 3. FIND PAGE
|
||||
var targetPageInChapter = 0
|
||||
var found = false
|
||||
|
||||
for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||
val isMatch = if (targetBlock != null) {
|
||||
// Fast path: We know exactly which block we are looking for
|
||||
page.content.any { it.blockIndex == targetBlock }
|
||||
} else {
|
||||
// Slow path: Linear ID scan (fallback)
|
||||
page.content.any { containsAnchor(it, anchor) }
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
targetPageInChapter = pageIndex
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val finalPage = chapterStartPage + targetPageInChapter
|
||||
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)")
|
||||
finalPage
|
||||
}
|
||||
|
||||
override fun findPageForAnchor(
|
||||
|
|
@ -1119,70 +1267,8 @@ class BookPaginator(
|
|||
onResult: (pageIndex: Int) -> Unit
|
||||
) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Timber.tag("TOC_NAV_DEBUG").d("Precision nav request for anchor: '$anchor'")
|
||||
|
||||
if (anchor.isNullOrBlank()) {
|
||||
val start = chapterStartPageIndices[chapterIndex] ?: 0
|
||||
withContext(Dispatchers.Main) { onResult(start) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 1. QUICK LOOKUP: Check the Anchor Index first
|
||||
val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor)
|
||||
|
||||
val (targetChapter, targetBlock) = if (indexEntry != null) {
|
||||
Timber.tag("TOC_NAV_DEBUG").i("Index HIT: Anchor '$anchor' is in Chapter ${indexEntry.chapterIndex}, Block ${indexEntry.blockIndex}")
|
||||
indexEntry.chapterIndex to indexEntry.blockIndex
|
||||
} else {
|
||||
Timber.tag("TOC_NAV_DEBUG").w("Index MISS: Falling back to linear scan for '$anchor' in Chapter $chapterIndex")
|
||||
chapterIndex to null
|
||||
}
|
||||
|
||||
// 2. ENSURE PAGINATION: Get pages for the determined chapter
|
||||
val chapterPages = pageCache[targetChapter] ?: paginateChapter(targetChapter)
|
||||
val chapterStartPage = chapterStartPageIndices[targetChapter] ?: 0
|
||||
|
||||
if (chapterPages == null) {
|
||||
withContext(Dispatchers.Main) { onResult(chapterStartPage) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val indexedPageInChapter = targetBlock?.let { blockIndex ->
|
||||
chapterPageNavigationIndex[targetChapter]
|
||||
?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex }
|
||||
?.pageInChapter
|
||||
} ?: chapterAnchorPageIndex[targetChapter]?.get(anchor)
|
||||
|
||||
if (indexedPageInChapter != null) {
|
||||
val finalPage = chapterStartPage + indexedPageInChapter
|
||||
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage")
|
||||
withContext(Dispatchers.Main) { onResult(finalPage) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 3. FIND PAGE
|
||||
var targetPageInChapter = 0
|
||||
var found = false
|
||||
|
||||
for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||
val isMatch = if (targetBlock != null) {
|
||||
// Fast path: We know exactly which block we are looking for
|
||||
page.content.any { it.blockIndex == targetBlock }
|
||||
} else {
|
||||
// Slow path: Linear ID scan (fallback)
|
||||
page.content.any { containsAnchor(it, anchor) }
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
targetPageInChapter = pageIndex
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val finalPage = chapterStartPage + targetPageInChapter
|
||||
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)")
|
||||
withContext(Dispatchers.Main) { onResult(finalPage) }
|
||||
val page = findStablePageForAnchor(chapterIndex, anchor) ?: return@launch
|
||||
withContext(Dispatchers.Main) { onResult(page) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1234,69 +1320,79 @@ class BookPaginator(
|
|||
onNavigationComplete: (pageIndex: Int) -> Unit
|
||||
) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Timber.i("Navigating to href: '$href' from chapter: '$currentChapterAbsPath'")
|
||||
|
||||
val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href)
|
||||
if (targetChapterPath == null) {
|
||||
Timber.w("Could not resolve href '$href' to a valid chapter path.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath }
|
||||
if (targetChapterIndex == -1) {
|
||||
Timber.w("Could not find chapter for path: $targetChapterPath")
|
||||
return@launch
|
||||
}
|
||||
|
||||
findPageForAnchor(targetChapterIndex, anchor, onNavigationComplete)
|
||||
val targetPage = findStablePageForHref(currentChapterAbsPath, href) ?: return@launch
|
||||
withContext(Dispatchers.Main) { onNavigationComplete(targetPage) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findStablePageForHref(currentChapterAbsPath: String, href: String): Int? = withContext(Dispatchers.IO) {
|
||||
Timber.i("Navigating to href: '$href' from chapter: '$currentChapterAbsPath'")
|
||||
|
||||
val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href)
|
||||
if (targetChapterPath == null) {
|
||||
Timber.w("Could not resolve href '$href' to a valid chapter path.")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath }
|
||||
if (targetChapterIndex == -1) {
|
||||
Timber.w("Could not find chapter for path: $targetChapterPath")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
findStablePageForAnchor(targetChapterIndex, anchor)
|
||||
}
|
||||
|
||||
suspend fun findStablePageForSearchResult(result: SearchResult): Int? = withContext(Dispatchers.IO) {
|
||||
val targetChapterIndex = result.locationInSource
|
||||
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
|
||||
|
||||
val chapterPages = ensureChapterPaginated(targetChapterIndex)
|
||||
val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex)
|
||||
|
||||
if (chapterPages == null || chapterStartPage == null) {
|
||||
Timber.e("Search result navigation failed: Could not stabilize target chapter $targetChapterIndex.")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
var targetPageInChapter = 0
|
||||
var occurrenceCount = 0
|
||||
|
||||
pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||
for (block in page.content) {
|
||||
val textToSearch = when (block) {
|
||||
is ParagraphBlock -> block.content.text
|
||||
is HeaderBlock -> block.content.text
|
||||
is QuoteBlock -> block.content.text
|
||||
is ListItemBlock -> block.content.text
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (textToSearch != null) {
|
||||
var lastIndex = -1
|
||||
while (true) {
|
||||
lastIndex = textToSearch.indexOf(result.query, startIndex = lastIndex + 1, ignoreCase = true)
|
||||
if (lastIndex == -1) break
|
||||
|
||||
if (occurrenceCount == result.occurrenceIndexInLocation) {
|
||||
targetPageInChapter = pageIndex
|
||||
Timber.i("Found search result '${result.query}' at occurrence ${result.occurrenceIndexInLocation} on page $pageIndex of chapter $targetChapterIndex")
|
||||
break@pageLoop
|
||||
}
|
||||
occurrenceCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val finalPageIndex = chapterStartPage + targetPageInChapter
|
||||
Timber.i("Search result found. Final page index: $finalPageIndex")
|
||||
finalPageIndex
|
||||
}
|
||||
|
||||
override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val targetChapterIndex = result.locationInSource
|
||||
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
|
||||
|
||||
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
|
||||
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
|
||||
|
||||
if (chapterPages == null) {
|
||||
Timber.e("Search result navigation failed: Could not paginate target chapter $targetChapterIndex.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
var targetPageInChapter = 0
|
||||
var occurrenceCount = 0
|
||||
|
||||
pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||
for (block in page.content) {
|
||||
val textToSearch = when (block) {
|
||||
is ParagraphBlock -> block.content.text
|
||||
is HeaderBlock -> block.content.text
|
||||
is QuoteBlock -> block.content.text
|
||||
is ListItemBlock -> block.content.text
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (textToSearch != null) {
|
||||
var lastIndex = -1
|
||||
while (true) {
|
||||
lastIndex = textToSearch.indexOf(result.query, startIndex = lastIndex + 1, ignoreCase = true)
|
||||
if (lastIndex == -1) break
|
||||
|
||||
if (occurrenceCount == result.occurrenceIndexInLocation) {
|
||||
targetPageInChapter = pageIndex
|
||||
Timber.i("Found search result '${result.query}' at occurrence ${result.occurrenceIndexInLocation} on page $pageIndex of chapter $targetChapterIndex")
|
||||
break@pageLoop
|
||||
}
|
||||
occurrenceCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val finalPageIndex = chapterStartPage + targetPageInChapter
|
||||
Timber.i("Search result found. Final page index: $finalPageIndex")
|
||||
withContext(Dispatchers.Main) { onResult(finalPageIndex) }
|
||||
val page = findStablePageForSearchResult(result) ?: return@launch
|
||||
withContext(Dispatchers.Main) { onResult(page) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1331,20 +1427,8 @@ class BookPaginator(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun findPageForLocator(locator: Locator): Int? {
|
||||
private fun findPageInChapterForLocator(locator: Locator, chapterPages: List<Page>): Int? {
|
||||
val targetChapterIndex = locator.chapterIndex
|
||||
Timber.tag("POS_DIAG").d("findPageForLocator: Searching for $locator")
|
||||
|
||||
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
|
||||
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
|
||||
|
||||
Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}")
|
||||
|
||||
if (chapterPages.isNullOrEmpty()) {
|
||||
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
|
||||
return null
|
||||
}
|
||||
|
||||
chapterTextRangeIndex[targetChapterIndex]
|
||||
?.firstOrNull { range ->
|
||||
range.blockIndex == locator.blockIndex &&
|
||||
|
|
@ -1352,17 +1436,15 @@ class BookPaginator(
|
|||
(range.startOffset == range.endOffset && locator.charOffset == range.startOffset))
|
||||
}
|
||||
?.let { range ->
|
||||
val finalPageIndex = chapterStartPage + range.pageInChapter
|
||||
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND via runtime index on absolute page $finalPageIndex")
|
||||
return finalPageIndex
|
||||
Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND via runtime index on pageInChapter ${range.pageInChapter}")
|
||||
return range.pageInChapter
|
||||
}
|
||||
|
||||
chapterPageNavigationIndex[targetChapterIndex]
|
||||
?.firstOrNull { locator.blockIndex in it.firstBlockIndex..it.lastBlockIndex }
|
||||
?.let { entry ->
|
||||
val finalPageIndex = chapterStartPage + entry.pageInChapter
|
||||
Timber.tag("POS_DIAG").w("findPageForLocator: Using block-range fallback page $finalPageIndex")
|
||||
return finalPageIndex
|
||||
Timber.tag("POS_DIAG").w("findPageInChapterForLocator: Using block-range fallback pageInChapter ${entry.pageInChapter}")
|
||||
return entry.pageInChapter
|
||||
}
|
||||
|
||||
var fallbackPageInChapter = -1
|
||||
|
|
@ -1370,7 +1452,7 @@ class BookPaginator(
|
|||
for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||
val allTextBlocks = getAllTextBlocks(page.content)
|
||||
if (allTextBlocks.any { it.blockIndex == locator.blockIndex }) {
|
||||
Timber.tag("POS_DIAG").d("findPageForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex (Abs ${chapterStartPage + pageIndex})")
|
||||
Timber.tag("POS_DIAG").d("findPageInChapterForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex")
|
||||
}
|
||||
for (textBlock in allTextBlocks) {
|
||||
if (textBlock.blockIndex == locator.blockIndex) {
|
||||
|
|
@ -1384,15 +1466,13 @@ class BookPaginator(
|
|||
|
||||
val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage
|
||||
if (isInside) {
|
||||
val finalPageIndex = chapterStartPage + pageIndex
|
||||
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND match on absolute page $finalPageIndex")
|
||||
return finalPageIndex
|
||||
Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND match on pageInChapter $pageIndex")
|
||||
return pageIndex
|
||||
}
|
||||
|
||||
if (textBlock.content.isEmpty() && locator.charOffset == startOffsetOnPage) {
|
||||
val finalPageIndex = chapterStartPage + pageIndex
|
||||
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND empty block match on absolute page $finalPageIndex")
|
||||
return finalPageIndex
|
||||
Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND empty block match on pageInChapter $pageIndex")
|
||||
return pageIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1408,15 +1488,37 @@ class BookPaginator(
|
|||
}
|
||||
|
||||
if (fallbackPageInChapter != -1) {
|
||||
val finalPageIndex = chapterStartPage + fallbackPageInChapter
|
||||
Timber.tag("POS_DIAG").w("findPageForLocator: Exact offset not found, using block-start fallback page $finalPageIndex")
|
||||
return finalPageIndex
|
||||
Timber.tag("POS_DIAG").w("findPageInChapterForLocator: Exact offset not found, using block-start fallback pageInChapter $fallbackPageInChapter")
|
||||
return fallbackPageInChapter
|
||||
}
|
||||
|
||||
Timber.tag("POS_DIAG").e("findPageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
|
||||
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? {
|
||||
val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null
|
||||
val chStart = chapterStartPageIndices[chapterIndex] ?: 0
|
||||
|
|
@ -1472,13 +1574,12 @@ class BookPaginator(
|
|||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'")
|
||||
|
||||
val chapterPages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
|
||||
val chapterStartPage = calculateAccurateStartIndex(chapterIndex)
|
||||
val chapterPages = ensureChapterPaginated(chapterIndex)
|
||||
val chapterStartPage = ensureStableStartPageForChapter(chapterIndex)
|
||||
Timber.d("findPageForCfi: Chapter $chapterIndex starts at absolute page $chapterStartPage.")
|
||||
|
||||
if (chapterPages == null) {
|
||||
Timber.e("CFI Navigation failed: Could not paginate target chapter $chapterIndex.")
|
||||
withContext(Dispatchers.Main) { onResult(chapterStartPage) }
|
||||
if (chapterPages == null || chapterStartPage == null) {
|
||||
Timber.e("CFI Navigation failed: Could not stabilize target chapter $chapterIndex.")
|
||||
return@launch
|
||||
}
|
||||
Timber.d("findPageForCfi: Chapter $chapterIndex has ${chapterPages.size} pages.")
|
||||
|
|
|
|||
|
|
@ -433,6 +433,14 @@ class ContentStyler(
|
|||
baselineShift = baselineShift
|
||||
)
|
||||
|
||||
if (!span.linkHref.isNullOrBlank()) {
|
||||
finalSpanStyle = finalSpanStyle.withReaderLinkStyle(
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = themeBackgroundColor,
|
||||
themeTextColor = themeTextColor
|
||||
)
|
||||
}
|
||||
|
||||
val hasCustomDeco = themedSpanStyle.textDecorationStyle != null ||
|
||||
themedSpanStyle.textDecorationColor.isSpecified ||
|
||||
themedSpanStyle.textUnderlineOffset.isSpecified
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import android.webkit.JavascriptInterface
|
|||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import com.aryan.reader.BuildConfig
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
|
@ -44,6 +43,8 @@ class MathMLRenderer(private val context: Context) {
|
|||
|
||||
private var webView: WebView? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
@Volatile
|
||||
private var isDestroyed = false
|
||||
private var isMathJaxReady = false
|
||||
private val readySignal = CompletableDeferred<Boolean>()
|
||||
|
||||
|
|
@ -59,11 +60,14 @@ class MathMLRenderer(private val context: Context) {
|
|||
|
||||
init {
|
||||
handler.post {
|
||||
setupWebView()
|
||||
if (!isDestroyed) {
|
||||
setupWebView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitReady(): Boolean {
|
||||
if (isDestroyed) return false
|
||||
Timber.d("awaitReady: Waiting for WebView and MathJax initialization...")
|
||||
return withTimeoutOrNull(10_000) {
|
||||
readySignal.await()
|
||||
|
|
@ -76,9 +80,7 @@ class MathMLRenderer(private val context: Context) {
|
|||
|
||||
private fun setupWebView() {
|
||||
try {
|
||||
if (BuildConfig.DEBUG) {
|
||||
WebView.setWebContentsDebuggingEnabled(true)
|
||||
}
|
||||
if (isDestroyed) return
|
||||
|
||||
webView = WebView(context).apply {
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
|
|
@ -114,6 +116,9 @@ class MathMLRenderer(private val context: Context) {
|
|||
}
|
||||
|
||||
suspend fun render(mathML: String, originalAltText: String): RenderResult {
|
||||
if (isDestroyed) {
|
||||
return RenderResult.Failure(originalAltText)
|
||||
}
|
||||
if (!awaitReady()) {
|
||||
Timber.e("WebView is not available or failed to initialize. Failing render.")
|
||||
return RenderResult.Failure(originalAltText)
|
||||
|
|
@ -140,6 +145,10 @@ class MathMLRenderer(private val context: Context) {
|
|||
}
|
||||
|
||||
private fun processNextJob() {
|
||||
if (isDestroyed) {
|
||||
isProcessing = false
|
||||
return
|
||||
}
|
||||
synchronized(jobQueue) {
|
||||
if (jobQueue.isEmpty()) {
|
||||
isProcessing = false
|
||||
|
|
@ -153,6 +162,10 @@ class MathMLRenderer(private val context: Context) {
|
|||
}
|
||||
|
||||
private fun executeRender() {
|
||||
if (isDestroyed) {
|
||||
isProcessing = false
|
||||
return
|
||||
}
|
||||
if (!isMathJaxReady) {
|
||||
Timber.d("executeRender called but MathJax not ready yet. Retrying...")
|
||||
handler.postDelayed({ executeRender() }, 100)
|
||||
|
|
@ -208,14 +221,43 @@ class MathMLRenderer(private val context: Context) {
|
|||
}
|
||||
|
||||
fun destroy() {
|
||||
isDestroyed = true
|
||||
if (!readySignal.isCompleted) {
|
||||
readySignal.complete(false)
|
||||
}
|
||||
val pendingJobs = synchronized(jobQueue) {
|
||||
val copy = jobQueue.toList()
|
||||
jobQueue.clear()
|
||||
isProcessing = false
|
||||
copy
|
||||
}
|
||||
pendingJobs.forEach { job ->
|
||||
job.continuation(RenderResult.Failure(extractAltText(job.mathML)))
|
||||
}
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
handler.post {
|
||||
webView?.destroy()
|
||||
webView?.releaseMathRendererResources()
|
||||
webView = null
|
||||
Timber.d("MathMLRenderer WebView destroyed.")
|
||||
}
|
||||
synchronized(jobQueue) {
|
||||
jobQueue.clear()
|
||||
isProcessing = false
|
||||
}
|
||||
|
||||
private fun extractAltText(mathML: String): String =
|
||||
mathML.substringAfter("alttext=\"", "").substringBefore("\"")
|
||||
.ifBlank { "MathML rendering failed" }
|
||||
|
||||
private fun WebView.releaseMathRendererResources() {
|
||||
try {
|
||||
stopLoading()
|
||||
removeJavascriptInterface("AndroidBridge")
|
||||
webChromeClient = null
|
||||
webViewClient = WebViewClient()
|
||||
loadDataWithBaseURL(null, "", "text/html", "UTF-8", null)
|
||||
clearHistory()
|
||||
removeAllViews()
|
||||
destroy()
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Failed to fully release MathML WebView resources")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +271,7 @@ class MathMLRenderer(private val context: Context) {
|
|||
} else {
|
||||
Timber.e("onSvgReady FAILURE. Received empty SVG.")
|
||||
val job = synchronized(jobQueue) { jobQueue.firstOrNull() }
|
||||
val altText = job?.mathML?.substringAfter("alttext=\"", "")?.substringBefore("\"") ?: "MathML rendering failed"
|
||||
val altText = job?.mathML?.let(::extractAltText) ?: "MathML rendering failed"
|
||||
completeCurrentJob(RenderResult.Failure(altText))
|
||||
}
|
||||
}
|
||||
|
|
@ -244,4 +286,4 @@ class MathMLRenderer(private val context: Context) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import androidx.compose.runtime.SideEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -253,6 +254,8 @@ private fun headerFontScale(level: Int): Float = when (level) {
|
|||
}
|
||||
|
||||
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
|
||||
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
|
||||
private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L
|
||||
|
||||
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
|
||||
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
|
||||
|
|
@ -410,6 +413,30 @@ internal object CfiUtils {
|
|||
|
||||
fun getPath(cfi: String): String = cfi.split(':').first()
|
||||
fun getOffset(cfi: String): Int = cfi.substringAfter(':', "0").toIntOrNull() ?: 0
|
||||
fun getOffsetOrNull(cfi: String): Int? = cfi.substringAfter(':', "").toIntOrNull()
|
||||
|
||||
fun isPathStrictlyBetween(candidate: String, start: String, end: String): Boolean {
|
||||
val candidateParts = pathParts(candidate) ?: return false
|
||||
val startParts = pathParts(start) ?: return false
|
||||
val endParts = pathParts(end) ?: return false
|
||||
return comparePathParts(candidateParts, startParts) > 0 &&
|
||||
comparePathParts(candidateParts, endParts) < 0
|
||||
}
|
||||
|
||||
private fun pathParts(cfi: String): List<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(
|
||||
|
|
@ -729,6 +756,7 @@ fun PaginatedReaderScreen(
|
|||
effectiveText: Color,
|
||||
pagerState: PagerState,
|
||||
isPageTurnAnimationEnabled: Boolean,
|
||||
isRightToLeftPagination: Boolean = false,
|
||||
searchQuery: String,
|
||||
fontSizeMultiplier: Float,
|
||||
lineHeightMultiplier: Float,
|
||||
|
|
@ -741,6 +769,9 @@ fun PaginatedReaderScreen(
|
|||
ttsHighlightInfo: TtsHighlightInfo?,
|
||||
initialChapterIndexInBook: Int?,
|
||||
fallbackLocatorForReconfiguration: Locator? = null,
|
||||
explicitNavigationAnchor: Locator? = null,
|
||||
explicitNavigationEpoch: Long = 0L,
|
||||
isExternalNavigationInProgress: Boolean = false,
|
||||
onReconfigurationAnchorCaptured: (Locator) -> Unit = {},
|
||||
onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {},
|
||||
onPaginatorReady: (IPaginator) -> Unit,
|
||||
|
|
@ -754,6 +785,7 @@ fun PaginatedReaderScreen(
|
|||
onStartTtsFromSelection: (String, Int) -> Unit,
|
||||
onNoteRequested: (String?) -> Unit,
|
||||
onFootnoteRequested: (String) -> Unit,
|
||||
onInternalLinkNavigated: (Int) -> Unit = {},
|
||||
userHighlights: List<UserHighlight>,
|
||||
onHighlightCreated: (String, String, String) -> Unit,
|
||||
onHighlightDeleted: (String) -> Unit,
|
||||
|
|
@ -784,6 +816,11 @@ fun PaginatedReaderScreen(
|
|||
} else Modifier
|
||||
|
||||
var isNavigatingByLink by remember { mutableStateOf(false) }
|
||||
var localExplicitNavigationAnchor by remember { mutableStateOf<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)) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
|
|
@ -1107,19 +1144,96 @@ fun PaginatedReaderScreen(
|
|||
|
||||
LaunchedEffect(paginator, pagerState) {
|
||||
paginator.pageShiftRequest.collect { shiftAmount ->
|
||||
val anchor = resolvePaginatedReconfigurationAnchor(
|
||||
currentPageLocator = anchorLocatorForReconfig,
|
||||
fallbackLocator = latestFallbackLocatorForReconfiguration
|
||||
if (pagerState.pageCount <= 0) {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV)
|
||||
.w("shift_drop reason=emptyPager shift=$shiftAmount")
|
||||
return@collect
|
||||
}
|
||||
|
||||
val bookPaginator = paginator as? BookPaginator
|
||||
val currentPageBeforeShift = pagerState.currentPage
|
||||
val now = System.currentTimeMillis()
|
||||
val externalAgeMs = if (latestExternalNavigationEpoch > 0L) {
|
||||
now - latestExternalNavigationEpoch
|
||||
} else {
|
||||
-1L
|
||||
}
|
||||
val localAgeMs = if (localExplicitNavigationEpoch > 0L) {
|
||||
now - localExplicitNavigationEpoch
|
||||
} else {
|
||||
-1L
|
||||
}
|
||||
val recentExternalNavigation =
|
||||
externalAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS
|
||||
val recentLocalNavigation =
|
||||
localAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS
|
||||
val activeExplicitAnchor = when {
|
||||
latestIsExternalNavigationInProgress -> latestExternalNavigationAnchor
|
||||
isNavigatingByLink -> localExplicitNavigationAnchor
|
||||
else -> null
|
||||
}
|
||||
val recentExplicitAnchor = when {
|
||||
recentExternalNavigation -> latestExternalNavigationAnchor
|
||||
recentLocalNavigation -> localExplicitNavigationAnchor
|
||||
else -> null
|
||||
}
|
||||
val activeExplicitAnchorSource = when {
|
||||
activeExplicitAnchor == null -> null
|
||||
latestIsExternalNavigationInProgress -> "explicit_external_active"
|
||||
else -> "explicit_link"
|
||||
}
|
||||
val recentExplicitAnchorSource = when {
|
||||
recentExplicitAnchor == null -> null
|
||||
recentExternalNavigation -> "explicit_external_recent"
|
||||
else -> "explicit_link_recent"
|
||||
}
|
||||
val currentPageLocator = bookPaginator?.getLocatorForPage(currentPageBeforeShift)
|
||||
val fallbackLocator = latestFallbackLocatorForReconfiguration
|
||||
var anchorSource = "none"
|
||||
val anchor = when {
|
||||
anchorLocatorForReconfig != null -> {
|
||||
anchorSource = "reconfiguration"
|
||||
anchorLocatorForReconfig
|
||||
}
|
||||
activeExplicitAnchor != null -> {
|
||||
anchorSource = activeExplicitAnchorSource ?: "explicit_active"
|
||||
activeExplicitAnchor
|
||||
}
|
||||
fallbackLocator != null -> {
|
||||
anchorSource = "last_known"
|
||||
fallbackLocator
|
||||
}
|
||||
recentExplicitAnchor != null -> {
|
||||
anchorSource = recentExplicitAnchorSource ?: "explicit_recent"
|
||||
recentExplicitAnchor
|
||||
}
|
||||
currentPageLocator != null -> {
|
||||
anchorSource = "current_page"
|
||||
currentPageLocator
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"shift_received shift=$shiftAmount currentPage=$currentPageBeforeShift anchorSource=$anchorSource anchor=$anchor currentLocator=$currentPageLocator fallback=$fallbackLocator externalInProgress=$latestIsExternalNavigationInProgress linkInProgress=$isNavigatingByLink externalAgeMs=$externalAgeMs localAgeMs=$localAgeMs"
|
||||
)
|
||||
|
||||
val resolvedPage = anchor?.let { locator ->
|
||||
(paginator as? BookPaginator)?.findPageForLocator(locator)
|
||||
bookPaginator?.findStablePageForLocator(locator)
|
||||
}
|
||||
|
||||
if (resolvedPage != null) {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"shift_apply_stable shift=$shiftAmount from=$currentPageBeforeShift to=$resolvedPage anchorSource=$anchorSource anchor=$anchor"
|
||||
)
|
||||
pagerState.scrollToPage(resolvedPage)
|
||||
paginator.onUserScrolledTo(resolvedPage)
|
||||
} else {
|
||||
val newPage = pagerState.currentPage + shiftAmount
|
||||
val maxPage = (pagerState.pageCount - 1).coerceAtLeast(0)
|
||||
val newPage = (currentPageBeforeShift + shiftAmount).coerceIn(0, maxPage)
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).w(
|
||||
"shift_apply_relative shift=$shiftAmount from=$currentPageBeforeShift to=$newPage anchorSource=$anchorSource anchor=$anchor"
|
||||
)
|
||||
pagerState.scrollToPage(newPage)
|
||||
paginator.onUserScrolledTo(newPage)
|
||||
}
|
||||
|
|
@ -1134,6 +1248,7 @@ fun PaginatedReaderScreen(
|
|||
uiState = uiState,
|
||||
pagerState = pagerState,
|
||||
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
|
||||
isRightToLeftPagination = isRightToLeftPagination,
|
||||
effectiveBg = effectiveBg,
|
||||
searchQuery = searchQuery,
|
||||
ttsHighlightInfo = ttsHighlightInfo,
|
||||
|
|
@ -1163,100 +1278,126 @@ fun PaginatedReaderScreen(
|
|||
}
|
||||
}
|
||||
},
|
||||
onInternalLinkNavigated = onInternalLinkNavigated,
|
||||
onLinkClick = { currentChapterPath, href, onNavComplete ->
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
isNavigatingByLink = true
|
||||
var isFootnote = false
|
||||
var footnoteHtml: String? = null
|
||||
withContext(Dispatchers.Main) { isNavigatingByLink = true }
|
||||
try {
|
||||
var isFootnote = false
|
||||
var footnoteHtml: String? = null
|
||||
|
||||
val sourceChapter =
|
||||
book.chaptersForPagination.find { it.absPath == currentChapterPath }
|
||||
if (sourceChapter != null) {
|
||||
val sourceHtml = sourceChapter.htmlContent.ifEmpty {
|
||||
try {
|
||||
File(book.extractionBasePath, sourceChapter.htmlFilePath)
|
||||
.readText()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
if (sourceHtml.isNotEmpty()) {
|
||||
val doc = Jsoup.parse(sourceHtml)
|
||||
val safeHref = href.replace("\"", "\\\"")
|
||||
val aTag = doc.select("a[href=\"$safeHref\"]").first()
|
||||
|
||||
if (aTag?.attr("epub:type") == "noteref" || href.startsWith("#")) {
|
||||
isFootnote = true
|
||||
}
|
||||
} else if (href.startsWith("#")) {
|
||||
isFootnote = true
|
||||
}
|
||||
} else if (href.startsWith("#")) {
|
||||
isFootnote = true
|
||||
}
|
||||
|
||||
if (isFootnote) {
|
||||
val decodedHref = try {
|
||||
URLDecoder.decode(href, "UTF-8")
|
||||
} catch (_: Exception) {
|
||||
href
|
||||
}
|
||||
val parts = decodedHref.split('#', limit = 2)
|
||||
val pathPart = parts[0]
|
||||
val anchor = if (parts.size > 1) parts[1] else null
|
||||
|
||||
if (anchor != null) {
|
||||
val targetPath = if (pathPart.isBlank()) currentChapterPath else {
|
||||
val sourceChapter =
|
||||
book.chaptersForPagination.find { it.absPath == currentChapterPath }
|
||||
if (sourceChapter != null) {
|
||||
val sourceHtml = sourceChapter.htmlContent.ifEmpty {
|
||||
try {
|
||||
URI(currentChapterPath).resolve(pathPart)
|
||||
.normalize().path
|
||||
File(book.extractionBasePath, sourceChapter.htmlFilePath)
|
||||
.readText()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
""
|
||||
}
|
||||
}
|
||||
if (sourceHtml.isNotEmpty()) {
|
||||
val doc = Jsoup.parse(sourceHtml)
|
||||
val safeHref = href.replace("\"", "\\\"")
|
||||
val aTag = doc.select("a[href=\"$safeHref\"]").first()
|
||||
|
||||
if (targetPath != null) {
|
||||
val targetChapter = book.chaptersForPagination.find {
|
||||
val linkType = aTag?.attr("epub:type").orEmpty()
|
||||
val linkRole = aTag?.attr("role").orEmpty()
|
||||
if (
|
||||
linkType.contains("noteref", ignoreCase = true) ||
|
||||
linkRole.contains("doc-noteref", ignoreCase = true)
|
||||
) {
|
||||
isFootnote = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run {
|
||||
val decodedHref = try {
|
||||
URLDecoder.decode(href, "UTF-8")
|
||||
} catch (_: Exception) {
|
||||
href
|
||||
}
|
||||
val parts = decodedHref.split('#', limit = 2)
|
||||
val pathPart = parts[0]
|
||||
val anchor = if (parts.size > 1) parts[1] else null
|
||||
|
||||
if (anchor != null) {
|
||||
val targetPath = if (pathPart.isBlank()) currentChapterPath else {
|
||||
try {
|
||||
URI(it.absPath).normalize().path == targetPath
|
||||
URI(currentChapterPath).resolve(pathPart)
|
||||
.normalize().path
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (targetChapter != null) {
|
||||
val targetHtml = targetChapter.htmlContent.ifEmpty {
|
||||
if (targetPath != null) {
|
||||
val targetChapter = book.chaptersForPagination.find {
|
||||
try {
|
||||
File(
|
||||
book.extractionBasePath,
|
||||
targetChapter.htmlFilePath
|
||||
).readText()
|
||||
URI(it.absPath).normalize().path == targetPath
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
false
|
||||
}
|
||||
}
|
||||
if (targetHtml.isNotEmpty()) {
|
||||
val doc = Jsoup.parse(targetHtml)
|
||||
val noteEl = doc.getElementById(anchor)
|
||||
if (noteEl != null) {
|
||||
footnoteHtml = noteEl.html()
|
||||
|
||||
if (targetChapter != null) {
|
||||
val targetHtml = targetChapter.htmlContent.ifEmpty {
|
||||
try {
|
||||
File(
|
||||
book.extractionBasePath,
|
||||
targetChapter.htmlFilePath
|
||||
).readText()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
if (targetHtml.isNotEmpty()) {
|
||||
val doc = Jsoup.parse(targetHtml)
|
||||
val noteEl = doc.getElementById(anchor)
|
||||
if (noteEl != null) {
|
||||
val targetType = noteEl.attr("epub:type")
|
||||
val targetRole = noteEl.attr("role")
|
||||
val targetClass = noteEl.className()
|
||||
val targetLooksLikeFootnote =
|
||||
targetType.contains("footnote", ignoreCase = true) ||
|
||||
targetRole.contains("doc-footnote", ignoreCase = true) ||
|
||||
targetClass.contains("footnote", ignoreCase = true)
|
||||
if (isFootnote || targetLooksLikeFootnote) {
|
||||
footnoteHtml = noteEl.html()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (!footnoteHtml.isNullOrBlank()) {
|
||||
onFootnoteRequested(footnoteHtml)
|
||||
isNavigatingByLink = false
|
||||
withContext(Dispatchers.Main) { onFootnoteRequested(footnoteHtml) }
|
||||
} else {
|
||||
paginator.navigateToHref(currentChapterPath, href) {
|
||||
onNavComplete(it)
|
||||
isNavigatingByLink = false
|
||||
val targetPage = (paginator as? BookPaginator)?.findStablePageForHref(currentChapterPath, href)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (targetPage != null) {
|
||||
val targetAnchor = (paginator as? BookPaginator)?.getLocatorForPage(targetPage)
|
||||
val navigationEpoch = System.currentTimeMillis()
|
||||
localExplicitNavigationAnchor = targetAnchor
|
||||
localExplicitNavigationEpoch = navigationEpoch
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"link_resolved href=$href targetPage=$targetPage anchor=$targetAnchor epoch=$navigationEpoch"
|
||||
)
|
||||
paginator.onUserScrolledTo(targetPage)
|
||||
onNavComplete(targetPage)
|
||||
} else {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).w(
|
||||
"link_failed href=$href currentChapterPath=$currentChapterPath"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) { isNavigatingByLink = false }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -1359,7 +1500,7 @@ private fun findFuzzyMatch(source: String, target: String, ignoreCase: Boolean =
|
|||
return null
|
||||
}
|
||||
|
||||
private fun getHighlightOffsetsInBlock(
|
||||
internal fun getHighlightOffsetsInBlock(
|
||||
block: TextContentBlock, highlight: UserHighlight
|
||||
): IntRange? {
|
||||
if (block.cfi == null) return null
|
||||
|
|
@ -1368,6 +1509,7 @@ private fun getHighlightOffsetsInBlock(
|
|||
val parts = highlight.cfi.split('|')
|
||||
val startCfi = parts.firstOrNull() ?: highlight.cfi
|
||||
val endCfi = parts.lastOrNull()
|
||||
val isMultipartHighlight = endCfi != null && endCfi != startCfi
|
||||
|
||||
@Suppress("REDUNDANT_ELSE_IN_WHEN") val blockStartAbs = when (block) {
|
||||
is ParagraphBlock -> block.startCharOffsetInSource
|
||||
|
|
@ -1376,6 +1518,9 @@ private fun getHighlightOffsetsInBlock(
|
|||
is ListItemBlock -> block.startCharOffsetInSource
|
||||
else -> 0
|
||||
}
|
||||
val blockEndAbs = block.endCharOffsetInSource
|
||||
.takeIf { it > blockStartAbs }
|
||||
?: (blockStartAbs + block.content.text.length)
|
||||
|
||||
Timber.d(
|
||||
"getHighlightOffsetsInBlock: Checking Block=${block.cfi} (AbsStart=$blockStartAbs) against Highlight=${highlight.cfi}"
|
||||
|
|
@ -1419,48 +1564,28 @@ private fun getHighlightOffsetsInBlock(
|
|||
)
|
||||
}
|
||||
|
||||
var isAfterStart = false
|
||||
var isBeforeEnd = true
|
||||
|
||||
if (relevantPart == null) {
|
||||
if (startCfi.isNotEmpty()) {
|
||||
try {
|
||||
if (CfiUtils.compare(block.cfi!!, startCfi) > 0) {
|
||||
isAfterStart = true
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
if (endCfi != null && endCfi != startCfi) {
|
||||
try {
|
||||
val endPath = CfiUtils.getPath(endCfi)
|
||||
val cmp = CfiUtils.compare(blockPath, endPath)
|
||||
Timber.d(" -> Comparing BlockPath ($blockPath) vs EndPath ($endPath). Result: $cmp")
|
||||
if (CfiUtils.compare(blockPath, endPath) > 0) {
|
||||
isBeforeEnd = false
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d(" -> relevantPart=$relevantPart, isAfterStart=$isAfterStart, isBeforeEnd=$isBeforeEnd")
|
||||
|
||||
if (relevantPart == null && (!isAfterStart || !isBeforeEnd)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val blockText = block.content.text
|
||||
val highlightText = highlight.text
|
||||
|
||||
if (blockText.isEmpty() || highlightText.isEmpty()) return null
|
||||
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
|
||||
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
|
||||
|
||||
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
|
||||
if (startIndex == -1) {
|
||||
startIndex = blockText.indexOf(highlightText, ignoreCase = true)
|
||||
val isIntermediateBlock = relevantPart == null &&
|
||||
isMultipartHighlight &&
|
||||
CfiUtils.isPathStrictlyBetween(block.cfi!!, startCfi, endCfi!!)
|
||||
|
||||
Timber.d(" -> relevantPart=$relevantPart, isIntermediateBlock=$isIntermediateBlock")
|
||||
|
||||
if (relevantPart == null) {
|
||||
if (!isIntermediateBlock) return null
|
||||
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
|
||||
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
|
||||
val normBlock = blockText.filter { !it.isWhitespace() }
|
||||
val normHighlight = highlightText.filter { !it.isWhitespace() }
|
||||
return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) {
|
||||
0 until blockText.length
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (relevantPart != null) {
|
||||
|
|
@ -1482,11 +1607,27 @@ private fun getHighlightOffsetsInBlock(
|
|||
Timber.d(" -> Path Equivalence: StartMatches=$startMatches, EndMatches=$endMatches")
|
||||
|
||||
if (startMatches || endMatches) {
|
||||
val startAbs = CfiUtils.getOffsetOrNull(startCfi)
|
||||
val endAbs = endCfi?.let { CfiUtils.getOffsetOrNull(it) }
|
||||
if (startMatches && endMatches && startAbs != null && endAbs != null) {
|
||||
val rangeStartAbs = minOf(startAbs, endAbs)
|
||||
val rangeEndAbs = maxOf(startAbs, endAbs)
|
||||
if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) {
|
||||
Timber.d(
|
||||
" -> Skipping same-path split block outside highlight offsets. " +
|
||||
"highlight=$rangeStartAbs..$rangeEndAbs block=$blockStartAbs..$blockEndAbs"
|
||||
)
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
if (startMatches && startAbs != null && startAbs >= blockEndAbs) return null
|
||||
if (endMatches && endAbs != null && endAbs <= blockStartAbs) return null
|
||||
}
|
||||
var s = 0
|
||||
var e = blockText.length
|
||||
|
||||
if (startMatches) {
|
||||
val absOffset = CfiUtils.getOffset(startCfi)
|
||||
val absOffset = startAbs ?: CfiUtils.getOffset(startCfi)
|
||||
val relOffset = absOffset - blockStartAbs
|
||||
|
||||
if (relOffset < 0) {
|
||||
|
|
@ -1530,7 +1671,7 @@ private fun getHighlightOffsetsInBlock(
|
|||
}
|
||||
|
||||
if (endMatches) {
|
||||
val absOffset = CfiUtils.getOffset(endCfi!!)
|
||||
val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!)
|
||||
val relOffset = absOffset - blockStartAbs
|
||||
|
||||
Timber.d(
|
||||
|
|
@ -1559,18 +1700,16 @@ private fun getHighlightOffsetsInBlock(
|
|||
}
|
||||
}
|
||||
|
||||
if (startIndex >= 0) {
|
||||
return startIndex until (startIndex + highlightText.length)
|
||||
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
|
||||
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
|
||||
|
||||
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
|
||||
if (startIndex == -1) {
|
||||
startIndex = blockText.indexOf(highlightText, ignoreCase = true)
|
||||
}
|
||||
|
||||
if (relevantPart == null) {
|
||||
@Suppress("KotlinConstantConditions") if (isAfterStart) {
|
||||
val normBlock = blockText.filter { !it.isWhitespace() }
|
||||
val normHighlight = highlightText.filter { !it.isWhitespace() }
|
||||
if (normHighlight.contains(normBlock, ignoreCase = true)) {
|
||||
return 0 until blockText.length
|
||||
}
|
||||
}
|
||||
if (startIndex >= 0) {
|
||||
return startIndex until (startIndex + highlightText.length)
|
||||
}
|
||||
|
||||
val match = findFuzzyMatch(blockText, highlightText)
|
||||
|
|
@ -2073,6 +2212,7 @@ internal fun PaginatedReaderContent(
|
|||
uiState: PaginatedReaderUiState,
|
||||
pagerState: PagerState,
|
||||
isPageTurnAnimationEnabled: Boolean,
|
||||
isRightToLeftPagination: Boolean = false,
|
||||
effectiveBg: Color,
|
||||
effectiveText: Color,
|
||||
searchQuery: String,
|
||||
|
|
@ -2084,6 +2224,7 @@ internal fun PaginatedReaderContent(
|
|||
onGetPage: (Int) -> Page?,
|
||||
onGetChapterPath: (Int) -> String?,
|
||||
onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit,
|
||||
onInternalLinkNavigated: (Int) -> Unit,
|
||||
onTap: (Offset?) -> Unit,
|
||||
isProUser: Boolean,
|
||||
isOss: Boolean,
|
||||
|
|
@ -2231,7 +2372,8 @@ internal fun PaginatedReaderContent(
|
|||
}
|
||||
}
|
||||
},
|
||||
beyondViewportPageCount = 1
|
||||
beyondViewportPageCount = 1,
|
||||
reverseLayout = isRightToLeftPagination
|
||||
) { pageIndex ->
|
||||
val pageOffset =
|
||||
(pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||
|
|
@ -2432,7 +2574,11 @@ internal fun PaginatedReaderContent(
|
|||
} else {
|
||||
currentChapterPath?.let { path ->
|
||||
onLinkClick(path, href) { targetPageIndex ->
|
||||
onInternalLinkNavigated(targetPageIndex)
|
||||
coroutineScope.launch {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"link_scroll targetPage=$targetPageIndex currentPage=${pagerState.currentPage}"
|
||||
)
|
||||
pagerState.scrollToPage(targetPageIndex)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
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
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import timber.log.Timber
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
|
@ -43,17 +45,114 @@ import androidx.compose.ui.unit.dp
|
|||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal data class MagnifierContentSource(
|
||||
val sourceWidth: Int,
|
||||
val sourceHeight: Int,
|
||||
val contentLeft: Float,
|
||||
val contentTop: Float,
|
||||
val contentWidth: Float,
|
||||
val contentHeight: Float
|
||||
) {
|
||||
val scaleX: Float
|
||||
get() = if (contentWidth > 0f) sourceWidth.toFloat() / contentWidth else 1f
|
||||
|
||||
val scaleY: Float
|
||||
get() = if (contentHeight > 0f) sourceHeight.toFloat() / contentHeight else 1f
|
||||
|
||||
fun sourceX(contentX: Float): Float = (contentX - contentLeft) * scaleX
|
||||
|
||||
fun sourceY(contentY: Float): Float = (contentY - contentTop) * scaleY
|
||||
}
|
||||
|
||||
internal data class MagnifierSampleGeometry(
|
||||
val srcLeft: Int,
|
||||
val srcTop: Int,
|
||||
val srcWidth: Int,
|
||||
val srcHeight: Int,
|
||||
val outputScaleX: Float,
|
||||
val outputScaleY: Float
|
||||
)
|
||||
|
||||
internal fun calculateMagnifierSampleGeometry(
|
||||
centerContentX: Float,
|
||||
centerContentY: Float,
|
||||
contentSource: MagnifierContentSource,
|
||||
magnifierWidthPx: Float,
|
||||
magnifierHeightPx: Float,
|
||||
zoomFactor: Float
|
||||
): MagnifierSampleGeometry? {
|
||||
if (
|
||||
contentSource.sourceWidth <= 0 ||
|
||||
contentSource.sourceHeight <= 0 ||
|
||||
contentSource.contentWidth <= 0f ||
|
||||
contentSource.contentHeight <= 0f ||
|
||||
magnifierWidthPx <= 0f ||
|
||||
magnifierHeightPx <= 0f ||
|
||||
zoomFactor <= 0f
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
val sourceCenterX = contentSource.sourceX(centerContentX)
|
||||
val sourceCenterY = contentSource.sourceY(centerContentY)
|
||||
val sourceRectWidth = (magnifierWidthPx / zoomFactor * contentSource.scaleX).coerceAtLeast(1f)
|
||||
val sourceRectHeight = (magnifierHeightPx / zoomFactor * contentSource.scaleY).coerceAtLeast(1f)
|
||||
|
||||
val maxSrcLeft = max(0f, contentSource.sourceWidth.toFloat() - sourceRectWidth)
|
||||
val maxSrcTop = max(0f, contentSource.sourceHeight.toFloat() - sourceRectHeight)
|
||||
val srcLeft = (sourceCenterX - sourceRectWidth / 2f).coerceIn(0f, maxSrcLeft)
|
||||
val srcTop = (sourceCenterY - sourceRectHeight / 2f).coerceIn(0f, maxSrcTop)
|
||||
|
||||
val srcLeftInt = srcLeft.roundToInt().coerceIn(0, contentSource.sourceWidth - 1)
|
||||
val srcTopInt = srcTop.roundToInt().coerceIn(0, contentSource.sourceHeight - 1)
|
||||
val srcWidthInt = (contentSource.sourceWidth - srcLeftInt)
|
||||
.coerceAtMost(sourceRectWidth.roundToInt().coerceAtLeast(1))
|
||||
.coerceAtLeast(1)
|
||||
val srcHeightInt = (contentSource.sourceHeight - srcTopInt)
|
||||
.coerceAtMost(sourceRectHeight.roundToInt().coerceAtLeast(1))
|
||||
.coerceAtLeast(1)
|
||||
|
||||
return MagnifierSampleGeometry(
|
||||
srcLeft = srcLeftInt,
|
||||
srcTop = srcTopInt,
|
||||
srcWidth = srcWidthInt,
|
||||
srcHeight = srcHeightInt,
|
||||
outputScaleX = magnifierWidthPx / srcWidthInt,
|
||||
outputScaleY = magnifierHeightPx / srcHeightInt
|
||||
)
|
||||
}
|
||||
|
||||
internal fun mapContentRectToMagnifier(
|
||||
contentRect: Rect,
|
||||
contentSource: MagnifierContentSource,
|
||||
sample: MagnifierSampleGeometry
|
||||
): RectF {
|
||||
val sourceLeft = contentSource.sourceX(contentRect.left.toFloat())
|
||||
val sourceTop = contentSource.sourceY(contentRect.top.toFloat())
|
||||
val sourceRight = contentSource.sourceX(contentRect.right.toFloat())
|
||||
val sourceBottom = contentSource.sourceY(contentRect.bottom.toFloat())
|
||||
|
||||
return RectF(
|
||||
(sourceLeft - sample.srcLeft) * sample.outputScaleX,
|
||||
(sourceTop - sample.srcTop) * sample.outputScaleY,
|
||||
(sourceRight - sample.srcLeft) * sample.outputScaleX,
|
||||
(sourceBottom - sample.srcTop) * sample.outputScaleY
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MagnifierComposable(
|
||||
sourceBitmap: ImageBitmap,
|
||||
tiles: List<PdfTile>,
|
||||
currentScale: Float,
|
||||
magnifierCenterOnBitmap: Offset,
|
||||
contentWidthPx: Int = sourceBitmap.width,
|
||||
contentHeightPx: Int = sourceBitmap.height,
|
||||
modifier: Modifier = Modifier,
|
||||
magnifierWidth: Dp = 120.dp,
|
||||
magnifierHeight: Dp = 60.dp,
|
||||
zoomFactor: Float = 1.5f,
|
||||
selectionRectsInBitmapCoords: List<Rect>,
|
||||
selectionRectsInContentCoords: List<Rect>,
|
||||
highlightColor: Color,
|
||||
colorFilter: ColorFilter? = null
|
||||
) {
|
||||
|
|
@ -81,155 +180,71 @@ fun MagnifierComposable(
|
|||
}
|
||||
} else null
|
||||
|
||||
if (relevantTile != null) {
|
||||
// --- HIGH-RES TILE PATH ---
|
||||
val bitmapToUse: ImageBitmap
|
||||
val contentSource: MagnifierContentSource
|
||||
if (relevantTile != null && !relevantTile.bitmap.isRecycled) {
|
||||
Timber.d("Magnifier: Using HIGH-RES TILE path.")
|
||||
Timber.d("Magnifier: Tile.renderRect=${relevantTile.renderRect}, Tile.bitmap.size=${relevantTile.bitmap.width}x${relevantTile.bitmap.height}")
|
||||
val bitmapToUse = relevantTile.bitmap.asImageBitmap()
|
||||
|
||||
val tileBitmapWidth = relevantTile.bitmap.width.toFloat()
|
||||
val tileRenderRectWidth = relevantTile.renderRect.width().toFloat()
|
||||
|
||||
val tileScale = if (tileRenderRectWidth > 0) {
|
||||
tileBitmapWidth / tileRenderRectWidth
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
Timber.d("Magnifier: Using derived tileScale=$tileScale instead of parent's currentScale=$currentScale")
|
||||
|
||||
|
||||
val centerInTileBitmap = Offset(
|
||||
x = (magnifierCenterOnBitmap.x - relevantTile.renderRect.left) * tileScale,
|
||||
y = (magnifierCenterOnBitmap.y - relevantTile.renderRect.top) * tileScale
|
||||
bitmapToUse = relevantTile.bitmap.asImageBitmap()
|
||||
contentSource = MagnifierContentSource(
|
||||
sourceWidth = bitmapToUse.width,
|
||||
sourceHeight = bitmapToUse.height,
|
||||
contentLeft = relevantTile.renderRect.left.toFloat(),
|
||||
contentTop = relevantTile.renderRect.top.toFloat(),
|
||||
contentWidth = relevantTile.renderRect.width().toFloat(),
|
||||
contentHeight = relevantTile.renderRect.height().toFloat()
|
||||
)
|
||||
|
||||
Timber.d("Magnifier: Calculated centerInTileBitmap=$centerInTileBitmap")
|
||||
|
||||
val sourceRectWidth = magnifierWidthPx / zoomFactor
|
||||
val sourceRectHeight = magnifierHeightPx / zoomFactor
|
||||
Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight")
|
||||
|
||||
val srcLeft = (centerInTileBitmap.x - sourceRectWidth / 2f)
|
||||
val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f)
|
||||
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
|
||||
|
||||
val maxSrcLeft = max(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
||||
val maxSrcTop = max(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
||||
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
|
||||
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
|
||||
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
|
||||
|
||||
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
|
||||
val finalSrcTopInt = clampedSrcTop.roundToInt()
|
||||
|
||||
val finalSrcWidthInt = (bitmapToUse.width - finalSrcLeftInt)
|
||||
.coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1)
|
||||
val finalSrcHeightInt = (bitmapToUse.height - finalSrcTopInt)
|
||||
.coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1)
|
||||
Timber.d("Magnifier: Final source rect to draw from tile: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt")
|
||||
|
||||
if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= bitmapToUse.width || finalSrcTopInt >= bitmapToUse.height) {
|
||||
Timber.w("Magnifier: Final source rect is invalid, returning.")
|
||||
return@Canvas
|
||||
}
|
||||
|
||||
drawImage(
|
||||
image = bitmapToUse,
|
||||
srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt),
|
||||
srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt),
|
||||
dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()),
|
||||
colorFilter = colorFilter
|
||||
)
|
||||
|
||||
selectionRectsInBitmapCoords.forEach { rectInBitmap ->
|
||||
val translatedLeft = (rectInBitmap.left - relevantTile.renderRect.left) * tileScale
|
||||
val translatedTop = (rectInBitmap.top - relevantTile.renderRect.top) * tileScale
|
||||
val translatedRight = (rectInBitmap.right - relevantTile.renderRect.left) * tileScale
|
||||
val translatedBottom = (rectInBitmap.bottom - relevantTile.renderRect.top) * tileScale
|
||||
|
||||
val finalLeft = translatedLeft - clampedSrcLeft
|
||||
val finalTop = translatedTop - clampedSrcTop
|
||||
val finalRight = translatedRight - clampedSrcLeft
|
||||
val finalBottom = translatedBottom - clampedSrcTop
|
||||
|
||||
val magnifiedLeft = finalLeft * zoomFactor
|
||||
val magnifiedTop = finalTop * zoomFactor
|
||||
val magnifiedRight = finalRight * zoomFactor
|
||||
val magnifiedBottom = finalBottom * zoomFactor
|
||||
|
||||
if (magnifiedRight > 0 && magnifiedLeft < magnifierWidthPx && magnifiedBottom > 0 && magnifiedTop < magnifierHeightPx) {
|
||||
drawRect(
|
||||
color = highlightColor,
|
||||
topLeft = Offset(magnifiedLeft, magnifiedTop),
|
||||
size = androidx.compose.ui.geometry.Size(
|
||||
width = magnifiedRight - magnifiedLeft,
|
||||
height = magnifiedBottom - magnifiedTop
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// --- LOW-RES / NO-ZOOM PATH ---
|
||||
Timber.d("Magnifier: Using LOW-RES (base bitmap) path.")
|
||||
val sourceRectWidth = magnifierWidthPx / zoomFactor
|
||||
val sourceRectHeight = magnifierHeightPx / zoomFactor
|
||||
Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight")
|
||||
|
||||
val srcLeft = (magnifierCenterOnBitmap.x - sourceRectWidth / 2f)
|
||||
val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f)
|
||||
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
|
||||
|
||||
val maxSrcLeft = max(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
||||
val maxSrcTop = max(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
||||
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
|
||||
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
|
||||
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
|
||||
|
||||
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
|
||||
val finalSrcTopInt = clampedSrcTop.roundToInt()
|
||||
|
||||
val finalSrcWidthInt = (sourceBitmap.width - finalSrcLeftInt)
|
||||
.coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1)
|
||||
val finalSrcHeightInt = (sourceBitmap.height - finalSrcTopInt)
|
||||
.coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1)
|
||||
Timber.d("Magnifier: Final source rect to draw from base: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt")
|
||||
|
||||
if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= sourceBitmap.width || finalSrcTopInt >= sourceBitmap.height) {
|
||||
Timber.w("Magnifier: Final source rect is invalid, returning.")
|
||||
return@Canvas
|
||||
}
|
||||
|
||||
drawImage(
|
||||
image = sourceBitmap,
|
||||
srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt),
|
||||
srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt),
|
||||
dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()),
|
||||
colorFilter = colorFilter
|
||||
bitmapToUse = sourceBitmap
|
||||
contentSource = MagnifierContentSource(
|
||||
sourceWidth = sourceBitmap.width,
|
||||
sourceHeight = sourceBitmap.height,
|
||||
contentLeft = 0f,
|
||||
contentTop = 0f,
|
||||
contentWidth = contentWidthPx.toFloat(),
|
||||
contentHeight = contentHeightPx.toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
selectionRectsInBitmapCoords.forEach { rectInBitmap ->
|
||||
val translatedLeft = rectInBitmap.left - clampedSrcLeft
|
||||
val translatedTop = rectInBitmap.top - clampedSrcTop
|
||||
val rectWidthInBitmap = rectInBitmap.width().toFloat()
|
||||
val rectHeightInBitmap = rectInBitmap.height().toFloat()
|
||||
val sample = calculateMagnifierSampleGeometry(
|
||||
centerContentX = magnifierCenterOnBitmap.x,
|
||||
centerContentY = magnifierCenterOnBitmap.y,
|
||||
contentSource = contentSource,
|
||||
magnifierWidthPx = magnifierWidthPx,
|
||||
magnifierHeightPx = magnifierHeightPx,
|
||||
zoomFactor = zoomFactor
|
||||
) ?: run {
|
||||
Timber.w("Magnifier: Source geometry is invalid, returning.")
|
||||
return@Canvas
|
||||
}
|
||||
Timber.d("Magnifier: Final source rect offset=(${sample.srcLeft}, ${sample.srcTop}), size=${sample.srcWidth}x${sample.srcHeight}")
|
||||
|
||||
val magnifiedLeft = translatedLeft * zoomFactor
|
||||
val magnifiedTop = translatedTop * zoomFactor
|
||||
val magnifiedWidth = rectWidthInBitmap * zoomFactor
|
||||
val magnifiedHeight = rectHeightInBitmap * zoomFactor
|
||||
drawImage(
|
||||
image = bitmapToUse,
|
||||
srcOffset = IntOffset(sample.srcLeft, sample.srcTop),
|
||||
srcSize = IntSize(sample.srcWidth, sample.srcHeight),
|
||||
dstSize = IntSize(
|
||||
magnifierWidthPx.roundToInt().coerceAtLeast(1),
|
||||
magnifierHeightPx.roundToInt().coerceAtLeast(1)
|
||||
),
|
||||
colorFilter = colorFilter
|
||||
)
|
||||
|
||||
if (magnifiedLeft + magnifiedWidth > 0 && magnifiedLeft < magnifierWidthPx &&
|
||||
magnifiedTop + magnifiedHeight > 0 && magnifiedTop < magnifierHeightPx) {
|
||||
drawRect(
|
||||
color = highlightColor,
|
||||
topLeft = Offset(magnifiedLeft, magnifiedTop),
|
||||
size = androidx.compose.ui.geometry.Size(
|
||||
width = magnifiedWidth,
|
||||
height = magnifiedHeight
|
||||
)
|
||||
selectionRectsInContentCoords.forEach { contentRect ->
|
||||
val magnifierRect = mapContentRectToMagnifier(contentRect, contentSource, sample)
|
||||
if (magnifierRect.width() > 0f && magnifierRect.height() > 0f &&
|
||||
magnifierRect.right > 0f && magnifierRect.left < magnifierWidthPx &&
|
||||
magnifierRect.bottom > 0f && magnifierRect.top < magnifierHeightPx
|
||||
) {
|
||||
drawRect(
|
||||
color = highlightColor,
|
||||
topLeft = Offset(magnifierRect.left, magnifierRect.top),
|
||||
size = Size(
|
||||
width = magnifierRect.width(),
|
||||
height = magnifierRect.height()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.random.Random
|
||||
|
||||
private const val PDF_PREVIEW_MAX_WIDTH_PX = 1080
|
||||
private const val PDF_PREVIEW_MAX_HEIGHT_PX = 2048
|
||||
private const val PDF_PREVIEW_MAX_BYTES = 16L * 1024L * 1024L
|
||||
|
||||
object PdfiumCoreProvider {
|
||||
val core: PdfiumCoreKt by lazy {
|
||||
PdfiumCoreKt(Dispatchers.Default)
|
||||
|
|
@ -31,7 +37,7 @@ object PdfiumCoreProvider {
|
|||
|
||||
internal data class DocumentCacheItem(
|
||||
val doc: ReaderDocument,
|
||||
val pfd: ParcelFileDescriptor,
|
||||
val pfd: ParcelFileDescriptor?,
|
||||
val totalPages: Int,
|
||||
val pageAspectRatios: List<Float>,
|
||||
val flatTableOfContents: List<TocEntry>
|
||||
|
|
@ -48,7 +54,7 @@ internal class DocumentCache(val maxSize: Int = 3) {
|
|||
if (evicted) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try { oldValue.doc.close() } catch (e: Exception) { Timber.e(e) }
|
||||
try { oldValue.pfd.close() } catch (e: Exception) { Timber.e(e) }
|
||||
try { oldValue.pfd?.close() } catch (e: Exception) { Timber.e(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -156,14 +162,34 @@ internal suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bi
|
|||
page = doc.openPage(pageIndex)
|
||||
if (page == null) return@withContext null
|
||||
|
||||
val bitmapWidth = 1080
|
||||
val pageWidth = page.getPageWidthPoint()
|
||||
val pageHeight = page.getPageHeightPoint()
|
||||
if (pageWidth <= 0 || pageHeight <= 0) {
|
||||
Timber.e("Invalid page size for page $pageIndex: ${pageWidth}x${pageHeight}")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val aspectRatio =
|
||||
page.getPageWidthPoint().toFloat() / page.getPageHeightPoint().toFloat()
|
||||
pageWidth.toFloat() / pageHeight.toFloat()
|
||||
if (aspectRatio.isNaN() || aspectRatio <= 0) {
|
||||
Timber.e("Invalid aspect ratio for page $pageIndex")
|
||||
return@withContext null
|
||||
}
|
||||
val bitmapHeight = (bitmapWidth / aspectRatio).toInt()
|
||||
|
||||
var bitmapWidth = PDF_PREVIEW_MAX_WIDTH_PX
|
||||
var bitmapHeight = (bitmapWidth / aspectRatio).roundToInt()
|
||||
|
||||
if (bitmapHeight > PDF_PREVIEW_MAX_HEIGHT_PX) {
|
||||
bitmapHeight = PDF_PREVIEW_MAX_HEIGHT_PX
|
||||
bitmapWidth = (bitmapHeight * aspectRatio).roundToInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
val requestedBytes = bitmapWidth.toLong() * bitmapHeight.toLong() * 4L
|
||||
if (requestedBytes > PDF_PREVIEW_MAX_BYTES) {
|
||||
val scale = sqrt(PDF_PREVIEW_MAX_BYTES.toDouble() / requestedBytes.toDouble())
|
||||
bitmapWidth = (bitmapWidth * scale).roundToInt().coerceAtLeast(1)
|
||||
bitmapHeight = (bitmapHeight * scale).roundToInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
if (bitmapHeight <= 0) {
|
||||
Timber.e("Invalid calculated bitmap height for page $pageIndex")
|
||||
|
|
@ -191,4 +217,4 @@ internal suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bi
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ import kotlinx.coroutines.withContext
|
|||
import org.json.JSONArray
|
||||
import timber.log.Timber
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
|
||||
private const val MAX_FIXED_RECURSION = 128
|
||||
|
||||
|
|
@ -281,6 +282,7 @@ internal fun PdfTocTreeItem(
|
|||
@Composable
|
||||
internal fun PdfNavigationDrawerContent(
|
||||
pdfDocument: ReaderDocument?,
|
||||
documentKey: String,
|
||||
flatTableOfContents: List<TocEntry>,
|
||||
bookmarks: Set<PdfBookmark>,
|
||||
userHighlights: List<PdfUserHighlight>,
|
||||
|
|
@ -859,13 +861,16 @@ internal fun PdfNavigationDrawerContent(
|
|||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
var thumb by remember { mutableStateOf(PdfThumbnailCache.get(pageIdx)) }
|
||||
val thumbPageId = remember(documentKey, pageIdx) {
|
||||
pdfRenderPageId(documentKey, pageIdx, VirtualPage.PdfPage(pageIdx))
|
||||
}
|
||||
var thumb by remember(thumbPageId) { mutableStateOf(PdfThumbnailCache.get(thumbPageId)) }
|
||||
|
||||
LaunchedEffect(pageIdx, pdfDocument) {
|
||||
LaunchedEffect(thumbPageId, pdfDocument) {
|
||||
if (thumb == null && pdfDocument != null) {
|
||||
withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
val cached = PdfThumbnailCache.get(pageIdx)
|
||||
val cached = PdfThumbnailCache.get(thumbPageId)
|
||||
if (cached != null) {
|
||||
thumb = cached
|
||||
} else {
|
||||
|
|
@ -878,7 +883,7 @@ internal fun PdfNavigationDrawerContent(
|
|||
val bmp = createBitmap(thumbW, thumbH)
|
||||
bmp.eraseColor(android.graphics.Color.WHITE)
|
||||
p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false)
|
||||
PdfThumbnailCache.put(pageIdx, bmp)
|
||||
PdfThumbnailCache.put(thumbPageId, bmp)
|
||||
thumb = bmp
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ import androidx.core.graphics.scale
|
|||
import androidx.core.graphics.set
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.SearchResult
|
||||
import com.aryan.reader.isCanvasSafeBitmap
|
||||
import com.aryan.reader.loadReaderTextureBitmap
|
||||
import com.aryan.reader.ml.SpeechBubble
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
|
|
@ -135,7 +136,6 @@ import kotlinx.coroutines.FlowPreview
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
|
|
@ -171,6 +171,47 @@ enum class InkType {
|
|||
PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT
|
||||
}
|
||||
|
||||
internal fun shouldReportPdfPageCamera(
|
||||
isZoomEnabled: Boolean,
|
||||
isVerticalScroll: Boolean,
|
||||
isScrollLocked: Boolean,
|
||||
lockedState: Triple<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(
|
||||
val index: Int,
|
||||
val subtype: Int,
|
||||
|
|
@ -186,8 +227,25 @@ data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)
|
|||
|
||||
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
|
||||
|
||||
internal fun pdfRenderPageId(documentKey: String, pageIndex: Int, virtualPage: VirtualPage?): String {
|
||||
val sourcePageId = when (virtualPage) {
|
||||
is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}"
|
||||
is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}"
|
||||
null -> "PDF_$pageIndex"
|
||||
}
|
||||
return "$documentKey:$sourcePageId"
|
||||
}
|
||||
|
||||
private fun Throwable.readablePdfErrorDetail(): String {
|
||||
return localizedMessage?.takeIf { it.isNotBlank() }
|
||||
?: javaClass.simpleName.takeIf { it.isNotBlank() }
|
||||
?: "Unknown error"
|
||||
}
|
||||
|
||||
private const val PDF_TILE_SIZE_DP = 256
|
||||
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
|
||||
private const val PDF_MAX_DRAW_BITMAP_BYTES = 64L * 1024L * 1024L
|
||||
private const val PDF_MAX_DRAW_BITMAP_DIMENSION_PX = 4096
|
||||
private const val PDF_TILE_SCALE_TOLERANCE = 0.06f
|
||||
private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 90L
|
||||
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
|
||||
|
|
@ -255,17 +313,22 @@ private suspend fun renderExpandedBubbleBitmap(
|
|||
}
|
||||
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
val cropWidth = (bubbleBounds.width() * renderScale).roundToInt().coerceAtLeast(1)
|
||||
val cropHeight = (bubbleBounds.height() * renderScale).roundToInt().coerceAtLeast(1)
|
||||
val safeRenderScale = safePdfBitmapRenderScale(
|
||||
contentWidth = bubbleBounds.width(),
|
||||
contentHeight = bubbleBounds.height(),
|
||||
requestedScale = renderScale
|
||||
)
|
||||
val cropWidth = (bubbleBounds.width() * safeRenderScale).roundToInt().coerceAtLeast(1)
|
||||
val cropHeight = (bubbleBounds.height() * safeRenderScale).roundToInt().coerceAtLeast(1)
|
||||
val bitmap = createBitmap(cropWidth, cropHeight)
|
||||
|
||||
try {
|
||||
page.renderPageBitmap(
|
||||
bitmap = bitmap,
|
||||
startX = (-bubbleBounds.left * renderScale).roundToInt(),
|
||||
startY = (-bubbleBounds.top * renderScale).roundToInt(),
|
||||
drawSizeX = (pageWidth * renderScale).roundToInt().coerceAtLeast(cropWidth),
|
||||
drawSizeY = (pageHeight * renderScale).roundToInt().coerceAtLeast(cropHeight),
|
||||
startX = (-bubbleBounds.left * safeRenderScale).roundToInt(),
|
||||
startY = (-bubbleBounds.top * safeRenderScale).roundToInt(),
|
||||
drawSizeX = (pageWidth * safeRenderScale).roundToInt().coerceAtLeast(cropWidth),
|
||||
drawSizeY = (pageHeight * safeRenderScale).roundToInt().coerceAtLeast(cropHeight),
|
||||
renderAnnot = true
|
||||
)
|
||||
bitmap
|
||||
|
|
@ -277,6 +340,23 @@ private suspend fun renderExpandedBubbleBitmap(
|
|||
}
|
||||
}
|
||||
|
||||
private fun safePdfBitmapRenderScale(
|
||||
contentWidth: Float,
|
||||
contentHeight: Float,
|
||||
requestedScale: Float
|
||||
): Float {
|
||||
if (contentWidth <= 0f || contentHeight <= 0f || requestedScale <= 0f) return 1f
|
||||
|
||||
val requestedWidth = contentWidth * requestedScale
|
||||
val requestedHeight = contentHeight * requestedScale
|
||||
val requestedBytes = requestedWidth.toDouble() * requestedHeight.toDouble() * 4.0
|
||||
val byteScale = sqrt(PDF_MAX_DRAW_BITMAP_BYTES.toDouble() / requestedBytes.coerceAtLeast(1.0))
|
||||
val dimensionScale = PDF_MAX_DRAW_BITMAP_DIMENSION_PX.toDouble() /
|
||||
max(requestedWidth, requestedHeight).toDouble().coerceAtLeast(1.0)
|
||||
val limiter = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01)
|
||||
return (requestedScale.toDouble() * limiter).coerceAtLeast(0.01).toFloat()
|
||||
}
|
||||
|
||||
object PdfInkGeometry {
|
||||
fun calculateFountainPenPoints(
|
||||
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
|
||||
|
|
@ -380,16 +460,15 @@ internal object PdfBitmapPool {
|
|||
fun get(size: Int): Bitmap = get(size, size)
|
||||
|
||||
fun recycle(bitmap: Bitmap) {
|
||||
// Overflow bitmaps are left for GC; HWUI may still reference recently drawn bitmaps.
|
||||
if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) {
|
||||
pool.offer(bitmap)
|
||||
} else {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
while (!pool.isEmpty()) {
|
||||
pool.poll()?.recycle()
|
||||
pool.poll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -400,20 +479,20 @@ internal object PdfThumbnailCache {
|
|||
|
||||
private data class CacheEntry(val bitmap: Bitmap, val sizeKb: Int)
|
||||
|
||||
private val memoryCache = object : LruCache<Int, CacheEntry>(cacheSize) {
|
||||
override fun sizeOf(key: Int, entry: CacheEntry): Int {
|
||||
private val memoryCache = object : LruCache<String, CacheEntry>(cacheSize) {
|
||||
override fun sizeOf(key: String, entry: CacheEntry): Int {
|
||||
return entry.sizeKb
|
||||
}
|
||||
}
|
||||
|
||||
fun get(pageIndex: Int): Bitmap? {
|
||||
return memoryCache.get(pageIndex)?.bitmap?.takeUnless { it.isRecycled }
|
||||
fun get(pageId: String): Bitmap? {
|
||||
return memoryCache.get(pageId)?.bitmap?.takeUnless { it.isRecycled }
|
||||
}
|
||||
|
||||
fun put(pageIndex: Int, bitmap: Bitmap) {
|
||||
if (get(pageIndex) == null) {
|
||||
fun put(pageId: String, bitmap: Bitmap) {
|
||||
if (get(pageId) == null) {
|
||||
val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1)
|
||||
memoryCache.put(pageIndex, CacheEntry(bitmap, sizeKb))
|
||||
memoryCache.put(pageId, CacheEntry(bitmap, sizeKb))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -475,6 +554,7 @@ data class PageSelectionData(
|
|||
@Composable
|
||||
internal fun PdfPageComposable(
|
||||
pdfDocument: StableHolder<ReaderDocument>,
|
||||
documentKey: String,
|
||||
pageIndex: Int,
|
||||
totalPages: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -507,6 +587,7 @@ internal fun PdfPageComposable(
|
|||
isScrolling: Boolean = false,
|
||||
lazyListState: LazyListState? = null,
|
||||
isVerticalScroll: Boolean = false,
|
||||
showPageNumberOverlay: Boolean = true,
|
||||
visualScaleProvider: () -> Float = { 1f },
|
||||
clearSelectionTrigger: Long = 0L,
|
||||
resetZoomTrigger: Long = 0L,
|
||||
|
|
@ -558,18 +639,13 @@ internal fun PdfPageComposable(
|
|||
onShowPanelPopup: (Bitmap) -> Unit = {}
|
||||
) {
|
||||
val pdfDocumentItem = pdfDocument.item
|
||||
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
|
||||
var currentRenderedPageId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val targetPageId = remember(virtualPage, pageIndex) {
|
||||
when (virtualPage) {
|
||||
is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}"
|
||||
is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}"
|
||||
null -> "PDF_$pageIndex"
|
||||
}
|
||||
val targetPageId = remember(documentKey, virtualPage, pageIndex) {
|
||||
pdfRenderPageId(documentKey, pageIndex, virtualPage)
|
||||
}
|
||||
var isLoadingPage by remember { mutableStateOf(true) }
|
||||
var pageErrorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var bitmapState by remember(targetPageId) { mutableStateOf(PdfThumbnailCache.get(targetPageId)) }
|
||||
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 context = LocalContext.current
|
||||
val viewConfiguration = LocalViewConfiguration.current
|
||||
|
|
@ -581,12 +657,30 @@ internal fun PdfPageComposable(
|
|||
var ocrRipplePosition by remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
var isTransforming by remember { mutableStateOf(false) }
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||
val initialCamera = initialPdfPageCamera(
|
||||
isZoomEnabled = isZoomEnabled,
|
||||
isVerticalScroll = isVerticalScroll,
|
||||
isScrollLocked = isScrollLocked,
|
||||
lockedState = lockedState
|
||||
)
|
||||
var scale by remember(targetPageId) { mutableFloatStateOf(initialCamera.first) }
|
||||
var offset by remember(targetPageId) { mutableStateOf(initialCamera.second) }
|
||||
var paginationPanFlingJob by remember { mutableStateOf<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) {
|
||||
onZoomAndPanChanged?.invoke(scale, offset)
|
||||
LaunchedEffect(scale, offset, shouldReportCamera) {
|
||||
if (shouldReportCamera) {
|
||||
onZoomAndPanChanged?.invoke(scale, offset)
|
||||
}
|
||||
}
|
||||
|
||||
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
|
||||
|
|
@ -609,7 +703,7 @@ internal fun PdfPageComposable(
|
|||
val isPdfPage = virtualPage == null || virtualPage is VirtualPage.PdfPage
|
||||
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
|
||||
|
||||
var tiles by remember { mutableStateOf<List<PdfTile>>(emptyList()) }
|
||||
var tiles by remember(targetPageId) { mutableStateOf<List<PdfTile>>(emptyList()) }
|
||||
val tileSizeDp = PDF_TILE_SIZE_DP.dp
|
||||
val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() }
|
||||
val latestEffectiveScale by rememberUpdatedState(effectiveScale)
|
||||
|
|
@ -645,15 +739,15 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
|
||||
val selectionCharRange = remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
var activeDraggingHandle by remember { mutableStateOf<Handle?>(null) }
|
||||
var selectedWordScreenRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
val startHandleContentPosition = remember { mutableStateOf<Offset?>(null) }
|
||||
val endHandleContentPosition = remember { mutableStateOf<Offset?>(null) }
|
||||
val selectionCharRange = remember(targetPageId) { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
var activeDraggingHandle by remember(targetPageId) { mutableStateOf<Handle?>(null) }
|
||||
var selectedWordScreenRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
val startHandleContentPosition = remember(targetPageId) { mutableStateOf<Offset?>(null) }
|
||||
val endHandleContentPosition = remember(targetPageId) { mutableStateOf<Offset?>(null) }
|
||||
|
||||
var actualBitmapWidthPx by remember { mutableIntStateOf(0) }
|
||||
var actualBitmapHeightPx by remember { mutableIntStateOf(0) }
|
||||
var currentPageRotation by remember { mutableIntStateOf(0) }
|
||||
var actualBitmapWidthPx by remember(targetPageId) { mutableIntStateOf(0) }
|
||||
var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) }
|
||||
var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) }
|
||||
|
||||
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
|
||||
|
||||
|
|
@ -736,7 +830,7 @@ internal fun PdfPageComposable(
|
|||
var magnifierBitmapCenterTarget by remember { mutableStateOf(Offset.Zero) }
|
||||
val magnifierZoomFactor = 2.0f
|
||||
|
||||
var customMenuState by remember { mutableStateOf<CustomPdfMenuState?>(null) }
|
||||
var customMenuState by remember(targetPageId) { mutableStateOf<CustomPdfMenuState?>(null) }
|
||||
|
||||
val inputScale = if (isZoomEnabled && !isVerticalScroll) scale else 1f
|
||||
val inputOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero
|
||||
|
|
@ -826,7 +920,15 @@ internal fun PdfPageComposable(
|
|||
expandedBubbleIndex = -1
|
||||
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
|
||||
expandedBubbleRender = null
|
||||
if (!isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled) {
|
||||
if (
|
||||
shouldResetPdfZoomAfterBubbleZoomCleanup(
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
scale = scale,
|
||||
isVerticalScroll = isVerticalScroll,
|
||||
isZoomEnabled = isZoomEnabled,
|
||||
isScrollLocked = isScrollLocked
|
||||
)
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
Animatable(scale).animateTo(1f, tween(300)) {
|
||||
scale = this.value
|
||||
|
|
@ -881,10 +983,10 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
DisposableEffect(targetPageId) {
|
||||
onDispose {
|
||||
val currentBitmap = bitmapState
|
||||
val cachedBitmap = PdfThumbnailCache.get(pageIndex)
|
||||
val cachedBitmap = PdfThumbnailCache.get(targetPageId)
|
||||
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
|
||||
currentBitmap.recycle()
|
||||
}
|
||||
|
|
@ -895,16 +997,16 @@ internal fun PdfPageComposable(
|
|||
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// OCR
|
||||
var ocrVisionTextForSelection by remember { mutableStateOf<OcrResult?>(null) }
|
||||
var ocrVisionTextForSelection by remember(targetPageId) { mutableStateOf<OcrResult?>(null) }
|
||||
var isPerformingOcrForSelection by remember { mutableStateOf(false) }
|
||||
var selectionMethodUsed by remember { mutableStateOf(PdfSelectionMethod.PDFIUM) }
|
||||
var ocrSelectionSymbolIndices by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
var allOcrSymbolsForSelection by remember { mutableStateOf<List<OcrSymbolInfo>>(emptyList()) }
|
||||
var ocrSelectionSymbolIndices by remember(targetPageId) { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
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)
|
||||
|
||||
var allTextPageHighlightRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
var allTextPageHighlightRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
|
||||
var accumulatedKeyboardOffset by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
|
|
@ -937,7 +1039,7 @@ internal fun PdfPageComposable(
|
|||
val mergedSearchHighlightRects =
|
||||
remember(searchHighlightRects) { mergeRectsIntoLines(searchHighlightRects) }
|
||||
|
||||
var pageLinks by remember { mutableStateOf<List<PageLink>>(emptyList()) }
|
||||
var pageLinks by remember(targetPageId) { mutableStateOf<List<PageLink>>(emptyList()) }
|
||||
val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
|
||||
val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } }
|
||||
|
||||
|
|
@ -1091,9 +1193,9 @@ internal fun PdfPageComposable(
|
|||
onHighlightLoading(false)
|
||||
}
|
||||
|
||||
@Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
|
||||
var standardAnnotScreenRects by remember { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
|
||||
var imageScreenRects by remember { mutableStateOf<List<android.graphics.Rect>>(emptyList()) }
|
||||
@Suppress("VariableNeverRead") var embeddedAnnotations by remember(targetPageId) { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
|
||||
var standardAnnotScreenRects by remember(targetPageId) { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
|
||||
var imageScreenRects by remember(targetPageId) { mutableStateOf<List<android.graphics.Rect>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
|
||||
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
|
||||
|
|
@ -1216,84 +1318,80 @@ internal fun PdfPageComposable(
|
|||
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
|
||||
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
||||
if (count > 0) {
|
||||
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
|
||||
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
||||
if (count > 0) {
|
||||
val allAnnots = (0 until count).mapNotNull { i ->
|
||||
val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i)
|
||||
if (subtype == annotLink) return@mapNotNull null
|
||||
val allAnnots = (0 until count).mapNotNull { i ->
|
||||
val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i)
|
||||
if (subtype == annotLink) return@mapNotNull null
|
||||
|
||||
var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents")
|
||||
if (contents.isNullOrBlank()) {
|
||||
contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC")
|
||||
}
|
||||
|
||||
val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM")
|
||||
val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT")
|
||||
val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T")
|
||||
|
||||
val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i)
|
||||
val pdfRectF = if (pdfRectArray != null) {
|
||||
android.graphics.RectF(
|
||||
min(pdfRectArray[0], pdfRectArray[2]),
|
||||
max(pdfRectArray[1], pdfRectArray[3]),
|
||||
max(pdfRectArray[0], pdfRectArray[2]),
|
||||
min(pdfRectArray[1], pdfRectArray[3])
|
||||
)
|
||||
} else android.graphics.RectF()
|
||||
|
||||
EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt)
|
||||
var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents")
|
||||
if (contents.isNullOrBlank()) {
|
||||
contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC")
|
||||
}
|
||||
|
||||
val annotMap = allAnnots.associateBy { it.name }
|
||||
val orphans = mutableListOf<EmbeddedAnnotation>()
|
||||
val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM")
|
||||
val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT")
|
||||
val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T")
|
||||
|
||||
allAnnots.forEach { annot ->
|
||||
if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) {
|
||||
Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}")
|
||||
annotMap[annot.inReplyTo]?.replies?.add(annot)
|
||||
} else {
|
||||
orphans.add(annot)
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}")
|
||||
|
||||
val groupedRoots = mutableListOf<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
|
||||
val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i)
|
||||
val pdfRectF = if (pdfRectArray != null) {
|
||||
android.graphics.RectF(
|
||||
min(pdfRectArray[0], pdfRectArray[2]),
|
||||
max(pdfRectArray[1], pdfRectArray[3]),
|
||||
max(pdfRectArray[0], pdfRectArray[2]),
|
||||
min(pdfRectArray[1], pdfRectArray[3])
|
||||
)
|
||||
annot to screenRect
|
||||
} else android.graphics.RectF()
|
||||
|
||||
EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt)
|
||||
}
|
||||
|
||||
val annotMap = allAnnots.associateBy { it.name }
|
||||
val orphans = mutableListOf<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 {
|
||||
Timber.tag("PdfCommentDebug").w("Page $pageIndex: Failed to resolve native page pointer.")
|
||||
|
|
@ -1323,7 +1421,7 @@ internal fun PdfPageComposable(
|
|||
Timber.d("Page $pageIndex hidden. Releasing bitmap to save memory.")
|
||||
val old = bitmapState
|
||||
bitmapState = null
|
||||
@Suppress("ControlFlowWithEmptyBody") if (old != null && old !== PdfThumbnailCache.get(pageIndex)) { }
|
||||
@Suppress("ControlFlowWithEmptyBody") if (old != null && old !== PdfThumbnailCache.get(targetPageId)) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1584,10 +1682,10 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
|
||||
var searchFocusedRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
var searchAllRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
var searchFocusedRects by remember(targetPageId) { 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 mergedSearchAllRects = remember(searchAllRects) { searchAllRects }
|
||||
|
|
@ -1932,10 +2030,6 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
|
||||
val errorSelection = stringResource(R.string.error_selection)
|
||||
val errorOcrSelection = stringResource(R.string.error_ocr_selection)
|
||||
val errorProcessingPage = stringResource(R.string.error_processing_page)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.onGloballyPositioned { layoutCoordinates = it }
|
||||
|
|
@ -2706,7 +2800,10 @@ internal fun PdfPageComposable(
|
|||
Timber.e(
|
||||
e, "Long press: Error during OCR text selection"
|
||||
)
|
||||
pageErrorMessage = errorOcrSelection
|
||||
pageErrorMessage = context.getString(
|
||||
R.string.error_ocr_selection,
|
||||
e.readablePdfErrorDetail()
|
||||
)
|
||||
} finally {
|
||||
isPerformingOcrForSelection = false
|
||||
ocrRipplePosition = null
|
||||
|
|
@ -2727,7 +2824,10 @@ internal fun PdfPageComposable(
|
|||
e,
|
||||
"Error during long press text selection on page $pageIndex"
|
||||
)
|
||||
pageErrorMessage = errorSelection
|
||||
pageErrorMessage = context.getString(
|
||||
R.string.error_selection,
|
||||
e.readablePdfErrorDetail()
|
||||
)
|
||||
customMenuState = null
|
||||
selectionCharRange.value = null
|
||||
selectedWordScreenRects = emptyList()
|
||||
|
|
@ -2774,7 +2874,8 @@ internal fun PdfPageComposable(
|
|||
selectedTool,
|
||||
isStylusOnlyMode,
|
||||
userHighlightScreenRects,
|
||||
bubbleTapSlopPx
|
||||
bubbleTapSlopPx,
|
||||
isScrollLocked
|
||||
) {
|
||||
val isTapDetectionAllowed = !isEditMode ||
|
||||
selectedTool == InkType.TEXT ||
|
||||
|
|
@ -3040,7 +3141,7 @@ internal fun PdfPageComposable(
|
|||
onScaleChanged(scale)
|
||||
}
|
||||
}
|
||||
} else if (isVerticalScroll && currentOnDoubleTap != null) {
|
||||
} else if (isVerticalScroll && !isScrollLocked && currentOnDoubleTap != null) {
|
||||
currentOnDoubleTap!!(tapOffset)
|
||||
}
|
||||
})
|
||||
|
|
@ -3276,7 +3377,7 @@ internal fun PdfPageComposable(
|
|||
val startOffset = offset
|
||||
paginationPanFlingJob = coroutineScope.launch {
|
||||
try {
|
||||
coroutineScope {
|
||||
kotlinx.coroutines.coroutineScope {
|
||||
launch {
|
||||
if (flingX != 0f) {
|
||||
Animatable(startOffset.x).animateDecay(flingX, decay) {
|
||||
|
|
@ -3543,15 +3644,37 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
|
||||
var previousLockedViewportSize by remember { mutableStateOf<Pair<Dp, Dp>?>(null) }
|
||||
|
||||
LaunchedEffect(
|
||||
pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight,
|
||||
isScrollLocked, lockedState
|
||||
) {
|
||||
if (isScrollLocked && !isVerticalScroll && lockedState != null) {
|
||||
scale = lockedState.first
|
||||
offset = Offset(lockedState.second, lockedState.third)
|
||||
val currentViewportSize = this@BoxWithConstraints.maxWidth to this@BoxWithConstraints.maxHeight
|
||||
val previousViewportSize = previousLockedViewportSize
|
||||
val orientationChanged = previousViewportSize != null &&
|
||||
(previousViewportSize.first > previousViewportSize.second) !=
|
||||
(currentViewportSize.first > currentViewportSize.second)
|
||||
previousLockedViewportSize = currentViewportSize
|
||||
|
||||
if (isScrollLocked && !isVerticalScroll) {
|
||||
if (orientationChanged) {
|
||||
scale = 1f
|
||||
offset = Offset.Zero
|
||||
hasAppliedLockedPaginationState = true
|
||||
Timber.tag("PdfLockDiagnostic").i(
|
||||
"Orientation changed while locked; reset paginated zoom to fit on page $pageIndex"
|
||||
)
|
||||
} else if (lockedState != null) {
|
||||
scale = lockedState.first
|
||||
offset = Offset(lockedState.second, lockedState.third)
|
||||
hasAppliedLockedPaginationState = true
|
||||
} else {
|
||||
hasAppliedLockedPaginationState = true
|
||||
}
|
||||
onScaleChanged(scale)
|
||||
} else if (!isScrollLocked && !isVerticalScroll) {
|
||||
hasAppliedLockedPaginationState = false
|
||||
scale = 1f
|
||||
offset = Offset.Zero
|
||||
onScaleChanged(1f)
|
||||
|
|
@ -3699,10 +3822,12 @@ internal fun PdfPageComposable(
|
|||
currentContainerMaxHeight,
|
||||
density,
|
||||
virtualPage,
|
||||
targetPageId,
|
||||
isVisible,
|
||||
currentRenderedPageId
|
||||
) {
|
||||
if (!isVisible && !isVerticalScroll) return@LaunchedEffect
|
||||
pageErrorMessage = null
|
||||
|
||||
val viewContainerWidthPx = with(density) { currentContainerMaxWidth.toPx().toInt() }
|
||||
val viewContainerHeightPx =
|
||||
|
|
@ -3721,12 +3846,18 @@ internal fun PdfPageComposable(
|
|||
1f / 1.414f
|
||||
}
|
||||
|
||||
var scaledWidth = viewContainerWidthPx
|
||||
var scaledHeight = (scaledWidth / pageAspect).toInt()
|
||||
val (scaledWidth, scaledHeight) = if (isVerticalScroll) {
|
||||
viewContainerWidthPx to viewContainerHeightPx
|
||||
} else {
|
||||
var fittedWidth = viewContainerWidthPx
|
||||
var fittedHeight = (fittedWidth / pageAspect).toInt()
|
||||
|
||||
if (scaledHeight > viewContainerHeightPx) {
|
||||
scaledHeight = viewContainerHeightPx
|
||||
scaledWidth = (scaledHeight * pageAspect).toInt()
|
||||
if (fittedHeight > viewContainerHeightPx) {
|
||||
fittedHeight = viewContainerHeightPx
|
||||
fittedWidth = (fittedHeight * pageAspect).toInt()
|
||||
}
|
||||
|
||||
fittedWidth to fittedHeight
|
||||
}
|
||||
|
||||
if (scaledWidth == actualBitmapWidthPx &&
|
||||
|
|
@ -3760,7 +3891,7 @@ internal fun PdfPageComposable(
|
|||
|
||||
val old = bitmapState
|
||||
if (old != null && old !== finalBitmap) {
|
||||
if (old !== PdfThumbnailCache.get(pageIndex)) {
|
||||
if (old !== PdfThumbnailCache.get(targetPageId)) {
|
||||
old.recycle()
|
||||
}
|
||||
}
|
||||
|
|
@ -3771,7 +3902,7 @@ internal fun PdfPageComposable(
|
|||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
kotlinx.coroutines.coroutineScope {
|
||||
var localBitmap: Bitmap? = null
|
||||
try {
|
||||
val renderResult = withContext(Dispatchers.IO) {
|
||||
|
|
@ -3783,62 +3914,68 @@ internal fun PdfPageComposable(
|
|||
return@withContext null
|
||||
}
|
||||
val page = pdfDocumentItem.openPage(pdfPageIndex) ?: return@withContext null
|
||||
val rotation = page.getPageRotation()
|
||||
val screenDpi = (density.density * 160).roundToInt()
|
||||
val originalWidthPdfUnits = page.getPageWidthPoint()
|
||||
val originalHeightPdfUnits = page.getPageHeightPoint()
|
||||
try {
|
||||
val rotation = page.getPageRotation()
|
||||
val originalWidthPdfUnits = page.getPageWidthPoint()
|
||||
val originalHeightPdfUnits = page.getPageHeightPoint()
|
||||
|
||||
if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
|
||||
if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
|
||||
throw Exception("Invalid page dimensions")
|
||||
}
|
||||
|
||||
val aspectRatio =
|
||||
originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat()
|
||||
val (scaledWidth, scaledHeight) = if (isVerticalScroll) {
|
||||
viewContainerWidthPx to viewContainerHeightPx
|
||||
} else {
|
||||
var fittedWidth = viewContainerWidthPx
|
||||
var fittedHeight = (fittedWidth / aspectRatio).toInt()
|
||||
|
||||
if (fittedHeight > viewContainerHeightPx) {
|
||||
fittedHeight = viewContainerHeightPx
|
||||
fittedWidth = (fittedHeight * aspectRatio).toInt()
|
||||
}
|
||||
|
||||
fittedWidth to fittedHeight
|
||||
}
|
||||
|
||||
if (scaledWidth == actualBitmapWidthPx &&
|
||||
scaledHeight == actualBitmapHeightPx &&
|
||||
bitmapState != null &&
|
||||
currentRenderedPageId == targetPageId
|
||||
) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val MAX_BASE_DIMEN = 3000
|
||||
|
||||
val baseRenderScale = 1.5f
|
||||
|
||||
var baseW = (scaledWidth * baseRenderScale).toInt()
|
||||
var baseH = (scaledHeight * baseRenderScale).toInt()
|
||||
|
||||
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
|
||||
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
||||
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
||||
baseH = (baseH * downScale).toInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
Timber.d(
|
||||
"Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})"
|
||||
)
|
||||
val newBitmap = createBitmap(baseW, baseH)
|
||||
localBitmap = newBitmap
|
||||
page.renderPageBitmap(
|
||||
newBitmap,
|
||||
0, 0,
|
||||
baseW, baseH,
|
||||
true
|
||||
)
|
||||
|
||||
Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight))
|
||||
} finally {
|
||||
page.close()
|
||||
throw Exception("Invalid page dimensions")
|
||||
}
|
||||
|
||||
val aspectRatio =
|
||||
originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat()
|
||||
var scaledWidth = viewContainerWidthPx
|
||||
var scaledHeight = (scaledWidth / aspectRatio).toInt()
|
||||
|
||||
if (scaledHeight > viewContainerHeightPx) {
|
||||
scaledHeight = viewContainerHeightPx
|
||||
scaledWidth = (scaledHeight * aspectRatio).toInt()
|
||||
}
|
||||
|
||||
if (scaledWidth == actualBitmapWidthPx &&
|
||||
scaledHeight == actualBitmapHeightPx &&
|
||||
bitmapState != null &&
|
||||
currentRenderedPageId == targetPageId
|
||||
) {
|
||||
page.close()
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val MAX_BASE_DIMEN = 3000
|
||||
|
||||
val baseRenderScale = 1.5f
|
||||
|
||||
var baseW = (scaledWidth * baseRenderScale).toInt()
|
||||
var baseH = (scaledHeight * baseRenderScale).toInt()
|
||||
|
||||
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
|
||||
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
||||
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
||||
baseH = (baseH * downScale).toInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
Timber.d(
|
||||
"Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})"
|
||||
)
|
||||
val newBitmap = createBitmap(baseW, baseH)
|
||||
localBitmap = newBitmap
|
||||
page.renderPageBitmap(
|
||||
newBitmap,
|
||||
0, 0,
|
||||
baseW, baseH,
|
||||
true
|
||||
)
|
||||
page.close()
|
||||
|
||||
Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight))
|
||||
}
|
||||
|
||||
if (renderResult != null) {
|
||||
|
|
@ -3856,7 +3993,7 @@ internal fun PdfPageComposable(
|
|||
|
||||
withContext(Dispatchers.IO) {
|
||||
if (old != null && old !== newBitmap && !old.isRecycled) {
|
||||
val cached = PdfThumbnailCache.get(pageIndex)
|
||||
val cached = PdfThumbnailCache.get(targetPageId)
|
||||
if (old !== cached) {
|
||||
old.recycle()
|
||||
}
|
||||
|
|
@ -3866,14 +4003,17 @@ internal fun PdfPageComposable(
|
|||
val thumbHeight = newBitmap.height / 2
|
||||
if (thumbWidth > 0 && thumbHeight > 0) {
|
||||
PdfThumbnailCache.put(
|
||||
pageIndex, newBitmap.scale(thumbWidth, thumbHeight)
|
||||
targetPageId, newBitmap.scale(thumbWidth, thumbHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
pageErrorMessage = errorProcessingPage
|
||||
pageErrorMessage = context.getString(
|
||||
R.string.error_processing_page,
|
||||
e.readablePdfErrorDetail()
|
||||
)
|
||||
} finally {
|
||||
isLoadingPage = false
|
||||
localBitmap?.recycle()
|
||||
|
|
@ -4265,6 +4405,7 @@ internal fun PdfPageComposable(
|
|||
contentToScreenCoordinates = contentToScreenCoordinates,
|
||||
density = density,
|
||||
isVerticalScroll = isVerticalScroll,
|
||||
showPageNumberOverlay = showPageNumberOverlay,
|
||||
isScrolling = isScrolling,
|
||||
isEditMode = isEditMode,
|
||||
selectedTool = selectedTool,
|
||||
|
|
@ -4296,7 +4437,7 @@ internal fun PdfPageComposable(
|
|||
|
||||
else -> {
|
||||
Text(
|
||||
text = stringResource(R.string.error_unable_to_display_page),
|
||||
text = stringResource(R.string.error_unable_to_display_page, pageIndex + 1),
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.align(Alignment.Center)
|
||||
|
|
@ -4355,7 +4496,13 @@ private fun PdfBitmapLayer(
|
|||
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
|
||||
translate(left = centeringOffsetX, top = centeringOffsetY) {
|
||||
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
|
||||
if (bitmapState != null && !bitmapState.isRecycled) {
|
||||
if (
|
||||
bitmapState != null &&
|
||||
bitmapState.isCanvasSafeBitmap(
|
||||
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
||||
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
||||
)
|
||||
) {
|
||||
val dstW = if (targetWidth > 0) targetWidth else bitmapState.width
|
||||
val dstH = if (targetHeight > 0) targetHeight else bitmapState.height
|
||||
val srcSize = IntSize(bitmapState.width, bitmapState.height)
|
||||
|
|
@ -4400,7 +4547,12 @@ private fun PdfBitmapLayer(
|
|||
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
|
||||
if (needsTiling) {
|
||||
tiles.forEach { tile ->
|
||||
if (!tile.bitmap.isRecycled) {
|
||||
if (
|
||||
tile.bitmap.isCanvasSafeBitmap(
|
||||
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
||||
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
||||
)
|
||||
) {
|
||||
drawImage(
|
||||
image = tile.bitmap.asImageBitmap(),
|
||||
srcOffset = IntOffset.Zero,
|
||||
|
|
@ -5086,6 +5238,7 @@ private fun PdfPageRenderer(
|
|||
contentToScreenCoordinates: (Offset) -> Offset,
|
||||
density: Density,
|
||||
isVerticalScroll: Boolean,
|
||||
showPageNumberOverlay: Boolean,
|
||||
isScrolling: Boolean,
|
||||
isEditMode: Boolean,
|
||||
selectedTool: InkType,
|
||||
|
|
@ -5282,7 +5435,7 @@ private fun PdfPageRenderer(
|
|||
}
|
||||
|
||||
// Layer 4: Page Number Indicator
|
||||
if (totalPages > 0) {
|
||||
if (showPageNumberOverlay && totalPages > 0) {
|
||||
val pageNumColor = if (staticData.isDarkMode) {
|
||||
Color.White
|
||||
} else {
|
||||
|
|
@ -5439,10 +5592,12 @@ private fun PdfPageRenderer(
|
|||
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(),
|
||||
currentScale = effectiveScale,
|
||||
magnifierCenterOnBitmap = magnifierCenterTarget,
|
||||
contentWidthPx = staticData.targetWidth,
|
||||
contentHeightPx = staticData.targetHeight,
|
||||
magnifierWidth = magnifierWidth,
|
||||
magnifierHeight = magnifierHeight,
|
||||
zoomFactor = effectiveZoomFactor,
|
||||
selectionRectsInBitmapCoords = selectionData.mergedSelectionRects.item,
|
||||
selectionRectsInContentCoords = selectionData.mergedSelectionRects.item,
|
||||
highlightColor = Color(0x6633B5E5),
|
||||
colorFilter = staticData.colorFilter.item,
|
||||
modifier = Modifier
|
||||
|
|
@ -5593,6 +5748,21 @@ private fun PdfPageRenderer(
|
|||
}
|
||||
|
||||
if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) {
|
||||
val baseBitmap = staticData.bitmap.item ?: return@Canvas
|
||||
if (
|
||||
!baseBitmap.isCanvasSafeBitmap(
|
||||
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
||||
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
||||
)
|
||||
) {
|
||||
return@Canvas
|
||||
}
|
||||
val safeExpandedBubbleRender = expandedBubbleRender?.takeIf {
|
||||
it.bitmap.isCanvasSafeBitmap(
|
||||
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
||||
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
||||
)
|
||||
}
|
||||
val bubble = detectedBubbles[animatingBubbleIndex]
|
||||
val left = bubble.bounds.left + staticData.centeringOffsetX
|
||||
val top = bubble.bounds.top + staticData.centeringOffsetY
|
||||
|
|
@ -5600,7 +5770,7 @@ private fun PdfPageRenderer(
|
|||
val logicalHeight = bubble.bounds.height()
|
||||
val pivotX = left + logicalWidth / 2f
|
||||
val pivotY = top + logicalHeight / 2f
|
||||
val targetZoomFactor = expandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
|
||||
val targetZoomFactor = safeExpandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
|
||||
bubbleBounds = bubble.bounds,
|
||||
viewportWidth = staticData.canvasWidth,
|
||||
viewportHeight = staticData.canvasHeight
|
||||
|
|
@ -5613,8 +5783,8 @@ private fun PdfPageRenderer(
|
|||
val dstOffset = IntOffset(left.toInt(), top.toInt())
|
||||
val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt())
|
||||
|
||||
val renderScaleX = staticData.bitmap.item.width.toFloat() / staticData.targetWidth.toFloat()
|
||||
val renderScaleY = staticData.bitmap.item.height.toFloat() / staticData.targetHeight.toFloat()
|
||||
val renderScaleX = baseBitmap.width.toFloat() / staticData.targetWidth.toFloat()
|
||||
val renderScaleY = baseBitmap.height.toFloat() / staticData.targetHeight.toFloat()
|
||||
|
||||
val srcOffset = IntOffset(
|
||||
(bubble.bounds.left * renderScaleX).toInt(),
|
||||
|
|
@ -5651,12 +5821,12 @@ private fun PdfPageRenderer(
|
|||
)
|
||||
drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint())
|
||||
drawImage(
|
||||
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
|
||||
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
||||
srcSize = if (expandedBubbleRender != null) {
|
||||
image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(),
|
||||
srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
||||
srcSize = if (safeExpandedBubbleRender != null) {
|
||||
IntSize(
|
||||
expandedBubbleRender.bitmap.width,
|
||||
expandedBubbleRender.bitmap.height)
|
||||
safeExpandedBubbleRender.bitmap.width,
|
||||
safeExpandedBubbleRender.bitmap.height)
|
||||
} else {
|
||||
srcSize
|
||||
},
|
||||
|
|
@ -5675,12 +5845,12 @@ private fun PdfPageRenderer(
|
|||
} else {
|
||||
clipRect(left, top, left + logicalWidth, top + logicalHeight) {
|
||||
drawImage(
|
||||
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
|
||||
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
||||
srcSize = if (expandedBubbleRender != null) {
|
||||
image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(),
|
||||
srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
||||
srcSize = if (safeExpandedBubbleRender != null) {
|
||||
IntSize(
|
||||
expandedBubbleRender.bitmap.width,
|
||||
expandedBubbleRender.bitmap.height)
|
||||
safeExpandedBubbleRender.bitmap.width,
|
||||
safeExpandedBubbleRender.bitmap.height)
|
||||
} else {
|
||||
srcSize
|
||||
},
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ internal const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
|
|||
internal const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
|
||||
internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
|
||||
internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
|
||||
internal const val PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY = "pdf_vertical_page_gap_visible"
|
||||
internal const val PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY = "pdf_page_number_overlay_visible"
|
||||
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
||||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version"
|
||||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 2
|
||||
|
||||
enum class PdfReaderTool(val title: String, val category: String) {
|
||||
DICTIONARY("External Apps", "Top Bar"),
|
||||
|
|
@ -62,8 +66,9 @@ enum class PdfReaderTool(val title: String, val category: String) {
|
|||
OCR_LANGUAGE("OCR Language", "Overflow Menu"),
|
||||
READING_MODE("Reading Mode", "Overflow Menu"),
|
||||
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
||||
SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
|
||||
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
||||
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
|
||||
TTS_SETTINGS("TTS Settings", "Overflow Menu"),
|
||||
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu"),
|
||||
BOOKMARK("Bookmark", "Overflow Menu"),
|
||||
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
|
||||
|
|
@ -91,12 +96,28 @@ val PdfBuiltInThemes = listOf(
|
|||
|
||||
internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
||||
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>) {
|
||||
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> {
|
||||
|
|
@ -164,6 +185,26 @@ internal fun loadPdfSystemUiMode(context: Context): SystemUiMode {
|
|||
return SystemUiMode.entries.find { it.id == id } ?: SystemUiMode.SYNC
|
||||
}
|
||||
|
||||
internal fun savePdfVerticalPageGapVisible(context: Context, isVisible: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY, isVisible) }
|
||||
}
|
||||
|
||||
internal fun loadPdfVerticalPageGapVisible(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY, true)
|
||||
}
|
||||
|
||||
internal fun savePdfPageNumberOverlayVisible(context: Context, isVisible: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, isVisible) }
|
||||
}
|
||||
|
||||
internal fun loadPdfPageNumberOverlayVisible(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, true)
|
||||
}
|
||||
|
||||
internal fun savePdfThemeId(context: Context, themeId: String) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(PDF_THEME_KEY, themeId) }
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.itemContentType
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.SearchResult
|
||||
|
||||
|
|
@ -154,9 +153,10 @@ fun PdfSearchResultsPanel(
|
|||
HorizontalDivider()
|
||||
|
||||
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
|
||||
items(count = lazyResults.itemCount, key = lazyResults.itemKey {
|
||||
"${it.locationInSource}_${it.occurrenceIndexInLocation}"
|
||||
}, contentType = lazyResults.itemContentType { "SearchResult" }) { index ->
|
||||
items(
|
||||
count = lazyResults.itemCount,
|
||||
contentType = lazyResults.itemContentType { "SearchResult" }
|
||||
) { index ->
|
||||
val result = lazyResults[index]
|
||||
if (result != null) {
|
||||
ListItem(
|
||||
|
|
|
|||
|
|
@ -36,11 +36,14 @@ import androidx.compose.material.icons.filled.Menu
|
|||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ScreenRotation
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -143,7 +146,8 @@ fun PdfCustomizeToolsSheet(
|
|||
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
|
||||
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
|
||||
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
|
||||
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS
|
||||
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS,
|
||||
PdfReaderTool.SCREEN_ORIENTATION
|
||||
)
|
||||
|
||||
var localHiddenTools by remember { mutableStateOf(hiddenTools) }
|
||||
|
|
@ -504,6 +508,7 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
|||
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
|
@ -511,7 +516,11 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
|||
@Composable
|
||||
fun PdfVisualOptionsSheet(
|
||||
systemUiMode: SystemUiMode,
|
||||
showVerticalPageGap: Boolean,
|
||||
showPageNumberOverlay: Boolean,
|
||||
onSystemUiModeChange: (SystemUiMode) -> Unit,
|
||||
onShowVerticalPageGapChange: (Boolean) -> Unit,
|
||||
onShowPageNumberOverlayChange: (Boolean) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
|
@ -549,6 +558,54 @@ fun PdfVisualOptionsSheet(
|
|||
onOptionSelected = onSystemUiModeChange,
|
||||
getLabel = { it.title }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(stringResource(R.string.visual_options_page_layout), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
PdfVisualOptionSwitchRow(
|
||||
title = stringResource(R.string.visual_options_remove_page_gap),
|
||||
description = stringResource(R.string.visual_options_remove_page_gap_desc),
|
||||
checked = !showVerticalPageGap,
|
||||
onCheckedChange = { removeGap ->
|
||||
onShowVerticalPageGapChange(!removeGap)
|
||||
}
|
||||
)
|
||||
PdfVisualOptionSwitchRow(
|
||||
title = stringResource(R.string.visual_options_hide_page_number_overlay),
|
||||
description = stringResource(R.string.visual_options_hide_page_number_overlay_desc),
|
||||
checked = !showPageNumberOverlay,
|
||||
onCheckedChange = { hideOverlay ->
|
||||
onShowPageNumberOverlayChange(!hideOverlay)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfVisualOptionSwitchRow(
|
||||
title: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ import com.aryan.reader.areReaderAiFeaturesEnabled
|
|||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
import kotlin.collections.isNotEmpty
|
||||
|
||||
internal val PdfTabStripHeight = 44.dp
|
||||
|
||||
private val pdfToolbarTools = setOf(
|
||||
PdfReaderTool.DICTIONARY,
|
||||
PdfReaderTool.THEME,
|
||||
|
|
@ -57,7 +59,8 @@ private val pdfToolbarTools = setOf(
|
|||
PdfReaderTool.HIGHLIGHT_ALL,
|
||||
PdfReaderTool.AI_FEATURES,
|
||||
PdfReaderTool.EDIT_MODE,
|
||||
PdfReaderTool.TTS_CONTROLS
|
||||
PdfReaderTool.TTS_CONTROLS,
|
||||
PdfReaderTool.SCREEN_ORIENTATION
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -81,6 +84,7 @@ internal fun PdfTopBar(
|
|||
isScrollLocked: Boolean,
|
||||
isEditMode: Boolean,
|
||||
displayMode: DisplayMode,
|
||||
isRightToLeftPagination: Boolean,
|
||||
isKeepScreenOn: Boolean,
|
||||
isTtsSessionActive: Boolean,
|
||||
isBookmarked: Boolean,
|
||||
|
|
@ -101,6 +105,7 @@ internal fun PdfTopBar(
|
|||
onShowCustomizeTools: () -> Unit,
|
||||
onShowOcrLanguage: () -> Unit,
|
||||
onShowVisualOptions: () -> Unit,
|
||||
onShowScreenOrientation: () -> Unit,
|
||||
onShowSlider: () -> Unit,
|
||||
onShowToc: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
|
|
@ -114,6 +119,7 @@ internal fun PdfTopBar(
|
|||
tapToNavigateEnabled: Boolean,
|
||||
onToggleTapToNavigate: () -> Unit,
|
||||
onChangeDisplayMode: (DisplayMode) -> Unit,
|
||||
onSetRightToLeftPagination: (Boolean) -> Unit,
|
||||
onToggleKeepScreenOn: () -> Unit,
|
||||
onStartAutoScroll: () -> Unit,
|
||||
onShowTtsSettings: () -> Unit,
|
||||
|
|
@ -262,6 +268,13 @@ internal fun PdfTopBar(
|
|||
) {
|
||||
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
||||
text = stringResource(R.string.menu_screen_orientation),
|
||||
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
||||
onClick = onShowScreenOrientation
|
||||
) {
|
||||
Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
|
@ -281,11 +294,17 @@ internal fun PdfTopBar(
|
|||
Box {
|
||||
var showMoreMenu by remember { mutableStateOf(false) }
|
||||
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
|
||||
var showReadingModeExpanded by remember { mutableStateOf(false) }
|
||||
var showTtsSettingsExpanded by remember { mutableStateOf(false) }
|
||||
var showFileActionsExpanded by remember { mutableStateOf(false) }
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_more_options),
|
||||
description = stringResource(R.string.tooltip_more_options_desc),
|
||||
onClick = {
|
||||
showHiddenToolsExpanded = false
|
||||
showReadingModeExpanded = false
|
||||
showTtsSettingsExpanded = false
|
||||
showFileActionsExpanded = false
|
||||
showMoreMenu = true
|
||||
}) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
|
||||
|
|
@ -295,6 +314,9 @@ internal fun PdfTopBar(
|
|||
expanded = showMoreMenu,
|
||||
onDismissRequest = {
|
||||
showHiddenToolsExpanded = false
|
||||
showReadingModeExpanded = false
|
||||
showTtsSettingsExpanded = false
|
||||
showFileActionsExpanded = false
|
||||
showMoreMenu = false
|
||||
}
|
||||
) {
|
||||
|
|
@ -340,7 +362,8 @@ internal fun PdfTopBar(
|
|||
onToggleHighlights = onToggleHighlights,
|
||||
onShowAiHub = onShowAiHub,
|
||||
onToggleEditMode = onToggleEditMode,
|
||||
onToggleTts = onToggleTts
|
||||
onToggleTts = onToggleTts,
|
||||
onShowScreenOrientation = onShowScreenOrientation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -366,18 +389,52 @@ internal fun PdfTopBar(
|
|||
|
||||
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
|
||||
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { onChangeDisplayMode(DisplayMode.PAGINATION); showMoreMenu = false },
|
||||
trailingIcon = { if (displayMode == DisplayMode.PAGINATION) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
|
||||
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showReadingModeExpanded) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
|
||||
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(false)
|
||||
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayMode == DisplayMode.PAGINATION && !isRightToLeftPagination) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(true)
|
||||
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayMode == DisplayMode.PAGINATION && isRightToLeftPagination) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
|
|
@ -419,24 +476,41 @@ internal fun PdfTopBar(
|
|||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) {
|
||||
val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)
|
||||
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
|
||||
if (showTtsVoiceSettings || showTtsReplacements) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
text = { Text(stringResource(R.string.menu_tts_settings)) },
|
||||
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showTtsSettingsExpanded) {
|
||||
if (showTtsVoiceSettings) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
if (showTtsReplacements) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
||||
onClick = { showMoreMenu = false; onShowTtsReplacements() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
||||
onClick = { showMoreMenu = false; onShowTtsReplacements() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
|
||||
|
|
@ -462,7 +536,7 @@ internal fun PdfTopBar(
|
|||
|
||||
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_reflow_progress); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
|
||||
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_text_view); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
|
||||
enabled = isPdfDocumentLoaded && !isReflowingThisBook,
|
||||
onClick = { showMoreMenu = false; onReflowAction() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
|
|
@ -470,28 +544,45 @@ internal fun PdfTopBar(
|
|||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.SHARE.name)) {
|
||||
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
|
||||
val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)
|
||||
val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)
|
||||
if (showShareAction || showSaveCopyAction || showPrintAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_share)) },
|
||||
onClick = { showMoreMenu = false; onShare() },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
|
||||
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
|
||||
onClick = { showMoreMenu = false; onSaveCopy() },
|
||||
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
|
||||
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_print)) },
|
||||
onClick = { showMoreMenu = false; onPrint() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
text = { Text(stringResource(R.string.menu_share_save_print)) },
|
||||
onClick = { showFileActionsExpanded = !showFileActionsExpanded },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showFileActionsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showFileActionsExpanded) {
|
||||
if (showShareAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_share)) },
|
||||
onClick = { showMoreMenu = false; onShare() },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
if (showSaveCopyAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
|
||||
onClick = { showMoreMenu = false; onSaveCopy() },
|
||||
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
if (showPrintAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_print)) },
|
||||
onClick = { showMoreMenu = false; onPrint() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -499,7 +590,7 @@ internal fun PdfTopBar(
|
|||
}
|
||||
if (isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF) {
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
|
||||
modifier = Modifier.fillMaxWidth().height(PdfTabStripHeight).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
items(openTabs, key = { it.bookId }) { tab ->
|
||||
|
|
@ -509,7 +600,7 @@ internal fun PdfTopBar(
|
|||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(if (isSelected) 44.dp else 36.dp)
|
||||
.height(if (isSelected) PdfTabStripHeight else 36.dp)
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.background(bgColor)
|
||||
.clickable { onTabClick(tab.bookId) }
|
||||
|
|
@ -564,7 +655,8 @@ private fun HiddenPdfToolMenuItem(
|
|||
onToggleHighlights: () -> Unit,
|
||||
onShowAiHub: () -> Unit,
|
||||
onToggleEditMode: () -> Unit,
|
||||
onToggleTts: () -> Unit
|
||||
onToggleTts: () -> Unit,
|
||||
onShowScreenOrientation: () -> Unit
|
||||
) {
|
||||
val enabled = when (tool) {
|
||||
PdfReaderTool.SLIDER,
|
||||
|
|
@ -588,6 +680,7 @@ private fun HiddenPdfToolMenuItem(
|
|||
PdfReaderTool.AI_FEATURES -> onShowAiHub()
|
||||
PdfReaderTool.EDIT_MODE -> onToggleEditMode()
|
||||
PdfReaderTool.TTS_CONTROLS -> onToggleTts()
|
||||
PdfReaderTool.SCREEN_ORIENTATION -> onShowScreenOrientation()
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
|
|
@ -606,6 +699,7 @@ private fun HiddenPdfToolMenuItem(
|
|||
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
PdfReaderTool.TTS_CONTROLS -> Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
|
@ -766,6 +860,8 @@ fun PdfBottomBar(
|
|||
onShowAiHub: () -> Unit,
|
||||
onToggleEditMode: () -> Unit,
|
||||
onToggleTts: () -> Unit,
|
||||
onShowScreenOrientation: () -> Unit,
|
||||
showBubbleZoom: Boolean,
|
||||
isBubbleZoomModeActive: Boolean,
|
||||
onToggleBubbleZoom: () -> Unit
|
||||
) {
|
||||
|
|
@ -868,11 +964,18 @@ fun PdfBottomBar(
|
|||
) {
|
||||
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
||||
text = stringResource(R.string.menu_screen_orientation),
|
||||
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
||||
onClick = onShowScreenOrientation
|
||||
) {
|
||||
Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.FLAVOR != "oss") {
|
||||
if (BuildConfig.FLAVOR != "oss" && showBubbleZoom) {
|
||||
TooltipIconButton(
|
||||
text = if (isBubbleZoomModeActive) stringResource(R.string.action_exit_smart_zoom) else stringResource(R.string.action_smart_comic_zoom),
|
||||
description = stringResource(R.string.desc_toggle_smart_comic_zoom),
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -81,7 +80,6 @@ import androidx.compose.runtime.withFrameNanos
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
|
|
@ -101,7 +99,6 @@ import androidx.compose.ui.layout.Layout
|
|||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
|
@ -116,6 +113,8 @@ import com.aryan.reader.ml.SpeechBubble
|
|||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import com.aryan.reader.pdf.data.PdfTextBox
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
import com.aryan.reader.shared.pdf.calculatePdfVerticalPageLayoutPx
|
||||
import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -175,14 +174,70 @@ fun rememberVerticalPdfReaderState(): VerticalPdfReaderState {
|
|||
|
||||
private data class PdfPageLayout(
|
||||
val index: Int,
|
||||
val y: Float,
|
||||
val height: Float,
|
||||
val width: Float,
|
||||
val yPx: Int,
|
||||
val heightPx: Int,
|
||||
val widthPx: Int,
|
||||
val widthDp: Dp,
|
||||
val heightDp: Dp
|
||||
) {
|
||||
val y: Float
|
||||
get() = yPx.toFloat()
|
||||
|
||||
val height: Float
|
||||
get() = heightPx.toFloat()
|
||||
|
||||
val width: Float
|
||||
get() = widthPx.toFloat()
|
||||
}
|
||||
|
||||
private data class DividerLayout(val yPx: Int, val widthPx: Int, val heightPx: Int) {
|
||||
val y: Float
|
||||
get() = yPx.toFloat()
|
||||
|
||||
val width: Float
|
||||
get() = widthPx.toFloat()
|
||||
|
||||
val height: Float
|
||||
get() = heightPx.toFloat()
|
||||
}
|
||||
|
||||
internal data class PdfLockedOrientationResetCamera(
|
||||
val zoom: Float,
|
||||
val panX: Float,
|
||||
val panY: Float
|
||||
)
|
||||
|
||||
private data class DividerLayout(val y: Float, val width: Float, val height: Float)
|
||||
internal fun calculateLockedOrientationResetCamera(
|
||||
pageTopY: Float,
|
||||
totalDocHeight: Float,
|
||||
screenWidth: Float,
|
||||
screenHeight: Float,
|
||||
headerHeightPx: Float,
|
||||
footerHeightPx: Float,
|
||||
fitZoom: Float
|
||||
): PdfLockedOrientationResetCamera {
|
||||
val targetPanY = headerHeightPx - (pageTopY * fitZoom)
|
||||
val zoomedDocHeight = totalDocHeight * fitZoom
|
||||
val minPanY = if (zoomedDocHeight < (screenHeight - headerHeightPx - footerHeightPx)) {
|
||||
headerHeightPx
|
||||
} else {
|
||||
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
|
||||
}
|
||||
val finalPanY = targetPanY.coerceIn(minPanY, headerHeightPx)
|
||||
|
||||
val zoomedDocWidth = screenWidth * fitZoom
|
||||
val targetPanX = if (zoomedDocWidth < screenWidth) {
|
||||
(screenWidth - zoomedDocWidth) / 2f
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
return PdfLockedOrientationResetCamera(
|
||||
zoom = fitZoom,
|
||||
panX = targetPanX,
|
||||
panY = finalPanY
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UnusedVariable")
|
||||
@SuppressLint("UnusedBoxWithConstraintsScope", "BinaryOperationInTimber")
|
||||
|
|
@ -192,6 +247,7 @@ internal fun PdfVerticalReader(
|
|||
modifier: Modifier = Modifier,
|
||||
state: VerticalPdfReaderState,
|
||||
pdfDocument: StableHolder<ReaderDocument>,
|
||||
documentKey: String,
|
||||
activeTheme: com.aryan.reader.ReaderTheme,
|
||||
activeTextureAlpha: Float = 0.55f,
|
||||
excludeImages: Boolean = false,
|
||||
|
|
@ -230,6 +286,7 @@ internal fun PdfVerticalReader(
|
|||
selectedTool: InkType,
|
||||
richTextController: RichTextController? = null,
|
||||
textBoxes: List<PdfTextBox> = emptyList(),
|
||||
textBoxesByPage: Map<Int, List<PdfTextBox>> = emptyMap(),
|
||||
selectedTextBoxId: String? = null,
|
||||
onTextBoxChange: (PdfTextBox) -> Unit = {},
|
||||
onTextBoxSelect: (String) -> Unit = {},
|
||||
|
|
@ -245,6 +302,7 @@ internal fun PdfVerticalReader(
|
|||
stylusButtonHovering: Boolean = false,
|
||||
isHighlighterSnapEnabled: Boolean = false,
|
||||
userHighlights: List<PdfUserHighlight> = emptyList(),
|
||||
userHighlightsByPage: Map<Int, List<PdfUserHighlight>> = emptyMap(),
|
||||
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
|
||||
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
|
||||
onHighlightDelete: (String) -> Unit = {},
|
||||
|
|
@ -258,9 +316,10 @@ internal fun PdfVerticalReader(
|
|||
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
|
||||
resetZoomTrigger: Long = 0L,
|
||||
isBubbleZoomModeActive: Boolean = false,
|
||||
showPageGap: Boolean = true,
|
||||
showPageNumberOverlay: Boolean = true,
|
||||
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() }
|
||||
) {
|
||||
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
|
||||
DisposableEffect(state) {
|
||||
onDispose {
|
||||
state.scrollToPageHandler = null
|
||||
|
|
@ -273,6 +332,13 @@ internal fun PdfVerticalReader(
|
|||
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
|
||||
var isStylusEraserOverride by remember { mutableStateOf(false) }
|
||||
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
|
||||
val verticalPageBackgroundColor = remember(activeTheme) {
|
||||
when (activeTheme.id) {
|
||||
"no_theme", "system" -> Color.White
|
||||
"reverse" -> Color.Black
|
||||
else -> activeTheme.backgroundColor
|
||||
}
|
||||
}
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
|
||||
val imeInsets = WindowInsets.ime
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -282,6 +348,12 @@ internal fun PdfVerticalReader(
|
|||
|
||||
val ratios = pageAspectRatios.item
|
||||
val bookmarkSet = bookmarks.item
|
||||
val effectiveTextBoxesByPage = remember(textBoxes, textBoxesByPage) {
|
||||
textBoxesByPage.ifEmpty { textBoxes.groupBy { it.pageIndex } }
|
||||
}
|
||||
val effectiveUserHighlightsByPage = remember(userHighlights, userHighlightsByPage) {
|
||||
userHighlightsByPage.ifEmpty { userHighlights.groupBy { it.pageIndex } }
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
|
@ -294,56 +366,37 @@ internal fun PdfVerticalReader(
|
|||
val headerHeightPx = with(density) { headerHeight.toPx() }
|
||||
val footerHeightPx = with(density) { footerHeight.toPx() }
|
||||
|
||||
val dividerHeightDp = 8.dp
|
||||
val dividerHeightDp = pdfVerticalPageGapDp(showPageGap, 8.dp)
|
||||
val dividerHeightPx = with(density) { dividerHeightDp.toPx() }
|
||||
val dividerHeightPxInt = dividerHeightPx.roundToInt().coerceAtLeast(0)
|
||||
|
||||
var isFlinging by remember { mutableStateOf(false) }
|
||||
var isFastFlinging by remember { mutableStateOf(false) }
|
||||
var isInteracting by remember { mutableStateOf(false) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
val layoutState = remember(ratios, screenWidth, screenHeight, density) {
|
||||
val layoutState = remember(ratios, constraints.maxWidth, constraints.maxHeight, density, showPageGap, dividerHeightPxInt) {
|
||||
data class LayoutResult(val pages: List<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 ratio = ratios[0]
|
||||
val safeRatio = if (ratio <= 0f) 1f else ratio
|
||||
val pageHeight = screenWidth / safeRatio
|
||||
if (pageHeight < screenHeight) {
|
||||
currentY = ((screenHeight - pageHeight) / 2f).toDouble()
|
||||
}
|
||||
val pages = verticalLayout.pages.map { page ->
|
||||
PdfPageLayout(
|
||||
index = page.pageIndex,
|
||||
yPx = page.topPx,
|
||||
heightPx = page.heightPx,
|
||||
widthPx = page.widthPx,
|
||||
widthDp = with(density) { page.widthPx.toDp() },
|
||||
heightDp = with(density) { page.heightPx.toDp() }
|
||||
)
|
||||
}
|
||||
|
||||
val pages = ratios.mapIndexed { index, ratio ->
|
||||
val safeRatio = if (ratio <= 0f) 1f else ratio
|
||||
val pageHeightDouble = screenWidth.toDouble() / safeRatio.toDouble()
|
||||
val pageHeight = pageHeightDouble.toFloat()
|
||||
|
||||
val info = PdfPageLayout(
|
||||
index = index,
|
||||
y = currentY.toFloat(),
|
||||
height = pageHeight,
|
||||
width = screenWidth,
|
||||
widthDp = with(density) { screenWidth.toDp() },
|
||||
heightDp = with(density) { pageHeight.toDp() })
|
||||
|
||||
currentY += pageHeightDouble
|
||||
if (index < ratios.lastIndex) {
|
||||
currentY += dividerHeightPx
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
val totalH = if (pages.isNotEmpty()) {
|
||||
val last = pages.last()
|
||||
last.y + last.height
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
LayoutResult(pages, totalH)
|
||||
LayoutResult(pages, verticalLayout.totalHeightPx.toFloat())
|
||||
}
|
||||
|
||||
val layoutInfo = layoutState.pages
|
||||
|
|
@ -375,11 +428,17 @@ internal fun PdfVerticalReader(
|
|||
var isResizing by remember { mutableStateOf(false) }
|
||||
var previousScreenWidth by remember { mutableFloatStateOf(0f) }
|
||||
var previousScreenHeight by remember { mutableFloatStateOf(0f) }
|
||||
var lockedOrientationChangedDuringResize by remember { mutableStateOf(false) }
|
||||
val targetPageDuringResize = remember { mutableIntStateOf(-1) }
|
||||
|
||||
if (previousScreenWidth != screenWidth || previousScreenHeight != screenHeight) {
|
||||
if (previousScreenWidth > 0f) {
|
||||
val previousWasLandscape = previousScreenWidth > previousScreenHeight
|
||||
val currentIsLandscape = screenWidth > screenHeight
|
||||
isResizing = true
|
||||
if (isScrollLocked && previousWasLandscape != currentIsLandscape) {
|
||||
lockedOrientationChangedDuringResize = true
|
||||
}
|
||||
if (targetPageDuringResize.intValue == -1) {
|
||||
targetPageDuringResize.intValue = state.currentPage
|
||||
}
|
||||
|
|
@ -424,7 +483,45 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
|
||||
LaunchedEffect(layoutState.pages) {
|
||||
if (!isInitialLayout && !isScrollLocked) {
|
||||
if (!isInitialLayout && isScrollLocked && lockedOrientationChangedDuringResize) {
|
||||
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
|
||||
targetPageDuringResize.intValue
|
||||
} else {
|
||||
state.currentPage
|
||||
}
|
||||
|
||||
val newLayout = layoutState.pages
|
||||
val pageLayout = newLayout.getOrNull(targetPageIdx)
|
||||
|
||||
if (pageLayout != null) {
|
||||
val resetCamera = calculateLockedOrientationResetCamera(
|
||||
pageTopY = pageLayout.y,
|
||||
totalDocHeight = layoutState.totalHeight,
|
||||
screenWidth = screenWidth,
|
||||
screenHeight = screenHeight,
|
||||
headerHeightPx = headerHeightPx,
|
||||
footerHeightPx = footerHeightPx,
|
||||
fitZoom = fitZoom
|
||||
)
|
||||
|
||||
panXAnimatable.updateBounds(null, null)
|
||||
panYAnimatable.updateBounds(null, null)
|
||||
|
||||
coroutineScope {
|
||||
launch { zoomAnimatable.snapTo(resetCamera.zoom) }
|
||||
launch { panXAnimatable.snapTo(resetCamera.panX) }
|
||||
launch { panYAnimatable.snapTo(resetCamera.panY) }
|
||||
}
|
||||
|
||||
state.currentPage = targetPageIdx
|
||||
hasRestoredLockedState = true
|
||||
onZoomChange(resetCamera.zoom)
|
||||
onZoomAndPanChanged?.invoke(resetCamera.zoom, Offset(resetCamera.panX, resetCamera.panY))
|
||||
Timber.tag("PdfLockDiagnostic").i(
|
||||
"Orientation changed while locked; reset zoom to fit and kept page $targetPageIdx"
|
||||
)
|
||||
}
|
||||
} else if (!isInitialLayout && !isScrollLocked) {
|
||||
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
|
||||
targetPageDuringResize.intValue
|
||||
} else {
|
||||
|
|
@ -466,6 +563,7 @@ internal fun PdfVerticalReader(
|
|||
if (!isInitialLayout) {
|
||||
delay(50)
|
||||
isResizing = false
|
||||
lockedOrientationChangedDuringResize = false
|
||||
targetPageDuringResize.intValue = -1
|
||||
}
|
||||
isInitialLayout = false
|
||||
|
|
@ -1080,8 +1178,9 @@ internal fun PdfVerticalReader(
|
|||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (showPageGap) Color.Transparent else verticalPageBackgroundColor)
|
||||
.then(globalDrawingModifier)
|
||||
.pointerInput(isEditMode, selectedTool, isStylusOnlyMode) {
|
||||
.pointerInput(isEditMode, selectedTool, isStylusOnlyMode, isScrollLocked) {
|
||||
Timber.tag("PdfTouchDebug").v(
|
||||
"VerticalReader: TapPointerInput init. isEditMode=$isEditMode"
|
||||
)
|
||||
|
|
@ -1101,8 +1200,10 @@ internal fun PdfVerticalReader(
|
|||
onPageClick()
|
||||
}
|
||||
}, onDoubleTap = { offset ->
|
||||
Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected")
|
||||
onDoubleTapToZoom(offset)
|
||||
if (!isScrollLocked) {
|
||||
Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected")
|
||||
onDoubleTapToZoom(offset)
|
||||
}
|
||||
})
|
||||
}
|
||||
.pointerInput(
|
||||
|
|
@ -1428,11 +1529,16 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
|
||||
val cached = cachedVisiblePages.value
|
||||
val indicesMatch = cached.size == finalPages.size && cached.indices.all {
|
||||
cached[it].index == finalPages[it].index
|
||||
val layoutMatches = cached.size == finalPages.size && cached.indices.all {
|
||||
val cachedPage = cached[it]
|
||||
val newPage = finalPages[it]
|
||||
cachedPage.index == newPage.index &&
|
||||
cachedPage.yPx == newPage.yPx &&
|
||||
cachedPage.heightPx == newPage.heightPx &&
|
||||
cachedPage.widthPx == newPage.widthPx
|
||||
}
|
||||
|
||||
if (!indicesMatch) {
|
||||
if (!layoutMatches) {
|
||||
cachedVisiblePages.value = finalPages
|
||||
Timber.tag("PdfDrawPerf").d(
|
||||
"Vertical Visible Pages Changed: ${finalPages.map { it.index }} (Dragging: ${draggedBox != null})"
|
||||
|
|
@ -1472,20 +1578,13 @@ internal fun PdfVerticalReader(
|
|||
Layout(
|
||||
content = {
|
||||
visiblePages.forEach { page ->
|
||||
key(page.index) {
|
||||
key(documentKey, page.index) {
|
||||
val isBookmarked by remember(bookmarkSet, page.index) {
|
||||
derivedStateOf {
|
||||
bookmarkSet.any { it.pageIndex == page.index }
|
||||
}
|
||||
}
|
||||
|
||||
SideEffect {
|
||||
if (page.index == state.currentPage) {
|
||||
Timber.tag("PdfDrawPerf")
|
||||
.v("VERTICAL READER: Emitting Page ${page.index}")
|
||||
}
|
||||
}
|
||||
|
||||
val visibleScreenRectLambda = remember(page, screenWidth, screenHeight) {
|
||||
{
|
||||
val zoom = zoomAnimatable.value
|
||||
|
|
@ -1579,6 +1678,7 @@ internal fun PdfVerticalReader(
|
|||
{ text: String -> onSearchText(text) }
|
||||
}
|
||||
|
||||
val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom)
|
||||
val onDoubleTapLambda = remember(page, screenWidth, screenHeight) {
|
||||
{ localOffset: Offset ->
|
||||
Timber.tag("PdfZoomDebug").d(
|
||||
|
|
@ -1592,7 +1692,7 @@ internal fun PdfVerticalReader(
|
|||
val screenX = contentX * currentZ + panX
|
||||
val screenY = contentY * currentZ + panY
|
||||
Timber.tag("PdfZoomDebug").d("Mapped to Screen: ($screenX, $screenY)") // Added log
|
||||
onDoubleTapToZoom(Offset(screenX, screenY))
|
||||
currentOnDoubleTapToZoom(Offset(screenX, screenY))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1661,32 +1761,10 @@ internal fun PdfVerticalReader(
|
|||
|
||||
Box(modifier = Modifier
|
||||
.layoutId(page)
|
||||
.graphicsLayer {
|
||||
val z = zoomAnimatable.value
|
||||
val px = panXAnimatable.value
|
||||
val py = panYAnimatable.value
|
||||
|
||||
scaleX = z
|
||||
scaleY = z
|
||||
translationX = px
|
||||
translationY = page.y * (z - 1f) + py
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
|
||||
if (page.index < 2 && z > 1.1f) {
|
||||
Timber.tag("PdfZoomDebug").v("Page ${page.index} Render: TransY=$translationY (PageY=${page.y}, GlobalY=${page.y + translationY})")
|
||||
}
|
||||
}
|
||||
.clipToBounds()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
if (page.index == 0) {
|
||||
val pos = coordinates.positionInWindow()
|
||||
Timber.d(
|
||||
"Page 0 Box | GlobalPos: $pos | Size: ${coordinates.size} | PageY: ${page.y}"
|
||||
)
|
||||
}
|
||||
}) {
|
||||
) {
|
||||
PdfPageComposable(
|
||||
pdfDocument = pdfDocument,
|
||||
documentKey = documentKey,
|
||||
pageIndex = page.index,
|
||||
virtualPage = virtualPage,
|
||||
totalPages = totalPages,
|
||||
|
|
@ -1716,6 +1794,8 @@ internal fun PdfVerticalReader(
|
|||
isZoomEnabled = false,
|
||||
isScrolling = isDragging || (isFlinging && isFastFlinging),
|
||||
isVerticalScroll = true,
|
||||
showPageNumberOverlay = showPageNumberOverlay,
|
||||
isScrollLocked = isScrollLocked,
|
||||
visualScaleProvider = currentScaleProvider,
|
||||
onDoubleTap = onDoubleTapLambda,
|
||||
clearSelectionTrigger = selectionClearTrigger,
|
||||
|
|
@ -1734,11 +1814,11 @@ internal fun PdfVerticalReader(
|
|||
isStylusOnlyMode = isStylusOnlyMode,
|
||||
stylusButtonHovering = stylusButtonHovering,
|
||||
isAutoScrollPlaying = isAutoScrollPlaying,
|
||||
textBoxes = textBoxes.filter { it.pageIndex == page.index },
|
||||
textBoxes = effectiveTextBoxesByPage[page.index].orEmpty(),
|
||||
selectedTextBoxId = selectedTextBoxId,
|
||||
onTextBoxChange = onTextBoxChange,
|
||||
onTextBoxSelect = onTextBoxSelect,
|
||||
userHighlights = userHighlights.filter { it.pageIndex == page.index },
|
||||
userHighlights = effectiveUserHighlightsByPage[page.index].orEmpty(),
|
||||
onHighlightAdd = onHighlightAdd,
|
||||
onHighlightUpdate = onHighlightUpdate,
|
||||
onHighlightDelete = onHighlightDelete,
|
||||
|
|
@ -1852,26 +1932,17 @@ internal fun PdfVerticalReader(
|
|||
)
|
||||
}
|
||||
|
||||
if (page.index < totalPages - 1) {
|
||||
val dividerY = page.y + page.height
|
||||
if (page.index < totalPages - 1 && dividerHeightPxInt > 0) {
|
||||
val dividerYPx = page.yPx + page.heightPx
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.layoutId(
|
||||
DividerLayout(
|
||||
dividerY, page.width, dividerHeightPx
|
||||
yPx = dividerYPx,
|
||||
widthPx = page.widthPx,
|
||||
heightPx = dividerHeightPxInt
|
||||
)
|
||||
)
|
||||
.graphicsLayer {
|
||||
val z = zoomAnimatable.value
|
||||
val px = panXAnimatable.value
|
||||
val py = panYAnimatable.value
|
||||
|
||||
scaleX = z
|
||||
scaleY = z
|
||||
translationX = px
|
||||
translationY = dividerY * (z - 1f) + py
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
}
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
))
|
||||
|
|
@ -1881,6 +1952,15 @@ internal fun PdfVerticalReader(
|
|||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
val z = zoomAnimatable.value
|
||||
|
||||
scaleX = z
|
||||
scaleY = z
|
||||
translationX = panXAnimatable.value
|
||||
translationY = panYAnimatable.value
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
}
|
||||
.onGloballyPositioned { _ -> }) { measurables, constraints ->
|
||||
val layoutStart = System.nanoTime()
|
||||
Timber.tag("PdfDrawPerf")
|
||||
|
|
@ -1891,19 +1971,19 @@ internal fun PdfVerticalReader(
|
|||
is PdfPageLayout -> {
|
||||
val placeable = measurable.measure(
|
||||
Constraints.fixed(
|
||||
id.width.roundToInt(), id.height.roundToInt()
|
||||
id.widthPx, id.heightPx
|
||||
)
|
||||
)
|
||||
placeable.place(0, id.y.roundToInt())
|
||||
placeable.place(0, id.yPx)
|
||||
}
|
||||
|
||||
is DividerLayout -> {
|
||||
val placeable = measurable.measure(
|
||||
Constraints.fixed(
|
||||
id.width.roundToInt(), id.height.roundToInt()
|
||||
id.widthPx, id.heightPx
|
||||
)
|
||||
)
|
||||
placeable.place(0, id.y.roundToInt())
|
||||
placeable.place(0, id.yPx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,6 @@ import androidx.compose.material3.rememberModalBottomSheetState
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -198,13 +197,13 @@ import com.aryan.reader.AiDefinitionPopup
|
|||
import com.aryan.reader.AiFeature
|
||||
import com.aryan.reader.AiDefinitionResult
|
||||
import com.aryan.reader.AiHubBottomSheet
|
||||
import com.aryan.reader.BannerMessage
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.CustomTopBanner
|
||||
import com.aryan.reader.FileType
|
||||
import com.aryan.reader.HighlightColorPickerDialog
|
||||
import com.aryan.reader.MainViewModel
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.ReaderScreenOrientationEffect
|
||||
import com.aryan.reader.ReaderScreenOrientationSheet
|
||||
import com.aryan.reader.ReaderThemePanel
|
||||
import com.aryan.reader.SearchResult
|
||||
import com.aryan.reader.SummarizationResult
|
||||
|
|
@ -225,6 +224,8 @@ import com.aryan.reader.callByokGeminiInlineAi
|
|||
import com.aryan.reader.isByokCloudTtsAvailable
|
||||
import com.aryan.reader.loadCustomThemes
|
||||
import com.aryan.reader.loadGlobalTextureTransparency
|
||||
import com.aryan.reader.loadReaderScreenOrientationMode
|
||||
import com.aryan.reader.loadPdfRightToLeftPagination
|
||||
import com.aryan.reader.loadTtsReplacementPreferences
|
||||
import com.aryan.reader.paginatedreader.TtsChunk
|
||||
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
|
||||
|
|
@ -240,7 +241,10 @@ import com.aryan.reader.pdf.data.VirtualPage
|
|||
import com.aryan.reader.rememberSearchState
|
||||
import com.aryan.reader.saveCustomThemes
|
||||
import com.aryan.reader.saveGlobalTextureTransparency
|
||||
import com.aryan.reader.saveReaderScreenOrientationMode
|
||||
import com.aryan.reader.savePdfRightToLeftPagination
|
||||
import com.aryan.reader.saveTtsReplacementPreferences
|
||||
import com.aryan.reader.scaledToCanvasLimit
|
||||
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||
import com.aryan.reader.summarizationUrl
|
||||
import com.aryan.reader.tts.SpeakerSamplePlayer
|
||||
|
|
@ -288,6 +292,36 @@ internal fun resolveEraserStrokeWidth(
|
|||
eraserToolThickness: Float
|
||||
): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness
|
||||
|
||||
internal fun canUsePdfSidecarsForBook(
|
||||
activeBookId: String?,
|
||||
loadedSidecarBookId: String?,
|
||||
areSidecarsLoaded: Boolean
|
||||
): Boolean = activeBookId != null && areSidecarsLoaded && loadedSidecarBookId == activeBookId
|
||||
|
||||
internal fun currentPageScaleAfterPdfPageChange(
|
||||
displayMode: DisplayMode,
|
||||
isScrollLocked: Boolean,
|
||||
lockedState: Triple<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")
|
||||
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
|
||||
@ExperimentalMaterial3Api
|
||||
|
|
@ -305,7 +339,6 @@ fun PdfViewerScreen(
|
|||
onNavigateToPro: () -> Unit,
|
||||
viewModel: MainViewModel
|
||||
) {
|
||||
SideEffect { Timber.tag("PdfDrawPerf").v("ROOT: PdfViewerScreen Recomposing") }
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
PdfFontCache.init(context.assets)
|
||||
|
|
@ -338,7 +371,12 @@ fun PdfViewerScreen(
|
|||
var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) }
|
||||
var showBars by rememberSaveable { mutableStateOf(true) }
|
||||
var systemUiMode by remember { mutableStateOf(loadPdfSystemUiMode(context)) }
|
||||
var showVerticalPageGap by remember { mutableStateOf(loadPdfVerticalPageGapVisible(context)) }
|
||||
var showPageNumberOverlay by remember { mutableStateOf(loadPdfPageNumberOverlayVisible(context)) }
|
||||
var showVisualOptionsSheet by remember { mutableStateOf(false) }
|
||||
var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) }
|
||||
var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) }
|
||||
var showScreenOrientationSheet by remember { mutableStateOf(false) }
|
||||
var isFullScreen by remember { mutableStateOf(false) }
|
||||
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
|
||||
|
|
@ -349,6 +387,7 @@ fun PdfViewerScreen(
|
|||
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||
var isPasswordError by remember { mutableStateOf(false) }
|
||||
LocalView.current
|
||||
ReaderScreenOrientationEffect(screenOrientationMode)
|
||||
|
||||
var ocrLanguage by remember { mutableStateOf(loadOcrLanguage(context)) }
|
||||
var hasSelectedOcrLanguage by remember { mutableStateOf(hasUserSelectedOcrLanguage(context)) }
|
||||
|
|
@ -397,6 +436,7 @@ fun PdfViewerScreen(
|
|||
val uiState by viewModel.uiState.collectAsState()
|
||||
val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri
|
||||
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
|
||||
val isComicFile = effectiveFileType == FileType.CBZ || effectiveFileType == FileType.CBR || effectiveFileType == FileType.CB7
|
||||
|
||||
var showNewTabSheet by remember { mutableStateOf(false) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
|
|
@ -404,6 +444,7 @@ fun PdfViewerScreen(
|
|||
val isTabsEnabled = uiState.isTabsEnabled
|
||||
val openTabs = uiState.openTabs
|
||||
val activeTabBookId = uiState.activeTabBookId
|
||||
val isPdfTabStripVisible = isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF
|
||||
val originalFileName by remember(uiState.recentFiles, effectivePdfUri) {
|
||||
derivedStateOf {
|
||||
uiState.recentFiles.find { it.uriString == effectivePdfUri.toString() }?.displayName
|
||||
|
|
@ -412,6 +453,7 @@ fun PdfViewerScreen(
|
|||
}
|
||||
var currentBookId by remember { mutableStateOf<String?>(null) }
|
||||
val bookId = currentBookId ?: effectivePdfUri.toString().hashCode().toString()
|
||||
val activeDocumentRenderKey = currentBookId ?: effectivePdfUri.toString()
|
||||
var documentMetadataTitle by remember { mutableStateOf<String?>(null) }
|
||||
val view = LocalView.current
|
||||
var isDockDragging by remember { mutableStateOf(false) }
|
||||
|
|
@ -425,8 +467,16 @@ fun PdfViewerScreen(
|
|||
}
|
||||
|
||||
LaunchedEffect(bookId) {
|
||||
isScrollLocked = loadPdfScrollLocked(context, bookId)
|
||||
lockedState = loadPdfLockedState(context, bookId)
|
||||
val savedIsScrollLocked = loadPdfScrollLocked(context, bookId)
|
||||
val savedLockedState = loadPdfLockedState(context, bookId)
|
||||
val activeCamera = activePdfCameraAfterLockPreferenceLoad(
|
||||
isScrollLocked = savedIsScrollLocked,
|
||||
lockedState = savedLockedState
|
||||
)
|
||||
isScrollLocked = savedIsScrollLocked
|
||||
lockedState = savedLockedState
|
||||
currentActiveScale = activeCamera.first
|
||||
currentActiveOffset = activeCamera.second
|
||||
}
|
||||
|
||||
var isAutoScrollModeActive by remember { mutableStateOf(false) }
|
||||
|
|
@ -496,9 +546,8 @@ fun PdfViewerScreen(
|
|||
|
||||
val customFonts by viewModel.customFonts.collectAsState()
|
||||
|
||||
var bannerMessage by remember { mutableStateOf<BannerMessage?>(null) }
|
||||
fun showBanner(message: String, isError: Boolean = false) {
|
||||
bannerMessage = BannerMessage(message, isError = isError)
|
||||
fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) {
|
||||
viewModel.showBanner(message, isError, isPersistent)
|
||||
}
|
||||
val onOcrStateChange: (Boolean) -> Unit = {}
|
||||
|
||||
|
|
@ -638,6 +687,13 @@ fun PdfViewerScreen(
|
|||
var showBubbleZoomDownloadDialog by remember { mutableStateOf(false) }
|
||||
val bubbleZoomDownloadProgress by viewModel.speechBubbleModelDownloadProgress.collectAsState()
|
||||
|
||||
LaunchedEffect(isComicFile) {
|
||||
if (!isComicFile) {
|
||||
isBubbleZoomModeActive = false
|
||||
showBubbleZoomDownloadDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
var dockLocation by remember { mutableStateOf(initialDockLocation) }
|
||||
var dockOffset by remember { mutableStateOf(initialDockOffset) }
|
||||
var snapPreviewLocation by remember { mutableStateOf<DockLocation?>(null) }
|
||||
|
|
@ -725,7 +781,8 @@ fun PdfViewerScreen(
|
|||
val targetTopOverlayInset = remember(
|
||||
showStandardBars,
|
||||
systemUiMode,
|
||||
statusBarHeightDp
|
||||
statusBarHeightDp,
|
||||
isPdfTabStripVisible
|
||||
) {
|
||||
if (!showStandardBars) {
|
||||
0.dp
|
||||
|
|
@ -737,6 +794,9 @@ fun PdfViewerScreen(
|
|||
if (isStatusBarVisible) {
|
||||
inset += statusBarHeightDp
|
||||
}
|
||||
if (isPdfTabStripVisible) {
|
||||
inset += PdfTabStripHeight
|
||||
}
|
||||
inset
|
||||
}
|
||||
}
|
||||
|
|
@ -775,13 +835,14 @@ fun PdfViewerScreen(
|
|||
|
||||
LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) }
|
||||
|
||||
LaunchedEffect(currentActiveScale, currentActiveOffset, isScrollLocked) {
|
||||
LaunchedEffect(bookId, currentActiveScale, currentActiveOffset, isScrollLocked) {
|
||||
if (isScrollLocked) {
|
||||
val requestedCamera = currentActiveScale to currentActiveOffset
|
||||
delay(500)
|
||||
Timber.tag("PdfLockDiagnostic").d("SAVING: BookId=$bookId | Scale=$currentActiveScale | X=${currentActiveOffset.x} | Y=${currentActiveOffset.y}")
|
||||
Timber.tag("PdfLockDiagnostic").d("SAVING: BookId=$bookId | Scale=${requestedCamera.first} | X=${requestedCamera.second.x} | Y=${requestedCamera.second.y}")
|
||||
|
||||
lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
|
||||
savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
|
||||
lockedState = Triple(requestedCamera.first, requestedCamera.second.x, requestedCamera.second.y)
|
||||
savePdfLockedState(context, bookId, requestedCamera.first, requestedCamera.second.x, requestedCamera.second.y)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -800,14 +861,6 @@ fun PdfViewerScreen(
|
|||
errorMessage?.let { showBanner(it, isError = true) }
|
||||
}
|
||||
|
||||
LaunchedEffect(bannerMessage) {
|
||||
val message = bannerMessage ?: return@LaunchedEffect
|
||||
if (!message.isPersistent) {
|
||||
delay(2500L)
|
||||
bannerMessage = null
|
||||
}
|
||||
}
|
||||
|
||||
val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
|
||||
val toolSettings by annotationSettingsRepo.settings.collectAsState()
|
||||
var showToolSettings by rememberSaveable { mutableStateOf(false) }
|
||||
|
|
@ -861,6 +914,7 @@ fun PdfViewerScreen(
|
|||
var lastEraserPoint by remember { mutableStateOf<PdfPoint?>(null) }
|
||||
|
||||
var areAnnotationsLoaded by remember { mutableStateOf(false) }
|
||||
var loadedSidecarBookId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val richTextRepository = remember(context) { PdfRichTextRepository(context) }
|
||||
val richTextController = remember(currentBookId) {
|
||||
|
|
@ -936,16 +990,10 @@ fun PdfViewerScreen(
|
|||
}
|
||||
|
||||
fun buildSpeechBubblePrefetchOrder(): List<Int> {
|
||||
if (totalDisplayPages <= 0) return emptyList()
|
||||
val ordered = LinkedHashSet<Int>()
|
||||
ordered += currentPage.coerceIn(0, totalDisplayPages - 1)
|
||||
for (distance in 1 until totalDisplayPages) {
|
||||
val next = currentPage + distance
|
||||
val previous = currentPage - distance
|
||||
if (next in 0 until totalDisplayPages) ordered += next
|
||||
if (previous in 0 until totalDisplayPages) ordered += previous
|
||||
}
|
||||
return ordered.toList()
|
||||
return buildPdfBubblePrefetchOrder(
|
||||
currentPage = currentPage,
|
||||
totalPages = totalDisplayPages
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun detectSpeechBubblesForPage(
|
||||
|
|
@ -1125,39 +1173,80 @@ fun PdfViewerScreen(
|
|||
|
||||
val lastSavedHashes = remember(currentBookId) { IntArray(5) { -1 } }
|
||||
|
||||
val sidecarsReadyForCurrentBook =
|
||||
canUsePdfSidecarsForBook(currentBookId, loadedSidecarBookId, areAnnotationsLoaded)
|
||||
val textBoxesSnapshot by remember { derivedStateOf { textBoxes.toList() } }
|
||||
val userHighlightsSnapshot by remember { derivedStateOf { userHighlights.toList() } }
|
||||
val visibleAllAnnotations = if (sidecarsReadyForCurrentBook) allAnnotations else emptyMap()
|
||||
val visibleTextBoxes = if (sidecarsReadyForCurrentBook) textBoxesSnapshot else emptyList()
|
||||
val visibleUserHighlights = if (sidecarsReadyForCurrentBook) userHighlightsSnapshot else emptyList()
|
||||
val visibleTextBoxesByPage = remember(sidecarsReadyForCurrentBook, textBoxesSnapshot) {
|
||||
if (sidecarsReadyForCurrentBook) {
|
||||
textBoxesSnapshot.groupBy { it.pageIndex }
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
val visibleUserHighlightsByPage = remember(sidecarsReadyForCurrentBook, userHighlightsSnapshot) {
|
||||
if (sidecarsReadyForCurrentBook) {
|
||||
userHighlightsSnapshot.groupBy { it.pageIndex }
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
val currentAnnotations by rememberUpdatedState(allAnnotations)
|
||||
val currentTextBoxes by rememberUpdatedState(textBoxes.toList())
|
||||
val currentHighlights by rememberUpdatedState(userHighlights.toList())
|
||||
val currentTextBoxes by rememberUpdatedState(textBoxesSnapshot)
|
||||
val currentHighlights by rememberUpdatedState(userHighlightsSnapshot)
|
||||
val currentLoadedSidecarBookId by rememberUpdatedState(loadedSidecarBookId)
|
||||
val currentAreAnnotationsLoaded by rememberUpdatedState(areAnnotationsLoaded)
|
||||
val currentBookmarks by rememberUpdatedState(bookmarks)
|
||||
val currentTotalPages by rememberUpdatedState(totalDisplayPages)
|
||||
val currentPageState by rememberUpdatedState(currentPage)
|
||||
val currentPendingPage by rememberUpdatedState(pendingRestorePage)
|
||||
val currentVisibleAllAnnotations by rememberUpdatedState(visibleAllAnnotations)
|
||||
|
||||
val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) {
|
||||
{ force: Boolean ->
|
||||
val bookIdSnapshot = currentBookId
|
||||
val loadedSidecarBookIdSnapshot = currentLoadedSidecarBookId
|
||||
val canSaveSidecarsSnapshot = canUsePdfSidecarsForBook(
|
||||
bookIdSnapshot,
|
||||
loadedSidecarBookIdSnapshot,
|
||||
currentAreAnnotationsLoaded
|
||||
)
|
||||
val isDocumentReadySnapshot = isDocumentReady
|
||||
val initialScrollDoneSnapshot = initialScrollDone
|
||||
val annotsSnapshot = currentAnnotations
|
||||
val boxesSnapshot = currentTextBoxes
|
||||
val highlightsSnapshot = currentHighlights
|
||||
val bookmarksSnapshot = currentBookmarks
|
||||
val totalPagesSnapshot = currentTotalPages
|
||||
val currentPageSnapshot = currentPageState
|
||||
val pendingPageSnapshot = currentPendingPage
|
||||
viewModel.viewModelScope.launch {
|
||||
val bookId = currentBookId ?: return@launch
|
||||
val bookId = bookIdSnapshot ?: return@launch
|
||||
|
||||
if (!isDocumentReady && !force) {
|
||||
if (!isDocumentReadySnapshot && !force) {
|
||||
Timber.tag("PdfPositionDebug").w("UI: Save ignored. Document not ready.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val annots = currentAnnotations
|
||||
val boxes = currentTextBoxes
|
||||
val highlights = currentHighlights
|
||||
val bms = currentBookmarks
|
||||
val totalPgs = currentTotalPages
|
||||
val annots = annotsSnapshot
|
||||
val boxes = boxesSnapshot
|
||||
val highlights = highlightsSnapshot
|
||||
val bms = bookmarksSnapshot
|
||||
val totalPgs = totalPagesSnapshot
|
||||
|
||||
val restoreTarget = currentPendingPage ?: 0
|
||||
val page = if (!initialScrollDone) {
|
||||
Timber.tag("PdfPositionDebug").i("UI: Save during restoration | Using restoreTarget: $restoreTarget (CurrentUI: $currentPageState)")
|
||||
val restoreTarget = pendingPageSnapshot ?: 0
|
||||
val page = if (!initialScrollDoneSnapshot) {
|
||||
Timber.tag("PdfPositionDebug").i("UI: Save during restoration | Using restoreTarget: $restoreTarget (CurrentUI: $currentPageSnapshot)")
|
||||
restoreTarget
|
||||
} else {
|
||||
currentPageState
|
||||
currentPageSnapshot
|
||||
}
|
||||
|
||||
Timber.tag("PdfPositionDebug").v("UI: Save logic | Choosing: $page (UI: $currentPageState, Target: $restoreTarget, Done: $initialScrollDone)")
|
||||
Timber.tag("PdfPositionDebug").v("UI: Save logic | Choosing: $page (UI: $currentPageSnapshot, Target: $restoreTarget, Done: $initialScrollDoneSnapshot)")
|
||||
|
||||
val annotsHash = annots.hashCode()
|
||||
val boxesHash = boxes.hashCode()
|
||||
|
|
@ -1169,20 +1258,26 @@ fun PdfViewerScreen(
|
|||
withContext(Dispatchers.IO) {
|
||||
@Suppress("VariableNeverRead") var didSave = false
|
||||
|
||||
if (force || annotsHash != lastSavedHashes[0]) {
|
||||
annotationRepository.saveAnnotations(bookId, annots)
|
||||
lastSavedHashes[0] = annotsHash
|
||||
didSave = true
|
||||
}
|
||||
if (force || boxesHash != lastSavedHashes[1]) {
|
||||
textBoxRepository.saveTextBoxes(bookId, boxes)
|
||||
lastSavedHashes[1] = boxesHash
|
||||
didSave = true
|
||||
}
|
||||
if (force || highlightsHash != lastSavedHashes[2]) {
|
||||
highlightRepository.saveHighlights(bookId, highlights)
|
||||
lastSavedHashes[2] = highlightsHash
|
||||
didSave = true
|
||||
if (canSaveSidecarsSnapshot) {
|
||||
if (force || annotsHash != lastSavedHashes[0]) {
|
||||
annotationRepository.saveAnnotations(bookId, annots)
|
||||
lastSavedHashes[0] = annotsHash
|
||||
didSave = true
|
||||
}
|
||||
if (force || boxesHash != lastSavedHashes[1]) {
|
||||
textBoxRepository.saveTextBoxes(bookId, boxes)
|
||||
lastSavedHashes[1] = boxesHash
|
||||
didSave = true
|
||||
}
|
||||
if (force || highlightsHash != lastSavedHashes[2]) {
|
||||
highlightRepository.saveHighlights(bookId, highlights)
|
||||
lastSavedHashes[2] = highlightsHash
|
||||
didSave = true
|
||||
}
|
||||
} else {
|
||||
Timber.tag("PdfTabSync").d(
|
||||
"Skipping PDF sidecar save for $bookId; loaded sidecars belong to $loadedSidecarBookIdSnapshot"
|
||||
)
|
||||
}
|
||||
if (force || bmsHash != lastSavedHashes[3]) {
|
||||
val objectList = bms.map { bookmark ->
|
||||
|
|
@ -1239,18 +1334,19 @@ fun PdfViewerScreen(
|
|||
|
||||
LaunchedEffect(
|
||||
allAnnotations,
|
||||
textBoxes.toList(),
|
||||
userHighlights.toList(),
|
||||
textBoxesSnapshot,
|
||||
userHighlightsSnapshot,
|
||||
bookmarks,
|
||||
currentPage
|
||||
currentPage,
|
||||
sidecarsReadyForCurrentBook
|
||||
) {
|
||||
if (areAnnotationsLoaded && currentBookId != null && initialScrollDone) {
|
||||
if (sidecarsReadyForCurrentBook && initialScrollDone) {
|
||||
delay(2000) // Debounce period
|
||||
saveAllData(false)
|
||||
}
|
||||
}
|
||||
|
||||
val allAnnotationsProvider = remember { { allAnnotations } }
|
||||
val allAnnotationsProvider = remember { { currentVisibleAllAnnotations } }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Timber.d("PdfViewerScreen init: initialBookmarksJson is '$initialBookmarksJson'")
|
||||
|
|
@ -2070,26 +2166,39 @@ fun PdfViewerScreen(
|
|||
}
|
||||
|
||||
LaunchedEffect(currentBookId) {
|
||||
if (currentBookId != null) {
|
||||
val loaded = annotationRepository.loadAnnotations(currentBookId!!)
|
||||
allAnnotations = loaded
|
||||
areAnnotationsLoaded = true
|
||||
val loadingBookId = currentBookId
|
||||
|
||||
val loadedBoxes = textBoxRepository.loadTextBoxes(currentBookId!!)
|
||||
textBoxes.clear()
|
||||
textBoxes.addAll(loadedBoxes)
|
||||
areAnnotationsLoaded = false
|
||||
loadedSidecarBookId = null
|
||||
allAnnotations = emptyMap()
|
||||
textBoxes.clear()
|
||||
userHighlights.clear()
|
||||
selectedTextBoxId = null
|
||||
undoStack.clear()
|
||||
redoStack.clear()
|
||||
erasedAnnotationsFromStroke.clear()
|
||||
drawingState.onDrawCancel()
|
||||
|
||||
val loadedHighlights = highlightRepository.loadHighlights(currentBookId!!)
|
||||
userHighlights.clear()
|
||||
userHighlights.addAll(loadedHighlights)
|
||||
}
|
||||
if (loadingBookId == null) return@LaunchedEffect
|
||||
|
||||
val loaded = annotationRepository.loadAnnotations(loadingBookId)
|
||||
val loadedBoxes = textBoxRepository.loadTextBoxes(loadingBookId)
|
||||
val loadedHighlights = highlightRepository.loadHighlights(loadingBookId)
|
||||
|
||||
if (currentBookId != loadingBookId) return@LaunchedEffect
|
||||
|
||||
allAnnotations = loaded
|
||||
textBoxes.addAll(loadedBoxes)
|
||||
userHighlights.addAll(loadedHighlights)
|
||||
loadedSidecarBookId = loadingBookId
|
||||
areAnnotationsLoaded = true
|
||||
}
|
||||
|
||||
var isRebuildingSyncedHighlightBounds by remember(currentBookId) { mutableStateOf(false) }
|
||||
LaunchedEffect(pdfDocument, currentBookId, userHighlights.toList()) {
|
||||
LaunchedEffect(pdfDocument, currentBookId, userHighlightsSnapshot, sidecarsReadyForCurrentBook) {
|
||||
val document = pdfDocument ?: return@LaunchedEffect
|
||||
if (currentBookId == null || isRebuildingSyncedHighlightBounds) return@LaunchedEffect
|
||||
val snapshot = userHighlights.toList()
|
||||
if (!sidecarsReadyForCurrentBook || isRebuildingSyncedHighlightBounds) return@LaunchedEffect
|
||||
val snapshot = userHighlightsSnapshot
|
||||
if (snapshot.none { it.bounds.isEmpty() && it.range.second > it.range.first }) return@LaunchedEffect
|
||||
|
||||
isRebuildingSyncedHighlightBounds = true
|
||||
|
|
@ -2116,18 +2225,18 @@ fun PdfViewerScreen(
|
|||
coroutineScope.launch {
|
||||
val currentRichTextLayouts = richTextController?.pageLayouts
|
||||
|
||||
Timber.tag("PdfExportDebug").i("SAVE TRIGGERED: userHighlights count: ${userHighlights.size}")
|
||||
if (userHighlights.isEmpty()) {
|
||||
Timber.tag("PdfExportDebug").i("SAVE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}")
|
||||
if (visibleUserHighlights.isEmpty()) {
|
||||
Timber.tag("PdfExportDebug").w("Warning: userHighlights is EMPTY during save.")
|
||||
}
|
||||
|
||||
viewModel.savePdfWithAnnotations(
|
||||
sourceUri = effectivePdfUri,
|
||||
destUri = uri,
|
||||
annotations = allAnnotations,
|
||||
annotations = visibleAllAnnotations,
|
||||
richTextPageLayouts = currentRichTextLayouts,
|
||||
textBoxes = textBoxes.toList(),
|
||||
highlights = userHighlights.toList(),
|
||||
textBoxes = visibleTextBoxes,
|
||||
highlights = visibleUserHighlights,
|
||||
bookId = currentBookId!!
|
||||
)
|
||||
}
|
||||
|
|
@ -2879,6 +2988,17 @@ fun PdfViewerScreen(
|
|||
isDocumentReady = false
|
||||
errorMessage = null
|
||||
documentMetadataTitle = null
|
||||
currentBookId = null
|
||||
areAnnotationsLoaded = false
|
||||
loadedSidecarBookId = null
|
||||
allAnnotations = emptyMap()
|
||||
textBoxes.clear()
|
||||
userHighlights.clear()
|
||||
selectedTextBoxId = null
|
||||
undoStack.clear()
|
||||
redoStack.clear()
|
||||
erasedAnnotationsFromStroke.clear()
|
||||
drawingState.onDrawCancel()
|
||||
|
||||
if (showPasswordDialog) isPasswordError = false
|
||||
|
||||
|
|
@ -2932,12 +3052,13 @@ fun PdfViewerScreen(
|
|||
withContext(Dispatchers.IO) {
|
||||
Timber.tag("PdfTabSync").v("UI: Opening PFD for $effectivePdfUri")
|
||||
|
||||
if (pdfUri.scheme != "opds-pse") {
|
||||
val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF
|
||||
if (pdfUri.scheme != "opds-pse" && selectedDocumentType == FileType.PDF) {
|
||||
currentPfdOpened = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
|
||||
if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor")
|
||||
}
|
||||
|
||||
val doc = DocumentFactory.loadDocument(context, effectivePdfUri, uiState.selectedFileType ?: FileType.PDF, documentPassword, pdfiumCore)
|
||||
val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore)
|
||||
|
||||
if (!isActive) {
|
||||
doc.close()
|
||||
|
|
@ -3027,7 +3148,7 @@ fun PdfViewerScreen(
|
|||
currentBookId!!,
|
||||
DocumentCacheItem(
|
||||
doc = doc,
|
||||
pfd = currentPfdOpened!!,
|
||||
pfd = currentPfdOpened,
|
||||
totalPages = pagesCount,
|
||||
pageAspectRatios = ratios,
|
||||
flatTableOfContents = flatTableOfContents
|
||||
|
|
@ -3094,8 +3215,8 @@ fun PdfViewerScreen(
|
|||
isLoadingDocument = false
|
||||
}
|
||||
} else {
|
||||
Timber.e(e, "Error loading PDF document")
|
||||
errorMessage = "Error loading PDF: ${e.localizedMessage}"
|
||||
Timber.e(e, "Error loading fixed-layout document")
|
||||
errorMessage = "Error loading document: ${e.localizedMessage}"
|
||||
isLoadingDocument = false
|
||||
}
|
||||
if (pdfDocument == null) {
|
||||
|
|
@ -3132,8 +3253,14 @@ fun PdfViewerScreen(
|
|||
summarizationResult = null
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
currentPageScale = 1f
|
||||
LaunchedEffect(pagerState.currentPage, displayMode, isScrollLocked, lockedState) {
|
||||
val nextPageScale = currentPageScaleAfterPdfPageChange(
|
||||
displayMode = displayMode,
|
||||
isScrollLocked = isScrollLocked,
|
||||
lockedState = lockedState,
|
||||
currentActiveScale = currentActiveScale
|
||||
)
|
||||
currentPageScale = nextPageScale
|
||||
ocrUsedForCurrentPageTts = false
|
||||
}
|
||||
|
||||
|
|
@ -3172,7 +3299,8 @@ fun PdfViewerScreen(
|
|||
LaunchedEffect(effectivePdfUri, currentBookId, totalPages) {
|
||||
if (currentBookId == null || totalPages == 0) return@LaunchedEffect
|
||||
if (isBackgroundIndexing && backgroundIndexingProgress > 0f) return@LaunchedEffect
|
||||
if (uiState.selectedFileType != FileType.PDF) return@LaunchedEffect
|
||||
val selectedDocumentType = uiState.selectedFileType ?: return@LaunchedEffect
|
||||
if (selectedDocumentType != FileType.PDF && selectedDocumentType != FileType.PPTX) return@LaunchedEffect
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val storedLang = pdfTextRepository.getBookLanguage(currentBookId!!)
|
||||
|
|
@ -3183,6 +3311,7 @@ fun PdfViewerScreen(
|
|||
isBackgroundIndexing = true
|
||||
var bgPfd: ParcelFileDescriptor? = null
|
||||
var bgDoc: PdfDocumentKt? = null
|
||||
var genericDoc: ReaderDocument? = null
|
||||
|
||||
try {
|
||||
val existingPages = pdfTextRepository.getIndexedPages(currentBookId!!)
|
||||
|
|
@ -3199,17 +3328,18 @@ fun PdfViewerScreen(
|
|||
"Indexer: Starting background indexing for ${totalPages - existingPages.size} pages."
|
||||
)
|
||||
|
||||
bgPfd = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
|
||||
val openedBgPfd = bgPfd
|
||||
if (openedBgPfd != null) {
|
||||
val pagesToIndex = (0 until totalPages).filter { !existingPages.contains(it) }
|
||||
val totalToDo = pagesToIndex.size
|
||||
var completed = 0
|
||||
|
||||
if (selectedDocumentType == FileType.PDF) {
|
||||
bgPfd = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
|
||||
val openedBgPfd = bgPfd
|
||||
if (openedBgPfd == null) return@withContext
|
||||
bgDoc = PdfiumEngineProvider.withPdfium {
|
||||
pdfiumCore.newDocument(openedBgPfd, documentPassword)
|
||||
}
|
||||
|
||||
val pagesToIndex = (0 until totalPages).filter { !existingPages.contains(it) }
|
||||
val totalToDo = pagesToIndex.size
|
||||
var completed = 0
|
||||
|
||||
for (pageIndex in pagesToIndex) {
|
||||
if (!isActive) break
|
||||
|
||||
|
|
@ -3223,6 +3353,37 @@ fun PdfViewerScreen(
|
|||
Timber.e(e, "Indexer: Failed on page $pageIndex")
|
||||
}
|
||||
|
||||
completed++
|
||||
if (completed % 5 == 0 || completed == totalToDo) {
|
||||
val totalIndexedSoFar = initialIndexedCount + completed
|
||||
backgroundIndexingProgress =
|
||||
totalIndexedSoFar.toFloat() / totalPages.toFloat()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val openedGenericDoc = DocumentFactory.loadDocument(
|
||||
context = context,
|
||||
uri = effectivePdfUri,
|
||||
type = selectedDocumentType,
|
||||
password = null,
|
||||
pdfiumCore = pdfiumCore
|
||||
)
|
||||
genericDoc = openedGenericDoc
|
||||
|
||||
for (pageIndex in pagesToIndex) {
|
||||
if (!isActive) break
|
||||
|
||||
try {
|
||||
pdfTextRepository.indexReaderPage(
|
||||
bookId = currentBookId!!,
|
||||
document = openedGenericDoc,
|
||||
pageIndex = pageIndex,
|
||||
onOcrModelDownloading = { isOcrModelDownloading = true }
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Indexer: Failed on page $pageIndex")
|
||||
}
|
||||
|
||||
completed++
|
||||
if (completed % 5 == 0 || completed == totalToDo) {
|
||||
val totalIndexedSoFar = initialIndexedCount + completed
|
||||
|
|
@ -3238,6 +3399,7 @@ fun PdfViewerScreen(
|
|||
PdfiumEngineProvider.withPdfium {
|
||||
bgDoc?.close()
|
||||
}
|
||||
genericDoc?.close()
|
||||
bgPfd?.close()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Indexer: Cleanup failed")
|
||||
|
|
@ -3510,9 +3672,10 @@ fun PdfViewerScreen(
|
|||
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
|
||||
PdfNavigationDrawerContent(
|
||||
pdfDocument = pdfDocument,
|
||||
documentKey = activeDocumentRenderKey,
|
||||
flatTableOfContents = flatTableOfContents,
|
||||
bookmarks = bookmarks,
|
||||
userHighlights = userHighlights,
|
||||
userHighlights = visibleUserHighlights,
|
||||
currentPage = currentPage,
|
||||
totalPages = totalDisplayPages,
|
||||
customHighlightColors = customHighlightColors,
|
||||
|
|
@ -3649,7 +3812,7 @@ fun PdfViewerScreen(
|
|||
}
|
||||
|
||||
pdfDocument != null && totalPages > 0 -> {
|
||||
val stablePdfDocument = remember(pdfDocument) { StableHolder(pdfDocument!!) }
|
||||
val stablePdfDocument = remember(activeDocumentRenderKey, pdfDocument) { StableHolder(pdfDocument!!) }
|
||||
when (displayMode) {
|
||||
DisplayMode.PAGINATION -> {
|
||||
val onPaginationPreSingleTap: (Offset) -> Boolean = { tapOffset ->
|
||||
|
|
@ -3664,7 +3827,11 @@ fun PdfViewerScreen(
|
|||
tapOffset.x < oneQuarterWidthPx -> {
|
||||
coroutineScope.launch {
|
||||
val targetPage =
|
||||
(pagerState.currentPage - 1).coerceAtLeast(0)
|
||||
if (rightToLeftPagination) {
|
||||
(pagerState.currentPage + 1).coerceAtMost(pagerState.pageCount - 1)
|
||||
} else {
|
||||
(pagerState.currentPage - 1).coerceAtLeast(0)
|
||||
}
|
||||
if (targetPage != pagerState.currentPage) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
|
|
@ -3675,9 +3842,13 @@ fun PdfViewerScreen(
|
|||
tapOffset.x > (boxMaxWidthFloat - oneQuarterWidthPx) -> {
|
||||
coroutineScope.launch {
|
||||
val targetPage =
|
||||
(pagerState.currentPage + 1).coerceAtMost(
|
||||
pagerState.pageCount - 1
|
||||
)
|
||||
if (rightToLeftPagination) {
|
||||
(pagerState.currentPage - 1).coerceAtLeast(0)
|
||||
} else {
|
||||
(pagerState.currentPage + 1).coerceAtMost(
|
||||
pagerState.pageCount - 1
|
||||
)
|
||||
}
|
||||
if (targetPage != pagerState.currentPage) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
|
|
@ -3694,14 +3865,14 @@ fun PdfViewerScreen(
|
|||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
key = { it },
|
||||
key = { page -> "$activeDocumentRenderKey:$page" },
|
||||
beyondViewportPageCount = dynamicBeyondViewportPageCount,
|
||||
reverseLayout = rightToLeftPagination,
|
||||
userScrollEnabled = run {
|
||||
val enabled = (currentPageScale == 1f || (isScrollLocked && displayMode == DisplayMode.PAGINATION)) && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null
|
||||
SideEffect {
|
||||
Timber.tag("PdfZoomDebug").v("Pager Scroll Enabled: $enabled (Scale: $currentPageScale, Playing: ${ttsState.isPlaying}, Slider: $isPageSliderVisible, DraggingBox: $paginationDraggingBoxId)")
|
||||
}
|
||||
enabled
|
||||
(currentPageScale == 1f || (isScrollLocked && displayMode == DisplayMode.PAGINATION)) &&
|
||||
!(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) &&
|
||||
!isPageSliderVisible &&
|
||||
paginationDraggingBoxId == null
|
||||
}
|
||||
) { pageIndex ->
|
||||
val isVisiblePage = remember(pagerState.currentPage, pageIndex) {
|
||||
|
|
@ -3881,6 +4052,7 @@ fun PdfViewerScreen(
|
|||
|
||||
PdfPageComposable(
|
||||
pdfDocument = stablePdfDocument,
|
||||
documentKey = activeDocumentRenderKey,
|
||||
pageIndex = pageIndex,
|
||||
virtualPage = virtualPage,
|
||||
totalPages = totalDisplayPages,
|
||||
|
|
@ -3923,6 +4095,7 @@ fun PdfViewerScreen(
|
|||
isBookmarked = isPageBookmarked,
|
||||
onBookmarkClick = { onToggleBookmark(pageIndex) },
|
||||
isZoomEnabled = true,
|
||||
showPageNumberOverlay = showPageNumberOverlay,
|
||||
clearSelectionTrigger = selectionClearTrigger,
|
||||
resetZoomTrigger = resetZoomTrigger,
|
||||
pageAnnotations = pageAnnotationsProvider,
|
||||
|
|
@ -3961,7 +4134,7 @@ fun PdfViewerScreen(
|
|||
onOcrModelDownloading = {
|
||||
isOcrModelDownloading = true
|
||||
},
|
||||
userHighlights = userHighlights.filter { it.pageIndex == pageIndex },
|
||||
userHighlights = visibleUserHighlightsByPage[pageIndex].orEmpty(),
|
||||
onHighlightAdd = onHighlightAdd,
|
||||
onHighlightUpdate = onHighlightUpdate,
|
||||
onHighlightDelete = onHighlightDelete,
|
||||
|
|
@ -3980,7 +4153,12 @@ fun PdfViewerScreen(
|
|||
detectSpeechBubblesForPage(sourcePageIndex, bitmap)
|
||||
},
|
||||
onShowPanelPopup = { bitmapWithRects ->
|
||||
poppedUpPanelBitmap = bitmapWithRects
|
||||
val safeBitmap = bitmapWithRects.scaledToCanvasLimit()
|
||||
if (safeBitmap !== bitmapWithRects && !bitmapWithRects.isRecycled) {
|
||||
bitmapWithRects.recycle()
|
||||
}
|
||||
poppedUpPanelBitmap?.takeUnless { it.isRecycled }?.recycle()
|
||||
poppedUpPanelBitmap = safeBitmap
|
||||
},
|
||||
onTwoFingerSwipe = { direction ->
|
||||
coroutineScope.launch {
|
||||
|
|
@ -3999,7 +4177,7 @@ fun PdfViewerScreen(
|
|||
isAutoScrollPlaying = isAutoScrollPlaying,
|
||||
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
|
||||
isEditMode = isDrawingActive,
|
||||
textBoxes = textBoxes.filter { it.pageIndex == pageIndex },
|
||||
textBoxes = visibleTextBoxesByPage[pageIndex].orEmpty(),
|
||||
selectedTextBoxId = selectedTextBoxId,
|
||||
onTextBoxChange = { updatedBox ->
|
||||
val idx = textBoxes.indexOfFirst { it.id == updatedBox.id }
|
||||
|
|
@ -4326,7 +4504,7 @@ fun PdfViewerScreen(
|
|||
Box(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RectangleShape)) {
|
||||
val docHolder = remember(pdfDocument) {
|
||||
val docHolder = remember(activeDocumentRenderKey, pdfDocument) {
|
||||
StableHolder(pdfDocument!!)
|
||||
}
|
||||
val bookmarksHolder =
|
||||
|
|
@ -4338,6 +4516,7 @@ fun PdfViewerScreen(
|
|||
PdfVerticalReader(
|
||||
state = verticalReaderState,
|
||||
pdfDocument = docHolder,
|
||||
documentKey = activeDocumentRenderKey,
|
||||
activeTheme = activeTheme,
|
||||
activeTextureAlpha = 1f - globalTextureTransparency,
|
||||
excludeImages = excludeImages,
|
||||
|
|
@ -4364,7 +4543,8 @@ fun PdfViewerScreen(
|
|||
onSearchText = onSearchTextStable,
|
||||
ttsHighlightData = ttsHighlightData,
|
||||
ttsReadingPage = ttsDisplayPageIndex,
|
||||
userHighlights = userHighlights,
|
||||
userHighlights = visibleUserHighlights,
|
||||
userHighlightsByPage = visibleUserHighlightsByPage,
|
||||
onHighlightAdd = onHighlightAdd,
|
||||
onHighlightUpdate = onHighlightUpdate,
|
||||
onHighlightDelete = onHighlightDelete,
|
||||
|
|
@ -4419,7 +4599,8 @@ fun PdfViewerScreen(
|
|||
isStylusOnlyMode = isStylusOnlyMode,
|
||||
stylusButtonHovering = stylusButtonHovering,
|
||||
isEditMode = isDrawingActive,
|
||||
textBoxes = textBoxes,
|
||||
textBoxes = visibleTextBoxes,
|
||||
textBoxesByPage = visibleTextBoxesByPage,
|
||||
selectedTextBoxId = selectedTextBoxId,
|
||||
onTextBoxChange = { updatedBox ->
|
||||
val idx = textBoxes.indexOfFirst { it.id == updatedBox.id }
|
||||
|
|
@ -4444,6 +4625,8 @@ fun PdfViewerScreen(
|
|||
autoScrollSpeed = autoScrollSpeed * 0.5f,
|
||||
onInteractionListener = onAutoScrollInteraction,
|
||||
lockedState = lockedState,
|
||||
showPageGap = showVerticalPageGap,
|
||||
showPageNumberOverlay = showPageNumberOverlay,
|
||||
onZoomAndPanChanged = { newScale, newOffset ->
|
||||
currentActiveScale = newScale
|
||||
currentActiveOffset = newOffset
|
||||
|
|
@ -5030,6 +5213,7 @@ fun PdfViewerScreen(
|
|||
isScrollLocked = isScrollLocked,
|
||||
isEditMode = isEditMode,
|
||||
displayMode = displayMode,
|
||||
isRightToLeftPagination = rightToLeftPagination,
|
||||
isKeepScreenOn = isKeepScreenOn,
|
||||
isTtsSessionActive = isTtsSessionActive,
|
||||
isBookmarked = isBookmarked,
|
||||
|
|
@ -5084,6 +5268,7 @@ fun PdfViewerScreen(
|
|||
}
|
||||
},
|
||||
onShowVisualOptions = { showVisualOptionsSheet = true },
|
||||
onShowScreenOrientation = { showScreenOrientationSheet = true },
|
||||
isTtsPlayingOrLoading = isPdfTtsPlayingOrLoading,
|
||||
showAllTextHighlights = showAllTextHighlights,
|
||||
isHighlightingLoading = isHighlightingLoading,
|
||||
|
|
@ -5100,6 +5285,10 @@ fun PdfViewerScreen(
|
|||
saveTapToNavigateSetting(context, tapToNavigateEnabled)
|
||||
},
|
||||
onChangeDisplayMode = { displayMode = it },
|
||||
onSetRightToLeftPagination = { enabled ->
|
||||
rightToLeftPagination = enabled
|
||||
savePdfRightToLeftPagination(context, enabled)
|
||||
},
|
||||
onToggleKeepScreenOn = {
|
||||
isKeepScreenOn = !isKeepScreenOn
|
||||
saveKeepScreenOn(context, isKeepScreenOn)
|
||||
|
|
@ -5215,7 +5404,11 @@ fun PdfViewerScreen(
|
|||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.msg_indexing_pages_progress),
|
||||
text = stringResource(
|
||||
R.string.msg_indexing_pages_progress,
|
||||
(backgroundIndexingProgress * 100f).roundToInt()
|
||||
.coerceIn(0, 100)
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
|
|
@ -5427,6 +5620,8 @@ fun PdfViewerScreen(
|
|||
onShowAiHub = showPdfAiHub,
|
||||
onToggleEditMode = togglePdfEditMode,
|
||||
onToggleTts = togglePdfTts,
|
||||
onShowScreenOrientation = { showScreenOrientationSheet = true },
|
||||
showBubbleZoom = isComicFile,
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
onToggleBubbleZoom = {
|
||||
if (isOss) {
|
||||
|
|
@ -6166,8 +6361,6 @@ fun PdfViewerScreen(
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
CustomTopBanner(bannerMessage = bannerMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6314,6 +6507,17 @@ fun PdfViewerScreen(
|
|||
|
||||
// --- PANEL POPUP ---
|
||||
if (poppedUpPanelBitmap != null) {
|
||||
val sourcePanelBitmap = poppedUpPanelBitmap
|
||||
val displayPanelBitmap = remember(sourcePanelBitmap) {
|
||||
sourcePanelBitmap?.scaledToCanvasLimit()
|
||||
}
|
||||
DisposableEffect(sourcePanelBitmap, displayPanelBitmap) {
|
||||
onDispose {
|
||||
if (displayPanelBitmap != null && displayPanelBitmap !== sourcePanelBitmap && !displayPanelBitmap.isRecycled) {
|
||||
displayPanelBitmap.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -6327,15 +6531,17 @@ fun PdfViewerScreen(
|
|||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
bitmap = poppedUpPanelBitmap!!.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.content_desc_annotated_page),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
displayPanelBitmap?.takeUnless { it.isRecycled }?.let { panelBitmap ->
|
||||
Image(
|
||||
bitmap = panelBitmap.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.content_desc_annotated_page),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
|
|
@ -6832,7 +7038,7 @@ fun PdfViewerScreen(
|
|||
onClick = {
|
||||
showShareDialog = false
|
||||
isShareLoading = true
|
||||
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}")
|
||||
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}")
|
||||
val filename = getSuggestedFilename(
|
||||
originalFileName, isAnnotated = true
|
||||
)
|
||||
|
|
@ -6842,10 +7048,10 @@ fun PdfViewerScreen(
|
|||
viewModel.sharePdf(
|
||||
activityContext = context,
|
||||
sourceUri = effectivePdfUri,
|
||||
annotations = allAnnotations,
|
||||
annotations = visibleAllAnnotations,
|
||||
richTextPageLayouts = currentRichTextLayouts,
|
||||
textBoxes = textBoxes.toList(),
|
||||
highlights = userHighlights.toList(),
|
||||
textBoxes = visibleTextBoxes,
|
||||
highlights = visibleUserHighlights,
|
||||
includeAnnotations = true,
|
||||
filename = filename,
|
||||
bookId = currentBookId
|
||||
|
|
@ -6869,7 +7075,7 @@ fun PdfViewerScreen(
|
|||
viewModel.sharePdf(
|
||||
activityContext = context,
|
||||
sourceUri = pdfUri,
|
||||
annotations = allAnnotations,
|
||||
annotations = emptyMap(),
|
||||
includeAnnotations = false,
|
||||
filename = filename
|
||||
)
|
||||
|
|
@ -6917,13 +7123,33 @@ fun PdfViewerScreen(
|
|||
if (showVisualOptionsSheet) {
|
||||
PdfVisualOptionsSheet(
|
||||
systemUiMode = systemUiMode,
|
||||
showVerticalPageGap = showVerticalPageGap,
|
||||
showPageNumberOverlay = showPageNumberOverlay,
|
||||
onSystemUiModeChange = { mode ->
|
||||
systemUiMode = mode
|
||||
savePdfSystemUiMode(context, mode)
|
||||
},
|
||||
onShowVerticalPageGapChange = { isVisible ->
|
||||
showVerticalPageGap = isVisible
|
||||
savePdfVerticalPageGapVisible(context, isVisible)
|
||||
},
|
||||
onShowPageNumberOverlayChange = { isVisible ->
|
||||
showPageNumberOverlay = isVisible
|
||||
savePdfPageNumberOverlayVisible(context, isVisible)
|
||||
},
|
||||
onDismiss = { showVisualOptionsSheet = false }
|
||||
)
|
||||
}
|
||||
if (showScreenOrientationSheet) {
|
||||
ReaderScreenOrientationSheet(
|
||||
selectedMode = screenOrientationMode,
|
||||
onModeSelected = { mode ->
|
||||
screenOrientationMode = mode
|
||||
saveReaderScreenOrientationMode(context, mode)
|
||||
},
|
||||
onDismiss = { showScreenOrientationSheet = false }
|
||||
)
|
||||
}
|
||||
if (showCustomizeToolsSheet) {
|
||||
PdfCustomizeToolsSheet(
|
||||
hiddenTools = hiddenTools,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
|
|
@ -267,18 +266,18 @@ class TextPaginationEngine {
|
|||
dirtyGlobalIndex: Int = 0
|
||||
): List<PageTextLayout> {
|
||||
val totalLen = globalText.length
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " +
|
||||
"margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex"
|
||||
)
|
||||
if (totalLen == 0) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate empty -> p0:0-0")
|
||||
Timber.d("android.paginate empty -> p0:0-0")
|
||||
return listOf(
|
||||
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
|
||||
)
|
||||
}
|
||||
if (pageWidthPx <= 0 || pageHeightPx <= 0) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate aborted invalid page size")
|
||||
Timber.d("android.paginate aborted invalid page size")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
|
|
@ -320,7 +319,7 @@ class TextPaginationEngine {
|
|||
" Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]"
|
||||
}
|
||||
Timber.tag("RichTextMigration").i("Pagination Map Generated:\n$mapLog")
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}")
|
||||
Timber.d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}")
|
||||
|
||||
return resultLayouts
|
||||
}
|
||||
|
|
@ -350,7 +349,7 @@ private fun MutableList<PageTextLayout>.appendMeasuredAndroidRichTextSegment(
|
|||
pageHeightPx = pageHeightPx
|
||||
)
|
||||
)
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.paginate pageBreakOnly page=$nextPageIndex global=$segmentStart..$breakEnd"
|
||||
)
|
||||
return nextPageIndex + 1
|
||||
|
|
@ -398,11 +397,11 @@ private fun MutableList<PageTextLayout>.appendMeasuredAndroidRichTextSegment(
|
|||
)
|
||||
)
|
||||
if (isLastContentPage && explicitBreakEnd != null) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd"
|
||||
)
|
||||
} else if (!fitsOnPage) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.paginate overflow page=$nextPageIndex global=$globalStart..$globalEnd line=$overflowLineIndex"
|
||||
)
|
||||
}
|
||||
|
|
@ -475,12 +474,12 @@ class PdfRichTextRepository(private val context: Context) {
|
|||
suspend fun load(bookId: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = getFile(bookId)
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.repository.load start book=$bookId exists=${file.exists()} path=${file.absolutePath}"
|
||||
)
|
||||
if (!file.exists()) {
|
||||
_document.value = GlobalRichDocument("", emptyList())
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.repository.load missing -> empty book=$bookId")
|
||||
Timber.d("android.repository.load missing -> empty book=$bookId")
|
||||
return@withContext
|
||||
}
|
||||
try {
|
||||
|
|
@ -508,11 +507,11 @@ class PdfRichTextRepository(private val context: Context) {
|
|||
)
|
||||
}
|
||||
_document.value = GlobalRichDocument(text, spans)
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.repository.load decoded book=$bookId rawLen=${jsonString.length} textLen=${text.length} spans=${spans.size}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.load failed book=$bookId")
|
||||
Timber.e(e, "android.repository.load failed book=$bookId")
|
||||
Timber.e(e, "Failed to load rich text doc")
|
||||
_document.value = GlobalRichDocument("", emptyList())
|
||||
}
|
||||
|
|
@ -523,7 +522,7 @@ class PdfRichTextRepository(private val context: Context) {
|
|||
_document.value = document
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.repository.save start book=$bookId textLen=${document.text.length} spans=${document.spans.size}"
|
||||
)
|
||||
val obj = JSONObject().apply {
|
||||
|
|
@ -548,11 +547,11 @@ class PdfRichTextRepository(private val context: Context) {
|
|||
}
|
||||
val file = getFile(bookId)
|
||||
file.writeText(obj.toString())
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.repository.save done book=$bookId bytes=${file.length()} path=${file.absolutePath}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.save failed book=$bookId")
|
||||
Timber.e(e, "android.repository.save failed book=$bookId")
|
||||
Timber.e(e, "Failed to save rich text doc")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import android.net.Uri
|
|||
import android.os.Build
|
||||
import com.aryan.reader.FileType
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.pptx.PptxDocumentWrapper
|
||||
import io.legere.pdfiumandroid.api.Bookmark
|
||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||
import io.legere.pdfiumandroid.suspend.PdfPageKt
|
||||
|
|
@ -79,7 +80,20 @@ object DocumentFactory {
|
|||
val catalogId = uri.getQueryParameter("catalogId")
|
||||
return OpdsStreamDocumentWrapper(context, bookId, urlTemplate, count, catalogId)
|
||||
}
|
||||
return if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
|
||||
return if (type == FileType.PPTX) {
|
||||
val cacheFile = File(context.cacheDir, "temp_pptx_${System.currentTimeMillis()}.pptx")
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
cacheFile.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: throw Exception("Failed to open PPTX")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
runCatching { cacheFile.delete() }
|
||||
throw e
|
||||
}
|
||||
PptxDocumentWrapper(cacheFile, deleteOnClose = true)
|
||||
} else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
|
||||
val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}")
|
||||
withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import com.aryan.reader.pdf.PdfUserHighlight
|
|||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
data class PdfTextBox(
|
||||
val id: String,
|
||||
|
|
@ -54,7 +55,9 @@ data class PdfAnnotation(
|
|||
val pageIndex: Int,
|
||||
val points: List<PdfPoint>,
|
||||
val color: Color,
|
||||
val strokeWidth: Float
|
||||
val strokeWidth: Float,
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val note: String? = null
|
||||
)
|
||||
|
||||
object AnnotationSerializer {
|
||||
|
|
@ -63,11 +66,15 @@ object AnnotationSerializer {
|
|||
annotations.forEach { (_, list) ->
|
||||
list.forEach { annotation ->
|
||||
val obj = JSONObject()
|
||||
obj.put("id", annotation.id)
|
||||
obj.put("pageIndex", annotation.pageIndex)
|
||||
obj.put("annotationType", annotation.type.name)
|
||||
obj.put("inkType", annotation.inkType.name)
|
||||
obj.put("color", annotation.color.toArgb())
|
||||
obj.put("strokeWidth", annotation.strokeWidth.toDouble())
|
||||
if (!annotation.note.isNullOrBlank()) {
|
||||
obj.put("note", annotation.note)
|
||||
}
|
||||
|
||||
val pointsArray = JSONArray()
|
||||
annotation.points.forEach { p ->
|
||||
|
|
@ -123,7 +130,10 @@ object AnnotationSerializer {
|
|||
pageIndex = pageIndex,
|
||||
points = points,
|
||||
color = Color(colorInt),
|
||||
strokeWidth = strokeWidth
|
||||
strokeWidth = strokeWidth,
|
||||
id = obj.optString("id").takeIf { it.isNotBlank() }
|
||||
?: UUID.randomUUID().toString(),
|
||||
note = obj.optString("note").takeIf { it.isNotBlank() }
|
||||
)
|
||||
|
||||
if (!resultMap.containsKey(pageIndex)) {
|
||||
|
|
@ -278,4 +288,4 @@ object HighlightSerializer {
|
|||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ class PdfAnnotationRepository(private val context: Context) {
|
|||
Timber.tag("AnnotationSync").d("Start saving local JSON for $bookId. Count: ${annotations.size}")
|
||||
|
||||
if (annotations.isEmpty()) {
|
||||
val file = getFile(bookId)
|
||||
if (file.exists()) file.delete()
|
||||
return@withContext
|
||||
}
|
||||
|
||||
|
|
@ -81,4 +83,4 @@ class PdfAnnotationRepository(private val context: Context) {
|
|||
|
||||
return if (valid) file else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ interface PdfTextDao {
|
|||
@Query("SELECT pageIndex FROM pdf_search_index WHERE bookId = :bookId")
|
||||
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)
|
||||
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 kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import com.aryan.reader.pdf.ReaderDocument
|
||||
|
||||
private const val TAG = "PdfSearchDiag"
|
||||
|
||||
|
|
@ -55,6 +56,11 @@ class PdfTextRepository(context: Context) {
|
|||
private val dao = db.pdfTextDao()
|
||||
private val metaDao = db.pdfMetaDao()
|
||||
|
||||
private suspend fun replacePageText(bookId: String, pageIndex: Int, content: String) {
|
||||
dao.deletePageText(bookId, pageIndex)
|
||||
dao.insertPageText(PdfSearchIndex(bookId = bookId, pageIndex = pageIndex, content = content))
|
||||
}
|
||||
|
||||
suspend fun getPageRatios(bookId: String): List<Float>? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val meta = metaDao.getMetadata(bookId)
|
||||
|
|
@ -250,7 +256,7 @@ class PdfTextRepository(context: Context) {
|
|||
Timber.tag(TAG).e("Page $pageIndex: Cleaning might have failed. Text still looks like path: $snippetClean")
|
||||
} else if (text.isNotBlank()) {
|
||||
Timber.tag(TAG).v("Page $pageIndex: Inserting valid text ($cleanedLength chars).")
|
||||
dao.insertPageText(PdfSearchIndex(bookId = bookId, pageIndex = pageIndex, content = text))
|
||||
replacePageText(bookId = bookId, pageIndex = pageIndex, content = text)
|
||||
} else {
|
||||
Timber.tag(TAG).i("Page $pageIndex: Text became empty after cleaning. Skipping insertion.")
|
||||
}
|
||||
|
|
@ -279,6 +285,69 @@ class PdfTextRepository(context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun indexReaderPage(
|
||||
bookId: String,
|
||||
document: ReaderDocument,
|
||||
pageIndex: Int,
|
||||
onOcrModelDownloading: () -> Unit = {}
|
||||
): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
var text = ""
|
||||
var ocrUsed = false
|
||||
|
||||
try {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val count = textPage.textPageCountChars()
|
||||
if (count > 0) {
|
||||
text = textPage.textPageGetText(0, count).orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "ReaderDocument extraction failed for page $pageIndex")
|
||||
}
|
||||
|
||||
if (text.isBlank()) {
|
||||
var bitmap: android.graphics.Bitmap? = null
|
||||
try {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
val targetWidth = 1080
|
||||
val pageWidth = page.getPageWidthPoint()
|
||||
val pageHeight = page.getPageHeightPoint()
|
||||
if (pageWidth > 0 && pageHeight > 0) {
|
||||
val aspectRatio = pageWidth.toFloat() / pageHeight.toFloat()
|
||||
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
|
||||
bitmap = createBitmap(targetWidth, targetHeight)
|
||||
page.renderPageBitmap(bitmap!!, 0, 0, targetWidth, targetHeight, false)
|
||||
}
|
||||
}
|
||||
|
||||
bitmap?.let {
|
||||
try {
|
||||
val visionText = OcrHelper.extractTextFromBitmap(it, onOcrModelDownloading)
|
||||
text = visionText?.text.orEmpty()
|
||||
ocrUsed = true
|
||||
} finally {
|
||||
it.recycle()
|
||||
bitmap = null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
bitmap?.recycle()
|
||||
Timber.tag(TAG).e(e, "ReaderDocument OCR failed for page $pageIndex")
|
||||
}
|
||||
}
|
||||
|
||||
val cleaned = cleanIndexedText(text)
|
||||
if (cleaned.isNotBlank()) {
|
||||
replacePageText(bookId = bookId, pageIndex = pageIndex, content = cleaned)
|
||||
}
|
||||
|
||||
ocrUsed && cleaned.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
|
@ -599,4 +668,17 @@ class PdfTextRepository(context: Context) {
|
|||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun cleanIndexedText(raw: String): String {
|
||||
if (raw.isBlank()) return ""
|
||||
var text = raw
|
||||
val patterns = listOf(
|
||||
Regex("(?i)file:/?/?/?\\S+"),
|
||||
Regex("(?i)/data/user/\\d+/\\S+"),
|
||||
Regex("(?i)/storage/emulated/\\d+/\\S+"),
|
||||
Regex("(?i)\\S*com\\.aryan\\.reader\\S*")
|
||||
)
|
||||
patterns.forEach { pattern -> text = text.replace(pattern, " ") }
|
||||
return text.replace(Regex("\\s+"), " ").trim()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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(
|
||||
private val player: Player,
|
||||
private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData,
|
||||
private val onResetContext: () -> Unit
|
||||
private val onResetContext: () -> Unit,
|
||||
private val onPlaybackSessionPreparing: (bookTitle: String?, chapterTitle: String?) -> Unit = { _, _ -> },
|
||||
private val onPlaybackSessionStopped: () -> Unit = {}
|
||||
) : MediaSession.Callback, Player.Listener {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
|
@ -415,6 +417,8 @@ class TtsPlaybackManager(
|
|||
handleStopTts(clearState = false)
|
||||
}
|
||||
|
||||
onPlaybackSessionPreparing(bookTitle, chapterTitle)
|
||||
|
||||
textChunks = chunks
|
||||
currentSpeakerId = speakerId
|
||||
currentTtsMode = ttsMode
|
||||
|
|
@ -550,6 +554,7 @@ class TtsPlaybackManager(
|
|||
val firstChunk = textChunks.getOrNull(startAtIndex)
|
||||
if (firstChunk == null) {
|
||||
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.")
|
||||
onPlaybackSessionStopped()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -627,6 +632,7 @@ class TtsPlaybackManager(
|
|||
isLoading = false,
|
||||
errorMessage = ttsAudioData.error ?: "Failed to load audio."
|
||||
)
|
||||
onPlaybackSessionStopped()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -667,6 +673,7 @@ class TtsPlaybackManager(
|
|||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"handleStopTts. clearState=$clearState, userInitiated=$userInitiated"
|
||||
)
|
||||
onPlaybackSessionStopped()
|
||||
onResetContext()
|
||||
preparationJob?.cancel()
|
||||
wordTrackingJob?.cancel()
|
||||
|
|
|
|||
|
|
@ -20,9 +20,15 @@
|
|||
package com.aryan.reader.tts
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
|
|
@ -30,13 +36,16 @@ import androidx.media3.common.util.UnstableApi
|
|||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.GEMINI_CLOUD_TTS_MODEL
|
||||
import com.aryan.reader.isByokCloudTtsAvailable
|
||||
import com.aryan.reader.loadAiByokSettings
|
||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
|
|
@ -229,6 +238,11 @@ class InputStreamDataSource : androidx.media3.datasource.BaseDataSource(true) {
|
|||
}
|
||||
}
|
||||
|
||||
private const val TTS_FOREGROUND_CHANNEL_ID = "tts_playback"
|
||||
// Keep this aligned with Media3's default notification ID so playback updates replace the fallback.
|
||||
private const val TTS_FOREGROUND_NOTIFICATION_ID = 1001
|
||||
private const val TTS_FOREGROUND_IDLE_GRACE_MS = 15_000L
|
||||
|
||||
@UnstableApi
|
||||
class TtsService : MediaSessionService() {
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
|
@ -237,6 +251,24 @@ class TtsService : MediaSessionService() {
|
|||
private lateinit var playbackManager: TtsPlaybackManager
|
||||
private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer
|
||||
private lateinit var cacheManager: TtsCacheManager
|
||||
private var foregroundNotificationShown = false
|
||||
private var foregroundPlaybackExpected = false
|
||||
private var foregroundIdleJob: Job? = null
|
||||
private var foregroundBookTitle: String? = null
|
||||
private var foregroundChapterTitle: String? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val result = super.onStartCommand(intent, flags, startId)
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"onStartCommand. action=${intent?.action}, startId=$startId, result=$result"
|
||||
)
|
||||
val hasPreparedMedia = ::player.isInitialized && player.mediaItemCount > 0
|
||||
if (!hasPreparedMedia) {
|
||||
showPreparingForegroundNotification("onStartCommand")
|
||||
scheduleForegroundIdleStop(startId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
|
|
@ -252,21 +284,133 @@ class TtsService : MediaSessionService() {
|
|||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
|
||||
if (startInForegroundRequired) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing while foreground is required. Calling stopSelf().")
|
||||
stopSelf()
|
||||
} else {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing. Skipping notification update.")
|
||||
}
|
||||
|
||||
return
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w(
|
||||
"POST_NOTIFICATIONS is missing, but MediaSession notifications are exempt. Delegating notification update."
|
||||
)
|
||||
}
|
||||
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Delegating notification update to MediaSessionService.")
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
|
||||
private fun showPreparingForegroundNotification(
|
||||
reason: String,
|
||||
bookTitle: String? = foregroundBookTitle,
|
||||
chapterTitle: String? = foregroundChapterTitle
|
||||
) {
|
||||
foregroundBookTitle = bookTitle
|
||||
foregroundChapterTitle = chapterTitle
|
||||
ensureTtsNotificationChannel()
|
||||
|
||||
try {
|
||||
val notification = buildPreparingForegroundNotification(bookTitle, chapterTitle)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
TTS_FOREGROUND_NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
|
||||
)
|
||||
} else {
|
||||
startForeground(TTS_FOREGROUND_NOTIFICATION_ID, notification)
|
||||
}
|
||||
foregroundNotificationShown = true
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Fallback foreground notification shown. reason=$reason")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(e, "Failed to show fallback foreground notification. reason=$reason")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildPreparingForegroundNotification(bookTitle: String?, chapterTitle: String?): Notification {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
val contentIntent = launchIntent?.let {
|
||||
PendingIntent.getActivity(this, 0, it, pendingIntentFlags())
|
||||
}
|
||||
val title = bookTitle?.takeIf { it.isNotBlank() } ?: getString(R.string.app_name)
|
||||
val text = chapterTitle?.takeIf { it.isNotBlank() }
|
||||
?.let { getString(R.string.tts_notification_preparing_chapter, it) }
|
||||
?: getString(R.string.tts_notification_preparing)
|
||||
|
||||
return NotificationCompat.Builder(this, TTS_FOREGROUND_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_monochrome)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.apply {
|
||||
if (contentIntent != null) {
|
||||
setContentIntent(contentIntent)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun ensureTtsNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
TTS_FOREGROUND_CHANNEL_ID,
|
||||
getString(R.string.tts_notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = getString(R.string.tts_notification_channel_desc)
|
||||
setShowBadge(false)
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun pendingIntentFlags(): Int {
|
||||
return PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
}
|
||||
|
||||
private fun scheduleForegroundIdleStop(startId: Int) {
|
||||
foregroundIdleJob?.cancel()
|
||||
foregroundIdleJob = scope.launch {
|
||||
delay(TTS_FOREGROUND_IDLE_GRACE_MS)
|
||||
val playbackInactive = !::player.isInitialized ||
|
||||
(!player.isPlaying && !player.playWhenReady && player.mediaItemCount == 0)
|
||||
if (!foregroundPlaybackExpected && playbackInactive) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w(
|
||||
"Foreground service start did not become an active TTS session. Stopping fallback foreground."
|
||||
)
|
||||
stopTtsForeground()
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPlaybackSessionPreparing(bookTitle: String?, chapterTitle: String?) {
|
||||
foregroundPlaybackExpected = true
|
||||
foregroundIdleJob?.cancel()
|
||||
showPreparingForegroundNotification("START_TTS_COMMAND", bookTitle, chapterTitle)
|
||||
}
|
||||
|
||||
private fun onPlaybackSessionStopped() {
|
||||
foregroundPlaybackExpected = false
|
||||
foregroundIdleJob?.cancel()
|
||||
foregroundBookTitle = null
|
||||
foregroundChapterTitle = null
|
||||
stopTtsForeground()
|
||||
}
|
||||
|
||||
private fun stopTtsForeground() {
|
||||
if (!foregroundNotificationShown) return
|
||||
try {
|
||||
stopForeground(android.app.Service.STOP_FOREGROUND_REMOVE)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w(e, "Failed to stop fallback foreground notification.")
|
||||
} finally {
|
||||
foregroundNotificationShown = false
|
||||
}
|
||||
}
|
||||
|
||||
private val okHttpClient = OkHttpClient.Builder().build()
|
||||
private val liveClient by lazy {
|
||||
GeminiLiveClient(okHttpClient) { errorMsg ->
|
||||
|
|
@ -668,7 +812,9 @@ class TtsService : MediaSessionService() {
|
|||
playbackManager = TtsPlaybackManager(
|
||||
player = player,
|
||||
generateAudioChunk = audioGenerator,
|
||||
onResetContext = { liveClient.close() }
|
||||
onResetContext = { liveClient.close() },
|
||||
onPlaybackSessionPreparing = ::onPlaybackSessionPreparing,
|
||||
onPlaybackSessionStopped = ::onPlaybackSessionStopped
|
||||
)
|
||||
|
||||
mediaSession = MediaSession.Builder(this, player)
|
||||
|
|
@ -683,7 +829,7 @@ class TtsService : MediaSessionService() {
|
|||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"onTaskRemoved. playWhenReady=${if (::player.isInitialized) player.playWhenReady else null}, isPlaying=${if (::player.isInitialized) player.isPlaying else null}"
|
||||
)
|
||||
if (!player.playWhenReady) {
|
||||
if (!::player.isInitialized || !player.playWhenReady) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Task removed while player is not playWhenReady. Calling stopSelf().")
|
||||
stopSelf()
|
||||
}
|
||||
|
|
@ -700,13 +846,26 @@ class TtsService : MediaSessionService() {
|
|||
override fun onDestroy() {
|
||||
Timber.d("TtsService is being destroyed.")
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsService onDestroy.")
|
||||
baseTtsSynthesizer.shutdown()
|
||||
playbackManager.release()
|
||||
mediaSession?.run {
|
||||
player.release()
|
||||
release()
|
||||
foregroundIdleJob?.cancel()
|
||||
stopTtsForeground()
|
||||
if (::baseTtsSynthesizer.isInitialized) {
|
||||
baseTtsSynthesizer.shutdown()
|
||||
}
|
||||
if (::playbackManager.isInitialized) {
|
||||
playbackManager.release()
|
||||
}
|
||||
var playerReleased = false
|
||||
mediaSession?.let { session ->
|
||||
if (::player.isInitialized) {
|
||||
player.release()
|
||||
playerReleased = true
|
||||
}
|
||||
session.release()
|
||||
mediaSession = null
|
||||
}
|
||||
if (!playerReleased && ::player.isInitialized) {
|
||||
player.release()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -508,6 +508,7 @@
|
|||
<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_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. -->
|
||||
<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. -->
|
||||
|
|
@ -690,10 +691,10 @@
|
|||
<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. -->
|
||||
<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. -->
|
||||
<string name="menu_reading_mode_vertical">Reading Mode: Vertical</string>
|
||||
<!-- Menu item to switch reading mode. The colon-separated format "Reading Mode: Paginated" is intentional — do not omit the colon. -->
|
||||
<string name="menu_reading_mode_paginated">Reading Mode: Paginated</string>
|
||||
<!-- Menu item inside the reading mode submenu. -->
|
||||
<string name="menu_reading_mode_vertical">Vertical</string>
|
||||
<!-- Menu item inside the reading mode submenu. -->
|
||||
<string name="menu_reading_mode_paginated">Paginated (left-to-right)</string>
|
||||
<!-- Accessibility label for a menu item that is currently active/checked. -->
|
||||
<string name="content_desc_enabled">Enabled</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_keep_screen_on">Keep Screen On</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>
|
||||
<!-- 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>
|
||||
<!-- TTS = Text-to-Speech. -->
|
||||
<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. -->
|
||||
<string name="menu_tts_settings_debug">TTS Settings (Debug)</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="no_imported_fonts_yet">No imported fonts yet.</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. -->
|
||||
<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_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_desc">The reading progress and chapter indicator on the reading screen.</string>
|
||||
<string name="visual_options_progress_bar_position">Position</string>
|
||||
|
|
@ -1164,6 +1178,11 @@
|
|||
<string name="section_files">Files</string>
|
||||
|
||||
<!-- 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_mode_cloud_ai">Cloud AI</string>
|
||||
<string name="tts_mode_device_native">Device Native</string>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ data class ProUpgradeState(
|
|||
val creditProducts: List<ProductDetailsEntity> = emptyList(),
|
||||
val hasValidPurchase: Boolean = false,
|
||||
val activePurchases: List<PurchaseEntity> = emptyList(),
|
||||
val hasAccountConflict: Boolean = false,
|
||||
val billingClientReady: Boolean = false,
|
||||
val error: String? = null,
|
||||
val isVerifying: Boolean = false
|
||||
|
|
@ -35,7 +36,11 @@ class BillingClientWrapper(
|
|||
// No-op
|
||||
}
|
||||
|
||||
fun launchPurchaseFlow(activity: Activity, productId: String = PRO_LIFETIME_PRODUCT_ID) {
|
||||
fun launchPurchaseFlow(
|
||||
activity: Activity,
|
||||
productId: String = PRO_LIFETIME_PRODUCT_ID,
|
||||
obfuscatedAccountId: String? = null
|
||||
) {
|
||||
_proUpgradeState.value = _proUpgradeState.value.copy(error = "Not available in Open Source version")
|
||||
}
|
||||
fun consumePurchase(purchaseToken: String) {}
|
||||
|
|
@ -44,6 +49,14 @@ class BillingClientWrapper(
|
|||
_proUpgradeState.value = _proUpgradeState.value.copy(error = null)
|
||||
}
|
||||
|
||||
fun markAccountConflict() {
|
||||
_proUpgradeState.value = _proUpgradeState.value.copy(hasAccountConflict = true, isVerifying = false)
|
||||
}
|
||||
|
||||
fun clearAccountConflict() {
|
||||
_proUpgradeState.value = _proUpgradeState.value.copy(hasAccountConflict = false)
|
||||
}
|
||||
|
||||
fun clearVerificationState() {
|
||||
_proUpgradeState.value = _proUpgradeState.value.copy(isVerifying = false)
|
||||
}
|
||||
|
|
@ -51,4 +64,4 @@ class BillingClientWrapper(
|
|||
companion object {
|
||||
const val PRO_LIFETIME_PRODUCT_ID = "episteme_pro_lifetime"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,17 @@ data class BookMetadata(
|
|||
val lastModifiedTimestamp: Long = 0L,
|
||||
val bookmarksJson: String? = null,
|
||||
val hasAnnotations: Boolean = false,
|
||||
val fileContentModifiedTimestamp: Long = 0L,
|
||||
val customName: String? = null,
|
||||
val highlightsJson: String? = null
|
||||
val highlightsJson: String? = null,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val description: String? = null,
|
||||
val originalTitle: String? = null,
|
||||
val originalAuthor: String? = null,
|
||||
val originalSeriesName: String? = null,
|
||||
val originalSeriesIndex: Double? = null,
|
||||
val originalDescription: String? = null
|
||||
)
|
||||
|
||||
data class DeviceItem(
|
||||
|
|
@ -135,4 +144,4 @@ class FirestoreRepository {
|
|||
|
||||
suspend fun addMessageToThread(threadId: String, messageId: String, uid: String, text: String, sender: String, attachments: List<String>) {}
|
||||
suspend fun markThreadAsRead(threadId: String) {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Episteme (OSS)</string>
|
||||
</resources>
|
||||
<string name="app_name">Episteme oss</string>
|
||||
</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.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -20,6 +21,21 @@ class FileTypeResolverTest {
|
|||
assertEquals(FileType.HTML, resolveFileTypeFromName("table.csv"))
|
||||
assertEquals(FileType.HTML, resolveFileTypeFromName("script.kt"))
|
||||
assertEquals(FileType.HTML, resolveFileTypeFromName("payload.json.txt"))
|
||||
assertEquals(com.aryan.reader.shared.SharedFileCapabilities.resolveFileTypeForName("payload.json.txt"), resolveFileTypeFromName("payload.json.txt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata resolver maps provider mime types without exposing generic archives`() {
|
||||
assertEquals(FileType.PDF, resolveFileTypeFromMetadata("download", "application/pdf"))
|
||||
assertEquals(FileType.DOCX, resolveFileTypeFromMetadata("download", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
|
||||
assertEquals(FileType.PPTX, resolveFileTypeFromMetadata("download", "application/vnd.openxmlformats-officedocument.presentationml.presentation"))
|
||||
assertEquals(FileType.MD, resolveFileTypeFromMetadata("notes.markdown.txt", "text/plain; charset=utf-8"))
|
||||
assertEquals(FileType.EPUB, resolveFileTypeFromMetadata("book.epub.txt", "text/plain"))
|
||||
assertEquals(FileType.TXT, resolveFileTypeFromMetadata("notes", "text/plain"))
|
||||
assertEquals(FileType.HTML, resolveFileTypeFromMetadata("payload", "application/json"))
|
||||
assertEquals(FileType.CBZ, resolveFileTypeFromMetadata("comic.cbz", "application/zip"))
|
||||
assertEquals(FileType.FB2, resolveFileTypeFromMetadata("book.fb2.zip", "application/zip"))
|
||||
assertNull(resolveFileTypeFromMetadata("archive.zip", "application/zip"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -39,7 +55,9 @@ class FileTypeResolverTest {
|
|||
@Test
|
||||
fun `plain txt remains txt when inner extension is unsupported`() {
|
||||
assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))
|
||||
assertEquals(FileType.PPTX, resolveFileTypeFromName("deck.pptx"))
|
||||
assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt"))
|
||||
assertNull(resolveFileTypeFromName("archive.zip"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ class LibraryStateProjectorTest {
|
|||
assertEquals(files.ids(), filterBySearch(files, " ").ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filterBySearch preserves android display-name matching when a custom name exists`() {
|
||||
val file = recentFile("custom", displayName = "Original File.pdf", customName = "Renamed")
|
||||
|
||||
assertEquals(listOf("custom"), filterBySearch(listOf(file), "original").ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyLibraryFilters requires all active filters to match`() {
|
||||
val activeTag = tag("active", "Active")
|
||||
|
|
@ -188,6 +195,7 @@ class LibraryStateProjectorTest {
|
|||
sortOrder = SortOrder.TITLE_ASC,
|
||||
recentFilesLimit = 1,
|
||||
openTabIds = listOf("beta", "missing"),
|
||||
activeTabBookId = "missing",
|
||||
contextualActionItems = setOf(recentFile("beta"), recentFile("missing")),
|
||||
viewingShelfId = "manual",
|
||||
contextualActionShelfIds = setOf("manual", "missing")
|
||||
|
|
@ -208,6 +216,8 @@ class LibraryStateProjectorTest {
|
|||
assertEquals(listOf("alpha", "beta", "gamma"), result.rawLibraryFiles.ids())
|
||||
assertEquals(listOf("beta"), result.recentFiles.ids())
|
||||
assertEquals(listOf("beta"), result.openTabs.ids())
|
||||
assertEquals(listOf("beta"), result.openTabIds)
|
||||
assertNull(result.activeTabBookId)
|
||||
assertEquals(setOf("beta"), result.contextualActionItems.mapTo(mutableSetOf()) { it.bookId })
|
||||
assertEquals(listOf(tag), result.contextualActionItems.first().tags)
|
||||
assertEquals("manual", result.viewingShelfId)
|
||||
|
|
@ -266,6 +276,30 @@ class LibraryStateProjectorTest {
|
|||
assertEquals(listOf(tag), result.allTags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `project keeps pinned home and library books first using shared projector`() {
|
||||
val older = recentFile("older", title = "Zulu", timestamp = 1L)
|
||||
val newer = recentFile("newer", title = "Alpha", timestamp = 2L)
|
||||
|
||||
val result = LibraryStateProjector().project(
|
||||
LibraryProjectionInput(
|
||||
state = ReaderScreenState(
|
||||
sortOrder = SortOrder.TITLE_ASC,
|
||||
pinnedHomeBookIds = setOf("older"),
|
||||
pinnedLibraryBookIds = setOf("older")
|
||||
),
|
||||
recentFilesFromDb = listOf(older, newer),
|
||||
dbShelves = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
dbTags = emptyList(),
|
||||
tagRefs = emptyList()
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("older", "newer"), result.recentFiles.ids())
|
||||
assertEquals(listOf("older", "newer"), result.allRecentFiles.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `project builds manual tag series and unshelved shelves`() {
|
||||
val favorite = tag("favorite", "Favorite")
|
||||
|
|
@ -479,7 +513,8 @@ class LibraryStateProjectorTest {
|
|||
tags: List<TagEntity> = emptyList(),
|
||||
fileSize: Long = 0L,
|
||||
seriesName: String? = null,
|
||||
seriesIndex: Double? = null
|
||||
seriesIndex: Double? = null,
|
||||
customName: String? = null
|
||||
) = RecentFileItem(
|
||||
bookId = id,
|
||||
uriString = uriString,
|
||||
|
|
@ -494,7 +529,8 @@ class LibraryStateProjectorTest {
|
|||
tags = tags,
|
||||
fileSize = fileSize,
|
||||
seriesName = seriesName,
|
||||
seriesIndex = seriesIndex
|
||||
seriesIndex = seriesIndex,
|
||||
customName = customName
|
||||
)
|
||||
|
||||
private fun tag(id: String, name: String) = TagEntity(
|
||||
|
|
|
|||
|
|
@ -9,16 +9,12 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.credentials.CredentialManager
|
||||
import androidx.work.WorkManager
|
||||
import com.android.billingclient.api.BillingClient
|
||||
import com.android.billingclient.api.BillingResult
|
||||
import com.aryan.reader.data.*
|
||||
import com.aryan.reader.paginatedreader.Locator
|
||||
import com.aryan.reader.paginatedreader.data.BookCacheDao
|
||||
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
||||
import com.aryan.reader.tts.TtsController
|
||||
import com.aryan.reader.tts.TtsPlaybackManager
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -108,28 +104,8 @@ class MainViewModelTest {
|
|||
mockkObject(WorkManager.Companion)
|
||||
val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
||||
every { WorkManager.getInstance(any()) } returns mockWorkManager
|
||||
mockkStatic(FirebaseAuth::class)
|
||||
every { FirebaseAuth.getInstance() } returns mockk(relaxed = true)
|
||||
mockkStatic(FirebaseFirestore::class)
|
||||
every { FirebaseFirestore.getInstance() } returns mockk(relaxed = true)
|
||||
mockkObject(CredentialManager.Companion)
|
||||
every { CredentialManager.create(any()) } returns mockk(relaxed = true)
|
||||
mockkStatic(BillingClient::class)
|
||||
val mockBillingClient = mockk<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(RecentFilesRepository::class)
|
||||
mockkConstructor(BillingClientWrapper::class)
|
||||
|
|
@ -140,6 +116,14 @@ class MainViewModelTest {
|
|||
mockkConstructor(TtsController::class)
|
||||
|
||||
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>().observeAuthState() } returns flowOf(null)
|
||||
every { anyConstructed<RemoteConfigRepository>().init() } just Runs
|
||||
|
|
@ -566,6 +550,36 @@ class MainViewModelTest {
|
|||
verify { mockEditor.putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateLibraryFilters drops unknown file type before state and prefs`() = runTest {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
val requested = LibraryFilters(
|
||||
fileTypes = setOf(FileType.PDF, FileType.UNKNOWN),
|
||||
sourceFolders = setOf("content://sync"),
|
||||
readStatus = ReadStatusFilter.IN_PROGRESS,
|
||||
tagIds = setOf("favorite")
|
||||
)
|
||||
val expected = requested.copy(fileTypes = setOf(FileType.PDF))
|
||||
|
||||
viewModel.updateLibraryFilters(requested)
|
||||
|
||||
val state = viewModel.uiState.first { it.libraryFilters == expected }
|
||||
assertEquals(expected, state.libraryFilters)
|
||||
assertFalse(FileType.UNKNOWN in state.libraryFilters.fileTypes)
|
||||
verify { mockEditor.putStringSet(KEY_FILTER_FILE_TYPES, setOf("PDF")) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved library file filters drop stale unknown values during restore`() = runTest {
|
||||
every { mockPrefs.getStringSet(KEY_FILTER_FILE_TYPES, any()) } returns mutableSetOf("PDF", "UNKNOWN")
|
||||
|
||||
val restored = MainViewModel(mockApplication)
|
||||
|
||||
assertEquals(setOf(FileType.PDF), restored.uiState.value.libraryFilters.fileTypes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
|
|
@ -954,6 +968,22 @@ class MainViewModelTest {
|
|||
assertEquals(null, clearedState.bannerMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persistent banner is not auto dismissed`() = runTest {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
||||
viewModel.showBanner("Syncing", isPersistent = true)
|
||||
viewModel.uiState.first { it.bannerMessage?.message == "Syncing" }
|
||||
runCurrent()
|
||||
|
||||
advanceTimeBy(3_000L)
|
||||
runCurrent()
|
||||
|
||||
assertEquals("Syncing", viewModel.uiState.value.bannerMessage?.message)
|
||||
}
|
||||
|
||||
private fun recentFile(
|
||||
id: String,
|
||||
type: FileType = FileType.EPUB,
|
||||
|
|
@ -970,10 +1000,16 @@ class MainViewModelTest {
|
|||
title = title
|
||||
)
|
||||
|
||||
private fun mockUri(uriString: String): Uri {
|
||||
private fun mockUri(
|
||||
uriString: String,
|
||||
path: String? = uriString.substringAfter(":", ""),
|
||||
lastPathSegment: String? = path?.substringAfterLast('/')
|
||||
): Uri {
|
||||
return mockk<Uri>().also { uri ->
|
||||
every { uri.toString() } returns uriString
|
||||
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())
|
||||
|
||||
assertEquals(metadata.copy(author = null, lastPage = null, locatorCharOffset = null), decoded)
|
||||
assertEquals(
|
||||
metadata.copy(
|
||||
title = null,
|
||||
author = null,
|
||||
lastPage = null,
|
||||
locatorCharOffset = null
|
||||
),
|
||||
decoded
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -78,8 +86,8 @@ class FolderBookMetadataTest {
|
|||
|
||||
assertEquals("book-2", item.bookId)
|
||||
assertEquals(FileType.EPUB, item.type)
|
||||
assertEquals("Remote Title", item.title)
|
||||
assertEquals("Author", item.author)
|
||||
assertEquals("Remote", item.title)
|
||||
assertNull(item.author)
|
||||
assertEquals(12, item.lastPage)
|
||||
assertEquals(7, item.locatorBlockIndex)
|
||||
assertEquals(8, item.locatorCharOffset)
|
||||
|
|
@ -87,4 +95,34 @@ class FolderBookMetadataTest {
|
|||
assertEquals("Shelf Name", item.customName)
|
||||
assertEquals("highlights", item.highlightsJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toRecentFileItem preserves explicit unknown file type`() {
|
||||
val metadata = FolderBookMetadata(
|
||||
bookId = "book-3",
|
||||
title = null,
|
||||
author = null,
|
||||
displayName = "Remote.bin",
|
||||
type = "UNKNOWN",
|
||||
lastChapterIndex = null,
|
||||
lastPage = null,
|
||||
lastPositionCfi = null,
|
||||
progressPercentage = 0f,
|
||||
isRecent = false,
|
||||
lastModifiedTimestamp = 500L,
|
||||
bookmarksJson = null,
|
||||
locatorBlockIndex = null,
|
||||
locatorCharOffset = null,
|
||||
customName = null,
|
||||
highlightsJson = null
|
||||
)
|
||||
|
||||
val item = metadata.toRecentFileItem(
|
||||
uriString = "content://book",
|
||||
coverPath = null,
|
||||
sourceFolderUri = "content://folder"
|
||||
)
|
||||
|
||||
assertEquals(FileType.UNKNOWN, item.type)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.locatorCharOffset, roundTripped.locatorCharOffset)
|
||||
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
|
||||
assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -30,6 +31,59 @@ class RecentFileItemReadingPositionMappingTest {
|
|||
assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex)
|
||||
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
|
||||
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
|
||||
assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud metadata mapping preserves non epub display rename as custom name`() {
|
||||
val item = recentFileItem().copy(
|
||||
type = FileType.PDF,
|
||||
customName = "Reader Display Name"
|
||||
)
|
||||
|
||||
val roundTripped = item.toBookMetadata().toRecentFileItem()
|
||||
|
||||
assertEquals("Reader Display Name", roundTripped.customName)
|
||||
assertEquals("One.epub", roundTripped.displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recent file entity mapping preserves original metadata snapshot`() {
|
||||
val item = recentFileItem().copy(
|
||||
title = "Edited One",
|
||||
author = "Edited Author",
|
||||
seriesName = "Edited Series",
|
||||
seriesIndex = 2.0,
|
||||
description = "Edited summary",
|
||||
originalTitle = "Original One",
|
||||
originalAuthor = "Original Author",
|
||||
originalSeriesName = "Original Series",
|
||||
originalSeriesIndex = 1.0,
|
||||
originalDescription = "Original summary"
|
||||
)
|
||||
|
||||
val roundTripped = item.toRecentFileEntity().toRecentFileItem()
|
||||
|
||||
assertEquals("Original One", roundTripped.originalTitle)
|
||||
assertEquals("Original Author", roundTripped.originalAuthor)
|
||||
assertEquals("Original Series", roundTripped.originalSeriesName)
|
||||
assertEquals(1.0, roundTripped.originalSeriesIndex)
|
||||
assertEquals("Original summary", roundTripped.originalDescription)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recent file entity mapping seeds original metadata when first stored`() {
|
||||
val entity = recentFileItem().copy(
|
||||
seriesName = "Series",
|
||||
seriesIndex = 1.0,
|
||||
description = "Summary"
|
||||
).toRecentFileEntity()
|
||||
|
||||
assertEquals("One", entity.originalTitle)
|
||||
assertEquals("Author", entity.originalAuthor)
|
||||
assertEquals("Series", entity.originalSeriesName)
|
||||
assertEquals(1.0, entity.originalSeriesIndex)
|
||||
assertEquals("Summary", entity.originalDescription)
|
||||
}
|
||||
|
||||
private fun recentFileItem(): RecentFileItem {
|
||||
|
|
@ -47,6 +101,7 @@ class RecentFileItemReadingPositionMappingTest {
|
|||
locatorCharOffset = 88,
|
||||
progressPercentage = 61.5f,
|
||||
lastModifiedTimestamp = 2_000L,
|
||||
fileContentModifiedTimestamp = 3_000L,
|
||||
bookmarksJson = """[{"cfi":"/4/2"}]""",
|
||||
highlightsJson = """[{"cfi":"/4/2/6:88"}]"""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import io.mockk.unmockkObject
|
|||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
|
@ -113,14 +115,82 @@ class RecentFilesRepositoryReadingPositionMergeTest {
|
|||
assertEquals(82f, inserted.captured.progressPercentage)
|
||||
}
|
||||
|
||||
private fun existingEntity(): RecentFileEntity {
|
||||
@Test
|
||||
fun `addRecentFile keeps edited embedded epub metadata when cached parser returns original metadata`() = runTest {
|
||||
val inserted = slot<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(
|
||||
bookId = "book-1",
|
||||
uriString = "content://old",
|
||||
type = FileType.EPUB,
|
||||
displayName = "Old.epub",
|
||||
timestamp = 1_000L,
|
||||
coverImagePath = "/covers/old.png",
|
||||
coverImagePath = coverImagePath,
|
||||
title = "Old",
|
||||
author = "Author",
|
||||
lastChapterIndex = 6,
|
||||
|
|
@ -138,11 +208,12 @@ class RecentFilesRepositoryReadingPositionMergeTest {
|
|||
isReflowPreferred = false,
|
||||
customName = "Custom",
|
||||
highlights = "highlights",
|
||||
fileSize = 123L,
|
||||
fileSize = fileSize,
|
||||
seriesName = "Series",
|
||||
seriesIndex = 1.0,
|
||||
description = "Description",
|
||||
folderTextMetadataParsed = true
|
||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||
folderCoverMetadataParsed = folderCoverMetadataParsed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import java.io.ByteArrayInputStream
|
|||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class EpubParserUnitTest {
|
||||
|
|
@ -103,6 +104,43 @@ class EpubParserUnitTest {
|
|||
assertTrue(extractionDir.list().isNullOrEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata only extraction streams images to disk without retaining image bytes`() {
|
||||
val cacheDir = temp.newFolder("cache-metadata-stream")
|
||||
val extractionDir = temp.newFolder("extract-metadata-stream")
|
||||
val parser = EpubParser(contextWithCache(cacheDir))
|
||||
val imageBytes = ByteArray(2 * 1024 * 1024) { 7 }
|
||||
val zipFileOnDisk = File(temp.root, "metadata-stream.epub")
|
||||
zipFileOnDisk.writeBytes(
|
||||
zipBinaryBytes(
|
||||
"META-INF/container.xml" to """
|
||||
<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
|
||||
fun `createEpubBook reuses active extraction cache on matching warm open`() = runTest {
|
||||
val cacheDir = temp.newFolder("cache-warm-open")
|
||||
|
|
@ -129,6 +167,35 @@ class EpubParserUnitTest {
|
|||
assertTrue(File(activeDir, "sentinel.txt").isFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createEpubBook invalidates active extraction cache when source fingerprint changes`() = runTest {
|
||||
val cacheDir = temp.newFolder("cache-source-change")
|
||||
val context = contextWithCache(cacheDir)
|
||||
val parser = EpubParser(context)
|
||||
|
||||
val first = parser.createEpubBook(
|
||||
inputStream = ByteArrayInputStream(sampleEpubBytes()),
|
||||
bookId = "changed-book",
|
||||
shouldUseToc = true,
|
||||
originalBookNameHint = "changed.epub",
|
||||
sourceFingerprint = "100:1000"
|
||||
)
|
||||
val activeDir = ImportedFileCache.activeBookDir(context, "changed-book")
|
||||
File(activeDir, "sentinel.txt").writeText("old extraction")
|
||||
|
||||
val second = parser.createEpubBook(
|
||||
inputStream = ByteArrayInputStream(sampleEpubBytes(author = "Edited Writer")),
|
||||
bookId = "changed-book",
|
||||
shouldUseToc = true,
|
||||
originalBookNameHint = "changed.epub",
|
||||
sourceFingerprint = "120:2000"
|
||||
)
|
||||
|
||||
assertEquals("Jane Writer", first.author)
|
||||
assertEquals("Edited Writer", second.author)
|
||||
assertFalse(File(activeDir, "sentinel.txt").isFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata only parse does not clear active extracted content`() = runTest {
|
||||
val cacheDir = temp.newFolder("cache-metadata-preserve")
|
||||
|
|
@ -325,7 +392,7 @@ class EpubParserUnitTest {
|
|||
return context
|
||||
}
|
||||
|
||||
private fun sampleEpubBytes(): ByteArray = zipBytes(
|
||||
private fun sampleEpubBytes(author: String = "Jane Writer"): ByteArray = zipBytes(
|
||||
"META-INF/container.xml" to """
|
||||
<container version="1.0">
|
||||
<rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles>
|
||||
|
|
@ -335,7 +402,7 @@ class EpubParserUnitTest {
|
|||
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata>
|
||||
<dc:title>Sample/Book</dc:title>
|
||||
<dc:creator>Jane Writer</dc:creator>
|
||||
<dc:creator>$author</dc:creator>
|
||||
<dc:language>en</dc:language>
|
||||
<dc:description>Long description</dc:description>
|
||||
<meta name="calibre:series" content="Series Name"/>
|
||||
|
|
@ -415,11 +482,15 @@ class EpubParserUnitTest {
|
|||
)
|
||||
|
||||
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()
|
||||
ZipOutputStream(out).use { zip ->
|
||||
entries.forEach { (name, content) ->
|
||||
zip.putNextEntry(ZipEntry(name))
|
||||
zip.write(content.toByteArray(Charsets.UTF_8))
|
||||
zip.write(content)
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,5 +196,6 @@ class EpubReaderBridgeAndControlsTest {
|
|||
assertTrue(ReaderTool.entries.any { it.category == "Top Bar" })
|
||||
assertTrue(ReaderTool.entries.any { it.category == "Bottom Bar" })
|
||||
assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" })
|
||||
assertEquals("Top Bar", ReaderTool.SCREEN_ORIENTATION.category)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,6 +188,10 @@ class OpdsParserTest {
|
|||
val acquisitions = listOf(
|
||||
OpdsAcquisition("txt", "text/plain"),
|
||||
OpdsAcquisition("pdf", "application/pdf"),
|
||||
OpdsAcquisition(
|
||||
"pptx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
),
|
||||
OpdsAcquisition("epub", "application/epub+zip"),
|
||||
OpdsAcquisition("unknown", "application/octet-stream")
|
||||
)
|
||||
|
|
@ -200,9 +204,10 @@ class OpdsParserTest {
|
|||
navigationUrl = null
|
||||
)
|
||||
|
||||
assertEquals("EPUB", acquisitions[2].formatName)
|
||||
assertEquals("EPUB", acquisitions[3].formatName)
|
||||
assertEquals("PPTX", acquisitions[2].formatName)
|
||||
assertEquals("TXT", acquisitions[0].formatName)
|
||||
assertEquals("OCTET-STREAM", acquisitions[3].formatName)
|
||||
assertEquals(acquisitions[2], entry.bestAcquisition)
|
||||
assertEquals("OCTET-STREAM", acquisitions[4].formatName)
|
||||
assertEquals(acquisitions[3], entry.bestAcquisition)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -20,6 +22,14 @@ class CfiUtilsTest {
|
|||
assertEquals(0, CfiUtils.getOffset("/4/2/6:bad"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getOffsetOrNull only returns explicit numeric offsets`() {
|
||||
assertEquals(13, CfiUtils.getOffsetOrNull("/4/2/6:13"))
|
||||
assertEquals(0, CfiUtils.getOffsetOrNull("/4/2/6:0"))
|
||||
assertNull(CfiUtils.getOffsetOrNull("/4/2/6"))
|
||||
assertNull(CfiUtils.getOffsetOrNull("/4/2/6:bad"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compare sorts numeric cfi paths before character offsets`() {
|
||||
assertTrue(CfiUtils.compare("/4/2", "/4/10") < 0)
|
||||
|
|
@ -27,4 +37,12 @@ class CfiUtilsTest {
|
|||
assertTrue(CfiUtils.compare("/4/2/6:7", "/4/2/6:18") < 0)
|
||||
assertEquals(0, CfiUtils.compare("/4/2/6:bad", "/4/2/6"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isPathStrictlyBetween requires a bounded numeric cfi path`() {
|
||||
assertTrue(CfiUtils.isPathStrictlyBetween("/4/4", "/4/2:1", "/4/6:1"))
|
||||
assertFalse(CfiUtils.isPathStrictlyBetween("/4/2", "/4/2:1", "/4/6:1"))
|
||||
assertFalse(CfiUtils.isPathStrictlyBetween("/4/8", "/4/2:1", "/4/6:1"))
|
||||
assertFalse(CfiUtils.isPathStrictlyBetween("/4/nav", "/4/2:1", "/4/6:1"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.junit.Assert.assertEquals
|
||||
|
|
@ -123,6 +126,47 @@ class ContentStylerTest {
|
|||
assertEquals("li1", first.elementId)
|
||||
assertEquals("https://example.org", first.content.getStringAnnotations("URL", 0, 5).single().item)
|
||||
assertEquals("link", first.content.getStringAnnotations("ID", 0, 5).single().item)
|
||||
assertTrue(first.content.spanStyles.any { range ->
|
||||
range.start <= 0 &&
|
||||
range.end >= 5 &&
|
||||
range.item.color.isSpecified &&
|
||||
range.item.color != Color.Red &&
|
||||
range.item.background.isSpecified &&
|
||||
range.item.textDecoration?.contains(TextDecoration.Underline) == true
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runtime theme reapplies visible link style for cached paginated text`() {
|
||||
val linkText = "Cached link"
|
||||
val text = buildAnnotatedString {
|
||||
append(linkText)
|
||||
addStringAnnotation("URL", "https://example.org", 0, linkText.length)
|
||||
}
|
||||
val page = Page(
|
||||
content = listOf(
|
||||
ParagraphBlock(
|
||||
content = text,
|
||||
blockIndex = 1
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val themed = page.applyReaderThemeForDisplay(
|
||||
isDarkTheme = true,
|
||||
themeBackgroundColor = Color(0xFF121212),
|
||||
themeTextColor = Color(0xFFE0E0E0)
|
||||
)
|
||||
val paragraph = themed.content.single() as ParagraphBlock
|
||||
|
||||
assertTrue(paragraph.content.spanStyles.any { range ->
|
||||
range.start == 0 &&
|
||||
range.end == linkText.length &&
|
||||
range.item.color.isSpecified &&
|
||||
range.item.color != Color(0xFFE0E0E0) &&
|
||||
range.item.background.isSpecified &&
|
||||
range.item.textDecoration?.contains(TextDecoration.Underline) == true
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -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