Windows ga (#358)
* Enhance Cloud TTS with navigation controls and shared UI overlay in desktop app * Refactor Cloud TTS voice settings and remove standalone settings overlay on desktop app * Persist reader window state and improve slider interaction in desktop app * Improve PDF page transitions and refine focus management in desktop app * Refactor scrollbar interaction and adjust desktop modal focus handling * Improve PDF sidecar synchronization and cross-platform metadata compatibility * Refactor PDF annotation comment logic to shared module and implement Desktop UI * Refactor reader screen to use tap-to-toggle and full-width styling in desktop app * Refactor reader workspace layout and chrome-panel interactions * Implement global search keyboard shortcuts and focusable chrome layers * Implement flavor-specific legal links and update the About UI * Refactor reader UI controls on desktop app * Enhance desktop folder sync with background metadata extraction and improved error handling * Refactor Library UI and remove redundant Home tab in desktop app * Add custom tooltips to reader icon buttons in desktop app * Integrate app theme controls into reader interfaces on desktop * Add right-to-left pagination support and improve focus restoration on desktop app * Improve EPUB pagination geometry and diagnostic logging for layout cutoffs on desktop app * Update desktop reader defaults and implement settings migration * Implement block-based position tracking in ReaderLocator * Enhance EPUB highlighting reliability in desktop app * Add support for custom reader themes and update highlight palette logic in desktop app * Replace the Tools panel with a "More" dropdown menu and refactor account UI * Implement account profile header in desktop sidebar * Implement cloud sync reliability improvements and sidebar toggle on desktop app * Improve EPUB annotation synchronization and highlight mapping accuracy in desktop app * Integrate WebView2 for EPUB vertical rendering on Windows * Refactor reader layout logic and enhance WebView2 diagnostics * Improve vertical reading layout and WebView2 resizing on Desktop * Refine vertical reading mode layout and margin handling * Enhance reader locator precision and Desktop mode-switching reliability * Implement chapter-level caching and warm-start pagination in desktop app * Replace bundled KCEF with native system webviews via SWT * Refactor EPUB page info bar visibility and layout logic * Improve PDF toolbar persistence and fix tab reactivation logic * Enable multi-selection and bulk operations for custom fonts * Refactor instrumentation tests * Add EPUB UI test fixture and initial instrumentation tests * Expand EpubReader UI tests and improve accessibility * Add instrumentation tests and test tags for library and reader screens * Enhance OPDS parser logic and catalog integration * Add support for toggling local synchronization on a per-folder basis. * Implement tri-state sizing for the TTS overlay * Persist TTS overlay size across sessions * Refactor reader brightness control and add incremental step buttons * Improve CSS support, pagination control, and style-aware semantic caching * Improve link handling, interaction, and diagnostics in the paginated reader * crash fixes * Implement persistent pending removal for external files * Implement book-specific word replacements * Add native vertical reading mode with custom renderer * Implement text selection and navigation improvements for the native vertical reader * Implement locator-based navigation and improved vertical scrolling in native vertical mode in epub * Implement lazy loading and chapter prefetching for native vertical reader * Improve window lifecycle and disposal handling on Desktop * Optimize vertical reading performance in desktop app * Enhance TTS start accuracy and diagnostic logging on desktop * Refactor AI settings visibility on desktop * Improve pagination height measurement and enhance cutoff diagnostics * Implement lifecycle management and improve justified text splitting for pagination * Refine AI usage tracking and force AI feature visibility on Desktop * Add descriptive context comments and usage examples to string and plural resources. * Optimize performance and memory usage in search and state mapping * Replace reader page sliders with minimal slider and navigation controls * Add support for CBT comic archives * Harden file path validation and XML parsing to prevent security vulnerabilities * Implement local account profile caching and optimize desktop performance * Improve desktop persistence reliability and add Linux secure storage support * Improved PDF zoom stability and layout prediction during zoom commits * Improved PDF spread layout prediction, reader focus restoration, and account profile caching * Enhance highlight precision and scoping using block-local offsets and CFIs * Enhance cloud book content synchronization and background downloads * Implement granular timestamp tracking for reading positions and PDF annotations * Restrict diagnostic logging and stack traces to debug builds * Refine PDF page gaps and reader chrome interaction logic * Refactor PDF highlight rendering and overhaul Desktop sidebar UI * Implement a new interaction dock and undo/redo history for PDF annotations in desktop * Enhance PDF color picker and improve navigation scroll restoration * Add highlight palette customization and improve selection menu UI in desktop app epub reader * Enhance desktop shelf management and library organization
This commit is contained in:
parent
5971eaa571
commit
83dcafa4b6
444 changed files with 47279 additions and 8096 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -21,8 +21,7 @@ google-services.json
|
||||||
.gradle/
|
.gradle/
|
||||||
third_party/pdfium/
|
third_party/pdfium/
|
||||||
*.tgz
|
*.tgz
|
||||||
kcef-bundle/
|
|
||||||
kcef-bundle-linux-x64/
|
|
||||||
cache/
|
cache/
|
||||||
worker/
|
worker/
|
||||||
output/
|
output/
|
||||||
|
policies/
|
||||||
40
.idea/androidTestResultsUserPreferences.xml
generated
40
.idea/androidTestResultsUserPreferences.xml
generated
|
|
@ -162,6 +162,19 @@
|
||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
|
<entry key="-908638882">
|
||||||
|
<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">
|
<entry key="-629314366">
|
||||||
<value>
|
<value>
|
||||||
<AndroidTestResultsTableState>
|
<AndroidTestResultsTableState>
|
||||||
|
|
@ -309,6 +322,19 @@
|
||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
|
<entry key="326896270">
|
||||||
|
<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">
|
<entry key="371509721">
|
||||||
<value>
|
<value>
|
||||||
<AndroidTestResultsTableState>
|
<AndroidTestResultsTableState>
|
||||||
|
|
@ -357,6 +383,7 @@
|
||||||
<entry key="Duration" value="90" />
|
<entry key="Duration" value="90" />
|
||||||
<entry key="Pixel_5" value="120" />
|
<entry key="Pixel_5" value="120" />
|
||||||
<entry key="Tests" value="360" />
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
|
|
@ -534,6 +561,19 @@
|
||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
|
<entry key="1500844897">
|
||||||
|
<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="1562612490">
|
<entry key="1562612490">
|
||||||
<value>
|
<value>
|
||||||
<AndroidTestResultsTableState>
|
<AndroidTestResultsTableState>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.kotlin.ksp)
|
alias(libs.plugins.kotlin.ksp)
|
||||||
id("com.diffplug.spotless") version "8.2.1"
|
id("com.diffplug.spotless") version "8.2.1"
|
||||||
alias(libs.plugins.kover)
|
alias(libs.plugins.kover)
|
||||||
|
|
@ -243,8 +243,6 @@ dependencies {
|
||||||
|
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
implementation("androidx.compose.material:material-icons-extended:1.7.8")
|
|
||||||
|
|
||||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
|
|
||||||
//noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break)
|
//noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break)
|
||||||
|
|
|
||||||
BIN
app/src/androidTest/assets/epub/reader_test_book.epub
Normal file
BIN
app/src/androidTest/assets/epub/reader_test_book.epub
Normal file
Binary file not shown.
48
app/src/androidTest/fixtures/epub/README.md
Normal file
48
app/src/androidTest/fixtures/epub/README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# EPUB UI Test Fixture
|
||||||
|
|
||||||
|
`reader_test_book.epub` is a deterministic EPUB fixture for Android instrumentation and UI tests.
|
||||||
|
|
||||||
|
Use it for reader flows that need stable EPUB content:
|
||||||
|
|
||||||
|
- open/import EPUB through a `content://` URI
|
||||||
|
- restore reading position
|
||||||
|
- navigate with chapter anchors
|
||||||
|
- create or verify bookmarks
|
||||||
|
- search unique fixture markers
|
||||||
|
- exercise highlight and CFI-related flows
|
||||||
|
|
||||||
|
The generated EPUB is stored at:
|
||||||
|
|
||||||
|
`app/src/androidTest/assets/epub/reader_test_book.epub`
|
||||||
|
|
||||||
|
The source files live in:
|
||||||
|
|
||||||
|
`app/src/androidTest/fixtures/epub/reader_test_book`
|
||||||
|
|
||||||
|
Regenerate the EPUB after editing the source:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python app/src/androidTest/fixtures/epub/build_reader_test_book.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests should copy the asset from the instrumentation context, not the target app context:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val testContext = InstrumentationRegistry.getInstrumentation().context
|
||||||
|
testContext.assets.open("epub/reader_test_book.epub")
|
||||||
|
```
|
||||||
|
|
||||||
|
Current basic UI coverage using this fixture lives in:
|
||||||
|
|
||||||
|
`app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderScreenTest.kt`
|
||||||
|
|
||||||
|
Stable markers intentionally embedded in the book:
|
||||||
|
|
||||||
|
- `POSITION_TARGET_ALPHA`
|
||||||
|
- `HIGHLIGHT_TARGET_BRAVO`
|
||||||
|
- `CFI_TARGET_CHARLIE`
|
||||||
|
- `SEARCH_TARGET_DELTA`
|
||||||
|
- `BOOKMARK_TARGET_ECHO`
|
||||||
|
- `POSITION_TARGET_FOXTROT`
|
||||||
|
- `ANNOTATION_TARGET_GOLF`
|
||||||
|
- `CFI_TARGET_HOTEL`
|
||||||
39
app/src/androidTest/fixtures/epub/build_reader_test_book.py
Normal file
39
app/src/androidTest/fixtures/epub/build_reader_test_book.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile, ZipInfo
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
SOURCE_DIR = ROOT / "reader_test_book"
|
||||||
|
OUTPUT = ROOT.parents[1] / "assets" / "epub" / "reader_test_book.epub"
|
||||||
|
FIXED_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def add_file(epub: ZipFile, source: Path, archive_name: str, compression: int) -> None:
|
||||||
|
info = ZipInfo(archive_name, FIXED_TIMESTAMP)
|
||||||
|
info.compress_type = compression
|
||||||
|
info.external_attr = 0o644 << 16
|
||||||
|
epub.writestr(info, source.read_bytes())
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with ZipFile(OUTPUT, "w") as epub:
|
||||||
|
info = ZipInfo("mimetype", FIXED_TIMESTAMP)
|
||||||
|
info.compress_type = ZIP_STORED
|
||||||
|
info.external_attr = 0o644 << 16
|
||||||
|
epub.writestr(info, b"application/epub+zip")
|
||||||
|
|
||||||
|
for source in sorted(SOURCE_DIR.rglob("*")):
|
||||||
|
if not source.is_file() or source.name == "mimetype":
|
||||||
|
continue
|
||||||
|
archive_name = source.relative_to(SOURCE_DIR).as_posix()
|
||||||
|
add_file(epub, source, archive_name, ZIP_DEFLATED)
|
||||||
|
|
||||||
|
print(f"Wrote {OUTPUT}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||||
|
<rootfiles>
|
||||||
|
<rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/>
|
||||||
|
</rootfiles>
|
||||||
|
</container>
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Chapter One</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="../styles/reader-test.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section id="chapter-one">
|
||||||
|
<h1 id="chapter-one-heading">Chapter One: Stable Opening</h1>
|
||||||
|
<p id="opening-paragraph">This EPUB is intentionally plain so Android UI tests can rely on stable text, stable IDs, and stable chapter order.</p>
|
||||||
|
<p id="position-target-alpha"><span class="target">POSITION_TARGET_ALPHA</span> appears near the start of chapter one. Use this marker for first-position and restore-position checks.</p>
|
||||||
|
<p id="highlight-target-bravo"><span class="target">HIGHLIGHT_TARGET_BRAVO</span> is a short highlight target. It is surrounded by ordinary words so selection handles have context.</p>
|
||||||
|
<p id="cfi-target-charlie"><span class="target">CFI_TARGET_CHARLIE</span> sits inside a paragraph with a fixed element id. It can be used for CFI and locator assertions.</p>
|
||||||
|
<p id="chapter-one-filler-01">Chapter one filler paragraph 01 keeps the document tall enough for scroll and pagination tests.</p>
|
||||||
|
<p id="chapter-one-filler-02">Chapter one filler paragraph 02 keeps the document tall enough for scroll and pagination tests.</p>
|
||||||
|
<p id="chapter-one-filler-03">Chapter one filler paragraph 03 keeps the document tall enough for scroll and pagination tests.</p>
|
||||||
|
<p id="chapter-one-filler-04">Chapter one filler paragraph 04 keeps the document tall enough for scroll and pagination tests.</p>
|
||||||
|
<p id="chapter-one-filler-05">Chapter one filler paragraph 05 keeps the document tall enough for scroll and pagination tests.</p>
|
||||||
|
<p id="chapter-one-filler-06">Chapter one filler paragraph 06 keeps the document tall enough for scroll and pagination tests.</p>
|
||||||
|
<p id="chapter-one-filler-07">Chapter one filler paragraph 07 keeps the document tall enough for scroll and pagination tests.</p>
|
||||||
|
<p id="chapter-one-filler-08">Chapter one filler paragraph 08 keeps the document tall enough for scroll and pagination tests.</p>
|
||||||
|
<p id="chapter-one-end">END_OF_CHAPTER_ONE_MARKER</p>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Chapter Two</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="../styles/reader-test.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section id="chapter-two">
|
||||||
|
<h1 id="chapter-two-heading">Chapter Two: Search And Bookmarks</h1>
|
||||||
|
<p id="search-target-delta"><span class="target">SEARCH_TARGET_DELTA</span> appears exactly once in the book. Use it for search result navigation.</p>
|
||||||
|
<p id="bookmark-target-echo"><span class="target">BOOKMARK_TARGET_ECHO</span> is positioned near the top of chapter two for bookmark add, list, and return flows.</p>
|
||||||
|
<p id="position-target-foxtrot"><span class="target">POSITION_TARGET_FOXTROT</span> is lower in chapter two and is useful for persistence tests after a chapter change.</p>
|
||||||
|
<p id="chapter-two-filler-01">Chapter two filler paragraph 01 provides enough height for gesture based navigation.</p>
|
||||||
|
<p id="chapter-two-filler-02">Chapter two filler paragraph 02 provides enough height for gesture based navigation.</p>
|
||||||
|
<p id="chapter-two-filler-03">Chapter two filler paragraph 03 provides enough height for gesture based navigation.</p>
|
||||||
|
<p id="chapter-two-filler-04">Chapter two filler paragraph 04 provides enough height for gesture based navigation.</p>
|
||||||
|
<p id="chapter-two-filler-05">Chapter two filler paragraph 05 provides enough height for gesture based navigation.</p>
|
||||||
|
<p id="chapter-two-filler-06">Chapter two filler paragraph 06 provides enough height for gesture based navigation.</p>
|
||||||
|
<p id="chapter-two-filler-07">Chapter two filler paragraph 07 provides enough height for gesture based navigation.</p>
|
||||||
|
<p id="chapter-two-filler-08">Chapter two filler paragraph 08 provides enough height for gesture based navigation.</p>
|
||||||
|
<p id="chapter-two-end">END_OF_CHAPTER_TWO_MARKER</p>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Chapter Three</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="../styles/reader-test.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section id="chapter-three">
|
||||||
|
<h1 id="chapter-three-heading">Chapter Three: Annotation Targets</h1>
|
||||||
|
<p id="annotation-target-golf"><span class="target">ANNOTATION_TARGET_GOLF</span> is reserved for tests that verify highlight or note persistence across app restarts.</p>
|
||||||
|
<p id="cfi-target-hotel"><span class="target">CFI_TARGET_HOTEL</span> is a second fixed CFI target in a later chapter.</p>
|
||||||
|
<p id="inline-image-note" class="fixture-note">The following SVG is local to the EPUB and can be used later for image rendering checks.</p>
|
||||||
|
<figure id="fixture-diagram">
|
||||||
|
<img src="../images/fixture-diagram.svg" alt="Fixture diagram"/>
|
||||||
|
<figcaption>Local SVG image for EPUB resource resolution.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<p id="chapter-three-filler-01">Chapter three filler paragraph 01 rounds out the fixture.</p>
|
||||||
|
<p id="chapter-three-filler-02">Chapter three filler paragraph 02 rounds out the fixture.</p>
|
||||||
|
<p id="chapter-three-filler-03">Chapter three filler paragraph 03 rounds out the fixture.</p>
|
||||||
|
<p id="chapter-three-end">END_OF_CHAPTER_THREE_MARKER</p>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="120" viewBox="0 0 320 120" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Fixture diagram</title>
|
||||||
|
<desc id="desc">A simple local SVG used by EPUB UI tests.</desc>
|
||||||
|
<rect x="1" y="1" width="318" height="118" fill="#f6f8fb" stroke="#4a90e2" stroke-width="2"/>
|
||||||
|
<circle cx="70" cy="60" r="28" fill="#4a90e2"/>
|
||||||
|
<rect x="130" y="34" width="130" height="52" fill="#2f855a"/>
|
||||||
|
<text x="160" y="66" font-size="16" fill="#ffffff">EPUB FIXTURE</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 568 B |
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en" xml:lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Reader Android UI Test Book</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="styles/reader-test.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav epub:type="toc" id="toc">
|
||||||
|
<h1>Contents</h1>
|
||||||
|
<ol>
|
||||||
|
<li><a href="chapters/chapter-01.xhtml#chapter-one-heading">Chapter One</a></li>
|
||||||
|
<li><a href="chapters/chapter-02.xhtml#chapter-two-heading">Chapter Two</a></li>
|
||||||
|
<li><a href="chapters/chapter-03.xhtml#chapter-three-heading">Chapter Three</a></li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<nav epub:type="landmarks" id="landmarks">
|
||||||
|
<h2>Landmarks</h2>
|
||||||
|
<ol>
|
||||||
|
<li><a epub:type="bodymatter" href="chapters/chapter-01.xhtml">Start</a></li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id" xml:lang="en">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:identifier id="book-id">urn:uuid:reader-android-ui-test-epub</dc:identifier>
|
||||||
|
<dc:title>Reader Android UI Test Book</dc:title>
|
||||||
|
<dc:creator>Reader Test Fixtures</dc:creator>
|
||||||
|
<dc:language>en</dc:language>
|
||||||
|
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
||||||
|
<item id="style" href="styles/reader-test.css" media-type="text/css"/>
|
||||||
|
<item id="chapter-1" href="chapters/chapter-01.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="chapter-2" href="chapters/chapter-02.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="chapter-3" href="chapters/chapter-03.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="inline-svg" href="images/fixture-diagram.svg" media-type="image/svg+xml"/>
|
||||||
|
</manifest>
|
||||||
|
<spine>
|
||||||
|
<itemref idref="chapter-1"/>
|
||||||
|
<itemref idref="chapter-2"/>
|
||||||
|
<itemref idref="chapter-3"/>
|
||||||
|
</spine>
|
||||||
|
</package>
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
body {
|
||||||
|
font-family: serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixture-note {
|
||||||
|
border-left: 0.25em solid #4a90e2;
|
||||||
|
padding-left: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
application/epub+zip
|
||||||
|
|
@ -1,146 +1,117 @@
|
||||||
// AppNavigationTest.kt
|
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
|
||||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
|
||||||
import androidx.compose.ui.unit.DpSize
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.navigation.compose.ComposeNavigator
|
|
||||||
import androidx.navigation.testing.TestNavHostController
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.aryan.reader.epub.EpubBook
|
import com.aryan.reader.shared.FileType
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import com.aryan.reader.shared.ReaderFeatureSurface
|
||||||
import org.junit.Assert.assertEquals
|
import com.aryan.reader.shared.ReaderPlatform
|
||||||
import org.junit.Before
|
import com.aryan.reader.shared.SharedFileCapabilities
|
||||||
import org.junit.Rule
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class AppNavigationTest {
|
class AppNavigationTest {
|
||||||
|
|
||||||
@get:Rule
|
@Test
|
||||||
val composeTestRule = createComposeRule()
|
fun appDestinations_useStableReaderRoutes() {
|
||||||
|
assertThat(AppDestinations.MAIN_ROUTE).isEqualTo("main")
|
||||||
|
assertThat(AppDestinations.PDF_VIEWER_ROUTE).isEqualTo("pdf_viewer")
|
||||||
|
assertThat(AppDestinations.EPUB_READER_ROUTE).isEqualTo("epub_reader")
|
||||||
|
}
|
||||||
|
|
||||||
private lateinit var navController: TestNavHostController
|
@Test
|
||||||
private val fakeUiState = MutableStateFlow(ReaderScreenState())
|
fun androidReaderSurface_mapsPdfBackedTypesToPdfViewer() {
|
||||||
|
val mappedSurfaces = listOf(
|
||||||
|
FileType.PDF,
|
||||||
|
FileType.CBZ,
|
||||||
|
FileType.CBR,
|
||||||
|
FileType.CB7,
|
||||||
|
FileType.CBT,
|
||||||
|
FileType.PPTX
|
||||||
|
).associateWith { it.readerSurfaceOnAndroid() }
|
||||||
|
|
||||||
// Mock ViewModel that uses the fake state
|
assertThat(mappedSurfaces).containsExactly(
|
||||||
private val fakeViewModel: MainViewModel = object : MainViewModel(
|
FileType.PDF, ReaderFeatureSurface.PDF_VIEWER,
|
||||||
ApplicationProvider.getApplicationContext()
|
FileType.CBZ, ReaderFeatureSurface.PDF_VIEWER,
|
||||||
) {
|
FileType.CBR, ReaderFeatureSurface.PDF_VIEWER,
|
||||||
override val uiState = fakeUiState
|
FileType.CB7, ReaderFeatureSurface.PDF_VIEWER,
|
||||||
override fun clearSelectedFile() {
|
FileType.CBT, ReaderFeatureSurface.PDF_VIEWER,
|
||||||
fakeUiState.value = fakeUiState.value.copy(
|
FileType.PPTX, ReaderFeatureSurface.PDF_VIEWER
|
||||||
selectedFileType = null,
|
)
|
||||||
selectedPdfUri = null,
|
}
|
||||||
selectedEpubBook = null
|
|
||||||
|
@Test
|
||||||
|
fun androidReaderSurface_mapsTextBackedTypesToEpubReader() {
|
||||||
|
val mappedSurfaces = listOf(
|
||||||
|
FileType.EPUB,
|
||||||
|
FileType.MOBI,
|
||||||
|
FileType.MD,
|
||||||
|
FileType.TXT,
|
||||||
|
FileType.HTML,
|
||||||
|
FileType.FB2,
|
||||||
|
FileType.DOCX,
|
||||||
|
FileType.ODT,
|
||||||
|
FileType.FODT
|
||||||
|
).associateWith { it.readerSurfaceOnAndroid() }
|
||||||
|
|
||||||
|
assertThat(mappedSurfaces).containsExactly(
|
||||||
|
FileType.EPUB, ReaderFeatureSurface.EPUB_READER,
|
||||||
|
FileType.MOBI, ReaderFeatureSurface.EPUB_READER,
|
||||||
|
FileType.MD, ReaderFeatureSurface.EPUB_READER,
|
||||||
|
FileType.TXT, ReaderFeatureSurface.EPUB_READER,
|
||||||
|
FileType.HTML, ReaderFeatureSurface.EPUB_READER,
|
||||||
|
FileType.FB2, ReaderFeatureSurface.EPUB_READER,
|
||||||
|
FileType.DOCX, ReaderFeatureSurface.EPUB_READER,
|
||||||
|
FileType.ODT, ReaderFeatureSurface.EPUB_READER,
|
||||||
|
FileType.FODT, ReaderFeatureSurface.EPUB_READER
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun androidReaderSurface_returnsNullForUnknownFileType() {
|
||||||
|
assertThat(FileType.UNKNOWN.readerSurfaceOnAndroid()).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appNavBackInterceptor_onlyHandlesResumedNonReaderBackStackEntries() {
|
||||||
|
assertThat(
|
||||||
|
shouldInterceptAppNavBack(
|
||||||
|
currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
|
||||||
|
hasPreviousBackStackEntry = true,
|
||||||
|
isCurrentEntryResumed = true
|
||||||
)
|
)
|
||||||
}
|
).isTrue()
|
||||||
}
|
assertThat(
|
||||||
|
shouldInterceptAppNavBack(
|
||||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
currentRoute = AppDestinations.MAIN_ROUTE,
|
||||||
@Before
|
hasPreviousBackStackEntry = true,
|
||||||
fun setup() {
|
isCurrentEntryResumed = true
|
||||||
composeTestRule.setContent {
|
|
||||||
navController = TestNavHostController(LocalContext.current)
|
|
||||||
navController.navigatorProvider.addNavigator(ComposeNavigator())
|
|
||||||
AppNavigation(
|
|
||||||
navController = navController,
|
|
||||||
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(400.dp, 800.dp)),
|
|
||||||
viewModel = fakeViewModel
|
|
||||||
)
|
)
|
||||||
}
|
).isFalse()
|
||||||
}
|
assertThat(
|
||||||
|
shouldInterceptAppNavBack(
|
||||||
@Test
|
currentRoute = AppDestinations.PDF_VIEWER_ROUTE,
|
||||||
fun appNavigation_defaultStartDestination_isMainRoute() {
|
hasPreviousBackStackEntry = true,
|
||||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
isCurrentEntryResumed = true
|
||||||
assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun appNavigation_whenPdfSelected_navigatesToPdfViewer() {
|
|
||||||
// Trigger state change
|
|
||||||
fakeUiState.value = ReaderScreenState(
|
|
||||||
selectedFileType = FileType.PDF,
|
|
||||||
selectedPdfUri = Uri.parse("content://dummy.pdf")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Let compose recompose and run LaunchedEffect
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
|
|
||||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
|
||||||
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun appNavigation_whenPptxSelected_navigatesToPdfViewer() {
|
|
||||||
fakeUiState.value = ReaderScreenState(
|
|
||||||
selectedFileType = FileType.PPTX,
|
|
||||||
selectedPdfUri = Uri.parse("content://dummy.pptx")
|
|
||||||
)
|
|
||||||
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
|
|
||||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
|
||||||
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun appNavigation_whenEpubSelected_navigatesToEpubReader() {
|
|
||||||
// Trigger state change
|
|
||||||
fakeUiState.value = ReaderScreenState(
|
|
||||||
selectedFileType = FileType.EPUB,
|
|
||||||
selectedEpubBook = EpubBook(
|
|
||||||
fileName = "dummy.epub",
|
|
||||||
title = "Dummy Book",
|
|
||||||
author = "Author",
|
|
||||||
language = "en",
|
|
||||||
coverImage = null
|
|
||||||
)
|
)
|
||||||
)
|
).isFalse()
|
||||||
|
assertThat(
|
||||||
composeTestRule.waitForIdle()
|
shouldInterceptAppNavBack(
|
||||||
|
currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
|
||||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
hasPreviousBackStackEntry = false,
|
||||||
assertEquals(AppDestinations.EPUB_READER_ROUTE, currentRoute)
|
isCurrentEntryResumed = true
|
||||||
|
)
|
||||||
|
).isFalse()
|
||||||
|
assertThat(
|
||||||
|
shouldInterceptAppNavBack(
|
||||||
|
currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
|
||||||
|
hasPreviousBackStackEntry = true,
|
||||||
|
isCurrentEntryResumed = false
|
||||||
|
)
|
||||||
|
).isFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
private fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? {
|
||||||
fun appNavigation_whenFileCleared_navigatesBackToMain() {
|
return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID)
|
||||||
// First, navigate to PDF viewer
|
|
||||||
fakeUiState.value = ReaderScreenState(
|
|
||||||
selectedFileType = FileType.PDF,
|
|
||||||
selectedPdfUri = Uri.parse("content://dummy.pdf")
|
|
||||||
)
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route)
|
|
||||||
|
|
||||||
// Then, trigger the clear action (simulating onNavigateBack)
|
|
||||||
fakeViewModel.clearSelectedFile()
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
|
|
||||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
|
||||||
assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun appNavigation_whenUnknownFileTypeSelected_navigatesBackToMain() {
|
|
||||||
fakeUiState.value = ReaderScreenState(
|
|
||||||
selectedFileType = FileType.PDF,
|
|
||||||
selectedPdfUri = Uri.parse("content://dummy.pdf")
|
|
||||||
)
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route)
|
|
||||||
|
|
||||||
fakeUiState.value = ReaderScreenState(selectedFileType = FileType.UNKNOWN)
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
|
|
||||||
assertEquals(AppDestinations.MAIN_ROUTE, navController.currentBackStackEntry?.destination?.route)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performTouchInput
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class HomeRecentFileCardTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recentFileCardShowsProgressAndUnavailableState() {
|
||||||
|
val item = recentBook(
|
||||||
|
bookId = "home_unavailable_epub",
|
||||||
|
title = "Unavailable Field Guide",
|
||||||
|
author = "Casey Example",
|
||||||
|
progress = 42f,
|
||||||
|
isAvailable = false
|
||||||
|
)
|
||||||
|
|
||||||
|
setRecentFileCard(item = item)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("HomeRecentFileCard_home_unavailable_epub").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("Unavailable Field Guide").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("Casey Example").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("42%").assertIsDisplayed()
|
||||||
|
composeTestRule.onAllNodesWithContentDescription(text(R.string.not_available_locally))[0]
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recentFileCardClickLongClickAndSelectedOverlayWork() {
|
||||||
|
val item = recentBook(
|
||||||
|
bookId = "home_selected_pdf",
|
||||||
|
title = "Selected Position Notes",
|
||||||
|
author = "Morgan Example",
|
||||||
|
progress = 7f
|
||||||
|
)
|
||||||
|
var clicked = false
|
||||||
|
var longClicked = false
|
||||||
|
|
||||||
|
setRecentFileCard(
|
||||||
|
item = item,
|
||||||
|
isSelected = true,
|
||||||
|
onClick = { clicked = true },
|
||||||
|
onLongClick = { longClicked = true }
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_selected))[0]
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performClick()
|
||||||
|
composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performTouchInput {
|
||||||
|
down(center)
|
||||||
|
advanceEventTime(600)
|
||||||
|
up()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(clicked).isTrue()
|
||||||
|
assertThat(longClicked).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setRecentFileCard(
|
||||||
|
item: RecentFileItem,
|
||||||
|
isSelected: Boolean = false,
|
||||||
|
isPinned: Boolean = false,
|
||||||
|
onClick: () -> Unit = {},
|
||||||
|
onLongClick: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
RecentFileCard(
|
||||||
|
item = item,
|
||||||
|
isSelected = isSelected,
|
||||||
|
isPinned = isPinned,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
isDownloading = false,
|
||||||
|
usePdfFileNameAsDisplayName = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recentBook(
|
||||||
|
bookId: String,
|
||||||
|
title: String,
|
||||||
|
author: String,
|
||||||
|
progress: Float,
|
||||||
|
isAvailable: Boolean = true
|
||||||
|
): RecentFileItem {
|
||||||
|
return RecentFileItem(
|
||||||
|
bookId = bookId,
|
||||||
|
uriString = "content://home-test/$bookId",
|
||||||
|
type = FileType.EPUB,
|
||||||
|
displayName = "$bookId.epub",
|
||||||
|
timestamp = 1_000L,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
progressPercentage = progress,
|
||||||
|
isRecent = true,
|
||||||
|
isAvailable = isAvailable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun text(resId: Int): String {
|
||||||
|
return context.getString(resId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performTextInput
|
||||||
|
import androidx.compose.ui.test.performTouchInput
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import com.aryan.reader.data.TagEntity
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class LibraryScreenContentTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
private val focusTag = TagEntity(id = "tag_focus", name = "Focus", color = null, createdAt = 1L)
|
||||||
|
private val libraryBooks = listOf(
|
||||||
|
libraryBook(
|
||||||
|
bookId = "pdf_beta",
|
||||||
|
type = FileType.PDF,
|
||||||
|
displayName = "beta.pdf",
|
||||||
|
title = "Beta Manual",
|
||||||
|
author = "Mira Example",
|
||||||
|
timestamp = 3_000L,
|
||||||
|
progress = 84f
|
||||||
|
),
|
||||||
|
libraryBook(
|
||||||
|
bookId = "epub_gamma",
|
||||||
|
type = FileType.EPUB,
|
||||||
|
displayName = "gamma.epub",
|
||||||
|
title = "Gamma Field Notes",
|
||||||
|
author = "Nora Example",
|
||||||
|
timestamp = 2_000L,
|
||||||
|
progress = 47f,
|
||||||
|
tags = listOf(focusTag)
|
||||||
|
),
|
||||||
|
libraryBook(
|
||||||
|
bookId = "epub_alpha",
|
||||||
|
type = FileType.EPUB,
|
||||||
|
displayName = "alpha.epub",
|
||||||
|
title = "Alpha Orchard",
|
||||||
|
author = "Zara Example",
|
||||||
|
timestamp = 1_000L,
|
||||||
|
progress = 12f,
|
||||||
|
tags = listOf(focusTag)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchFiltersAndClearRestoresLibraryList() {
|
||||||
|
setLibraryContent()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Beta Manual").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick()
|
||||||
|
composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Gamma")
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
assertNoText("Alpha Orchard")
|
||||||
|
assertNoText("Beta Manual")
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick()
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() &&
|
||||||
|
composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_search)).performClick()
|
||||||
|
composeTestRule.onNodeWithText(text(R.string.library_title)).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchMatchesAuthorAndTagNames() {
|
||||||
|
setLibraryContent()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick()
|
||||||
|
composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Zara")
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
assertNoText("Gamma Field Notes")
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick()
|
||||||
|
composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Focus")
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() &&
|
||||||
|
composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
assertNoText("Beta Manual")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun activeFileTypeFilterChipCanBeCleared() {
|
||||||
|
setLibraryContent(initialFilters = LibraryFilters(fileTypes = setOf(FileType.EPUB)))
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed()
|
||||||
|
assertNoText("Beta Manual")
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(text(R.string.filter_types, FileType.EPUB.name)).performClick()
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tagAndReadStatusFiltersUseSharedLibraryRules() {
|
||||||
|
setLibraryContent(
|
||||||
|
initialFilters = LibraryFilters(
|
||||||
|
tagIds = setOf(focusTag.id),
|
||||||
|
readStatus = ReadStatusFilter.IN_PROGRESS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed()
|
||||||
|
assertNoText("Beta Manual")
|
||||||
|
composeTestRule.onNodeWithText(text(R.string.filter_tags, focusTag.name)).assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText(
|
||||||
|
text(R.string.filter_status, text(ReadStatusFilter.IN_PROGRESS.labelRes))
|
||||||
|
).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearSelectionReturnsToNormalToolbar() {
|
||||||
|
setLibraryContent()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput {
|
||||||
|
down(center)
|
||||||
|
advanceEventTime(600)
|
||||||
|
up()
|
||||||
|
}
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithContentDescription(text(R.string.clear_selection)).performClick()
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodesWithText(text(R.string.library_title)).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sortMenuSelectionReordersLibraryItems() {
|
||||||
|
setLibraryContent()
|
||||||
|
|
||||||
|
assertBookAbove("pdf_beta", "epub_alpha")
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("LibrarySortButton").performClick()
|
||||||
|
composeTestRule.onNodeWithText(text(SortOrder.TITLE_ASC.labelRes)).performClick()
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
runCatching {
|
||||||
|
bookTop("epub_alpha") < bookTop("pdf_beta")
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
assertBookAbove("epub_alpha", "pdf_beta")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun longPressBookShowsContextualToolbarActions() {
|
||||||
|
var tagClicked = false
|
||||||
|
var pinClicked = false
|
||||||
|
var infoClicked = false
|
||||||
|
var selectAllClicked = false
|
||||||
|
var deleteClicked = false
|
||||||
|
|
||||||
|
setLibraryContent(
|
||||||
|
onTagClick = { tagClicked = true },
|
||||||
|
onPinClick = { pinClicked = true },
|
||||||
|
onInfoClick = { infoClicked = true },
|
||||||
|
onSelectAllClick = { selectAllClicked = true },
|
||||||
|
onDeleteClick = { deleteClicked = true }
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput {
|
||||||
|
down(center)
|
||||||
|
advanceEventTime(600)
|
||||||
|
up()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_tag)).performClick()
|
||||||
|
composeTestRule.onNodeWithContentDescription(text(R.string.pin_unpin)).performClick()
|
||||||
|
composeTestRule.onNodeWithContentDescription(text(R.string.info)).performClick()
|
||||||
|
composeTestRule.onNodeWithContentDescription(text(R.string.select_all)).performClick()
|
||||||
|
composeTestRule.onNodeWithContentDescription(text(R.string.action_delete)).performClick()
|
||||||
|
|
||||||
|
assertThat(tagClicked).isTrue()
|
||||||
|
assertThat(pinClicked).isTrue()
|
||||||
|
assertThat(infoClicked).isTrue()
|
||||||
|
assertThat(selectAllClicked).isTrue()
|
||||||
|
assertThat(deleteClicked).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shelvesTabShowsShelfRowsAndNewShelfAction() {
|
||||||
|
val shelf = Shelf(
|
||||||
|
id = "manual_favorites",
|
||||||
|
name = "Manual Favorites",
|
||||||
|
type = ShelfType.MANUAL,
|
||||||
|
books = listOf(libraryBooks[0], libraryBooks[1])
|
||||||
|
)
|
||||||
|
var clickedShelfId: String? = null
|
||||||
|
var longClickedShelfId: String? = null
|
||||||
|
var newShelfClicked = false
|
||||||
|
|
||||||
|
setLibraryContent(
|
||||||
|
initialPage = 1,
|
||||||
|
shelves = listOf(shelf),
|
||||||
|
onShelfClick = { clickedShelfId = it.id },
|
||||||
|
onShelfLongClick = { longClickedShelfId = it.id },
|
||||||
|
onNewShelfClick = { newShelfClicked = true }
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("Manual Favorites").assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performClick()
|
||||||
|
composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performTouchInput {
|
||||||
|
down(center)
|
||||||
|
advanceEventTime(600)
|
||||||
|
up()
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithTag("LibraryNewShelfFab").performClick()
|
||||||
|
|
||||||
|
assertThat(clickedShelfId).isEqualTo("manual_favorites")
|
||||||
|
assertThat(longClickedShelfId).isEqualTo("manual_favorites")
|
||||||
|
assertThat(newShelfClicked).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setLibraryContent(
|
||||||
|
initialFilters: LibraryFilters = LibraryFilters(),
|
||||||
|
initialPage: Int = 0,
|
||||||
|
shelves: List<Shelf> = emptyList(),
|
||||||
|
onTagClick: () -> Unit = {},
|
||||||
|
onPinClick: () -> Unit = {},
|
||||||
|
onInfoClick: () -> Unit = {},
|
||||||
|
onSelectAllClick: () -> Unit = {},
|
||||||
|
onDeleteClick: () -> Unit = {},
|
||||||
|
onShelfClick: (Shelf) -> Unit = {},
|
||||||
|
onShelfLongClick: (Shelf) -> Unit = {},
|
||||||
|
onNewShelfClick: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
val searchQuery = mutableStateOf("")
|
||||||
|
val isSearchActive = mutableStateOf(false)
|
||||||
|
val filters = mutableStateOf(initialFilters)
|
||||||
|
val sortOrder = mutableStateOf(SortOrder.RECENT)
|
||||||
|
val selectedItems = mutableStateOf(emptySet<RecentFileItem>())
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val pagerState = rememberPagerState(
|
||||||
|
initialPage = initialPage,
|
||||||
|
pageCount = { 3 }
|
||||||
|
)
|
||||||
|
val visibleBooks = sortFiles(
|
||||||
|
applyLibraryFilters(
|
||||||
|
filterBySearch(libraryBooks, searchQuery.value),
|
||||||
|
filters.value
|
||||||
|
),
|
||||||
|
sortOrder.value
|
||||||
|
)
|
||||||
|
|
||||||
|
MaterialTheme {
|
||||||
|
LibraryScreenContent(
|
||||||
|
tabTitles = listOf(
|
||||||
|
text(R.string.tab_all_books),
|
||||||
|
text(R.string.tab_shelves),
|
||||||
|
text(R.string.tab_folders)
|
||||||
|
),
|
||||||
|
recentFiles = visibleBooks,
|
||||||
|
rawLibraryFiles = libraryBooks,
|
||||||
|
shelves = shelves,
|
||||||
|
selectedItems = selectedItems.value,
|
||||||
|
selectedShelves = emptySet(),
|
||||||
|
sortOrder = sortOrder.value,
|
||||||
|
libraryFilters = filters.value,
|
||||||
|
allTags = listOf(focusTag),
|
||||||
|
pinnedLibraryBookIds = emptySet(),
|
||||||
|
pagerState = pagerState,
|
||||||
|
scope = rememberCoroutineScope(),
|
||||||
|
searchQuery = searchQuery.value,
|
||||||
|
isSearchActive = isSearchActive.value,
|
||||||
|
onSearchQueryChange = { searchQuery.value = it },
|
||||||
|
onSearchActiveChange = { isSearchActive.value = it },
|
||||||
|
onSortOrderChange = { sortOrder.value = it },
|
||||||
|
onFilterClick = {},
|
||||||
|
onClearFilters = { filters.value = LibraryFilters() },
|
||||||
|
onRemoveFilter = { filters.value = it },
|
||||||
|
onTagClick = onTagClick,
|
||||||
|
onPinClick = onPinClick,
|
||||||
|
onClearSelection = { selectedItems.value = emptySet() },
|
||||||
|
onItemClick = {},
|
||||||
|
onItemLongClick = { item -> selectedItems.value = setOf(item) },
|
||||||
|
onInfoClick = onInfoClick,
|
||||||
|
onDeleteClick = onDeleteClick,
|
||||||
|
onSelectAllClick = onSelectAllClick,
|
||||||
|
onShelfClick = onShelfClick,
|
||||||
|
onShelfLongClick = onShelfLongClick,
|
||||||
|
onClearShelfSelection = {},
|
||||||
|
onDeleteShelves = {},
|
||||||
|
onNewShelfClick = onNewShelfClick,
|
||||||
|
onSelectFileClick = {},
|
||||||
|
onScanNowClick = {},
|
||||||
|
onSyncMetadataClick = {},
|
||||||
|
onSelectSyncFolderClick = {},
|
||||||
|
onEditFolderFiltersClick = { _, _ -> },
|
||||||
|
onDisconnectSyncFolderClick = {},
|
||||||
|
downloadingBookIds = emptySet(),
|
||||||
|
lastFolderScanTime = null,
|
||||||
|
isLoading = false,
|
||||||
|
isRefreshing = false,
|
||||||
|
syncedFolders = emptyList(),
|
||||||
|
onRemoveFolderClick = {},
|
||||||
|
onFolderLocalSyncChange = { _, _, _ -> },
|
||||||
|
onOpdsBookDownloaded = { _, _ -> },
|
||||||
|
onStreamOpdsBook = { _, _ -> },
|
||||||
|
onDeleteCatalogStreams = {},
|
||||||
|
onSettingsClick = {},
|
||||||
|
usePdfFileNameAsDisplayName = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun libraryBook(
|
||||||
|
bookId: String,
|
||||||
|
type: FileType,
|
||||||
|
displayName: String,
|
||||||
|
title: String,
|
||||||
|
author: String,
|
||||||
|
timestamp: Long,
|
||||||
|
progress: Float,
|
||||||
|
tags: List<TagEntity> = emptyList()
|
||||||
|
): RecentFileItem {
|
||||||
|
return RecentFileItem(
|
||||||
|
bookId = bookId,
|
||||||
|
uriString = "content://library-test/$bookId",
|
||||||
|
type = type,
|
||||||
|
displayName = displayName,
|
||||||
|
timestamp = timestamp,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
progressPercentage = progress,
|
||||||
|
isRecent = true,
|
||||||
|
isAvailable = true,
|
||||||
|
fileSize = timestamp * 10,
|
||||||
|
tags = tags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertBookAbove(upperBookId: String, lowerBookId: String) {
|
||||||
|
assertThat(bookTop(upperBookId)).isLessThan(bookTop(lowerBookId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertNoText(value: String) {
|
||||||
|
assertThat(composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bookTop(bookId: String): Float {
|
||||||
|
return composeTestRule
|
||||||
|
.onNodeWithTag("LibraryBookItem_$bookId")
|
||||||
|
.fetchSemanticsNode()
|
||||||
|
.boundsInRoot
|
||||||
|
.top
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun text(resId: Int, vararg args: Any): String {
|
||||||
|
return context.getString(resId, *args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
package com.aryan.reader.epubreader
|
package com.aryan.reader.epubreader
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class ChapterWebViewBridgeTest {
|
class ChapterWebViewBridgeTest {
|
||||||
|
|
@ -20,7 +26,8 @@ class ChapterWebViewBridgeTest {
|
||||||
var receivedCfi = ""
|
var receivedCfi = ""
|
||||||
val bridge = CfiJsBridge(
|
val bridge = CfiJsBridge(
|
||||||
onCfiReady = { cfi -> receivedCfi = cfi },
|
onCfiReady = { cfi -> receivedCfi = cfi },
|
||||||
onCfiForBookmarkReady = {}
|
onCfiForBookmarkReady = {},
|
||||||
|
onScrollFinishedCallback = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
val cfi = "/4/2[chapter1]/6:10"
|
val cfi = "/4/2[chapter1]/6:10"
|
||||||
|
|
@ -39,7 +46,8 @@ class ChapterWebViewBridgeTest {
|
||||||
var receivedCfi = ""
|
var receivedCfi = ""
|
||||||
val bridge = CfiJsBridge(
|
val bridge = CfiJsBridge(
|
||||||
onCfiReady = { cfi -> receivedCfi = cfi },
|
onCfiReady = { cfi -> receivedCfi = cfi },
|
||||||
onCfiForBookmarkReady = {}
|
onCfiForBookmarkReady = {},
|
||||||
|
onScrollFinishedCallback = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
val invalidJson = "this is not json"
|
val invalidJson = "this is not json"
|
||||||
|
|
@ -54,7 +62,8 @@ class ChapterWebViewBridgeTest {
|
||||||
var receivedCfi: String? = null
|
var receivedCfi: String? = null
|
||||||
val bridge = CfiJsBridge(
|
val bridge = CfiJsBridge(
|
||||||
onCfiReady = { cfi -> receivedCfi = cfi },
|
onCfiReady = { cfi -> receivedCfi = cfi },
|
||||||
onCfiForBookmarkReady = {}
|
onCfiForBookmarkReady = {},
|
||||||
|
onScrollFinishedCallback = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
val jsonResponse = JSONObject().apply {
|
val jsonResponse = JSONObject().apply {
|
||||||
|
|
@ -69,29 +78,51 @@ class ChapterWebViewBridgeTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() = runTest {
|
fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() {
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
var receivedJson: String? = null
|
var receivedJson: String? = null
|
||||||
val bridge = TtsJsBridge(
|
val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
scope = this,
|
try {
|
||||||
ttsStructuredTextHandler = { json -> receivedJson = json }
|
val bridge = TtsJsBridge(
|
||||||
)
|
scope = bridgeScope,
|
||||||
val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]"
|
ttsStructuredTextHandler = { json ->
|
||||||
bridge.onStructuredTextExtracted(jsonPayload)
|
receivedJson = json
|
||||||
advanceUntilIdle()
|
latch.countDown()
|
||||||
assertThat(receivedJson).isEqualTo(jsonPayload)
|
}
|
||||||
|
)
|
||||||
|
val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]"
|
||||||
|
|
||||||
|
bridge.onStructuredTextExtracted(jsonPayload)
|
||||||
|
|
||||||
|
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
|
||||||
|
assertThat(receivedJson).isEqualTo(jsonPayload)
|
||||||
|
} finally {
|
||||||
|
bridgeScope.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() = runTest {
|
fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() {
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
var receivedJson: String? = null
|
var receivedJson: String? = null
|
||||||
val bridge = TtsJsBridge(
|
val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
scope = this,
|
try {
|
||||||
ttsStructuredTextHandler = { json -> receivedJson = json }
|
val bridge = TtsJsBridge(
|
||||||
)
|
scope = bridgeScope,
|
||||||
val jsonPayload = ""
|
ttsStructuredTextHandler = { json ->
|
||||||
bridge.onStructuredTextExtracted(jsonPayload)
|
receivedJson = json
|
||||||
advanceUntilIdle()
|
latch.countDown()
|
||||||
assertThat(receivedJson).isEqualTo("[]")
|
}
|
||||||
|
)
|
||||||
|
val jsonPayload = ""
|
||||||
|
|
||||||
|
bridge.onStructuredTextExtracted(jsonPayload)
|
||||||
|
|
||||||
|
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
|
||||||
|
assertThat(receivedJson).isEqualTo("[]")
|
||||||
|
} finally {
|
||||||
|
bridgeScope.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,43 @@
|
||||||
// app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt
|
|
||||||
package com.aryan.reader.epubreader
|
package com.aryan.reader.epubreader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import timber.log.Timber
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.aryan.reader.SearchResult
|
|
||||||
import com.aryan.reader.epub.EpubBook
|
import com.aryan.reader.epub.EpubBook
|
||||||
import com.aryan.reader.epub.EpubChapter
|
import com.aryan.reader.epub.EpubChapter
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.max
|
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class EpubReaderLogicTest {
|
class EpubReaderLogicTest {
|
||||||
|
|
||||||
private lateinit var context: Context
|
private lateinit var context: Context
|
||||||
private lateinit var testDir: File
|
private lateinit var testDir: File
|
||||||
private lateinit var mockEpubBook: EpubBook
|
private lateinit var testBook: EpubBook
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
context = ApplicationProvider.getApplicationContext()
|
context = ApplicationProvider.getApplicationContext()
|
||||||
testDir = File(context.cacheDir, "test_epub").apply { mkdirs() }
|
testDir = File(context.cacheDir, "test_epub_search").apply {
|
||||||
|
deleteRecursively()
|
||||||
|
mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
// Create dummy chapter files
|
val chapter1File = File(testDir, "chapter1.html").apply {
|
||||||
val chapter1File = File(testDir, "chapter1.html")
|
writeText("<html><body><p>A simple Test case.</p></body></html>")
|
||||||
chapter1File.writeText("<html><body><p>A simple Test case.</p></body></html>")
|
}
|
||||||
|
val chapter2File = File(testDir, "chapter2.html").apply {
|
||||||
|
writeText("<html><body><p>Another test case here.</p><p>The word Test appears twice.</p></body></html>")
|
||||||
|
}
|
||||||
|
|
||||||
val chapter2File = File(testDir, "chapter2.html")
|
testBook = EpubBook(
|
||||||
chapter2File.writeText("<html><body><p>Another test case here.</p><p>The word Test appears twice.</p></body></html>")
|
|
||||||
|
|
||||||
mockEpubBook = EpubBook(
|
|
||||||
fileName = "test.epub",
|
fileName = "test.epub",
|
||||||
title = "Test Book",
|
title = "Test Book",
|
||||||
author = "Tester",
|
author = "Tester",
|
||||||
|
|
@ -77,86 +70,19 @@ class EpubReaderLogicTest {
|
||||||
testDir.deleteRecursively()
|
testDir.deleteRecursively()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun searchEpub(book: EpubBook, query: String): List<SearchResult> {
|
|
||||||
val TAG = "EpubReaderLogicTest"
|
|
||||||
Timber.d("Starting search for query: '$query'")
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
val results = mutableListOf<SearchResult>()
|
|
||||||
book.chapters.forEachIndexed { chapterIndex, chapter ->
|
|
||||||
try {
|
|
||||||
val fullPath = "${book.extractionBasePath}/${chapter.htmlFilePath}"
|
|
||||||
Timber.d("Chapter ${chapterIndex + 1}: Checking path '$fullPath'")
|
|
||||||
val htmlFile = File(fullPath)
|
|
||||||
if (!htmlFile.exists()) {
|
|
||||||
Timber.e("File does not exist: $fullPath")
|
|
||||||
return@forEachIndexed
|
|
||||||
}
|
|
||||||
|
|
||||||
val doc = Jsoup.parse(htmlFile, "UTF-8")
|
|
||||||
val bodyChildren = doc.body().children().toList()
|
|
||||||
val chunks = bodyChildren.chunked(20)
|
|
||||||
|
|
||||||
chunks.forEachIndexed { chunkIndex, chunkOfElements ->
|
|
||||||
val chunkHtml = chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
|
|
||||||
val content = Jsoup.parse(chunkHtml).text()
|
|
||||||
var lastIndex = -1
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
lastIndex = content.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true)
|
|
||||||
if (lastIndex == -1) break
|
|
||||||
|
|
||||||
Timber.d("Found potential match for '$query' at index $lastIndex.")
|
|
||||||
val isWordStart = lastIndex == 0 || !content[lastIndex - 1].isLetterOrDigit()
|
|
||||||
Timber.d("Is it a word start? -> $isWordStart")
|
|
||||||
if (isWordStart) {
|
|
||||||
Timber.d("Match is a word start. Adding to results.")
|
|
||||||
val snippetStart = max(0, lastIndex - 35)
|
|
||||||
val snippetEnd = min(content.length, lastIndex + query.length + 35)
|
|
||||||
val rawSnippet = content.substring(snippetStart, snippetEnd)
|
|
||||||
val annotatedSnippet = buildAnnotatedString {
|
|
||||||
append(rawSnippet)
|
|
||||||
val highlightStart = content.indexOf(query, lastIndex, ignoreCase = true) - snippetStart
|
|
||||||
val highlightEnd = highlightStart + query.length
|
|
||||||
addStyle(
|
|
||||||
style = SpanStyle(fontWeight = FontWeight.Bold),
|
|
||||||
start = highlightStart,
|
|
||||||
end = highlightEnd
|
|
||||||
)
|
|
||||||
}
|
|
||||||
results.add(
|
|
||||||
SearchResult(
|
|
||||||
locationInSource = chapterIndex,
|
|
||||||
locationTitle = chapter.title,
|
|
||||||
snippet = annotatedSnippet,
|
|
||||||
query = query,
|
|
||||||
occurrenceIndexInLocation = results.count { it.locationInSource == chapterIndex },
|
|
||||||
chunkIndex = chunkIndex
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e("Error during search in chapter ${chapter.title}", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Timber.d("Search finished. Total results found: ${results.size}")
|
|
||||||
results
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun search_findsCorrectResults() = runBlocking {
|
fun search_findsCorrectResults() = runTest {
|
||||||
val results = searchEpub(mockEpubBook, "case")
|
val results = createEpubSearcher(testBook)("case")
|
||||||
|
|
||||||
assertThat(results).hasSize(2)
|
assertThat(results).hasSize(2)
|
||||||
assertThat(results.count { it.locationTitle == "Chapter 1" }).isEqualTo(1)
|
assertThat(results.count { it.locationTitle == "Chapter 1" }).isEqualTo(1)
|
||||||
assertThat(results.count { it.locationTitle == "Chapter 2" }).isEqualTo(1)
|
assertThat(results.count { it.locationTitle == "Chapter 2" }).isEqualTo(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun search_isCaseInsensitive() = runBlocking {
|
fun search_isCaseInsensitive() = runTest {
|
||||||
val results = searchEpub(mockEpubBook, "test")
|
val results = createEpubSearcher(testBook)("test")
|
||||||
|
|
||||||
assertThat(results).hasSize(3)
|
assertThat(results).hasSize(3)
|
||||||
assertThat(results[0].locationTitle).isEqualTo("Chapter 1")
|
assertThat(results[0].locationTitle).isEqualTo("Chapter 1")
|
||||||
assertThat(results[1].locationTitle).isEqualTo("Chapter 2")
|
assertThat(results[1].locationTitle).isEqualTo("Chapter 2")
|
||||||
|
|
@ -164,41 +90,19 @@ class EpubReaderLogicTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun search_noResultsFound() = runBlocking {
|
fun search_noResultsFound() = runTest {
|
||||||
val results = searchEpub(mockEpubBook, "nonexistent")
|
val results = createEpubSearcher(testBook)("nonexistent")
|
||||||
|
|
||||||
assertThat(results).isEmpty()
|
assertThat(results).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun search_createsCorrectSnippetHighlight() = runBlocking {
|
fun search_createsCorrectSnippetHighlight() = runTest {
|
||||||
val query = "Test"
|
val result = createEpubSearcher(testBook)("Test").first()
|
||||||
mockEpubBook.chapters.first()
|
val boldRanges = result.snippet.spanStyles.filter { it.item == SpanStyle(fontWeight = FontWeight.Bold) }
|
||||||
val content = "A simple Test case."
|
|
||||||
val annotatedString = buildAnnotatedStringWithHighlight(content, query)
|
|
||||||
|
|
||||||
val spanStyles = annotatedString.spanStyles
|
assertThat(boldRanges).hasSize(1)
|
||||||
assertThat(spanStyles).hasSize(1)
|
val highlight = boldRanges.first()
|
||||||
|
assertThat(result.snippet.text.substring(highlight.start, highlight.end)).isEqualTo("Test")
|
||||||
val style = spanStyles.first().item
|
|
||||||
assertThat(style.fontWeight).isEqualTo(FontWeight.Bold)
|
|
||||||
|
|
||||||
val start = spanStyles.first().start
|
|
||||||
val end = spanStyles.first().end
|
|
||||||
assertThat(annotatedString.substring(start, end)).isEqualTo(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("SameParameterValue")
|
|
||||||
private fun buildAnnotatedStringWithHighlight(content: String, query: String): AnnotatedString {
|
|
||||||
return buildAnnotatedString {
|
|
||||||
append(content)
|
|
||||||
val highlightStart = content.indexOf(query, ignoreCase = true)
|
|
||||||
if (highlightStart != -1) {
|
|
||||||
addStyle(
|
|
||||||
style = SpanStyle(fontWeight = FontWeight.Bold),
|
|
||||||
start = highlightStart,
|
|
||||||
end = highlightStart + query.length
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,818 @@
|
||||||
|
package com.aryan.reader.epubreader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertTextContains
|
||||||
|
import androidx.compose.ui.test.click
|
||||||
|
import androidx.compose.ui.test.hasSetTextAction
|
||||||
|
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performTextClearance
|
||||||
|
import androidx.compose.ui.test.performTextInput
|
||||||
|
import androidx.compose.ui.test.performTouchInput
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.aryan.reader.FileType
|
||||||
|
import com.aryan.reader.FileHasher
|
||||||
|
import com.aryan.reader.MainActivity
|
||||||
|
import com.aryan.reader.R
|
||||||
|
import com.aryan.reader.RenderMode
|
||||||
|
import com.aryan.reader.data.AppDatabase
|
||||||
|
import com.aryan.reader.data.RecentFileEntity
|
||||||
|
import com.aryan.reader.shared.EpubAnnotationSerializer
|
||||||
|
import com.aryan.reader.shared.ReaderLocator
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class EpubReaderScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createEmptyComposeRule()
|
||||||
|
|
||||||
|
private val fixtureAssetName = "epub/reader_test_book.epub"
|
||||||
|
private val fixtureBookTitle = "Reader Android UI Test Book"
|
||||||
|
private val sanitizedFixtureBookTitle = "ReaderAndroidUITestBook"
|
||||||
|
private val targetContext: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
private val instrumentationContext: Context = InstrumentationRegistry.getInstrumentation().context
|
||||||
|
private var currentEpubFile: File? = null
|
||||||
|
private var scenario: ActivityScenario<MainActivity>? = null
|
||||||
|
private lateinit var fixtureBookId: String
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
clearReaderPrefs()
|
||||||
|
fixtureBookId = requireNotNull(
|
||||||
|
runBlocking {
|
||||||
|
FileHasher.calculateSha256 {
|
||||||
|
instrumentationContext.assets.open(fixtureAssetName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
runBlocking {
|
||||||
|
AppDatabase.getDatabase(targetContext)
|
||||||
|
.recentFileDao()
|
||||||
|
.deleteFilePermanently(listOf(fixtureBookId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
scenario?.close()
|
||||||
|
currentEpubFile?.let {
|
||||||
|
if (it.exists()) it.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_opensReaderAndShowsBookTitle() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
showReaderChrome()
|
||||||
|
|
||||||
|
waitForText(fixtureBookTitle)
|
||||||
|
composeTestRule.onNodeWithText(fixtureBookTitle).assertIsDisplayed()
|
||||||
|
assertThat(hasContentDescription(text(R.string.tooltip_search))).isTrue()
|
||||||
|
assertThat(hasContentDescription(text(R.string.content_desc_chapters_menu))).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_recordsInitialReadingPosition() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
|
||||||
|
recentFile?.lastChapterIndex != null &&
|
||||||
|
recentFile.locatorBlockIndex != null &&
|
||||||
|
recentFile.locatorCharOffset != null &&
|
||||||
|
recentFile.progressPercentage != null
|
||||||
|
}
|
||||||
|
|
||||||
|
val recentFile = readFixtureRecentFile()
|
||||||
|
assertThat(recentFile?.lastChapterIndex).isAtLeast(0)
|
||||||
|
assertThat(recentFile?.locatorBlockIndex).isAtLeast(0)
|
||||||
|
assertThat(recentFile?.locatorCharOffset).isAtLeast(0)
|
||||||
|
assertThat(recentFile?.progressPercentage).isAtLeast(0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_restoresSeededReadingPositionWithoutResettingToStart() {
|
||||||
|
launchFixtureReader { fixtureUri ->
|
||||||
|
seedFixtureRecentFile(
|
||||||
|
uriString = fixtureUri.toString(),
|
||||||
|
chapterIndex = 1,
|
||||||
|
blockIndex = 1,
|
||||||
|
charOffset = 0,
|
||||||
|
progress = 45f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
|
||||||
|
recentFile?.lastChapterIndex == 1 &&
|
||||||
|
recentFile.locatorBlockIndex == 1 &&
|
||||||
|
recentFile.locatorCharOffset == 0 &&
|
||||||
|
(recentFile.progressPercentage ?: 0f) >= 45f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_drawerShowsFixtureChapters() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
||||||
|
|
||||||
|
waitForText(text(R.string.tab_chapters))
|
||||||
|
waitForTextContaining("Chapter One")
|
||||||
|
waitForTextContaining("Chapter Two")
|
||||||
|
waitForTextContaining("Chapter Three")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_drawerShowsFixtureImageCatalog() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
||||||
|
clickText(text(R.string.tab_images))
|
||||||
|
|
||||||
|
waitForText("Fixture diagram")
|
||||||
|
waitForTextContaining("Chapter Three")
|
||||||
|
assertThat(hasContentDescription(text(R.string.content_desc_download_image))).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_searchFindsUniqueFixtureMarker() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
clickReaderControl(text(R.string.tooltip_search))
|
||||||
|
waitForTag("SearchTextField")
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("SearchTextField").performTextInput("SEARCH_TARGET_DELTA")
|
||||||
|
composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("SEARCH_TARGET_DELTA")
|
||||||
|
|
||||||
|
waitForTag("SearchResultItem_1", timeoutMillis = 20_000)
|
||||||
|
composeTestRule.onNodeWithTag("SearchResultItem_1").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_searchNavigationPositionSurvivesReadingModeSwitches() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
navigateToFixtureSearchResult("SEARCH_TARGET_DELTA", expectedChapterIndex = 1)
|
||||||
|
clickContentDescription(text(R.string.tooltip_close_search))
|
||||||
|
|
||||||
|
waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
|
||||||
|
recentFile?.lastChapterIndex == 1 &&
|
||||||
|
recentFile.locatorBlockIndex != null &&
|
||||||
|
recentFile.locatorCharOffset != null &&
|
||||||
|
recentFile.progressPercentage != null
|
||||||
|
}
|
||||||
|
|
||||||
|
openOverflowMenu()
|
||||||
|
clickText(text(R.string.menu_change_reading_mode))
|
||||||
|
clickText(text(R.string.menu_reading_mode_paginated))
|
||||||
|
waitForRenderMode(RenderMode.PAGINATED)
|
||||||
|
|
||||||
|
waitForRecentFile(timeoutMillis = 20_000) { recentFile ->
|
||||||
|
recentFile?.lastChapterIndex == 1 &&
|
||||||
|
(recentFile.progressPercentage ?: 0f) > 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
openOverflowMenu()
|
||||||
|
clickText(text(R.string.menu_change_reading_mode))
|
||||||
|
clickText(text(R.string.menu_reading_mode_vertical_webview))
|
||||||
|
waitForRenderMode(RenderMode.VERTICAL_SCROLL)
|
||||||
|
|
||||||
|
waitForRecentFile(timeoutMillis = 20_000) { recentFile ->
|
||||||
|
recentFile?.lastChapterIndex == 1 &&
|
||||||
|
(recentFile.progressPercentage ?: 0f) > 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_addsCurrentPageBookmarkPersistsAndDeletes() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
openOverflowMenu()
|
||||||
|
clickText(text(R.string.menu_bookmark_this_page))
|
||||||
|
|
||||||
|
waitForFixtureBookmarks(timeoutMillis = 20_000) { bookmarksJson ->
|
||||||
|
parseBookmarksJson(bookmarksJson).isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
||||||
|
clickText(text(R.string.tab_bookmarks))
|
||||||
|
waitForTextContaining("Chapter One")
|
||||||
|
assertThat(hasContentDescription(text(R.string.content_desc_more_options_bookmark))).isTrue()
|
||||||
|
|
||||||
|
clickContentDescription(text(R.string.content_desc_more_options_bookmark))
|
||||||
|
clickText(text(R.string.action_delete))
|
||||||
|
waitForText(text(R.string.dialog_delete_bookmark))
|
||||||
|
clickText(text(R.string.action_delete))
|
||||||
|
|
||||||
|
waitForText(text(R.string.no_bookmarks_yet))
|
||||||
|
waitForFixtureBookmarks(timeoutMillis = 10_000) { bookmarksJson ->
|
||||||
|
parseBookmarksJson(bookmarksJson).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_drawerShowsSeededBookmarkAndSupportsRenameDelete() {
|
||||||
|
seedFixtureBookmark()
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
||||||
|
clickText(text(R.string.tab_bookmarks))
|
||||||
|
|
||||||
|
waitForText("BOOKMARK_TARGET_ECHO")
|
||||||
|
waitForText("Chapter Two")
|
||||||
|
|
||||||
|
clickContentDescription(text(R.string.content_desc_more_options_bookmark))
|
||||||
|
clickText(text(R.string.action_rename))
|
||||||
|
waitForText(text(R.string.dialog_rename_bookmark))
|
||||||
|
composeTestRule.onNode(hasSetTextAction()).performTextInput("Renamed fixture bookmark")
|
||||||
|
clickText(text(R.string.action_save))
|
||||||
|
waitForText("Renamed fixture bookmark")
|
||||||
|
|
||||||
|
clickContentDescription(text(R.string.content_desc_more_options_bookmark))
|
||||||
|
clickText(text(R.string.action_delete))
|
||||||
|
waitForText(text(R.string.dialog_delete_bookmark))
|
||||||
|
clickText(text(R.string.action_delete))
|
||||||
|
waitForText(text(R.string.no_bookmarks_yet))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_drawerShowsSeededAnnotationWithNoteAndFilter() {
|
||||||
|
seedFixtureHighlight()
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
||||||
|
clickText(text(R.string.tab_annotations))
|
||||||
|
|
||||||
|
waitForText("ANNOTATION_TARGET_GOLF")
|
||||||
|
waitForText("Fixture note survives startup")
|
||||||
|
waitForTextContaining("Chapter Three")
|
||||||
|
|
||||||
|
clickText(text(R.string.filter_with_notes))
|
||||||
|
waitForText("ANNOTATION_TARGET_GOLF")
|
||||||
|
waitForText("Fixture note survives startup")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_seededAnnotationSupportsColorNoteAndDeletePersistence() {
|
||||||
|
seedFixtureHighlight()
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
||||||
|
clickText(text(R.string.tab_annotations))
|
||||||
|
|
||||||
|
waitForText("ANNOTATION_TARGET_GOLF")
|
||||||
|
clickContentDescription(text(R.string.content_desc_options))
|
||||||
|
composeTestRule.onNodeWithTag("HighlightColor_blue").performClick()
|
||||||
|
|
||||||
|
waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
|
||||||
|
parseHighlightsJson(highlightsJson).singleOrNull()?.color == HighlightColor.BLUE
|
||||||
|
}
|
||||||
|
|
||||||
|
clickContentDescription(text(R.string.content_desc_options))
|
||||||
|
clickText(text(R.string.menu_edit_note))
|
||||||
|
waitForText(text(R.string.action_save_note))
|
||||||
|
composeTestRule.onNode(hasSetTextAction())
|
||||||
|
.performTextClearance()
|
||||||
|
composeTestRule.onNode(hasSetTextAction())
|
||||||
|
.performTextInput("Updated fixture note")
|
||||||
|
clickText(text(R.string.action_save_note))
|
||||||
|
|
||||||
|
waitForText("Updated fixture note")
|
||||||
|
waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
|
||||||
|
parseHighlightsJson(highlightsJson).singleOrNull()?.note == "Updated fixture note"
|
||||||
|
}
|
||||||
|
|
||||||
|
clickContentDescription(text(R.string.content_desc_options))
|
||||||
|
clickText(text(R.string.action_delete))
|
||||||
|
waitForText(text(R.string.dialog_delete_highlight))
|
||||||
|
clickText(text(R.string.action_delete))
|
||||||
|
|
||||||
|
waitForText(text(R.string.no_highlights_yet))
|
||||||
|
waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
|
||||||
|
parseHighlightsJson(highlightsJson).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_overflowSwitchesReadingModeAndTogglesPageOptions() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
openOverflowMenu()
|
||||||
|
clickText(text(R.string.menu_change_reading_mode))
|
||||||
|
clickText(text(R.string.menu_reading_mode_paginated))
|
||||||
|
waitForRenderMode(RenderMode.PAGINATED)
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
openOverflowMenu()
|
||||||
|
clickText(text(R.string.menu_tap_to_turn_pages))
|
||||||
|
assertReaderSettingEventually(
|
||||||
|
prefsName = "epub_reader_settings",
|
||||||
|
key = "tap_to_navigate_enabled",
|
||||||
|
expected = true
|
||||||
|
)
|
||||||
|
|
||||||
|
openOverflowMenu()
|
||||||
|
clickText(text(R.string.menu_realistic_page_turns))
|
||||||
|
assertReaderSettingEventually(
|
||||||
|
prefsName = "reader_prefs",
|
||||||
|
key = "page_turn_animation_enabled",
|
||||||
|
expected = true
|
||||||
|
)
|
||||||
|
|
||||||
|
openOverflowMenu()
|
||||||
|
clickText(text(R.string.menu_keep_screen_on))
|
||||||
|
assertReaderSettingEventually(
|
||||||
|
prefsName = "reader_prefs",
|
||||||
|
key = "keep_screen_on_enabled",
|
||||||
|
expected = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_visualOptionsSheetPersistsProgressPosition() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
openOverflowMenu()
|
||||||
|
clickText(text(R.string.menu_visual_options))
|
||||||
|
|
||||||
|
waitForText(text(R.string.visual_options_system_ui))
|
||||||
|
waitForText(text(R.string.visual_options_progress_bar))
|
||||||
|
waitForText(text(R.string.visual_options_progress_bar_position))
|
||||||
|
clickText(text(R.string.label_top))
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
||||||
|
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
||||||
|
.getInt("reader_page_info_position", 0) == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_formatPanelShowsControlsAndPersistsLocalFontSize() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
clickReaderControl(text(R.string.content_desc_text_formatting))
|
||||||
|
waitForText(text(R.string.section_font_alignment))
|
||||||
|
waitForText(text(R.string.section_layout_spacing))
|
||||||
|
waitForText(text(R.string.label_font_size))
|
||||||
|
waitForText(text(R.string.label_line_height))
|
||||||
|
waitForText(text(R.string.label_paragraph_gap))
|
||||||
|
waitForText(text(R.string.label_image_size))
|
||||||
|
waitForText(text(R.string.label_horizontal_margin))
|
||||||
|
waitForText(text(R.string.label_vertical_margin))
|
||||||
|
|
||||||
|
composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_select_mode))[0]
|
||||||
|
.performClick()
|
||||||
|
clickText(text(R.string.format_local))
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
||||||
|
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
||||||
|
.getBoolean("format_is_local_$fixtureBookId", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_increase))[0]
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
||||||
|
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
||||||
|
.getFloat("local_font_size_$fixtureBookId", 1.0f) > 1.0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_fontSelectionSheetPersistsFontFamily() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
clickReaderControl(text(R.string.content_desc_text_formatting))
|
||||||
|
waitForText(text(R.string.section_font_alignment))
|
||||||
|
clickContentDescription(text(R.string.content_desc_select_font_family))
|
||||||
|
|
||||||
|
waitForText(text(R.string.select_font))
|
||||||
|
waitForText(text(R.string.tab_presets))
|
||||||
|
waitForText(text(R.string.tab_imported))
|
||||||
|
clickText("Lato")
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
||||||
|
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
||||||
|
.getString("reader_font_family", "original") == "lato"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_themePanelShowsThemesAndPersistsSelection() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
clickReaderControl(text(R.string.tooltip_theme_desc))
|
||||||
|
|
||||||
|
waitForText(text(R.string.reading_themes))
|
||||||
|
waitForText(text(R.string.theme_solid_colors))
|
||||||
|
waitForText("Light")
|
||||||
|
waitForText("Dark")
|
||||||
|
clickText("Sepia")
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
||||||
|
targetContext.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
|
.getString(PREF_READER_THEME, "system") == "sepia"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_drawerShowsEmptyBookmarkAndAnnotationStates() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
||||||
|
clickText(text(R.string.tab_bookmarks))
|
||||||
|
waitForText(text(R.string.no_bookmarks_yet))
|
||||||
|
|
||||||
|
clickText(text(R.string.tab_annotations))
|
||||||
|
waitForText(text(R.string.no_highlights_yet))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_searchCanClearAndClose() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
clickReaderControl(text(R.string.tooltip_search))
|
||||||
|
waitForTag("SearchTextField")
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("SearchTextField")
|
||||||
|
.performTextInput("SEARCH_TARGET_DELTA")
|
||||||
|
waitForTextContaining("SEARCH_TARGET_DELTA")
|
||||||
|
|
||||||
|
clickContentDescription(text(R.string.tooltip_clear_search))
|
||||||
|
waitForNoContentDescription(text(R.string.tooltip_clear_search))
|
||||||
|
|
||||||
|
clickContentDescription(text(R.string.tooltip_close_search))
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
||||||
|
hasContentDescription(text(R.string.tooltip_search))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_dictionarySettingsShowsLookupSections() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
clickReaderControl(text(R.string.content_desc_dictionary_settings))
|
||||||
|
|
||||||
|
waitForText(text(R.string.dict_lookup_settings))
|
||||||
|
waitForText(text(R.string.tooltip_dictionary))
|
||||||
|
waitForText(text(R.string.dict_translate))
|
||||||
|
waitForText(text(R.string.tooltip_search))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixtureEpub_ttsReplacementSheetShowsGlobalAndBookScopes() {
|
||||||
|
launchFixtureReader()
|
||||||
|
waitForReader()
|
||||||
|
|
||||||
|
openOverflowMenu()
|
||||||
|
clickText(text(R.string.menu_tts_settings))
|
||||||
|
clickText(text(R.string.menu_tts_word_replacements))
|
||||||
|
|
||||||
|
waitForText(text(R.string.menu_tts_word_replacements))
|
||||||
|
waitForText(fixtureBookTitle)
|
||||||
|
waitForText(text(R.string.tts_replacements_tab_global))
|
||||||
|
waitForText(text(R.string.tts_replacements_tab_this_book))
|
||||||
|
waitForText(text(R.string.tts_replacements_enable))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchFixtureReader(beforeLaunch: (Uri) -> Unit = {}) {
|
||||||
|
val fixtureUri = copyAndroidTestAssetToCache(fixtureAssetName)
|
||||||
|
beforeLaunch(fixtureUri)
|
||||||
|
scenario = ActivityScenario.launch<MainActivity>(createEpubViewIntent(fixtureUri))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearReaderPrefs() {
|
||||||
|
listOf(
|
||||||
|
"epub_reader_settings",
|
||||||
|
"epub_reader_bookmarks",
|
||||||
|
"reader_prefs"
|
||||||
|
).forEach { prefsName ->
|
||||||
|
targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.clear()
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString("render_mode", "VERTICAL_SCROLL")
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun text(resId: Int): String = targetContext.getString(resId)
|
||||||
|
|
||||||
|
private fun seedFixtureBookmark() {
|
||||||
|
val bookmark = org.json.JSONObject().apply {
|
||||||
|
put("cfi", "android-locator:1:1:0")
|
||||||
|
put("chapterTitle", "Chapter Two")
|
||||||
|
put("label", org.json.JSONObject.NULL)
|
||||||
|
put("snippet", "BOOKMARK_TARGET_ECHO")
|
||||||
|
put("pageInChapter", 1)
|
||||||
|
put("totalPagesInChapter", 3)
|
||||||
|
put("chapterIndex", 1)
|
||||||
|
put(
|
||||||
|
"locator",
|
||||||
|
org.json.JSONObject().apply {
|
||||||
|
put("chapterIndex", 1)
|
||||||
|
put("chapterId", "chapter-02")
|
||||||
|
put("pageIndex", 0)
|
||||||
|
put("blockIndex", 1)
|
||||||
|
put("charOffset", 0)
|
||||||
|
put("textQuote", "BOOKMARK_TARGET_ECHO")
|
||||||
|
put("cfi", "android-locator:1:1:0")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetContext.getSharedPreferences("epub_reader_bookmarks", Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putStringSet("bookmarks_cfi_$sanitizedFixtureBookTitle", setOf(bookmark.toString()))
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seedFixtureHighlight() {
|
||||||
|
val highlight = UserHighlight(
|
||||||
|
id = "fixture_annotation_golf",
|
||||||
|
cfi = "android-locator:2:1:0",
|
||||||
|
text = "ANNOTATION_TARGET_GOLF",
|
||||||
|
color = HighlightColor.GREEN,
|
||||||
|
chapterIndex = 2,
|
||||||
|
note = "Fixture note survives startup",
|
||||||
|
locator = ReaderLocator(
|
||||||
|
chapterIndex = 2,
|
||||||
|
chapterId = "chapter-03",
|
||||||
|
blockIndex = 1,
|
||||||
|
charOffset = 0,
|
||||||
|
textQuote = "ANNOTATION_TARGET_GOLF",
|
||||||
|
cfi = "android-locator:2:1:0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString("highlights_data_$sanitizedFixtureBookTitle", highlightsToJson(listOf(highlight)))
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seedFixtureRecentFile(
|
||||||
|
uriString: String,
|
||||||
|
chapterIndex: Int,
|
||||||
|
blockIndex: Int,
|
||||||
|
charOffset: Int,
|
||||||
|
progress: Float
|
||||||
|
) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
runBlocking {
|
||||||
|
AppDatabase.getDatabase(targetContext)
|
||||||
|
.recentFileDao()
|
||||||
|
.insertOrUpdateFile(
|
||||||
|
RecentFileEntity(
|
||||||
|
bookId = fixtureBookId,
|
||||||
|
uriString = uriString,
|
||||||
|
type = FileType.EPUB,
|
||||||
|
displayName = fixtureBookTitle,
|
||||||
|
timestamp = now,
|
||||||
|
coverImagePath = null,
|
||||||
|
title = fixtureBookTitle,
|
||||||
|
author = "Fixture Author",
|
||||||
|
lastChapterIndex = chapterIndex,
|
||||||
|
lastPage = null,
|
||||||
|
lastPositionCfi = "android-locator:$chapterIndex:$blockIndex:$charOffset",
|
||||||
|
progressPercentage = progress,
|
||||||
|
isRecent = true,
|
||||||
|
isAvailable = true,
|
||||||
|
lastModifiedTimestamp = now,
|
||||||
|
isDeleted = false,
|
||||||
|
locatorBlockIndex = blockIndex,
|
||||||
|
locatorCharOffset = charOffset,
|
||||||
|
bookmarks = null,
|
||||||
|
sourceFolderUri = null,
|
||||||
|
isReflowPreferred = false,
|
||||||
|
customName = null,
|
||||||
|
highlights = null,
|
||||||
|
fileSize = 0L,
|
||||||
|
fileContentModifiedTimestamp = 0L,
|
||||||
|
seriesName = null,
|
||||||
|
seriesIndex = null,
|
||||||
|
description = null,
|
||||||
|
folderTextMetadataParsed = false,
|
||||||
|
folderCoverMetadataParsed = false,
|
||||||
|
originalTitle = fixtureBookTitle,
|
||||||
|
originalAuthor = "Fixture Author",
|
||||||
|
originalSeriesName = null,
|
||||||
|
originalSeriesIndex = null,
|
||||||
|
originalDescription = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createEpubViewIntent(uri: Uri): Intent {
|
||||||
|
return Intent(targetContext, MainActivity::class.java).apply {
|
||||||
|
action = Intent.ACTION_VIEW
|
||||||
|
setDataAndType(uri, "application/epub+zip")
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyAndroidTestAssetToCache(assetName: String): Uri {
|
||||||
|
val file = File(targetContext.cacheDir, "${UUID.randomUUID()}_reader_test_book.epub")
|
||||||
|
currentEpubFile = file
|
||||||
|
|
||||||
|
instrumentationContext.assets.open(assetName).use { inputStream ->
|
||||||
|
file.outputStream().use { outputStream ->
|
||||||
|
inputStream.copyTo(outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileProvider.getUriForFile(
|
||||||
|
targetContext,
|
||||||
|
"${targetContext.packageName}.provider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToFixtureSearchResult(query: String, expectedChapterIndex: Int) {
|
||||||
|
clickReaderControl(text(R.string.tooltip_search))
|
||||||
|
waitForTag("SearchTextField")
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("SearchTextField").performTextInput(query)
|
||||||
|
waitForTag("SearchResultItem_$expectedChapterIndex", timeoutMillis = 20_000)
|
||||||
|
composeTestRule.onNodeWithTag("SearchResultItem_$expectedChapterIndex").performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForReader() {
|
||||||
|
waitForTag("ReaderContainer", timeoutMillis = 30_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showReaderChrome() {
|
||||||
|
if (hasAnyReaderControl()) return
|
||||||
|
|
||||||
|
composeTestRule.onRoot().performTouchInput { click(center) }
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
||||||
|
hasAnyReaderControl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clickReaderControl(contentDescription: String) {
|
||||||
|
showReaderChrome()
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
||||||
|
hasContentDescription(contentDescription)
|
||||||
|
}
|
||||||
|
composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clickContentDescription(contentDescription: String) {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
||||||
|
hasContentDescription(contentDescription)
|
||||||
|
}
|
||||||
|
composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clickText(value: String) {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 10_000) {
|
||||||
|
composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
composeTestRule.onAllNodesWithText(value)[0].performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openOverflowMenu() {
|
||||||
|
clickReaderControl(text(R.string.content_desc_more_options))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasAnyReaderControl(): Boolean {
|
||||||
|
return hasContentDescription(text(R.string.tooltip_search)) ||
|
||||||
|
hasContentDescription(text(R.string.content_desc_chapters_menu)) ||
|
||||||
|
hasContentDescription(text(R.string.content_desc_more_options))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasContentDescription(contentDescription: String): Boolean {
|
||||||
|
return composeTestRule
|
||||||
|
.onAllNodesWithContentDescription(contentDescription)
|
||||||
|
.fetchSemanticsNodes()
|
||||||
|
.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForTag(tag: String, timeoutMillis: Long = 10_000) {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
||||||
|
composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForText(value: String, timeoutMillis: Long = 10_000) {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
||||||
|
composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForTextContaining(value: String, timeoutMillis: Long = 10_000) {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
||||||
|
composeTestRule.onAllNodesWithText(value, substring = true).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForNoContentDescription(contentDescription: String, timeoutMillis: Long = 5_000) {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithContentDescription(contentDescription)
|
||||||
|
.fetchSemanticsNodes()
|
||||||
|
.isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForRenderMode(expected: RenderMode) {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 20_000) {
|
||||||
|
targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
.getString("render_mode", RenderMode.VERTICAL_SCROLL.name) == expected.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertReaderSettingEventually(
|
||||||
|
prefsName: String,
|
||||||
|
key: String,
|
||||||
|
expected: Boolean
|
||||||
|
) {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
||||||
|
targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
|
||||||
|
.getBoolean(key, !expected) == expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readFixtureRecentFile(): RecentFileEntity? {
|
||||||
|
return runBlocking {
|
||||||
|
AppDatabase.getDatabase(targetContext)
|
||||||
|
.recentFileDao()
|
||||||
|
.getFileByBookId(fixtureBookId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForRecentFile(
|
||||||
|
timeoutMillis: Long = 10_000,
|
||||||
|
predicate: (RecentFileEntity?) -> Boolean
|
||||||
|
) {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
||||||
|
predicate(readFixtureRecentFile())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForFixtureBookmarks(
|
||||||
|
timeoutMillis: Long = 10_000,
|
||||||
|
predicate: (String?) -> Boolean
|
||||||
|
) {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
||||||
|
predicate(readFixtureRecentFile()?.bookmarks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForFixtureHighlights(
|
||||||
|
timeoutMillis: Long = 10_000,
|
||||||
|
predicate: (String?) -> Boolean
|
||||||
|
) {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
||||||
|
predicate(readFixtureRecentFile()?.highlights)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseBookmarksJson(rawJson: String?): Set<Bookmark> {
|
||||||
|
return EpubAnnotationSerializer.parseBookmarksJson(rawJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,49 +17,66 @@ import org.junit.runner.RunWith
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class CssParserTest {
|
class CssParserTest {
|
||||||
|
|
||||||
|
private val dummyConstraints = androidx.compose.ui.unit.Constraints()
|
||||||
|
private val baseFontSize = 16f
|
||||||
|
private val density = 1f
|
||||||
|
|
||||||
|
private fun parseTextColor(value: String): Color? {
|
||||||
|
val result = CssParser.parse(
|
||||||
|
cssContent = "p { color: $value; }",
|
||||||
|
cssPath = null,
|
||||||
|
baseFontSizeSp = baseFontSize,
|
||||||
|
density = density,
|
||||||
|
constraints = dummyConstraints,
|
||||||
|
isDarkTheme = false
|
||||||
|
)
|
||||||
|
return result.rules.byTag["p"]
|
||||||
|
?.firstOrNull()
|
||||||
|
?.style
|
||||||
|
?.spanStyle
|
||||||
|
?.color
|
||||||
|
?.takeIf { it.isSpecified }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_handlesNamedColorsCorrectly() {
|
fun parseColor_handlesNamedColorsCorrectly() {
|
||||||
assertThat(CssParser.parseColor("red")).isEqualTo(Color.Red)
|
assertThat(parseTextColor("red")).isEqualTo(Color.Red)
|
||||||
assertThat(CssParser.parseColor("black")).isEqualTo(Color.Black)
|
assertThat(parseTextColor("black")).isEqualTo(Color.Black)
|
||||||
assertThat(CssParser.parseColor("transparent")).isEqualTo(Color.Transparent)
|
assertThat(parseTextColor("transparent")).isEqualTo(Color.Transparent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_handles3DigitHexCodes() {
|
fun parseColor_handles3DigitHexCodes() {
|
||||||
assertThat(CssParser.parseColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
|
assertThat(parseTextColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_handles6DigitHexCodes() {
|
fun parseColor_handles6DigitHexCodes() {
|
||||||
assertThat(CssParser.parseColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
|
assertThat(parseTextColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_handles8DigitHexCodes() {
|
fun parseColor_handles8DigitHexCodes() {
|
||||||
assertThat(CssParser.parseColor("#80FF00CC")).isEqualTo(Color(0x80FF00CC))
|
assertThat(parseTextColor("#80FF00CC")).isEqualTo(Color(128, 255, 0, 204))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_handlesRgbFunction() {
|
fun parseColor_handlesRgbFunction() {
|
||||||
assertThat(CssParser.parseColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
|
assertThat(parseTextColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_handlesRgbaFunction() {
|
fun parseColor_handlesRgbaFunction() {
|
||||||
assertThat(CssParser.parseColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
|
assertThat(parseTextColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_returnsNullForInvalidInput() {
|
fun parseColor_returnsNullForInvalidInput() {
|
||||||
assertThat(CssParser.parseColor("not a color")).isNull()
|
assertThat(parseTextColor("not a color")).isNull()
|
||||||
assertThat(CssParser.parseColor("#12345")).isNull()
|
assertThat(parseTextColor("#12345")).isNull()
|
||||||
assertThat(CssParser.parseColor("rgb(1,2)")).isNull()
|
assertThat(parseTextColor("rgb(1,2)")).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val dummyConstraints = androidx.compose.ui.unit.Constraints()
|
|
||||||
private val baseFontSize = 16f
|
|
||||||
private val density = 1f
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parse_handlesSimpleRule() {
|
fun parse_handlesSimpleRule() {
|
||||||
val css = "p { color: red; }"
|
val css = "p { color: red; }"
|
||||||
|
|
@ -120,12 +137,12 @@ class CssParserTest {
|
||||||
}
|
}
|
||||||
p { color: black; }
|
p { color: black; }
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val result = CssParser.parse(css, "/some/path/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
val result = CssParser.parse(css, "OEBPS/styles/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||||
assertThat(result.rules.byTag).containsKey("p")
|
assertThat(result.rules.byTag).containsKey("p")
|
||||||
assertThat(result.fontFaces).hasSize(1)
|
assertThat(result.fontFaces).hasSize(1)
|
||||||
val fontFace = result.fontFaces.first()
|
val fontFace = result.fontFaces.first()
|
||||||
assertThat(fontFace.fontFamily).isEqualTo("mycustomfont")
|
assertThat(fontFace.fontFamily).isEqualTo("mycustomfont")
|
||||||
assertThat(fontFace.src).isEqualTo("/some/fonts/myfont.ttf")
|
assertThat(fontFace.src).isEqualTo("OEBPS/fonts/myfont.ttf")
|
||||||
assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold)
|
assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold)
|
||||||
assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal)
|
assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal)
|
||||||
}
|
}
|
||||||
|
|
@ -190,10 +207,11 @@ class CssParserTest {
|
||||||
val css = "div { border: 2px solid red; }"
|
val css = "div { border: 2px solid red; }"
|
||||||
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||||
val style = result.rules.byTag["div"]?.first()?.style?.blockStyle
|
val style = result.rules.byTag["div"]?.first()?.style?.blockStyle
|
||||||
assertThat(style?.border).isNotNull()
|
val expectedBorder = BorderStyle(width = 2.dp, color = Color.Red, style = "solid")
|
||||||
assertThat(style?.border?.width).isEqualTo(2.dp)
|
assertThat(style?.borderTop).isEqualTo(expectedBorder)
|
||||||
assertThat(style?.border?.style).isEqualTo("solid")
|
assertThat(style?.borderRight).isEqualTo(expectedBorder)
|
||||||
assertThat(style?.border?.color).isEqualTo(Color.Red)
|
assertThat(style?.borderBottom).isEqualTo(expectedBorder)
|
||||||
|
assertThat(style?.borderLeft).isEqualTo(expectedBorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -262,9 +280,70 @@ class CssParserTest {
|
||||||
url("font.ttf") format("truetype");
|
url("font.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val result = CssParser.parse(css, "/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
val result = CssParser.parse(css, "OEBPS/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||||
assertThat(result.fontFaces).hasSize(1)
|
assertThat(result.fontFaces).hasSize(1)
|
||||||
assertThat(result.fontFaces.first().src).isEqualTo("/css/font.otf")
|
assertThat(result.fontFaces.first().src).isEqualTo("OEBPS/css/font.otf")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_handlesNestedMediaAndCalcVariables() {
|
||||||
|
val css = """
|
||||||
|
:root { --gap: 12px; }
|
||||||
|
@media screen and (min-width: 300px) {
|
||||||
|
p { margin-left: calc(var(--gap) + 8px); color: hsl(120 100% 25%); }
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val result = CssParser.parse(
|
||||||
|
css,
|
||||||
|
null,
|
||||||
|
baseFontSize,
|
||||||
|
density,
|
||||||
|
androidx.compose.ui.unit.Constraints(maxWidth = 500),
|
||||||
|
isDarkTheme = false
|
||||||
|
)
|
||||||
|
|
||||||
|
val style = result.rules.byTag["p"]!!.first().style
|
||||||
|
assertThat(style.blockStyle.margin.left).isEqualTo(20.dp)
|
||||||
|
assertThat(style.spanStyle.color).isEqualTo(Color(0, 128, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_preservesBeforeAfterPseudoElementRules() {
|
||||||
|
val css = "p::before { content: 'Note: '; color: red; } p { color: blue; }"
|
||||||
|
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||||
|
|
||||||
|
val pseudoRule = result.rules.otherComplex.single { it.pseudoElement == "before" }
|
||||||
|
assertThat(pseudoRule.selector.selector).isEqualTo("p")
|
||||||
|
assertThat(pseudoRule.style.content).isEqualTo("'Note: '")
|
||||||
|
assertThat(pseudoRule.style.spanStyle.color).isEqualTo(Color.Red)
|
||||||
|
assertThat(result.rules.byTag["p"]!!.single().style.spanStyle.color).isEqualTo(Color.Blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_handlesModernRgbSlashAlphaAndCssHexAlpha() {
|
||||||
|
assertThat(parseTextColor("rgb(255 0 204 / 50%)")).isEqualTo(Color(255, 0, 204, 128))
|
||||||
|
assertThat(parseTextColor("#ff00cc80")).isEqualTo(Color(255, 0, 204, 128))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_backgroundShorthandExtractsColorAndImage() {
|
||||||
|
val css = "section { background: #ffeecc url('../images/paper.png') repeat; }"
|
||||||
|
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||||
|
val style = result.rules.byTag["section"]!!.first().style.blockStyle
|
||||||
|
|
||||||
|
assertThat(style.backgroundColor).isEqualTo(Color(255, 238, 204))
|
||||||
|
assertThat(style.backgroundImage).isEqualTo("../images/paper.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_listStyleShorthandExtractsMarkerTypeAndImage() {
|
||||||
|
val css = "ul { list-style: square url('../images/bullet.png') outside; }"
|
||||||
|
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||||
|
val style = result.rules.byTag["ul"]!!.first().style.blockStyle
|
||||||
|
|
||||||
|
assertThat(style.listStyleType).isEqualTo("square")
|
||||||
|
assertThat(style.listStyleImage).isEqualTo("../images/bullet.png")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -327,10 +406,10 @@ class CssParserTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parse_lineHeightClampsSmallEmValues() {
|
fun parse_lineHeightPreservesUnitlessMultiplier() {
|
||||||
val css = "p { line-height: 1.1; }" // This is treated as 1.1em
|
val css = "p { line-height: 1.1; }" // This is treated as 1.1em
|
||||||
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||||
val style = result.rules.byTag["p"]?.first()?.style
|
val style = result.rules.byTag["p"]?.first()?.style
|
||||||
assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(2.0.em)
|
assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(1.1.em)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,7 +3,6 @@ package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
|
|
@ -41,7 +40,7 @@ class HtmlParserTest {
|
||||||
|
|
||||||
val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules
|
val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules
|
||||||
|
|
||||||
return htmlToSemanticBlocks(
|
return androidHtmlToSemanticBlocks(
|
||||||
html = "<body>$html</body>", // Wrap in body to match real usage
|
html = "<body>$html</body>", // Wrap in body to match real usage
|
||||||
cssRules = allRules, // Use the combined list of rules
|
cssRules = allRules, // Use the combined list of rules
|
||||||
textStyle = defaultTextStyle,
|
textStyle = defaultTextStyle,
|
||||||
|
|
@ -97,7 +96,61 @@ class HtmlParserTest {
|
||||||
assertThat(blocks).hasSize(1)
|
assertThat(blocks).hasSize(1)
|
||||||
val pBlock = blocks.first() as SemanticParagraph
|
val pBlock = blocks.first() as SemanticParagraph
|
||||||
val blockStyle = pBlock.style.spanStyle
|
val blockStyle = pBlock.style.spanStyle
|
||||||
assertThat(blockStyle.color).isEqualTo(Color.Green)
|
assertThat(blockStyle.color).isEqualTo(Color(0, 128, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun htmlToSemanticBlocks_contextSensitiveSelectors_areNotReusedAcrossSameClass() {
|
||||||
|
val css = """
|
||||||
|
.warning p.note { color: red; }
|
||||||
|
.safe p.note { color: blue; }
|
||||||
|
""".trimIndent()
|
||||||
|
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
||||||
|
|
||||||
|
val blocks = parse(
|
||||||
|
"""
|
||||||
|
<div class="warning"><p class="note">Danger</p></div>
|
||||||
|
<div class="safe"><p class="note">Okay</p></div>
|
||||||
|
""".trimIndent(),
|
||||||
|
cssRules = cssRules
|
||||||
|
)
|
||||||
|
|
||||||
|
val first = blocks[0] as SemanticParagraph
|
||||||
|
val second = blocks[1] as SemanticParagraph
|
||||||
|
assertThat(first.style.spanStyle.color).isEqualTo(Color.Red)
|
||||||
|
assertThat(second.style.spanStyle.color).isEqualTo(Color.Blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun htmlToSemanticBlocks_generatedBeforeContent_isMaterializedIntoText() {
|
||||||
|
val css = "p.note::before { content: 'Note: '; color: red; }"
|
||||||
|
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
||||||
|
|
||||||
|
val blocks = parse("<p class=\"note\">Remember this</p>", cssRules = cssRules)
|
||||||
|
|
||||||
|
val paragraph = blocks.single() as SemanticParagraph
|
||||||
|
assertThat(paragraph.text).isEqualTo("Note: Remember this")
|
||||||
|
val generatedSpan = paragraph.spans.first { it.tag == "::before" }
|
||||||
|
assertThat(generatedSpan.start).isEqualTo(0)
|
||||||
|
assertThat(generatedSpan.end).isEqualTo("Note: ".length)
|
||||||
|
assertThat(generatedSpan.style.spanStyle.color).isEqualTo(Color.Red)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun htmlToSemanticBlocks_backgroundImageUrl_isResolvedIntoBlockStyle() {
|
||||||
|
val imageRelativeSrc = "images/paper.png"
|
||||||
|
val chapterParentDir = File(defaultChapterPath).parent ?: ""
|
||||||
|
val imageFile = File(File(defaultExtractionPath, chapterParentDir), imageRelativeSrc).canonicalFile
|
||||||
|
imageFile.parentFile?.mkdirs()
|
||||||
|
imageFile.createNewFile()
|
||||||
|
imageFile.deleteOnExit()
|
||||||
|
val css = "p.paper { background-image: url('$imageRelativeSrc'); }"
|
||||||
|
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
||||||
|
|
||||||
|
val blocks = parse("<p class=\"paper\">Text over paper</p>", cssRules = cssRules)
|
||||||
|
|
||||||
|
val paragraph = blocks.single() as SemanticParagraph
|
||||||
|
assertThat(paragraph.style.blockStyle.backgroundImage).isEqualTo(imageFile.absolutePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -197,7 +250,7 @@ class HtmlParserTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun htmlToSemanticBlocks_complexInlineFormatting_isPreserved() {
|
fun htmlToSemanticBlocks_complexInlineText_isPreserved() {
|
||||||
val html = "<p>This is <b>bold</b> and <i>italic</i> text.</p>"
|
val html = "<p>This is <b>bold</b> and <i>italic</i> text.</p>"
|
||||||
val blocks = parse(html)
|
val blocks = parse(html)
|
||||||
|
|
||||||
|
|
@ -205,17 +258,6 @@ class HtmlParserTest {
|
||||||
val pBlock = blocks.first() as SemanticParagraph
|
val pBlock = blocks.first() as SemanticParagraph
|
||||||
|
|
||||||
assertThat(pBlock.text).isEqualTo("This is bold and italic text.")
|
assertThat(pBlock.text).isEqualTo("This is bold and italic text.")
|
||||||
|
|
||||||
// Find the range for "bold" and check its style
|
|
||||||
val boldRange = pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "bold" }
|
|
||||||
assertThat(boldRange).isNotNull()
|
|
||||||
assertThat(boldRange!!.style.spanStyle.fontWeight).isEqualTo(FontWeight.Bold)
|
|
||||||
|
|
||||||
// Find the range for "italic" and check its style
|
|
||||||
val italicRange =
|
|
||||||
pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "italic" }
|
|
||||||
assertThat(italicRange).isNotNull()
|
|
||||||
assertThat(italicRange!!.style.spanStyle.fontStyle).isEqualTo(androidx.compose.ui.text.font.FontStyle.Italic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -242,15 +284,14 @@ class HtmlParserTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun htmlToSemanticBlocks_pseudoElements_areIgnoredByTheParser() {
|
fun htmlToSemanticBlocks_beforePseudoElementContent_isIncludedInParagraphText() {
|
||||||
val css = "p::before { content: \"Note: \"; }"
|
val css = "p::before { content: \"Note: \"; }"
|
||||||
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
||||||
val blocks = parse("<p>This is a test.</p>", cssRules = cssRules)
|
val blocks = parse("<p>This is a test.</p>", cssRules = cssRules)
|
||||||
|
|
||||||
// The parser now ignores pseudo-elements, so only the paragraph content should be parsed.
|
|
||||||
assertThat(blocks).hasSize(1)
|
assertThat(blocks).hasSize(1)
|
||||||
val pBlock = blocks[0] as SemanticParagraph
|
val pBlock = blocks[0] as SemanticParagraph
|
||||||
assertThat(pBlock.text).isEqualTo("This is a test.")
|
assertThat(pBlock.text).isEqualTo("Note: This is a test.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ class PaginatedReaderDataTest {
|
||||||
margin = BoxBorders(bottom = 10.dp, left = 10.dp),
|
margin = BoxBorders(bottom = 10.dp, left = 10.dp),
|
||||||
width = 200.dp,
|
width = 200.dp,
|
||||||
backgroundColor = Color.Black,
|
backgroundColor = Color.Black,
|
||||||
border = BorderStyle(width = 1.dp, color = Color.Red)
|
borderTop = BorderStyle(width = 1.dp, color = Color.Red)
|
||||||
)
|
)
|
||||||
|
|
||||||
val merged = baseStyle.merge(overrideStyle)
|
val merged = baseStyle.merge(overrideStyle)
|
||||||
|
|
@ -95,8 +95,8 @@ class PaginatedReaderDataTest {
|
||||||
// Other properties
|
// Other properties
|
||||||
assertThat(merged.width).isEqualTo(200.dp)
|
assertThat(merged.width).isEqualTo(200.dp)
|
||||||
assertThat(merged.backgroundColor).isEqualTo(Color.Black)
|
assertThat(merged.backgroundColor).isEqualTo(Color.Black)
|
||||||
assertThat(merged.border).isNotNull()
|
assertThat(merged.borderTop).isNotNull()
|
||||||
assertThat(merged.border?.width).isEqualTo(1.dp)
|
assertThat(merged.borderTop?.width).isEqualTo(1.dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -115,6 +115,9 @@ class PaginatedReaderDataTest {
|
||||||
assertThat(merged.margin.top).isEqualTo(5.dp)
|
assertThat(merged.margin.top).isEqualTo(5.dp)
|
||||||
assertThat(merged.width).isEqualTo(100.dp)
|
assertThat(merged.width).isEqualTo(100.dp)
|
||||||
assertThat(merged.backgroundColor).isEqualTo(Color.White)
|
assertThat(merged.backgroundColor).isEqualTo(Color.White)
|
||||||
assertThat(merged.border).isNull()
|
assertThat(merged.borderTop).isNull()
|
||||||
|
assertThat(merged.borderRight).isNull()
|
||||||
|
assertThat(merged.borderBottom).isNull()
|
||||||
|
assertThat(merged.borderLeft).isNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,36 +1,29 @@
|
||||||
// PaginatedReaderViewModelTest.kt
|
|
||||||
package com.aryan.reader.paginatedreader
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshots.Snapshot
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.TextMeasurer
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.SdkSuppress
|
||||||
import com.aryan.reader.SearchResult
|
import com.aryan.reader.SearchResult
|
||||||
import com.aryan.reader.epub.EpubBook
|
import com.aryan.reader.epub.EpubBook
|
||||||
import com.aryan.reader.epub.EpubChapter
|
|
||||||
import com.aryan.reader.paginatedreader.data.BookCacheDao
|
|
||||||
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
|
||||||
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.mockk.coEvery
|
|
||||||
import io.mockk.every
|
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkObject
|
|
||||||
import io.mockk.unmockkAll
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -64,26 +57,26 @@ private class FakePaginator(
|
||||||
|
|
||||||
override fun findPageForSearchResult(
|
override fun findPageForSearchResult(
|
||||||
result: SearchResult,
|
result: SearchResult,
|
||||||
onResult: (Int) -> Unit
|
onResult: (pageIndex: Int) -> Unit
|
||||||
) = Unit
|
) = Unit
|
||||||
|
|
||||||
// Add stubs for the other missing interface members
|
override fun findPageForAnchor(
|
||||||
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (Int) -> Unit) = Unit
|
|
||||||
override fun findPageForCfiAndOffset(
|
|
||||||
chapterIndex: Int,
|
chapterIndex: Int,
|
||||||
cfi: String,
|
anchor: String?,
|
||||||
charOffset: Int
|
onResult: (pageIndex: Int) -> Unit
|
||||||
): Int? {
|
) = Unit
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) = Unit
|
||||||
|
override fun findPageForCfiAndOffset(chapterIndex: Int, cfi: String, charOffset: Int): Int? = null
|
||||||
override fun findChapterIndexForPage(pageIndex: Int): Int? = null
|
override fun findChapterIndexForPage(pageIndex: Int): Int? = null
|
||||||
override fun getCfiForPage(pageIndex: Int): String? = null
|
override fun getCfiForPage(pageIndex: Int): String? = null
|
||||||
override fun onUserScrolledTo(pageIndex: Int) = Unit
|
override fun onUserScrolledTo(pageIndex: Int) = Unit
|
||||||
|
override fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List<String>): String? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||||
class PaginatedReaderViewModelTest {
|
class PaginatedReaderViewModelTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
|
|
@ -103,11 +96,6 @@ class PaginatedReaderViewModelTest {
|
||||||
viewModel.setPaginatorForTest(fakePaginator)
|
viewModel.setPaginatorForTest(fakePaginator)
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
unmockkAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun uiState_reflectsPaginatorInitialState() = runTest {
|
fun uiState_reflectsPaginatorInitialState() = runTest {
|
||||||
val initialState = viewModel.uiState.value
|
val initialState = viewModel.uiState.value
|
||||||
|
|
@ -159,146 +147,34 @@ class PaginatedReaderViewModelTest {
|
||||||
assertThat(fakePaginator.lastNavigatedChapter).isEqualTo(currentChapter)
|
assertThat(fakePaginator.lastNavigatedChapter).isEqualTo(currentChapter)
|
||||||
assertThat(fakePaginator.lastNavigatedHref).isEqualTo(href)
|
assertThat(fakePaginator.lastNavigatedHref).isEqualTo(href)
|
||||||
}
|
}
|
||||||
@Test
|
|
||||||
fun initialize_createsARealPaginatorAndUpdateState() = runTest {
|
|
||||||
// Arrange
|
|
||||||
val viewModel = PaginatedReaderViewModel() // Create a fresh ViewModel
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
val textMeasurer = mockk<TextMeasurer>(relaxed = true)
|
|
||||||
val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
|
|
||||||
val textStyle = TextStyle.Default
|
|
||||||
val density = Density(1f)
|
|
||||||
val mathMLRenderer = mockk<MathMLRenderer>(relaxed = true)
|
|
||||||
val testBook = EpubBook(
|
|
||||||
fileName = "test.epub",
|
|
||||||
title = "Test Book",
|
|
||||||
author = "Test Author",
|
|
||||||
language = "en",
|
|
||||||
coverImage = null,
|
|
||||||
chapters = listOf(
|
|
||||||
EpubChapter(
|
|
||||||
chapterId = "ch1",
|
|
||||||
title = "Chapter 1",
|
|
||||||
htmlFilePath = "ch1.html",
|
|
||||||
absPath = "/ops/ch1.html",
|
|
||||||
htmlContent = "<p>Some content</p>",
|
|
||||||
plainTextContent = "Some content"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
css = mapOf("/ops/style.css" to "p {color: red;}"),
|
|
||||||
extractionBasePath = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mock dependencies for BookPaginator
|
|
||||||
val mockDao = mockk<BookCacheDao>(relaxed = true)
|
|
||||||
coEvery { mockDao.getProcessedBook(any()) } returns null // Simulate cache miss
|
|
||||||
|
|
||||||
val mockDb = mockk<BookCacheDatabase>()
|
|
||||||
every { mockDb.bookCacheDao() } returns mockDao
|
|
||||||
|
|
||||||
mockkObject(BookCacheDatabase.Companion)
|
|
||||||
every { BookCacheDatabase.getDatabase(any()) } returns mockDb
|
|
||||||
|
|
||||||
mockkObject(BookProcessingWorker.Companion)
|
|
||||||
every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
|
|
||||||
|
|
||||||
// Pre-condition check
|
|
||||||
assertThat(viewModel.uiState.value.isLoading).isTrue()
|
|
||||||
assertThat(viewModel.paginator).isNull()
|
|
||||||
|
|
||||||
// Act
|
|
||||||
viewModel.initialize(
|
|
||||||
book = testBook,
|
|
||||||
textMeasurer = textMeasurer,
|
|
||||||
textConstraints = constraints,
|
|
||||||
textStyle = textStyle,
|
|
||||||
density = density,
|
|
||||||
isDarkTheme = false,
|
|
||||||
context = context,
|
|
||||||
initialChapterToPaginate = 0,
|
|
||||||
mathMLRenderer = mathMLRenderer
|
|
||||||
)
|
|
||||||
advanceUntilIdle() // Allow coroutines to complete
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertThat(viewModel.paginator).isInstanceOf(BookPaginator::class.java)
|
|
||||||
assertThat(viewModel.uiState.value.isLoading).isFalse()
|
|
||||||
assertThat(viewModel.uiState.value.totalPageCount).isGreaterThan(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun initialize_isIdempotent() = runTest {
|
fun initialize_whenPaginatorAlreadySet_keepsExistingPaginator() = runTest {
|
||||||
// Arrange
|
|
||||||
val viewModel = PaginatedReaderViewModel()
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
val textMeasurer = mockk<TextMeasurer>(relaxed = true)
|
val existingPaginator = viewModel.paginator
|
||||||
val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
|
|
||||||
val textStyle = TextStyle.Default
|
|
||||||
val density = Density(1f)
|
|
||||||
val mathMLRenderer = mockk<MathMLRenderer>(relaxed = true)
|
|
||||||
val testBook = EpubBook(
|
|
||||||
fileName = "test.epub",
|
|
||||||
title = "Test Book",
|
|
||||||
author = "Test Author",
|
|
||||||
language = "en",
|
|
||||||
coverImage = null,
|
|
||||||
chapters = listOf(
|
|
||||||
EpubChapter(
|
|
||||||
chapterId = "ch1",
|
|
||||||
title = "Chapter 1",
|
|
||||||
htmlFilePath = "ch1.html",
|
|
||||||
absPath = "/ops/ch1.html",
|
|
||||||
htmlContent = "<p>Some content</p>",
|
|
||||||
plainTextContent = "Some content"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
css = mapOf("/ops/style.css" to "p {color: red;}"),
|
|
||||||
extractionBasePath = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
val mockDao = mockk<BookCacheDao>(relaxed = true)
|
|
||||||
coEvery { mockDao.getProcessedBook(any()) } returns null
|
|
||||||
val mockDb = mockk<BookCacheDatabase>()
|
|
||||||
every { mockDb.bookCacheDao() } returns mockDao
|
|
||||||
mockkObject(BookCacheDatabase.Companion)
|
|
||||||
every { BookCacheDatabase.getDatabase(any()) } returns mockDb
|
|
||||||
mockkObject(BookProcessingWorker.Companion)
|
|
||||||
every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
|
|
||||||
|
|
||||||
// Act
|
|
||||||
viewModel.initialize(
|
viewModel.initialize(
|
||||||
book = testBook,
|
book = EpubBook(
|
||||||
textMeasurer = textMeasurer,
|
fileName = "test.epub",
|
||||||
textConstraints = constraints,
|
title = "Test Book",
|
||||||
textStyle = textStyle,
|
author = "Test Author",
|
||||||
density = density,
|
language = "en",
|
||||||
|
coverImage = null
|
||||||
|
),
|
||||||
|
textMeasurer = mockk<TextMeasurer>(relaxed = true),
|
||||||
|
textConstraints = Constraints(maxWidth = 1080, maxHeight = 1920),
|
||||||
|
textStyle = TextStyle.Default,
|
||||||
|
density = Density(1f),
|
||||||
isDarkTheme = false,
|
isDarkTheme = false,
|
||||||
|
themeBackgroundColor = Color.White,
|
||||||
|
themeTextColor = Color.Black,
|
||||||
context = context,
|
context = context,
|
||||||
initialChapterToPaginate = 0,
|
initialChapterToPaginate = 0,
|
||||||
mathMLRenderer = mathMLRenderer
|
mathMLRenderer = mockk<MathMLRenderer>(relaxed = true),
|
||||||
|
paragraphGapMultiplier = 1.0f
|
||||||
)
|
)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
val firstPaginator = viewModel.paginator
|
assertThat(viewModel.paginator).isSameInstanceAs(existingPaginator)
|
||||||
assertThat(firstPaginator).isNotNull()
|
|
||||||
|
|
||||||
// Act again
|
|
||||||
viewModel.initialize(
|
|
||||||
book = testBook,
|
|
||||||
textMeasurer = textMeasurer,
|
|
||||||
textConstraints = constraints,
|
|
||||||
textStyle = textStyle,
|
|
||||||
density = density,
|
|
||||||
isDarkTheme = false,
|
|
||||||
context = context,
|
|
||||||
initialChapterToPaginate = 0,
|
|
||||||
mathMLRenderer = mathMLRenderer
|
|
||||||
)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
val secondPaginator = viewModel.paginator
|
|
||||||
assertThat(secondPaginator).isSameInstanceAs(firstPaginator)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
// PaginatorTest.kt
|
// PaginatorTest.kt
|
||||||
package com.aryan.reader.paginatedreader
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.SdkSuppress
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
@ -42,14 +44,52 @@ class FakeSplittableMeasurementProvider(
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun split(block: TableBlock, availableHeight: Int): Pair<TableBlock, TableBlock>? = null
|
||||||
|
|
||||||
|
override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair<FlexContainerBlock, FlexContainerBlock>? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||||
class PaginatorTest {
|
class PaginatorTest {
|
||||||
|
|
||||||
private val testDensity = Density(density = 1f, fontScale = 1f)
|
private val testDensity = Density(density = 1f, fontScale = 1f)
|
||||||
private val pageHeight = 1000
|
private val pageHeight = 1000
|
||||||
|
|
||||||
|
private fun List<ContentBlock>.withoutMeasuredHeights(): List<ContentBlock> {
|
||||||
|
return map { it.withoutMeasuredHeight() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ContentBlock.withoutMeasuredHeight(): ContentBlock {
|
||||||
|
return when (this) {
|
||||||
|
is ParagraphBlock -> copy(expectedHeight = 0)
|
||||||
|
is ImageBlock -> copy(expectedHeight = 0)
|
||||||
|
is HeaderBlock -> copy(expectedHeight = 0)
|
||||||
|
is SpacerBlock -> copy(expectedHeight = 0)
|
||||||
|
is QuoteBlock -> copy(expectedHeight = 0)
|
||||||
|
is ListItemBlock -> copy(expectedHeight = 0)
|
||||||
|
is TableBlock -> copy(
|
||||||
|
rows = rows.map { row ->
|
||||||
|
row.map { cell ->
|
||||||
|
cell.copy(content = cell.content.withoutMeasuredHeights())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expectedHeight = 0
|
||||||
|
)
|
||||||
|
is MathBlock -> copy(expectedHeight = 0)
|
||||||
|
is WrappingContentBlock -> copy(
|
||||||
|
floatedImage = floatedImage.copy(expectedHeight = 0),
|
||||||
|
paragraphsToWrap = paragraphsToWrap.map { it.copy(expectedHeight = 0) },
|
||||||
|
expectedHeight = 0
|
||||||
|
)
|
||||||
|
is FlexContainerBlock -> copy(
|
||||||
|
children = children.withoutMeasuredHeights(),
|
||||||
|
expectedHeight = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun paginate_givenEmptyBlocks_createsZeroPages() = runTest {
|
fun paginate_givenEmptyBlocks_createsZeroPages() = runTest {
|
||||||
val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity)
|
val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity)
|
||||||
|
|
@ -85,8 +125,53 @@ class PaginatorTest {
|
||||||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||||
|
|
||||||
assertThat(pages).hasSize(2)
|
assertThat(pages).hasSize(2)
|
||||||
assertThat(pages[0].content).containsExactly(block1)
|
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
||||||
assertThat(pages[1].content).containsExactly(block2)
|
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun paginate_honorsBreakBeforePage() = runTest {
|
||||||
|
val block1 = ParagraphBlock(content = AnnotatedString("Before"), blockIndex = 0)
|
||||||
|
val block2 = ParagraphBlock(
|
||||||
|
content = AnnotatedString("After"),
|
||||||
|
style = BlockStyle(breakBefore = "page"),
|
||||||
|
blockIndex = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
val pages = paginate(
|
||||||
|
listOf(block1, block2),
|
||||||
|
pageHeight,
|
||||||
|
FakeSplittableMeasurementProvider(mapOf(block1 to 100, block2 to 100)),
|
||||||
|
testDensity
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(pages).hasSize(2)
|
||||||
|
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
||||||
|
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun paginate_breakInsideAvoidPreventsParagraphSplit() = runTest {
|
||||||
|
val block1 = ParagraphBlock(
|
||||||
|
content = AnnotatedString("Keep together"),
|
||||||
|
style = BlockStyle(breakInside = "avoid"),
|
||||||
|
blockIndex = 0
|
||||||
|
)
|
||||||
|
val part1 = block1.copy(content = AnnotatedString("Keep"))
|
||||||
|
val part2 = block1.copy(content = AnnotatedString("together"))
|
||||||
|
|
||||||
|
val pages = paginate(
|
||||||
|
listOf(block1),
|
||||||
|
pageHeight = 400,
|
||||||
|
measurementProvider = FakeSplittableMeasurementProvider(
|
||||||
|
heights = mapOf(block1 to 800, part1 to 300, part2 to 500),
|
||||||
|
splittableParagraphs = mapOf(block1 to (part1 to part2))
|
||||||
|
),
|
||||||
|
density = testDensity
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(pages).hasSize(1)
|
||||||
|
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -110,8 +195,8 @@ class PaginatorTest {
|
||||||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||||
|
|
||||||
assertThat(pages).hasSize(2)
|
assertThat(pages).hasSize(2)
|
||||||
assertThat(pages[0].content).containsExactly(block1, part1).inOrder()
|
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1, part1).inOrder()
|
||||||
assertThat(pages[1].content).containsExactly(part2)
|
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(part2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -136,8 +221,8 @@ class PaginatorTest {
|
||||||
val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity)
|
val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity)
|
||||||
|
|
||||||
assertThat(pages).hasSize(2)
|
assertThat(pages).hasSize(2)
|
||||||
assertThat(pages[0].content).containsExactly(splitWrapper)
|
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(splitWrapper)
|
||||||
assertThat(pages[1].content).containsExactly(para2)
|
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(para2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -158,8 +243,8 @@ class PaginatorTest {
|
||||||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||||
|
|
||||||
assertThat(pages).hasSize(2)
|
assertThat(pages).hasSize(2)
|
||||||
assertThat(pages[0].content).containsExactly(block1)
|
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
||||||
assertThat(pages[1].content).containsExactly(unsplittableBlock)
|
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(unsplittableBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -172,7 +257,7 @@ class PaginatorTest {
|
||||||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||||
|
|
||||||
assertThat(pages).hasSize(1)
|
assertThat(pages).hasSize(1)
|
||||||
assertThat(pages[0].content).containsExactly(oversizedBlock)
|
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(oversizedBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -248,8 +333,8 @@ class PaginatorTest {
|
||||||
|
|
||||||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||||
assertThat(pages).hasSize(2)
|
assertThat(pages).hasSize(2)
|
||||||
assertThat(pages[0].content).containsExactly(block1)
|
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
||||||
assertThat(pages[1].content).containsExactly(splittableBlock) // Was not split
|
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(splittableBlock) // Was not split
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -271,8 +356,7 @@ class PaginatorTest {
|
||||||
// Set page height so that a split is attempted.
|
// Set page height so that a split is attempted.
|
||||||
val pages = paginate(blocks, 150, measurementProvider, testDensity)
|
val pages = paginate(blocks, 150, measurementProvider, testDensity)
|
||||||
assertThat(pages).hasSize(1)
|
assertThat(pages).hasSize(1)
|
||||||
// The page should be empty because part1 was empty, and the original block was re-added
|
// Empty split heads are skipped so pagination keeps only the remaining content.
|
||||||
// to the remaining list. The next page then contains the full block.
|
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(part2)
|
||||||
assertThat(pages[0].content).containsExactly(part2)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ReaderLinkHitTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun urlAnnotationAtPositionIgnoresSameLineSpaceAfterLink() {
|
||||||
|
lateinit var text: AnnotatedString
|
||||||
|
var layoutResult: TextLayoutResult? = null
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
text = linkText("Open")
|
||||||
|
layoutResult = textMeasurer.measure(
|
||||||
|
text = text,
|
||||||
|
style = TextStyle(fontSize = 24.sp),
|
||||||
|
constraints = Constraints.fixedWidth(500)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
val layout = checkNotNull(layoutResult)
|
||||||
|
val firstBox = layout.getBoundingBox(0)
|
||||||
|
val lastBox = layout.getBoundingBox(text.length - 1)
|
||||||
|
val y = (firstBox.top + firstBox.bottom) / 2f
|
||||||
|
|
||||||
|
assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(firstBox.left + 1f, y)))
|
||||||
|
.isEqualTo(HREF)
|
||||||
|
assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(lastBox.right + 60f, y)))
|
||||||
|
.isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun urlAnnotationAtPositionAppliesTextStartOffsetForWrappedLineLayouts() {
|
||||||
|
lateinit var fullText: AnnotatedString
|
||||||
|
var lineLayoutResult: TextLayoutResult? = null
|
||||||
|
val prefix = "Before "
|
||||||
|
val label = "Open"
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
fullText = buildAnnotatedString {
|
||||||
|
append(prefix)
|
||||||
|
append(label)
|
||||||
|
addStringAnnotation("URL", HREF, prefix.length, prefix.length + label.length)
|
||||||
|
}
|
||||||
|
lineLayoutResult = textMeasurer.measure(
|
||||||
|
text = fullText.subSequence(prefix.length, fullText.length),
|
||||||
|
style = TextStyle(fontSize = 24.sp),
|
||||||
|
constraints = Constraints.fixedWidth(500)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
val layout = checkNotNull(lineLayoutResult)
|
||||||
|
val firstBox = layout.getBoundingBox(0)
|
||||||
|
val lastBox = layout.getBoundingBox(label.length - 1)
|
||||||
|
val y = (firstBox.top + firstBox.bottom) / 2f
|
||||||
|
|
||||||
|
assertThat(
|
||||||
|
fullText.readerUrlAnnotationAtPosition(
|
||||||
|
layout = layout,
|
||||||
|
position = Offset(firstBox.left + 1f, y),
|
||||||
|
textStartOffset = prefix.length
|
||||||
|
)
|
||||||
|
).isEqualTo(HREF)
|
||||||
|
assertThat(
|
||||||
|
fullText.readerUrlAnnotationAtPosition(
|
||||||
|
layout = layout,
|
||||||
|
position = Offset(lastBox.right + 60f, y),
|
||||||
|
textStartOffset = prefix.length
|
||||||
|
)
|
||||||
|
).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun linkText(label: String) = buildAnnotatedString {
|
||||||
|
append(label)
|
||||||
|
addStringAnnotation("URL", HREF, 0, label.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val HREF = "chapter.xhtml#target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.ui.test.assertIsSelected
|
||||||
import androidx.compose.ui.test.assertIsNotSelected
|
import androidx.compose.ui.test.assertIsNotSelected
|
||||||
import androidx.compose.ui.test.click
|
import androidx.compose.ui.test.click
|
||||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
|
|
@ -20,6 +21,8 @@ import androidx.test.core.app.ActivityScenario
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.aryan.reader.MainActivity
|
import com.aryan.reader.MainActivity
|
||||||
|
import com.aryan.reader.R
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
|
|
@ -38,6 +41,12 @@ class PdfAnnotationTest {
|
||||||
private var currentPdfFile: File? = null
|
private var currentPdfFile: File? = null
|
||||||
private var scenario: ActivityScenario<MainActivity>? = null
|
private var scenario: ActivityScenario<MainActivity>? = null
|
||||||
private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
|
private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
|
||||||
|
private fun text(resId: Int): String = context.getString(resId)
|
||||||
|
private fun dockTag(resId: Int): String = "DockItem_${text(resId)}"
|
||||||
|
|
||||||
|
private fun assertNoNodeWithTag(tag: String) {
|
||||||
|
assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
|
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
|
||||||
return Intent(context, MainActivity::class.java).apply {
|
return Intent(context, MainActivity::class.java).apply {
|
||||||
|
|
@ -55,7 +64,7 @@ class PdfAnnotationTest {
|
||||||
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
||||||
.edit().clear().commit()
|
.edit().clear().commit()
|
||||||
|
|
||||||
scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri))
|
scenario = ActivityScenario.launch<MainActivity>(createPdfViewIntent(context, samplePdfUri))
|
||||||
waitForDocumentLoad()
|
waitForDocumentLoad()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,11 +84,11 @@ class PdfAnnotationTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enterEditMode() {
|
private fun enterEditMode() {
|
||||||
composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode")
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode))
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
.performClick()
|
.performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
composeTestRule.onNodeWithContentDescription("Close Edit Mode").assertIsDisplayed()
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tapOutsidePopup() {
|
private fun tapOutsidePopup() {
|
||||||
|
|
@ -111,13 +120,13 @@ class PdfAnnotationTest {
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
// Verify Dock Items exist using new Tags
|
// Verify Dock Items exist using new Tags
|
||||||
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsDisplayed()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsDisplayed()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsDisplayed()
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode").assertIsDisplayed()
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode)).assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- TOOL LOGIC TESTS ---
|
// --- TOOL LOGIC TESTS ---
|
||||||
|
|
@ -127,38 +136,38 @@ class PdfAnnotationTest {
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
// 1. Select Highlighter
|
// 1. Select Highlighter
|
||||||
composeTestRule.onNodeWithTag("DockItem_Highlighter").performClick()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// 2. Verify selection state
|
// 2. Verify selection state
|
||||||
composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected()
|
||||||
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsNotSelected()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsNotSelected()
|
||||||
|
|
||||||
// 3. Exit Edit Mode
|
// 3. Exit Edit Mode
|
||||||
composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// 4. Re-enter Edit Mode
|
// 4. Re-enter Edit Mode
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
// 5. Verify Highlighter is STILL selected (Persistence)
|
// 5. Verify Highlighter is STILL selected (Persistence)
|
||||||
composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testEraserHasNoPopup() {
|
fun testEraserSettingsPopupOpensWhenAlreadySelected() {
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
// Select Eraser
|
// Select Eraser
|
||||||
composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsSelected()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsSelected()
|
||||||
|
|
||||||
// Click Eraser AGAIN (Should NOT open popup)
|
// Click Eraser again to open its settings popup.
|
||||||
composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
|
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SETTINGS POPUP TESTS ---
|
// --- SETTINGS POPUP TESTS ---
|
||||||
|
|
@ -169,7 +178,7 @@ class PdfAnnotationTest {
|
||||||
|
|
||||||
// 1. Pen is default. Click Pen ONCE to open Settings.
|
// 1. Pen is default. Click Pen ONCE to open Settings.
|
||||||
// (Clicking twice would toggle it off, which caused previous failures)
|
// (Clicking twice would toggle it off, which caused previous failures)
|
||||||
composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// 2. Verify Popup Displayed
|
// 2. Verify Popup Displayed
|
||||||
|
|
@ -186,7 +195,7 @@ class PdfAnnotationTest {
|
||||||
|
|
||||||
// 5. Dismiss Settings
|
// 5. Dismiss Settings
|
||||||
tapOutsidePopup()
|
tapOutsidePopup()
|
||||||
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
|
assertNoNodeWithTag("ToolSettingsPopup")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -194,7 +203,7 @@ class PdfAnnotationTest {
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
// Open Settings for Pen (Default selected, so one click opens settings)
|
// Open Settings for Pen (Default selected, so one click opens settings)
|
||||||
composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// Test Palette Click (Index 1)
|
// Test Palette Click (Index 1)
|
||||||
|
|
@ -208,7 +217,7 @@ class PdfAnnotationTest {
|
||||||
tapOutsidePopup()
|
tapOutsidePopup()
|
||||||
|
|
||||||
// Quick verification that settings didn't crash app
|
// Quick verification that settings didn't crash app
|
||||||
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- UNDO/REDO TESTS ---
|
// --- UNDO/REDO TESTS ---
|
||||||
|
|
@ -217,7 +226,7 @@ class PdfAnnotationTest {
|
||||||
fun testDrawingEnablesUndo() {
|
fun testDrawingEnablesUndo() {
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription("Undo")
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo))
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
.assertIsNotEnabled()
|
.assertIsNotEnabled()
|
||||||
|
|
||||||
|
|
@ -227,7 +236,7 @@ class PdfAnnotationTest {
|
||||||
}
|
}
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription("Undo").assertIsEnabled()
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo)).assertIsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -240,8 +249,8 @@ class PdfAnnotationTest {
|
||||||
}
|
}
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
val undoNode = composeTestRule.onNodeWithContentDescription("Undo")
|
val undoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo))
|
||||||
val redoNode = composeTestRule.onNodeWithContentDescription("Redo")
|
val redoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_redo))
|
||||||
|
|
||||||
undoNode.assertIsEnabled()
|
undoNode.assertIsEnabled()
|
||||||
redoNode.assertIsNotEnabled()
|
redoNode.assertIsNotEnabled()
|
||||||
|
|
@ -268,7 +277,7 @@ class PdfAnnotationTest {
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
// 1. Drag Dock to make it floating (using Pen icon as handle)
|
// 1. Drag Dock to make it floating (using Pen icon as handle)
|
||||||
composeTestRule.onNodeWithTag("DockItem_Pen").performTouchInput {
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performTouchInput {
|
||||||
down(center)
|
down(center)
|
||||||
advanceEventTime(600) // Long press
|
advanceEventTime(600) // Long press
|
||||||
// Drag UP significantly
|
// Drag UP significantly
|
||||||
|
|
@ -278,20 +287,20 @@ class PdfAnnotationTest {
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// 2. Minimize (Eye icon)
|
// 2. Minimize (Eye icon)
|
||||||
composeTestRule.onNodeWithContentDescription("Toggle Visibility").performClick()
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_visibility)).performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// 3. Verify Dock items are hidden
|
// 3. Verify Dock items are hidden
|
||||||
composeTestRule.onNodeWithTag("DockItem_Pen").assertDoesNotExist()
|
assertNoNodeWithTag(dockTag(R.string.content_desc_pen))
|
||||||
|
|
||||||
// 4. Verify "Show Dock" floating button is visible
|
// 4. Verify "Show Dock" floating button is visible
|
||||||
composeTestRule.onNodeWithContentDescription("Show Dock").assertIsDisplayed()
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).assertIsDisplayed()
|
||||||
|
|
||||||
// 5. Restore
|
// 5. Restore
|
||||||
composeTestRule.onNodeWithContentDescription("Show Dock").performClick()
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// 6. Verify Dock items return
|
// 6. Verify Dock items return
|
||||||
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
|
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7,25 +7,25 @@ import android.net.Uri
|
||||||
import androidx.compose.ui.test.assert
|
import androidx.compose.ui.test.assert
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertTextContains
|
import androidx.compose.ui.test.assertTextContains
|
||||||
import androidx.compose.ui.test.hasTestTag
|
|
||||||
import androidx.compose.ui.test.hasText
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.onRoot
|
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performTextInput
|
import androidx.compose.ui.test.performTextInput
|
||||||
import androidx.compose.ui.test.performTouchInput
|
|
||||||
import androidx.compose.ui.test.swipe
|
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.rules.ActivityScenarioRule
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.rule.GrantPermissionRule
|
import androidx.test.rule.GrantPermissionRule
|
||||||
import com.aryan.reader.MainActivity
|
import com.aryan.reader.MainActivity
|
||||||
|
import com.aryan.reader.R
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
@ -41,13 +41,33 @@ class PdfViewerScreenTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
|
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
|
||||||
@org.junit.Before
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
private var currentPdfFile: File? = null
|
||||||
|
private var scenario: ActivityScenario<MainActivity>? = null
|
||||||
|
|
||||||
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
||||||
.edit()
|
.edit()
|
||||||
.clear()
|
.clear()
|
||||||
.commit()
|
.commit()
|
||||||
|
|
||||||
|
val samplePdfUri = copyAssetToCache(context, "sample.pdf")
|
||||||
|
scenario = ActivityScenario.launch<MainActivity>(createPdfViewIntent(context, samplePdfUri))
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
scenario?.close()
|
||||||
|
currentPdfFile?.let {
|
||||||
|
if (it.exists()) it.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun text(resId: Int, vararg args: Any): String = context.getString(resId, *args)
|
||||||
|
|
||||||
|
private fun assertNoNodeWithTag(tag: String) {
|
||||||
|
assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
|
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
|
||||||
|
|
@ -58,50 +78,47 @@ class PdfViewerScreenTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
private fun waitForDocumentLoad(pageText: String = text(R.string.page_of_pages, 1, 4)) {
|
||||||
|
|
||||||
private var currentPdfFile: File? = null
|
|
||||||
|
|
||||||
private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val activityRule = ActivityScenarioRule<MainActivity>(createPdfViewIntent(context, samplePdfUri))
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
currentPdfFile?.let {
|
|
||||||
if (it.exists()) it.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun waitForDocumentLoad(pageText: String = "Page 1 of 4") {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 15_000) {
|
composeTestRule.waitUntil(timeoutMillis = 15_000) {
|
||||||
composeTestRule
|
composeTestRule
|
||||||
.onAllNodesWithText(pageText)
|
.onAllNodesWithText(pageText)
|
||||||
.fetchSemanticsNodes().size == 1
|
.fetchSemanticsNodes().isNotEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensurePaginationMode() {
|
private fun openMoreOptions() {
|
||||||
composeTestRule.onNodeWithContentDescription("More Options").performClick()
|
composeTestRule.onNodeWithContentDescription(text(R.string.tooltip_more_options)).performClick()
|
||||||
composeTestRule.onNodeWithText("Reading Mode: Paginated").performClick()
|
}
|
||||||
|
|
||||||
|
private fun openNavigationDrawer() {
|
||||||
|
composeTestRule.onNodeWithTag("TocButton").performClick()
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodesWithText(text(R.string.tab_chapters)).fetchSemanticsNodes().isNotEmpty() &&
|
||||||
|
composeTestRule.onAllNodesWithTag("BookmarksTab").fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectChaptersTabIfTabsPaneIsFirst() {
|
||||||
|
if (composeTestRule.onAllNodesWithTag("TabsTab").fetchSemanticsNodes().isNotEmpty()) {
|
||||||
|
composeTestRule.onNodeWithText(text(R.string.tab_chapters)).performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectBookmarksTab() {
|
||||||
|
composeTestRule.onNodeWithTag("BookmarksTab").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
private fun selectReadingMode(modeText: String) {
|
||||||
fun documentLoadsAndDisplaysCorrectPageCount() {
|
openMoreOptions()
|
||||||
waitForDocumentLoad()
|
composeTestRule.onNodeWithText(text(R.string.menu_change_reading_mode)).performClick()
|
||||||
composeTestRule.onNodeWithTag("PageNumberIndicator")
|
composeTestRule.onNodeWithText(modeText).performClick()
|
||||||
.assertIsDisplayed()
|
composeTestRule.waitForIdle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
private fun ensurePaginationMode() {
|
||||||
fun tableOfContents_displaysEmptyState() {
|
selectReadingMode(text(R.string.menu_reading_mode_paginated))
|
||||||
waitForDocumentLoad()
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("TocButton").performClick()
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithText("Chapters are not available for this book.").assertIsDisplayed()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("SameParameterValue")
|
@Suppress("SameParameterValue")
|
||||||
|
|
@ -125,189 +142,117 @@ class PdfViewerScreenTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun bookmarkFunctionality_addNavigateAndDelete() {
|
fun documentLoadsAndDisplaysCorrectPageCount() {
|
||||||
|
waitForDocumentLoad()
|
||||||
|
composeTestRule.onNodeWithTag("PageNumberIndicator")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tableOfContentsButton_handlesTabsPaneAndOpensChaptersTab() {
|
||||||
waitForDocumentLoad()
|
waitForDocumentLoad()
|
||||||
|
|
||||||
ensurePaginationMode()
|
openNavigationDrawer()
|
||||||
|
selectChaptersTabIfTabsPaneIsFirst()
|
||||||
|
|
||||||
composeTestRule.onNodeWithText("Page 1 of 4").assertIsDisplayed()
|
composeTestRule.onNodeWithText(text(R.string.tab_chapters)).assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("BookmarksTab").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
@Test
|
||||||
composeTestRule.onRoot().performTouchInput { swipe(start = this.centerRight, end = this.centerLeft, durationMillis = 300) }
|
fun bookmarkFunctionality_addAndDeleteCurrentPage() {
|
||||||
composeTestRule.onRoot().performClick()
|
waitForDocumentLoad()
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodesWithText("Page 2 of 4").fetchSemanticsNodes().isNotEmpty()
|
openMoreOptions()
|
||||||
}
|
composeTestRule.onNodeWithText(text(R.string.menu_bookmark_this_page)).performClick()
|
||||||
composeTestRule.onNodeWithText("Page 2 of 4").assertIsDisplayed()
|
composeTestRule.waitForIdle()
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
openNavigationDrawer()
|
||||||
|
selectBookmarksTab()
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodesWithTag("BookmarkItem_0").fetchSemanticsNodes().isNotEmpty()
|
||||||
}
|
}
|
||||||
|
composeTestRule.onNodeWithTag("BookmarkItem_0").assertIsDisplayed()
|
||||||
|
.assert(hasText(text(R.string.pdf_page_short, 1), substring = true))
|
||||||
|
|
||||||
try {
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_more_options_bookmark)).performClick()
|
||||||
composeTestRule.onNodeWithContentDescription("More Options").performClick()
|
composeTestRule.onNodeWithText(text(R.string.action_delete)).performClick()
|
||||||
composeTestRule.onNodeWithText("Bookmark this page").performClick()
|
composeTestRule.waitForIdle()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.onNodeWithText(text(R.string.action_delete), useUnmergedTree = true).performClick()
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
composeTestRule.waitUntil(5_000) {
|
||||||
}
|
composeTestRule.onAllNodesWithTag("BookmarkItem_0").fetchSemanticsNodes().isEmpty()
|
||||||
|
|
||||||
try {
|
|
||||||
composeTestRule.onRoot().performTouchInput { swipe(start = this.centerRight, end = this.centerLeft, durationMillis = 300) }
|
|
||||||
composeTestRule.onRoot().performClick()
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodesWithText("Page 3 of 4").fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
composeTestRule.onNodeWithText("Page 3 of 4").assertIsDisplayed()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
composeTestRule.onNodeWithTag("TocButton").performClick()
|
|
||||||
composeTestRule.onNodeWithTag("BookmarksTab").performClick()
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
composeTestRule.onNodeWithTag("BookmarkItem_1").assertIsDisplayed()
|
|
||||||
.assert(hasText("Page 2", substring = true))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
composeTestRule.onNodeWithTag("BookmarkItem_1").performClick()
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodes(hasTestTag("PageNumberIndicator").and(hasText("Page 2 of 4"))).fetchSemanticsNodes().size == 1
|
|
||||||
}
|
|
||||||
composeTestRule.onNode(hasTestTag("PageNumberIndicator").and(hasText("Page 2 of 4"))).assertIsDisplayed()
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
composeTestRule.onNodeWithTag("TocButton").performClick()
|
|
||||||
composeTestRule.onNodeWithTag("BookmarksTab").performClick()
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
composeTestRule.onNodeWithContentDescription("More options for bookmark").performClick()
|
|
||||||
composeTestRule.onNodeWithText("Delete").performClick()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
composeTestRule.onNodeWithText("Delete", useUnmergedTree = true).performClick()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
composeTestRule.onNodeWithTag("BookmarkItem_1").assertDoesNotExist()
|
|
||||||
composeTestRule.onNodeWithText("You haven't added any bookmarks yet.").assertIsDisplayed()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
|
assertNoNodeWithTag("BookmarkItem_0")
|
||||||
|
composeTestRule.onNodeWithText(text(R.string.no_bookmarks_yet)).assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sliderNavigation_opensAndDisplaysCorrectly() {
|
fun sliderNavigation_opensAndDisplaysCorrectly() {
|
||||||
waitForDocumentLoad()
|
waitForDocumentLoad()
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription("Navigate with slider").performClick()
|
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_navigate_slider)).performClick()
|
||||||
composeTestRule.onNodeWithContentDescription("Exit slider navigation").assertIsDisplayed()
|
|
||||||
composeTestRule.onNodeWithText("1 / 4").assertIsDisplayed()
|
composeTestRule.onNodeWithText(text(R.string.page_format, 1, 4)).assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun displayMode_switchesToVerticalScroll() {
|
fun displayMode_switchesToVerticalScroll() {
|
||||||
waitForDocumentLoad()
|
waitForDocumentLoad()
|
||||||
|
|
||||||
// Ensure we are in Pagination mode first to test the switch
|
|
||||||
ensurePaginationMode()
|
ensurePaginationMode()
|
||||||
|
|
||||||
// Verify Vertical Scroll component is NOT displayed initially
|
assertNoNodeWithTag("PdfVerticalScroll")
|
||||||
composeTestRule.onNodeWithTag("PdfVerticalScroll").assertDoesNotExist()
|
|
||||||
|
|
||||||
// Switch to Vertical Scroll
|
selectReadingMode(text(R.string.menu_reading_mode_vertical))
|
||||||
composeTestRule.onNodeWithContentDescription("More Options").performClick()
|
|
||||||
composeTestRule.onNodeWithText("Reading Mode: Vertical scroll").performClick()
|
|
||||||
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
|
|
||||||
// Verify Vertical Scroll component IS displayed
|
|
||||||
composeTestRule.onNodeWithTag("PdfVerticalScroll").assertIsDisplayed()
|
composeTestRule.onNodeWithTag("PdfVerticalScroll").assertIsDisplayed()
|
||||||
|
|
||||||
// Switch back to Pagination
|
|
||||||
ensurePaginationMode()
|
ensurePaginationMode()
|
||||||
|
|
||||||
// Verify Vertical Scroll component is gone
|
assertNoNodeWithTag("PdfVerticalScroll")
|
||||||
composeTestRule.onNodeWithTag("PdfVerticalScroll").assertDoesNotExist()
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun displayModeSelectionPersistsReaderPreference() {
|
||||||
|
waitForDocumentLoad()
|
||||||
|
|
||||||
|
selectReadingMode(text(R.string.menu_reading_mode_paginated))
|
||||||
|
waitForDisplayModePreference(DisplayMode.PAGINATION)
|
||||||
|
|
||||||
|
selectReadingMode(text(R.string.menu_reading_mode_vertical))
|
||||||
|
waitForDisplayModePreference(DisplayMode.VERTICAL_SCROLL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun search_uiOpensAndAcceptsQuery() {
|
fun search_uiOpensAndAcceptsQuery() {
|
||||||
waitForDocumentLoad()
|
waitForDocumentLoad()
|
||||||
|
|
||||||
// Click search button
|
|
||||||
composeTestRule.onNodeWithTag("SearchButton").performClick()
|
composeTestRule.onNodeWithTag("SearchButton").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
composeTestRule.onNodeWithText("English, Spanish, French, etc.").performClick()
|
val ocrLanguageText = text(R.string.ocr_language_latin)
|
||||||
|
if (composeTestRule.onAllNodesWithText(ocrLanguageText).fetchSemanticsNodes().isNotEmpty()) {
|
||||||
|
composeTestRule.onNodeWithText(ocrLanguageText).performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
// Verify text field appears
|
|
||||||
composeTestRule.onNodeWithTag("SearchTextField").assertIsDisplayed()
|
composeTestRule.onNodeWithTag("SearchTextField").assertIsDisplayed()
|
||||||
|
|
||||||
// Enter text
|
|
||||||
composeTestRule.onNodeWithTag("SearchTextField").performTextInput("test query")
|
composeTestRule.onNodeWithTag("SearchTextField").performTextInput("test query")
|
||||||
|
|
||||||
// Verify text exists in the field
|
|
||||||
composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("test query")
|
composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("test query")
|
||||||
|
|
||||||
// Close search
|
composeTestRule.onNodeWithContentDescription(text(R.string.tooltip_close_search)).performClick()
|
||||||
composeTestRule.onNodeWithContentDescription("Close Search").performClick()
|
|
||||||
|
|
||||||
// Verify text field is gone
|
assertNoNodeWithTag("SearchTextField")
|
||||||
composeTestRule.onNodeWithTag("SearchTextField").assertDoesNotExist()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
private fun waitForDisplayModePreference(expected: DisplayMode) {
|
||||||
fun fullScreen_togglesVisibility() {
|
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
||||||
waitForDocumentLoad()
|
context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
.getString(DISPLAY_MODE_KEY, DisplayMode.VERTICAL_SCROLL.name) == expected.name
|
||||||
// Click enter full screen button
|
}
|
||||||
composeTestRule.onNodeWithContentDescription("Enter Full Screen").performClick()
|
|
||||||
|
|
||||||
// Verify exit full screen button appears
|
|
||||||
composeTestRule.onNodeWithContentDescription("Exit Full Screen").assertIsDisplayed()
|
|
||||||
|
|
||||||
// Click exit full screen
|
|
||||||
composeTestRule.onNodeWithContentDescription("Exit Full Screen").performClick()
|
|
||||||
|
|
||||||
// Verify exit button is gone and enter button returns
|
|
||||||
composeTestRule.onNodeWithContentDescription("Exit Full Screen").assertDoesNotExist()
|
|
||||||
composeTestRule.onNodeWithContentDescription("Enter Full Screen").assertIsDisplayed()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun darkMode_togglesState() {
|
|
||||||
waitForDocumentLoad()
|
|
||||||
|
|
||||||
// Initial state: Light mode (default from cleared prefs), so button says "Enable Dark Mode"
|
|
||||||
composeTestRule.onNodeWithContentDescription("Enable Dark Mode").assertIsDisplayed()
|
|
||||||
|
|
||||||
// Toggle On
|
|
||||||
composeTestRule.onNodeWithContentDescription("Enable Dark Mode").performClick()
|
|
||||||
|
|
||||||
// State changed: Now button says "Disable Dark Mode"
|
|
||||||
composeTestRule.onNodeWithContentDescription("Disable Dark Mode").assertIsDisplayed()
|
|
||||||
|
|
||||||
// Toggle Off
|
|
||||||
composeTestRule.onNodeWithContentDescription("Disable Dark Mode").performClick()
|
|
||||||
|
|
||||||
// State changed back
|
|
||||||
composeTestRule.onNodeWithContentDescription("Enable Dark Mode").assertIsDisplayed()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -31,7 +31,7 @@ class EpubTestActivity : ComponentActivity() {
|
||||||
coverImagePath = null,
|
coverImagePath = null,
|
||||||
onRenderModeChange = {},
|
onRenderModeChange = {},
|
||||||
customFonts = TODO(),
|
customFonts = TODO(),
|
||||||
onImportFont = TODO(), viewModel = TODO()
|
onImportFonts = TODO(), viewModel = TODO()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,19 @@
|
||||||
<data android:mimeType="application/x-cb7" />
|
<data android:mimeType="application/x-cb7" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- CBT known MIME types -->
|
||||||
|
<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/x-cbt" />
|
||||||
|
<data android:mimeType="application/vnd.comicbook+tar" />
|
||||||
|
<data android:mimeType="application/x-tar" />
|
||||||
|
<data android:mimeType="application/tar" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- DOCX -->
|
<!-- DOCX -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
|
||||||
|
|
@ -2924,6 +2924,29 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const HL_LOG_TAG = "HIGHLIGHT_DEBUG";
|
const HL_LOG_TAG = "HIGHLIGHT_DEBUG";
|
||||||
|
const HL_RENDER_LOG_TAG = "AndroidHighlightRenderDiag";
|
||||||
|
|
||||||
|
function hlRenderPreview(value, maxLength) {
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
var text = String(value).replace(/\s+/g, " ");
|
||||||
|
var max = maxLength || 100;
|
||||||
|
return text.length > max ? text.substring(0, max) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hlRenderLocatorLabel(locator) {
|
||||||
|
locator = locator || {};
|
||||||
|
return "locatorChapter=" + (locator.chapterIndex === undefined || locator.chapterIndex === null ? "null" : locator.chapterIndex) +
|
||||||
|
" locatorPage=" + (locator.pageIndex === undefined || locator.pageIndex === null ? "null" : locator.pageIndex) +
|
||||||
|
" locatorOffsets=" + (locator.startOffset === undefined || locator.startOffset === null ? "null" : locator.startOffset) +
|
||||||
|
".." + (locator.endOffset === undefined || locator.endOffset === null ? "null" : locator.endOffset) +
|
||||||
|
" locatorBlock=" + (locator.blockIndex === undefined || locator.blockIndex === null ? "null" : locator.blockIndex) +
|
||||||
|
" locatorChar=" + (locator.charOffset === undefined || locator.charOffset === null ? "null" : locator.charOffset) +
|
||||||
|
" locatorCfi=" + hlRenderPreview(locator.cfi || "", 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hlRenderLog(message) {
|
||||||
|
console.log(HL_RENDER_LOG_TAG + ": " + message);
|
||||||
|
}
|
||||||
|
|
||||||
window.HighlightBridgeHelper = {
|
window.HighlightBridgeHelper = {
|
||||||
updateHighlightStyle: function (cfi, newColorClass, colorId) {
|
updateHighlightStyle: function (cfi, newColorClass, colorId) {
|
||||||
|
|
@ -3280,11 +3303,13 @@
|
||||||
try {
|
try {
|
||||||
var highlights = JSON.parse(jsonArrayString);
|
var highlights = JSON.parse(jsonArrayString);
|
||||||
var self = this;
|
var self = this;
|
||||||
|
hlRenderLog("webview_restore_start count=" + highlights.length);
|
||||||
|
|
||||||
highlights.forEach(function (h) {
|
highlights.forEach(function (h) {
|
||||||
self.applyHighlight(h.cfi, h.text, h.cssClass);
|
self.applyHighlightObject(h);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
hlRenderLog("webview_restore_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
|
||||||
console.log(
|
console.log(
|
||||||
`$ {
|
`$ {
|
||||||
HL_LOG_TAG
|
HL_LOG_TAG
|
||||||
|
|
@ -3295,136 +3320,376 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
applyHighlight: function (cfi, text, cssClass) {
|
applyHighlightObject: function (highlight) {
|
||||||
|
if (!highlight) return;
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_apply_object id=" + (highlight.id || "") +
|
||||||
|
" cfi=" + hlRenderPreview(highlight.cfi || "", 120) +
|
||||||
|
" textLen=" + String(highlight.text || "").length +
|
||||||
|
" text='" + hlRenderPreview(highlight.text || "", 80) + "' " +
|
||||||
|
hlRenderLocatorLabel(highlight.locator || {})
|
||||||
|
);
|
||||||
|
this.applyHighlight(highlight.cfi, highlight.text, highlight.cssClass, highlight.locator || null);
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightTextRoot: function () {
|
||||||
|
return document.getElementById("content-container") || document.body;
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightTextNodes: function (root) {
|
||||||
|
var nodes = [];
|
||||||
|
if (!root) return nodes;
|
||||||
|
var walker = document.createTreeWalker(
|
||||||
|
root,
|
||||||
|
NodeFilter.SHOW_TEXT,
|
||||||
|
{
|
||||||
|
acceptNode: function (node) {
|
||||||
|
if (!node || !node.nodeValue) return NodeFilter.FILTER_REJECT;
|
||||||
|
var parent = node.parentElement;
|
||||||
|
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||||
|
if (parent.closest && parent.closest("script, style, noscript")) return NodeFilter.FILTER_REJECT;
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||||
|
return nodes;
|
||||||
|
},
|
||||||
|
|
||||||
|
rangeFromTextOffsets: function (root, startOffset, endOffset) {
|
||||||
|
var start = parseInt(startOffset, 10);
|
||||||
|
var end = parseInt(endOffset, 10);
|
||||||
|
if (!root || !isFinite(start) || !isFinite(end) || end <= start) {
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_range_offsets_skip reason=invalid_offsets start=" + startOffset +
|
||||||
|
" end=" + endOffset + " hasRoot=" + !!root
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodes = this.highlightTextNodes(root);
|
||||||
|
var cursor = 0;
|
||||||
|
var startNode = null;
|
||||||
|
var startInNode = 0;
|
||||||
|
var endNode = null;
|
||||||
|
var endInNode = 0;
|
||||||
|
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
var value = nodes[i].nodeValue || "";
|
||||||
|
var next = cursor + value.length;
|
||||||
|
if (!startNode && start >= cursor && start <= next) {
|
||||||
|
startNode = nodes[i];
|
||||||
|
startInNode = Math.max(0, Math.min(value.length, start - cursor));
|
||||||
|
}
|
||||||
|
if (startNode && end >= cursor && end <= next) {
|
||||||
|
endNode = nodes[i];
|
||||||
|
endInNode = Math.max(0, Math.min(value.length, end - cursor));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startNode || !endNode) {
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_range_offsets_skip reason=missing_boundary start=" + start +
|
||||||
|
" end=" + end + " nodes=" + nodes.length + " textCursor=" + cursor
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var range = document.createRange();
|
||||||
|
range.setStart(startNode, startInNode);
|
||||||
|
range.setEnd(endNode, endInNode);
|
||||||
|
if (range.collapsed) {
|
||||||
|
hlRenderLog("webview_range_offsets_skip reason=collapsed start=" + start + " end=" + end);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_range_offsets_result start=" + start + " end=" + end +
|
||||||
|
" text='" + hlRenderPreview(range.toString(), 80) + "'"
|
||||||
|
);
|
||||||
|
return range;
|
||||||
|
},
|
||||||
|
|
||||||
|
rangeMatchesText: function (range, text) {
|
||||||
|
if (!range || !text) return true;
|
||||||
|
var actual = (range.toString() || "").trim();
|
||||||
|
var expected = String(text || "").trim();
|
||||||
|
if (!expected) return true;
|
||||||
|
return actual === expected || actual.replace(/\s+/g, " ") === expected.replace(/\s+/g, " ");
|
||||||
|
},
|
||||||
|
|
||||||
|
rangeFromVisibleTextSearch: function (text, locator) {
|
||||||
|
if (!text) {
|
||||||
|
hlRenderLog("webview_text_search_skip reason=empty_text " + hlRenderLocatorLabel(locator));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var root = this.highlightTextRoot();
|
||||||
|
var nodes = this.highlightTextNodes(root);
|
||||||
|
if (!nodes.length) {
|
||||||
|
hlRenderLog("webview_text_search_skip reason=no_nodes " + hlRenderLocatorLabel(locator));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullText = nodes.map(function (node) { return node.nodeValue || ""; }).join("");
|
||||||
|
var expected = String(text);
|
||||||
|
var candidates = [];
|
||||||
|
var index = fullText.indexOf(expected);
|
||||||
|
while (index !== -1) {
|
||||||
|
candidates.push(index);
|
||||||
|
index = fullText.indexOf(expected, index + 1);
|
||||||
|
}
|
||||||
|
if (!candidates.length) {
|
||||||
|
var lowerFull = fullText.toLowerCase();
|
||||||
|
var lowerExpected = expected.toLowerCase();
|
||||||
|
index = lowerFull.indexOf(lowerExpected);
|
||||||
|
while (index !== -1) {
|
||||||
|
candidates.push(index);
|
||||||
|
index = lowerFull.indexOf(lowerExpected, index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!candidates.length && expected.length > 20) {
|
||||||
|
var partial = expected.substring(0, Math.min(expected.length, 40));
|
||||||
|
index = fullText.indexOf(partial);
|
||||||
|
while (index !== -1) {
|
||||||
|
candidates.push(index);
|
||||||
|
index = fullText.indexOf(partial, index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!candidates.length) {
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_text_search_skip reason=no_candidates textLen=" + expected.length +
|
||||||
|
" text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator)
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var preferred = locator && locator.startOffset !== undefined && locator.startOffset !== null
|
||||||
|
? parseInt(locator.startOffset, 10)
|
||||||
|
: candidates[0];
|
||||||
|
if (!isFinite(preferred)) preferred = candidates[0];
|
||||||
|
var best = candidates.reduce(function (currentBest, candidate) {
|
||||||
|
return Math.abs(candidate - preferred) < Math.abs(currentBest - preferred) ? candidate : currentBest;
|
||||||
|
}, candidates[0]);
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_text_search_candidate count=" + candidates.length +
|
||||||
|
" preferred=" + preferred + " best=" + best +
|
||||||
|
" text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator)
|
||||||
|
);
|
||||||
|
return this.rangeFromTextOffsets(root, best, best + expected.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
rangeFromLocator: function (locator, text) {
|
||||||
|
if (!locator) {
|
||||||
|
hlRenderLog("webview_locator_skip reason=missing_locator");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (locator.cfi && String(locator.cfi).charAt(0) === "/") {
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_locator_skip reason=structural_cfi_prefers_cfi " +
|
||||||
|
hlRenderLocatorLabel(locator)
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var root = this.highlightTextRoot();
|
||||||
|
var range = this.rangeFromTextOffsets(root, locator.startOffset, locator.endOffset);
|
||||||
|
if (range && this.rangeMatchesText(range, text)) {
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_locator_result matched=true text='" + hlRenderPreview(range.toString(), 80) + "' " +
|
||||||
|
hlRenderLocatorLabel(locator)
|
||||||
|
);
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_locator_skip reason=" + (range ? "text_mismatch" : "range_missing") +
|
||||||
|
" rangeText='" + hlRenderPreview(range ? range.toString() : "", 80) + "' " +
|
||||||
|
"expected='" + hlRenderPreview(text || "", 80) + "' " + hlRenderLocatorLabel(locator)
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
rangeFromCfi: function (cfi, text) {
|
||||||
|
if (!cfi || cfi.indexOf("desktop:") === 0) {
|
||||||
|
hlRenderLog("webview_cfi_skip reason=unsupported cfi=" + hlRenderPreview(cfi || "", 120));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var sourceCfi = String(cfi).split("|")[0];
|
||||||
|
const location = window.getNodeAndOffsetFromCfi(sourceCfi);
|
||||||
|
if (!location || !location.node) {
|
||||||
|
hlRenderLog("webview_cfi_skip reason=missing_location cfi=" + hlRenderPreview(cfi, 120));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let startNode = location.node;
|
||||||
|
let startOffset = location.offset;
|
||||||
|
|
||||||
|
if (startNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||||
|
walker.currentNode = startNode;
|
||||||
|
|
||||||
|
while (startNode && startOffset >= startNode.nodeValue.length) {
|
||||||
|
if (startOffset === startNode.nodeValue.length) {
|
||||||
|
const next = walker.nextNode();
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
startOffset -= startNode.nodeValue.length;
|
||||||
|
startNode = next;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
startOffset -= startNode.nodeValue.length;
|
||||||
|
startNode = walker.nextNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const nodeVal = startNode.nodeValue;
|
||||||
|
const substring = nodeVal.substring(startOffset, startOffset + text.length);
|
||||||
|
|
||||||
|
if (substring !== text && substring.trim() !== text.trim()) {
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_cfi_text_mismatch cfi=" + hlRenderPreview(cfi, 120) +
|
||||||
|
" startOffset=" + startOffset +
|
||||||
|
" actual='" + hlRenderPreview(substring, 80) + "'" +
|
||||||
|
" expected='" + hlRenderPreview(text, 80) + "'"
|
||||||
|
);
|
||||||
|
console.log("HIGHLIGHT_DEBUG: Text mismatch at CFI. Searching nearby.");
|
||||||
|
const foundIndex = nodeVal.indexOf(text);
|
||||||
|
|
||||||
|
if (foundIndex !== -1) {
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_cfi_text_adjust reason=full_match oldStartOffset=" + startOffset +
|
||||||
|
" newStartOffset=" + foundIndex + " cfi=" + hlRenderPreview(cfi, 120)
|
||||||
|
);
|
||||||
|
startOffset = foundIndex;
|
||||||
|
} else {
|
||||||
|
const partial = text.substring(0, Math.min(text.length, 20));
|
||||||
|
const partialIndex = nodeVal.indexOf(partial);
|
||||||
|
|
||||||
|
if (partialIndex !== -1) {
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_cfi_text_adjust reason=partial_match oldStartOffset=" + startOffset +
|
||||||
|
" newStartOffset=" + partialIndex + " cfi=" + hlRenderPreview(cfi, 120)
|
||||||
|
);
|
||||||
|
startOffset = partialIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startNode) {
|
||||||
|
hlRenderLog("webview_cfi_skip reason=missing_start_node cfi=" + hlRenderPreview(cfi, 120));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
|
||||||
|
if (startNode.nodeType === Node.TEXT_NODE && startOffset > startNode.nodeValue.length) {
|
||||||
|
startOffset = Math.max(0, startNode.nodeValue.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
range.setStart(startNode, startOffset);
|
||||||
|
|
||||||
|
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||||
|
treeWalker.currentNode = startNode;
|
||||||
|
|
||||||
|
let remainingLen = text.length;
|
||||||
|
let endNode = treeWalker.currentNode;
|
||||||
|
let endOffset = startOffset;
|
||||||
|
|
||||||
|
while (remainingLen > 0 && endNode) {
|
||||||
|
let avail = endNode.nodeValue.length - endOffset;
|
||||||
|
|
||||||
|
if (avail >= remainingLen) {
|
||||||
|
endOffset += remainingLen;
|
||||||
|
remainingLen = 0;
|
||||||
|
} else {
|
||||||
|
remainingLen -= avail;
|
||||||
|
endNode = treeWalker.nextNode();
|
||||||
|
endOffset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endNode) {
|
||||||
|
hlRenderLog("webview_cfi_skip reason=missing_end_node cfi=" + hlRenderPreview(cfi, 120));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
range.setEnd(endNode, endOffset);
|
||||||
|
if (range.collapsed) {
|
||||||
|
hlRenderLog("webview_cfi_skip reason=collapsed cfi=" + hlRenderPreview(cfi, 120));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_cfi_result cfi=" + hlRenderPreview(cfi, 120) +
|
||||||
|
" text='" + hlRenderPreview(range.toString(), 80) + "'"
|
||||||
|
);
|
||||||
|
return range;
|
||||||
|
},
|
||||||
|
|
||||||
|
applyHighlight: function (cfi, text, cssClass, locator) {
|
||||||
try {
|
try {
|
||||||
|
cssClass = cssClass || "user-highlight-yellow";
|
||||||
var alreadyApplied = false;
|
var alreadyApplied = false;
|
||||||
var spans = document.querySelectorAll(`span[data-cfi]`);
|
var spans = document.querySelectorAll(`span[data-cfi]`);
|
||||||
for (var i = 0; i < spans.length; i++) {
|
for (var i = 0; i < spans.length; i++) {
|
||||||
if ((spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) {
|
if (cfi && (spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) {
|
||||||
alreadyApplied = true;
|
alreadyApplied = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (alreadyApplied) return;
|
if (alreadyApplied) {
|
||||||
|
hlRenderLog(
|
||||||
const location = window.getNodeAndOffsetFromCfi(cfi);
|
"webview_apply_skip reason=already_applied cfi=" + hlRenderPreview(cfi || "", 120) +
|
||||||
if (!location || !location.node) return;
|
" textLen=" + String(text || "").length + " " + hlRenderLocatorLabel(locator)
|
||||||
|
);
|
||||||
let startNode = location.node;
|
return;
|
||||||
let startOffset = location.offset;
|
|
||||||
|
|
||||||
if (startNode.nodeType === Node.TEXT_NODE) {
|
|
||||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
|
||||||
walker.currentNode = startNode;
|
|
||||||
|
|
||||||
while (startNode && startOffset >= startNode.nodeValue.length) {
|
|
||||||
if (startOffset === startNode.nodeValue.length) {
|
|
||||||
const next = walker.nextNode();
|
|
||||||
|
|
||||||
if (next) {
|
|
||||||
startOffset -= startNode.nodeValue.length;
|
|
||||||
startNode = next;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
startOffset -= startNode.nodeValue.length;
|
|
||||||
startNode = walker.nextNode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Text Verification / Healing
|
var hasPreciseLocator = locator &&
|
||||||
if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) {
|
locator.startOffset !== undefined && locator.startOffset !== null &&
|
||||||
const nodeVal = startNode.nodeValue;
|
locator.endOffset !== undefined && locator.endOffset !== null &&
|
||||||
// Check if text matches at exact offset
|
parseInt(locator.endOffset, 10) > parseInt(locator.startOffset, 10);
|
||||||
const substring = nodeVal.substring(startOffset, startOffset + text.length);
|
hlRenderLog(
|
||||||
|
"webview_apply_start cfi=" + hlRenderPreview(cfi || "", 120) +
|
||||||
// Allow for some whitespace looseness (trim comparison)
|
" textLen=" + String(text || "").length +
|
||||||
if (substring !== text && substring.trim() !== text.trim()) {
|
" cssClass=" + cssClass +
|
||||||
console.log(`$ {
|
" hasPreciseLocator=" + !!hasPreciseLocator + " " +
|
||||||
HL_LOG_TAG
|
hlRenderLocatorLabel(locator)
|
||||||
}
|
);
|
||||||
|
var rangeSource = "locator";
|
||||||
: Text mismatch at CFI. Searching nearby... Expected: '${text.substring(0, 10)}...', Found: '${substring.substring(0, 10)}...' `);
|
var sourceCfi = (locator && locator.cfi) || cfi;
|
||||||
|
var hasSourceCfi = sourceCfi && String(sourceCfi).charAt(0) === "/";
|
||||||
// Try finding the text in the whole node
|
var range = hasSourceCfi ? null : this.rangeFromLocator(locator, text);
|
||||||
const foundIndex = nodeVal.indexOf(text);
|
if (!range && hasSourceCfi) {
|
||||||
|
rangeSource = "cfi";
|
||||||
if (foundIndex !== -1) {
|
range = this.rangeFromCfi(sourceCfi, text || "");
|
||||||
console.log(`$ {
|
|
||||||
HL_LOG_TAG
|
|
||||||
}
|
|
||||||
|
|
||||||
: Found text elsewhere in node. Adjusting offset from $ {
|
|
||||||
startOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
to $ {
|
|
||||||
foundIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
.`);
|
|
||||||
startOffset = foundIndex;
|
|
||||||
} else {
|
|
||||||
// Simple fuzzy: Try finding first 20 chars
|
|
||||||
const partial = text.substring(0, Math.min(text.length, 20));
|
|
||||||
const partialIndex = nodeVal.indexOf(partial);
|
|
||||||
|
|
||||||
if (partialIndex !== -1) {
|
|
||||||
console.log(`$ {
|
|
||||||
HL_LOG_TAG
|
|
||||||
}
|
|
||||||
|
|
||||||
: Found partial match. Adjusting offset.`);
|
|
||||||
startOffset = partialIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!range) {
|
||||||
if (!startNode) return;
|
rangeSource = "locator";
|
||||||
|
range = this.rangeFromLocator(locator, text);
|
||||||
const range = document.createRange();
|
|
||||||
|
|
||||||
// Set Start
|
|
||||||
if (startNode.nodeType === Node.TEXT_NODE) {
|
|
||||||
// Ensure offset is valid
|
|
||||||
if (startOffset > startNode.nodeValue.length) {
|
|
||||||
startOffset = Math.max(0, startNode.nodeValue.length - 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!range && !hasPreciseLocator && !hasSourceCfi) {
|
||||||
range.setStart(startNode, startOffset);
|
rangeSource = "text_search";
|
||||||
|
range = this.rangeFromVisibleTextSearch(text || "", locator);
|
||||||
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
|
||||||
treeWalker.currentNode = startNode;
|
|
||||||
|
|
||||||
let currentNode = treeWalker.currentNode;
|
|
||||||
let remainingOffset = startOffset;
|
|
||||||
let remainingLen = text.length;
|
|
||||||
let endNode = currentNode;
|
|
||||||
let endOffset = startOffset;
|
|
||||||
|
|
||||||
while (remainingLen > 0 && endNode) {
|
|
||||||
let avail = endNode.nodeValue.length - endOffset;
|
|
||||||
|
|
||||||
if (avail >= remainingLen) {
|
|
||||||
endOffset += remainingLen;
|
|
||||||
remainingLen = 0;
|
|
||||||
} else {
|
|
||||||
remainingLen -= avail;
|
|
||||||
endNode = treeWalker.nextNode();
|
|
||||||
endOffset = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!range) {
|
||||||
if (endNode) {
|
hlRenderLog(
|
||||||
range.setEnd(endNode, endOffset);
|
"webview_apply_skip reason=no_range cfi=" + hlRenderPreview(cfi || "", 120) +
|
||||||
var normalizedRange = this.normalizeRangeBoundaries(range);
|
" hasPreciseLocator=" + !!hasPreciseLocator + " " + hlRenderLocatorLabel(locator)
|
||||||
this.highlightRangeSafe(normalizedRange, cssClass, cfi);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
var normalizedRange = this.normalizeRangeBoundaries(range);
|
||||||
|
this.highlightRangeSafe(normalizedRange, cssClass, cfi);
|
||||||
|
hlRenderLog(
|
||||||
|
"webview_apply_result applied=true cfi=" + hlRenderPreview(cfi || "", 120) +
|
||||||
|
" source=" + rangeSource +
|
||||||
|
" renderedText='" + hlRenderPreview(normalizedRange.toString(), 80) + "'"
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
hlRenderLog("webview_apply_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
|
||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ fun androidSettingsHubInput(
|
||||||
val supportsOssAiKeys = isOssBuild && !isOfflineBuild
|
val supportsOssAiKeys = isOssBuild && !isOfflineBuild
|
||||||
val featurePolicy = if (isOfflineBuild) {
|
val featurePolicy = if (isOfflineBuild) {
|
||||||
SharedFeaturePolicy.OssOffline
|
SharedFeaturePolicy.OssOffline
|
||||||
|
} else if (isOssBuild) {
|
||||||
|
SharedFeaturePolicy.OssOnline
|
||||||
} else {
|
} else {
|
||||||
SharedFeaturePolicy.Standard
|
SharedFeaturePolicy.Standard
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ internal object AndroidSharedStateBridge {
|
||||||
val sharedInput = SharedLibraryProjectionInput(
|
val sharedInput = SharedLibraryProjectionInput(
|
||||||
state = projectionState.toSharedReaderScreenState(
|
state = projectionState.toSharedReaderScreenState(
|
||||||
rawBooks = taggedBooks,
|
rawBooks = taggedBooks,
|
||||||
dbTags = input.dbTags
|
dbTags = input.dbTags,
|
||||||
|
includeReaderAnnotations = false
|
||||||
),
|
),
|
||||||
booksFromStore = taggedBooks
|
booksFromStore = taggedBooks
|
||||||
.filterNot { it.bookId.endsWith("_reflow") }
|
.filterNot { it.bookId.endsWith("_reflow") }
|
||||||
|
|
@ -195,7 +196,8 @@ internal object AndroidSharedStateBridge {
|
||||||
private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState {
|
private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState {
|
||||||
return toSharedReaderScreenState(
|
return toSharedReaderScreenState(
|
||||||
rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles },
|
rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles },
|
||||||
dbTags = projectedState.allTags.ifEmpty { allTags }
|
dbTags = projectedState.allTags.ifEmpty { allTags },
|
||||||
|
includeReaderAnnotations = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ package com.aryan.reader
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
|
|
@ -80,6 +81,18 @@ object AppDestinations {
|
||||||
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
|
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun shouldInterceptAppNavBack(
|
||||||
|
currentRoute: String?,
|
||||||
|
hasPreviousBackStackEntry: Boolean,
|
||||||
|
isCurrentEntryResumed: Boolean
|
||||||
|
): Boolean {
|
||||||
|
if (!hasPreviousBackStackEntry || !isCurrentEntryResumed) return false
|
||||||
|
return currentRoute != null &&
|
||||||
|
currentRoute != AppDestinations.MAIN_ROUTE &&
|
||||||
|
currentRoute != AppDestinations.PDF_VIEWER_ROUTE &&
|
||||||
|
currentRoute != AppDestinations.EPUB_READER_ROUTE
|
||||||
|
}
|
||||||
|
|
||||||
private fun NavHostController.isReadyForBackStackChange(): Boolean {
|
private fun NavHostController.isReadyForBackStackChange(): Boolean {
|
||||||
return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
|
return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +179,11 @@ fun AppNavigation(
|
||||||
val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp(
|
val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp(
|
||||||
isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE
|
isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE
|
||||||
).dp
|
).dp
|
||||||
|
val shouldInterceptBack = shouldInterceptAppNavBack(
|
||||||
|
currentRoute = currentRoute,
|
||||||
|
hasPreviousBackStackEntry = navController.previousBackStackEntry != null,
|
||||||
|
isCurrentEntryResumed = currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
|
||||||
|
)
|
||||||
|
|
||||||
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
||||||
if (!uiState.isLoading) {
|
if (!uiState.isLoading) {
|
||||||
|
|
@ -195,6 +213,10 @@ fun AppNavigation(
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
BackHandler(enabled = shouldInterceptBack) {
|
||||||
|
navController.popBackStackIfReady()
|
||||||
|
}
|
||||||
|
|
||||||
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
|
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
|
||||||
composable(AppDestinations.MAIN_ROUTE) {
|
composable(AppDestinations.MAIN_ROUTE) {
|
||||||
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
|
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
|
||||||
|
|
@ -315,7 +337,7 @@ fun AppNavigation(
|
||||||
},
|
},
|
||||||
onRenderModeChange = viewModel::setRenderMode,
|
onRenderModeChange = viewModel::setRenderMode,
|
||||||
customFonts = customFonts,
|
customFonts = customFonts,
|
||||||
onImportFont = viewModel::importFont,
|
onImportFonts = viewModel::importFonts,
|
||||||
viewModel = viewModel
|
viewModel = viewModel
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
80
app/src/main/java/com/aryan/reader/BookReplacementStore.kt
Normal file
80
app/src/main/java/com/aryan/reader/BookReplacementStore.kt
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import com.aryan.reader.shared.ReaderBookReplacementPreferences
|
||||||
|
import com.aryan.reader.shared.ReaderBookReplacementPreferencesJson
|
||||||
|
import com.aryan.reader.shared.ReaderWordReplacementEngine
|
||||||
|
import com.aryan.reader.shared.ReaderWordReplacementRule
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Node
|
||||||
|
import org.jsoup.nodes.TextNode
|
||||||
|
|
||||||
|
private const val READER_PREFS_NAME = "reader_prefs"
|
||||||
|
private const val BOOK_REPLACEMENTS_KEY = "book_word_replacements_json"
|
||||||
|
|
||||||
|
fun loadBookReplacementPreferences(context: Context): ReaderBookReplacementPreferences {
|
||||||
|
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return ReaderBookReplacementPreferencesJson.decodeOrEmpty(prefs.getString(BOOK_REPLACEMENTS_KEY, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBookReplacementPreferences(
|
||||||
|
context: Context,
|
||||||
|
preferences: ReaderBookReplacementPreferences,
|
||||||
|
) {
|
||||||
|
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit {
|
||||||
|
putString(BOOK_REPLACEMENTS_KEY, ReaderBookReplacementPreferencesJson.encode(preferences))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun applyBookReplacementsToHtmlDocument(
|
||||||
|
document: Document,
|
||||||
|
preferences: ReaderBookReplacementPreferences,
|
||||||
|
fileId: String?,
|
||||||
|
): Boolean {
|
||||||
|
val rules = preferences.activeRulesForFile(fileId)
|
||||||
|
if (rules.isEmpty()) return false
|
||||||
|
|
||||||
|
var changed = false
|
||||||
|
|
||||||
|
fun rewriteTextNodes(node: Node) {
|
||||||
|
if (node is TextNode && !node.hasReplacementBlockedAncestor()) {
|
||||||
|
val original = node.wholeText
|
||||||
|
val replaced = applyBookReplacementRules(original, rules)
|
||||||
|
if (replaced != original) {
|
||||||
|
node.text(replaced)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node.childNodes().forEach(::rewriteTextNodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body()?.let(::rewriteTextNodes)
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyBookReplacementRules(
|
||||||
|
text: String,
|
||||||
|
rules: List<ReaderWordReplacementRule>,
|
||||||
|
): String {
|
||||||
|
return ReaderWordReplacementEngine.apply(
|
||||||
|
text = text,
|
||||||
|
rules = rules,
|
||||||
|
).text
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TextNode.hasReplacementBlockedAncestor(): Boolean {
|
||||||
|
var current: Node? = parent()
|
||||||
|
while (current != null) {
|
||||||
|
when (current.nodeName().lowercase()) {
|
||||||
|
"script",
|
||||||
|
"style",
|
||||||
|
"noscript" -> return true
|
||||||
|
}
|
||||||
|
current = current.parent()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
400
app/src/main/java/com/aryan/reader/BookWordReplacementsSheet.kt
Normal file
400
app/src/main/java/com/aryan/reader/BookWordReplacementsSheet.kt
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.aryan.reader.shared.ReaderBookReplacementEngine
|
||||||
|
import com.aryan.reader.shared.ReaderBookReplacementPreferences
|
||||||
|
import com.aryan.reader.shared.ReaderWordReplacementRule
|
||||||
|
|
||||||
|
private data class BookRuleEditTarget(
|
||||||
|
val ruleId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BookWordReplacementsSheet(
|
||||||
|
isVisible: Boolean,
|
||||||
|
bookId: String,
|
||||||
|
bookTitle: String?,
|
||||||
|
preferences: ReaderBookReplacementPreferences,
|
||||||
|
onPreferencesChange: (ReaderBookReplacementPreferences) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (!isVisible) return
|
||||||
|
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
var editTarget by remember(bookId) { mutableStateOf<BookRuleEditTarget?>(null) }
|
||||||
|
val rules = preferences.rulesForFile(bookId)
|
||||||
|
val editingRule = editTarget?.ruleId?.let { id -> rules.firstOrNull { it.id == id } }
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 720.dp)
|
||||||
|
.imePadding()
|
||||||
|
.padding(horizontal = 20.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.menu_book_word_replacements),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = bookTitle?.takeIf { it.isNotBlank() } ?: stringResource(R.string.book_replacements_current_book),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.heightIn(max = 560.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
TextButton(
|
||||||
|
onClick = { editTarget = BookRuleEditTarget() },
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.book_replacements_add_rule))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (editTarget != null) {
|
||||||
|
item {
|
||||||
|
BookRuleEditorCard(
|
||||||
|
seedRule = editingRule,
|
||||||
|
onCancel = { editTarget = null },
|
||||||
|
onSave = { rule ->
|
||||||
|
val updatedRules = if (editingRule == null) {
|
||||||
|
rules + rule
|
||||||
|
} else {
|
||||||
|
rules.map { if (it.id == editingRule.id) rule else it }
|
||||||
|
}
|
||||||
|
onPreferencesChange(preferences.withFileRules(bookId, updatedRules))
|
||||||
|
editTarget = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
BookReplacementRuleList(
|
||||||
|
rules = rules,
|
||||||
|
emptyTextRes = R.string.book_replacements_empty,
|
||||||
|
onToggle = { rule, enabled ->
|
||||||
|
onPreferencesChange(
|
||||||
|
preferences.withFileRules(
|
||||||
|
bookId,
|
||||||
|
rules.map { if (it.id == rule.id) it.copy(enabled = enabled) else it },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onEdit = { editTarget = BookRuleEditTarget(it.id) },
|
||||||
|
onDelete = { rule ->
|
||||||
|
onPreferencesChange(preferences.withFileRules(bookId, rules.filterNot { it.id == rule.id }))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BookRuleEditorCard(
|
||||||
|
seedRule: ReaderWordReplacementRule?,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onSave: (ReaderWordReplacementRule) -> Unit,
|
||||||
|
) {
|
||||||
|
val draftRuleId = remember(seedRule?.id) { seedRule?.id ?: newBookReplacementRuleId() }
|
||||||
|
val initial = seedRule ?: ReaderWordReplacementRule(
|
||||||
|
id = draftRuleId,
|
||||||
|
from = "",
|
||||||
|
to = "",
|
||||||
|
)
|
||||||
|
var from by remember(initial.id) { mutableStateOf(initial.from) }
|
||||||
|
var to by remember(initial.id) { mutableStateOf(initial.to) }
|
||||||
|
var enabled by remember(initial.id) { mutableStateOf(initial.enabled) }
|
||||||
|
var isRegex by remember(initial.id) { mutableStateOf(initial.isRegex) }
|
||||||
|
var wholeWord by remember(initial.id) { mutableStateOf(initial.wholeWord) }
|
||||||
|
var matchCase by remember(initial.id) { mutableStateOf(initial.matchCase) }
|
||||||
|
val defaultPreviewInput = stringResource(R.string.book_replacements_preview_default)
|
||||||
|
var previewInput by remember(initial.id, defaultPreviewInput) {
|
||||||
|
mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: defaultPreviewInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
val draft = ReaderWordReplacementRule(
|
||||||
|
id = initial.id,
|
||||||
|
from = from,
|
||||||
|
to = to,
|
||||||
|
enabled = enabled,
|
||||||
|
isRegex = isRegex,
|
||||||
|
matchCase = matchCase,
|
||||||
|
wholeWord = wholeWord,
|
||||||
|
)
|
||||||
|
val validation = ReaderBookReplacementEngine.validate(draft)
|
||||||
|
val previewOutput = if (validation.isValid) {
|
||||||
|
ReaderBookReplacementEngine.apply(
|
||||||
|
text = previewInput,
|
||||||
|
preferences = ReaderBookReplacementPreferences(fileRules = mapOf("preview" to listOf(draft.copy(enabled = true)))),
|
||||||
|
fileId = "preview",
|
||||||
|
).text
|
||||||
|
} else {
|
||||||
|
previewInput
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.35f)),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(if (seedRule == null) R.string.book_replacements_new_replacement else R.string.book_replacements_edit_replacement),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = from,
|
||||||
|
onValueChange = { from = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text(stringResource(R.string.tts_replacements_label_replace)) },
|
||||||
|
singleLine = !isRegex,
|
||||||
|
isError = !validation.isValid,
|
||||||
|
supportingText = if (validation.message != null) {
|
||||||
|
{ Text(validation.message.orEmpty()) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = to,
|
||||||
|
onValueChange = { to = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text(stringResource(R.string.book_replacements_label_with)) },
|
||||||
|
singleLine = !isRegex,
|
||||||
|
)
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
item {
|
||||||
|
FilterChip(
|
||||||
|
selected = enabled,
|
||||||
|
onClick = { enabled = !enabled },
|
||||||
|
label = { Text(stringResource(R.string.tts_replacements_chip_enabled)) },
|
||||||
|
leadingIcon = if (enabled) {
|
||||||
|
{ Icon(Icons.Default.Check, contentDescription = null) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
FilterChip(
|
||||||
|
selected = isRegex,
|
||||||
|
onClick = { isRegex = !isRegex },
|
||||||
|
label = { Text(stringResource(R.string.tts_replacements_chip_regex)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
FilterChip(
|
||||||
|
selected = wholeWord,
|
||||||
|
onClick = { wholeWord = !wholeWord },
|
||||||
|
label = { Text(stringResource(R.string.tts_replacements_chip_whole_word)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
FilterChip(
|
||||||
|
selected = matchCase,
|
||||||
|
onClick = { matchCase = !matchCase },
|
||||||
|
label = { Text(stringResource(R.string.tts_replacements_chip_match_case)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = previewInput,
|
||||||
|
onValueChange = { previewInput = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text(stringResource(R.string.tts_replacements_label_preview_input)) },
|
||||||
|
minLines = 2,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = previewOutput,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onCancel) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { onSave(draft) },
|
||||||
|
enabled = validation.isValid,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_save))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BookReplacementRuleList(
|
||||||
|
rules: List<ReaderWordReplacementRule>,
|
||||||
|
@StringRes emptyTextRes: Int,
|
||||||
|
onToggle: (ReaderWordReplacementRule, Boolean) -> Unit,
|
||||||
|
onEdit: (ReaderWordReplacementRule) -> Unit,
|
||||||
|
onDelete: (ReaderWordReplacementRule) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tts_replacements_rules),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
if (rules.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(emptyTextRes),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rules.forEach { rule ->
|
||||||
|
val emptyLabel = stringResource(R.string.book_replacements_empty_replacement)
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = rule.summaryText(emptyLabel),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(rule.optionSummary())
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Switch(
|
||||||
|
checked = rule.enabled,
|
||||||
|
onCheckedChange = { onToggle(rule, it) },
|
||||||
|
)
|
||||||
|
IconButton(onClick = { onEdit(rule) }) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.action_edit))
|
||||||
|
}
|
||||||
|
IconButton(onClick = { onDelete(rule) }) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReaderWordReplacementRule.summaryText(emptyLabel: String): String {
|
||||||
|
val replacement = to.ifBlank { emptyLabel }
|
||||||
|
return "$from -> $replacement"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReaderWordReplacementRule.optionSummary(): String {
|
||||||
|
val regexLabel = stringResource(R.string.tts_replacements_chip_regex)
|
||||||
|
val plainTextLabel = stringResource(R.string.tts_replacements_plain_text)
|
||||||
|
val wholeWordLabel = stringResource(R.string.tts_replacements_chip_whole_word)
|
||||||
|
val caseSensitiveLabel = stringResource(R.string.tts_replacements_case_sensitive)
|
||||||
|
val parts = buildList {
|
||||||
|
add(if (isRegex) regexLabel else plainTextLabel)
|
||||||
|
if (wholeWord) add(wholeWordLabel)
|
||||||
|
if (matchCase) add(caseSensitiveLabel)
|
||||||
|
}
|
||||||
|
return parts.joinToString(" - ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newBookReplacementRuleId(): String {
|
||||||
|
return "book_rule_${System.currentTimeMillis()}"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import com.aryan.reader.data.BookMetadata
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
|
||||||
|
internal fun RecentFileItem.needsRemoteEpubAnnotationMetadataGuard(): Boolean {
|
||||||
|
return type in EPUB_READER_FILE_TYPES &&
|
||||||
|
(bookmarksJson.isNullOrBlank() || highlightsJson.isNullOrBlank())
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun RecentFileItem.mergeRemoteEpubAnnotationMetadata(remote: BookMetadata?): RecentFileItem {
|
||||||
|
if (remote == null || remote.isDeleted || type !in EPUB_READER_FILE_TYPES || !remote.isEpubReaderMetadata()) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
val nextBookmarks = if (bookmarksJson.isNullOrBlank() && remote.bookmarksJson.hasCloudAnnotationPayload()) {
|
||||||
|
remote.bookmarksJson
|
||||||
|
} else {
|
||||||
|
bookmarksJson
|
||||||
|
}
|
||||||
|
val nextHighlights = if (highlightsJson.isNullOrBlank() && remote.highlightsJson.hasCloudAnnotationPayload()) {
|
||||||
|
remote.highlightsJson
|
||||||
|
} else {
|
||||||
|
highlightsJson
|
||||||
|
}
|
||||||
|
if (nextBookmarks == bookmarksJson && nextHighlights == highlightsJson) return this
|
||||||
|
return copy(
|
||||||
|
bookmarksJson = nextBookmarks,
|
||||||
|
highlightsJson = nextHighlights
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BookMetadata.isEpubReaderMetadata(): Boolean {
|
||||||
|
val remoteType = runCatching { FileType.valueOf(type) }.getOrNull() ?: return false
|
||||||
|
return remoteType in EPUB_READER_FILE_TYPES
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String?.hasCloudAnnotationPayload(): Boolean {
|
||||||
|
val normalized = this?.trim().orEmpty()
|
||||||
|
return normalized.isNotEmpty() && normalized != "[]"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun annotationJsonEquivalentForNoop(existing: String?, incoming: String): Boolean {
|
||||||
|
val existingNormalized = existing?.trim().orEmpty()
|
||||||
|
val incomingNormalized = incoming.trim()
|
||||||
|
if (existingNormalized == incomingNormalized) return true
|
||||||
|
return existingNormalized.isAnnotationJsonEmpty() && incomingNormalized.isAnnotationJsonEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.isAnnotationJsonEmpty(): Boolean {
|
||||||
|
return isBlank() || this == "[]"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
internal data class AndroidPdfCloudSidecarState(
|
||||||
|
val hasInk: Boolean,
|
||||||
|
val inkTimestamp: Long,
|
||||||
|
val hasDeletedInk: Boolean = false,
|
||||||
|
val deletedInkTimestamp: Long = 0L,
|
||||||
|
val hasRichText: Boolean,
|
||||||
|
val richTextTimestamp: Long,
|
||||||
|
val hasLayout: Boolean,
|
||||||
|
val layoutTimestamp: Long,
|
||||||
|
val hasTextBoxes: Boolean,
|
||||||
|
val textBoxesTimestamp: Long,
|
||||||
|
val hasHighlights: Boolean,
|
||||||
|
val highlightsTimestamp: Long
|
||||||
|
) {
|
||||||
|
val hasAnnotationPayload: Boolean
|
||||||
|
get() = hasInk || hasDeletedInk || hasRichText || hasTextBoxes || hasHighlights
|
||||||
|
|
||||||
|
val annotationPayloadTimestamp: Long
|
||||||
|
get() = maxOf(
|
||||||
|
inkTimestamp.takeIf { hasInk } ?: 0L,
|
||||||
|
deletedInkTimestamp.takeIf { hasDeletedInk } ?: 0L,
|
||||||
|
richTextTimestamp.takeIf { hasRichText } ?: 0L,
|
||||||
|
textBoxesTimestamp.takeIf { hasTextBoxes } ?: 0L,
|
||||||
|
highlightsTimestamp.takeIf { hasHighlights } ?: 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
val bundleTimestamp: Long
|
||||||
|
get() = if (hasAnnotationPayload) {
|
||||||
|
maxOf(annotationPayloadTimestamp, layoutTimestamp.takeIf { hasLayout } ?: 0L)
|
||||||
|
} else {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun shouldUploadLocalPdfCloudAnnotations(
|
||||||
|
localSidecars: AndroidPdfCloudSidecarState,
|
||||||
|
remoteHasAnnotations: Boolean,
|
||||||
|
remoteAnnotationModifiedTimestamp: Long
|
||||||
|
): Boolean {
|
||||||
|
return localSidecars.hasAnnotationPayload &&
|
||||||
|
(!remoteHasAnnotations || localSidecars.annotationPayloadTimestamp > remoteAnnotationModifiedTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun shouldDownloadRemotePdfCloudAnnotations(
|
||||||
|
localSidecars: AndroidPdfCloudSidecarState,
|
||||||
|
localAnnotationsShouldUpload: Boolean,
|
||||||
|
remoteHasAnnotations: Boolean,
|
||||||
|
remoteAnnotationModifiedTimestamp: Long
|
||||||
|
): Boolean {
|
||||||
|
if (localAnnotationsShouldUpload || !remoteHasAnnotations) return false
|
||||||
|
return !localSidecars.hasAnnotationPayload ||
|
||||||
|
remoteAnnotationModifiedTimestamp > localSidecars.annotationPayloadTimestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun File?.hasSyncableCloudAnnotationPayload(): Boolean {
|
||||||
|
val file = this ?: return false
|
||||||
|
if (!file.isFile || file.length() <= 0L) return false
|
||||||
|
val trimmed = runCatching { file.readText().trim() }.getOrDefault("")
|
||||||
|
return trimmed.isNotBlank() && trimmed != "[]" && trimmed != "{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun markPdfCloudAnnotationSidecarsSynced(timestamp: Long, vararg files: File?) {
|
||||||
|
if (timestamp <= 0L) return
|
||||||
|
files.forEach { file ->
|
||||||
|
if (file?.exists() == true) {
|
||||||
|
file.setLastModified(timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun cloudPdfAnnotationDriveFileName(bookId: String): String = "annotation_$bookId.json"
|
||||||
70
app/src/main/java/com/aryan/reader/CloudSyncTrace.kt
Normal file
70
app/src/main/java/com/aryan/reader/CloudSyncTrace.kt
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.aryan.reader.data.BookMetadata
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import com.aryan.reader.data.effectiveAnnotationModifiedTimestamp
|
||||||
|
import com.aryan.reader.data.effectiveReadingPositionModifiedTimestamp
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
internal const val CloudSyncTraceTag = "EpistemeCloudSync"
|
||||||
|
internal const val CloudAnnotationSyncTraceTag = "EpistemeCloudAnnotations"
|
||||||
|
|
||||||
|
internal fun logCloudSyncTrace(message: () -> String) {
|
||||||
|
if (!BuildConfig.DEBUG) return
|
||||||
|
val text = message()
|
||||||
|
Log.d(CloudSyncTraceTag, text)
|
||||||
|
Timber.tag(CloudSyncTraceTag).d(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logCloudSyncError(error: Throwable, message: () -> String) {
|
||||||
|
if (!BuildConfig.DEBUG) return
|
||||||
|
val text = message()
|
||||||
|
Log.e(CloudSyncTraceTag, text, error)
|
||||||
|
Timber.tag(CloudSyncTraceTag).e(error, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logCloudAnnotationSyncTrace(message: () -> String) {
|
||||||
|
if (!BuildConfig.DEBUG) return
|
||||||
|
val text = message()
|
||||||
|
Log.d(CloudAnnotationSyncTraceTag, text)
|
||||||
|
Timber.tag(CloudAnnotationSyncTraceTag).d(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logCloudAnnotationSyncError(error: Throwable, message: () -> String) {
|
||||||
|
if (!BuildConfig.DEBUG) return
|
||||||
|
val text = message()
|
||||||
|
Log.e(CloudAnnotationSyncTraceTag, text, error)
|
||||||
|
Timber.tag(CloudAnnotationSyncTraceTag).e(error, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun RecentFileItem.cloudSyncTraceSummary(prefix: String = "local"): String {
|
||||||
|
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
|
||||||
|
"contentTs=$fileContentModifiedTimestamp " +
|
||||||
|
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
|
||||||
|
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
|
||||||
|
"bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun BookMetadata.cloudSyncTraceSummary(prefix: String = "remote"): String {
|
||||||
|
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
|
||||||
|
"annTs=${effectiveAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
|
||||||
|
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
|
||||||
|
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
|
||||||
|
"hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
|
||||||
|
"highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String?.cloudSyncPreview(maxLength: Int = 80): String {
|
||||||
|
val value = this ?: return "null"
|
||||||
|
return if (value.length <= maxLength) value else value.take(maxLength) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String?.cloudSyncAnnotationSummary(): String {
|
||||||
|
val value = this?.trim() ?: return "null"
|
||||||
|
return when {
|
||||||
|
value.isEmpty() -> "blank"
|
||||||
|
value == "[]" -> "empty"
|
||||||
|
else -> "present(${value.length})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2949,7 +2949,14 @@ private fun ThemeGridItem(
|
||||||
Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(
|
||||||
|
text = theme.name,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.clickable { onThemeSelected(theme.id) }
|
||||||
|
)
|
||||||
|
|
||||||
if (theme.isCustom && onEdit != null && onDelete != null) {
|
if (theme.isCustom && onEdit != null && onDelete != null) {
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ package com.aryan.reader
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
|
@ -50,8 +51,7 @@ object FileHasher {
|
||||||
}
|
}
|
||||||
hexString.toString()
|
hexString.toString()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// In a real app, you'd want to log this error
|
Timber.e(e, "Failed to calculate SHA-256 hash")
|
||||||
e.printStackTrace()
|
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,45 +72,27 @@ class FolderSyncWorker(
|
||||||
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
|
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
|
||||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
val jsonString = prefs.getString("synced_folders_list_json", null)
|
val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null)
|
||||||
val folders = mutableListOf<Pair<String, Set<FileType>>>()
|
val folders = SyncedFolderPrefs.decodeSyncedFolders(
|
||||||
|
jsonString = jsonString,
|
||||||
if (jsonString != null) {
|
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
|
||||||
try {
|
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
|
||||||
val array = org.json.JSONArray(jsonString)
|
)
|
||||||
for (i in 0 until array.length()) {
|
|
||||||
val obj = array.getJSONObject(i)
|
|
||||||
val uri = obj.getString("uri")
|
|
||||||
val allowedFileTypes = mutableSetOf<FileType>()
|
|
||||||
if (obj.has("allowedFileTypes")) {
|
|
||||||
val typesArray = obj.getJSONArray("allowedFileTypes")
|
|
||||||
for (j in 0 until typesArray.length()) {
|
|
||||||
try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES)
|
|
||||||
}
|
|
||||||
folders.add(Pair(uri, allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) { Timber.e(e) }
|
|
||||||
} else {
|
|
||||||
val single = prefs.getString("synced_folder_uri", null)
|
|
||||||
if (single != null) folders.add(Pair(single, ANDROID_SYNCABLE_FILE_TYPES))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (folders.isEmpty()) {
|
if (folders.isEmpty()) {
|
||||||
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
|
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val enabledFolders = folders.filter { it.localSyncEnabled }
|
||||||
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
|
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
|
||||||
folders
|
enabledFolders
|
||||||
} else {
|
} else {
|
||||||
folders.filter { it.first == targetFolderUri }
|
enabledFolders.filter { it.uriString == targetFolderUri }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foldersToProcess.isEmpty()) {
|
if (foldersToProcess.isEmpty()) {
|
||||||
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked target=$targetFolderUri")
|
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri")
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,8 +105,8 @@ class FolderSyncWorker(
|
||||||
syncMutex.withLock {
|
syncMutex.withLock {
|
||||||
var allSuccess = true
|
var allSuccess = true
|
||||||
|
|
||||||
for ((uriString, allowedTypes) in foldersToProcess) {
|
for (folderConfig in foldersToProcess) {
|
||||||
val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
|
val success = performSyncForFolder(folderConfig, isMetadataOnly)
|
||||||
if (!success) allSuccess = false
|
if (!success) allSuccess = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,13 +114,14 @@ class FolderSyncWorker(
|
||||||
try {
|
try {
|
||||||
val array = org.json.JSONArray(jsonString)
|
val array = org.json.JSONArray(jsonString)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString }
|
||||||
for (i in 0 until array.length()) {
|
for (i in 0 until array.length()) {
|
||||||
val obj = array.getJSONObject(i)
|
val obj = array.getJSONObject(i)
|
||||||
if (targetFolderUri.isNullOrBlank() || obj.optString("uri") == targetFolderUri) {
|
if (obj.optString("uri") in processedUris) {
|
||||||
obj.put("lastScanTime", now)
|
obj.put("lastScanTime", now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prefs.edit { putString("synced_folders_list_json", array.toString()) }
|
prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) }
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,7 +136,9 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean {
|
private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean {
|
||||||
|
val folderUriString = folderConfig.uriString
|
||||||
|
val allowedFileTypes = folderConfig.allowedFileTypes
|
||||||
if (folderUriString.isBlank()) return true
|
if (folderUriString.isBlank()) return true
|
||||||
val folderUri = folderUriString.toUri()
|
val folderUri = folderUriString.toUri()
|
||||||
val folderStart = ReaderPerfLog.nowNanos()
|
val folderStart = ReaderPerfLog.nowNanos()
|
||||||
|
|
@ -235,9 +220,10 @@ class FolderSyncWorker(
|
||||||
val nowMillis = System.currentTimeMillis()
|
val nowMillis = System.currentTimeMillis()
|
||||||
val folder = SyncedFolder(
|
val folder = SyncedFolder(
|
||||||
uriString = folderUriString,
|
uriString = folderUriString,
|
||||||
name = documentTree.name ?: "Local Folder",
|
name = documentTree.name ?: folderConfig.name,
|
||||||
lastScanTime = nowMillis,
|
lastScanTime = nowMillis,
|
||||||
allowedFileTypes = allowedFileTypes
|
allowedFileTypes = allowedFileTypes,
|
||||||
|
localSyncEnabled = true
|
||||||
)
|
)
|
||||||
val sharedState = SharedReaderScreenState(
|
val sharedState = SharedReaderScreenState(
|
||||||
rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
|
rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
|
||||||
|
|
@ -564,7 +550,8 @@ class FolderSyncWorker(
|
||||||
lastPageIndex = lastPage,
|
lastPageIndex = lastPage,
|
||||||
readerPosition = readerPositionOrNull(),
|
readerPosition = readerPositionOrNull(),
|
||||||
readerBookmarks = parseReaderBookmarks(),
|
readerBookmarks = parseReaderBookmarks(),
|
||||||
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
|
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson),
|
||||||
|
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -613,8 +600,8 @@ class FolderSyncWorker(
|
||||||
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
|
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
|
||||||
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
|
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
|
||||||
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
|
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
|
||||||
locatorBlockIndex = appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
|
locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
|
||||||
locatorCharOffset = appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
|
locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
|
||||||
progressPercentage = progressPercentage,
|
progressPercentage = progressPercentage,
|
||||||
isRecent = isRecent,
|
isRecent = isRecent,
|
||||||
isAvailable = true,
|
isAvailable = true,
|
||||||
|
|
@ -637,6 +624,7 @@ class FolderSyncWorker(
|
||||||
originalDescription = originalDescription,
|
originalDescription = originalDescription,
|
||||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||||
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
|
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
|
||||||
|
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
|
||||||
tags = existing?.tags.orEmpty()
|
tags = existing?.tags.orEmpty()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -720,18 +708,12 @@ class FolderSyncWorker(
|
||||||
|
|
||||||
private fun isFolderStillLinked(folderUriString: String): Boolean {
|
private fun isFolderStillLinked(folderUriString: String): Boolean {
|
||||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
val jsonString = prefs.getString("synced_folders_list_json", null)
|
return SyncedFolderPrefs.isLocalSyncEnabled(
|
||||||
if (jsonString != null) {
|
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
|
||||||
return try {
|
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
|
||||||
val array = org.json.JSONArray(jsonString)
|
folderUriString = folderUriString,
|
||||||
(0 until array.length()).any { index ->
|
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
|
||||||
array.getJSONObject(index).optString("uri") == folderUriString
|
)
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return prefs.getString("synced_folder_uri", null) == folderUriString
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFileType(name: String, mimeType: String?): FileType? {
|
private fun getFileType(name: String, mimeType: String?): FileType? {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@
|
||||||
|
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -27,10 +30,12 @@ import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.CloudDownload
|
import androidx.compose.material.icons.filled.CloudDownload
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.SelectAll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
|
@ -46,6 +51,7 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -80,37 +86,67 @@ fun FontsScreen(
|
||||||
|
|
||||||
val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE)
|
val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE)
|
||||||
|
|
||||||
// Dialog state
|
var fontsPendingDelete by remember { mutableStateOf<List<CustomFontEntity>>(emptyList()) }
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
|
||||||
var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) }
|
|
||||||
var showGoogleFontsSheet by remember { mutableStateOf(false) }
|
var showGoogleFontsSheet by remember { mutableStateOf(false) }
|
||||||
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
|
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
|
||||||
|
var selectedFontIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
|
|
||||||
val pickFontLauncher = rememberFilePickerLauncher { uris ->
|
val pickFontLauncher = rememberFilePickerLauncher(viewModel::importFonts)
|
||||||
uris.firstOrNull()?.let { viewModel.importFont(it) }
|
val fontMimeTypes = remember { supportedFontMimeTypes() }
|
||||||
|
val allFontIds = remember(fonts) { fonts.mapTo(mutableSetOf()) { it.id } }
|
||||||
|
val selectedFonts = remember(fonts, selectedFontIds) {
|
||||||
|
fonts.filter { it.id in selectedFontIds }
|
||||||
|
}
|
||||||
|
val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty()
|
||||||
|
|
||||||
|
LaunchedEffect(fonts) {
|
||||||
|
selectedFontIds = selectedFontIds.intersect(allFontIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
val fontMimeTypes = arrayOf(
|
BackHandler(enabled = isFontSelectionMode) {
|
||||||
"font/ttf", "font/otf", "font/woff2",
|
selectedFontIds = emptySet()
|
||||||
"application/x-font-ttf", "application/x-font-otf",
|
}
|
||||||
"application/font-woff2", "application/vnd.ms-opentype",
|
|
||||||
"application/x-font-opentype"
|
|
||||||
)
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.statusBarsPadding(),
|
modifier = Modifier.statusBarsPadding(),
|
||||||
topBar = {
|
topBar = {
|
||||||
CustomTopAppBar(
|
if (isFontSelectionMode) {
|
||||||
title = { Text(stringResource(R.string.custom_fonts)) },
|
ContextualTopAppBar(
|
||||||
navigationIcon = {
|
selectedItemCount = selectedFonts.size,
|
||||||
IconButton(onClick = onBackClick) {
|
onNavIconClick = { selectedFontIds = emptySet() },
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
onSelectAllClick = {
|
||||||
|
selectedFontIds = if (selectedFontIds.containsAll(allFontIds)) {
|
||||||
|
emptySet()
|
||||||
|
} else {
|
||||||
|
allFontIds
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDeleteClick = {
|
||||||
|
if (selectedFonts.isNotEmpty()) {
|
||||||
|
fontsPendingDelete = selectedFonts
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
} else {
|
||||||
|
CustomTopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.custom_fonts)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBackClick) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { selectedFontIds = allFontIds }) {
|
||||||
|
Icon(Icons.Default.SelectAll, contentDescription = stringResource(R.string.select_all))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
|
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty() && !isFontSelectionMode) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.End,
|
horizontalAlignment = Alignment.End,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
|
@ -139,7 +175,10 @@ fun FontsScreen(
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
SharedFontSettingsTabs(
|
SharedFontSettingsTabs(
|
||||||
selectedSection = selectedSection,
|
selectedSection = selectedSection,
|
||||||
onSectionChange = { selectedSection = it },
|
onSectionChange = {
|
||||||
|
selectedFontIds = emptySet()
|
||||||
|
selectedSection = it
|
||||||
|
},
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -166,9 +205,13 @@ fun FontsScreen(
|
||||||
items(fonts, key = { it.id }) { font ->
|
items(fonts, key = { it.id }) { font ->
|
||||||
FontListItem(
|
FontListItem(
|
||||||
font = font,
|
font = font,
|
||||||
|
isSelected = font.id in selectedFontIds,
|
||||||
|
isSelectionMode = isFontSelectionMode,
|
||||||
|
onSelectionToggle = {
|
||||||
|
selectedFontIds = selectedFontIds.toggle(font.id)
|
||||||
|
},
|
||||||
onDelete = {
|
onDelete = {
|
||||||
fontToDelete = font
|
fontsPendingDelete = listOf(font)
|
||||||
showDeleteDialog = true
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -208,17 +251,17 @@ fun FontsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDeleteDialog && fontToDelete != null) {
|
if (fontsPendingDelete.isNotEmpty()) {
|
||||||
DeleteFontConfirmationDialog(
|
DeleteFontsConfirmationDialog(
|
||||||
fontName = fontToDelete!!.displayName,
|
fonts = fontsPendingDelete,
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
fontToDelete?.let { viewModel.deleteFont(it.id) }
|
val pendingIds = fontsPendingDelete.map { it.id }
|
||||||
showDeleteDialog = false
|
viewModel.deleteFonts(pendingIds)
|
||||||
fontToDelete = null
|
selectedFontIds = selectedFontIds - pendingIds.toSet()
|
||||||
|
fontsPendingDelete = emptyList()
|
||||||
},
|
},
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
showDeleteDialog = false
|
fontsPendingDelete = emptyList()
|
||||||
fontToDelete = null
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -388,9 +431,13 @@ fun GoogleFontsBottomSheet(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existing unchanged components
|
// Existing unchanged components
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun FontListItem(
|
fun FontListItem(
|
||||||
font: CustomFontEntity,
|
font: CustomFontEntity,
|
||||||
|
isSelected: Boolean,
|
||||||
|
isSelectionMode: Boolean,
|
||||||
|
onSelectionToggle: () -> Unit,
|
||||||
onDelete: () -> Unit
|
onDelete: () -> Unit
|
||||||
) {
|
) {
|
||||||
val customTypeface = remember(font.path) {
|
val customTypeface = remember(font.path) {
|
||||||
|
|
@ -402,8 +449,23 @@ fun FontListItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = {
|
||||||
|
if (isSelectionMode) {
|
||||||
|
onSelectionToggle()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = onSelectionToggle
|
||||||
|
),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -411,17 +473,27 @@ fun FontListItem(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
if (isSelectionMode) {
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = { onSelectionToggle() },
|
||||||
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = font.displayName,
|
text = font.displayName,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
|
if (!isSelectionMode) {
|
||||||
Icon(
|
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
|
||||||
Icons.Default.Delete,
|
Icon(
|
||||||
contentDescription = stringResource(R.string.action_delete),
|
Icons.Default.Delete,
|
||||||
tint = MaterialTheme.colorScheme.error
|
contentDescription = stringResource(R.string.action_delete),
|
||||||
)
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -476,15 +548,32 @@ private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontIte
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeleteFontConfirmationDialog(
|
fun DeleteFontsConfirmationDialog(
|
||||||
fontName: String,
|
fonts: List<CustomFontEntity>,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val isSingleFont = fonts.size == 1
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(stringResource(R.string.dialog_delete_font)) },
|
title = {
|
||||||
text = { Text(stringResource(R.string.dialog_delete_font_desc, fontName)) },
|
Text(
|
||||||
|
if (isSingleFont) {
|
||||||
|
stringResource(R.string.dialog_delete_font)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.dialog_delete_fonts)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
if (isSingleFont) {
|
||||||
|
stringResource(R.string.dialog_delete_font_desc, fonts.first().displayName)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.dialog_delete_fonts_desc, fonts.size)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onConfirm,
|
onClick = onConfirm,
|
||||||
|
|
@ -498,3 +587,7 @@ fun DeleteFontConfirmationDialog(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Set<String>.toggle(id: String): Set<String> {
|
||||||
|
return if (id in this) this - id else this + id
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@ import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -436,7 +437,7 @@ fun HomeScreen(
|
||||||
onRefresh = { viewModel.refreshLibrary() },
|
onRefresh = { viewModel.refreshLibrary() },
|
||||||
isRefreshing = uiState.isRefreshing,
|
isRefreshing = uiState.isRefreshing,
|
||||||
isSyncEnabled = uiState.isSyncEnabled,
|
isSyncEnabled = uiState.isSyncEnabled,
|
||||||
hasSyncedFolder = uiState.syncedFolders.isNotEmpty(),
|
hasSyncedFolder = uiState.syncedFolders.any { it.localSyncEnabled },
|
||||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
|
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -767,7 +768,8 @@ private fun RecentFilesGrid(
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
modifier = Modifier.testTag("HomeTab_${tab.bookId}")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -817,6 +819,7 @@ fun RecentFileCard(
|
||||||
|
|
||||||
androidx.compose.material3.ElevatedCard(
|
androidx.compose.material3.ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.testTag("HomeRecentFileCard_${item.bookId}")
|
||||||
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
|
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
||||||
|
|
@ -1485,7 +1488,7 @@ private fun AppDrawerContent(
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
// legal links
|
// legal links
|
||||||
if (uiState.currentUser != null && !isOss) {
|
if (uiState.currentUser != null || (isOss && !BuildConfig.IS_OFFLINE)) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val baseStyle = MaterialTheme.typography.labelMedium
|
val baseStyle = MaterialTheme.typography.labelMedium
|
||||||
var scaledTextStyle by remember { mutableStateOf(baseStyle) }
|
var scaledTextStyle by remember { mutableStateOf(baseStyle) }
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ typealias ShelfType = com.aryan.reader.shared.ShelfType
|
||||||
|
|
||||||
internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
|
internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
|
||||||
internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID)
|
internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID)
|
||||||
|
internal val COMIC_ARCHIVE_FILE_TYPES = SharedFileCapabilities.comicArchiveTypes
|
||||||
internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES
|
internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES
|
||||||
internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES
|
internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -120,6 +121,7 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -135,15 +137,19 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import coil.ImageLoader
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.decode.SvgDecoder
|
||||||
import com.aryan.reader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import com.aryan.reader.data.TagEntity
|
import com.aryan.reader.data.TagEntity
|
||||||
import com.aryan.reader.opds.OpdsAcquisition
|
import com.aryan.reader.opds.OpdsAcquisition
|
||||||
import com.aryan.reader.opds.OpdsCatalog
|
import com.aryan.reader.opds.OpdsCatalog
|
||||||
import com.aryan.reader.opds.OpdsDownloadState
|
import com.aryan.reader.opds.OpdsDownloadState
|
||||||
import com.aryan.reader.opds.OpdsEntry
|
import com.aryan.reader.opds.OpdsEntry
|
||||||
|
import com.aryan.reader.opds.OpdsRepository
|
||||||
import com.aryan.reader.opds.OpdsViewModel
|
import com.aryan.reader.opds.OpdsViewModel
|
||||||
|
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||||
|
import com.aryan.reader.shared.opds.SharedOpdsLocalBookMatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
@ -325,6 +331,7 @@ fun LibraryScreen(
|
||||||
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
|
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
|
||||||
syncedFolders = uiState.syncedFolders,
|
syncedFolders = uiState.syncedFolders,
|
||||||
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
|
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
|
||||||
|
onFolderLocalSyncChange = viewModel::setFolderLocalSyncEnabled,
|
||||||
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
|
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
|
||||||
downloadingBookIds = uiState.downloadingBookIds,
|
downloadingBookIds = uiState.downloadingBookIds,
|
||||||
lastFolderScanTime = uiState.lastFolderScanTime,
|
lastFolderScanTime = uiState.lastFolderScanTime,
|
||||||
|
|
@ -590,6 +597,7 @@ fun LibraryScreenContent(
|
||||||
isRefreshing: Boolean,
|
isRefreshing: Boolean,
|
||||||
syncedFolders: List<SyncedFolder>,
|
syncedFolders: List<SyncedFolder>,
|
||||||
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
||||||
|
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
|
||||||
onOpdsBookDownloaded: (Uri, String) -> Unit,
|
onOpdsBookDownloaded: (Uri, String) -> Unit,
|
||||||
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
||||||
onDeleteCatalogStreams: (String) -> Unit,
|
onDeleteCatalogStreams: (String) -> Unit,
|
||||||
|
|
@ -666,7 +674,8 @@ fun LibraryScreenContent(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.focusRequester(searchFocusRequester),
|
.focusRequester(searchFocusRequester)
|
||||||
|
.testTag("LibrarySearchTextField"),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
colors = TextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedContainerColor = Color.Transparent,
|
focusedContainerColor = Color.Transparent,
|
||||||
|
|
@ -693,7 +702,10 @@ fun LibraryScreenContent(
|
||||||
Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter))
|
Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter))
|
||||||
}
|
}
|
||||||
Box {
|
Box {
|
||||||
TextButton(onClick = { showSortMenu = true }) {
|
TextButton(
|
||||||
|
onClick = { showSortMenu = true },
|
||||||
|
modifier = Modifier.testTag("LibrarySortButton")
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.sort),
|
painter = painterResource(id = R.drawable.sort),
|
||||||
contentDescription = stringResource(R.string.content_desc_sort),
|
contentDescription = stringResource(R.string.content_desc_sort),
|
||||||
|
|
@ -822,7 +834,9 @@ fun LibraryScreenContent(
|
||||||
text = { Text(stringResource(R.string.fab_new_shelf)) },
|
text = { Text(stringResource(R.string.fab_new_shelf)) },
|
||||||
icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) },
|
icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) },
|
||||||
onClick = onNewShelfClick,
|
onClick = onNewShelfClick,
|
||||||
modifier = Modifier.padding(16.dp)
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.testTag("LibraryNewShelfFab")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -888,6 +902,7 @@ fun LibraryScreenContent(
|
||||||
allRecentFiles = rawLibraryFiles,
|
allRecentFiles = rawLibraryFiles,
|
||||||
onAddFolderClick = onSelectSyncFolderClick,
|
onAddFolderClick = onSelectSyncFolderClick,
|
||||||
onRemoveFolderClick = onRemoveFolderClick,
|
onRemoveFolderClick = onRemoveFolderClick,
|
||||||
|
onFolderLocalSyncChange = onFolderLocalSyncChange,
|
||||||
onEditFolderFiltersClick = onEditFolderFiltersClick,
|
onEditFolderFiltersClick = onEditFolderFiltersClick,
|
||||||
onScanNowClick = onScanNowClick,
|
onScanNowClick = onScanNowClick,
|
||||||
onSyncMetadataClick = onSyncMetadataClick,
|
onSyncMetadataClick = onSyncMetadataClick,
|
||||||
|
|
@ -1140,7 +1155,8 @@ private fun ShelfDetailScreen(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.focusRequester(searchFocusRequester),
|
.focusRequester(searchFocusRequester)
|
||||||
|
.testTag("ShelfSearchTextField"),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
colors = TextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedContainerColor = Color.Transparent,
|
focusedContainerColor = Color.Transparent,
|
||||||
|
|
@ -1193,7 +1209,10 @@ private fun ShelfDetailScreen(
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
Box {
|
Box {
|
||||||
TextButton(onClick = { showSortMenu = true }) {
|
TextButton(
|
||||||
|
onClick = { showSortMenu = true },
|
||||||
|
modifier = Modifier.testTag("ShelfSortButton")
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.sort),
|
painter = painterResource(id = R.drawable.sort),
|
||||||
contentDescription = stringResource(R.string.content_desc_sort),
|
contentDescription = stringResource(R.string.content_desc_sort),
|
||||||
|
|
@ -1375,7 +1394,10 @@ private fun AddBooksModeScreen(
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
Box {
|
Box {
|
||||||
TextButton(onClick = { showSortMenu = true }) {
|
TextButton(
|
||||||
|
onClick = { showSortMenu = true },
|
||||||
|
modifier = Modifier.testTag("AddBooksSortButton")
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.sort),
|
painter = painterResource(id = R.drawable.sort),
|
||||||
contentDescription = stringResource(R.string.content_desc_sort),
|
contentDescription = stringResource(R.string.content_desc_sort),
|
||||||
|
|
@ -1581,6 +1603,7 @@ private fun ShelfListItem(
|
||||||
),
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.testTag("ShelfItem_${shelf.id}")
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
||||||
else Modifier
|
else Modifier
|
||||||
|
|
@ -1659,6 +1682,7 @@ private fun LibraryListItem(
|
||||||
),
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.testTag("LibraryBookItem_${item.bookId}")
|
||||||
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
|
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
||||||
|
|
@ -1929,12 +1953,15 @@ private fun FolderSyncScreen(
|
||||||
allRecentFiles: List<RecentFileItem>,
|
allRecentFiles: List<RecentFileItem>,
|
||||||
onAddFolderClick: () -> Unit,
|
onAddFolderClick: () -> Unit,
|
||||||
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
||||||
|
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
|
||||||
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
|
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
|
||||||
onScanNowClick: () -> Unit,
|
onScanNowClick: () -> Unit,
|
||||||
onSyncMetadataClick: () -> Unit,
|
onSyncMetadataClick: () -> Unit,
|
||||||
isLoading: Boolean
|
isLoading: Boolean
|
||||||
) {
|
) {
|
||||||
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
|
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
|
||||||
|
var disablingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
|
||||||
|
val hasEnabledSyncFolders = syncedFolders.any { it.localSyncEnabled }
|
||||||
val folderStatsByUri = remember(allRecentFiles) {
|
val folderStatsByUri = remember(allRecentFiles) {
|
||||||
allRecentFiles
|
allRecentFiles
|
||||||
.asSequence()
|
.asSequence()
|
||||||
|
|
@ -1973,7 +2000,7 @@ private fun FolderSyncScreen(
|
||||||
) {
|
) {
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = onScanNowClick,
|
onClick = onScanNowClick,
|
||||||
enabled = !isLoading,
|
enabled = !isLoading && hasEnabledSyncFolders,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
shape = MaterialTheme.shapes.small
|
shape = MaterialTheme.shapes.small
|
||||||
) {
|
) {
|
||||||
|
|
@ -1988,7 +2015,7 @@ private fun FolderSyncScreen(
|
||||||
|
|
||||||
androidx.compose.material3.OutlinedButton(
|
androidx.compose.material3.OutlinedButton(
|
||||||
onClick = onSyncMetadataClick,
|
onClick = onSyncMetadataClick,
|
||||||
enabled = !isLoading,
|
enabled = !isLoading && hasEnabledSyncFolders,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
shape = MaterialTheme.shapes.small
|
shape = MaterialTheme.shapes.small
|
||||||
) {
|
) {
|
||||||
|
|
@ -2016,6 +2043,13 @@ private fun FolderSyncScreen(
|
||||||
folder = folder,
|
folder = folder,
|
||||||
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
|
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
|
||||||
onRemoveClick = onRemoveFolderClick,
|
onRemoveClick = onRemoveFolderClick,
|
||||||
|
onLocalSyncToggleClick = { selectedFolder ->
|
||||||
|
if (selectedFolder.localSyncEnabled) {
|
||||||
|
disablingFolder = selectedFolder
|
||||||
|
} else {
|
||||||
|
onFolderLocalSyncChange(selectedFolder, true, false)
|
||||||
|
}
|
||||||
|
},
|
||||||
onEditFiltersClick = { editingFolder = folder }
|
onEditFiltersClick = { editingFolder = folder }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -2033,6 +2067,46 @@ private fun FolderSyncScreen(
|
||||||
onDismiss = { editingFolder = null }
|
onDismiss = { editingFolder = null }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disablingFolder?.let { folder ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { disablingFolder = null },
|
||||||
|
title = { Text(stringResource(R.string.dialog_disable_folder_local_sync_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.dialog_disable_folder_local_sync_desc,
|
||||||
|
LOCAL_FOLDER_SYNC_DATA_DIR
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onFolderLocalSyncChange(folder, false, true)
|
||||||
|
disablingFolder = null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_disable_remove_sync_data))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = { disablingFolder = null }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onFolderLocalSyncChange(folder, false, false)
|
||||||
|
disablingFolder = null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_disable_keep_sync_data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class FolderFileStats(
|
private data class FolderFileStats(
|
||||||
|
|
@ -2050,6 +2124,7 @@ private fun FolderCard(
|
||||||
folder: SyncedFolder,
|
folder: SyncedFolder,
|
||||||
stats: FolderFileStats,
|
stats: FolderFileStats,
|
||||||
onRemoveClick: (SyncedFolder) -> Unit,
|
onRemoveClick: (SyncedFolder) -> Unit,
|
||||||
|
onLocalSyncToggleClick: (SyncedFolder) -> Unit,
|
||||||
onEditFiltersClick: (SyncedFolder) -> Unit
|
onEditFiltersClick: (SyncedFolder) -> Unit
|
||||||
) {
|
) {
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
@ -2075,13 +2150,22 @@ private fun FolderCard(
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Text(
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
text = folder.name,
|
Text(
|
||||||
style = MaterialTheme.typography.titleMedium,
|
text = folder.name,
|
||||||
fontWeight = FontWeight.Bold,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
maxLines = 1,
|
fontWeight = FontWeight.Bold,
|
||||||
overflow = TextOverflow.Ellipsis
|
maxLines = 1,
|
||||||
)
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (!folder.localSyncEnabled) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.folder_local_sync_disabled),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box {
|
Box {
|
||||||
|
|
@ -2096,6 +2180,21 @@ private fun FolderCard(
|
||||||
onEditFiltersClick(folder)
|
onEditFiltersClick(folder)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
if (folder.localSyncEnabled) {
|
||||||
|
stringResource(R.string.menu_disable_folder_local_sync)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.menu_enable_folder_local_sync)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onLocalSyncToggleClick(folder)
|
||||||
|
}
|
||||||
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_remove_folder)) },
|
text = { Text(stringResource(R.string.menu_remove_folder)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
@ -2377,6 +2476,7 @@ fun OpdsTab(
|
||||||
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val downloadingState = uiState.downloadingState
|
val downloadingState = uiState.downloadingState
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val coverImageLoader = rememberOpdsCoverImageLoader(uiState.currentCatalog)
|
||||||
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
|
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
|
||||||
var showCatalogDialog by remember { mutableStateOf(false) }
|
var showCatalogDialog by remember { mutableStateOf(false) }
|
||||||
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
|
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
|
||||||
|
|
@ -2601,6 +2701,7 @@ fun OpdsTab(
|
||||||
entry = entry,
|
entry = entry,
|
||||||
localLibraryFiles = localLibraryFiles,
|
localLibraryFiles = localLibraryFiles,
|
||||||
downloadState = downloadingState[entry.id],
|
downloadState = downloadingState[entry.id],
|
||||||
|
coverImageLoader = coverImageLoader,
|
||||||
onDownloadClick = { acquisition ->
|
onDownloadClick = { acquisition ->
|
||||||
opdsViewModel.downloadBook(
|
opdsViewModel.downloadBook(
|
||||||
entry, acquisition, context
|
entry, acquisition, context
|
||||||
|
|
@ -2649,6 +2750,7 @@ fun OpdsTab(
|
||||||
entry = selectedEntry!!,
|
entry = selectedEntry!!,
|
||||||
localLibraryFiles = localLibraryFiles,
|
localLibraryFiles = localLibraryFiles,
|
||||||
downloadState = downloadingState[selectedEntry!!.id],
|
downloadState = downloadingState[selectedEntry!!.id],
|
||||||
|
coverImageLoader = coverImageLoader,
|
||||||
onDownloadFormat = { acquisition ->
|
onDownloadFormat = { acquisition ->
|
||||||
opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
|
opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
|
||||||
onBookDownloaded(downloadedUri, selectedEntry!!.title)
|
onBookDownloaded(downloadedUri, selectedEntry!!.title)
|
||||||
|
|
@ -2776,6 +2878,29 @@ fun OpdsTab(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberOpdsCoverImageLoader(catalog: OpdsCatalog?): ImageLoader {
|
||||||
|
val context = LocalContext.current.applicationContext
|
||||||
|
val username = catalog?.username
|
||||||
|
val password = catalog?.password
|
||||||
|
val imageLoader = remember(context, username, password) {
|
||||||
|
ImageLoader.Builder(context)
|
||||||
|
.okHttpClient {
|
||||||
|
OpdsRepository.sharedHttpClient.newBuilder()
|
||||||
|
.authenticator(OpdsRepository.OpdsAuthenticator(username, password))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.components {
|
||||||
|
add(SvgDecoder.Factory())
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
DisposableEffect(imageLoader) {
|
||||||
|
onDispose { imageLoader.shutdown() }
|
||||||
|
}
|
||||||
|
return imageLoader
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
|
fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
|
||||||
Surface(
|
Surface(
|
||||||
|
|
@ -2853,13 +2978,20 @@ fun OpdsBookCard(
|
||||||
entry: OpdsEntry,
|
entry: OpdsEntry,
|
||||||
localLibraryFiles: List<RecentFileItem>,
|
localLibraryFiles: List<RecentFileItem>,
|
||||||
downloadState: OpdsDownloadState?,
|
downloadState: OpdsDownloadState?,
|
||||||
|
coverImageLoader: ImageLoader,
|
||||||
onDownloadClick: (OpdsAcquisition) -> Unit,
|
onDownloadClick: (OpdsAcquisition) -> Unit,
|
||||||
onReadClick: (RecentFileItem) -> Unit,
|
onReadClick: (RecentFileItem) -> Unit,
|
||||||
onStreamClick: () -> Unit,
|
onStreamClick: () -> Unit,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val libraryItem = remember(entry, localLibraryFiles) {
|
val libraryItem = remember(entry, localLibraryFiles) {
|
||||||
localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
|
SharedOpdsLocalBookMatcher.find(
|
||||||
|
entry = entry,
|
||||||
|
books = localLibraryFiles,
|
||||||
|
title = { it.title },
|
||||||
|
displayName = { it.displayName },
|
||||||
|
path = { it.uriString }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val isDownloading = downloadState?.isDownloading == true
|
val isDownloading = downloadState?.isDownloading == true
|
||||||
val progress = downloadState?.progress
|
val progress = downloadState?.progress
|
||||||
|
|
@ -2878,6 +3010,7 @@ fun OpdsBookCard(
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = entry.coverUrl,
|
model = entry.coverUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
|
imageLoader = coverImageLoader,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 70.dp, height = 100.dp)
|
.size(width = 70.dp, height = 100.dp)
|
||||||
|
|
@ -2984,6 +3117,7 @@ fun OpdsBookDetailsSheet(
|
||||||
entry: OpdsEntry,
|
entry: OpdsEntry,
|
||||||
localLibraryFiles: List<RecentFileItem>,
|
localLibraryFiles: List<RecentFileItem>,
|
||||||
downloadState: OpdsDownloadState?,
|
downloadState: OpdsDownloadState?,
|
||||||
|
coverImageLoader: ImageLoader,
|
||||||
onDownloadFormat: (OpdsAcquisition) -> Unit,
|
onDownloadFormat: (OpdsAcquisition) -> Unit,
|
||||||
onReadClick: (RecentFileItem) -> Unit,
|
onReadClick: (RecentFileItem) -> Unit,
|
||||||
onStreamClick: () -> Unit,
|
onStreamClick: () -> Unit,
|
||||||
|
|
@ -2992,7 +3126,13 @@ fun OpdsBookDetailsSheet(
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val libraryItem = remember(entry, localLibraryFiles) {
|
val libraryItem = remember(entry, localLibraryFiles) {
|
||||||
localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
|
SharedOpdsLocalBookMatcher.find(
|
||||||
|
entry = entry,
|
||||||
|
books = localLibraryFiles,
|
||||||
|
title = { it.title },
|
||||||
|
displayName = { it.displayName },
|
||||||
|
path = { it.uriString }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val isDownloading = downloadState?.isDownloading == true
|
val isDownloading = downloadState?.isDownloading == true
|
||||||
val progress = downloadState?.progress
|
val progress = downloadState?.progress
|
||||||
|
|
@ -3012,6 +3152,7 @@ fun OpdsBookDetailsSheet(
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = entry.coverUrl,
|
model = entry.coverUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
|
imageLoader = coverImageLoader,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 110.dp, height = 160.dp)
|
.size(width = 110.dp, height = 160.dp)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -50,11 +50,20 @@ class MetadataExtractionWorker(
|
||||||
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
|
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
|
||||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
val hasLegacy = prefs.contains("synced_folder_uri")
|
val linkedFolders = SyncedFolderPrefs.decodeSyncedFolders(
|
||||||
val hasNew = prefs.contains("synced_folders_list_json")
|
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
|
||||||
|
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null)
|
||||||
|
)
|
||||||
|
val enabledFolderUris = linkedFolders
|
||||||
|
.filter { it.localSyncEnabled }
|
||||||
|
.mapTo(mutableSetOf()) { it.uriString }
|
||||||
|
|
||||||
if (!hasLegacy && !hasNew) {
|
if (enabledFolderUris.isEmpty()) {
|
||||||
ReaderPerfLog.d("MetadataWorker skipped: no linked folders")
|
ReaderPerfLog.d("MetadataWorker skipped: no linked folders with sync enabled")
|
||||||
|
return@withContext Result.success()
|
||||||
|
}
|
||||||
|
if (!sourceFolderUri.isNullOrBlank() && sourceFolderUri !in enabledFolderUris) {
|
||||||
|
ReaderPerfLog.d("MetadataWorker skipped: folder sync disabled folder=$sourceFolderUri")
|
||||||
return@withContext Result.success()
|
return@withContext Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,7 +71,9 @@ class MetadataExtractionWorker(
|
||||||
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
|
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
|
||||||
sourceFolderUri = sourceFolderUri,
|
sourceFolderUri = sourceFolderUri,
|
||||||
limit = METADATA_WORKER_BOOK_BATCH_SIZE
|
limit = METADATA_WORKER_BOOK_BATCH_SIZE
|
||||||
)
|
).filter { item ->
|
||||||
|
item.sourceFolderUri != null && item.sourceFolderUri in enabledFolderUris
|
||||||
|
}
|
||||||
|
|
||||||
if (filesToProcess.isEmpty()) {
|
if (filesToProcess.isEmpty()) {
|
||||||
ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")
|
ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,16 @@ import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Remove
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
|
@ -28,20 +33,37 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
|
import com.aryan.reader.shared.ui.ReaderMinimalSlider
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private const val READER_PREFS_NAME = "reader_prefs"
|
private const val READER_PREFS_NAME = "reader_prefs"
|
||||||
private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system"
|
private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system"
|
||||||
private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value"
|
private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value"
|
||||||
private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f
|
private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f
|
||||||
private const val MIN_CUSTOM_BRIGHTNESS = 0.05f
|
private const val MIN_CUSTOM_BRIGHTNESS_PERCENT = 1
|
||||||
|
private const val MAX_CUSTOM_BRIGHTNESS_PERCENT = 100
|
||||||
|
private const val CUSTOM_BRIGHTNESS_STEP_PERCENT = 1
|
||||||
|
private const val MIN_CUSTOM_BRIGHTNESS = 0.01f
|
||||||
|
|
||||||
data class ReaderBrightnessSettings(
|
data class ReaderBrightnessSettings(
|
||||||
val useSystemBrightness: Boolean = true,
|
val useSystemBrightness: Boolean = true,
|
||||||
val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS
|
val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS
|
||||||
) {
|
) {
|
||||||
val safeCustomBrightness: Float
|
val safeCustomBrightness: Float
|
||||||
get() = customBrightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
|
get() = normalizeReaderBrightness(customBrightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun normalizeReaderBrightness(brightness: Float): Float {
|
||||||
|
val percent = (brightness * 100f).roundToInt()
|
||||||
|
.coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
|
||||||
|
return percent / 100f
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun stepReaderBrightness(brightness: Float, percentDelta: Int): Float {
|
||||||
|
val currentPercent = (normalizeReaderBrightness(brightness) * 100f).roundToInt()
|
||||||
|
val nextPercent = (currentPercent + percentDelta)
|
||||||
|
.coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
|
||||||
|
return nextPercent / 100f
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
|
fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
|
||||||
|
|
@ -49,7 +71,7 @@ fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
|
||||||
return ReaderBrightnessSettings(
|
return ReaderBrightnessSettings(
|
||||||
useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true),
|
useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true),
|
||||||
customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS)
|
customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS)
|
||||||
.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
|
.let(::normalizeReaderBrightness)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,17 +184,9 @@ fun ReaderBrightnessSheet(
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Slider(
|
ReaderBrightnessControl(
|
||||||
value = settings.safeCustomBrightness,
|
settings = settings,
|
||||||
onValueChange = { brightness ->
|
onSettingsChange = onSettingsChange
|
||||||
onSettingsChange(
|
|
||||||
settings.copy(
|
|
||||||
useSystemBrightness = false,
|
|
||||||
customBrightness = brightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
valueRange = MIN_CUSTOM_BRIGHTNESS..1f
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.reader_brightness_custom_desc),
|
text = stringResource(R.string.reader_brightness_custom_desc),
|
||||||
|
|
@ -186,6 +200,67 @@ fun ReaderBrightnessSheet(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReaderBrightnessControl(
|
||||||
|
settings: ReaderBrightnessSettings,
|
||||||
|
onSettingsChange: (ReaderBrightnessSettings) -> Unit
|
||||||
|
) {
|
||||||
|
val brightness = settings.safeCustomBrightness
|
||||||
|
val canDecrease = brightness > MIN_CUSTOM_BRIGHTNESS
|
||||||
|
val canIncrease = brightness < 1f
|
||||||
|
|
||||||
|
fun updateBrightness(value: Float) {
|
||||||
|
onSettingsChange(
|
||||||
|
settings.copy(
|
||||||
|
useSystemBrightness = false,
|
||||||
|
customBrightness = normalizeReaderBrightness(value)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
updateBrightness(stepReaderBrightness(brightness, -CUSTOM_BRIGHTNESS_STEP_PERCENT))
|
||||||
|
},
|
||||||
|
enabled = canDecrease,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Remove,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_decrease),
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ReaderMinimalSlider(
|
||||||
|
value = brightness,
|
||||||
|
onValueChange = ::updateBrightness,
|
||||||
|
valueRange = MIN_CUSTOM_BRIGHTNESS..1f,
|
||||||
|
activeColor = MaterialTheme.colorScheme.primary,
|
||||||
|
inactiveColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
||||||
|
thumbColor = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
updateBrightness(stepReaderBrightness(brightness, CUSTOM_BRIGHTNESS_STEP_PERCENT))
|
||||||
|
},
|
||||||
|
enabled = canIncrease,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_increase),
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun Window.setReaderBrightness(brightness: Float) {
|
private fun Window.setReaderBrightness(brightness: Float) {
|
||||||
attributes = attributes.apply {
|
attributes = attributes.apply {
|
||||||
screenBrightness = brightness
|
screenBrightness = brightness
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,21 @@ internal fun shouldRenderReaderSlider(
|
||||||
isSearchActive: Boolean
|
isSearchActive: Boolean
|
||||||
): Boolean = isToggledOn && isBottomChromeVisible && !isSearchActive
|
): Boolean = isToggledOn && isBottomChromeVisible && !isSearchActive
|
||||||
|
|
||||||
|
internal fun readerSliderStepPage(
|
||||||
|
currentPage: Int,
|
||||||
|
delta: Int,
|
||||||
|
minPage: Int,
|
||||||
|
maxPage: Int
|
||||||
|
): Int {
|
||||||
|
val lowerBound = min(minPage, maxPage)
|
||||||
|
val upperBound = max(minPage, maxPage)
|
||||||
|
val nextPage = currentPage.toLong() + delta.toLong()
|
||||||
|
|
||||||
|
return nextPage
|
||||||
|
.coerceIn(lowerBound.toLong(), upperBound.toLong())
|
||||||
|
.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
internal fun readerSliderTogglePreferenceKey(bookId: String): String =
|
internal fun readerSliderTogglePreferenceKey(bookId: String): String =
|
||||||
READER_SLIDER_TOGGLE_PREFIX + bookId
|
READER_SLIDER_TOGGLE_PREFIX + bookId
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,9 +142,12 @@ import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.core.text.HtmlCompat
|
import androidx.core.text.HtmlCompat
|
||||||
|
import com.aryan.reader.shared.SharedLegalLinks
|
||||||
|
import com.aryan.reader.shared.SharedLegalProfile
|
||||||
import com.aryan.reader.data.BookMetadataEdit
|
import com.aryan.reader.data.BookMetadataEdit
|
||||||
import com.aryan.reader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import com.aryan.reader.shared.SharedText
|
import com.aryan.reader.shared.SharedText
|
||||||
|
import com.aryan.reader.shared.sharedLegalLinksForProfile
|
||||||
import com.aryan.reader.shared.ui.SharedMarkdownText
|
import com.aryan.reader.shared.ui.SharedMarkdownText
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
|
@ -154,9 +157,25 @@ import kotlin.math.log10
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
|
internal fun legalLinksForAndroidFlavor(flavor: String = BuildConfig.FLAVOR): SharedLegalLinks {
|
||||||
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
|
val profile = if (flavor == "oss") SharedLegalProfile.OSS else SharedLegalProfile.STANDARD
|
||||||
internal const val LICENSES_URL = "https://aryan-raj3112.github.io/reader-policy/licenses.html"
|
return sharedLegalLinksForProfile(profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val PRIVACY_POLICY_URL: String get() = legalLinksForAndroidFlavor().privacyPolicyUrl
|
||||||
|
internal val TERMS_URL: String get() = legalLinksForAndroidFlavor().termsUrl
|
||||||
|
internal val LICENSES_URL: String get() = legalLinksForAndroidFlavor().licensesUrl
|
||||||
|
|
||||||
|
fun supportedFontMimeTypes(): Array<String> = arrayOf(
|
||||||
|
"font/ttf",
|
||||||
|
"font/otf",
|
||||||
|
"font/woff2",
|
||||||
|
"application/x-font-ttf",
|
||||||
|
"application/x-font-otf",
|
||||||
|
"application/font-woff2",
|
||||||
|
"application/vnd.ms-opentype",
|
||||||
|
"application/x-font-opentype"
|
||||||
|
)
|
||||||
|
|
||||||
class CustomTabUriHandler(private val context: Context) : UriHandler {
|
class CustomTabUriHandler(private val context: Context) : UriHandler {
|
||||||
override fun openUri(uri: String) {
|
override fun openUri(uri: String) {
|
||||||
|
|
@ -1237,53 +1256,55 @@ fun AboutDialog(onDismiss: () -> Unit) {
|
||||||
subtitle = stringResource(R.string.about_github_desc),
|
subtitle = stringResource(R.string.about_github_desc),
|
||||||
onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme") }
|
onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme") }
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
AboutInfoRow(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Policy,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = stringResource(R.string.legal_privacy_policy),
|
|
||||||
subtitle = stringResource(R.string.about_privacy_desc),
|
|
||||||
onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
AboutInfoRow(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Gavel,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = stringResource(R.string.legal_terms_of_service),
|
|
||||||
subtitle = stringResource(R.string.about_terms_desc),
|
|
||||||
onClick = { uriHandler.openUri(TERMS_URL) }
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
|
|
||||||
AboutInfoRow(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.FileOpen,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = stringResource(R.string.legal_licenses),
|
|
||||||
subtitle = stringResource(R.string.about_licenses_desc),
|
|
||||||
onClick = { uriHandler.openUri(LICENSES_URL) }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AboutInfoRow(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Policy,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = stringResource(R.string.legal_privacy_policy),
|
||||||
|
subtitle = stringResource(R.string.about_privacy_desc),
|
||||||
|
onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
AboutInfoRow(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Gavel,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = stringResource(R.string.legal_terms_of_service),
|
||||||
|
subtitle = stringResource(R.string.about_terms_desc),
|
||||||
|
onClick = { uriHandler.openUri(TERMS_URL) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
AboutInfoRow(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.FileOpen,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = stringResource(R.string.legal_licenses),
|
||||||
|
subtitle = stringResource(R.string.about_licenses_desc),
|
||||||
|
onClick = { uriHandler.openUri(LICENSES_URL) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,13 @@ import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
|
||||||
import com.aryan.reader.shared.EpubAnnotationSerializer
|
import com.aryan.reader.shared.EpubAnnotationSerializer
|
||||||
import com.aryan.reader.shared.FileType as SharedFileType
|
import com.aryan.reader.shared.FileType as SharedFileType
|
||||||
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
|
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
|
||||||
|
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
|
||||||
import com.aryan.reader.shared.SharedReaderScreenState
|
import com.aryan.reader.shared.SharedReaderScreenState
|
||||||
import com.aryan.reader.shared.Shelf as SharedShelf
|
import com.aryan.reader.shared.Shelf as SharedShelf
|
||||||
import com.aryan.reader.shared.ShelfRecord
|
import com.aryan.reader.shared.ShelfRecord
|
||||||
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
|
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
|
||||||
import com.aryan.reader.shared.Tag as SharedTag
|
import com.aryan.reader.shared.Tag as SharedTag
|
||||||
|
import com.aryan.reader.shared.toStablePositionCfi
|
||||||
|
|
||||||
fun FileType.toSharedFileType(): SharedFileType = this
|
fun FileType.toSharedFileType(): SharedFileType = this
|
||||||
|
|
||||||
|
|
@ -29,11 +31,21 @@ fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder = this
|
||||||
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this
|
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this
|
||||||
|
|
||||||
fun RecentFileItem.toSharedBookItem(): SharedBookItem {
|
fun RecentFileItem.toSharedBookItem(): SharedBookItem {
|
||||||
|
return toSharedBookItem(
|
||||||
|
displayName = customName ?: displayName,
|
||||||
|
includeReaderAnnotations = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RecentFileItem.toSharedBookItem(
|
||||||
|
displayName: String,
|
||||||
|
includeReaderAnnotations: Boolean
|
||||||
|
): SharedBookItem {
|
||||||
return SharedBookItem(
|
return SharedBookItem(
|
||||||
id = bookId,
|
id = bookId,
|
||||||
path = uriString,
|
path = uriString,
|
||||||
type = type,
|
type = type,
|
||||||
displayName = customName ?: displayName,
|
displayName = displayName,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
coverImagePath = coverImagePath,
|
coverImagePath = coverImagePath,
|
||||||
title = title,
|
title = title,
|
||||||
|
|
@ -53,13 +65,22 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem {
|
||||||
seriesName = seriesName,
|
seriesName = seriesName,
|
||||||
seriesIndex = seriesIndex,
|
seriesIndex = seriesIndex,
|
||||||
lastPageIndex = lastPage,
|
lastPageIndex = lastPage,
|
||||||
|
readerPosition = toSharedReaderLocatorOrNull(),
|
||||||
tags = tags.map { it.toSharedTag() },
|
tags = tags.map { it.toSharedTag() },
|
||||||
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
|
readerHighlights = if (includeReaderAnnotations) {
|
||||||
|
EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
},
|
||||||
|
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem {
|
fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem {
|
||||||
return toSharedBookItem().copy(displayName = displayName)
|
return toSharedBookItem(
|
||||||
|
displayName = displayName,
|
||||||
|
includeReaderAnnotations = false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun SharedBookItem.toRecentFileItem(
|
fun SharedBookItem.toRecentFileItem(
|
||||||
|
|
@ -67,36 +88,49 @@ fun SharedBookItem.toRecentFileItem(
|
||||||
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
||||||
): RecentFileItem {
|
): RecentFileItem {
|
||||||
val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
|
val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
|
||||||
return androidBooksById[id]?.copy(tags = resolvedTags)
|
val positionCfi = readerPosition?.toSharedPositionCfi()
|
||||||
?.copy(
|
androidBooksById[id]?.let { existing ->
|
||||||
|
val mappedLastChapterIndex = readerPosition?.chapterIndex ?: existing.lastChapterIndex
|
||||||
|
val mappedLastPositionCfi = positionCfi ?: existing.lastPositionCfi
|
||||||
|
val mappedLocatorBlockIndex = readerPosition?.blockIndex ?: existing.locatorBlockIndex
|
||||||
|
val mappedLocatorCharOffset = readerPosition?.charOffset ?: existing.locatorCharOffset
|
||||||
|
|
||||||
|
if (
|
||||||
|
existing.uriString == path &&
|
||||||
|
existing.type == type &&
|
||||||
|
existing.timestamp == timestamp &&
|
||||||
|
existing.coverImagePath == coverImagePath &&
|
||||||
|
existing.title == title &&
|
||||||
|
existing.author == author &&
|
||||||
|
existing.description == description &&
|
||||||
|
existing.originalTitle == originalTitle &&
|
||||||
|
existing.originalAuthor == originalAuthor &&
|
||||||
|
existing.originalSeriesName == originalSeriesName &&
|
||||||
|
existing.originalSeriesIndex == originalSeriesIndex &&
|
||||||
|
existing.originalDescription == originalDescription &&
|
||||||
|
existing.lastPage == lastPageIndex &&
|
||||||
|
existing.progressPercentage == progressPercentage &&
|
||||||
|
existing.isRecent == isRecent &&
|
||||||
|
existing.sourceFolderUri == sourceFolder &&
|
||||||
|
existing.fileSize == fileSize &&
|
||||||
|
existing.fileContentModifiedTimestamp == fileContentModifiedTimestamp &&
|
||||||
|
existing.seriesName == seriesName &&
|
||||||
|
existing.seriesIndex == seriesIndex &&
|
||||||
|
existing.folderTextMetadataParsed == folderTextMetadataParsed &&
|
||||||
|
existing.lastChapterIndex == mappedLastChapterIndex &&
|
||||||
|
existing.lastPositionCfi == mappedLastPositionCfi &&
|
||||||
|
existing.locatorBlockIndex == mappedLocatorBlockIndex &&
|
||||||
|
existing.locatorCharOffset == mappedLocatorCharOffset &&
|
||||||
|
existing.readingPositionModifiedTimestamp == readingPositionModifiedTimestamp &&
|
||||||
|
existing.tags == resolvedTags
|
||||||
|
) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
return existing.copy(
|
||||||
uriString = path,
|
uriString = path,
|
||||||
type = type,
|
type = type,
|
||||||
displayName = androidBooksById[id]?.displayName ?: displayName,
|
displayName = existing.displayName,
|
||||||
timestamp = timestamp,
|
|
||||||
coverImagePath = coverImagePath,
|
|
||||||
title = title,
|
|
||||||
author = author,
|
|
||||||
description = description,
|
|
||||||
originalTitle = originalTitle,
|
|
||||||
originalAuthor = originalAuthor,
|
|
||||||
originalSeriesName = originalSeriesName,
|
|
||||||
originalSeriesIndex = originalSeriesIndex,
|
|
||||||
originalDescription = originalDescription,
|
|
||||||
lastPage = lastPageIndex,
|
|
||||||
progressPercentage = progressPercentage,
|
|
||||||
isRecent = isRecent,
|
|
||||||
sourceFolderUri = sourceFolder,
|
|
||||||
fileSize = fileSize,
|
|
||||||
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
|
||||||
seriesName = seriesName,
|
|
||||||
seriesIndex = seriesIndex,
|
|
||||||
folderTextMetadataParsed = folderTextMetadataParsed
|
|
||||||
)
|
|
||||||
?: RecentFileItem(
|
|
||||||
bookId = id,
|
|
||||||
uriString = path,
|
|
||||||
type = type,
|
|
||||||
displayName = displayName,
|
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
coverImagePath = coverImagePath,
|
coverImagePath = coverImagePath,
|
||||||
title = title,
|
title = title,
|
||||||
|
|
@ -116,8 +150,70 @@ fun SharedBookItem.toRecentFileItem(
|
||||||
seriesName = seriesName,
|
seriesName = seriesName,
|
||||||
seriesIndex = seriesIndex,
|
seriesIndex = seriesIndex,
|
||||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||||
|
lastChapterIndex = mappedLastChapterIndex,
|
||||||
|
lastPositionCfi = mappedLastPositionCfi,
|
||||||
|
locatorBlockIndex = mappedLocatorBlockIndex,
|
||||||
|
locatorCharOffset = mappedLocatorCharOffset,
|
||||||
|
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
|
||||||
tags = resolvedTags
|
tags = resolvedTags
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RecentFileItem(
|
||||||
|
bookId = id,
|
||||||
|
uriString = path,
|
||||||
|
type = type,
|
||||||
|
displayName = displayName,
|
||||||
|
timestamp = timestamp,
|
||||||
|
coverImagePath = coverImagePath,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
description = description,
|
||||||
|
originalTitle = originalTitle,
|
||||||
|
originalAuthor = originalAuthor,
|
||||||
|
originalSeriesName = originalSeriesName,
|
||||||
|
originalSeriesIndex = originalSeriesIndex,
|
||||||
|
originalDescription = originalDescription,
|
||||||
|
lastPage = lastPageIndex,
|
||||||
|
progressPercentage = progressPercentage,
|
||||||
|
isRecent = isRecent,
|
||||||
|
sourceFolderUri = sourceFolder,
|
||||||
|
fileSize = fileSize,
|
||||||
|
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||||
|
seriesName = seriesName,
|
||||||
|
seriesIndex = seriesIndex,
|
||||||
|
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||||
|
lastChapterIndex = readerPosition?.chapterIndex,
|
||||||
|
lastPositionCfi = positionCfi,
|
||||||
|
locatorBlockIndex = readerPosition?.blockIndex,
|
||||||
|
locatorCharOffset = readerPosition?.charOffset,
|
||||||
|
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
|
||||||
|
tags = resolvedTags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RecentFileItem.toSharedReaderLocatorOrNull(): SharedReaderLocator? {
|
||||||
|
if (
|
||||||
|
lastChapterIndex == null &&
|
||||||
|
lastPage == null &&
|
||||||
|
lastPositionCfi.isNullOrBlank() &&
|
||||||
|
locatorBlockIndex == null &&
|
||||||
|
locatorCharOffset == null
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return SharedReaderLocator.fromLegacy(
|
||||||
|
chapterIndex = lastChapterIndex,
|
||||||
|
cfi = lastPositionCfi,
|
||||||
|
pageIndex = lastPage
|
||||||
|
).withFallbacks(
|
||||||
|
blockIndex = locatorBlockIndex,
|
||||||
|
charOffset = locatorCharOffset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SharedReaderLocator.toSharedPositionCfi(): String? {
|
||||||
|
return toStablePositionCfi()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun TagEntity.toSharedTag(): SharedTag {
|
fun TagEntity.toSharedTag(): SharedTag {
|
||||||
|
|
@ -167,8 +263,16 @@ fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
|
||||||
|
|
||||||
fun ReaderScreenState.toSharedReaderScreenState(
|
fun ReaderScreenState.toSharedReaderScreenState(
|
||||||
rawBooks: List<RecentFileItem> = rawLibraryFiles,
|
rawBooks: List<RecentFileItem> = rawLibraryFiles,
|
||||||
dbTags: List<TagEntity> = allTags
|
dbTags: List<TagEntity> = allTags,
|
||||||
|
includeReaderAnnotations: Boolean = true
|
||||||
): SharedReaderScreenState {
|
): SharedReaderScreenState {
|
||||||
|
fun RecentFileItem.toStateSharedBookItem(): SharedBookItem {
|
||||||
|
return toSharedBookItem(
|
||||||
|
displayName = customName ?: displayName,
|
||||||
|
includeReaderAnnotations = includeReaderAnnotations
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return SharedReaderScreenState(
|
return SharedReaderScreenState(
|
||||||
selectedBookId = selectedBookId,
|
selectedBookId = selectedBookId,
|
||||||
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
|
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
|
||||||
|
|
@ -202,16 +306,16 @@ fun ReaderScreenState.toSharedReaderScreenState(
|
||||||
isSearchActive = isSearchActive,
|
isSearchActive = isSearchActive,
|
||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
reflowProgress = reflowProgress,
|
reflowProgress = reflowProgress,
|
||||||
recentBooks = recentFiles.map { it.toSharedBookItem() },
|
recentBooks = recentFiles.map { it.toStateSharedBookItem() },
|
||||||
libraryBooks = allRecentFiles.map { it.toSharedBookItem() },
|
libraryBooks = allRecentFiles.map { it.toStateSharedBookItem() },
|
||||||
rawLibraryBooks = rawBooks.map { it.toSharedBookItem() },
|
rawLibraryBooks = rawBooks.map { it.toStateSharedBookItem() },
|
||||||
pinnedHomeBookIds = pinnedHomeBookIds,
|
pinnedHomeBookIds = pinnedHomeBookIds,
|
||||||
pinnedLibraryBookIds = pinnedLibraryBookIds,
|
pinnedLibraryBookIds = pinnedLibraryBookIds,
|
||||||
libraryFilters = libraryFilters,
|
libraryFilters = libraryFilters,
|
||||||
recentFilesLimit = recentFilesLimit,
|
recentFilesLimit = recentFilesLimit,
|
||||||
isTabsEnabled = isTabsEnabled,
|
isTabsEnabled = isTabsEnabled,
|
||||||
openTabIds = openTabIds,
|
openTabIds = openTabIds,
|
||||||
openTabs = openTabs.map { it.toSharedBookItem() },
|
openTabs = openTabs.map { it.toStateSharedBookItem() },
|
||||||
activeTabBookId = activeTabBookId,
|
activeTabBookId = activeTabBookId,
|
||||||
showExternalFileSavePromptFor = showExternalFileSavePromptFor,
|
showExternalFileSavePromptFor = showExternalFileSavePromptFor,
|
||||||
externalFileBehavior = externalFileBehavior,
|
externalFileBehavior = externalFileBehavior,
|
||||||
|
|
@ -237,7 +341,14 @@ fun List<RecentFileItem>.withResolvedTags(
|
||||||
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
|
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
|
||||||
entry.value.mapNotNull { tagsById[it.tagId] }
|
entry.value.mapNotNull { tagsById[it.tagId] }
|
||||||
}
|
}
|
||||||
return map { item -> item.copy(tags = bookTagsMap[item.bookId].orEmpty()) }
|
return map { item ->
|
||||||
|
val resolvedTags = bookTagsMap[item.bookId].orEmpty()
|
||||||
|
if (item.tags == resolvedTags) {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
item.copy(tags = resolvedTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun SharedReaderScreenState.toAndroidReaderScreenState(
|
fun SharedReaderScreenState.toAndroidReaderScreenState(
|
||||||
|
|
@ -246,8 +357,11 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
|
||||||
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
||||||
): ReaderScreenState {
|
): ReaderScreenState {
|
||||||
val fallbackBooksById = rawLibraryBooks.associateBy { it.id }
|
val fallbackBooksById = rawLibraryBooks.associateBy { it.id }
|
||||||
|
val mappedBooksById = LinkedHashMap<String, RecentFileItem>()
|
||||||
fun SharedBookItem.toAndroidBook(): RecentFileItem {
|
fun SharedBookItem.toAndroidBook(): RecentFileItem {
|
||||||
return toRecentFileItem(androidBooksById, tagEntitiesById)
|
return mappedBooksById.getOrPut(id) {
|
||||||
|
toRecentFileItem(androidBooksById, tagEntitiesById)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fun bookById(bookId: String): RecentFileItem? {
|
fun bookById(bookId: String): RecentFileItem? {
|
||||||
return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook()
|
return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook()
|
||||||
|
|
@ -260,7 +374,7 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
|
||||||
isAddingBooksToShelf = isAddingBooksToShelf,
|
isAddingBooksToShelf = isAddingBooksToShelf,
|
||||||
contextualActionShelfIds = selectedShelfIds,
|
contextualActionShelfIds = selectedShelfIds,
|
||||||
contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) },
|
contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) },
|
||||||
shelves = shelves.map { it.toAndroidShelf(androidBooksById, tagEntitiesById) },
|
shelves = shelves.map { shelf -> shelf.toAndroidShelf { book -> book.toAndroidBook() } },
|
||||||
openTabs = openTabs.map { it.toAndroidBook() },
|
openTabs = openTabs.map { it.toAndroidBook() },
|
||||||
openTabIds = openTabIds,
|
openTabIds = openTabIds,
|
||||||
activeTabBookId = activeTabBookId,
|
activeTabBookId = activeTabBookId,
|
||||||
|
|
@ -272,13 +386,19 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
|
||||||
fun SharedShelf.toAndroidShelf(
|
fun SharedShelf.toAndroidShelf(
|
||||||
androidBooksById: Map<String, RecentFileItem> = emptyMap(),
|
androidBooksById: Map<String, RecentFileItem> = emptyMap(),
|
||||||
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
||||||
|
): Shelf {
|
||||||
|
return toAndroidShelf { it.toRecentFileItem(androidBooksById, tagEntitiesById) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SharedShelf.toAndroidShelf(
|
||||||
|
resolveBook: (SharedBookItem) -> RecentFileItem
|
||||||
): Shelf {
|
): Shelf {
|
||||||
return Shelf(
|
return Shelf(
|
||||||
id = id,
|
id = id,
|
||||||
name = name,
|
name = name,
|
||||||
type = type,
|
type = type,
|
||||||
books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
|
books = books.map(resolveBook),
|
||||||
directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
|
directBooks = directBooks.map(resolveBook),
|
||||||
parentShelfId = parentShelfId,
|
parentShelfId = parentShelfId,
|
||||||
childShelfIds = childShelfIds,
|
childShelfIds = childShelfIds,
|
||||||
depth = depth,
|
depth = depth,
|
||||||
|
|
|
||||||
108
app/src/main/java/com/aryan/reader/SyncedFolderPrefs.kt
Normal file
108
app/src/main/java/com/aryan/reader/SyncedFolderPrefs.kt
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
internal object SyncedFolderPrefs {
|
||||||
|
const val KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json"
|
||||||
|
const val KEY_LEGACY_SYNCED_FOLDER_URI = "synced_folder_uri"
|
||||||
|
const val KEY_LEGACY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time"
|
||||||
|
|
||||||
|
fun decodeSyncedFolders(
|
||||||
|
jsonString: String?,
|
||||||
|
legacyUri: String?,
|
||||||
|
legacyLastScanTime: Long = 0L,
|
||||||
|
legacyNameResolver: (String) -> String = { it },
|
||||||
|
syncableTypes: Set<FileType> = ANDROID_SYNCABLE_FILE_TYPES
|
||||||
|
): List<SyncedFolder> {
|
||||||
|
if (jsonString == null) {
|
||||||
|
return legacyUri
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { uri ->
|
||||||
|
listOf(
|
||||||
|
SyncedFolder(
|
||||||
|
uriString = uri,
|
||||||
|
name = legacyNameResolver(uri),
|
||||||
|
lastScanTime = legacyLastScanTime,
|
||||||
|
allowedFileTypes = syncableTypes,
|
||||||
|
localSyncEnabled = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val array = JSONArray(jsonString)
|
||||||
|
buildList {
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val obj = array.getJSONObject(i)
|
||||||
|
val uri = obj.optString("uri").takeIf { it.isNotBlank() }
|
||||||
|
if (uri == null) continue
|
||||||
|
val name = obj.optString("name").takeIf { it.isNotBlank() } ?: legacyNameResolver(uri)
|
||||||
|
add(
|
||||||
|
SyncedFolder(
|
||||||
|
uriString = uri,
|
||||||
|
name = name,
|
||||||
|
lastScanTime = obj.optLong("lastScanTime", 0L),
|
||||||
|
allowedFileTypes = decodeAllowedFileTypes(obj, syncableTypes),
|
||||||
|
localSyncEnabled = obj.optBoolean("localSyncEnabled", true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to parse synced folders JSON")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encodeSyncedFolders(
|
||||||
|
folders: List<SyncedFolder>,
|
||||||
|
syncableTypes: Set<FileType> = ANDROID_SYNCABLE_FILE_TYPES
|
||||||
|
): String {
|
||||||
|
val jsonArray = JSONArray()
|
||||||
|
folders.forEach { folder ->
|
||||||
|
val obj = JSONObject()
|
||||||
|
obj.put("uri", folder.uriString)
|
||||||
|
obj.put("name", folder.name)
|
||||||
|
obj.put("lastScanTime", folder.lastScanTime)
|
||||||
|
obj.put("localSyncEnabled", folder.localSyncEnabled)
|
||||||
|
val typesArray = JSONArray()
|
||||||
|
folder.allowedFileTypes
|
||||||
|
.filter { it in syncableTypes }
|
||||||
|
.forEach { typesArray.put(it.name) }
|
||||||
|
obj.put("allowedFileTypes", typesArray)
|
||||||
|
jsonArray.put(obj)
|
||||||
|
}
|
||||||
|
return jsonArray.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isLocalSyncEnabled(
|
||||||
|
jsonString: String?,
|
||||||
|
legacyUri: String?,
|
||||||
|
folderUriString: String,
|
||||||
|
syncableTypes: Set<FileType> = ANDROID_SYNCABLE_FILE_TYPES
|
||||||
|
): Boolean {
|
||||||
|
return decodeSyncedFolders(
|
||||||
|
jsonString = jsonString,
|
||||||
|
legacyUri = legacyUri,
|
||||||
|
syncableTypes = syncableTypes
|
||||||
|
).firstOrNull { it.uriString == folderUriString }?.localSyncEnabled == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeAllowedFileTypes(
|
||||||
|
obj: JSONObject,
|
||||||
|
syncableTypes: Set<FileType>
|
||||||
|
): Set<FileType> {
|
||||||
|
if (!obj.has("allowedFileTypes")) return syncableTypes
|
||||||
|
val typesArray = obj.optJSONArray("allowedFileTypes") ?: return syncableTypes
|
||||||
|
return buildSet {
|
||||||
|
for (i in 0 until typesArray.length()) {
|
||||||
|
val type = runCatching { FileType.valueOf(typesArray.getString(i)) }.getOrNull()
|
||||||
|
if (type != null && type in syncableTypes) add(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
TagEntity::class,
|
TagEntity::class,
|
||||||
BookTagCrossRef::class
|
BookTagCrossRef::class
|
||||||
],
|
],
|
||||||
version = 22,
|
version = 23,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(FileTypeConverter::class)
|
@TypeConverters(FileTypeConverter::class)
|
||||||
|
|
@ -288,6 +288,25 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_22_23 = object : Migration(22, 23) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE recent_files ADD COLUMN readingPositionModifiedTimestamp INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL("""
|
||||||
|
UPDATE recent_files
|
||||||
|
SET readingPositionModifiedTimestamp = lastModifiedTimestamp
|
||||||
|
WHERE lastModifiedTimestamp > 0
|
||||||
|
AND (
|
||||||
|
lastChapterIndex IS NOT NULL OR
|
||||||
|
lastPage IS NOT NULL OR
|
||||||
|
lastPositionCfi IS NOT NULL OR
|
||||||
|
locatorBlockIndex IS NOT NULL OR
|
||||||
|
locatorCharOffset IS NOT NULL OR
|
||||||
|
COALESCE(progressPercentage, 0) > 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getDatabase(context: Context): AppDatabase {
|
fun getDatabase(context: Context): AppDatabase {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
val instance = Room.databaseBuilder(
|
val instance = Room.databaseBuilder(
|
||||||
|
|
@ -301,7 +320,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
||||||
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
|
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
|
||||||
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20,
|
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20,
|
||||||
MIGRATION_20_21, MIGRATION_21_22
|
MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration(false)
|
.fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import android.provider.DocumentsContract
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.aryan.reader.ReaderPerfLog
|
import com.aryan.reader.ReaderPerfLog
|
||||||
import com.aryan.reader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX
|
import com.aryan.reader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX
|
||||||
|
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||||
import com.aryan.reader.shared.localFolderSyncAnnotationFileName
|
import com.aryan.reader.shared.localFolderSyncAnnotationFileName
|
||||||
import com.aryan.reader.shared.localFolderSyncAnnotationTempFileName
|
import com.aryan.reader.shared.localFolderSyncAnnotationTempFileName
|
||||||
import com.aryan.reader.shared.localFolderSyncMetadataFileName
|
import com.aryan.reader.shared.localFolderSyncMetadataFileName
|
||||||
|
|
@ -21,7 +22,7 @@ import timber.log.Timber
|
||||||
object LocalSyncUtils {
|
object LocalSyncUtils {
|
||||||
private const val TAG = "FolderSync"
|
private const val TAG = "FolderSync"
|
||||||
private const val ANNOTATION_SUFFIX = "_annotations"
|
private const val ANNOTATION_SUFFIX = "_annotations"
|
||||||
private const val SYNC_SUBFOLDER_NAME = "EpistemeSyncData"
|
private const val SYNC_SUBFOLDER_NAME = LOCAL_FOLDER_SYNC_DATA_DIR
|
||||||
|
|
||||||
private data class SyncFileEntry(
|
private data class SyncFileEntry(
|
||||||
val name: String,
|
val name: String,
|
||||||
|
|
@ -638,6 +639,21 @@ object LocalSyncUtils {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun deleteSyncDataFolder(
|
||||||
|
context: Context,
|
||||||
|
sourceFolderUri: Uri
|
||||||
|
): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext false
|
||||||
|
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext true
|
||||||
|
if (!syncDir.isDirectory) return@withContext false
|
||||||
|
syncDir.delete()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(TAG).e(e, "Failed to delete sync data folder")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getAllFolderMetadata(
|
suspend fun getAllFolderMetadata(
|
||||||
context: Context,
|
context: Context,
|
||||||
sourceFolderUri: Uri
|
sourceFolderUri: Uri
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ interface RecentFileDao {
|
||||||
@Upsert
|
@Upsert
|
||||||
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
|
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
|
||||||
|
|
||||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
|
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
|
||||||
fun getRecentFiles(): Flow<List<RecentFileSummary>>
|
fun getRecentFiles(): Flow<List<RecentFileSummary>>
|
||||||
|
|
||||||
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
|
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
|
||||||
|
|
@ -45,7 +45,7 @@ interface RecentFileDao {
|
||||||
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
|
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
|
||||||
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
|
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
|
||||||
|
|
||||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
|
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
|
||||||
fun getRecentFilesList(limit: Int): List<RecentFileSummary>
|
fun getRecentFilesList(limit: Int): List<RecentFileSummary>
|
||||||
|
|
||||||
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
|
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
|
||||||
|
|
@ -75,10 +75,10 @@ interface RecentFileDao {
|
||||||
@Query("DELETE FROM recent_files")
|
@Query("DELETE FROM recent_files")
|
||||||
suspend fun clearAll()
|
suspend fun clearAll()
|
||||||
|
|
||||||
@Query("UPDATE recent_files SET lastPositionCfi = :cfi, lastChapterIndex = :chapterIndex, locatorBlockIndex = :blockIndex, locatorCharOffset = :charOffset, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
|
@Query("UPDATE recent_files SET lastPositionCfi = :cfi, lastChapterIndex = :chapterIndex, locatorBlockIndex = :blockIndex, locatorCharOffset = :charOffset, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp, readingPositionModifiedTimestamp = :timestamp WHERE bookId = :bookId")
|
||||||
suspend fun updateEpubReadingPosition(bookId: String, cfi: String?, chapterIndex: Int, blockIndex: Int, charOffset: Int, progress: Float, timestamp: Long)
|
suspend fun updateEpubReadingPosition(bookId: String, cfi: String?, chapterIndex: Int, blockIndex: Int, charOffset: Int, progress: Float, timestamp: Long)
|
||||||
|
|
||||||
@Query("UPDATE recent_files SET lastPage = :page, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
|
@Query("UPDATE recent_files SET lastPage = :page, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp, readingPositionModifiedTimestamp = :timestamp WHERE bookId = :bookId")
|
||||||
suspend fun updatePdfReadingPosition(bookId: String, page: Int, progress: Float, timestamp: Long)
|
suspend fun updatePdfReadingPosition(bookId: String, page: Int, progress: Float, timestamp: Long)
|
||||||
|
|
||||||
@Query("UPDATE recent_files SET bookmarks = :bookmarksJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
|
@Query("UPDATE recent_files SET bookmarks = :bookmarksJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,8 @@ data class RecentFileEntity(
|
||||||
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
|
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
|
||||||
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
|
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
|
||||||
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
|
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
|
||||||
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
|
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "0") val readingPositionModifiedTimestamp: Long = 0L
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RecentFileSummary(
|
data class RecentFileSummary(
|
||||||
|
|
@ -96,5 +97,6 @@ data class RecentFileSummary(
|
||||||
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
|
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
|
||||||
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
|
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
|
||||||
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
|
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
|
||||||
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
|
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "0") val readingPositionModifiedTimestamp: Long = 0L
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,25 @@ data class RecentFileItem(
|
||||||
val originalDescription: String? = null,
|
val originalDescription: String? = null,
|
||||||
val folderTextMetadataParsed: Boolean = false,
|
val folderTextMetadataParsed: Boolean = false,
|
||||||
val folderCoverMetadataParsed: Boolean = false,
|
val folderCoverMetadataParsed: Boolean = false,
|
||||||
|
val readingPositionModifiedTimestamp: Long = 0L,
|
||||||
val tags: List<TagEntity> = emptyList()
|
val tags: List<TagEntity> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun RecentFileItem.hasReadingPositionForSync(): Boolean {
|
||||||
|
return lastChapterIndex != null ||
|
||||||
|
lastPage != null ||
|
||||||
|
!lastPositionCfi.isNullOrBlank() ||
|
||||||
|
locatorBlockIndex != null ||
|
||||||
|
locatorCharOffset != null ||
|
||||||
|
(progressPercentage ?: 0f) > 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun RecentFileItem.effectiveReadingPositionModifiedTimestamp(): Long {
|
||||||
|
return readingPositionModifiedTimestamp.takeIf { it > 0L }
|
||||||
|
?: lastModifiedTimestamp.takeIf { hasReadingPositionForSync() }
|
||||||
|
?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
|
fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
|
||||||
return RecentFileItem(
|
return RecentFileItem(
|
||||||
bookId = this.bookId,
|
bookId = this.bookId,
|
||||||
|
|
@ -96,7 +112,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
|
||||||
originalSeriesIndex = this.originalSeriesIndex,
|
originalSeriesIndex = this.originalSeriesIndex,
|
||||||
originalDescription = this.originalDescription,
|
originalDescription = this.originalDescription,
|
||||||
folderTextMetadataParsed = this.folderTextMetadataParsed,
|
folderTextMetadataParsed = this.folderTextMetadataParsed,
|
||||||
folderCoverMetadataParsed = this.folderCoverMetadataParsed
|
folderCoverMetadataParsed = this.folderCoverMetadataParsed,
|
||||||
|
readingPositionModifiedTimestamp = this.readingPositionModifiedTimestamp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,7 +153,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
|
||||||
originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex,
|
originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex,
|
||||||
originalDescription = this.originalDescription ?: this.description,
|
originalDescription = this.originalDescription ?: this.description,
|
||||||
folderTextMetadataParsed = this.folderTextMetadataParsed,
|
folderTextMetadataParsed = this.folderTextMetadataParsed,
|
||||||
folderCoverMetadataParsed = this.folderCoverMetadataParsed
|
folderCoverMetadataParsed = this.folderCoverMetadataParsed,
|
||||||
|
readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,6 +174,7 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
|
||||||
isRecent = this.isRecent,
|
isRecent = this.isRecent,
|
||||||
isDeleted = this.isDeleted,
|
isDeleted = this.isDeleted,
|
||||||
lastModifiedTimestamp = this.lastModifiedTimestamp,
|
lastModifiedTimestamp = this.lastModifiedTimestamp,
|
||||||
|
readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp(),
|
||||||
bookmarksJson = this.bookmarksJson,
|
bookmarksJson = this.bookmarksJson,
|
||||||
hasAnnotations = false,
|
hasAnnotations = false,
|
||||||
customName = this.customName,
|
customName = this.customName,
|
||||||
|
|
@ -172,6 +191,27 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun BookMetadata.hasReadingPositionForSync(): Boolean {
|
||||||
|
return lastChapterIndex != null ||
|
||||||
|
lastPage != null ||
|
||||||
|
!lastPositionCfi.isNullOrBlank() ||
|
||||||
|
locatorBlockIndex != null ||
|
||||||
|
locatorCharOffset != null ||
|
||||||
|
(progressPercentage ?: 0f) > 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BookMetadata.effectiveReadingPositionModifiedTimestamp(): Long {
|
||||||
|
return readingPositionModifiedTimestamp.takeIf { it > 0L }
|
||||||
|
?: lastModifiedTimestamp.takeIf { hasReadingPositionForSync() }
|
||||||
|
?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BookMetadata.effectiveAnnotationModifiedTimestamp(sidecarModifiedTimestamp: Long = 0L): Long {
|
||||||
|
return sidecarModifiedTimestamp.takeIf { it > 0L }
|
||||||
|
?: annotationModifiedTimestamp.takeIf { it > 0L }
|
||||||
|
?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
fun BookMetadata.toRecentFileItem(): RecentFileItem {
|
fun BookMetadata.toRecentFileItem(): RecentFileItem {
|
||||||
return RecentFileItem(
|
return RecentFileItem(
|
||||||
bookId = this.bookId,
|
bookId = this.bookId,
|
||||||
|
|
@ -203,7 +243,8 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem {
|
||||||
originalAuthor = this.originalAuthor,
|
originalAuthor = this.originalAuthor,
|
||||||
originalSeriesName = this.originalSeriesName,
|
originalSeriesName = this.originalSeriesName,
|
||||||
originalSeriesIndex = this.originalSeriesIndex,
|
originalSeriesIndex = this.originalSeriesIndex,
|
||||||
originalDescription = this.originalDescription
|
originalDescription = this.originalDescription,
|
||||||
|
readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,6 +282,7 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem {
|
||||||
originalAuthor = this.originalAuthor,
|
originalAuthor = this.originalAuthor,
|
||||||
originalSeriesName = this.originalSeriesName,
|
originalSeriesName = this.originalSeriesName,
|
||||||
originalSeriesIndex = this.originalSeriesIndex,
|
originalSeriesIndex = this.originalSeriesIndex,
|
||||||
originalDescription = this.originalDescription
|
originalDescription = this.originalDescription,
|
||||||
|
readingPositionModifiedTimestamp = this.readingPositionModifiedTimestamp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,15 @@ import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.aryan.reader.FileType
|
import com.aryan.reader.FileType
|
||||||
import com.aryan.reader.ReaderPerfLog
|
import com.aryan.reader.ReaderPerfLog
|
||||||
|
import com.aryan.reader.SyncedFolderPrefs
|
||||||
|
import com.aryan.reader.cloudSyncPreview
|
||||||
|
import com.aryan.reader.cloudSyncTraceSummary
|
||||||
|
import com.aryan.reader.logCloudAnnotationSyncTrace
|
||||||
|
import com.aryan.reader.logCloudSyncTrace
|
||||||
import com.aryan.reader.scaledToCanvasLimit
|
import com.aryan.reader.scaledToCanvasLimit
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import com.aryan.reader.BookImporter
|
import com.aryan.reader.BookImporter
|
||||||
|
import com.aryan.reader.cloudSyncAnnotationSummary
|
||||||
import com.aryan.reader.paginatedreader.Locator
|
import com.aryan.reader.paginatedreader.Locator
|
||||||
import com.aryan.reader.pdf.PdfRichTextRepository
|
import com.aryan.reader.pdf.PdfRichTextRepository
|
||||||
import com.aryan.reader.epub.ImportedFileCache
|
import com.aryan.reader.epub.ImportedFileCache
|
||||||
|
|
@ -189,7 +195,20 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
!embeddedMetadataFileChanged &&
|
!embeddedMetadataFileChanged &&
|
||||||
existingItem.hasEmbeddedMetadataChanges()
|
existingItem.hasEmbeddedMetadataChanges()
|
||||||
|
|
||||||
item.toRecentFileEntity().copy(
|
val incomingEntity = item.toRecentFileEntity()
|
||||||
|
val incomingReadingTimestamp = item.effectiveReadingPositionModifiedTimestamp()
|
||||||
|
val existingReadingTimestamp = existingItem.readingPositionModifiedTimestamp.takeIf { it > 0L }
|
||||||
|
?: existingItem.lastModifiedTimestamp.takeIf {
|
||||||
|
existingItem.lastChapterIndex != null ||
|
||||||
|
existingItem.lastPage != null ||
|
||||||
|
!existingItem.lastPositionCfi.isNullOrBlank() ||
|
||||||
|
existingItem.locatorBlockIndex != null ||
|
||||||
|
existingItem.locatorCharOffset != null ||
|
||||||
|
(existingItem.progressPercentage ?: 0f) > 0f
|
||||||
|
}
|
||||||
|
?: 0L
|
||||||
|
val incomingReadingWins = incomingReadingTimestamp >= existingReadingTimestamp
|
||||||
|
incomingEntity.copy(
|
||||||
uriString = existingItem.uriString ?: item.uriString,
|
uriString = existingItem.uriString ?: item.uriString,
|
||||||
isAvailable = existingItem.isAvailable || item.isAvailable,
|
isAvailable = existingItem.isAvailable || item.isAvailable,
|
||||||
coverImagePath = if (folderFileChanged) {
|
coverImagePath = if (folderFileChanged) {
|
||||||
|
|
@ -211,13 +230,13 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
} else {
|
} else {
|
||||||
item.author ?: existingItem.author
|
item.author ?: existingItem.author
|
||||||
},
|
},
|
||||||
lastChapterIndex = item.lastChapterIndex ?: existingItem.lastChapterIndex,
|
lastChapterIndex = if (incomingReadingWins) item.lastChapterIndex ?: existingItem.lastChapterIndex else existingItem.lastChapterIndex,
|
||||||
lastPage = item.lastPage ?: existingItem.lastPage,
|
lastPage = if (incomingReadingWins) item.lastPage ?: existingItem.lastPage else existingItem.lastPage,
|
||||||
lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi,
|
lastPositionCfi = if (incomingReadingWins) item.lastPositionCfi ?: existingItem.lastPositionCfi else existingItem.lastPositionCfi,
|
||||||
locatorBlockIndex = item.locatorBlockIndex ?: existingItem.locatorBlockIndex,
|
locatorBlockIndex = if (incomingReadingWins) item.locatorBlockIndex ?: existingItem.locatorBlockIndex else existingItem.locatorBlockIndex,
|
||||||
locatorCharOffset = item.locatorCharOffset ?: existingItem.locatorCharOffset,
|
locatorCharOffset = if (incomingReadingWins) item.locatorCharOffset ?: existingItem.locatorCharOffset else existingItem.locatorCharOffset,
|
||||||
bookmarks = item.bookmarksJson ?: existingItem.bookmarks,
|
bookmarks = item.bookmarksJson ?: existingItem.bookmarks,
|
||||||
progressPercentage = item.progressPercentage ?: existingItem.progressPercentage,
|
progressPercentage = if (incomingReadingWins) item.progressPercentage ?: existingItem.progressPercentage else existingItem.progressPercentage,
|
||||||
isRecent = item.isRecent,
|
isRecent = item.isRecent,
|
||||||
isDeleted = item.isDeleted,
|
isDeleted = item.isDeleted,
|
||||||
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
|
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
|
||||||
|
|
@ -259,13 +278,26 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
item.folderCoverMetadataParsed
|
item.folderCoverMetadataParsed
|
||||||
} else {
|
} else {
|
||||||
item.folderCoverMetadataParsed || existingItem.folderCoverMetadataParsed
|
item.folderCoverMetadataParsed || existingItem.folderCoverMetadataParsed
|
||||||
}
|
},
|
||||||
|
readingPositionModifiedTimestamp = maxOf(incomingReadingTimestamp, existingReadingTimestamp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
item.toRecentFileEntity()
|
item.toRecentFileEntity()
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.d("SyncDebug: -> Final entity to insert: uri='${entityToInsert.uriString}', isAvailable=${entityToInsert.isAvailable}, isDeleted=${entityToInsert.isDeleted}, isRecent=${entityToInsert.isRecent}")
|
Timber.d("SyncDebug: -> Final entity to insert: uri='${entityToInsert.uriString}', isAvailable=${entityToInsert.isAvailable}, isDeleted=${entityToInsert.isDeleted}, isRecent=${entityToInsert.isRecent}")
|
||||||
|
logCloudSyncTrace {
|
||||||
|
"android.db.upsert book=${item.bookId} ${item.cloudSyncTraceSummary("incoming")} " +
|
||||||
|
"existingTs=${existingItem?.lastModifiedTimestamp} existingPage=${existingItem?.lastPage} " +
|
||||||
|
"existingReadTs=${existingItem?.readingPositionModifiedTimestamp} " +
|
||||||
|
"existingChapter=${existingItem?.lastChapterIndex} finalTs=${entityToInsert.lastModifiedTimestamp} " +
|
||||||
|
"finalReadTs=${entityToInsert.readingPositionModifiedTimestamp} " +
|
||||||
|
"finalPage=${entityToInsert.lastPage} finalChapter=${entityToInsert.lastChapterIndex} " +
|
||||||
|
"finalBlock=${entityToInsert.locatorBlockIndex} finalChar=${entityToInsert.locatorCharOffset} " +
|
||||||
|
"finalProgress=${entityToInsert.progressPercentage} finalCfi=${entityToInsert.lastPositionCfi.cloudSyncPreview()} " +
|
||||||
|
"finalBookmarks=${entityToInsert.bookmarks.cloudSyncAnnotationSummary()} " +
|
||||||
|
"finalHighlights=${entityToInsert.highlights.cloudSyncAnnotationSummary()}"
|
||||||
|
}
|
||||||
recentFileDao.insertOrUpdateFile(entityToInsert)
|
recentFileDao.insertOrUpdateFile(entityToInsert)
|
||||||
Timber.d("Added/Updated recent file in DB: ${item.displayName}")
|
Timber.d("Added/Updated recent file in DB: ${item.displayName}")
|
||||||
}
|
}
|
||||||
|
|
@ -328,6 +360,11 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
val folderUriString = entity.sourceFolderUri
|
val folderUriString = entity.sourceFolderUri
|
||||||
|
|
||||||
if (folderUriString != null) {
|
if (folderUriString != null) {
|
||||||
|
if (!isLocalFolderSyncEnabled(folderUriString)) {
|
||||||
|
Timber.d("SyncDebug: Folder sync disabled for $folderUriString. Skipping metadata sidecar.")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
val hasProgress = (entity.progressPercentage != null && entity.progressPercentage > 0f)
|
val hasProgress = (entity.progressPercentage != null && entity.progressPercentage > 0f)
|
||||||
val hasBookmarks = !entity.bookmarks.isNullOrEmpty() && entity.bookmarks != "[]"
|
val hasBookmarks = !entity.bookmarks.isNullOrEmpty() && entity.bookmarks != "[]"
|
||||||
val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]"
|
val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]"
|
||||||
|
|
@ -385,6 +422,10 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
Timber.tag("FolderAnnotationSync").w("sourceFolderUri is null for bookId: $bookId")
|
Timber.tag("FolderAnnotationSync").w("sourceFolderUri is null for bookId: $bookId")
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
if (!isLocalFolderSyncEnabled(folderUriString)) {
|
||||||
|
Timber.tag("FolderAnnotationSync").d("Folder sync disabled for $folderUriString. Skipping annotation sidecar.")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId)
|
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId)
|
||||||
val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
|
val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
|
||||||
|
|
@ -466,12 +507,20 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun importAnnotationBundle(bookId: String, jsonString: String) = withContext(Dispatchers.IO) {
|
suspend fun importAnnotationBundle(
|
||||||
|
bookId: String,
|
||||||
|
jsonString: String,
|
||||||
|
lastModifiedTimestamp: Long? = null
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
Timber.tag("FolderAnnotationSync").d("importAnnotationBundle: Processing bundle for $bookId")
|
Timber.tag("FolderAnnotationSync").d("importAnnotationBundle: Processing bundle for $bookId")
|
||||||
try {
|
try {
|
||||||
val bundle = JSONObject(
|
val bundle = JSONObject(
|
||||||
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
|
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
|
||||||
)
|
)
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.repository.import_bundle book=$bookId remoteTs=${lastModifiedTimestamp ?: 0L} " +
|
||||||
|
"rawBytes=${jsonString.length} keys=${bundle.keys().asSequence().toList()}"
|
||||||
|
}
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " +
|
"android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " +
|
||||||
"hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
|
"hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
|
||||||
|
|
@ -482,12 +531,22 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
file.parentFile?.mkdirs()
|
file.parentFile?.mkdirs()
|
||||||
val contentStr = bundle.get(key).toString()
|
val contentStr = bundle.get(key).toString()
|
||||||
file.writeText(contentStr)
|
file.writeText(contentStr)
|
||||||
|
lastModifiedTimestamp?.takeIf { it > 0L }?.let(file::setLastModified)
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.repository.import_write key=$key book=$bookId bytes=${contentStr.length} " +
|
||||||
|
"path=${file.absolutePath.cloudSyncPreview(140)} ts=${file.lastModified()}"
|
||||||
|
}
|
||||||
if (key == "text") {
|
if (key == "text") {
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}"
|
"android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Timber.tag("FolderAnnotationSync").v(" -> Updated $key file (${contentStr.length} chars)")
|
Timber.tag("FolderAnnotationSync").v(" -> Updated $key file (${contentStr.length} chars)")
|
||||||
|
} else if (file != null) {
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.repository.import_missing_key key=$key book=$bookId " +
|
||||||
|
"path=${file.absolutePath.cloudSyncPreview(140)} exists=${file.exists()}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -496,6 +555,10 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
context.filesDir, "annotations/annotation_$bookId.json"
|
context.filesDir, "annotations/annotation_$bookId.json"
|
||||||
)
|
)
|
||||||
writeSafe("ink", inkFile)
|
writeSafe("ink", inkFile)
|
||||||
|
writeSafe(
|
||||||
|
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS,
|
||||||
|
File(context.filesDir, "annotations/deleted_annotation_$bookId.json")
|
||||||
|
)
|
||||||
|
|
||||||
// 2. Text
|
// 2. Text
|
||||||
writeSafe("text", pdfRichTextRepository.getFileForSync(bookId))
|
writeSafe("text", pdfRichTextRepository.getFileForSync(bookId))
|
||||||
|
|
@ -606,6 +669,15 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
Timber.d("Detached all folder books. They are now standard local files.")
|
Timber.d("Detached all folder books. They are now standard local files.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isLocalFolderSyncEnabled(folderUriString: String): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
return SyncedFolderPrefs.isLocalSyncEnabled(
|
||||||
|
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
|
||||||
|
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
|
||||||
|
folderUriString = folderUriString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) {
|
suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) {
|
||||||
val currentTime = System.currentTimeMillis()
|
val currentTime = System.currentTimeMillis()
|
||||||
recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime)
|
recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime)
|
||||||
|
|
@ -618,6 +690,9 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
val currentTime = System.currentTimeMillis()
|
val currentTime = System.currentTimeMillis()
|
||||||
recentFileDao.updatePdfReadingPosition(item.bookId, page, progress, currentTime)
|
recentFileDao.updatePdfReadingPosition(item.bookId, page, progress, currentTime)
|
||||||
Timber.tag("PdfPositionDebug").i("Repository: Executed DB update for ${item.bookId} to Page $page, Progress $progress% at TS: $currentTime")
|
Timber.tag("PdfPositionDebug").i("Repository: Executed DB update for ${item.bookId} to Page $page, Progress $progress% at TS: $currentTime")
|
||||||
|
logCloudSyncTrace {
|
||||||
|
"android.repository.pdf_position_update book=${item.bookId} page=$page progress=$progress ts=$currentTime"
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.tag("PdfPositionDebug").e("Repository: DB Update Failed! No recent file found matching URI: $uriString")
|
Timber.tag("PdfPositionDebug").e("Repository: DB Update Failed! No recent file found matching URI: $uriString")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ import timber.log.Timber
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
import java.util.zip.ZipInputStream
|
import java.util.zip.ZipInputStream
|
||||||
import javax.xml.parsers.DocumentBuilderFactory
|
|
||||||
|
|
||||||
data class CalibreBundleResult(
|
data class CalibreBundleResult(
|
||||||
val internalBookUri: Uri,
|
val internalBookUri: Uri,
|
||||||
|
|
@ -89,7 +89,7 @@ object CalibreBundleExtractor {
|
||||||
|
|
||||||
if (tempBookFile != null && opfData != null && extractedType != null) {
|
if (tempBookFile != null && opfData != null && extractedType != null) {
|
||||||
val finalBookFile = bookImporter.createBookFile("$bookId.$ext")
|
val finalBookFile = bookImporter.createBookFile("$bookId.$ext")
|
||||||
tempBookFile!!.renameTo(finalBookFile)
|
moveExtractedBook(tempBookFile!!, finalBookFile)
|
||||||
|
|
||||||
var coverPath: String? = null
|
var coverPath: String? = null
|
||||||
if (coverBytes != null) {
|
if (coverBytes != null) {
|
||||||
|
|
@ -106,7 +106,7 @@ object CalibreBundleExtractor {
|
||||||
var seriesIndex: Double? = null
|
var seriesIndex: Double? = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val factory = DocumentBuilderFactory.newInstance()
|
val factory = secureDocumentBuilderFactory()
|
||||||
val builder = factory.newDocumentBuilder()
|
val builder = factory.newDocumentBuilder()
|
||||||
val document = builder.parse(ByteArrayInputStream(opfData!!.toByteArray(Charsets.UTF_8)))
|
val document = builder.parse(ByteArrayInputStream(opfData!!.toByteArray(Charsets.UTF_8)))
|
||||||
val metadataNodes = document.getElementsByTagName("metadata")
|
val metadataNodes = document.getElementsByTagName("metadata")
|
||||||
|
|
@ -159,8 +159,25 @@ object CalibreBundleExtractor {
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to process zip bundle")
|
Timber.e(e, "Failed to process zip bundle")
|
||||||
} finally {
|
} finally {
|
||||||
tempBookFile?.delete() // Cleanup if parsing failed midway
|
tempBookFile?.takeIf { it.exists() }?.delete()
|
||||||
}
|
}
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun moveExtractedBook(tempBookFile: File, finalBookFile: File) {
|
||||||
|
finalBookFile.parentFile?.mkdirs()
|
||||||
|
if (finalBookFile.exists() && !finalBookFile.delete()) {
|
||||||
|
throw IOException("Could not replace existing book file: ${finalBookFile.absolutePath}")
|
||||||
|
}
|
||||||
|
if (tempBookFile.renameTo(finalBookFile)) return
|
||||||
|
|
||||||
|
tempBookFile.inputStream().use { input ->
|
||||||
|
FileOutputStream(finalBookFile).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!finalBookFile.isFile) {
|
||||||
|
throw IOException("Could not move extracted book to: ${finalBookFile.absolutePath}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -33,5 +33,10 @@ data class EpubChapter @OptIn(ExperimentalSerializationApi::class) constructor(
|
||||||
@ProtoNumber(5) val plainTextContent: String,
|
@ProtoNumber(5) val plainTextContent: String,
|
||||||
@ProtoNumber(6) val htmlContent: String,
|
@ProtoNumber(6) val htmlContent: String,
|
||||||
@ProtoNumber(7) val depth: Int = 0,
|
@ProtoNumber(7) val depth: Int = 0,
|
||||||
@ProtoNumber(8) val isInToc: Boolean = true
|
@ProtoNumber(8) val isInToc: Boolean = true,
|
||||||
|
@ProtoNumber(9) val plainTextLength: Int = plainTextContent.length
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun EpubChapter.plainTextCharacterCount(): Int {
|
||||||
|
return maxOf(plainTextLength, plainTextContent.length)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,22 +23,53 @@ import org.w3c.dom.Document
|
||||||
import org.w3c.dom.Element
|
import org.w3c.dom.Element
|
||||||
import org.w3c.dom.Node
|
import org.w3c.dom.Node
|
||||||
import org.w3c.dom.NodeList
|
import org.w3c.dom.NodeList
|
||||||
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import javax.xml.XMLConstants
|
||||||
import javax.xml.parsers.DocumentBuilderFactory
|
import javax.xml.parsers.DocumentBuilderFactory
|
||||||
|
|
||||||
fun parseXMLFile(inputSteam: InputStream): Document? =
|
fun parseXMLFile(inputSteam: InputStream): Document? =
|
||||||
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(inputSteam)
|
secureDocumentBuilderFactory().newDocumentBuilder().parse(inputSteam)
|
||||||
|
|
||||||
fun parseXMLFile(byteArray: ByteArray): Document? = parseXMLFile(byteArray.inputStream())
|
fun parseXMLFile(byteArray: ByteArray): Document? = parseXMLFile(byteArray.inputStream())
|
||||||
|
|
||||||
fun String.asFileName(): String = this.replace("/", "_")
|
fun String.asFileName(): String = this.replace("/", "_")
|
||||||
|
|
||||||
|
internal fun secureDocumentBuilderFactory(): DocumentBuilderFactory {
|
||||||
|
return DocumentBuilderFactory.newInstance().apply {
|
||||||
|
isNamespaceAware = false
|
||||||
|
setFeatureSafely(XMLConstants.FEATURE_SECURE_PROCESSING, true)
|
||||||
|
setFeatureSafely("http://apache.org/xml/features/disallow-doctype-decl", true)
|
||||||
|
setFeatureSafely("http://xml.org/sax/features/external-general-entities", false)
|
||||||
|
setFeatureSafely("http://xml.org/sax/features/external-parameter-entities", false)
|
||||||
|
setFeatureSafely("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
|
||||||
|
runCatching { isXIncludeAware = false }
|
||||||
|
runCatching { isExpandEntityReferences = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DocumentBuilderFactory.setFeatureSafely(name: String, value: Boolean) {
|
||||||
|
runCatching { setFeature(name, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun safeFileInRoot(root: File, childPath: String): File? {
|
||||||
|
val rootFile = runCatching { root.canonicalFile }.getOrNull() ?: return null
|
||||||
|
val targetFile = runCatching { File(rootFile, childPath).canonicalFile }.getOrNull() ?: return null
|
||||||
|
return targetFile.takeIf { it.isInsideOrSame(rootFile) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun File.isInsideOrSame(root: File): Boolean {
|
||||||
|
val rootPath = runCatching { root.canonicalFile.path }.getOrNull() ?: return false
|
||||||
|
val targetPath = runCatching { canonicalFile.path }.getOrNull() ?: return false
|
||||||
|
return targetPath == rootPath || targetPath.startsWith(rootPath + File.separator)
|
||||||
|
}
|
||||||
|
|
||||||
fun Document.selectFirstTag(tag: String): Node? = getElementsByTagName(tag).item(0)
|
fun Document.selectFirstTag(tag: String): Node? = getElementsByTagName(tag).item(0)
|
||||||
fun Node.selectFirstChildTag(tag: String) = childElements.find { it.tagName == tag }
|
fun Node.selectFirstChildTag(tag: String) = childElements.find { it.tagName == tag }
|
||||||
fun Node.selectChildTag(tag: String) = childElements.filter { it.tagName == tag }
|
fun Node.selectChildTag(tag: String) = childElements.filter { it.tagName == tag }
|
||||||
fun Node.getAttributeValue(attribute: String): String? =
|
fun Node.getAttributeValue(attribute: String): String? =
|
||||||
attributes?.getNamedItem(attribute)?.textContent
|
attributes?.getNamedItem(attribute)?.textContent
|
||||||
|
|
||||||
val NodeList.elements get() = (0..length).asSequence().mapNotNull { item(it) as? Element }
|
val NodeList.elements get() = (0 until length).asSequence().mapNotNull { item(it) as? Element }
|
||||||
val Node.childElements get() = childNodes.elements
|
val Node.childElements get() = childNodes.elements
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.util.zip.ZipInputStream
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
class Fb2Parser(private val context: Context) {
|
class Fb2Parser(private val context: Context) {
|
||||||
|
|
@ -210,7 +211,12 @@ class Fb2Parser(private val context: Context) {
|
||||||
if (!inBody) {
|
if (!inBody) {
|
||||||
if (coverImageId == null) coverImageId = id
|
if (coverImageId == null) coverImageId = id
|
||||||
} else {
|
} else {
|
||||||
currentChapterHtml.append("<img src=\"$id\" />")
|
val safeImageName = safeResourceFileName(id)
|
||||||
|
if (safeImageName != null) {
|
||||||
|
currentChapterHtml.append("<img src=\"$safeImageName\" />")
|
||||||
|
} else {
|
||||||
|
Timber.w("Skipping unsafe FB2 image reference: $id")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -220,12 +226,23 @@ class Fb2Parser(private val context: Context) {
|
||||||
val base64Data = parser.nextText()
|
val base64Data = parser.nextText()
|
||||||
try {
|
try {
|
||||||
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
|
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
|
||||||
|
val safeId = safeResourceFileName(id)
|
||||||
if (parseContent) {
|
if (parseContent) {
|
||||||
val imgFile = File(extractionDir, id)
|
if (safeId != null) {
|
||||||
FileOutputStream(imgFile).use { it.write(bytes) }
|
val imgFile = safeFileInRoot(extractionDir, safeId)
|
||||||
|
if (imgFile != null) {
|
||||||
|
FileOutputStream(imgFile).use { it.write(bytes) }
|
||||||
|
} else {
|
||||||
|
Timber.w("Skipping unsafe FB2 binary path: $id")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Timber.w("Skipping unsafe FB2 binary id: $id")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
images.add(EpubImage(absPath = id))
|
if (safeId != null) {
|
||||||
|
images.add(EpubImage(absPath = safeId))
|
||||||
|
}
|
||||||
|
|
||||||
if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) {
|
if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) {
|
||||||
coverBytes = bytes
|
coverBytes = bytes
|
||||||
|
|
@ -326,4 +343,30 @@ class Fb2Parser(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun safeResourceFileName(id: String): String? {
|
||||||
|
val rawName = id.substringAfterLast('/').substringAfterLast('\\').trim()
|
||||||
|
if (rawName.isBlank() || rawName == "." || rawName == "..") return null
|
||||||
|
|
||||||
|
val extension = rawName.substringAfterLast('.', missingDelimiterValue = "")
|
||||||
|
.takeIf { it.isNotBlank() && it.length <= 12 }
|
||||||
|
?.replace(Regex("[^A-Za-z0-9]"), "")
|
||||||
|
.orEmpty()
|
||||||
|
val baseName = rawName.substringBeforeLast('.', rawName)
|
||||||
|
.replace(Regex("[^A-Za-z0-9._-]+"), "_")
|
||||||
|
.trim('.', '_', '-')
|
||||||
|
.ifBlank { "image" }
|
||||||
|
.take(48)
|
||||||
|
val suffix = sha256Hex(id).take(12)
|
||||||
|
return if (extension.isBlank()) {
|
||||||
|
"${baseName}_$suffix"
|
||||||
|
} else {
|
||||||
|
"${baseName}_$suffix.$extension"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256Hex(value: String): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
|
||||||
|
return digest.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,9 +184,13 @@ class OdtParser(private val context: Context) {
|
||||||
"Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes()
|
"Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes()
|
||||||
else -> {
|
else -> {
|
||||||
if (entry.name !in ignoredFiles) {
|
if (entry.name !in ignoredFiles) {
|
||||||
val extractedFile = File(extractionDir, entry.name)
|
val extractedFile = safeFileInRoot(extractionDir, entry.name)
|
||||||
extractedFile.parentFile?.mkdirs()
|
if (extractedFile != null) {
|
||||||
FileOutputStream(extractedFile).use { out -> zis.copyTo(out) }
|
extractedFile.parentFile?.mkdirs()
|
||||||
|
FileOutputStream(extractedFile).use { out -> zis.copyTo(out) }
|
||||||
|
} else {
|
||||||
|
Timber.w("Skipping unsafe ODT entry outside extraction root: ${entry.name}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -371,10 +375,18 @@ class OdtParser(private val context: Context) {
|
||||||
if (isFlat) {
|
if (isFlat) {
|
||||||
try {
|
try {
|
||||||
val bytes = Base64.decode(base64Builder.toString(), Base64.DEFAULT)
|
val bytes = Base64.decode(base64Builder.toString(), Base64.DEFAULT)
|
||||||
val imgName = currentImageHref?.substringAfterLast("/") ?: "${UUID.randomUUID()}.png"
|
val imgName = currentImageHref
|
||||||
val imgFile = File(extractionDir, imgName)
|
?.substringAfterLast("/")
|
||||||
FileOutputStream(imgFile).use { it.write(bytes) }
|
?.substringAfterLast("\\")
|
||||||
currentChapterHtml.append("<img src=\"${imgName}\" />")
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: "${UUID.randomUUID()}.png"
|
||||||
|
val imgFile = safeFileInRoot(extractionDir, imgName)
|
||||||
|
if (imgFile != null) {
|
||||||
|
FileOutputStream(imgFile).use { it.write(bytes) }
|
||||||
|
currentChapterHtml.append("<img src=\"${imgName}\" />")
|
||||||
|
} else {
|
||||||
|
Timber.w("Skipping unsafe FODT image path: $imgName")
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to decode FODT image")
|
Timber.e(e, "Failed to decode FODT image")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ class SingleFileImporter(private val context: Context) {
|
||||||
private const val MAX_HTML_BUFFERED_LINE_CHARS = 128_000
|
private const val MAX_HTML_BUFFERED_LINE_CHARS = 128_000
|
||||||
private const val MAX_HTML_HEAD_SCAN_CHARS = 256_000
|
private const val MAX_HTML_HEAD_SCAN_CHARS = 256_000
|
||||||
private const val MAX_HTML_INLINE_CSS_CHARS = 256_000
|
private const val MAX_HTML_INLINE_CSS_CHARS = 256_000
|
||||||
|
private const val MAX_SINGLE_FILE_METADATA_BYTES = 2L * 1024L * 1024L
|
||||||
|
private const val BOOK_METADATA_FILE = "book_metadata.json"
|
||||||
private const val PAGE_BREAK_MARKER = "<page-break></page-break>"
|
private const val PAGE_BREAK_MARKER = "<page-break></page-break>"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +70,62 @@ class SingleFileImporter(private val context: Context) {
|
||||||
|
|
||||||
private val htmlOutputSettings = Document.OutputSettings().prettyPrint(false)
|
private val htmlOutputSettings = Document.OutputSettings().prettyPrint(false)
|
||||||
|
|
||||||
|
private fun metadataFile(extractionDir: File): File = File(extractionDir, BOOK_METADATA_FILE)
|
||||||
|
|
||||||
|
private fun EpubBook.lightweightSingleFileCache(): EpubBook {
|
||||||
|
val cacheChapters = chapters.map { chapter ->
|
||||||
|
chapter.copy(
|
||||||
|
plainTextContent = "",
|
||||||
|
htmlContent = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return copy(
|
||||||
|
coverImage = null,
|
||||||
|
chapters = cacheChapters,
|
||||||
|
chaptersForPagination = cacheChapters
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readCachedSingleFileBook(metadataFile: File, extractionDir: File, tag: String): EpubBook? {
|
||||||
|
if (!metadataFile.exists()) return null
|
||||||
|
if (metadataFile.length() > MAX_SINGLE_FILE_METADATA_BYTES) {
|
||||||
|
Timber.w(
|
||||||
|
"Ignoring oversized $tag metadata cache (${metadataFile.length()} bytes). " +
|
||||||
|
"The file will be reparsed with lightweight metadata."
|
||||||
|
)
|
||||||
|
runCatching { metadataFile.delete() }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val decodedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
||||||
|
val cacheChapters = decodedBook.chapters.map { it.copy(htmlContent = "") }
|
||||||
|
decodedBook.copy(
|
||||||
|
chapters = cacheChapters,
|
||||||
|
chaptersForPagination = cacheChapters,
|
||||||
|
extractionBasePath = extractionDir.absolutePath
|
||||||
|
).takeIf { it.hasReadableExtractedContent() }
|
||||||
|
} catch (e: OutOfMemoryError) {
|
||||||
|
Timber.e(e, "Failed to load cached $tag metadata without exhausting memory")
|
||||||
|
runCatching { metadataFile.delete() }
|
||||||
|
null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to load cached $tag, parsing again")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeSingleFileMetadata(metadataFile: File, book: EpubBook, tag: String) {
|
||||||
|
try {
|
||||||
|
metadataFile.writeText(jsonSerializer.encodeToString(book.lightweightSingleFileCache()))
|
||||||
|
} catch (e: OutOfMemoryError) {
|
||||||
|
Timber.e(e, "Failed to cache lightweight $tag metadata without exhausting memory")
|
||||||
|
runCatching { metadataFile.delete() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to cache $tag metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun importSingleFile(
|
suspend fun importSingleFile(
|
||||||
inputStream: InputStream,
|
inputStream: InputStream,
|
||||||
type: FileType,
|
type: FileType,
|
||||||
|
|
@ -195,17 +253,11 @@ class SingleFileImporter(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
|
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
|
||||||
val metadataFile = File(extractionDir, "book_metadata.json")
|
val metadataFile = metadataFile(extractionDir)
|
||||||
|
|
||||||
if (metadataFile.exists()) {
|
readCachedSingleFileBook(metadataFile, extractionDir, "MD")?.let { cachedBook ->
|
||||||
try {
|
Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId")
|
||||||
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
return@withContext cachedBook
|
||||||
.copy(extractionBasePath = extractionDir.absolutePath)
|
|
||||||
Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId")
|
|
||||||
return@withContext cachedBook
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to load cached MD, parsing again")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ImportedFileCache.resetActiveBookDir(context, bookId)
|
ImportedFileCache.resetActiveBookDir(context, bookId)
|
||||||
|
|
||||||
|
|
@ -299,11 +351,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
css = emptyMap()
|
css = emptyMap()
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
writeSingleFileMetadata(metadataFile, book, "MD")
|
||||||
metadataFile.writeText(jsonSerializer.encodeToString(book))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to cache MD metadata")
|
|
||||||
}
|
|
||||||
|
|
||||||
return@withContext book
|
return@withContext book
|
||||||
}
|
}
|
||||||
|
|
@ -331,17 +379,11 @@ class SingleFileImporter(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
|
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
|
||||||
val metadataFile = File(extractionDir, "book_metadata.json")
|
val metadataFile = metadataFile(extractionDir)
|
||||||
|
|
||||||
if (metadataFile.exists()) {
|
readCachedSingleFileBook(metadataFile, extractionDir, "TXT")?.let { cachedBook ->
|
||||||
try {
|
Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId")
|
||||||
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
return@withContext cachedBook
|
||||||
.copy(extractionBasePath = extractionDir.absolutePath)
|
|
||||||
Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId")
|
|
||||||
return@withContext cachedBook
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to load cached TXT, parsing again")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ImportedFileCache.resetActiveBookDir(context, bookId)
|
ImportedFileCache.resetActiveBookDir(context, bookId)
|
||||||
|
|
||||||
|
|
@ -462,11 +504,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
css = emptyMap()
|
css = emptyMap()
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
writeSingleFileMetadata(metadataFile, book, "TXT")
|
||||||
metadataFile.writeText(jsonSerializer.encodeToString(book))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to cache TXT metadata")
|
|
||||||
}
|
|
||||||
|
|
||||||
return@withContext book
|
return@withContext book
|
||||||
}
|
}
|
||||||
|
|
@ -494,17 +532,11 @@ class SingleFileImporter(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
|
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
|
||||||
val metadataFile = File(extractionDir, "book_metadata.json")
|
val metadataFile = metadataFile(extractionDir)
|
||||||
|
|
||||||
if (metadataFile.exists()) {
|
readCachedSingleFileBook(metadataFile, extractionDir, "HTML")?.let { cachedBook ->
|
||||||
try {
|
Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId")
|
||||||
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
return@withContext cachedBook
|
||||||
.copy(extractionBasePath = extractionDir.absolutePath)
|
|
||||||
Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId")
|
|
||||||
return@withContext cachedBook
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to load cached HTML, parsing again")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ImportedFileCache.resetActiveBookDir(context, bookId)
|
ImportedFileCache.resetActiveBookDir(context, bookId)
|
||||||
|
|
||||||
|
|
@ -699,11 +731,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
css = emptyMap()
|
css = emptyMap()
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
writeSingleFileMetadata(metadataFile, book, "HTML")
|
||||||
metadataFile.writeText(jsonSerializer.encodeToString(book))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to cache HTML metadata")
|
|
||||||
}
|
|
||||||
|
|
||||||
return@withContext book
|
return@withContext book
|
||||||
}
|
}
|
||||||
|
|
@ -783,17 +811,11 @@ class SingleFileImporter(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
|
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
|
||||||
val metadataFile = File(extractionDir, "book_metadata.json")
|
val metadataFile = metadataFile(extractionDir)
|
||||||
|
|
||||||
if (metadataFile.exists()) {
|
readCachedSingleFileBook(metadataFile, extractionDir, "DOCX")?.let { cachedBook ->
|
||||||
try {
|
Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId")
|
||||||
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
return@withContext cachedBook
|
||||||
.copy(extractionBasePath = extractionDir.absolutePath)
|
|
||||||
Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId")
|
|
||||||
return@withContext cachedBook
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to load cached DOCX, parsing again")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ImportedFileCache.resetActiveBookDir(context, bookId)
|
ImportedFileCache.resetActiveBookDir(context, bookId)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ import java.io.InputStreamReader
|
||||||
|
|
||||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||||
private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter"
|
private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter"
|
||||||
|
private const val TAG_ANDROID_HIGHLIGHT_RENDER_DIAG = "AndroidHighlightRenderDiag"
|
||||||
private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
|
private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
|
||||||
"PageInfoReporter",
|
"PageInfoReporter",
|
||||||
"ProgressReporter",
|
"ProgressReporter",
|
||||||
|
|
@ -386,6 +387,36 @@ private data class CustomMenuState(
|
||||||
val selectedColor: HighlightColor? = null
|
val selectedColor: HighlightColor? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal fun highlightsJsonForWebView(userHighlights: List<UserHighlight>): String {
|
||||||
|
val jsonArray = org.json.JSONArray()
|
||||||
|
userHighlights.forEach { highlight ->
|
||||||
|
val obj = JSONObject()
|
||||||
|
obj.put("id", highlight.id)
|
||||||
|
obj.put("cfi", highlight.cfi)
|
||||||
|
obj.put("text", highlight.text)
|
||||||
|
obj.put("cssClass", highlight.color.cssClass)
|
||||||
|
obj.put("colorId", highlight.color.id)
|
||||||
|
obj.put("chapterIndex", highlight.chapterIndex)
|
||||||
|
obj.put(
|
||||||
|
"locator",
|
||||||
|
JSONObject().apply {
|
||||||
|
highlight.locator.chapterIndex?.let { put("chapterIndex", it) }
|
||||||
|
highlight.locator.chapterId?.let { put("chapterId", it) }
|
||||||
|
highlight.locator.href?.let { put("href", it) }
|
||||||
|
highlight.locator.pageIndex?.let { put("pageIndex", it) }
|
||||||
|
highlight.locator.startOffset?.let { put("startOffset", it) }
|
||||||
|
highlight.locator.endOffset?.let { put("endOffset", it) }
|
||||||
|
highlight.locator.blockIndex?.let { put("blockIndex", it) }
|
||||||
|
highlight.locator.charOffset?.let { put("charOffset", it) }
|
||||||
|
highlight.locator.textQuote?.let { put("textQuote", it) }
|
||||||
|
highlight.locator.cfi?.let { put("cfi", it) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
jsonArray.put(obj)
|
||||||
|
}
|
||||||
|
return jsonArray.toString()
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
class AiJsBridge(
|
class AiJsBridge(
|
||||||
private val scope: CoroutineScope, private val onContentReady: suspend (String) -> Unit
|
private val scope: CoroutineScope, private val onContentReady: suspend (String) -> Unit
|
||||||
|
|
@ -526,17 +557,7 @@ fun ChapterWebView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val highlightsJson = remember(userHighlights) {
|
val highlightsJson = remember(userHighlights) { highlightsJsonForWebView(userHighlights) }
|
||||||
val jsonArray = org.json.JSONArray()
|
|
||||||
userHighlights.forEach { h ->
|
|
||||||
val obj = JSONObject()
|
|
||||||
obj.put("cfi", h.cfi)
|
|
||||||
obj.put("text", h.text)
|
|
||||||
obj.put("cssClass", h.color.cssClass)
|
|
||||||
jsonArray.put(obj)
|
|
||||||
}
|
|
||||||
jsonArray.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showExternalLinkDialog != null) {
|
if (showExternalLinkDialog != null) {
|
||||||
val urlToShow = showExternalLinkDialog!!
|
val urlToShow = showExternalLinkDialog!!
|
||||||
|
|
@ -719,6 +740,11 @@ fun ChapterWebView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message.startsWith("$TAG_ANDROID_HIGHLIGHT_RENDER_DIAG:") -> {
|
||||||
|
Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG)
|
||||||
|
.d("JS -> ${message.substringAfter("$TAG_ANDROID_HIGHLIGHT_RENDER_DIAG: ")}")
|
||||||
|
}
|
||||||
|
|
||||||
message.startsWith("ReaderFontDiagnosis") -> {
|
message.startsWith("ReaderFontDiagnosis") -> {
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"JS -> ${message.substringAfter("ReaderFontDiagnosis: ")}"
|
"JS -> ${message.substringAfter("ReaderFontDiagnosis: ")}"
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -71,6 +72,7 @@ import androidx.core.text.HtmlCompat
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.epub.EpubChapter
|
import com.aryan.reader.epub.EpubChapter
|
||||||
import com.aryan.reader.shared.EpubAnnotationSerializer
|
import com.aryan.reader.shared.EpubAnnotationSerializer
|
||||||
|
import com.aryan.reader.shared.ReaderLocator
|
||||||
|
|
||||||
private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks"
|
private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks"
|
||||||
|
|
||||||
|
|
@ -155,14 +157,20 @@ fun processAndAddHighlight(
|
||||||
newText: String,
|
newText: String,
|
||||||
newColor: HighlightColor,
|
newColor: HighlightColor,
|
||||||
chapterIndex: Int,
|
chapterIndex: Int,
|
||||||
currentList: MutableList<UserHighlight>
|
currentList: MutableList<UserHighlight>,
|
||||||
|
locator: ReaderLocator = ReaderLocator.fromLegacy(
|
||||||
|
chapterIndex = chapterIndex,
|
||||||
|
cfi = newCfi,
|
||||||
|
textQuote = newText
|
||||||
|
)
|
||||||
): String {
|
): String {
|
||||||
return EpubAnnotationSerializer.processAndAddHighlight(
|
return EpubAnnotationSerializer.processAndAddHighlight(
|
||||||
newCfi = newCfi,
|
newCfi = newCfi,
|
||||||
newText = newText,
|
newText = newText,
|
||||||
newColor = newColor,
|
newColor = newColor,
|
||||||
chapterIndex = chapterIndex,
|
chapterIndex = chapterIndex,
|
||||||
currentList = currentList
|
currentList = currentList,
|
||||||
|
locator = locator
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -593,6 +601,7 @@ fun HighlightColorRow(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.size(28.dp)
|
.size(28.dp)
|
||||||
|
.testTag("HighlightColor_${colorEnum.id}")
|
||||||
.clip(CircleShape) // 1. Clip shape for ripple
|
.clip(CircleShape) // 1. Clip shape for ripple
|
||||||
.background(colorEnum.color) // 2. Apply background
|
.background(colorEnum.color) // 2. Apply background
|
||||||
.clickable {
|
.clickable {
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,17 @@ package com.aryan.reader.epubreader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import timber.log.Timber
|
import com.aryan.reader.applyBookReplacementsToHtmlDocument
|
||||||
import com.aryan.reader.epub.EpubBook
|
import com.aryan.reader.epub.EpubBook
|
||||||
import com.aryan.reader.epub.contentFilePath
|
import com.aryan.reader.epub.contentFilePath
|
||||||
import com.aryan.reader.paginatedreader.LocatorConverter
|
import com.aryan.reader.paginatedreader.LocatorConverter
|
||||||
|
import com.aryan.reader.shared.ReaderBookReplacementPreferences
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import org.jsoup.nodes.Node
|
import org.jsoup.nodes.Node
|
||||||
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
data class ChapterLoadingResult(
|
data class ChapterLoadingResult(
|
||||||
|
|
@ -86,7 +88,9 @@ suspend fun loadChapterContent(
|
||||||
chunkTargetOverride: Int?,
|
chunkTargetOverride: Int?,
|
||||||
isInitialCfiLoad: Boolean,
|
isInitialCfiLoad: Boolean,
|
||||||
cfiToLoad: String?,
|
cfiToLoad: String?,
|
||||||
locatorConverter: LocatorConverter
|
locatorConverter: LocatorConverter,
|
||||||
|
bookReplacementPreferences: ReaderBookReplacementPreferences = ReaderBookReplacementPreferences(),
|
||||||
|
bookReplacementFileId: String? = null,
|
||||||
): ChapterLoadingResult = withContext(Dispatchers.IO) {
|
): ChapterLoadingResult = withContext(Dispatchers.IO) {
|
||||||
val chapter =
|
val chapter =
|
||||||
epubBook.chapters.getOrNull(chapterIndex) ?: return@withContext ChapterLoadingResult(
|
epubBook.chapters.getOrNull(chapterIndex) ?: return@withContext ChapterLoadingResult(
|
||||||
|
|
@ -100,6 +104,11 @@ suspend fun loadChapterContent(
|
||||||
val doc = Jsoup.parse(htmlFile, "UTF-8")
|
val doc = Jsoup.parse(htmlFile, "UTF-8")
|
||||||
val head = doc.head().html()
|
val head = doc.head().html()
|
||||||
doc.select("script").remove()
|
doc.select("script").remove()
|
||||||
|
applyBookReplacementsToHtmlDocument(
|
||||||
|
document = doc,
|
||||||
|
preferences = bookReplacementPreferences,
|
||||||
|
fileId = bookReplacementFileId,
|
||||||
|
)
|
||||||
val bodyNodes = doc.body().childNodes().toList()
|
val bodyNodes = doc.body().childNodes().toList()
|
||||||
val htmlChunks = splitBodyNodesIntoReaderChunks(bodyNodes)
|
val htmlChunks = splitBodyNodesIntoReaderChunks(bodyNodes)
|
||||||
if (htmlChunks.isEmpty()) {
|
if (htmlChunks.isEmpty()) {
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,6 @@
|
||||||
/*
|
|
||||||
* Episteme Reader - A native Android document reader.
|
|
||||||
* Copyright (C) 2026 Episteme
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as
|
|
||||||
* published by the Free Software Foundation, either version 3 of the
|
|
||||||
* License, or (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*
|
|
||||||
* mail: epistemereader@gmail.com
|
|
||||||
*/
|
|
||||||
// EpubReaderControls.kt
|
|
||||||
package com.aryan.reader.epubreader
|
package com.aryan.reader.epubreader
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.webkit.WebView
|
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
|
@ -38,14 +14,12 @@ import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|
@ -56,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.only
|
import androidx.compose.foundation.layout.only
|
||||||
|
|
@ -72,6 +47,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.NavigateBefore
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.NavigateNext
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
|
@ -82,6 +59,10 @@ import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.GraphicEq
|
import androidx.compose.material.icons.filled.GraphicEq
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowLeft
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
|
@ -121,9 +102,7 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
|
@ -136,7 +115,6 @@ import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.core.graphics.createBitmap
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.aryan.reader.BuildConfig
|
import com.aryan.reader.BuildConfig
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
|
|
@ -145,15 +123,13 @@ import com.aryan.reader.SearchState
|
||||||
import com.aryan.reader.SearchTopBar
|
import com.aryan.reader.SearchTopBar
|
||||||
import com.aryan.reader.TooltipIconButton
|
import com.aryan.reader.TooltipIconButton
|
||||||
import com.aryan.reader.areReaderAiFeaturesEnabled
|
import com.aryan.reader.areReaderAiFeaturesEnabled
|
||||||
import com.aryan.reader.epub.EpubChapter
|
|
||||||
import com.aryan.reader.loadNativeVoice
|
import com.aryan.reader.loadNativeVoice
|
||||||
import com.aryan.reader.paginatedreader.BookPaginator
|
import com.aryan.reader.readerSliderStepPage
|
||||||
import com.aryan.reader.paginatedreader.IPaginator
|
import com.aryan.reader.shared.ui.ReaderMinimalSlider
|
||||||
import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
|
import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
|
||||||
|
import com.aryan.reader.tts.ReaderTtsOverlaySize
|
||||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
|
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
|
||||||
import kotlinx.coroutines.Dispatchers
|
import com.aryan.reader.tts.formatReaderTtsChunkLabel
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import timber.log.Timber
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
enum class ReaderTool(@StringRes val titleRes: Int, val category: String) {
|
enum class ReaderTool(@StringRes val titleRes: Int, val category: String) {
|
||||||
|
|
@ -177,7 +153,8 @@ enum class ReaderTool(@StringRes val titleRes: Int, val category: String) {
|
||||||
SCREEN_ORIENTATION(R.string.menu_screen_orientation, "Top Bar"),
|
SCREEN_ORIENTATION(R.string.menu_screen_orientation, "Top Bar"),
|
||||||
AUTO_SCROLL(R.string.menu_auto_scroll, "Overflow Menu"),
|
AUTO_SCROLL(R.string.menu_auto_scroll, "Overflow Menu"),
|
||||||
TTS_SETTINGS(R.string.menu_tts_settings, "Overflow Menu"),
|
TTS_SETTINGS(R.string.menu_tts_settings, "Overflow Menu"),
|
||||||
TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu")
|
TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu"),
|
||||||
|
BOOK_REPLACEMENTS(R.string.menu_book_word_replacements, "Overflow Menu")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
|
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
|
||||||
|
|
@ -286,6 +263,7 @@ internal enum class EpubOverflowMenuSection {
|
||||||
KEEP_SCREEN_ON,
|
KEEP_SCREEN_ON,
|
||||||
VISUAL_OPTIONS,
|
VISUAL_OPTIONS,
|
||||||
AUTO_SCROLL,
|
AUTO_SCROLL,
|
||||||
|
BOOK_REPLACEMENTS,
|
||||||
TTS_SETTINGS,
|
TTS_SETTINGS,
|
||||||
FILE_INFO
|
FILE_INFO
|
||||||
}
|
}
|
||||||
|
|
@ -309,6 +287,7 @@ internal fun epubOverflowMenuSections(
|
||||||
if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) add(EpubOverflowMenuSection.KEEP_SCREEN_ON)
|
if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) add(EpubOverflowMenuSection.KEEP_SCREEN_ON)
|
||||||
if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) add(EpubOverflowMenuSection.VISUAL_OPTIONS)
|
if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) add(EpubOverflowMenuSection.VISUAL_OPTIONS)
|
||||||
if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) add(EpubOverflowMenuSection.AUTO_SCROLL)
|
if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) add(EpubOverflowMenuSection.AUTO_SCROLL)
|
||||||
|
if (!hiddenTools.contains(ReaderTool.BOOK_REPLACEMENTS.name)) add(EpubOverflowMenuSection.BOOK_REPLACEMENTS)
|
||||||
if (
|
if (
|
||||||
!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name) ||
|
!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name) ||
|
||||||
!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)
|
!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)
|
||||||
|
|
@ -381,11 +360,13 @@ fun EpubReaderTopBar(
|
||||||
volumeScrollEnabled: Boolean,
|
volumeScrollEnabled: Boolean,
|
||||||
isPageTurnAnimationEnabled: Boolean,
|
isPageTurnAnimationEnabled: Boolean,
|
||||||
isRightToLeftPagination: Boolean,
|
isRightToLeftPagination: Boolean,
|
||||||
|
useNativeVerticalRenderer: Boolean,
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
isKeepScreenOn: Boolean,
|
isKeepScreenOn: Boolean,
|
||||||
onToggleKeepScreenOn: (Boolean) -> Unit,
|
onToggleKeepScreenOn: (Boolean) -> Unit,
|
||||||
onCloseSearch: () -> Unit,
|
onCloseSearch: () -> Unit,
|
||||||
onChangeRenderMode: (RenderMode) -> Unit,
|
onChangeRenderMode: (RenderMode) -> Unit,
|
||||||
|
onUseNativeVerticalRendererChange: (Boolean) -> Unit,
|
||||||
onToggleBookmark: () -> Unit,
|
onToggleBookmark: () -> Unit,
|
||||||
onToggleTapToNavigate: (Boolean) -> Unit,
|
onToggleTapToNavigate: (Boolean) -> Unit,
|
||||||
onToggleVolumeScroll: (Boolean) -> Unit,
|
onToggleVolumeScroll: (Boolean) -> Unit,
|
||||||
|
|
@ -394,6 +375,7 @@ fun EpubReaderTopBar(
|
||||||
onStartAutoScroll: () -> Unit,
|
onStartAutoScroll: () -> Unit,
|
||||||
onOpenTtsSettings: () -> Unit,
|
onOpenTtsSettings: () -> Unit,
|
||||||
onOpenTtsReplacements: () -> Unit,
|
onOpenTtsReplacements: () -> Unit,
|
||||||
|
onOpenBookReplacements: () -> Unit,
|
||||||
onOpenDictionarySettings: () -> Unit,
|
onOpenDictionarySettings: () -> Unit,
|
||||||
onOpenThemeSettings: () -> Unit,
|
onOpenThemeSettings: () -> Unit,
|
||||||
onOpenBrightness: () -> Unit,
|
onOpenBrightness: () -> Unit,
|
||||||
|
|
@ -701,14 +683,29 @@ fun EpubReaderTopBar(
|
||||||
)
|
)
|
||||||
if (showReadingModeExpanded) {
|
if (showReadingModeExpanded) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
text = { Text(stringResource(R.string.menu_reading_mode_vertical_webview)) },
|
||||||
enabled = !isTtsActive,
|
enabled = !isTtsActive,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
onUseNativeVerticalRendererChange(false)
|
||||||
showMoreMenu = false
|
showMoreMenu = false
|
||||||
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
|
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
|
||||||
},
|
},
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
|
if (currentRenderMode == RenderMode.VERTICAL_SCROLL && !useNativeVerticalRenderer) Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_selected)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_reading_mode_vertical_native)) },
|
||||||
|
enabled = !isTtsActive,
|
||||||
|
onClick = {
|
||||||
|
onUseNativeVerticalRendererChange(true)
|
||||||
|
showMoreMenu = false
|
||||||
|
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (currentRenderMode == RenderMode.VERTICAL_SCROLL && useNativeVerticalRenderer) Icon(
|
||||||
Icons.Default.Check,
|
Icons.Default.Check,
|
||||||
contentDescription = stringResource(R.string.content_desc_selected)
|
contentDescription = stringResource(R.string.content_desc_selected)
|
||||||
)
|
)
|
||||||
|
|
@ -849,6 +846,22 @@ fun EpubReaderTopBar(
|
||||||
onStartAutoScroll()
|
onStartAutoScroll()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
EpubOverflowMenuSection.BOOK_REPLACEMENTS -> {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_book_word_replacements)) },
|
||||||
|
onClick = {
|
||||||
|
showMoreMenu = false
|
||||||
|
onOpenBookReplacements()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.text_fields),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
EpubOverflowMenuSection.TTS_SETTINGS -> {
|
EpubOverflowMenuSection.TTS_SETTINGS -> {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_tts_settings)) },
|
text = { Text(stringResource(R.string.menu_tts_settings)) },
|
||||||
|
|
@ -1158,26 +1171,18 @@ fun EpubReaderBottomBar(
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||||
@SuppressLint("UnusedBoxWithConstraintsScope")
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EpubReaderPageSlider(
|
fun EpubReaderPageSlider(
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
currentRenderMode: RenderMode,
|
|
||||||
totalPages: Int,
|
totalPages: Int,
|
||||||
sliderCurrentPage: Float,
|
sliderCurrentPage: Float,
|
||||||
sliderStartPage: Int,
|
sliderStartPage: Int,
|
||||||
startPageThumbnail: Bitmap?,
|
|
||||||
paginator: IPaginator?,
|
|
||||||
chapters: List<EpubChapter>,
|
|
||||||
onScrub: (Float) -> Unit,
|
onScrub: (Float) -> Unit,
|
||||||
onJumpToPage: (Int) -> Unit,
|
onJumpToPage: (Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
activeColor: Color = Color.Unspecified,
|
activeColor: Color = Color.Unspecified,
|
||||||
inactiveColor: Color = Color.Unspecified,
|
inactiveColor: Color = Color.Unspecified,
|
||||||
contentColor: Color = Color.Unspecified,
|
contentColor: Color = Color.Unspecified
|
||||||
thumbnailSurfaceColor: Color = Color.Unspecified,
|
|
||||||
thumbnailContentColor: Color = Color.Unspecified
|
|
||||||
) {
|
) {
|
||||||
val effectiveActiveColor = if (activeColor == Color.Unspecified) {
|
val effectiveActiveColor = if (activeColor == Color.Unspecified) {
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
|
|
@ -1194,16 +1199,8 @@ fun EpubReaderPageSlider(
|
||||||
} else {
|
} else {
|
||||||
contentColor
|
contentColor
|
||||||
}
|
}
|
||||||
val effectiveThumbnailSurfaceColor = if (thumbnailSurfaceColor == Color.Unspecified) {
|
val maxPage = totalPages.coerceAtLeast(1)
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
val currentPage = sliderCurrentPage.roundToInt().coerceIn(1, maxPage)
|
||||||
} else {
|
|
||||||
thumbnailSurfaceColor
|
|
||||||
}
|
|
||||||
val effectiveThumbnailContentColor = if (thumbnailContentColor == Color.Unspecified) {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
} else {
|
|
||||||
thumbnailContentColor
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isVisible,
|
visible = isVisible,
|
||||||
|
|
@ -1211,128 +1208,73 @@ fun EpubReaderPageSlider(
|
||||||
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
|
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Box(
|
||||||
Spacer(Modifier.height(72.dp))
|
modifier = Modifier
|
||||||
Box(
|
.fillMaxWidth()
|
||||||
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
|
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
IconButton(
|
||||||
modifier = Modifier
|
onClick = {
|
||||||
.fillMaxWidth()
|
onJumpToPage(
|
||||||
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
|
readerSliderStepPage(
|
||||||
.padding(horizontal = 32.dp, vertical = 14.dp),
|
currentPage = currentPage,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
delta = -1,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
minPage = 1,
|
||||||
) {
|
maxPage = maxPage
|
||||||
BoxWithConstraints(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Slider(
|
|
||||||
value = sliderCurrentPage,
|
|
||||||
onValueChange = onScrub,
|
|
||||||
valueRange = 1f..(totalPages.toFloat().coerceAtLeast(1f)),
|
|
||||||
steps = if (totalPages > 2) totalPages - 2 else 0,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
thumb = {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
shape = CircleShape,
|
|
||||||
color = effectiveActiveColor,
|
|
||||||
tonalElevation = 0.dp,
|
|
||||||
shadowElevation = 0.dp
|
|
||||||
) {}
|
|
||||||
},
|
|
||||||
track = { sliderState ->
|
|
||||||
val trackHeight = 2.dp
|
|
||||||
val trackShape = RoundedCornerShape(trackHeight)
|
|
||||||
val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start
|
|
||||||
val fraction = if (range == 0f) 0f else {
|
|
||||||
((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(trackHeight)
|
|
||||||
.background(
|
|
||||||
color = effectiveInactiveColor,
|
|
||||||
shape = trackShape
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(fraction)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.background(
|
|
||||||
color = effectiveActiveColor,
|
|
||||||
shape = trackShape
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Thumbnail Indicator
|
|
||||||
val startPageOffsetFraction = if (totalPages > 1) {
|
|
||||||
(sliderStartPage - 1).toFloat() / (totalPages - 1)
|
|
||||||
} else {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
val thumbWidth = 20.dp
|
|
||||||
val trackWidth = maxWidth - thumbWidth
|
|
||||||
val startPagePixelPosition = (trackWidth * startPageOffsetFraction) + (thumbWidth / 2)
|
|
||||||
val thumbnailModifier = Modifier
|
|
||||||
.graphicsLayer { clip = false }
|
|
||||||
.align(Alignment.TopStart)
|
|
||||||
.offset(
|
|
||||||
x = startPagePixelPosition - (45.dp / 2),
|
|
||||||
y = (-72).dp
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = currentPage > 1,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.NavigateBefore,
|
||||||
|
contentDescription = stringResource(R.string.desktop_previous_page),
|
||||||
|
tint = effectiveContentColor.copy(alpha = if (currentPage > 1) 0.9f else 0.32f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
|
ReaderMinimalSlider(
|
||||||
startPageThumbnail?.let { thumbnail ->
|
value = sliderCurrentPage.coerceIn(1f, maxPage.toFloat()),
|
||||||
ThumbnailWithIndicator(
|
onValueChange = onScrub,
|
||||||
modifier = thumbnailModifier,
|
valueRange = 1f..maxPage.toFloat(),
|
||||||
borderColor = effectiveActiveColor,
|
enabled = maxPage > 1,
|
||||||
onClick = { onJumpToPage(sliderStartPage) }
|
activeColor = effectiveActiveColor,
|
||||||
) {
|
inactiveColor = effectiveInactiveColor,
|
||||||
Image(
|
thumbColor = effectiveActiveColor,
|
||||||
bitmap = thumbnail.asImageBitmap(),
|
markerValue = sliderStartPage.toFloat(),
|
||||||
contentDescription = stringResource(R.string.content_desc_start_page_thumbnail),
|
markerColor = effectiveActiveColor,
|
||||||
contentScale = ContentScale.FillBounds,
|
modifier = Modifier
|
||||||
modifier = Modifier.fillMaxSize()
|
.weight(1f)
|
||||||
)
|
.height(32.dp)
|
||||||
}
|
)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val startPageChapterIndex = remember(sliderStartPage, paginator) {
|
|
||||||
(paginator as? BookPaginator)?.findChapterIndexForPage(sliderStartPage - 1)
|
|
||||||
}
|
|
||||||
val startPageChapterTitle = remember(startPageChapterIndex) {
|
|
||||||
startPageChapterIndex?.let { chapters.getOrNull(it)?.title }
|
|
||||||
}
|
|
||||||
ThumbnailWithIndicator(
|
|
||||||
modifier = thumbnailModifier,
|
|
||||||
borderColor = effectiveActiveColor,
|
|
||||||
onClick = { onJumpToPage(sliderStartPage) }
|
|
||||||
) {
|
|
||||||
PaginatedThumbnailContent(
|
|
||||||
pageNumber = sliderStartPage,
|
|
||||||
chapterTitle = startPageChapterTitle,
|
|
||||||
surfaceColor = effectiveThumbnailSurfaceColor,
|
|
||||||
contentColor = effectiveThumbnailContentColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
IconButton(
|
||||||
text = "${sliderCurrentPage.roundToInt()} / $totalPages",
|
onClick = {
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
onJumpToPage(
|
||||||
color = effectiveContentColor,
|
readerSliderStepPage(
|
||||||
fontSize = 18.sp
|
currentPage = currentPage,
|
||||||
|
delta = 1,
|
||||||
|
minPage = 1,
|
||||||
|
maxPage = maxPage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = currentPage < maxPage,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.NavigateNext,
|
||||||
|
contentDescription = stringResource(R.string.desktop_next_page),
|
||||||
|
tint = effectiveContentColor.copy(alpha = if (currentPage < maxPage) 0.9f else 0.32f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1375,109 +1317,6 @@ fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun ThumbnailWithIndicator(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
borderColor: Color = Color.Unspecified,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
val effectiveBorderColor = if (borderColor == Color.Unspecified) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
borderColor
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = modifier,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(45.dp)
|
|
||||||
.height(64.dp)
|
|
||||||
.clickable(onClick = onClick),
|
|
||||||
shape = RoundedCornerShape(4.dp),
|
|
||||||
border = BorderStroke(2.dp, effectiveBorderColor)
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.offset(y = (-4).dp)
|
|
||||||
.size(8.dp)
|
|
||||||
.rotate(45f)
|
|
||||||
.background(effectiveBorderColor)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PaginatedThumbnailContent(
|
|
||||||
pageNumber: Int,
|
|
||||||
chapterTitle: String?,
|
|
||||||
surfaceColor: Color = Color.Unspecified,
|
|
||||||
contentColor: Color = Color.Unspecified
|
|
||||||
) {
|
|
||||||
val effectiveSurfaceColor = if (surfaceColor == Color.Unspecified) {
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
} else {
|
|
||||||
surfaceColor
|
|
||||||
}
|
|
||||||
val effectiveContentColor = if (contentColor == Color.Unspecified) {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
} else {
|
|
||||||
contentColor
|
|
||||||
}
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
color = effectiveSurfaceColor,
|
|
||||||
contentColor = effectiveContentColor
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(4.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
if (chapterTitle != null) {
|
|
||||||
Text(
|
|
||||||
text = chapterTitle,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
maxLines = 3,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
lineHeight = 10.sp
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "$pageNumber",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun captureWebViewVisibleArea(webView: WebView): Bitmap? {
|
|
||||||
return withContext(Dispatchers.Main) {
|
|
||||||
if (webView.width <= 0 || webView.height <= 0) return@withContext null
|
|
||||||
try {
|
|
||||||
val thumbnailWidth = 180
|
|
||||||
val thumbnailHeight = 256
|
|
||||||
val bitmap = createBitmap(thumbnailWidth, thumbnailHeight)
|
|
||||||
val canvas = Canvas(bitmap)
|
|
||||||
val scale = thumbnailWidth.toFloat() / webView.width.toFloat()
|
|
||||||
canvas.scale(scale, scale)
|
|
||||||
canvas.translate(-webView.scrollX.toFloat(), -webView.scrollY.toFloat())
|
|
||||||
webView.draw(canvas)
|
|
||||||
bitmap
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to capture webview content")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SpeedDropdown(
|
fun SpeedDropdown(
|
||||||
label: String,
|
label: String,
|
||||||
|
|
@ -2204,6 +2043,7 @@ private fun ToolPreviewIcon(tool: ReaderTool, isSliderActive: Boolean = false) {
|
||||||
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
|
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp))
|
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp))
|
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
|
ReaderTool.BOOK_REPLACEMENTS -> Icon(painterResource(id = R.drawable.text_fields), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.FILE_INFO -> Icon(Icons.Default.Info, contentDescription = title, modifier = Modifier.size(20.dp))
|
ReaderTool.FILE_INFO -> Icon(Icons.Default.Info, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp))
|
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp))
|
else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
|
|
@ -2260,8 +2100,8 @@ fun TtsOverlayControls(
|
||||||
ttsController: com.aryan.reader.tts.TtsController,
|
ttsController: com.aryan.reader.tts.TtsController,
|
||||||
ttsState: TtsState,
|
ttsState: TtsState,
|
||||||
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
|
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
|
||||||
isCollapsed: Boolean,
|
overlaySize: ReaderTtsOverlaySize,
|
||||||
onCollapseChange: (Boolean) -> Unit,
|
onOverlaySizeChange: (ReaderTtsOverlaySize) -> Unit,
|
||||||
onLocateCurrentChunk: () -> Unit,
|
onLocateCurrentChunk: () -> Unit,
|
||||||
onOpenTtsSettings: () -> Unit,
|
onOpenTtsSettings: () -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
|
|
@ -2301,11 +2141,17 @@ fun TtsOverlayControls(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) {
|
val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) {
|
||||||
if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) {
|
formatReaderTtsChunkLabel(ttsState.currentChunkIndex, ttsState.totalChunks)
|
||||||
"Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}"
|
}
|
||||||
} else {
|
val miniBarTitle = ttsState.bookTitle
|
||||||
null
|
?.takeIf { it.isNotBlank() }
|
||||||
}
|
?: stringResource(R.string.action_read_aloud)
|
||||||
|
val miniBarSubtitle = remember(chapterLabel, chunkLabel, progressPercent, miniBarTitle) {
|
||||||
|
listOfNotNull(
|
||||||
|
chunkLabel,
|
||||||
|
progressPercent?.let { "$it%" },
|
||||||
|
chapterLabel?.takeIf { it != miniBarTitle }
|
||||||
|
).joinToString(" - ")
|
||||||
}
|
}
|
||||||
val canSkipPreviousChunk = !ttsState.isLoading &&
|
val canSkipPreviousChunk = !ttsState.isLoading &&
|
||||||
ttsState.currentChunkIndex > 0 &&
|
ttsState.currentChunkIndex > 0 &&
|
||||||
|
|
@ -2330,24 +2176,40 @@ fun TtsOverlayControls(
|
||||||
tonalElevation = 0.dp,
|
tonalElevation = 0.dp,
|
||||||
shadowElevation = 0.dp,
|
shadowElevation = 0.dp,
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
|
||||||
modifier = modifier.widthIn(max = 400.dp).animateContentSize()
|
modifier = modifier
|
||||||
|
.widthIn(max = if (overlaySize == ReaderTtsOverlaySize.MEDIUM) 560.dp else 400.dp)
|
||||||
|
.animateContentSize()
|
||||||
) {
|
) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = isCollapsed,
|
targetState = overlaySize,
|
||||||
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||||
label = "TtsOverlayUnified"
|
label = "TtsOverlayUnified"
|
||||||
) { collapsed ->
|
) { size ->
|
||||||
if (collapsed) {
|
if (size == ReaderTtsOverlaySize.SMALL) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { onCollapseChange(false) },
|
onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.LARGE) },
|
||||||
modifier = Modifier.size(36.dp)
|
modifier = Modifier.size(36.dp)
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.ChevronLeft, "Expand", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(
|
||||||
|
Icons.Default.KeyboardArrowUp,
|
||||||
|
stringResource(R.string.content_desc_expand),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.MEDIUM) },
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.KeyboardArrowLeft,
|
||||||
|
stringResource(R.string.content_desc_expand),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
|
||||||
FilledIconButton(
|
FilledIconButton(
|
||||||
|
|
@ -2371,6 +2233,114 @@ fun TtsOverlayControls(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (size == ReaderTtsOverlaySize.MEDIUM) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 64.dp)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.clickable(onClick = onLocateCurrentChunk)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = miniBarTitle,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (miniBarSubtitle.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = miniBarSubtitle,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
enabled = canSkipPreviousChunk,
|
||||||
|
onClick = { ttsController.skipToPreviousChunk() },
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SkipPrevious,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_tts_previous_chunk),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() },
|
||||||
|
modifier = Modifier.size(44.dp),
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
|
||||||
|
stringResource(R.string.content_desc_play_pause),
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (ttsState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
enabled = canSkipNextChunk,
|
||||||
|
onClick = { ttsController.skipToNextChunk() },
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SkipNext,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_tts_next_chunk),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.LARGE) },
|
||||||
|
modifier = Modifier.size(34.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.KeyboardArrowUp,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_expand),
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.SMALL) },
|
||||||
|
modifier = Modifier.size(34.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.KeyboardArrowRight,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_collapse),
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -2378,7 +2348,10 @@ fun TtsOverlayControls(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.primaryContainer,
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
|
@ -2428,6 +2401,8 @@ fun TtsOverlayControls(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
IconButton(onClick = onLocateCurrentChunk, modifier = Modifier.size(32.dp)) {
|
IconButton(onClick = onLocateCurrentChunk, modifier = Modifier.size(32.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -2437,8 +2412,27 @@ fun TtsOverlayControls(
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) {
|
IconButton(
|
||||||
Icon(Icons.Default.ChevronRight, stringResource(R.string.content_desc_collapse), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.MEDIUM) },
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.KeyboardArrowDown,
|
||||||
|
stringResource(R.string.content_desc_collapse),
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.SMALL) },
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.KeyboardArrowRight,
|
||||||
|
stringResource(R.string.content_desc_collapse),
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) {
|
IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) {
|
||||||
Icon(Icons.Default.Close, stringResource(R.string.content_desc_stop_tts), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
|
Icon(Icons.Default.Close, stringResource(R.string.content_desc_stop_tts), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -53,15 +53,64 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import org.jsoup.nodes.Node
|
||||||
|
import org.jsoup.nodes.TextNode
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
|
private const val EPUB_SEARCH_WINDOW_CHARS = 32_768
|
||||||
|
private const val EPUB_SEARCH_SNIPPET_RADIUS = 35
|
||||||
|
private const val EPUB_SEARCH_MAX_OVERLAP_CHARS = 4_096
|
||||||
|
|
||||||
|
private val epubSearchSkippedTags = setOf("script", "style", "noscript")
|
||||||
|
private val epubSearchBlockBoundaryTags = setOf(
|
||||||
|
"address",
|
||||||
|
"article",
|
||||||
|
"aside",
|
||||||
|
"blockquote",
|
||||||
|
"br",
|
||||||
|
"caption",
|
||||||
|
"dd",
|
||||||
|
"div",
|
||||||
|
"dl",
|
||||||
|
"dt",
|
||||||
|
"figcaption",
|
||||||
|
"figure",
|
||||||
|
"footer",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
"header",
|
||||||
|
"hr",
|
||||||
|
"li",
|
||||||
|
"main",
|
||||||
|
"nav",
|
||||||
|
"ol",
|
||||||
|
"p",
|
||||||
|
"pre",
|
||||||
|
"section",
|
||||||
|
"table",
|
||||||
|
"td",
|
||||||
|
"th",
|
||||||
|
"tr",
|
||||||
|
"ul"
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the search implementation for EPUB chapters.
|
* Creates the search implementation for EPUB chapters.
|
||||||
*/
|
*/
|
||||||
fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List<SearchResult> = { query ->
|
fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List<SearchResult> = { query ->
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
|
val searchQuery = query.trim()
|
||||||
|
if (searchQuery.isBlank()) {
|
||||||
|
return@withContext emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
val results = mutableListOf<SearchResult>()
|
val results = mutableListOf<SearchResult>()
|
||||||
epubBook.chapters.forEachIndexed { chapterIndex, chapter ->
|
epubBook.chapters.forEachIndexed { chapterIndex, chapter ->
|
||||||
try {
|
try {
|
||||||
|
|
@ -69,54 +118,208 @@ fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List<SearchResul
|
||||||
if (!htmlFile.exists()) return@forEachIndexed
|
if (!htmlFile.exists()) return@forEachIndexed
|
||||||
|
|
||||||
val doc = Jsoup.parse(htmlFile, "UTF-8")
|
val doc = Jsoup.parse(htmlFile, "UTF-8")
|
||||||
val bodyChildren = doc.body().children().toList()
|
doc.select("script, style, noscript").remove()
|
||||||
val chunks = bodyChildren.chunked(20)
|
val bodyNodes = doc.body().childNodes().toList()
|
||||||
|
val chunks = bodyNodes.chunked(20)
|
||||||
|
var occurrenceIndexInChapter = 0
|
||||||
|
|
||||||
chunks.forEachIndexed { chunkIndex, chunkOfElements ->
|
chunks.forEachIndexed { chunkIndex, chunkNodes ->
|
||||||
val chunkHtml = chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
|
occurrenceIndexInChapter = appendSearchResultsFromNodes(
|
||||||
val content = Jsoup.parse(chunkHtml).text()
|
nodes = chunkNodes,
|
||||||
var lastIndex = -1
|
query = searchQuery,
|
||||||
|
chapterIndex = chapterIndex,
|
||||||
while (true) {
|
chapterTitle = chapter.title,
|
||||||
lastIndex = content.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true)
|
chunkIndex = chunkIndex,
|
||||||
if (lastIndex == -1) break
|
occurrenceIndexInChapter = occurrenceIndexInChapter,
|
||||||
|
results = results
|
||||||
val isWordStart = lastIndex == 0 || !content[lastIndex - 1].isLetterOrDigit()
|
)
|
||||||
if (isWordStart) {
|
|
||||||
val snippetStart = max(0, lastIndex - 35)
|
|
||||||
val snippetEnd = min(content.length, lastIndex + query.length + 35)
|
|
||||||
val rawSnippet = content.substring(snippetStart, snippetEnd)
|
|
||||||
val annotatedSnippet = buildAnnotatedString {
|
|
||||||
append(rawSnippet)
|
|
||||||
val highlightStart = content.indexOf(query, lastIndex, ignoreCase = true) - snippetStart
|
|
||||||
val highlightEnd = highlightStart + query.length
|
|
||||||
addStyle(
|
|
||||||
style = SpanStyle(fontWeight = FontWeight.Bold),
|
|
||||||
start = highlightStart,
|
|
||||||
end = highlightEnd
|
|
||||||
)
|
|
||||||
}
|
|
||||||
results.add(
|
|
||||||
SearchResult(
|
|
||||||
locationInSource = chapterIndex,
|
|
||||||
locationTitle = chapter.title,
|
|
||||||
snippet = annotatedSnippet,
|
|
||||||
query = query,
|
|
||||||
occurrenceIndexInLocation = results.count { it.locationInSource == chapterIndex },
|
|
||||||
chunkIndex = chunkIndex
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e("Failed to search in chapter $chapterIndex", e)
|
Timber.e(e, "Failed to search in chapter $chapterIndex")
|
||||||
|
} catch (e: OutOfMemoryError) {
|
||||||
|
Timber.e(e, "Skipping search in chapter $chapterIndex after running out of memory")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
results
|
results
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun appendSearchResultsFromNodes(
|
||||||
|
nodes: List<Node>,
|
||||||
|
query: String,
|
||||||
|
chapterIndex: Int,
|
||||||
|
chapterTitle: String,
|
||||||
|
chunkIndex: Int,
|
||||||
|
occurrenceIndexInChapter: Int,
|
||||||
|
results: MutableList<SearchResult>
|
||||||
|
): Int {
|
||||||
|
val searchWindow = EpubSearchWindow(
|
||||||
|
query = query,
|
||||||
|
chapterIndex = chapterIndex,
|
||||||
|
chapterTitle = chapterTitle,
|
||||||
|
chunkIndex = chunkIndex,
|
||||||
|
initialOccurrenceIndex = occurrenceIndexInChapter,
|
||||||
|
results = results
|
||||||
|
)
|
||||||
|
nodes.forEach { node ->
|
||||||
|
searchWindow.visit(node)
|
||||||
|
}
|
||||||
|
searchWindow.finish()
|
||||||
|
return searchWindow.occurrenceIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
private class EpubSearchWindow(
|
||||||
|
private val query: String,
|
||||||
|
private val chapterIndex: Int,
|
||||||
|
private val chapterTitle: String,
|
||||||
|
private val chunkIndex: Int,
|
||||||
|
initialOccurrenceIndex: Int,
|
||||||
|
private val results: MutableList<SearchResult>
|
||||||
|
) {
|
||||||
|
private val buffer = StringBuilder()
|
||||||
|
private val overlapChars = (query.length + EPUB_SEARCH_SNIPPET_RADIUS)
|
||||||
|
.coerceIn(EPUB_SEARCH_SNIPPET_RADIUS * 2, EPUB_SEARCH_MAX_OVERLAP_CHARS)
|
||||||
|
private var lastAppendedWasWhitespace = true
|
||||||
|
private var previousCharBeforeBuffer: Char? = null
|
||||||
|
|
||||||
|
var occurrenceIndex: Int = initialOccurrenceIndex
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun visit(node: Node) {
|
||||||
|
when (node) {
|
||||||
|
is TextNode -> appendNormalizedText(node.wholeText)
|
||||||
|
is Element -> {
|
||||||
|
val tagName = node.tagName().lowercase()
|
||||||
|
if (tagName in epubSearchSkippedTags) return
|
||||||
|
|
||||||
|
if (tagName == "br") {
|
||||||
|
appendNormalizedWhitespace()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node.childNodes().forEach(::visit)
|
||||||
|
if (tagName in epubSearchBlockBoundaryTags) {
|
||||||
|
appendNormalizedWhitespace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> node.childNodes().forEach(::visit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finish() {
|
||||||
|
scanBuffer(buffer.length)
|
||||||
|
buffer.clear()
|
||||||
|
previousCharBeforeBuffer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendNormalizedText(text: String) {
|
||||||
|
text.forEach { char ->
|
||||||
|
if (char.isWhitespace()) {
|
||||||
|
appendNormalizedWhitespace()
|
||||||
|
} else {
|
||||||
|
buffer.append(char)
|
||||||
|
lastAppendedWasWhitespace = false
|
||||||
|
trimScannedPrefixIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendNormalizedWhitespace() {
|
||||||
|
if (buffer.isEmpty() || lastAppendedWasWhitespace) {
|
||||||
|
lastAppendedWasWhitespace = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buffer.append(' ')
|
||||||
|
lastAppendedWasWhitespace = true
|
||||||
|
trimScannedPrefixIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trimScannedPrefixIfNeeded() {
|
||||||
|
if (buffer.length < EPUB_SEARCH_WINDOW_CHARS) return
|
||||||
|
|
||||||
|
val scanEndExclusive = (buffer.length - overlapChars).coerceAtLeast(0)
|
||||||
|
if (scanEndExclusive <= 0) return
|
||||||
|
|
||||||
|
scanBuffer(scanEndExclusive)
|
||||||
|
previousCharBeforeBuffer = buffer[scanEndExclusive - 1]
|
||||||
|
buffer.delete(0, scanEndExclusive)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanBuffer(scanEndExclusive: Int) {
|
||||||
|
var searchFrom = 0
|
||||||
|
while (searchFrom < scanEndExclusive) {
|
||||||
|
val matchStart = buffer.indexOfIgnoreCase(query, searchFrom, scanEndExclusive)
|
||||||
|
if (matchStart == -1) break
|
||||||
|
|
||||||
|
if (isWordStart(matchStart)) {
|
||||||
|
addSearchResult(matchStart)
|
||||||
|
}
|
||||||
|
searchFrom = matchStart + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isWordStart(matchStart: Int): Boolean {
|
||||||
|
val previousChar = if (matchStart > 0) {
|
||||||
|
buffer[matchStart - 1]
|
||||||
|
} else {
|
||||||
|
previousCharBeforeBuffer
|
||||||
|
}
|
||||||
|
return previousChar == null || !previousChar.isLetterOrDigit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addSearchResult(matchStart: Int) {
|
||||||
|
val snippetStart = max(0, matchStart - EPUB_SEARCH_SNIPPET_RADIUS)
|
||||||
|
val snippetEnd = min(buffer.length, matchStart + query.length + EPUB_SEARCH_SNIPPET_RADIUS)
|
||||||
|
val rawSnippet = buffer.substring(snippetStart, snippetEnd)
|
||||||
|
val highlightStart = matchStart - snippetStart
|
||||||
|
val highlightEnd = highlightStart + query.length
|
||||||
|
val annotatedSnippet = buildAnnotatedString {
|
||||||
|
append(rawSnippet)
|
||||||
|
addStyle(
|
||||||
|
style = SpanStyle(fontWeight = FontWeight.Bold),
|
||||||
|
start = highlightStart,
|
||||||
|
end = highlightEnd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
results.add(
|
||||||
|
SearchResult(
|
||||||
|
locationInSource = chapterIndex,
|
||||||
|
locationTitle = chapterTitle,
|
||||||
|
snippet = annotatedSnippet,
|
||||||
|
query = query,
|
||||||
|
occurrenceIndexInLocation = occurrenceIndex,
|
||||||
|
chunkIndex = chunkIndex
|
||||||
|
)
|
||||||
|
)
|
||||||
|
occurrenceIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CharSequence.indexOfIgnoreCase(
|
||||||
|
query: String,
|
||||||
|
startIndex: Int,
|
||||||
|
matchStartLimitExclusive: Int
|
||||||
|
): Int {
|
||||||
|
if (query.isEmpty()) return -1
|
||||||
|
val lastStart = min(length - query.length, matchStartLimitExclusive - 1)
|
||||||
|
if (lastStart < startIndex) return -1
|
||||||
|
|
||||||
|
var index = startIndex.coerceAtLeast(0)
|
||||||
|
while (index <= lastStart) {
|
||||||
|
var queryIndex = 0
|
||||||
|
while (
|
||||||
|
queryIndex < query.length &&
|
||||||
|
this[index + queryIndex].equals(query[queryIndex], ignoreCase = true)
|
||||||
|
) {
|
||||||
|
queryIndex++
|
||||||
|
}
|
||||||
|
if (queryIndex == query.length) return index
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the navigation to a specific search result.
|
* Handles the navigation to a specific search result.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,8 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -106,6 +108,7 @@ import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.data.CustomFontEntity
|
import com.aryan.reader.data.CustomFontEntity
|
||||||
|
import com.aryan.reader.supportedFontMimeTypes
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
|
@ -130,6 +133,7 @@ private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode"
|
||||||
private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode"
|
private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode"
|
||||||
private const val PAGE_INFO_POSITION_KEY = "reader_page_info_position"
|
private const val PAGE_INFO_POSITION_KEY = "reader_page_info_position"
|
||||||
private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
|
private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
|
||||||
|
private const val NATIVE_VERTICAL_RENDERER_KEY = "reader_native_vertical_renderer"
|
||||||
|
|
||||||
const val DEFAULT_FONT_SIZE_VAL = 1.0f
|
const val DEFAULT_FONT_SIZE_VAL = 1.0f
|
||||||
const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
|
const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
|
||||||
|
|
@ -295,6 +299,16 @@ fun loadPullToTurn(context: Context): Boolean {
|
||||||
return prefs.getBoolean(PULL_TO_TURN_ENABLED_KEY, true)
|
return prefs.getBoolean(PULL_TO_TURN_ENABLED_KEY, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveNativeVerticalRenderer(context: Context, enabled: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(NATIVE_VERTICAL_RENDERER_KEY, enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadNativeVerticalRenderer(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(NATIVE_VERTICAL_RENDERER_KEY, false)
|
||||||
|
}
|
||||||
|
|
||||||
private const val PULL_TO_TURN_MULTIPLIER_KEY = "reader_pull_to_turn_multiplier"
|
private const val PULL_TO_TURN_MULTIPLIER_KEY = "reader_pull_to_turn_multiplier"
|
||||||
|
|
||||||
fun savePullToTurnMultiplier(context: Context, multiplier: Float) {
|
fun savePullToTurnMultiplier(context: Context, multiplier: Float) {
|
||||||
|
|
@ -603,6 +617,7 @@ fun ReaderTextFormatPanel(
|
||||||
)
|
)
|
||||||
|
|
||||||
// Font Button
|
// Font Button
|
||||||
|
val fontSelectorDescription = stringResource(R.string.content_desc_select_font_family)
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onFontOptionClick,
|
onClick = onFontOptionClick,
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
|
@ -610,6 +625,9 @@ fun ReaderTextFormatPanel(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(52.dp)
|
.height(52.dp)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = fontSelectorDescription
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
@ -769,12 +787,12 @@ fun FontSelectionSheetContent(
|
||||||
currentCustomFontPath: String?,
|
currentCustomFontPath: String?,
|
||||||
onFontSelected: (ReaderFont, String?) -> Unit,
|
onFontSelected: (ReaderFont, String?) -> Unit,
|
||||||
customFonts: List<CustomFontEntity>,
|
customFonts: List<CustomFontEntity>,
|
||||||
onImportFont: (Uri) -> Unit,
|
onImportFonts: (List<Uri>) -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
||||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
||||||
uri?.let { onImportFont(it) }
|
if (uris.isNotEmpty()) onImportFonts(uris)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
|
@ -817,7 +835,7 @@ fun FontSelectionSheetContent(
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { launcher.launch(arrayOf("font/ttf", "font/otf", "application/x-font-ttf")) },
|
onClick = { launcher.launch(supportedFontMimeTypes()) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Add, contentDescription = null)
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.aryan.reader.epubreader
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.PageInfoMode
|
||||||
|
|
||||||
|
internal fun shouldShowEpubPageInfoBar(
|
||||||
|
pageInfoMode: PageInfoMode,
|
||||||
|
showReaderChrome: Boolean
|
||||||
|
): Boolean {
|
||||||
|
return when (pageInfoMode) {
|
||||||
|
PageInfoMode.DEFAULT -> true
|
||||||
|
PageInfoMode.SYNC -> showReaderChrome
|
||||||
|
PageInfoMode.HIDDEN -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -116,7 +116,7 @@ class OpdsRepository(context: Context) : SharedOpdsRepository {
|
||||||
if (wwwAuth.startsWith("Digest", ignoreCase = true)) {
|
if (wwwAuth.startsWith("Digest", ignoreCase = true)) {
|
||||||
val realm = extractParam(wwwAuth, "realm") ?: ""
|
val realm = extractParam(wwwAuth, "realm") ?: ""
|
||||||
val nonce = extractParam(wwwAuth, "nonce") ?: ""
|
val nonce = extractParam(wwwAuth, "nonce") ?: ""
|
||||||
val qop = extractParam(wwwAuth, "qop")
|
val qop = selectAuthQop(extractParam(wwwAuth, "qop"))
|
||||||
val opaque = extractParam(wwwAuth, "opaque")
|
val opaque = extractParam(wwwAuth, "opaque")
|
||||||
|
|
||||||
cnonceCount++
|
cnonceCount++
|
||||||
|
|
@ -162,6 +162,13 @@ class OpdsRepository(context: Context) : SharedOpdsRepository {
|
||||||
return match?.groupValues?.get(1)
|
return match?.groupValues?.get(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun selectAuthQop(value: String?): String? {
|
||||||
|
return value
|
||||||
|
?.split(',')
|
||||||
|
?.map { it.trim().trim('"') }
|
||||||
|
?.firstOrNull { it.equals("auth", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun md5(input: String): String {
|
private fun md5(input: String): String {
|
||||||
val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray())
|
val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray())
|
||||||
return bytes.joinToString("") { "%02x".format(it) }
|
return bytes.joinToString("") { "%02x".format(it) }
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import com.aryan.reader.epub.safeFileInRoot
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
|
|
@ -16,12 +17,14 @@ object AndroidHtmlResourceResolver : HtmlResourceResolver {
|
||||||
}
|
}
|
||||||
val parentPath = File(chapterAbsPath).parent ?: ""
|
val parentPath = File(chapterAbsPath).parent ?: ""
|
||||||
val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString()
|
val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString()
|
||||||
val fromRelativeFile = File(extractionBasePath, relativePath)
|
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
|
val extractionRoot = File(extractionBasePath)
|
||||||
|
val fromRelativeFile = safeFileInRoot(extractionRoot, relativePath)
|
||||||
|
val fromRootFile = safeFileInRoot(extractionRoot, decodedSrc)
|
||||||
when {
|
when {
|
||||||
fromRelativeFile.exists() -> fromRelativeFile.canonicalFile.absolutePath
|
fromRelativeFile?.exists() == true -> fromRelativeFile.absolutePath
|
||||||
File(extractionBasePath, decodedSrc).exists() -> File(extractionBasePath, decodedSrc).canonicalFile.absolutePath
|
fromRootFile?.exists() == true -> fromRootFile.absolutePath
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,10 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import com.aryan.reader.SearchResult
|
import com.aryan.reader.SearchResult
|
||||||
|
import com.aryan.reader.applyBookReplacementsToHtmlDocument
|
||||||
import com.aryan.reader.epub.EpubChapter
|
import com.aryan.reader.epub.EpubChapter
|
||||||
import com.aryan.reader.epub.contentFilePath
|
import com.aryan.reader.epub.contentFilePath
|
||||||
|
import com.aryan.reader.epub.plainTextCharacterCount
|
||||||
import com.aryan.reader.paginatedreader.data.BookCacheDao
|
import com.aryan.reader.paginatedreader.data.BookCacheDao
|
||||||
import com.aryan.reader.paginatedreader.data.BookProcessingInput
|
import com.aryan.reader.paginatedreader.data.BookProcessingInput
|
||||||
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
|
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
|
||||||
|
|
@ -50,14 +52,20 @@ import com.aryan.reader.paginatedreader.data.PageIndexEntry
|
||||||
import com.aryan.reader.paginatedreader.data.ProcessedBook
|
import com.aryan.reader.paginatedreader.data.ProcessedBook
|
||||||
import com.aryan.reader.paginatedreader.data.ProcessedChapter
|
import com.aryan.reader.paginatedreader.data.ProcessedChapter
|
||||||
import com.aryan.reader.paginatedreader.data.SerializableEpubChapter
|
import com.aryan.reader.paginatedreader.data.SerializableEpubChapter
|
||||||
|
import com.aryan.reader.shared.ReaderBookReplacementPreferences
|
||||||
|
import com.aryan.reader.shared.ReaderBookReplacementPreferencesJson
|
||||||
import com.aryan.reader.tts.PageCharacterRange
|
import com.aryan.reader.tts.PageCharacterRange
|
||||||
import com.aryan.reader.tts.splitTextIntoChunks
|
import com.aryan.reader.tts.splitTextIntoChunks
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
|
@ -74,6 +82,8 @@ import java.net.URI
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.PriorityBlockingQueue
|
import java.util.concurrent.PriorityBlockingQueue
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
private const val PRIORITY_HIGHEST = 0
|
private const val PRIORITY_HIGHEST = 0
|
||||||
private const val PRIORITY_HIGH = 1
|
private const val PRIORITY_HIGH = 1
|
||||||
|
|
@ -134,7 +144,7 @@ private data class PageNavigationEntry(
|
||||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||||
@Stable
|
@Stable
|
||||||
class BookPaginator(
|
class BookPaginator(
|
||||||
private val coroutineScope: CoroutineScope,
|
coroutineScope: CoroutineScope,
|
||||||
private val chapters: List<EpubChapter>,
|
private val chapters: List<EpubChapter>,
|
||||||
private val textMeasurer: TextMeasurer,
|
private val textMeasurer: TextMeasurer,
|
||||||
private val constraints: Constraints,
|
private val constraints: Constraints,
|
||||||
|
|
@ -157,7 +167,9 @@ class BookPaginator(
|
||||||
private val userTextAlign: TextAlign?,
|
private val userTextAlign: TextAlign?,
|
||||||
private val paragraphGapMultiplier: Float,
|
private val paragraphGapMultiplier: Float,
|
||||||
private val imageSizeMultiplier: Float,
|
private val imageSizeMultiplier: Float,
|
||||||
private val verticalMarginMultiplier: Float
|
private val verticalMarginMultiplier: Float,
|
||||||
|
private val bookReplacementPreferences: ReaderBookReplacementPreferences = ReaderBookReplacementPreferences(),
|
||||||
|
private val bookReplacementFileId: String? = null
|
||||||
) : IPaginator {
|
) : IPaginator {
|
||||||
override var totalPageCount by mutableIntStateOf(0)
|
override var totalPageCount by mutableIntStateOf(0)
|
||||||
private set
|
private set
|
||||||
|
|
@ -200,8 +212,27 @@ class BookPaginator(
|
||||||
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
|
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
|
||||||
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
|
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
|
||||||
private val chapterPaginationLocks = ConcurrentHashMap<Int, Mutex>()
|
private val chapterPaginationLocks = ConcurrentHashMap<Int, Mutex>()
|
||||||
|
private val chapterBlockLocks = ConcurrentHashMap<Int, Mutex>()
|
||||||
private val navigationCallbacks = ConcurrentHashMap<Int, MutableList<(List<Page>) -> Unit>>()
|
private val navigationCallbacks = ConcurrentHashMap<Int, MutableList<(List<Page>) -> Unit>>()
|
||||||
private var paginationWorker: Job? = null
|
private var paginationWorker: Job? = null
|
||||||
|
private val paginatorJob = SupervisorJob(coroutineScope.coroutineContext[Job])
|
||||||
|
private val paginatorScope = CoroutineScope(coroutineScope.coroutineContext + paginatorJob)
|
||||||
|
@Volatile
|
||||||
|
private var disposed = false
|
||||||
|
|
||||||
|
override fun dispose() {
|
||||||
|
if (disposed) return
|
||||||
|
disposed = true
|
||||||
|
paginationQueue.clear()
|
||||||
|
navigationCallbacks.clear()
|
||||||
|
chaptersBeingProcessed.clear()
|
||||||
|
paginationWorker?.cancel()
|
||||||
|
paginatorJob.cancel(CancellationException("BookPaginator disposed"))
|
||||||
|
isLoading = false
|
||||||
|
Timber.i("BookPaginator disposed for book=$bookId configHash=$currentConfigHash")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isDisposed(): Boolean = disposed || !paginatorJob.isActive
|
||||||
|
|
||||||
internal fun getCharactersScrolledInChapter(chapterIndex: Int, pageInChapter: Int): Long {
|
internal fun getCharactersScrolledInChapter(chapterIndex: Int, pageInChapter: Int): Long {
|
||||||
val cumulativeCharsList = chapterCumulativeChars[chapterIndex]
|
val cumulativeCharsList = chapterCumulativeChars[chapterIndex]
|
||||||
|
|
@ -224,7 +255,7 @@ class BookPaginator(
|
||||||
Timber.e("Paginator received UNBOUNDED HEIGHT. Pagination will fail.")
|
Timber.e("Paginator received UNBOUNDED HEIGHT. Pagination will fail.")
|
||||||
} else {
|
} else {
|
||||||
Timber.i("Paginator initializing with constraints: $constraints")
|
Timber.i("Paginator initializing with constraints: $constraints")
|
||||||
coroutineScope.launch {
|
paginatorScope.launch {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
Timber.d("Initialization started.")
|
Timber.d("Initialization started.")
|
||||||
|
|
||||||
|
|
@ -236,22 +267,46 @@ class BookPaginator(
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Book processing check (Keep existing logic)
|
// 1. Generate config hash before touching semantic cache; processed chapters are style-sensitive.
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
currentConfigHash = generateConfigurationHash()
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
|
||||||
|
// 2. Book processing check (Keep existing logic)
|
||||||
val bookRecord = bookCacheDao.getProcessedBook(bookId)
|
val bookRecord = bookCacheDao.getProcessedBook(bookId)
|
||||||
|
var shouldEnqueueBookProcessing = false
|
||||||
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
|
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
|
||||||
Timber.i("Book cache is new or stale. Creating initial record.")
|
Timber.i("Book cache is new or stale. Creating initial record.")
|
||||||
bookCacheDao.deleteEntireBookCache(bookId)
|
bookCacheDao.deleteEntireBookCache(bookId)
|
||||||
val initialBook = ProcessedBook(bookId, LATEST_PROCESSING_VERSION, 0) // Temp 0
|
val initialBook = ProcessedBook(bookId, LATEST_PROCESSING_VERSION, 0) // Temp 0
|
||||||
bookCacheDao.insertProcessedBook(initialBook)
|
bookCacheDao.insertProcessedBook(initialBook)
|
||||||
enqueueBookProcessingWork()
|
shouldEnqueueBookProcessing = true
|
||||||
|
} else if (bookCacheDao.getProcessedChapter(
|
||||||
|
bookId,
|
||||||
|
initialChapterToPaginate.coerceIn(0, chapters.lastIndex),
|
||||||
|
currentConfigHash
|
||||||
|
) == null
|
||||||
|
) {
|
||||||
|
Timber.i("Semantic chapter cache is missing for current style config. Enqueuing config-aware processing.")
|
||||||
|
shouldEnqueueBookProcessing = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. GENERATE CONFIG HASH
|
coroutineContext.ensureActive()
|
||||||
currentConfigHash = generateConfigurationHash()
|
if (isDisposed()) return@launch
|
||||||
|
|
||||||
|
if (shouldEnqueueBookProcessing) {
|
||||||
|
enqueueBookProcessingWork()
|
||||||
|
} else {
|
||||||
|
BookProcessingWorker.cancelForBook(context, bookId)
|
||||||
|
}
|
||||||
|
|
||||||
// 3. TRY LOAD EXACT COUNTS FROM DB
|
// 3. TRY LOAD EXACT COUNTS FROM DB
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
if (isDisposed()) return@launch
|
||||||
val cachedConfig = bookCacheDao.getConfigurationCache(bookId, currentConfigHash)
|
val cachedConfig = bookCacheDao.getConfigurationCache(bookId, currentConfigHash)
|
||||||
|
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
if (isDisposed()) return@launch
|
||||||
if (cachedConfig != null) {
|
if (cachedConfig != null) {
|
||||||
Timber.i("Configuration Cache HIT. Using saved page counts.")
|
Timber.i("Configuration Cache HIT. Using saved page counts.")
|
||||||
applyAccuratePageCounts(cachedConfig.chapterPageCounts)
|
applyAccuratePageCounts(cachedConfig.chapterPageCounts)
|
||||||
|
|
@ -308,15 +363,7 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getAllTextBlocks(blocks: List<ContentBlock>): List<TextContentBlock> {
|
private fun getAllTextBlocks(blocks: List<ContentBlock>): List<TextContentBlock> {
|
||||||
return blocks.flatMap { block ->
|
return flattenTextContentBlocksForNavigation(blocks)
|
||||||
when (block) {
|
|
||||||
is WrappingContentBlock -> getAllTextBlocks(block.paragraphsToWrap)
|
|
||||||
is FlexContainerBlock -> getAllTextBlocks(block.children)
|
|
||||||
is TableBlock -> block.rows.flatten().flatMap { getAllTextBlocks(it.content) }
|
|
||||||
is TextContentBlock -> listOf(block)
|
|
||||||
else -> emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateConfigurationHash(): Int {
|
private fun generateConfigurationHash(): Int {
|
||||||
|
|
@ -326,10 +373,14 @@ class BookPaginator(
|
||||||
append("-fs:${textStyle.fontSize.value}")
|
append("-fs:${textStyle.fontSize.value}")
|
||||||
append("-lh:${textStyle.lineHeight.value}")
|
append("-lh:${textStyle.lineHeight.value}")
|
||||||
append("-ff:${textStyle.fontFamily}")
|
append("-ff:${textStyle.fontFamily}")
|
||||||
|
append("-style:${textStyle.hashCode()}")
|
||||||
|
append("-density:${density.density}")
|
||||||
|
append("-fontScale:${density.fontScale}")
|
||||||
append("-ta:$userTextAlign")
|
append("-ta:$userTextAlign")
|
||||||
append("-pg:$paragraphGapMultiplier")
|
append("-pg:$paragraphGapMultiplier")
|
||||||
append("-img:$imageSizeMultiplier")
|
append("-img:$imageSizeMultiplier")
|
||||||
append("-vm:$verticalMarginMultiplier")
|
append("-vm:$verticalMarginMultiplier")
|
||||||
|
append("-book-replacements:${bookReplacementPreferences.signatureForFile(bookReplacementFileId)}")
|
||||||
append("-proc:$LATEST_PROCESSING_VERSION")
|
append("-proc:$LATEST_PROCESSING_VERSION")
|
||||||
append("-pageCache:$LATEST_PAGE_CACHE_VERSION")
|
append("-pageCache:$LATEST_PAGE_CACHE_VERSION")
|
||||||
append("-ua:${userAgentStylesheet.hashCode()}")
|
append("-ua:${userAgentStylesheet.hashCode()}")
|
||||||
|
|
@ -413,7 +464,7 @@ class BookPaginator(
|
||||||
append('|')
|
append('|')
|
||||||
append(chapter.htmlContent.hashCode())
|
append(chapter.htmlContent.hashCode())
|
||||||
append('|')
|
append('|')
|
||||||
append(chapter.plainTextContent.length)
|
append(chapter.plainTextCharacterCount())
|
||||||
append('|')
|
append('|')
|
||||||
append(chapter.plainTextContent.hashCode())
|
append(chapter.plainTextContent.hashCode())
|
||||||
append('|')
|
append('|')
|
||||||
|
|
@ -426,13 +477,24 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadCachedPagesForChapter(chapter: EpubChapter, chapterIndex: Int): List<Page>? {
|
private suspend fun loadCachedPagesForChapter(chapter: EpubChapter, chapterIndex: Int): List<Page>? {
|
||||||
val cachedPages = bookCacheDao.getPageCache(bookId, currentConfigHash, chapterIndex) ?: return null
|
val cachedPages = bookCacheDao.getPageCache(bookId, currentConfigHash, chapterIndex) ?: run {
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"page_cache_lookup result=miss chapter=$chapterIndex configHash=$currentConfigHash"
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
val expectedContentVersion = chapterContentVersion(chapter)
|
val expectedContentVersion = chapterContentVersion(chapter)
|
||||||
val isCompatible = cachedPages.processingVersion == LATEST_PROCESSING_VERSION &&
|
val isCompatible = cachedPages.processingVersion == LATEST_PROCESSING_VERSION &&
|
||||||
cachedPages.pageCacheVersion == LATEST_PAGE_CACHE_VERSION &&
|
cachedPages.pageCacheVersion == LATEST_PAGE_CACHE_VERSION &&
|
||||||
cachedPages.contentVersion == expectedContentVersion
|
cachedPages.contentVersion == expectedContentVersion
|
||||||
|
|
||||||
if (!isCompatible) {
|
if (!isCompatible) {
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"page_cache_lookup result=stale chapter=$chapterIndex " +
|
||||||
|
"cachedProcessing=${cachedPages.processingVersion} expectedProcessing=$LATEST_PROCESSING_VERSION " +
|
||||||
|
"cachedPageCache=${cachedPages.pageCacheVersion} expectedPageCache=$LATEST_PAGE_CACHE_VERSION " +
|
||||||
|
"cachedContent=${cachedPages.contentVersion} expectedContent=$expectedContentVersion"
|
||||||
|
)
|
||||||
Timber.d("Page cache stale for chapter $chapterIndex. Ignoring cached pages.")
|
Timber.d("Page cache stale for chapter $chapterIndex. Ignoring cached pages.")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -446,6 +508,10 @@ class BookPaginator(
|
||||||
applyPageRuntimeIndexes(chapterIndex, pages)
|
applyPageRuntimeIndexes(chapterIndex, pages)
|
||||||
updatePageCountsOnMain(chapterIndex, pages.size)
|
updatePageCountsOnMain(chapterIndex, pages.size)
|
||||||
pageCache.put(chapterIndex, pages)
|
pageCache.put(chapterIndex, pages)
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"page_cache_lookup result=hit chapter=$chapterIndex configHash=$currentConfigHash " +
|
||||||
|
pages.readerPagesLinkDiagSummary()
|
||||||
|
)
|
||||||
Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.")
|
Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.")
|
||||||
pages
|
pages
|
||||||
}
|
}
|
||||||
|
|
@ -456,8 +522,14 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun savePageCacheAsync(chapter: EpubChapter, chapterIndex: Int, pages: List<Page>) {
|
private fun savePageCacheAsync(chapter: EpubChapter, chapterIndex: Int, pages: List<Page>) {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
if (isDisposed()) return
|
||||||
|
paginatorScope.launch(Dispatchers.IO) {
|
||||||
|
if (isDisposed()) return@launch
|
||||||
try {
|
try {
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"page_cache_save chapter=$chapterIndex configHash=$currentConfigHash " +
|
||||||
|
pages.readerPagesLinkDiagSummary()
|
||||||
|
)
|
||||||
val pageIndexEntries = buildPersistentPageIndexEntries(chapterIndex, pages)
|
val pageIndexEntries = buildPersistentPageIndexEntries(chapterIndex, pages)
|
||||||
val cacheEntry = PageCacheEntry(
|
val cacheEntry = PageCacheEntry(
|
||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
|
|
@ -578,16 +650,19 @@ class BookPaginator(
|
||||||
|
|
||||||
private suspend fun updatePageCountsOnMain(chapterIndex: Int, actualPageCount: Int) {
|
private suspend fun updatePageCountsOnMain(chapterIndex: Int, actualPageCount: Int) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
if (isDisposed()) return@withContext
|
||||||
if (chapterPageCounts[chapterIndex] != actualPageCount) {
|
if (chapterPageCounts[chapterIndex] != actualPageCount) {
|
||||||
updatePageCounts(chapterIndex, actualPageCount)
|
updatePageCounts(chapterIndex, actualPageCount)
|
||||||
} else if (finalizedChapterCounts.add(chapterIndex)) {
|
} else if (finalizedChapterCounts.add(chapterIndex)) {
|
||||||
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
|
paginatorScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
|
||||||
}
|
}
|
||||||
generation++
|
generation++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ensureChapterPaginated(chapterIndex: Int): List<Page>? {
|
private suspend fun ensureChapterPaginated(chapterIndex: Int): List<Page>? {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
if (isDisposed()) return null
|
||||||
if (chapterIndex !in chapters.indices) {
|
if (chapterIndex !in chapters.indices) {
|
||||||
Timber.w("ensureChapterPaginated: Ignoring invalid chapter index $chapterIndex.")
|
Timber.w("ensureChapterPaginated: Ignoring invalid chapter index $chapterIndex.")
|
||||||
return null
|
return null
|
||||||
|
|
@ -612,6 +687,29 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun getCachedBlocksForChapter(chapter: EpubChapter, chapterIndex: Int): List<ContentBlock> {
|
||||||
|
blockCache[chapterIndex]?.let {
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"content_l2_cache_hit chapter=$chapterIndex " + it.readerContentLinkDiagSummary()
|
||||||
|
)
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
val lock = chapterBlockLocks.computeIfAbsent(chapterIndex) { Mutex() }
|
||||||
|
return lock.withLock {
|
||||||
|
blockCache[chapterIndex]?.also {
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"content_l2_cache_hit_after_wait chapter=$chapterIndex " + it.readerContentLinkDiagSummary()
|
||||||
|
)
|
||||||
|
} ?: run {
|
||||||
|
Timber.d("getCachedBlocksForChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.")
|
||||||
|
getBlocksForChapter(chapter, chapterIndex).also { blocks ->
|
||||||
|
blockCache.put(chapterIndex, blocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun ensureStableStartPageForChapter(chapterIndex: Int): Int? {
|
private suspend fun ensureStableStartPageForChapter(chapterIndex: Int): Int? {
|
||||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||||
"stable_start request chapter=$chapterIndex countsAccurate=$pageCountsAreAccurate finalized=${chapterIndex in finalizedChapterCounts}"
|
"stable_start request chapter=$chapterIndex countsAccurate=$pageCountsAreAccurate finalized=${chapterIndex in finalizedChapterCounts}"
|
||||||
|
|
@ -672,7 +770,7 @@ class BookPaginator(
|
||||||
ordinalInChapter: Int
|
ordinalInChapter: Int
|
||||||
): Pair<Int, Locator>? = withContext(Dispatchers.IO) {
|
): Pair<Int, Locator>? = withContext(Dispatchers.IO) {
|
||||||
val chapter = chapters.getOrNull(chapterIndex) ?: return@withContext null
|
val chapter = chapters.getOrNull(chapterIndex) ?: return@withContext null
|
||||||
val imageBlocks = getAllBlocks(getBlocksForChapter(chapter, chapterIndex))
|
val imageBlocks = getAllBlocks(getCachedBlocksForChapter(chapter, chapterIndex))
|
||||||
.filterIsInstance<ImageBlock>()
|
.filterIsInstance<ImageBlock>()
|
||||||
if (imageBlocks.isEmpty()) return@withContext null
|
if (imageBlocks.isEmpty()) return@withContext null
|
||||||
|
|
||||||
|
|
@ -756,6 +854,7 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enqueueBookProcessingWork() {
|
private fun enqueueBookProcessingWork() {
|
||||||
|
if (isDisposed()) return
|
||||||
val serializableChapters = chapters.map {
|
val serializableChapters = chapters.map {
|
||||||
SerializableEpubChapter(
|
SerializableEpubChapter(
|
||||||
htmlContent = it.htmlContent,
|
htmlContent = it.htmlContent,
|
||||||
|
|
@ -773,9 +872,15 @@ class BookPaginator(
|
||||||
density = density.density,
|
density = density.density,
|
||||||
constraintsMaxWidth = constraints.maxWidth,
|
constraintsMaxWidth = constraints.maxWidth,
|
||||||
constraintsMaxHeight = constraints.maxHeight,
|
constraintsMaxHeight = constraints.maxHeight,
|
||||||
fontFaces = this.allFontFaces
|
fontFaces = this.allFontFaces,
|
||||||
|
styleConfigHash = currentConfigHash,
|
||||||
|
bookReplacementPreferencesJson = ReaderBookReplacementPreferencesJson.encode(
|
||||||
|
bookReplacementPreferences.scopedToFile(bookReplacementFileId),
|
||||||
|
),
|
||||||
|
bookReplacementFileId = bookReplacementFileId.orEmpty()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (isDisposed()) return
|
||||||
BookProcessingWorker.enqueue(
|
BookProcessingWorker.enqueue(
|
||||||
context = context,
|
context = context,
|
||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
|
|
@ -802,10 +907,14 @@ class BookPaginator(
|
||||||
adaptThemeColors = false
|
adaptThemeColors = false
|
||||||
)
|
)
|
||||||
|
|
||||||
bookCacheDao.getProcessedChapter(bookId, chapterIndex)?.let { cachedChapter ->
|
bookCacheDao.getProcessedChapter(bookId, chapterIndex, currentConfigHash)?.let { cachedChapter ->
|
||||||
if (cachedChapter.contentBlocksProto.isNotEmpty()) {
|
if (cachedChapter.contentBlocksProto.isNotEmpty()) {
|
||||||
try {
|
try {
|
||||||
val semanticBlocks = proto.decodeFromByteArray<List<SemanticBlock>>(cachedChapter.contentBlocksProto)
|
val semanticBlocks = proto.decodeFromByteArray<List<SemanticBlock>>(cachedChapter.contentBlocksProto)
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"semantic_cache_hit chapter=$chapterIndex configHash=$currentConfigHash " +
|
||||||
|
semanticBlocks.readerSemanticLinkDiagSummary()
|
||||||
|
)
|
||||||
|
|
||||||
val isCacheEmpty = semanticBlocks.isEmpty()
|
val isCacheEmpty = semanticBlocks.isEmpty()
|
||||||
val isLazyChapter = chapter.htmlContent.isEmpty()
|
val isLazyChapter = chapter.htmlContent.isEmpty()
|
||||||
|
|
@ -821,7 +930,12 @@ class BookPaginator(
|
||||||
|
|
||||||
if (!shouldIgnoreCache) {
|
if (!shouldIgnoreCache) {
|
||||||
Timber.d("getBlocksForChapter: Cache HIT for chapter $chapterIndex in DATABASE.")
|
Timber.d("getBlocksForChapter: Cache HIT for chapter $chapterIndex in DATABASE.")
|
||||||
return styler.style(semanticBlocks)
|
val styledBlocks = styler.style(semanticBlocks)
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"content_from_semantic_cache chapter=$chapterIndex configHash=$currentConfigHash " +
|
||||||
|
styledBlocks.readerContentLinkDiagSummary()
|
||||||
|
)
|
||||||
|
return styledBlocks
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to deserialize/style chapter $chapterIndex from DB. Reprocessing for this session.")
|
Timber.e(e, "Failed to deserialize/style chapter $chapterIndex from DB. Reprocessing for this session.")
|
||||||
|
|
@ -849,6 +963,10 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
|
|
||||||
val document = Jsoup.parse(htmlToParse, chapter.absPath)
|
val document = Jsoup.parse(htmlToParse, chapter.absPath)
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"html_parse_input chapter=$chapterIndex htmlChars=${htmlToParse.length} " +
|
||||||
|
document.readerHtmlLinkDiagSummary()
|
||||||
|
)
|
||||||
val mathElements = document.select("math")
|
val mathElements = document.select("math")
|
||||||
val svgResults = mutableMapOf<String, String>()
|
val svgResults = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
|
@ -865,6 +983,11 @@ class BookPaginator(
|
||||||
element.replaceWith(placeholder)
|
element.replaceWith(placeholder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
applyBookReplacementsToHtmlDocument(
|
||||||
|
document = document,
|
||||||
|
preferences = bookReplacementPreferences,
|
||||||
|
fileId = bookReplacementFileId,
|
||||||
|
)
|
||||||
val processedHtml = document.outerHtml()
|
val processedHtml = document.outerHtml()
|
||||||
|
|
||||||
var parsingCssRules = OptimizedCssRules()
|
var parsingCssRules = OptimizedCssRules()
|
||||||
|
|
@ -887,11 +1010,15 @@ class BookPaginator(
|
||||||
mathSvgCache = svgResults,
|
mathSvgCache = svgResults,
|
||||||
adaptThemeColors = false
|
adaptThemeColors = false
|
||||||
)
|
)
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"semantic_parse_result chapter=$chapterIndex " +
|
||||||
|
semanticBlocks.readerSemanticLinkDiagSummary()
|
||||||
|
)
|
||||||
|
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
if (!isDisposed()) paginatorScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val protoBytes = proto.encodeToByteArray(semanticBlocks)
|
val protoBytes = proto.encodeToByteArray(semanticBlocks)
|
||||||
val newCacheEntry = ProcessedChapter(bookId, chapterIndex, protoBytes, chapterPageCounts[chapterIndex] ?: 0)
|
val newCacheEntry = ProcessedChapter(bookId, chapterIndex, protoBytes, chapterPageCounts[chapterIndex] ?: 0, currentConfigHash)
|
||||||
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
|
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
|
||||||
Timber.i("Successfully cached SEMANTIC content for chapter $chapterIndex.")
|
Timber.i("Successfully cached SEMANTIC content for chapter $chapterIndex.")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -899,15 +1026,27 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return styler.style(semanticBlocks)
|
val styledBlocks = styler.style(semanticBlocks)
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"content_parse_result chapter=$chapterIndex " +
|
||||||
|
styledBlocks.readerContentLinkDiagSummary()
|
||||||
|
)
|
||||||
|
return styledBlocks
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startPaginationWorker(): Job = coroutineScope.launch(Dispatchers.IO) {
|
internal suspend fun getFlowBlocksForChapter(chapterIndex: Int): List<ContentBlock>? = withContext(Dispatchers.IO) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
if (isDisposed()) return@withContext null
|
||||||
|
val chapter = chapters.getOrNull(chapterIndex) ?: return@withContext null
|
||||||
|
getCachedBlocksForChapter(chapter, chapterIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPaginationWorker(): Job = paginatorScope.launch(Dispatchers.IO) {
|
||||||
Timber.i("Pagination worker started.")
|
Timber.i("Pagination worker started.")
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
var request: PaginationRequest? = null
|
var request: PaginationRequest? = null
|
||||||
try {
|
try {
|
||||||
request = paginationQueue.take()
|
request = paginationQueue.poll(250, TimeUnit.MILLISECONDS) ?: continue
|
||||||
val chapterIndex = request.chapterIndex
|
val chapterIndex = request.chapterIndex
|
||||||
Timber.d("Worker: Took chapter $chapterIndex from queue with priority ${request.priority}.")
|
Timber.d("Worker: Took chapter $chapterIndex from queue with priority ${request.priority}.")
|
||||||
|
|
||||||
|
|
@ -933,6 +1072,9 @@ class BookPaginator(
|
||||||
} else {
|
} else {
|
||||||
Timber.e("Worker: Pagination for chapter $chapterIndex resulted in null.")
|
Timber.e("Worker: Pagination for chapter $chapterIndex resulted in null.")
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
Timber.i("Pagination worker cancelled. Shutting down.")
|
||||||
|
throw e
|
||||||
} catch (_: InterruptedException) {
|
} catch (_: InterruptedException) {
|
||||||
Timber.i("Pagination worker interrupted. Shutting down.")
|
Timber.i("Pagination worker interrupted. Shutting down.")
|
||||||
Thread.currentThread().interrupt()
|
Thread.currentThread().interrupt()
|
||||||
|
|
@ -968,7 +1110,7 @@ class BookPaginator(
|
||||||
"page_count_noop chapter=$chapterIndex count=$actualPageCount currentUserChapter=${currentUserChapterIndex.value}"
|
"page_count_noop chapter=$chapterIndex count=$actualPageCount currentUserChapter=${currentUserChapterIndex.value}"
|
||||||
)
|
)
|
||||||
if (!pageCountsAreAccurate && finalizedChapterCounts.add(chapterIndex)) {
|
if (!pageCountsAreAccurate && finalizedChapterCounts.add(chapterIndex)) {
|
||||||
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
|
paginatorScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1000,7 +1142,7 @@ class BookPaginator(
|
||||||
|
|
||||||
if (!pageCountsAreAccurate) {
|
if (!pageCountsAreAccurate) {
|
||||||
if (finalizedChapterCounts.add(chapterIndex)) {
|
if (finalizedChapterCounts.add(chapterIndex)) {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
paginatorScope.launch(Dispatchers.IO) {
|
||||||
updateAndSaveConfigurationCache()
|
updateAndSaveConfigurationCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1041,6 +1183,7 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getPageContent(pageIndex: Int): Page? {
|
override fun getPageContent(pageIndex: Int): Page? {
|
||||||
|
if (isDisposed()) return null
|
||||||
Timber.v("getPageContent requested for pageIndex $pageIndex")
|
Timber.v("getPageContent requested for pageIndex $pageIndex")
|
||||||
val chapterIndex = findChapterIndexForPage(pageIndex)
|
val chapterIndex = findChapterIndexForPage(pageIndex)
|
||||||
if (chapterIndex == null) {
|
if (chapterIndex == null) {
|
||||||
|
|
@ -1127,8 +1270,13 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun paginateChapter(chapterIndex: Int): List<Page>? {
|
private suspend fun paginateChapter(chapterIndex: Int): List<Page>? {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
if (isDisposed()) return null
|
||||||
pageCache[chapterIndex]?.let {
|
pageCache[chapterIndex]?.let {
|
||||||
Timber.d("paginateChapter: L1 Cache HIT for chapter $chapterIndex in MEMORY, returning cached pages.")
|
Timber.d("paginateChapter: L1 Cache HIT for chapter $chapterIndex in MEMORY, returning cached pages.")
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"page_memory_cache_hit chapter=$chapterIndex " + it.readerPagesLinkDiagSummary()
|
||||||
|
)
|
||||||
return it
|
return it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1142,12 +1290,9 @@ class BookPaginator(
|
||||||
return it
|
return it
|
||||||
}
|
}
|
||||||
|
|
||||||
val blocks = blockCache[chapterIndex] ?: run {
|
val blocks = getCachedBlocksForChapter(chapter, chapterIndex)
|
||||||
Timber.d("paginateChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.")
|
coroutineContext.ensureActive()
|
||||||
val blocksFromDb = getBlocksForChapter(chapter, chapterIndex)
|
if (isDisposed()) return null
|
||||||
blockCache.put(chapterIndex, blocksFromDb) // Store in L2 cache
|
|
||||||
blocksFromDb
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.d("paginateChapter: Chapter $chapterIndex retrieved/parsed into ${blocks.size} content blocks.")
|
Timber.d("paginateChapter: Chapter $chapterIndex retrieved/parsed into ${blocks.size} content blocks.")
|
||||||
|
|
||||||
|
|
@ -1165,7 +1310,12 @@ class BookPaginator(
|
||||||
measurementProvider = measurementProvider,
|
measurementProvider = measurementProvider,
|
||||||
density = density
|
density = density
|
||||||
)
|
)
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
if (isDisposed()) return null
|
||||||
Timber.d("paginateChapter: PaginatorLogic returned ${pages.size} pages for chapter $chapterIndex.")
|
Timber.d("paginateChapter: PaginatorLogic returned ${pages.size} pages for chapter $chapterIndex.")
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"pagination_result chapter=$chapterIndex " + pages.readerPagesLinkDiagSummary()
|
||||||
|
)
|
||||||
|
|
||||||
applyPageRuntimeIndexes(chapterIndex, pages)
|
applyPageRuntimeIndexes(chapterIndex, pages)
|
||||||
savePageCacheAsync(chapter, chapterIndex, pages)
|
savePageCacheAsync(chapter, chapterIndex, pages)
|
||||||
|
|
@ -1177,6 +1327,7 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun triggerPagination(chapterIndex: Int, priority: Int) {
|
private fun triggerPagination(chapterIndex: Int, priority: Int) {
|
||||||
|
if (isDisposed()) return
|
||||||
if (chapterIndex !in chapters.indices) {
|
if (chapterIndex !in chapters.indices) {
|
||||||
Timber.w("Trigger: Ignoring invalid chapter index $chapterIndex. Chapter count: ${chapters.size}.")
|
Timber.w("Trigger: Ignoring invalid chapter index $chapterIndex. Chapter count: ${chapters.size}.")
|
||||||
return
|
return
|
||||||
|
|
@ -1209,6 +1360,7 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun prefetchChapters(currentChapterIndex: Int) {
|
private fun prefetchChapters(currentChapterIndex: Int) {
|
||||||
|
if (isDisposed()) return
|
||||||
Timber.v("Prefetching chapters around index $currentChapterIndex.")
|
Timber.v("Prefetching chapters around index $currentChapterIndex.")
|
||||||
for (offset in 1..2) {
|
for (offset in 1..2) {
|
||||||
val nextChapterIndex = currentChapterIndex + offset
|
val nextChapterIndex = currentChapterIndex + offset
|
||||||
|
|
@ -1307,12 +1459,32 @@ class BookPaginator(
|
||||||
finalPage
|
finalPage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun findStableLocatorForAnchor(chapterIndex: Int, anchor: String?): Locator? = withContext(Dispatchers.IO) {
|
||||||
|
if (anchor.isNullOrBlank()) return@withContext Locator(chapterIndex, 0, 0)
|
||||||
|
|
||||||
|
val requestedChapter = chapters.getOrNull(chapterIndex) ?: return@withContext null
|
||||||
|
val requestedBlocks = getCachedBlocksForChapter(requestedChapter, chapterIndex)
|
||||||
|
findLocatorForAnchorInBlocks(chapterIndex, anchor, requestedBlocks)?.let { locator ->
|
||||||
|
return@withContext locator
|
||||||
|
}
|
||||||
|
|
||||||
|
val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor)
|
||||||
|
val targetChapter = indexEntry?.chapterIndex ?: chapterIndex
|
||||||
|
val chapter = chapters.getOrNull(targetChapter) ?: return@withContext null
|
||||||
|
val blocks = getCachedBlocksForChapter(chapter, targetChapter)
|
||||||
|
|
||||||
|
findLocatorForAnchorInBlocks(targetChapter, anchor, blocks)
|
||||||
|
?: indexEntry?.let { Locator(it.chapterIndex, it.blockIndex, 0) }
|
||||||
|
}
|
||||||
|
|
||||||
override fun findPageForAnchor(
|
override fun findPageForAnchor(
|
||||||
chapterIndex: Int,
|
chapterIndex: Int,
|
||||||
anchor: String?,
|
anchor: String?,
|
||||||
onResult: (pageIndex: Int) -> Unit
|
onResult: (pageIndex: Int) -> Unit
|
||||||
) {
|
) {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
if (isDisposed()) return
|
||||||
|
paginatorScope.launch(Dispatchers.IO) {
|
||||||
|
if (isDisposed()) return@launch
|
||||||
val page = findStablePageForAnchor(chapterIndex, anchor) ?: return@launch
|
val page = findStablePageForAnchor(chapterIndex, anchor) ?: return@launch
|
||||||
withContext(Dispatchers.Main) { onResult(page) }
|
withContext(Dispatchers.Main) { onResult(page) }
|
||||||
}
|
}
|
||||||
|
|
@ -1365,7 +1537,9 @@ class BookPaginator(
|
||||||
href: String,
|
href: String,
|
||||||
onNavigationComplete: (pageIndex: Int) -> Unit
|
onNavigationComplete: (pageIndex: Int) -> Unit
|
||||||
) {
|
) {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
if (isDisposed()) return
|
||||||
|
paginatorScope.launch(Dispatchers.IO) {
|
||||||
|
if (isDisposed()) return@launch
|
||||||
val targetPage = findStablePageForHref(currentChapterAbsPath, href) ?: return@launch
|
val targetPage = findStablePageForHref(currentChapterAbsPath, href) ?: return@launch
|
||||||
withContext(Dispatchers.Main) { onNavigationComplete(targetPage) }
|
withContext(Dispatchers.Main) { onNavigationComplete(targetPage) }
|
||||||
}
|
}
|
||||||
|
|
@ -1389,10 +1563,33 @@ class BookPaginator(
|
||||||
findStablePageForAnchor(targetChapterIndex, anchor)
|
findStablePageForAnchor(targetChapterIndex, anchor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun findStableLocatorForHref(currentChapterAbsPath: String, href: String): Locator? = withContext(Dispatchers.IO) {
|
||||||
|
val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href)
|
||||||
|
if (targetChapterPath == null) {
|
||||||
|
Timber.w("Could not resolve href '$href' to a valid chapter path.")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath }
|
||||||
|
if (targetChapterIndex == -1) {
|
||||||
|
Timber.w("Could not find chapter for path: $targetChapterPath")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
findStableLocatorForAnchor(targetChapterIndex, anchor)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun findStablePageForSearchResult(result: SearchResult): Int? = withContext(Dispatchers.IO) {
|
suspend fun findStablePageForSearchResult(result: SearchResult): Int? = withContext(Dispatchers.IO) {
|
||||||
val targetChapterIndex = result.locationInSource
|
val targetChapterIndex = result.locationInSource
|
||||||
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
|
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
|
||||||
|
|
||||||
|
findStableLocatorForSearchResult(result)?.let { locator ->
|
||||||
|
findStablePageForLocator(locator)?.let { page ->
|
||||||
|
Timber.i("Found exact search result locator $locator on absolute page $page")
|
||||||
|
return@withContext page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val chapterPages = ensureChapterPaginated(targetChapterIndex)
|
val chapterPages = ensureChapterPaginated(targetChapterIndex)
|
||||||
val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex)
|
val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex)
|
||||||
|
|
||||||
|
|
@ -1435,8 +1632,16 @@ class BookPaginator(
|
||||||
finalPageIndex
|
finalPageIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun findStableLocatorForSearchResult(result: SearchResult): Locator? = withContext(Dispatchers.IO) {
|
||||||
|
val chapter = chapters.getOrNull(result.locationInSource) ?: return@withContext null
|
||||||
|
val blocks = getCachedBlocksForChapter(chapter, result.locationInSource)
|
||||||
|
findLocatorForSearchResultInBlocks(result, blocks)
|
||||||
|
}
|
||||||
|
|
||||||
override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) {
|
override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
if (isDisposed()) return
|
||||||
|
paginatorScope.launch(Dispatchers.IO) {
|
||||||
|
if (isDisposed()) return@launch
|
||||||
val page = findStablePageForSearchResult(result) ?: return@launch
|
val page = findStablePageForSearchResult(result) ?: return@launch
|
||||||
withContext(Dispatchers.Main) { onResult(page) }
|
withContext(Dispatchers.Main) { onResult(page) }
|
||||||
}
|
}
|
||||||
|
|
@ -1617,7 +1822,9 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) {
|
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
if (isDisposed()) return
|
||||||
|
paginatorScope.launch(Dispatchers.IO) {
|
||||||
|
if (isDisposed()) return@launch
|
||||||
Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'")
|
Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'")
|
||||||
|
|
||||||
val chapterPages = ensureChapterPaginated(chapterIndex)
|
val chapterPages = ensureChapterPaginated(chapterIndex)
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,11 @@ class ContentStyler(
|
||||||
|
|
||||||
return when (block) {
|
return when (block) {
|
||||||
is SemanticParagraph -> {
|
is SemanticParagraph -> {
|
||||||
val computedTextAlign = userTextAlign ?: themedStyle.paragraphStyle.textAlign
|
val computedTextAlign = when {
|
||||||
|
userTextAlign != null -> userTextAlign
|
||||||
|
themedStyle.paragraphStyle.textAlign == TextAlign.Justify -> TextAlign.Left
|
||||||
|
else -> themedStyle.paragraphStyle.textAlign
|
||||||
|
}
|
||||||
|
|
||||||
ParagraphBlock(
|
ParagraphBlock(
|
||||||
content = buildAnnotatedString(block, themedStyle),
|
content = buildAnnotatedString(block, themedStyle),
|
||||||
|
|
@ -411,7 +415,10 @@ class ContentStyler(
|
||||||
withStyle(finalParagraphStyle) {
|
withStyle(finalParagraphStyle) {
|
||||||
withStyle(initialSpanStyle) {
|
withStyle(initialSpanStyle) {
|
||||||
append(block.text)
|
append(block.text)
|
||||||
|
val linkSpans = mutableListOf<SemanticSpan>()
|
||||||
block.spans.sortedBy { it.start }.forEach { span ->
|
block.spans.sortedBy { it.start }.forEach { span ->
|
||||||
|
val spanStart = span.start.coerceIn(0, block.text.length)
|
||||||
|
val spanEnd = span.end.coerceIn(spanStart, block.text.length)
|
||||||
val themedSpanStyle = applyThemeToStyle(span.style)
|
val themedSpanStyle = applyThemeToStyle(span.style)
|
||||||
val spanFontFamily = findFirstAvailableFontFamily(themedSpanStyle.fontFamilies, fontFamilyMap)
|
val spanFontFamily = findFirstAvailableFontFamily(themedSpanStyle.fontFamilies, fontFamilyMap)
|
||||||
val effectiveSpanFontFamily = if (spanFontFamily == FontFamily.Monospace) {
|
val effectiveSpanFontFamily = if (spanFontFamily == FontFamily.Monospace) {
|
||||||
|
|
@ -434,6 +441,7 @@ class ContentStyler(
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!span.linkHref.isNullOrBlank()) {
|
if (!span.linkHref.isNullOrBlank()) {
|
||||||
|
linkSpans.add(span)
|
||||||
finalSpanStyle = finalSpanStyle.withReaderLinkStyle(
|
finalSpanStyle = finalSpanStyle.withReaderLinkStyle(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
themeBackgroundColor = themeBackgroundColor,
|
themeBackgroundColor = themeBackgroundColor,
|
||||||
|
|
@ -459,31 +467,58 @@ class ContentStyler(
|
||||||
val offsetStr = if (themedSpanStyle.textUnderlineOffset.isSpecified) themedSpanStyle.textUnderlineOffset.value.toString() else "0"
|
val offsetStr = if (themedSpanStyle.textUnderlineOffset.isSpecified) themedSpanStyle.textUnderlineOffset.value.toString() else "0"
|
||||||
|
|
||||||
val annotationData = "$styleStr|$colorStr|$offsetStr"
|
val annotationData = "$styleStr|$colorStr|$offsetStr"
|
||||||
addStringAnnotation("CustomUnderline", annotationData, span.start, span.end)
|
if (spanStart < spanEnd) {
|
||||||
|
addStringAnnotation("CustomUnderline", annotationData, spanStart, spanEnd)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addStyle(initialSpanStyle.merge(finalSpanStyle), span.start, span.end)
|
if (spanStart < spanEnd) {
|
||||||
|
addStyle(initialSpanStyle.merge(finalSpanStyle), spanStart, spanEnd)
|
||||||
|
}
|
||||||
|
|
||||||
val ws = themedSpanStyle.wordSpacing
|
val ws = themedSpanStyle.wordSpacing
|
||||||
if (ws.isSpecified && ws.value != 0f) {
|
if (ws.isSpecified && ws.value != 0f && spanStart < spanEnd) {
|
||||||
val textToStyle = block.text.substring(span.start, span.end)
|
val textToStyle = block.text.substring(spanStart, spanEnd)
|
||||||
for (i in textToStyle.indices) {
|
for (i in textToStyle.indices) {
|
||||||
if (textToStyle[i] == ' ') {
|
if (textToStyle[i] == ' ') {
|
||||||
addStyle(SpanStyle(letterSpacing = ws), span.start + i, span.start + i + 1)
|
addStyle(SpanStyle(letterSpacing = ws), spanStart + i, spanStart + i + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
span.linkHref?.let { linkHref ->
|
span.linkHref?.takeIf { it.isNotBlank() }?.let { linkHref ->
|
||||||
addStringAnnotation("URL", linkHref, span.start, span.end)
|
if (spanStart < spanEnd) {
|
||||||
|
addStringAnnotation("URL", linkHref, spanStart, spanEnd)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
span.elementId?.let { elementId ->
|
span.elementId?.let { elementId ->
|
||||||
addStringAnnotation("ID", elementId, span.start, span.end)
|
addStringAnnotation("ID", elementId, spanStart, spanEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val forcedLinkStyle = readerLinkSpanStyle(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
themeBackgroundColor = themeBackgroundColor,
|
||||||
|
themeTextColor = themeTextColor
|
||||||
|
)
|
||||||
|
linkSpans.forEach { span ->
|
||||||
|
val start = span.start.coerceIn(0, block.text.length)
|
||||||
|
val end = span.end.coerceIn(start, block.text.length)
|
||||||
|
if (start < end) {
|
||||||
|
addStyle(forcedLinkStyle, start, end)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (block.spans.any { !it.linkHref.isNullOrBlank() }) {
|
||||||
|
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
|
||||||
|
"style_text_block type=${block::class.simpleName ?: "Text"} " +
|
||||||
|
"block=${block.blockIndex} cfi=${block.cfi} " +
|
||||||
|
"rawLinkSpans=${block.spans.count { !it.linkHref.isNullOrBlank() }} " +
|
||||||
|
builtString.readerAnnotatedLinkDiagSummary()
|
||||||
|
)
|
||||||
|
}
|
||||||
return builtString.maybeAdjustLineHeightForEmphasis()
|
return builtString.maybeAdjustLineHeightForEmphasis()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -562,7 +597,10 @@ class ContentStyler(
|
||||||
fontFamilyMap: Map<String, FontFamily>
|
fontFamilyMap: Map<String, FontFamily>
|
||||||
): FontFamily? {
|
): FontFamily? {
|
||||||
if (fontFamilyNames.isEmpty()) return null
|
if (fontFamilyNames.isEmpty()) return null
|
||||||
val specificFont = fontFamilyNames.firstNotNullOfOrNull { fontFamilyMap[it] }
|
val normalizedMap = fontFamilyMap.entries.associate { it.key.trim().lowercase() to it.value }
|
||||||
|
val specificFont = fontFamilyNames.firstNotNullOfOrNull { name ->
|
||||||
|
normalizedMap[name.trim().removeSurrounding("\"").removeSurrounding("'").lowercase()]
|
||||||
|
}
|
||||||
if (specificFont != null) return specificFont
|
if (specificFont != null) return specificFont
|
||||||
return fontFamilyNames.firstNotNullOfOrNull { name -> FontFamilyMapper.nameToFontFamily(name) }
|
return fontFamilyNames.firstNotNullOfOrNull { name -> FontFamilyMapper.nameToFontFamily(name) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,4 +53,5 @@ interface IPaginator {
|
||||||
fun getCfiForPage(pageIndex: Int): String?
|
fun getCfiForPage(pageIndex: Int): String?
|
||||||
fun onUserScrolledTo(pageIndex: Int)
|
fun onUserScrolledTo(pageIndex: Int)
|
||||||
fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List<String>): String?
|
fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List<String>): String?
|
||||||
|
fun dispose() = Unit
|
||||||
}
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import com.aryan.reader.epub.EpubBook
|
import com.aryan.reader.epub.EpubBook
|
||||||
|
import com.aryan.reader.epub.EpubChapter
|
||||||
import com.aryan.reader.epub.contentFilePath
|
import com.aryan.reader.epub.contentFilePath
|
||||||
import com.aryan.reader.paginatedreader.data.BookCacheDao
|
import com.aryan.reader.paginatedreader.data.BookCacheDao
|
||||||
import com.aryan.reader.paginatedreader.data.ProcessedChapter
|
import com.aryan.reader.paginatedreader.data.ProcessedChapter
|
||||||
|
|
@ -36,6 +37,9 @@ import kotlinx.serialization.encodeToByteArray
|
||||||
import kotlinx.serialization.protobuf.ProtoBuf
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
private const val MAX_LOCATOR_ON_DEMAND_HTML_BYTES = 2L * 1024L * 1024L
|
||||||
|
private const val MAX_LOCATOR_ON_DEMAND_HTML_CHARS = 2 * 1024 * 1024
|
||||||
|
|
||||||
data class Locator(
|
data class Locator(
|
||||||
val chapterIndex: Int,
|
val chapterIndex: Int,
|
||||||
val blockIndex: Int,
|
val blockIndex: Int,
|
||||||
|
|
@ -66,21 +70,9 @@ class LocatorConverter(
|
||||||
try {
|
try {
|
||||||
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null
|
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null
|
||||||
|
|
||||||
val htmlToParse = chapter.htmlContent.ifBlank {
|
val htmlToParse = readChapterHtmlForLocator(book, chapter, chapterIndex)
|
||||||
try {
|
|
||||||
val file = File(book.extractionBasePath, chapter.contentFilePath())
|
|
||||||
if (file.exists()) {
|
|
||||||
val content = file.readText()
|
|
||||||
content
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (htmlToParse.isBlank()) {
|
if (htmlToParse.isNullOrBlank()) {
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,22 +141,77 @@ class LocatorConverter(
|
||||||
)
|
)
|
||||||
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
|
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
|
||||||
semanticBlocks
|
semanticBlocks
|
||||||
|
} catch (e: OutOfMemoryError) {
|
||||||
|
Timber.e(e, "Out of memory while processing locator cache for chapter $chapterIndex")
|
||||||
|
null
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun readChapterHtmlForLocator(
|
||||||
|
book: EpubBook,
|
||||||
|
chapter: EpubChapter,
|
||||||
|
chapterIndex: Int
|
||||||
|
): String? {
|
||||||
|
chapter.htmlContent.takeIf { it.isNotBlank() }?.let { inlineHtml ->
|
||||||
|
if (inlineHtml.length > MAX_LOCATOR_ON_DEMAND_HTML_CHARS) {
|
||||||
|
Timber.w(
|
||||||
|
"Skipping on-demand locator processing for chapter $chapterIndex: " +
|
||||||
|
"inline HTML is ${inlineHtml.length} chars"
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return inlineHtml
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val file = File(book.extractionBasePath, chapter.contentFilePath())
|
||||||
|
if (!file.isFile) return null
|
||||||
|
if (file.length() > MAX_LOCATOR_ON_DEMAND_HTML_BYTES) {
|
||||||
|
Timber.w(
|
||||||
|
"Skipping on-demand locator processing for chapter $chapterIndex: " +
|
||||||
|
"HTML file is ${file.length()} bytes"
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
file.bufferedReader().use { it.readText() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeCachedBlocks(
|
||||||
|
processedChapter: ProcessedChapter?,
|
||||||
|
chapterIndex: Int
|
||||||
|
): List<SemanticBlock>? {
|
||||||
|
if (processedChapter == null || processedChapter.contentBlocksProto.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
|
||||||
|
} catch (e: OutOfMemoryError) {
|
||||||
|
Timber.e(e, "Out of memory while decoding locator cache for chapter $chapterIndex")
|
||||||
|
null
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getProcessedChapterSafely(bookId: String, chapterIndex: Int): ProcessedChapter? {
|
||||||
|
return try {
|
||||||
|
bookCacheDao.getProcessedChapter(bookId = bookId, chapterIndex = chapterIndex)
|
||||||
|
} catch (e: OutOfMemoryError) {
|
||||||
|
Timber.e(e, "Out of memory while loading locator cache for chapter $chapterIndex")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String, bookId: String? = null): Locator? = withContext(Dispatchers.IO) {
|
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String, bookId: String? = null): Locator? = withContext(Dispatchers.IO) {
|
||||||
Timber.tag("POS_DIAG").d("getLocatorFromCfi: Input CFI='$cfi' for chapterIndex=$chapterIndex")
|
Timber.tag("POS_DIAG").d("getLocatorFromCfi: Input CFI='$cfi' for chapterIndex=$chapterIndex")
|
||||||
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
|
val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
|
||||||
|
|
||||||
var allBlocks: List<SemanticBlock>? = null
|
var allBlocks = decodeCachedBlocks(processedChapter, chapterIndex)
|
||||||
|
|
||||||
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
|
|
||||||
allBlocks = try {
|
|
||||||
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allBlocks.isNullOrEmpty()) {
|
if (allBlocks.isNullOrEmpty()) {
|
||||||
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
|
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
|
||||||
|
|
@ -174,17 +221,33 @@ class LocatorConverter(
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
val (baseCfiPath, charOffset) = cfi.split(':').let {
|
val firstCfiPoint = cfi.substringBefore('|')
|
||||||
it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 0)
|
val cfiOffsetSeparator = firstCfiPoint.lastIndexOf(':')
|
||||||
|
val baseCfiPath = if (cfiOffsetSeparator > 0) {
|
||||||
|
firstCfiPoint.substring(0, cfiOffsetSeparator)
|
||||||
|
} else {
|
||||||
|
firstCfiPoint
|
||||||
|
}
|
||||||
|
val charOffset = if (cfiOffsetSeparator > 0 && cfiOffsetSeparator < firstCfiPoint.lastIndex) {
|
||||||
|
firstCfiPoint.substring(cfiOffsetSeparator + 1).toIntOrNull() ?: 0
|
||||||
|
} else {
|
||||||
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath)
|
val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath)
|
||||||
|
|
||||||
if (bestMatch != null) {
|
if (bestMatch != null) {
|
||||||
|
val absoluteCharOffset = when (bestMatch) {
|
||||||
|
is SemanticTextBlock -> {
|
||||||
|
val localOffset = charOffset.coerceIn(0, bestMatch.text.length)
|
||||||
|
bestMatch.startCharOffsetInSource + localOffset
|
||||||
|
}
|
||||||
|
else -> charOffset.coerceAtLeast(0)
|
||||||
|
}
|
||||||
val locator = Locator(
|
val locator = Locator(
|
||||||
chapterIndex = chapterIndex,
|
chapterIndex = chapterIndex,
|
||||||
blockIndex = bestMatch.blockIndex,
|
blockIndex = bestMatch.blockIndex,
|
||||||
charOffset = charOffset
|
charOffset = absoluteCharOffset
|
||||||
)
|
)
|
||||||
Timber.tag("POS_DIAG").d("getLocatorFromCfi: Successfully resolved to $locator")
|
Timber.tag("POS_DIAG").d("getLocatorFromCfi: Successfully resolved to $locator")
|
||||||
locator
|
locator
|
||||||
|
|
@ -238,14 +301,9 @@ class LocatorConverter(
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int, bookId: String? = null): List<TtsChunk>? = withContext(Dispatchers.IO) {
|
suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int, bookId: String? = null): List<TtsChunk>? = withContext(Dispatchers.IO) {
|
||||||
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
|
val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
|
||||||
|
|
||||||
var allBlocks: List<SemanticBlock>? = null
|
var allBlocks = decodeCachedBlocks(processedChapter, chapterIndex)
|
||||||
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
|
|
||||||
allBlocks = try {
|
|
||||||
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allBlocks.isNullOrEmpty()) {
|
if (allBlocks.isNullOrEmpty()) {
|
||||||
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
|
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
|
||||||
|
|
@ -294,14 +352,9 @@ class LocatorConverter(
|
||||||
|
|
||||||
suspend fun getCfiFromLocator(book: EpubBook, locator: Locator, bookId: String? = null): String? = withContext(Dispatchers.IO) {
|
suspend fun getCfiFromLocator(book: EpubBook, locator: Locator, bookId: String? = null): String? = withContext(Dispatchers.IO) {
|
||||||
Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator")
|
Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator")
|
||||||
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
|
val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
|
||||||
|
|
||||||
var blocks: List<SemanticBlock>? = null
|
var blocks = decodeCachedBlocks(processedChapter, locator.chapterIndex)
|
||||||
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
|
|
||||||
blocks = try {
|
|
||||||
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blocks.isNullOrEmpty()) {
|
if (blocks.isNullOrEmpty()) {
|
||||||
blocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
|
blocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
|
||||||
|
|
@ -313,8 +366,20 @@ class LocatorConverter(
|
||||||
|
|
||||||
val foundBlock = findBlockByBlockIndex(blocks, locator.blockIndex)
|
val foundBlock = findBlockByBlockIndex(blocks, locator.blockIndex)
|
||||||
val resultCfi = foundBlock?.cfi?.let { cfi ->
|
val resultCfi = foundBlock?.cfi?.let { cfi ->
|
||||||
if (locator.charOffset > 0) {
|
val localOffset = when (foundBlock) {
|
||||||
"$cfi:${locator.charOffset}"
|
is SemanticTextBlock -> {
|
||||||
|
val start = foundBlock.startCharOffsetInSource
|
||||||
|
val end = start + foundBlock.text.length
|
||||||
|
if (locator.charOffset in start..end) {
|
||||||
|
locator.charOffset - start
|
||||||
|
} else {
|
||||||
|
locator.charOffset
|
||||||
|
}.coerceIn(0, foundBlock.text.length)
|
||||||
|
}
|
||||||
|
else -> locator.charOffset.coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
if (localOffset > 0) {
|
||||||
|
"$cfi:$localOffset"
|
||||||
} else {
|
} else {
|
||||||
cfi
|
cfi
|
||||||
}
|
}
|
||||||
|
|
@ -363,14 +428,9 @@ class LocatorConverter(
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getTextOffset(book: EpubBook, locator: Locator, bookId: String? = null): Int? = withContext(Dispatchers.IO) {
|
suspend fun getTextOffset(book: EpubBook, locator: Locator, bookId: String? = null): Int? = withContext(Dispatchers.IO) {
|
||||||
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
|
val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
|
||||||
|
|
||||||
var allBlocks: List<SemanticBlock>? = null
|
var allBlocks = decodeCachedBlocks(processedChapter, locator.chapterIndex)
|
||||||
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
|
|
||||||
allBlocks = try {
|
|
||||||
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
|
|
||||||
} catch(_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allBlocks.isNullOrEmpty()) {
|
if (allBlocks.isNullOrEmpty()) {
|
||||||
allBlocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
|
allBlocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
|
||||||
|
|
@ -384,7 +444,19 @@ class LocatorConverter(
|
||||||
fun traverse(blocks: List<SemanticBlock>): Boolean {
|
fun traverse(blocks: List<SemanticBlock>): Boolean {
|
||||||
for (block in blocks) {
|
for (block in blocks) {
|
||||||
if (block.blockIndex == locator.blockIndex) {
|
if (block.blockIndex == locator.blockIndex) {
|
||||||
offset += locator.charOffset
|
val absoluteOffset = when (block) {
|
||||||
|
is SemanticTextBlock -> {
|
||||||
|
val start = block.startCharOffsetInSource
|
||||||
|
val end = start + block.text.length
|
||||||
|
locator.charOffset.takeIf { (start > 0 || offset == 0) && it in start..end }
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (absoluteOffset != null) {
|
||||||
|
offset = absoluteOffset
|
||||||
|
} else {
|
||||||
|
offset += locator.charOffset
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import android.webkit.JavascriptInterface
|
||||||
import android.webkit.WebChromeClient
|
import android.webkit.WebChromeClient
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
|
import com.aryan.reader.BuildConfig
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
@ -182,11 +183,18 @@ class MathMLRenderer(private val context: Context) {
|
||||||
val mathMLForJs = job.mathML.replace("`", "\\`")
|
val mathMLForJs = job.mathML.replace("`", "\\`")
|
||||||
val script = """
|
val script = """
|
||||||
(function() {
|
(function() {
|
||||||
console.log("MATH_DIAGNOSTIC: Starting MathML to SVG conversion.");
|
var mathDiagnosticsEnabled = ${BuildConfig.DEBUG};
|
||||||
|
function mathLog() {
|
||||||
|
if (mathDiagnosticsEnabled) console.log.apply(console, arguments);
|
||||||
|
}
|
||||||
|
function mathError() {
|
||||||
|
if (mathDiagnosticsEnabled) console.error.apply(console, arguments);
|
||||||
|
}
|
||||||
|
mathLog("MATH_DIAGNOSTIC: Starting MathML to SVG conversion.");
|
||||||
const mathMLContent = `${mathMLForJs}`;
|
const mathMLContent = `${mathMLForJs}`;
|
||||||
console.log("MATH_DIAGNOSTIC: Input MathML: " + mathMLContent);
|
mathLog("MATH_DIAGNOSTIC: Input MathML: " + mathMLContent);
|
||||||
MathJax.mathml2svgPromise(mathMLContent).then(function (node) {
|
MathJax.mathml2svgPromise(mathMLContent).then(function (node) {
|
||||||
console.log("MATH_DIAGNOSTIC: mathml2svgPromise successful.");
|
mathLog("MATH_DIAGNOSTIC: mathml2svgPromise successful.");
|
||||||
var svgElement = node.querySelector('svg');
|
var svgElement = node.querySelector('svg');
|
||||||
if (svgElement) {
|
if (svgElement) {
|
||||||
svgElement.style.fill = 'currentColor';
|
svgElement.style.fill = 'currentColor';
|
||||||
|
|
@ -194,15 +202,15 @@ class MathMLRenderer(private val context: Context) {
|
||||||
var width = svgElement.getAttribute('width');
|
var width = svgElement.getAttribute('width');
|
||||||
var height = svgElement.getAttribute('height');
|
var height = svgElement.getAttribute('height');
|
||||||
var viewBox = svgElement.getAttribute('viewBox');
|
var viewBox = svgElement.getAttribute('viewBox');
|
||||||
console.log('MATH_SIZE_DIAGNOSTIC: Generated SVG details -> width: ' + width + ', height: ' + height + ', viewBox: ' + viewBox + ', length: ' + svgOutput.length);
|
mathLog('MATH_SIZE_DIAGNOSTIC: Generated SVG details -> width: ' + width + ', height: ' + height + ', viewBox: ' + viewBox + ', length: ' + svgOutput.length);
|
||||||
console.log("MATH_DIAGNOSTIC: SVG generated: " + svgOutput);
|
mathLog("MATH_DIAGNOSTIC: SVG generated: " + svgOutput);
|
||||||
AndroidBridge.onSvgReady(svgOutput);
|
AndroidBridge.onSvgReady(svgOutput);
|
||||||
} else {
|
} else {
|
||||||
console.error("MATH_DIAGNOSTIC: SVG element not found in MathJax output.");
|
mathError("MATH_DIAGNOSTIC: SVG element not found in MathJax output.");
|
||||||
AndroidBridge.onSvgReady('');
|
AndroidBridge.onSvgReady('');
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.error("MATH_DIAGNOSTIC: MathJax conversion error:", err);
|
mathError("MATH_DIAGNOSTIC: MathJax conversion error:", err);
|
||||||
AndroidBridge.onSvgReady('');
|
AndroidBridge.onSvgReady('');
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -58,6 +58,7 @@ class PaginatedReaderViewModel : ViewModel() {
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
internal fun setPaginatorForTest(testPaginator: IPaginator) {
|
internal fun setPaginatorForTest(testPaginator: IPaginator) {
|
||||||
|
paginator?.dispose()
|
||||||
paginator = testPaginator
|
paginator = testPaginator
|
||||||
observePaginatorState()
|
observePaginatorState()
|
||||||
}
|
}
|
||||||
|
|
@ -177,4 +178,9 @@ class PaginatedReaderViewModel : ViewModel() {
|
||||||
fun onLinkClick(currentChapterPath: String, href: String, onNavigationComplete: (Int) -> Unit) {
|
fun onLinkClick(currentChapterPath: String, href: String, onNavigationComplete: (Int) -> Unit) {
|
||||||
paginator?.navigateToHref(currentChapterPath, href, onNavigationComplete)
|
paginator?.navigateToHref(currentChapterPath, href, onNavigationComplete)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
paginator?.dispose()
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,17 @@
|
||||||
package com.aryan.reader.paginatedreader
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import com.aryan.reader.BuildConfig
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.TextMeasurer
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextIndent
|
import androidx.compose.ui.text.style.TextIndent
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
|
|
@ -34,11 +39,66 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.isSpecified
|
import androidx.compose.ui.unit.isSpecified
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.math.ceil
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
private const val DEBUG_PAGINATION_LOGS = false
|
private const val DEBUG_PAGINATION_LOGS = false
|
||||||
|
private const val AndroidEpubCutoffLogTag = "EpistemeEpubCutoff"
|
||||||
|
private const val JustifiedSplitGapProbeMinFraction = 0.18f
|
||||||
|
|
||||||
|
internal fun measuredTextHeightForPagination(
|
||||||
|
layoutHeightPx: Int,
|
||||||
|
lastLineBottomPx: Float
|
||||||
|
): Int {
|
||||||
|
return maxOf(layoutHeightPx, ceil(lastLineBottomPx.toDouble()).toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TextLayoutResult.paginationMeasuredHeightPx(): Int {
|
||||||
|
val lastLineBottomPx = if (lineCount > 0) getLineBottom(lineCount - 1) else 0f
|
||||||
|
return measuredTextHeightForPagination(size.height, lastLineBottomPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logAndroidEpubCutoff(message: String) {
|
||||||
|
if (!BuildConfig.DEBUG) return
|
||||||
|
Log.d(AndroidEpubCutoffLogTag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CharSequence.firstWordOrEmpty(): String {
|
||||||
|
var start = 0
|
||||||
|
while (start < length && this[start].isWhitespace()) start++
|
||||||
|
if (start >= length) return ""
|
||||||
|
var end = start
|
||||||
|
while (end < length && !this[end].isWhitespace()) end++
|
||||||
|
return subSequence(start, end).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CharSequence.skipWhitespaceFrom(index: Int): Int {
|
||||||
|
var current = index.coerceIn(0, length)
|
||||||
|
while (current < length && this[current].isWhitespace()) current++
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CharSequence.trimTrailingWhitespaceBefore(index: Int): Int {
|
||||||
|
var current = index.coerceIn(0, length)
|
||||||
|
while (current > 0 && this[current - 1].isWhitespace()) current--
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CharSequence.nextWordEndAfter(index: Int): Int {
|
||||||
|
var current = skipWhitespaceFrom(index)
|
||||||
|
while (current < length && !this[current].isWhitespace()) current++
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CharSequence.previousWordEndBefore(index: Int): Int {
|
||||||
|
var current = trimTrailingWhitespaceBefore(index)
|
||||||
|
while (current > 0 && !this[current - 1].isWhitespace()) current--
|
||||||
|
return trimTrailingWhitespaceBefore(current)
|
||||||
|
}
|
||||||
|
|
||||||
interface BlockMeasurementProvider {
|
interface BlockMeasurementProvider {
|
||||||
suspend fun measure(block: ContentBlock): Int
|
suspend fun measure(block: ContentBlock): Int
|
||||||
|
|
@ -59,6 +119,7 @@ class SuspendingAndroidBlockMeasurementProvider(
|
||||||
private val measurementCache = ConcurrentHashMap<Int, Int>()
|
private val measurementCache = ConcurrentHashMap<Int, Int>()
|
||||||
|
|
||||||
override suspend fun measure(block: ContentBlock): Int {
|
override suspend fun measure(block: ContentBlock): Int {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val cacheKey = blockMeasurementCacheKey(block)
|
val cacheKey = blockMeasurementCacheKey(block)
|
||||||
measurementCache[cacheKey]?.let { return it }
|
measurementCache[cacheKey]?.let { return it }
|
||||||
|
|
||||||
|
|
@ -85,6 +146,7 @@ class SuspendingAndroidBlockMeasurementProvider(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair<ParagraphBlock, ParagraphBlock>? {
|
override suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair<ParagraphBlock, ParagraphBlock>? {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
return splitParagraphBlock(
|
return splitParagraphBlock(
|
||||||
block = block,
|
block = block,
|
||||||
textMeasurer = textMeasurer,
|
textMeasurer = textMeasurer,
|
||||||
|
|
@ -96,6 +158,7 @@ class SuspendingAndroidBlockMeasurementProvider(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun split(block: WrappingContentBlock, availableHeight: Int): Pair<WrappingContentBlock, List<ContentBlock>>? {
|
override suspend fun split(block: WrappingContentBlock, availableHeight: Int): Pair<WrappingContentBlock, List<ContentBlock>>? {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
|
||||||
val imageBlock = block.floatedImage
|
val imageBlock = block.floatedImage
|
||||||
val (imageWidthPx, imageHeightPx) = run {
|
val (imageWidthPx, imageHeightPx) = run {
|
||||||
|
|
@ -145,6 +208,7 @@ class SuspendingAndroidBlockMeasurementProvider(
|
||||||
val wrappingContentWidth = (constraints.maxWidth - imageWidthPx).toInt().coerceAtLeast(0)
|
val wrappingContentWidth = (constraints.maxWidth - imageWidthPx).toInt().coerceAtLeast(0)
|
||||||
|
|
||||||
while (textOffset < fullText.length) {
|
while (textOffset < fullText.length) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val isBesideImage = currentY < imageHeightPx
|
val isBesideImage = currentY < imageHeightPx
|
||||||
val currentMaxWidth = if (isBesideImage) wrappingContentWidth else constraints.maxWidth
|
val currentMaxWidth = if (isBesideImage) wrappingContentWidth else constraints.maxWidth
|
||||||
|
|
||||||
|
|
@ -212,6 +276,7 @@ class SuspendingAndroidBlockMeasurementProvider(
|
||||||
var splitOccurred = false
|
var splitOccurred = false
|
||||||
|
|
||||||
for ((index, paraRange) in paragraphOffsets.withIndex()) {
|
for ((index, paraRange) in paragraphOffsets.withIndex()) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val originalPara = block.paragraphsToWrap[index]
|
val originalPara = block.paragraphsToWrap[index]
|
||||||
|
|
||||||
if (splitOccurred) {
|
if (splitOccurred) {
|
||||||
|
|
@ -287,6 +352,7 @@ class SuspendingAndroidBlockMeasurementProvider(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun split(block: TableBlock, availableHeight: Int): Pair<TableBlock, TableBlock>? {
|
override suspend fun split(block: TableBlock, availableHeight: Int): Pair<TableBlock, TableBlock>? {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
var currentHeight = 0
|
var currentHeight = 0
|
||||||
var splitRowIndex = -1
|
var splitRowIndex = -1
|
||||||
|
|
||||||
|
|
@ -304,17 +370,28 @@ class SuspendingAndroidBlockMeasurementProvider(
|
||||||
currentHeight += decorationTop
|
currentHeight += decorationTop
|
||||||
|
|
||||||
for (i in block.rows.indices) {
|
for (i in block.rows.indices) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val row = block.rows[i]
|
val row = block.rows[i]
|
||||||
var maxRowHeight = 0
|
var maxRowHeight = 0
|
||||||
val totalColspan = row.sumOf { it.colspan }.toFloat().coerceAtLeast(1f)
|
val totalColspan = row.sumOf { it.colspan }.toFloat().coerceAtLeast(1f)
|
||||||
|
|
||||||
row.forEach { cell ->
|
for (cell in row) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val cellMaxWidth = ((constraints.maxWidth) * (cell.colspan.toFloat() / totalColspan)).roundToInt()
|
val cellMaxWidth = ((constraints.maxWidth) * (cell.colspan.toFloat() / totalColspan)).roundToInt()
|
||||||
@Suppress("UnusedVariable", "Unused") val cellConstraints = constraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0))
|
val cellConstraints = constraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0))
|
||||||
|
|
||||||
var cellHeight = 0
|
var cellHeight = 0
|
||||||
cell.content.forEach { b ->
|
for (b in cell.content) {
|
||||||
cellHeight += measure(b)
|
coroutineContext.ensureActive()
|
||||||
|
cellHeight += measureBlockHeight(
|
||||||
|
block = b,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
constraints = cellConstraints,
|
||||||
|
defaultStyle = textStyle,
|
||||||
|
headerStyle = textStyle.copy(fontWeight = FontWeight.Bold),
|
||||||
|
density = density,
|
||||||
|
imageSizeMultiplier = imageSizeMultiplier
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val cellDecoration = with(density) {
|
val cellDecoration = with(density) {
|
||||||
cell.style.blockStyle.padding.top.toPx() + cell.style.blockStyle.padding.bottom.toPx() +
|
cell.style.blockStyle.padding.top.toPx() + cell.style.blockStyle.padding.bottom.toPx() +
|
||||||
|
|
@ -346,6 +423,7 @@ class SuspendingAndroidBlockMeasurementProvider(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair<FlexContainerBlock, FlexContainerBlock>? {
|
override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair<FlexContainerBlock, FlexContainerBlock>? {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
if (block.style.flexDirection == "row") return null
|
if (block.style.flexDirection == "row") return null
|
||||||
|
|
||||||
var currentHeight = 0
|
var currentHeight = 0
|
||||||
|
|
@ -362,6 +440,7 @@ class SuspendingAndroidBlockMeasurementProvider(
|
||||||
currentHeight += decorationTop
|
currentHeight += decorationTop
|
||||||
|
|
||||||
for (i in block.children.indices) {
|
for (i in block.children.indices) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val child = block.children[i]
|
val child = block.children[i]
|
||||||
val childHeight = measure(child)
|
val childHeight = measure(child)
|
||||||
val margin = with(density) {
|
val margin = with(density) {
|
||||||
|
|
@ -422,6 +501,15 @@ private fun <T : ContentBlock> setBlockExpectedHeight(block: T, height: Int): T
|
||||||
} as T
|
} as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun BlockStyle.avoidsBreakInside(): Boolean =
|
||||||
|
pageBreakInsideAvoid || breakInside in setOf("avoid", "avoid-page", "avoid-column")
|
||||||
|
|
||||||
|
private fun BlockStyle.forcesBreakBefore(): Boolean =
|
||||||
|
breakBefore in setOf("page", "always", "left", "right", "recto", "verso")
|
||||||
|
|
||||||
|
private fun BlockStyle.forcesBreakAfter(): Boolean =
|
||||||
|
breakAfter in setOf("page", "always", "left", "right", "recto", "verso")
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||||
suspend fun paginate(
|
suspend fun paginate(
|
||||||
blocks: List<ContentBlock>,
|
blocks: List<ContentBlock>,
|
||||||
|
|
@ -444,8 +532,19 @@ suspend fun paginate(
|
||||||
val safetyMarginPerBlock = 0
|
val safetyMarginPerBlock = 0
|
||||||
|
|
||||||
while (remainingBlocks.isNotEmpty()) {
|
while (remainingBlocks.isNotEmpty()) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val block = remainingBlocks.removeAt(0)
|
val block = remainingBlocks.removeAt(0)
|
||||||
|
|
||||||
|
if (currentPageContent.isNotEmpty() && block.style.forcesBreakBefore()) {
|
||||||
|
zeroOutBottomMargin(currentPageContent)
|
||||||
|
pages.add(Page(content = currentPageContent.toList()))
|
||||||
|
pageIndex++
|
||||||
|
currentPageContent = mutableListOf()
|
||||||
|
remainingHeight = pageHeight
|
||||||
|
remainingBlocks.add(0, block)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
val blockHeight = measurementProvider.measure(block)
|
val blockHeight = measurementProvider.measure(block)
|
||||||
val blockHeightWithSafetyMargin = blockHeight + safetyMarginPerBlock
|
val blockHeightWithSafetyMargin = blockHeight + safetyMarginPerBlock
|
||||||
|
|
||||||
|
|
@ -488,6 +587,14 @@ suspend fun paginate(
|
||||||
|
|
||||||
currentPageContent.add(blockToAdd)
|
currentPageContent.add(blockToAdd)
|
||||||
remainingHeight -= spaceRequired
|
remainingHeight -= spaceRequired
|
||||||
|
|
||||||
|
if (block.style.forcesBreakAfter() && remainingBlocks.isNotEmpty()) {
|
||||||
|
zeroOutBottomMargin(currentPageContent)
|
||||||
|
pages.add(Page(content = currentPageContent.toList()))
|
||||||
|
pageIndex++
|
||||||
|
currentPageContent = mutableListOf()
|
||||||
|
remainingHeight = pageHeight
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
var wasSplit = false
|
var wasSplit = false
|
||||||
val heightForSplitting = remainingHeight - spaceBetweenBlocks
|
val heightForSplitting = remainingHeight - spaceBetweenBlocks
|
||||||
|
|
@ -495,7 +602,7 @@ suspend fun paginate(
|
||||||
if (heightForSplitting > 50) {
|
if (heightForSplitting > 50) {
|
||||||
when (block) {
|
when (block) {
|
||||||
is ParagraphBlock -> {
|
is ParagraphBlock -> {
|
||||||
if (!block.style.pageBreakInsideAvoid) {
|
if (!block.style.avoidsBreakInside()) {
|
||||||
measurementProvider.split(block, heightForSplitting)
|
measurementProvider.split(block, heightForSplitting)
|
||||||
?.let { (part1, part2) ->
|
?.let { (part1, part2) ->
|
||||||
if (part1.content.isNotEmpty()) {
|
if (part1.content.isNotEmpty()) {
|
||||||
|
|
@ -534,7 +641,7 @@ suspend fun paginate(
|
||||||
}
|
}
|
||||||
|
|
||||||
is WrappingContentBlock -> {
|
is WrappingContentBlock -> {
|
||||||
measurementProvider.split(block, heightForSplitting)
|
if (!block.style.avoidsBreakInside()) measurementProvider.split(block, heightForSplitting)
|
||||||
?.let { (part1, part2) ->
|
?.let { (part1, part2) ->
|
||||||
if (part1.paragraphsToWrap.any { it.content.isNotBlank() }) {
|
if (part1.paragraphsToWrap.any { it.content.isNotBlank() }) {
|
||||||
val collapsedMarginDp =
|
val collapsedMarginDp =
|
||||||
|
|
@ -570,7 +677,7 @@ suspend fun paginate(
|
||||||
}
|
}
|
||||||
|
|
||||||
is TableBlock -> {
|
is TableBlock -> {
|
||||||
measurementProvider.split(block, heightForSplitting)
|
if (!block.style.avoidsBreakInside()) measurementProvider.split(block, heightForSplitting)
|
||||||
?.let { (part1, part2) ->
|
?.let { (part1, part2) ->
|
||||||
val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() }
|
val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() }
|
||||||
if (currentPageContent.isNotEmpty()) {
|
if (currentPageContent.isNotEmpty()) {
|
||||||
|
|
@ -602,7 +709,7 @@ suspend fun paginate(
|
||||||
}
|
}
|
||||||
|
|
||||||
is FlexContainerBlock -> {
|
is FlexContainerBlock -> {
|
||||||
measurementProvider.split(block, heightForSplitting)
|
if (!block.style.avoidsBreakInside()) measurementProvider.split(block, heightForSplitting)
|
||||||
?.let { (part1, part2) ->
|
?.let { (part1, part2) ->
|
||||||
val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() }
|
val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() }
|
||||||
if (currentPageContent.isNotEmpty()) {
|
if (currentPageContent.isNotEmpty()) {
|
||||||
|
|
@ -692,6 +799,7 @@ private suspend fun measureBlockHeight(
|
||||||
density: Density,
|
density: Density,
|
||||||
imageSizeMultiplier: Float = 1.0f
|
imageSizeMultiplier: Float = 1.0f
|
||||||
): Int {
|
): Int {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val boxMetrics = computeBlockBoxMetrics(block, constraints, density)
|
val boxMetrics = computeBlockBoxMetrics(block, constraints, density)
|
||||||
val verticalPaddingPx = boxMetrics.verticalPaddingPx
|
val verticalPaddingPx = boxMetrics.verticalPaddingPx
|
||||||
val verticalBorderPx = boxMetrics.verticalBorderPx
|
val verticalBorderPx = boxMetrics.verticalBorderPx
|
||||||
|
|
@ -705,7 +813,7 @@ private suspend fun measureBlockHeight(
|
||||||
text = block.content,
|
text = block.content,
|
||||||
style = paragraphStyle,
|
style = paragraphStyle,
|
||||||
constraints = adjustedConstraints
|
constraints = adjustedConstraints
|
||||||
).size.height
|
).paginationMeasuredHeightPx()
|
||||||
}
|
}
|
||||||
height + centeredTextSafetyPaddingPx(paragraphStyle, density)
|
height + centeredTextSafetyPaddingPx(paragraphStyle, density)
|
||||||
}
|
}
|
||||||
|
|
@ -718,7 +826,7 @@ private suspend fun measureBlockHeight(
|
||||||
text = block.content,
|
text = block.content,
|
||||||
style = style,
|
style = style,
|
||||||
constraints = adjustedConstraints
|
constraints = adjustedConstraints
|
||||||
).size.height
|
).paginationMeasuredHeightPx()
|
||||||
}
|
}
|
||||||
height + centeredTextSafetyPaddingPx(style, density)
|
height + centeredTextSafetyPaddingPx(style, density)
|
||||||
}
|
}
|
||||||
|
|
@ -731,7 +839,9 @@ private suspend fun measureBlockHeight(
|
||||||
) ?: with(density) { 250.dp.toPx() }
|
) ?: with(density) { 250.dp.toPx() }
|
||||||
|
|
||||||
val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt()
|
val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt()
|
||||||
Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})")
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
|
Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})")
|
||||||
|
}
|
||||||
finalHeight
|
finalHeight
|
||||||
}
|
}
|
||||||
is SpacerBlock -> {
|
is SpacerBlock -> {
|
||||||
|
|
@ -745,7 +855,7 @@ private suspend fun measureBlockHeight(
|
||||||
text = block.content,
|
text = block.content,
|
||||||
style = quoteStyle,
|
style = quoteStyle,
|
||||||
constraints = adjustedConstraints
|
constraints = adjustedConstraints
|
||||||
).size.height
|
).paginationMeasuredHeightPx()
|
||||||
}
|
}
|
||||||
height + centeredTextSafetyPaddingPx(quoteStyle, density)
|
height + centeredTextSafetyPaddingPx(quoteStyle, density)
|
||||||
}
|
}
|
||||||
|
|
@ -759,7 +869,7 @@ private suspend fun measureBlockHeight(
|
||||||
text = block.content,
|
text = block.content,
|
||||||
style = defaultStyle,
|
style = defaultStyle,
|
||||||
constraints = textConstraints
|
constraints = textConstraints
|
||||||
).size.height
|
).paginationMeasuredHeightPx()
|
||||||
}
|
}
|
||||||
val markerImageHeight = if (block.itemMarkerImage != null) {
|
val markerImageHeight = if (block.itemMarkerImage != null) {
|
||||||
with(density) { (defaultStyle.fontSize.value * 0.8f).sp.toPx().roundToInt() }
|
with(density) { (defaultStyle.fontSize.value * 0.8f).sp.toPx().roundToInt() }
|
||||||
|
|
@ -771,11 +881,13 @@ private suspend fun measureBlockHeight(
|
||||||
}
|
}
|
||||||
is TableBlock -> {
|
is TableBlock -> {
|
||||||
var totalHeight = 0
|
var totalHeight = 0
|
||||||
block.rows.forEach { row ->
|
for (row in block.rows) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
var maxRowHeight = 0
|
var maxRowHeight = 0
|
||||||
val totalColspan = row.sumOf { it.colspan }.toFloat().coerceAtLeast(1f)
|
val totalColspan = row.sumOf { it.colspan }.toFloat().coerceAtLeast(1f)
|
||||||
|
|
||||||
row.forEach { cell ->
|
for (cell in row) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val cellBlockStyle = cell.style.blockStyle
|
val cellBlockStyle = cell.style.blockStyle
|
||||||
val cellMaxWidth = when {
|
val cellMaxWidth = when {
|
||||||
cellBlockStyle.width.isSpecified -> with(density) { cellBlockStyle.width.toPx().roundToInt() }
|
cellBlockStyle.width.isSpecified -> with(density) { cellBlockStyle.width.toPx().roundToInt() }
|
||||||
|
|
@ -844,6 +956,7 @@ private suspend fun measureBlockHeight(
|
||||||
|
|
||||||
// Loop until all text is measured.
|
// Loop until all text is measured.
|
||||||
while (textOffset < fullText.length) {
|
while (textOffset < fullText.length) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val isBesideImage = currentY < imageHeightPx
|
val isBesideImage = currentY < imageHeightPx
|
||||||
val currentMaxWidth = if (isBesideImage) {
|
val currentMaxWidth = if (isBesideImage) {
|
||||||
wrappingContentWidth
|
wrappingContentWidth
|
||||||
|
|
@ -936,11 +1049,19 @@ private suspend fun measureBlockHeight(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val specifiedHeightDp = block.style.height
|
val specifiedHeightDp = block.style.height
|
||||||
val finalHeight = if (block.style.boxSizing == "border-box" && specifiedHeightDp != Dp.Unspecified) {
|
var finalHeight = if (block.style.boxSizing == "border-box" && specifiedHeightDp != Dp.Unspecified) {
|
||||||
with(density) { specifiedHeightDp.toPx().roundToInt() }
|
with(density) { specifiedHeightDp.toPx().roundToInt() }
|
||||||
} else {
|
} else {
|
||||||
(contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt()
|
(contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt()
|
||||||
}
|
}
|
||||||
|
with(density) {
|
||||||
|
if (block.style.minHeight.isSpecified) {
|
||||||
|
finalHeight = finalHeight.coerceAtLeast(block.style.minHeight.toPx().roundToInt())
|
||||||
|
}
|
||||||
|
if (block.style.maxHeight.isSpecified && block.style.overflow in setOf("hidden", "clip", "scroll", "auto")) {
|
||||||
|
finalHeight = finalHeight.coerceAtMost(block.style.maxHeight.toPx().roundToInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (DEBUG_PAGINATION_LOGS) {
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight")
|
Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight")
|
||||||
|
|
@ -948,6 +1069,380 @@ private suspend fun measureBlockHeight(
|
||||||
return finalHeight
|
return finalHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun logJustifiedSplitGapIfSuspicious(
|
||||||
|
block: ParagraphBlock,
|
||||||
|
text: AnnotatedString,
|
||||||
|
textMeasurer: TextMeasurer,
|
||||||
|
paragraphStyle: TextStyle,
|
||||||
|
paragraphConstraints: Constraints,
|
||||||
|
layoutResult: TextLayoutResult,
|
||||||
|
lastVisibleLine: Int,
|
||||||
|
splitOffset: Int,
|
||||||
|
availableTextHeight: Int
|
||||||
|
) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
val isJustified = block.textAlign == TextAlign.Justify ||
|
||||||
|
paragraphStyle.textAlign == TextAlign.Justify ||
|
||||||
|
text.paragraphStyles.any { it.item.textAlign == TextAlign.Justify }
|
||||||
|
if (!isJustified || lastVisibleLine !in 0 until layoutResult.lineCount) return
|
||||||
|
|
||||||
|
val lineStart = layoutResult.getLineStart(lastVisibleLine)
|
||||||
|
val lineEnd = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true)
|
||||||
|
if (lineStart >= lineEnd || lineEnd > text.length) return
|
||||||
|
|
||||||
|
val visibleRightPx = (lineStart until lineEnd)
|
||||||
|
.asSequence()
|
||||||
|
.filter { !text[it].isWhitespace() }
|
||||||
|
.mapNotNull { index ->
|
||||||
|
runCatching { layoutResult.getBoundingBox(index).right }.getOrNull()
|
||||||
|
}
|
||||||
|
.maxOrNull() ?: return
|
||||||
|
|
||||||
|
val contentWidthPx = paragraphConstraints.maxWidth.takeIf { it > 0 } ?: return
|
||||||
|
val visualGapPx = contentWidthPx - visibleRightPx
|
||||||
|
if (visualGapPx < contentWidthPx * JustifiedSplitGapProbeMinFraction) return
|
||||||
|
|
||||||
|
val nextWord = text.text.subSequence(splitOffset.coerceIn(0, text.length), text.length)
|
||||||
|
.firstWordOrEmpty()
|
||||||
|
.take(48)
|
||||||
|
if (nextWord.isBlank()) return
|
||||||
|
|
||||||
|
val lineText = text.text.substring(lineStart, lineEnd).trimEnd()
|
||||||
|
val candidateLineCount = withContext(Dispatchers.Main) {
|
||||||
|
textMeasurer.measure(
|
||||||
|
text = "$lineText $nextWord",
|
||||||
|
style = paragraphStyle,
|
||||||
|
constraints = paragraphConstraints
|
||||||
|
).lineCount
|
||||||
|
}
|
||||||
|
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
logAndroidEpubCutoff(
|
||||||
|
"cutoff_probe layer=android_justified_split_gap block=${block.blockIndex} " +
|
||||||
|
"line=$lastVisibleLine lineOffsets=$lineStart..$lineEnd splitOffset=$splitOffset " +
|
||||||
|
"sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " +
|
||||||
|
"contentWidthPx=$contentWidthPx visibleRightPx=${visibleRightPx.roundToInt()} " +
|
||||||
|
"visualGapPx=${visualGapPx.roundToInt()} availableTextHeightPx=$availableTextHeight " +
|
||||||
|
"nextWordChars=${nextWord.length} candidateLineCount=$candidateLineCount " +
|
||||||
|
"note=justify_expands_spaces_so_visual_gap_may_not_be_fit_capacity"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun logRenderedJustifiedSplitGapIfSuspicious(
|
||||||
|
block: ParagraphBlock,
|
||||||
|
part1Text: AnnotatedString,
|
||||||
|
part2Text: AnnotatedString,
|
||||||
|
textMeasurer: TextMeasurer,
|
||||||
|
paragraphStyle: TextStyle,
|
||||||
|
paragraphConstraints: Constraints,
|
||||||
|
originalLayoutResult: TextLayoutResult,
|
||||||
|
originalLastVisibleLine: Int,
|
||||||
|
splitOffset: Int,
|
||||||
|
availableTextHeight: Int
|
||||||
|
) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
val isJustified = block.textAlign == TextAlign.Justify ||
|
||||||
|
paragraphStyle.textAlign == TextAlign.Justify ||
|
||||||
|
part1Text.paragraphStyles.any { it.item.textAlign == TextAlign.Justify }
|
||||||
|
if (!isJustified || part1Text.isEmpty()) return
|
||||||
|
|
||||||
|
val renderedPart1Layout = withContext(Dispatchers.Main) {
|
||||||
|
textMeasurer.measure(
|
||||||
|
text = part1Text,
|
||||||
|
style = paragraphStyle,
|
||||||
|
constraints = paragraphConstraints
|
||||||
|
)
|
||||||
|
}
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
|
||||||
|
val renderedLastLine = renderedPart1Layout.lineCount - 1
|
||||||
|
if (renderedLastLine < 0) return
|
||||||
|
|
||||||
|
val renderedLineStart = renderedPart1Layout.getLineStart(renderedLastLine)
|
||||||
|
val renderedLineEnd = renderedPart1Layout.getLineEnd(renderedLastLine, visibleEnd = true)
|
||||||
|
if (renderedLineStart >= renderedLineEnd || renderedLineEnd > part1Text.length) return
|
||||||
|
|
||||||
|
val visibleRightPx = (renderedLineStart until renderedLineEnd)
|
||||||
|
.asSequence()
|
||||||
|
.filter { !part1Text[it].isWhitespace() }
|
||||||
|
.mapNotNull { index ->
|
||||||
|
runCatching { renderedPart1Layout.getBoundingBox(index).right }.getOrNull()
|
||||||
|
}
|
||||||
|
.maxOrNull() ?: return
|
||||||
|
|
||||||
|
val contentWidthPx = paragraphConstraints.maxWidth.takeIf { it > 0 } ?: return
|
||||||
|
val visualGapPx = contentWidthPx - visibleRightPx
|
||||||
|
val renderedLineText = part1Text.text.substring(renderedLineStart, renderedLineEnd).trim()
|
||||||
|
val renderedLineWordCount = renderedLineText.split(Regex("\\s+")).count { it.isNotBlank() }
|
||||||
|
val sparseByGap = visualGapPx >= contentWidthPx * 0.10f
|
||||||
|
val sparseByWords = renderedLineWordCount <= 4 && visualGapPx >= contentWidthPx * 0.06f
|
||||||
|
if (!sparseByGap && !sparseByWords) return
|
||||||
|
|
||||||
|
val nextWord = part2Text.text.firstWordOrEmpty().take(48)
|
||||||
|
if (nextWord.isBlank()) return
|
||||||
|
|
||||||
|
val visualCandidateLineCount = withContext(Dispatchers.Main) {
|
||||||
|
textMeasurer.measure(
|
||||||
|
text = "$renderedLineText $nextWord",
|
||||||
|
style = paragraphStyle,
|
||||||
|
constraints = paragraphConstraints
|
||||||
|
).lineCount
|
||||||
|
}
|
||||||
|
|
||||||
|
val part2LineCount = withContext(Dispatchers.Main) {
|
||||||
|
textMeasurer.measure(
|
||||||
|
text = part2Text,
|
||||||
|
style = paragraphStyle,
|
||||||
|
constraints = paragraphConstraints
|
||||||
|
).lineCount
|
||||||
|
}
|
||||||
|
val nextWordEnd = part2Text.text.nextWordEndAfter(0)
|
||||||
|
val remainingAfterNextWordStart = part2Text.text.skipWhitespaceFrom(nextWordEnd)
|
||||||
|
val remainingAfterNextWordLineCount = if (remainingAfterNextWordStart < part2Text.length) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
textMeasurer.measure(
|
||||||
|
text = part2Text.subSequence(remainingAfterNextWordStart, part2Text.length),
|
||||||
|
style = paragraphStyle,
|
||||||
|
constraints = paragraphConstraints
|
||||||
|
).lineCount
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
val originalLineStart = if (originalLastVisibleLine in 0 until originalLayoutResult.lineCount) {
|
||||||
|
originalLayoutResult.getLineStart(originalLastVisibleLine)
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
val originalLineEnd = if (originalLastVisibleLine in 0 until originalLayoutResult.lineCount) {
|
||||||
|
originalLayoutResult.getLineEnd(originalLastVisibleLine, visibleEnd = true)
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
logAndroidEpubCutoff(
|
||||||
|
"cutoff_probe layer=android_justified_split_gap block=${block.blockIndex} " +
|
||||||
|
"sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " +
|
||||||
|
"splitOffset=$splitOffset availableTextHeightPx=$availableTextHeight " +
|
||||||
|
"renderedLines=${renderedPart1Layout.lineCount} renderedLastLine=$renderedLastLine " +
|
||||||
|
"renderedLineOffsets=$renderedLineStart..$renderedLineEnd " +
|
||||||
|
"renderedLineChars=${renderedLineText.length} renderedLineWords=$renderedLineWordCount " +
|
||||||
|
"contentWidthPx=$contentWidthPx renderedVisibleRightPx=${visibleRightPx.roundToInt()} " +
|
||||||
|
"renderedVisualGapPx=${visualGapPx.roundToInt()} nextWordChars=${nextWord.length} " +
|
||||||
|
"visualCandidateLineCount=$visualCandidateLineCount part2Lines=$part2LineCount " +
|
||||||
|
"remainingAfterNextWordLines=$remainingAfterNextWordLineCount " +
|
||||||
|
"originalLastLine=$originalLastVisibleLine originalLineOffsets=$originalLineStart..$originalLineEnd " +
|
||||||
|
"note=rendered_split_final_line_is_unjustified_so_gap_can_appear_after_pagination"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RenderedSplitCandidate(
|
||||||
|
val splitOffset: Int,
|
||||||
|
val prefixHeightPx: Int,
|
||||||
|
val prefixLineCount: Int,
|
||||||
|
val remainingLineCount: Int,
|
||||||
|
val lastLineChars: Int,
|
||||||
|
val lastLineWords: Int,
|
||||||
|
val lastLineVisualGapPx: Int,
|
||||||
|
val contentWidthPx: Int
|
||||||
|
) {
|
||||||
|
val sparseLastLine: Boolean
|
||||||
|
get() = lastLineVisualGapPx >= contentWidthPx * 0.20f ||
|
||||||
|
(lastLineWords <= 4 && lastLineVisualGapPx >= contentWidthPx * 0.08f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBetterRenderedJustifySplitCandidate(
|
||||||
|
candidate: RenderedSplitCandidate,
|
||||||
|
current: RenderedSplitCandidate
|
||||||
|
): Boolean {
|
||||||
|
if (candidate.sparseLastLine != current.sparseLastLine) {
|
||||||
|
return !candidate.sparseLastLine
|
||||||
|
}
|
||||||
|
if (candidate.sparseLastLine) {
|
||||||
|
if (candidate.lastLineVisualGapPx != current.lastLineVisualGapPx) {
|
||||||
|
return candidate.lastLineVisualGapPx < current.lastLineVisualGapPx
|
||||||
|
}
|
||||||
|
return candidate.splitOffset > current.splitOffset
|
||||||
|
}
|
||||||
|
if (candidate.prefixLineCount != current.prefixLineCount) {
|
||||||
|
return candidate.prefixLineCount > current.prefixLineCount
|
||||||
|
}
|
||||||
|
if (candidate.lastLineVisualGapPx != current.lastLineVisualGapPx) {
|
||||||
|
return candidate.lastLineVisualGapPx < current.lastLineVisualGapPx
|
||||||
|
}
|
||||||
|
return candidate.splitOffset > current.splitOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun measureRenderedSplitCandidate(
|
||||||
|
text: AnnotatedString,
|
||||||
|
textMeasurer: TextMeasurer,
|
||||||
|
paragraphStyle: TextStyle,
|
||||||
|
paragraphConstraints: Constraints,
|
||||||
|
splitOffset: Int
|
||||||
|
): RenderedSplitCandidate? {
|
||||||
|
val prefixEnd = text.text.trimTrailingWhitespaceBefore(splitOffset)
|
||||||
|
if (prefixEnd <= 0 || prefixEnd >= text.length) return null
|
||||||
|
|
||||||
|
val remainingStart = text.text.skipWhitespaceFrom(prefixEnd)
|
||||||
|
if (remainingStart >= text.length) return null
|
||||||
|
|
||||||
|
val prefixLayout = withContext(Dispatchers.Main) {
|
||||||
|
textMeasurer.measure(
|
||||||
|
text = text.subSequence(0, prefixEnd),
|
||||||
|
style = paragraphStyle,
|
||||||
|
constraints = paragraphConstraints
|
||||||
|
)
|
||||||
|
}
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
|
||||||
|
val remainingLayout = withContext(Dispatchers.Main) {
|
||||||
|
textMeasurer.measure(
|
||||||
|
text = text.subSequence(remainingStart, text.length),
|
||||||
|
style = paragraphStyle,
|
||||||
|
constraints = paragraphConstraints
|
||||||
|
)
|
||||||
|
}
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
|
||||||
|
val lastLine = prefixLayout.lineCount - 1
|
||||||
|
if (lastLine < 0) return null
|
||||||
|
val lineStart = prefixLayout.getLineStart(lastLine)
|
||||||
|
val lineEnd = prefixLayout.getLineEnd(lastLine, visibleEnd = true)
|
||||||
|
if (lineStart >= lineEnd || lineEnd > prefixEnd) return null
|
||||||
|
val prefixText = text.text.substring(0, prefixEnd)
|
||||||
|
val lastLineText = prefixText.substring(lineStart, lineEnd).trim()
|
||||||
|
val lastLineWords = lastLineText.split(Regex("\\s+")).count { it.isNotBlank() }
|
||||||
|
val contentWidthPx = paragraphConstraints.maxWidth.takeIf { it > 0 } ?: return null
|
||||||
|
val visibleRightPx = (lineStart until lineEnd)
|
||||||
|
.asSequence()
|
||||||
|
.filter { !prefixText[it].isWhitespace() }
|
||||||
|
.mapNotNull { index ->
|
||||||
|
runCatching { prefixLayout.getBoundingBox(index).right }.getOrNull()
|
||||||
|
}
|
||||||
|
.maxOrNull() ?: return null
|
||||||
|
val lastLineVisualGapPx = (contentWidthPx - visibleRightPx).roundToInt()
|
||||||
|
|
||||||
|
return RenderedSplitCandidate(
|
||||||
|
splitOffset = prefixEnd,
|
||||||
|
prefixHeightPx = prefixLayout.paginationMeasuredHeightPx(),
|
||||||
|
prefixLineCount = prefixLayout.lineCount,
|
||||||
|
remainingLineCount = remainingLayout.lineCount,
|
||||||
|
lastLineChars = lastLineText.length,
|
||||||
|
lastLineWords = lastLineWords,
|
||||||
|
lastLineVisualGapPx = lastLineVisualGapPx,
|
||||||
|
contentWidthPx = contentWidthPx
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun adjustJustifiedSplitOffsetForRenderedPrefix(
|
||||||
|
block: ParagraphBlock,
|
||||||
|
text: AnnotatedString,
|
||||||
|
textMeasurer: TextMeasurer,
|
||||||
|
paragraphStyle: TextStyle,
|
||||||
|
paragraphConstraints: Constraints,
|
||||||
|
initialSplitOffset: Int,
|
||||||
|
availableTextHeight: Int,
|
||||||
|
orphanLines: Int,
|
||||||
|
widowLines: Int
|
||||||
|
): Int? {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
val isJustified = block.textAlign == TextAlign.Justify ||
|
||||||
|
paragraphStyle.textAlign == TextAlign.Justify ||
|
||||||
|
text.paragraphStyles.any { it.item.textAlign == TextAlign.Justify }
|
||||||
|
if (!isJustified) return initialSplitOffset
|
||||||
|
|
||||||
|
val normalizedInitialOffset = text.text.trimTrailingWhitespaceBefore(initialSplitOffset)
|
||||||
|
var candidateOffset = normalizedInitialOffset
|
||||||
|
var bestCandidate: RenderedSplitCandidate? = null
|
||||||
|
|
||||||
|
while (candidateOffset > 0) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
val candidate = measureRenderedSplitCandidate(
|
||||||
|
text = text,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
paragraphStyle = paragraphStyle,
|
||||||
|
paragraphConstraints = paragraphConstraints,
|
||||||
|
splitOffset = candidateOffset
|
||||||
|
)
|
||||||
|
if (candidate != null &&
|
||||||
|
candidate.prefixHeightPx <= availableTextHeight &&
|
||||||
|
candidate.prefixLineCount >= orphanLines &&
|
||||||
|
candidate.remainingLineCount >= widowLines
|
||||||
|
) {
|
||||||
|
bestCandidate = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
candidateOffset = text.text.previousWordEndBefore(candidateOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestCandidate == null) {
|
||||||
|
logAndroidEpubCutoff(
|
||||||
|
"cutoff_probe layer=android_justified_split_adjust block=${block.blockIndex} " +
|
||||||
|
"sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " +
|
||||||
|
"initialSplitOffset=$initialSplitOffset adjustedSplitOffset=null " +
|
||||||
|
"availableTextHeightPx=$availableTextHeight reason=no_rendered_prefix_fit"
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
var acceptedCandidate: RenderedSplitCandidate = bestCandidate ?: return null
|
||||||
|
var furthestFittingCandidate: RenderedSplitCandidate = acceptedCandidate
|
||||||
|
while (true) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
val nextOffset = text.text.nextWordEndAfter(furthestFittingCandidate.splitOffset)
|
||||||
|
if (nextOffset <= furthestFittingCandidate.splitOffset || nextOffset >= text.length) break
|
||||||
|
|
||||||
|
val nextCandidate = measureRenderedSplitCandidate(
|
||||||
|
text = text,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
paragraphStyle = paragraphStyle,
|
||||||
|
paragraphConstraints = paragraphConstraints,
|
||||||
|
splitOffset = nextOffset
|
||||||
|
) ?: break
|
||||||
|
|
||||||
|
if (nextCandidate.prefixHeightPx > availableTextHeight ||
|
||||||
|
nextCandidate.prefixLineCount < orphanLines ||
|
||||||
|
nextCandidate.remainingLineCount < widowLines
|
||||||
|
) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
furthestFittingCandidate = nextCandidate
|
||||||
|
if (isBetterRenderedJustifySplitCandidate(nextCandidate, acceptedCandidate)) {
|
||||||
|
acceptedCandidate = nextCandidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (acceptedCandidate.splitOffset != normalizedInitialOffset ||
|
||||||
|
acceptedCandidate.splitOffset != furthestFittingCandidate.splitOffset
|
||||||
|
) {
|
||||||
|
val reason = if (acceptedCandidate.splitOffset != furthestFittingCandidate.splitOffset) {
|
||||||
|
"best_rendered_last_line"
|
||||||
|
} else {
|
||||||
|
"rendered_prefix_fit"
|
||||||
|
}
|
||||||
|
logAndroidEpubCutoff(
|
||||||
|
"cutoff_probe layer=android_justified_split_adjust block=${block.blockIndex} " +
|
||||||
|
"sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " +
|
||||||
|
"initialSplitOffset=$initialSplitOffset normalizedInitialOffset=$normalizedInitialOffset " +
|
||||||
|
"adjustedSplitOffset=${acceptedCandidate.splitOffset} availableTextHeightPx=$availableTextHeight " +
|
||||||
|
"adjustedPrefixHeightPx=${acceptedCandidate.prefixHeightPx} " +
|
||||||
|
"adjustedPrefixLines=${acceptedCandidate.prefixLineCount} " +
|
||||||
|
"adjustedRemainingLines=${acceptedCandidate.remainingLineCount} " +
|
||||||
|
"adjustedLineChars=${acceptedCandidate.lastLineChars} " +
|
||||||
|
"adjustedLineWords=${acceptedCandidate.lastLineWords} " +
|
||||||
|
"adjustedLineGapPx=${acceptedCandidate.lastLineVisualGapPx} " +
|
||||||
|
"furthestFitSplitOffset=${furthestFittingCandidate.splitOffset} " +
|
||||||
|
"furthestFitLineWords=${furthestFittingCandidate.lastLineWords} " +
|
||||||
|
"furthestFitLineGapPx=${furthestFittingCandidate.lastLineVisualGapPx} " +
|
||||||
|
"reason=$reason"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return acceptedCandidate.splitOffset
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun splitParagraphBlock(
|
private suspend fun splitParagraphBlock(
|
||||||
block: ParagraphBlock,
|
block: ParagraphBlock,
|
||||||
textMeasurer: TextMeasurer,
|
textMeasurer: TextMeasurer,
|
||||||
|
|
@ -956,6 +1451,7 @@ private suspend fun splitParagraphBlock(
|
||||||
availableHeight: Int,
|
availableHeight: Int,
|
||||||
density: Density
|
density: Density
|
||||||
): Pair<ParagraphBlock, ParagraphBlock>? {
|
): Pair<ParagraphBlock, ParagraphBlock>? {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val text = block.content
|
val text = block.content
|
||||||
if (text.isEmpty()) return null
|
if (text.isEmpty()) return null
|
||||||
val boxMetrics = computeBlockBoxMetrics(block, constraints, density)
|
val boxMetrics = computeBlockBoxMetrics(block, constraints, density)
|
||||||
|
|
@ -992,7 +1488,8 @@ private suspend fun splitParagraphBlock(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layoutResult.size.height <= availableTextHeight) {
|
coroutineContext.ensureActive()
|
||||||
|
if (layoutResult.paginationMeasuredHeightPx() <= availableTextHeight) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1010,9 +1507,12 @@ private suspend fun splitParagraphBlock(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastVisibleLine == 0) {
|
val orphanLines = block.style.orphans.coerceAtLeast(1)
|
||||||
|
val widowLines = block.style.widows.coerceAtLeast(1)
|
||||||
|
val visibleLineCount = lastVisibleLine + 1
|
||||||
|
if (visibleLineCount < orphanLines) {
|
||||||
if (DEBUG_PAGINATION_LOGS) {
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.")
|
Timber.d("Orphan control: Preventing split that would leave $visibleLineCount line(s) at the bottom of the page.")
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -1028,15 +1528,42 @@ private suspend fun splitParagraphBlock(
|
||||||
constraints = paragraphConstraints
|
constraints = paragraphConstraints
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (part2Layout.lineCount == 1) {
|
coroutineContext.ensureActive()
|
||||||
|
if (part2Layout.lineCount < widowLines) {
|
||||||
if (DEBUG_PAGINATION_LOGS) {
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.")
|
Timber.d("Widow control: Adjusting split to keep at least $widowLines line(s) at the top of the next page.")
|
||||||
}
|
}
|
||||||
lastVisibleLine--
|
val linesToMove = widowLines - part2Layout.lineCount
|
||||||
|
lastVisibleLine -= linesToMove.coerceAtLeast(1)
|
||||||
|
if (lastVisibleLine + 1 < orphanLines) return null
|
||||||
splitOffset = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true)
|
splitOffset = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
splitOffset = adjustJustifiedSplitOffsetForRenderedPrefix(
|
||||||
|
block = block,
|
||||||
|
text = text,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
paragraphStyle = paragraphStyle,
|
||||||
|
paragraphConstraints = paragraphConstraints,
|
||||||
|
initialSplitOffset = splitOffset,
|
||||||
|
availableTextHeight = availableTextHeight,
|
||||||
|
orphanLines = orphanLines,
|
||||||
|
widowLines = widowLines
|
||||||
|
) ?: return null
|
||||||
|
|
||||||
|
logJustifiedSplitGapIfSuspicious(
|
||||||
|
block = block,
|
||||||
|
text = text,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
paragraphStyle = paragraphStyle,
|
||||||
|
paragraphConstraints = paragraphConstraints,
|
||||||
|
layoutResult = layoutResult,
|
||||||
|
lastVisibleLine = lastVisibleLine,
|
||||||
|
splitOffset = splitOffset,
|
||||||
|
availableTextHeight = availableTextHeight
|
||||||
|
)
|
||||||
|
|
||||||
if (splitOffset <= 0 || splitOffset >= text.length) {
|
if (splitOffset <= 0 || splitOffset >= text.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -1058,6 +1585,19 @@ private suspend fun splitParagraphBlock(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logRenderedJustifiedSplitGapIfSuspicious(
|
||||||
|
block = block,
|
||||||
|
part1Text = part1Text,
|
||||||
|
part2Text = part2Text,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
paragraphStyle = paragraphStyle,
|
||||||
|
paragraphConstraints = paragraphConstraints,
|
||||||
|
originalLayoutResult = layoutResult,
|
||||||
|
originalLastVisibleLine = lastVisibleLine,
|
||||||
|
splitOffset = splitOffset,
|
||||||
|
availableTextHeight = availableTextHeight
|
||||||
|
)
|
||||||
|
|
||||||
val part2TextWithoutIndent = buildAnnotatedString {
|
val part2TextWithoutIndent = buildAnnotatedString {
|
||||||
append(part2Text)
|
append(part2Text)
|
||||||
part2Text.paragraphStyles.firstOrNull { it.start == 0 && it.item.textIndent != null }?.let { styleRange ->
|
part2Text.paragraphStyles.firstOrNull { it.start == 0 && it.item.textIndent != null }?.let { styleRange ->
|
||||||
|
|
@ -1126,7 +1666,8 @@ private suspend fun calculateContentHeightWithMargins(
|
||||||
imageSizeMultiplier: Float = 1.0f
|
imageSizeMultiplier: Float = 1.0f
|
||||||
): Int {
|
): Int {
|
||||||
var totalHeight = 0
|
var totalHeight = 0
|
||||||
children.forEachIndexed { index, child ->
|
for ((index, child) in children.withIndex()) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val childHeight = measureBlockHeight(child, textMeasurer, constraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
|
val childHeight = measureBlockHeight(child, textMeasurer, constraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
|
||||||
val margin = with(density) {
|
val margin = with(density) {
|
||||||
if (index > 0) {
|
if (index > 0) {
|
||||||
|
|
@ -1174,6 +1715,7 @@ private fun computeBlockBoxMetrics(
|
||||||
val isBorderBox = block.style.boxSizing == "border-box"
|
val isBorderBox = block.style.boxSizing == "border-box"
|
||||||
val specifiedWidthDp = block.style.width
|
val specifiedWidthDp = block.style.width
|
||||||
val specifiedMaxWidthDp = block.style.maxWidth
|
val specifiedMaxWidthDp = block.style.maxWidth
|
||||||
|
val specifiedMinWidthDp = block.style.minWidth
|
||||||
|
|
||||||
val blockOuterWidthPx = with(density) {
|
val blockOuterWidthPx = with(density) {
|
||||||
var effectiveWidthPx = constraints.maxWidth.toFloat()
|
var effectiveWidthPx = constraints.maxWidth.toFloat()
|
||||||
|
|
@ -1186,6 +1728,9 @@ private fun computeBlockBoxMetrics(
|
||||||
effectiveWidthPx = maxWidthPx
|
effectiveWidthPx = maxWidthPx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (specifiedMinWidthDp != Dp.Unspecified) {
|
||||||
|
effectiveWidthPx = effectiveWidthPx.coerceAtLeast(specifiedMinWidthDp.toPx())
|
||||||
|
}
|
||||||
effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat())
|
effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1209,7 +1754,7 @@ private fun centeredTextSafetyPaddingPx(
|
||||||
style: TextStyle,
|
style: TextStyle,
|
||||||
density: Density
|
density: Density
|
||||||
): Int {
|
): Int {
|
||||||
if (style.textAlign != androidx.compose.ui.text.style.TextAlign.Center) return 0
|
if (style.textAlign != TextAlign.Center) return 0
|
||||||
|
|
||||||
val fallbackLineHeight = if (style.fontSize.isSpecified) {
|
val fallbackLineHeight = if (style.fontSize.isSpecified) {
|
||||||
style.fontSize * 1.2f
|
style.fontSize * 1.2f
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,247 @@
|
||||||
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
|
||||||
|
internal const val TAG_PAGINATED_LINK_DIAG = "PaginatedLinkDiag"
|
||||||
|
|
||||||
|
private const val LINK_DIAG_MAX_SAMPLES = 4
|
||||||
|
private val linkDiagWhitespaceRegex = Regex("\\s+")
|
||||||
|
|
||||||
|
internal fun Document.readerHtmlLinkDiagSummary(): String {
|
||||||
|
val linkElements = getElementsByTag("a").mapNotNull { element ->
|
||||||
|
element.readerHrefForDiagnostics()?.let { href -> element to href }
|
||||||
|
}
|
||||||
|
val samples = linkElements.take(LINK_DIAG_MAX_SAMPLES).joinToString(
|
||||||
|
prefix = "[",
|
||||||
|
postfix = "]"
|
||||||
|
) { (element, href) ->
|
||||||
|
"href=${href.readerLinkDiagPreview()} text=\"${element.text().readerLinkDiagPreview()}\""
|
||||||
|
}
|
||||||
|
return "htmlAnchors=${linkElements.size} htmlSamples=$samples"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun List<SemanticBlock>.readerSemanticLinkDiagSummary(): String {
|
||||||
|
val collector = ReaderLinkDiagCollector()
|
||||||
|
forEach { it.collectSemanticLinks(collector) }
|
||||||
|
return collector.semanticSummary()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun List<ContentBlock>.readerContentLinkDiagSummary(): String {
|
||||||
|
val collector = ReaderLinkDiagCollector()
|
||||||
|
forEach { it.collectContentLinks(collector, pageInChapter = null) }
|
||||||
|
return collector.contentSummary()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Page.readerPageLinkDiagSummary(): String {
|
||||||
|
val collector = ReaderLinkDiagCollector()
|
||||||
|
content.forEach { it.collectContentLinks(collector, pageInChapter = null) }
|
||||||
|
return collector.contentSummary()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun List<Page>.readerPagesLinkDiagSummary(): String {
|
||||||
|
val collector = ReaderLinkDiagCollector()
|
||||||
|
forEachIndexed { pageInChapter, page ->
|
||||||
|
page.content.forEach { it.collectContentLinks(collector, pageInChapter) }
|
||||||
|
}
|
||||||
|
return "pages=$size ${collector.contentSummary()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun AnnotatedString.readerAnnotatedLinkDiagSummary(): String {
|
||||||
|
val collector = ReaderLinkDiagCollector()
|
||||||
|
collector.addAnnotatedLinks(
|
||||||
|
blockIndex = null,
|
||||||
|
blockType = "AnnotatedString",
|
||||||
|
cfi = null,
|
||||||
|
text = this,
|
||||||
|
pageInChapter = null
|
||||||
|
)
|
||||||
|
return collector.contentSummary()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerLinkDiagPreview(maxLength: Int = 96): String {
|
||||||
|
val cleaned = replace(linkDiagWhitespaceRegex, " ").trim()
|
||||||
|
return if (cleaned.length <= maxLength) cleaned else cleaned.take(maxLength - 3) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Element.readerHrefForDiagnostics(): String? {
|
||||||
|
return attr("href")
|
||||||
|
.ifBlank { attr("xlink:href") }
|
||||||
|
.ifBlank { attr("l:href") }
|
||||||
|
.ifBlank { attr("epub:href") }
|
||||||
|
.ifBlank { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ReaderLinkDiagCollector {
|
||||||
|
private var semanticTextBlocks = 0
|
||||||
|
private var semanticLinkSpans = 0
|
||||||
|
private val semanticSamples = mutableListOf<String>()
|
||||||
|
|
||||||
|
private var contentTextBlocks = 0
|
||||||
|
private var contentUrlAnnotations = 0
|
||||||
|
private var contentLinksWithColor = 0
|
||||||
|
private var contentLinksWithBackground = 0
|
||||||
|
private var contentLinksWithUnderline = 0
|
||||||
|
private var contentLinksWithCoveringStyle = 0
|
||||||
|
private val contentSamples = mutableListOf<String>()
|
||||||
|
|
||||||
|
fun addSemanticTextBlock(block: SemanticTextBlock) {
|
||||||
|
semanticTextBlocks++
|
||||||
|
block.spans.forEach { span ->
|
||||||
|
val href = span.linkHref?.takeIf { it.isNotBlank() } ?: return@forEach
|
||||||
|
semanticLinkSpans++
|
||||||
|
if (semanticSamples.size < LINK_DIAG_MAX_SAMPLES) {
|
||||||
|
val start = span.start.coerceIn(0, block.text.length)
|
||||||
|
val end = span.end.coerceIn(start, block.text.length)
|
||||||
|
semanticSamples += buildString {
|
||||||
|
append("block=")
|
||||||
|
append(block.blockIndex)
|
||||||
|
append(" type=")
|
||||||
|
append(block::class.simpleName ?: "Text")
|
||||||
|
append(" tag=")
|
||||||
|
append(span.tag)
|
||||||
|
append(" range=")
|
||||||
|
append(start)
|
||||||
|
append("..")
|
||||||
|
append(end)
|
||||||
|
append(" href=")
|
||||||
|
append(href.readerLinkDiagPreview())
|
||||||
|
append(" text=\"")
|
||||||
|
append(block.text.substring(start, end).readerLinkDiagPreview())
|
||||||
|
append("\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addAnnotatedLinks(
|
||||||
|
blockIndex: Int?,
|
||||||
|
blockType: String,
|
||||||
|
cfi: String?,
|
||||||
|
text: AnnotatedString,
|
||||||
|
pageInChapter: Int?
|
||||||
|
) {
|
||||||
|
contentTextBlocks++
|
||||||
|
val annotations = text.getStringAnnotations("URL", 0, text.length)
|
||||||
|
.filter { it.item.isNotBlank() }
|
||||||
|
annotations.forEach { annotation ->
|
||||||
|
contentUrlAnnotations++
|
||||||
|
val coverage = text.readerLinkStyleCoverage(annotation)
|
||||||
|
if (coverage.hasColor) contentLinksWithColor++
|
||||||
|
if (coverage.hasBackground) contentLinksWithBackground++
|
||||||
|
if (coverage.hasUnderline) contentLinksWithUnderline++
|
||||||
|
if (coverage.hasCoveringStyle) contentLinksWithCoveringStyle++
|
||||||
|
if (contentSamples.size < LINK_DIAG_MAX_SAMPLES) {
|
||||||
|
contentSamples += buildString {
|
||||||
|
if (pageInChapter != null) {
|
||||||
|
append("pageInChapter=")
|
||||||
|
append(pageInChapter)
|
||||||
|
append(" ")
|
||||||
|
}
|
||||||
|
append("block=")
|
||||||
|
append(blockIndex ?: -1)
|
||||||
|
append(" type=")
|
||||||
|
append(blockType)
|
||||||
|
if (!cfi.isNullOrBlank()) {
|
||||||
|
append(" cfi=")
|
||||||
|
append(cfi)
|
||||||
|
}
|
||||||
|
append(" range=")
|
||||||
|
append(annotation.start)
|
||||||
|
append("..")
|
||||||
|
append(annotation.end)
|
||||||
|
append(" href=")
|
||||||
|
append(annotation.item.readerLinkDiagPreview())
|
||||||
|
append(" style={color=")
|
||||||
|
append(coverage.hasColor)
|
||||||
|
append(",bg=")
|
||||||
|
append(coverage.hasBackground)
|
||||||
|
append(",underline=")
|
||||||
|
append(coverage.hasUnderline)
|
||||||
|
append(",covering=")
|
||||||
|
append(coverage.hasCoveringStyle)
|
||||||
|
append("} text=\"")
|
||||||
|
append(
|
||||||
|
text.text.substring(
|
||||||
|
annotation.start.coerceIn(0, text.length),
|
||||||
|
annotation.end.coerceIn(annotation.start.coerceIn(0, text.length), text.length)
|
||||||
|
).readerLinkDiagPreview()
|
||||||
|
)
|
||||||
|
append("\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun semanticSummary(): String {
|
||||||
|
return "semanticTextBlocks=$semanticTextBlocks semanticLinkSpans=$semanticLinkSpans semanticSamples=${semanticSamples.joinToString(prefix = "[", postfix = "]")}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun contentSummary(): String {
|
||||||
|
return "contentTextBlocks=$contentTextBlocks urlAnnotations=$contentUrlAnnotations styled={color=$contentLinksWithColor,bg=$contentLinksWithBackground,underline=$contentLinksWithUnderline,covering=$contentLinksWithCoveringStyle} contentSamples=${contentSamples.joinToString(prefix = "[", postfix = "]")}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ReaderLinkStyleCoverage(
|
||||||
|
val hasColor: Boolean,
|
||||||
|
val hasBackground: Boolean,
|
||||||
|
val hasUnderline: Boolean,
|
||||||
|
val hasCoveringStyle: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun AnnotatedString.readerLinkStyleCoverage(
|
||||||
|
link: AnnotatedString.Range<String>
|
||||||
|
): ReaderLinkStyleCoverage {
|
||||||
|
val overlappingStyles = spanStyles.filter { styleRange ->
|
||||||
|
styleRange.start < link.end && styleRange.end > link.start
|
||||||
|
}
|
||||||
|
return ReaderLinkStyleCoverage(
|
||||||
|
hasColor = overlappingStyles.any { it.item.color.isSpecified },
|
||||||
|
hasBackground = overlappingStyles.any { it.item.background.isSpecified },
|
||||||
|
hasUnderline = overlappingStyles.any {
|
||||||
|
it.item.textDecoration?.contains(TextDecoration.Underline) == true
|
||||||
|
},
|
||||||
|
hasCoveringStyle = overlappingStyles.any {
|
||||||
|
it.start <= link.start && it.end >= link.end
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SemanticBlock.collectSemanticLinks(collector: ReaderLinkDiagCollector) {
|
||||||
|
when (this) {
|
||||||
|
is SemanticList -> items.forEach { it.collectSemanticLinks(collector) }
|
||||||
|
is SemanticTable -> rows.flatten().forEach { cell ->
|
||||||
|
cell.content.forEach { it.collectSemanticLinks(collector) }
|
||||||
|
}
|
||||||
|
is SemanticFlexContainer -> children.forEach { it.collectSemanticLinks(collector) }
|
||||||
|
is SemanticWrappingBlock -> paragraphsToWrap.forEach { it.collectSemanticLinks(collector) }
|
||||||
|
is SemanticTextBlock -> collector.addSemanticTextBlock(this)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ContentBlock.collectContentLinks(
|
||||||
|
collector: ReaderLinkDiagCollector,
|
||||||
|
pageInChapter: Int?
|
||||||
|
) {
|
||||||
|
when (this) {
|
||||||
|
is WrappingContentBlock -> paragraphsToWrap.forEach {
|
||||||
|
it.collectContentLinks(collector, pageInChapter)
|
||||||
|
}
|
||||||
|
is TableBlock -> rows.flatten().forEach { cell ->
|
||||||
|
cell.content.forEach { it.collectContentLinks(collector, pageInChapter) }
|
||||||
|
}
|
||||||
|
is FlexContainerBlock -> children.forEach { it.collectContentLinks(collector, pageInChapter) }
|
||||||
|
is TextContentBlock -> collector.addAnnotatedLinks(
|
||||||
|
blockIndex = blockIndex,
|
||||||
|
blockType = this::class.simpleName ?: "Text",
|
||||||
|
cfi = cfi,
|
||||||
|
text = content,
|
||||||
|
pageInChapter = pageInChapter
|
||||||
|
)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
import com.aryan.reader.SearchResult
|
||||||
|
|
||||||
|
internal fun flattenTextContentBlocksForNavigation(blocks: List<ContentBlock>): List<TextContentBlock> {
|
||||||
|
return blocks.flatMap { block ->
|
||||||
|
when (block) {
|
||||||
|
is WrappingContentBlock -> flattenTextContentBlocksForNavigation(
|
||||||
|
listOf<ContentBlock>(block.floatedImage) + block.paragraphsToWrap
|
||||||
|
)
|
||||||
|
is FlexContainerBlock -> flattenTextContentBlocksForNavigation(block.children)
|
||||||
|
is TableBlock -> block.rows.flatten().flatMap { flattenTextContentBlocksForNavigation(it.content) }
|
||||||
|
is TextContentBlock -> listOf(block)
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun findLocatorForSearchResultInBlocks(
|
||||||
|
result: SearchResult,
|
||||||
|
blocks: List<ContentBlock>
|
||||||
|
): Locator? {
|
||||||
|
val query = result.query.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
var occurrenceCount = 0
|
||||||
|
|
||||||
|
flattenTextContentBlocksForNavigation(blocks).forEach { block ->
|
||||||
|
val text = block.content.text
|
||||||
|
var lastIndex = -1
|
||||||
|
while (true) {
|
||||||
|
lastIndex = text.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true)
|
||||||
|
if (lastIndex == -1) break
|
||||||
|
|
||||||
|
val isWordStart = lastIndex == 0 || !text[lastIndex - 1].isLetterOrDigit()
|
||||||
|
if (isWordStart) {
|
||||||
|
if (occurrenceCount == result.occurrenceIndexInLocation) {
|
||||||
|
return Locator(
|
||||||
|
chapterIndex = result.locationInSource,
|
||||||
|
blockIndex = block.blockIndex,
|
||||||
|
charOffset = block.startCharOffsetInSource + lastIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
occurrenceCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun findLocatorForAnchorInBlocks(
|
||||||
|
chapterIndex: Int,
|
||||||
|
anchor: String?,
|
||||||
|
blocks: List<ContentBlock>
|
||||||
|
): Locator? {
|
||||||
|
if (anchor.isNullOrBlank()) return Locator(chapterIndex, 0, 0)
|
||||||
|
return blocks.asSequence()
|
||||||
|
.mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) }
|
||||||
|
.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findLocatorForAnchorInBlock(
|
||||||
|
chapterIndex: Int,
|
||||||
|
anchor: String,
|
||||||
|
block: ContentBlock
|
||||||
|
): Locator? {
|
||||||
|
if (block.elementId == anchor) return locatorForBlockStart(chapterIndex, block)
|
||||||
|
|
||||||
|
if (block is TextContentBlock) {
|
||||||
|
block.content.getStringAnnotations("ID", 0, block.content.length)
|
||||||
|
.firstOrNull { it.item == anchor }
|
||||||
|
?.let { annotation ->
|
||||||
|
return Locator(
|
||||||
|
chapterIndex = chapterIndex,
|
||||||
|
blockIndex = block.blockIndex,
|
||||||
|
charOffset = block.startCharOffsetInSource + annotation.start
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (block) {
|
||||||
|
is FlexContainerBlock -> block.children.asSequence()
|
||||||
|
.mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) }
|
||||||
|
.firstOrNull()
|
||||||
|
is TableBlock -> block.rows.asSequence()
|
||||||
|
.flatMap { row -> row.asSequence() }
|
||||||
|
.flatMap { cell -> cell.content.asSequence() }
|
||||||
|
.mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) }
|
||||||
|
.firstOrNull()
|
||||||
|
is WrappingContentBlock -> sequenceOf<ContentBlock>(block.floatedImage)
|
||||||
|
.plus(block.paragraphsToWrap.asSequence().map { it as ContentBlock })
|
||||||
|
.mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) }
|
||||||
|
.firstOrNull()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun locatorForBlockStart(chapterIndex: Int, block: ContentBlock): Locator {
|
||||||
|
val firstText = flattenTextContentBlocksForNavigation(listOf(block)).firstOrNull()
|
||||||
|
return if (firstText != null) {
|
||||||
|
Locator(
|
||||||
|
chapterIndex = chapterIndex,
|
||||||
|
blockIndex = firstText.blockIndex,
|
||||||
|
charOffset = firstText.startCharOffsetInSource
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Locator(
|
||||||
|
chapterIndex = chapterIndex,
|
||||||
|
blockIndex = block.blockIndex,
|
||||||
|
charOffset = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,11 +50,14 @@ abstract class BookCacheDao {
|
||||||
|
|
||||||
// --- Chapter Operations (Internal Raw Access) ---
|
// --- Chapter Operations (Internal Raw Access) ---
|
||||||
|
|
||||||
@Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex")
|
@Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex AND style_config_hash = :styleConfigHash")
|
||||||
protected abstract suspend fun getChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata?
|
protected abstract suspend fun getChapterMetadata(bookId: String, chapterIndex: Int, styleConfigHash: Int): ProcessedChapterMetadata?
|
||||||
|
|
||||||
@Query("SELECT chunk_data FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex ORDER BY chunk_index ASC")
|
@Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex ORDER BY rowid DESC LIMIT 1")
|
||||||
protected abstract suspend fun getChapterChunks(bookId: String, chapterIndex: Int): List<ByteArray>
|
protected abstract suspend fun getAnyChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata?
|
||||||
|
|
||||||
|
@Query("SELECT chunk_data FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex AND style_config_hash = :styleConfigHash ORDER BY chunk_index ASC")
|
||||||
|
protected abstract suspend fun getChapterChunks(bookId: String, chapterIndex: Int, styleConfigHash: Int): List<ByteArray>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
protected abstract suspend fun insertChapterMetadata(metadata: ProcessedChapterMetadata)
|
protected abstract suspend fun insertChapterMetadata(metadata: ProcessedChapterMetadata)
|
||||||
|
|
@ -65,6 +68,9 @@ abstract class BookCacheDao {
|
||||||
@Query("DELETE FROM processed_chapter_metadata WHERE book_id = :bookId")
|
@Query("DELETE FROM processed_chapter_metadata WHERE book_id = :bookId")
|
||||||
protected abstract suspend fun deleteChapterMetadataForBook(bookId: String)
|
protected abstract suspend fun deleteChapterMetadataForBook(bookId: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex AND style_config_hash = :styleConfigHash")
|
||||||
|
protected abstract suspend fun deleteChapterChunksForChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
abstract suspend fun insertAnchorIndices(anchors: List<AnchorIndexEntry>)
|
abstract suspend fun insertAnchorIndices(anchors: List<AnchorIndexEntry>)
|
||||||
|
|
||||||
|
|
@ -75,12 +81,16 @@ abstract class BookCacheDao {
|
||||||
abstract suspend fun deleteAnchorsForBook(bookId: String)
|
abstract suspend fun deleteAnchorsForBook(bookId: String)
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
open suspend fun getProcessedChapter(bookId: String, chapterIndex: Int): ProcessedChapter? {
|
open suspend fun getProcessedChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int? = null): ProcessedChapter? {
|
||||||
val metadata = getChapterMetadata(bookId, chapterIndex) ?: return null
|
val metadata = if (styleConfigHash == null) {
|
||||||
val chunks = getChapterChunks(bookId, chapterIndex)
|
getAnyChapterMetadata(bookId, chapterIndex)
|
||||||
|
} else {
|
||||||
|
getChapterMetadata(bookId, chapterIndex, styleConfigHash)
|
||||||
|
} ?: return null
|
||||||
|
val chunks = getChapterChunks(bookId, chapterIndex, metadata.styleConfigHash)
|
||||||
|
|
||||||
if (chunks.isEmpty()) {
|
if (chunks.isEmpty()) {
|
||||||
return ProcessedChapter(bookId, chapterIndex, ByteArray(0), metadata.estimatedPageCount)
|
return ProcessedChapter(bookId, chapterIndex, ByteArray(0), metadata.estimatedPageCount, metadata.styleConfigHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
val totalSize = chunks.sumOf { it.size }
|
val totalSize = chunks.sumOf { it.size }
|
||||||
|
|
@ -95,7 +105,8 @@ abstract class BookCacheDao {
|
||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
chapterIndex = chapterIndex,
|
chapterIndex = chapterIndex,
|
||||||
contentBlocksProto = mergedData,
|
contentBlocksProto = mergedData,
|
||||||
estimatedPageCount = metadata.estimatedPageCount
|
estimatedPageCount = metadata.estimatedPageCount,
|
||||||
|
styleConfigHash = metadata.styleConfigHash
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,8 +118,10 @@ abstract class BookCacheDao {
|
||||||
val metadata = ProcessedChapterMetadata(
|
val metadata = ProcessedChapterMetadata(
|
||||||
bookId = chapter.bookId,
|
bookId = chapter.bookId,
|
||||||
chapterIndex = chapter.chapterIndex,
|
chapterIndex = chapter.chapterIndex,
|
||||||
estimatedPageCount = chapter.estimatedPageCount
|
estimatedPageCount = chapter.estimatedPageCount,
|
||||||
|
styleConfigHash = chapter.styleConfigHash
|
||||||
)
|
)
|
||||||
|
deleteChapterChunksForChapter(chapter.bookId, chapter.chapterIndex, chapter.styleConfigHash)
|
||||||
insertChapterMetadata(metadata)
|
insertChapterMetadata(metadata)
|
||||||
|
|
||||||
val fullData = chapter.contentBlocksProto
|
val fullData = chapter.contentBlocksProto
|
||||||
|
|
@ -126,6 +139,7 @@ abstract class BookCacheDao {
|
||||||
ProcessedChapterChunk(
|
ProcessedChapterChunk(
|
||||||
bookId = chapter.bookId,
|
bookId = chapter.bookId,
|
||||||
chapterIndex = chapter.chapterIndex,
|
chapterIndex = chapter.chapterIndex,
|
||||||
|
styleConfigHash = chapter.styleConfigHash,
|
||||||
chunkIndex = chunkIndex,
|
chunkIndex = chunkIndex,
|
||||||
chunkData = chunkBytes
|
chunkData = chunkBytes
|
||||||
)
|
)
|
||||||
|
|
@ -309,7 +323,7 @@ abstract class BookCacheDao {
|
||||||
PageCacheChunk::class,
|
PageCacheChunk::class,
|
||||||
PageIndexEntry::class
|
PageIndexEntry::class
|
||||||
],
|
],
|
||||||
version = 11,
|
version = 12,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class BookCacheDatabase : RoomDatabase() {
|
abstract class BookCacheDatabase : RoomDatabase() {
|
||||||
|
|
@ -326,7 +340,7 @@ abstract class BookCacheDatabase : RoomDatabase() {
|
||||||
BookCacheDatabase::class.java,
|
BookCacheDatabase::class.java,
|
||||||
"book_cache_database"
|
"book_cache_database"
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_10_11)
|
.addMigrations(MIGRATION_10_11, MIGRATION_11_12)
|
||||||
.fallbackToDestructiveMigration(true)
|
.fallbackToDestructiveMigration(true)
|
||||||
.build()
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
|
|
@ -394,5 +408,65 @@ abstract class BookCacheDatabase : RoomDatabase() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_11_12 = object : Migration(11, 12) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"ALTER TABLE `processed_chapter_chunks` RENAME TO `processed_chapter_chunks_old`"
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"ALTER TABLE `processed_chapter_metadata` RENAME TO `processed_chapter_metadata_old`"
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `processed_chapter_metadata` (
|
||||||
|
`book_id` TEXT NOT NULL,
|
||||||
|
`chapter_index` INTEGER NOT NULL,
|
||||||
|
`estimated_page_count` INTEGER NOT NULL,
|
||||||
|
`style_config_hash` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY(`book_id`, `chapter_index`, `style_config_hash`)
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO `processed_chapter_metadata` (`book_id`, `chapter_index`, `estimated_page_count`, `style_config_hash`)
|
||||||
|
SELECT `book_id`, `chapter_index`, `estimated_page_count`, 0
|
||||||
|
FROM `processed_chapter_metadata_old`
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `processed_chapter_chunks` (
|
||||||
|
`book_id` TEXT NOT NULL,
|
||||||
|
`chapter_index` INTEGER NOT NULL,
|
||||||
|
`style_config_hash` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`chunk_index` INTEGER NOT NULL,
|
||||||
|
`chunk_data` BLOB NOT NULL,
|
||||||
|
PRIMARY KEY(`book_id`, `chapter_index`, `style_config_hash`, `chunk_index`),
|
||||||
|
FOREIGN KEY(`book_id`, `chapter_index`, `style_config_hash`)
|
||||||
|
REFERENCES `processed_chapter_metadata`(`book_id`, `chapter_index`, `style_config_hash`)
|
||||||
|
ON UPDATE NO ACTION ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO `processed_chapter_chunks` (`book_id`, `chapter_index`, `style_config_hash`, `chunk_index`, `chunk_data`)
|
||||||
|
SELECT `book_id`, `chapter_index`, 0, `chunk_index`, `chunk_data`
|
||||||
|
FROM `processed_chapter_chunks_old`
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_processed_chapter_chunks_book_id_chapter_index_style_config_hash` ON `processed_chapter_chunks` (`book_id`, `chapter_index`, `style_config_hash`)"
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"DROP TABLE `processed_chapter_chunks_old`"
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"DROP TABLE `processed_chapter_metadata_old`"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
const val LATEST_PROCESSING_VERSION = 11
|
const val LATEST_PROCESSING_VERSION = 15
|
||||||
const val LATEST_PAGE_CACHE_VERSION = 3
|
const val LATEST_PAGE_CACHE_VERSION = 4
|
||||||
|
|
||||||
@Entity(tableName = "processed_books")
|
@Entity(tableName = "processed_books")
|
||||||
data class ProcessedBook(
|
data class ProcessedBook(
|
||||||
|
|
@ -52,7 +52,8 @@ data class ProcessedChapter(
|
||||||
val bookId: String,
|
val bookId: String,
|
||||||
val chapterIndex: Int,
|
val chapterIndex: Int,
|
||||||
val contentBlocksProto: ByteArray,
|
val contentBlocksProto: ByteArray,
|
||||||
val estimatedPageCount: Int
|
val estimatedPageCount: Int,
|
||||||
|
val styleConfigHash: Int = 0
|
||||||
) {
|
) {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
|
|
@ -62,6 +63,7 @@ data class ProcessedChapter(
|
||||||
if (chapterIndex != other.chapterIndex) return false
|
if (chapterIndex != other.chapterIndex) return false
|
||||||
if (!contentBlocksProto.contentEquals(other.contentBlocksProto)) return false
|
if (!contentBlocksProto.contentEquals(other.contentBlocksProto)) return false
|
||||||
if (estimatedPageCount != other.estimatedPageCount) return false
|
if (estimatedPageCount != other.estimatedPageCount) return false
|
||||||
|
if (styleConfigHash != other.styleConfigHash) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +72,7 @@ data class ProcessedChapter(
|
||||||
result = 31 * result + chapterIndex
|
result = 31 * result + chapterIndex
|
||||||
result = 31 * result + contentBlocksProto.contentHashCode()
|
result = 31 * result + contentBlocksProto.contentHashCode()
|
||||||
result = 31 * result + estimatedPageCount
|
result = 31 * result + estimatedPageCount
|
||||||
|
result = 31 * result + styleConfigHash
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -77,11 +80,12 @@ data class ProcessedChapter(
|
||||||
/**
|
/**
|
||||||
* Database Entity: Stores metadata only (small size).
|
* Database Entity: Stores metadata only (small size).
|
||||||
*/
|
*/
|
||||||
@Entity(tableName = "processed_chapter_metadata", primaryKeys = ["book_id", "chapter_index"])
|
@Entity(tableName = "processed_chapter_metadata", primaryKeys = ["book_id", "chapter_index", "style_config_hash"])
|
||||||
data class ProcessedChapterMetadata(
|
data class ProcessedChapterMetadata(
|
||||||
@ColumnInfo(name = "book_id") val bookId: String,
|
@ColumnInfo(name = "book_id") val bookId: String,
|
||||||
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
|
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
|
||||||
@ColumnInfo(name = "estimated_page_count") val estimatedPageCount: Int
|
@ColumnInfo(name = "estimated_page_count") val estimatedPageCount: Int,
|
||||||
|
@ColumnInfo(name = "style_config_hash") val styleConfigHash: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -89,20 +93,21 @@ data class ProcessedChapterMetadata(
|
||||||
*/
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "processed_chapter_chunks",
|
tableName = "processed_chapter_chunks",
|
||||||
primaryKeys = ["book_id", "chapter_index", "chunk_index"],
|
primaryKeys = ["book_id", "chapter_index", "style_config_hash", "chunk_index"],
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = ProcessedChapterMetadata::class,
|
entity = ProcessedChapterMetadata::class,
|
||||||
parentColumns = ["book_id", "chapter_index"],
|
parentColumns = ["book_id", "chapter_index", "style_config_hash"],
|
||||||
childColumns = ["book_id", "chapter_index"],
|
childColumns = ["book_id", "chapter_index", "style_config_hash"],
|
||||||
onDelete = ForeignKey.CASCADE
|
onDelete = ForeignKey.CASCADE
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
indices = [Index(value = ["book_id", "chapter_index"])]
|
indices = [Index(value = ["book_id", "chapter_index", "style_config_hash"])]
|
||||||
)
|
)
|
||||||
data class ProcessedChapterChunk(
|
data class ProcessedChapterChunk(
|
||||||
@ColumnInfo(name = "book_id") val bookId: String,
|
@ColumnInfo(name = "book_id") val bookId: String,
|
||||||
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
|
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
|
||||||
|
@ColumnInfo(name = "style_config_hash") val styleConfigHash: Int = 0,
|
||||||
@ColumnInfo(name = "chunk_index") val chunkIndex: Int,
|
@ColumnInfo(name = "chunk_index") val chunkIndex: Int,
|
||||||
@ColumnInfo(name = "chunk_data", typeAffinity = ColumnInfo.BLOB) val chunkData: ByteArray
|
@ColumnInfo(name = "chunk_data", typeAffinity = ColumnInfo.BLOB) val chunkData: ByteArray
|
||||||
) {
|
) {
|
||||||
|
|
@ -112,6 +117,7 @@ data class ProcessedChapterChunk(
|
||||||
other as ProcessedChapterChunk
|
other as ProcessedChapterChunk
|
||||||
if (bookId != other.bookId) return false
|
if (bookId != other.bookId) return false
|
||||||
if (chapterIndex != other.chapterIndex) return false
|
if (chapterIndex != other.chapterIndex) return false
|
||||||
|
if (styleConfigHash != other.styleConfigHash) return false
|
||||||
if (chunkIndex != other.chunkIndex) return false
|
if (chunkIndex != other.chunkIndex) return false
|
||||||
if (!chunkData.contentEquals(other.chunkData)) return false
|
if (!chunkData.contentEquals(other.chunkData)) return false
|
||||||
return true
|
return true
|
||||||
|
|
@ -120,6 +126,7 @@ data class ProcessedChapterChunk(
|
||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
||||||
var result = bookId.hashCode()
|
var result = bookId.hashCode()
|
||||||
result = 31 * result + chapterIndex
|
result = 31 * result + chapterIndex
|
||||||
|
result = 31 * result + styleConfigHash
|
||||||
result = 31 * result + chunkIndex
|
result = 31 * result + chunkIndex
|
||||||
result = 31 * result + chunkData.contentHashCode()
|
result = 31 * result + chunkData.contentHashCode()
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,14 @@ import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import com.aryan.reader.applyBookReplacementsToHtmlDocument
|
||||||
import com.aryan.reader.epub.epubContentFilePath
|
import com.aryan.reader.epub.epubContentFilePath
|
||||||
import com.aryan.reader.paginatedreader.CssParser
|
import com.aryan.reader.paginatedreader.CssParser
|
||||||
|
import com.aryan.reader.paginatedreader.AndroidHtmlResourceResolver
|
||||||
import com.aryan.reader.paginatedreader.FontFaceInfo
|
import com.aryan.reader.paginatedreader.FontFaceInfo
|
||||||
import com.aryan.reader.paginatedreader.MathMLRenderer
|
import com.aryan.reader.paginatedreader.MathMLRenderer
|
||||||
import com.aryan.reader.paginatedreader.OptimizedCssRules
|
import com.aryan.reader.paginatedreader.OptimizedCssRules
|
||||||
|
|
@ -43,9 +46,12 @@ import com.aryan.reader.paginatedreader.RenderResult
|
||||||
import com.aryan.reader.paginatedreader.androidHtmlToSemanticBlocks
|
import com.aryan.reader.paginatedreader.androidHtmlToSemanticBlocks
|
||||||
import com.aryan.reader.paginatedreader.loadFontFamilies
|
import com.aryan.reader.paginatedreader.loadFontFamilies
|
||||||
import com.aryan.reader.paginatedreader.semanticBlockModule
|
import com.aryan.reader.paginatedreader.semanticBlockModule
|
||||||
|
import com.aryan.reader.shared.ReaderBookReplacementPreferencesJson
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -56,8 +62,8 @@ import kotlinx.serialization.protobuf.ProtoNumber
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URLDecoder
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -78,7 +84,10 @@ data class BookProcessingInput(
|
||||||
@ProtoNumber(5) val density: Float,
|
@ProtoNumber(5) val density: Float,
|
||||||
@ProtoNumber(6) val constraintsMaxWidth: Int,
|
@ProtoNumber(6) val constraintsMaxWidth: Int,
|
||||||
@ProtoNumber(7) val constraintsMaxHeight: Int,
|
@ProtoNumber(7) val constraintsMaxHeight: Int,
|
||||||
@ProtoNumber(8) val fontFaces: List<FontFaceInfo> = emptyList()
|
@ProtoNumber(8) val fontFaces: List<FontFaceInfo> = emptyList(),
|
||||||
|
@ProtoNumber(9) val styleConfigHash: Int = 0,
|
||||||
|
@ProtoNumber(10) val bookReplacementPreferencesJson: String = "",
|
||||||
|
@ProtoNumber(11) val bookReplacementFileId: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
|
@ -95,6 +104,13 @@ class BookProcessingWorker(
|
||||||
private const val KEY_ESTIMATED_TOTAL_PAGES = "estimatedTotalPages"
|
private const val KEY_ESTIMATED_TOTAL_PAGES = "estimatedTotalPages"
|
||||||
private const val KEY_START_CHAPTER_INDEX = "startChapterIndex"
|
private const val KEY_START_CHAPTER_INDEX = "startChapterIndex"
|
||||||
|
|
||||||
|
private fun uniqueWorkName(bookId: String): String = "process_$bookId"
|
||||||
|
|
||||||
|
fun cancelForBook(context: Context, bookId: String) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkName(bookId))
|
||||||
|
Timber.i("Cancelled stale background processing for book: $bookId")
|
||||||
|
}
|
||||||
|
|
||||||
fun enqueue(
|
fun enqueue(
|
||||||
context: Context,
|
context: Context,
|
||||||
bookId: String,
|
bookId: String,
|
||||||
|
|
@ -121,11 +137,11 @@ class BookProcessingWorker(
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
"process_$bookId",
|
uniqueWorkName(bookId),
|
||||||
androidx.work.ExistingWorkPolicy.KEEP,
|
ExistingWorkPolicy.REPLACE,
|
||||||
workRequest
|
workRequest
|
||||||
)
|
)
|
||||||
Timber.i("Enqueued background processing for book: $bookId")
|
Timber.i("Enqueued latest background processing for book: $bookId config=${processingInput.styleConfigHash}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +153,6 @@ class BookProcessingWorker(
|
||||||
Timber.i("Starting pre-scan to calculate image dimensions...")
|
Timber.i("Starting pre-scan to calculate image dimensions...")
|
||||||
for (chapter in chapters) {
|
for (chapter in chapters) {
|
||||||
val document = Jsoup.parse(chapter.htmlContent)
|
val document = Jsoup.parse(chapter.htmlContent)
|
||||||
val chapterParentPath = File(chapter.absPath).parent ?: ""
|
|
||||||
|
|
||||||
// Find all image tags (both <img> and <svg><image>)
|
// Find all image tags (both <img> and <svg><image>)
|
||||||
document.select("img, image").forEach { element ->
|
document.select("img, image").forEach { element ->
|
||||||
|
|
@ -145,14 +160,10 @@ class BookProcessingWorker(
|
||||||
val src = element.attr(srcAttr).ifBlank { element.attr("xlink:href") }
|
val src = element.attr(srcAttr).ifBlank { element.attr("xlink:href") }
|
||||||
|
|
||||||
if (src.isNotBlank()) {
|
if (src.isNotBlank()) {
|
||||||
val decodedSrc = try {
|
val imagePath = AndroidHtmlResourceResolver
|
||||||
URLDecoder.decode(src, "UTF-8")
|
.resolvePath(chapter.absPath, extractionBasePath, src)
|
||||||
} catch (_: Exception) {
|
?: return@forEach
|
||||||
src
|
val imageFile = File(imagePath)
|
||||||
}
|
|
||||||
|
|
||||||
val imageFile = File(File(extractionBasePath, chapterParentPath), decodedSrc).canonicalFile
|
|
||||||
val imagePath = imageFile.absolutePath
|
|
||||||
|
|
||||||
// If not already cached, read dimensions from disk
|
// If not already cached, read dimensions from disk
|
||||||
if (imageFile.exists() && !dimensionsCache.containsKey(imagePath)) {
|
if (imageFile.exists() && !dimensionsCache.containsKey(imagePath)) {
|
||||||
|
|
@ -196,6 +207,9 @@ class BookProcessingWorker(
|
||||||
return@withContext Result.failure()
|
return@withContext Result.failure()
|
||||||
}
|
}
|
||||||
val input = proto.decodeFromByteArray<BookProcessingInput>(inputFile.readBytes())
|
val input = proto.decodeFromByteArray<BookProcessingInput>(inputFile.readBytes())
|
||||||
|
val bookReplacementPreferences = ReaderBookReplacementPreferencesJson.decodeOrEmpty(
|
||||||
|
input.bookReplacementPreferencesJson,
|
||||||
|
)
|
||||||
Timber.i("Worker decoded input. Number of chapters received: ${input.chapters.size}")
|
Timber.i("Worker decoded input. Number of chapters received: ${input.chapters.size}")
|
||||||
|
|
||||||
// Worker now reconstructs everything it needs for a pure light-theme processing run.
|
// Worker now reconstructs everything it needs for a pure light-theme processing run.
|
||||||
|
|
@ -240,11 +254,13 @@ class BookProcessingWorker(
|
||||||
Timber.i("Worker processing with up to $numCores threads, prioritizing around chapter $startChapterIndex.")
|
Timber.i("Worker processing with up to $numCores threads, prioritizing around chapter $startChapterIndex.")
|
||||||
|
|
||||||
chaptersToProcess.chunked(numCores).forEach { chunk ->
|
chaptersToProcess.chunked(numCores).forEach { chunk ->
|
||||||
|
coroutineContext.ensureActive()
|
||||||
Timber.d("Processing a chunk of ${chunk.size} chapters.")
|
Timber.d("Processing a chunk of ${chunk.size} chapters.")
|
||||||
val deferreds = chunk.map { (index, chapter) ->
|
val deferreds = chunk.map { (index, chapter) ->
|
||||||
async {
|
async {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
Timber.d("Async task started for chapter index $index.")
|
Timber.d("Async task started for chapter index $index.")
|
||||||
if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) {
|
if (db.bookCacheDao().getProcessedChapter(bookId, index, input.styleConfigHash) == null) {
|
||||||
Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}")
|
Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}")
|
||||||
val htmlToParse = chapter.htmlContent.ifBlank {
|
val htmlToParse = chapter.htmlContent.ifBlank {
|
||||||
val backingFile = File(extractionBasePath, epubContentFilePath(chapter.htmlFilePath))
|
val backingFile = File(extractionBasePath, epubContentFilePath(chapter.htmlFilePath))
|
||||||
|
|
@ -290,6 +306,12 @@ class BookProcessingWorker(
|
||||||
}
|
}
|
||||||
Timber.d("Chapter $index (Background Worker): Finished processing MathML. SVG cache has ${svgResults.size} items. Keys: ${svgResults.keys.joinToString()}")
|
Timber.d("Chapter $index (Background Worker): Finished processing MathML. SVG cache has ${svgResults.size} items. Keys: ${svgResults.keys.joinToString()}")
|
||||||
}
|
}
|
||||||
|
applyBookReplacementsToHtmlDocument(
|
||||||
|
document = document,
|
||||||
|
preferences = bookReplacementPreferences,
|
||||||
|
fileId = input.bookReplacementFileId,
|
||||||
|
)
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val processedHtml = document.outerHtml()
|
val processedHtml = document.outerHtml()
|
||||||
Timber.d("Chapter $index (Background Worker): Processed HTML contains <math-placeholder>: ${processedHtml.contains("math-placeholder")}")
|
Timber.d("Chapter $index (Background Worker): Processed HTML contains <math-placeholder>: ${processedHtml.contains("math-placeholder")}")
|
||||||
|
|
||||||
|
|
@ -306,12 +328,14 @@ class BookProcessingWorker(
|
||||||
imageDimensionsCache = imageDimensionsCache,
|
imageDimensionsCache = imageDimensionsCache,
|
||||||
mathSvgCache = svgResults
|
mathSvgCache = svgResults
|
||||||
)
|
)
|
||||||
|
coroutineContext.ensureActive()
|
||||||
val protoBytes = proto.encodeToByteArray(semanticBlocks)
|
val protoBytes = proto.encodeToByteArray(semanticBlocks)
|
||||||
ProcessedChapter(
|
ProcessedChapter(
|
||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
chapterIndex = index,
|
chapterIndex = index,
|
||||||
contentBlocksProto = protoBytes,
|
contentBlocksProto = protoBytes,
|
||||||
estimatedPageCount = estimateSemanticPageCount(semanticBlocks)
|
estimatedPageCount = estimateSemanticPageCount(semanticBlocks),
|
||||||
|
styleConfigHash = input.styleConfigHash
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Timber.d("Chapter $index was already in the database. Skipping.")
|
Timber.d("Chapter $index was already in the database. Skipping.")
|
||||||
|
|
@ -341,6 +365,9 @@ class BookProcessingWorker(
|
||||||
Timber.i("[BG_PROC] Finished processing all chapters for book $bookId.")
|
Timber.i("[BG_PROC] Finished processing all chapters for book $bookId.")
|
||||||
|
|
||||||
return@withContext Result.success()
|
return@withContext Result.success()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
Timber.i("Background processing cancelled for book $bookId")
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Error in pagination worker for book $bookId")
|
Timber.e(e, "Error in pagination worker for book $bookId")
|
||||||
return@withContext Result.failure()
|
return@withContext Result.failure()
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,11 @@ import com.aryan.reader.pdf.ocr.OcrElement
|
||||||
import com.aryan.reader.pdf.ocr.OcrLine
|
import com.aryan.reader.pdf.ocr.OcrLine
|
||||||
import com.aryan.reader.pdf.ocr.OcrResult
|
import com.aryan.reader.pdf.ocr.OcrResult
|
||||||
import com.aryan.reader.pdf.ocr.OcrSymbol
|
import com.aryan.reader.pdf.ocr.OcrSymbol
|
||||||
|
import com.aryan.reader.shared.pdf.DEFAULT_SHARED_PDF_COMMENT_AUTHOR
|
||||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
|
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
|
||||||
|
import com.aryan.reader.shared.pdf.pdfCommentChildren
|
||||||
|
import com.aryan.reader.shared.pdf.visiblePdfAnnotationComments
|
||||||
|
import com.aryan.reader.shared.pdf.withoutPdfCommentThread
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
@ -708,8 +712,6 @@ private enum class PdfAnnotationSheetSection {
|
||||||
COMMENTS
|
COMMENTS
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader"
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PdfAnnotationBottomSheet(
|
fun PdfAnnotationBottomSheet(
|
||||||
|
|
@ -740,7 +742,7 @@ fun PdfAnnotationBottomSheet(
|
||||||
highlight.comments
|
highlight.comments
|
||||||
.lastOrNull { it.author.isNotBlank() }
|
.lastOrNull { it.author.isNotBlank() }
|
||||||
?.author
|
?.author
|
||||||
?: DEFAULT_PDF_COMMENT_AUTHOR
|
?: DEFAULT_SHARED_PDF_COMMENT_AUTHOR
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -844,14 +846,14 @@ fun PdfAnnotationBottomSheet(
|
||||||
editingCommentId = comment.id
|
editingCommentId = comment.id
|
||||||
replyTargetId = null
|
replyTargetId = null
|
||||||
commentText = comment.contents
|
commentText = comment.contents
|
||||||
commentAuthor = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
|
commentAuthor = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }
|
||||||
},
|
},
|
||||||
onCancelEdit = {
|
onCancelEdit = {
|
||||||
editingCommentId = null
|
editingCommentId = null
|
||||||
commentText = ""
|
commentText = ""
|
||||||
},
|
},
|
||||||
onDelete = { comment ->
|
onDelete = { comment ->
|
||||||
val nextComments = comments.withoutCommentThread(comment.id)
|
val nextComments = comments.withoutPdfCommentThread(comment.id)
|
||||||
persistComments(nextComments)
|
persistComments(nextComments)
|
||||||
if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) {
|
if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) {
|
||||||
replyTargetId = null
|
replyTargetId = null
|
||||||
|
|
@ -865,7 +867,7 @@ fun PdfAnnotationBottomSheet(
|
||||||
val contents = commentText.trim()
|
val contents = commentText.trim()
|
||||||
if (contents.isNotBlank()) {
|
if (contents.isNotBlank()) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val author = commentAuthor.trim().ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
|
val author = commentAuthor.trim().ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }
|
||||||
val nextComments = if (editingCommentId != null) {
|
val nextComments = if (editingCommentId != null) {
|
||||||
comments.map { comment ->
|
comments.map { comment ->
|
||||||
if (comment.id == editingCommentId) {
|
if (comment.id == editingCommentId) {
|
||||||
|
|
@ -1005,16 +1007,7 @@ private fun PdfHighlightCommentsEditor(
|
||||||
onDelete: (SharedPdfAnnotationComment) -> Unit,
|
onDelete: (SharedPdfAnnotationComment) -> Unit,
|
||||||
onAddComment: () -> Unit
|
onAddComment: () -> Unit
|
||||||
) {
|
) {
|
||||||
val commentIds = comments.filter { it.contents.isNotBlank() }.map { it.id }.toSet()
|
val visibleComments = comments.visiblePdfAnnotationComments()
|
||||||
val visibleComments = comments
|
|
||||||
.filter { it.contents.isNotBlank() }
|
|
||||||
.map { comment ->
|
|
||||||
if (comment.parentId != null && comment.parentId !in commentIds) {
|
|
||||||
comment.copy(parentId = null)
|
|
||||||
} else {
|
|
||||||
comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId }
|
val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId }
|
||||||
val editingComment = visibleComments.firstOrNull { it.id == editingCommentId }
|
val editingComment = visibleComments.firstOrNull { it.id == editingCommentId }
|
||||||
|
|
||||||
|
|
@ -1048,7 +1041,7 @@ private fun PdfHighlightCommentsEditor(
|
||||||
} else {
|
} else {
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.label_replying_to,
|
R.string.label_replying_to,
|
||||||
replyTarget?.author?.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }.orEmpty()
|
replyTarget?.author?.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }.orEmpty()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
|
@ -1115,8 +1108,7 @@ private fun PdfHighlightCommentThread(
|
||||||
onDelete: (SharedPdfAnnotationComment) -> Unit
|
onDelete: (SharedPdfAnnotationComment) -> Unit
|
||||||
) {
|
) {
|
||||||
comments
|
comments
|
||||||
.filter { it.parentId == parentId }
|
.pdfCommentChildren(parentId)
|
||||||
.sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id }))
|
|
||||||
.forEach { comment ->
|
.forEach { comment ->
|
||||||
if (comment.id in visitedIds) return@forEach
|
if (comment.id in visitedIds) return@forEach
|
||||||
PdfHighlightCommentItem(
|
PdfHighlightCommentItem(
|
||||||
|
|
@ -1167,7 +1159,7 @@ private fun PdfHighlightCommentItem(
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR },
|
text = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR },
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
|
@ -1216,19 +1208,6 @@ private fun pdfAnnotationTextFieldColors(effectiveText: Color) =
|
||||||
unfocusedTextColor = effectiveText
|
unfocusedTextColor = effectiveText
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun List<SharedPdfAnnotationComment>.withoutCommentThread(commentId: String): List<SharedPdfAnnotationComment> {
|
|
||||||
val childrenByParentId = groupBy { it.parentId }
|
|
||||||
val idsToRemove = mutableSetOf<String>()
|
|
||||||
|
|
||||||
fun collect(id: String) {
|
|
||||||
if (!idsToRemove.add(id)) return
|
|
||||||
childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) }
|
|
||||||
}
|
|
||||||
|
|
||||||
collect(commentId)
|
|
||||||
return filterNot { it.id in idsToRemove }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Long.formatPdfCommentTimestamp(): String {
|
private fun Long.formatPdfCommentTimestamp(): String {
|
||||||
if (this <= 0L) return ""
|
if (this <= 0L) return ""
|
||||||
return runCatching {
|
return runCatching {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
package com.aryan.reader.pdf
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.Orientation
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
|
@ -21,7 +18,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
|
@ -38,10 +34,7 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -195,42 +188,6 @@ internal fun PageScrubbingAnimation(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun ThumbnailWithIndicator(
|
|
||||||
thumbnail: Bitmap,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
borderColor: Color = Color.Unspecified,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
val effectiveBorderColor = if (borderColor == Color.Unspecified) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
borderColor
|
|
||||||
}
|
|
||||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(45.dp)
|
|
||||||
.height(64.dp)
|
|
||||||
.clickable(onClick = onClick),
|
|
||||||
shape = RoundedCornerShape(4.dp),
|
|
||||||
border = BorderStroke(2.dp, effectiveBorderColor)
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
bitmap = thumbnail.asImageBitmap(),
|
|
||||||
contentDescription = stringResource(R.string.content_desc_start_page_thumbnail),
|
|
||||||
contentScale = ContentScale.FillBounds,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(modifier = Modifier
|
|
||||||
.offset(y = (-4).dp)
|
|
||||||
.size(8.dp)
|
|
||||||
.rotate(45f)
|
|
||||||
.background(effectiveBorderColor))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BookmarkButton(
|
internal fun BookmarkButton(
|
||||||
isBookmarked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier
|
isBookmarked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier
|
||||||
|
|
|
||||||
|
|
@ -5713,10 +5713,11 @@ fun PdfRichTextLayer(
|
||||||
val selection = tfv.selection
|
val selection = tfv.selection
|
||||||
|
|
||||||
@Suppress("ControlFlowWithEmptyBody") if (controller.activePageIndex == pageIndex) {
|
@Suppress("ControlFlowWithEmptyBody") if (controller.activePageIndex == pageIndex) {
|
||||||
val localStart = selection.start.coerceIn(0, textToRender.length)
|
androidPdfRichTextSelectionBounds(
|
||||||
val localEnd = selection.end.coerceIn(0, textToRender.length)
|
selectionStart = selection.start,
|
||||||
|
selectionEnd = selection.end,
|
||||||
if (localStart != localEnd) {
|
textLength = textToRender.length
|
||||||
|
)?.let { (localStart, localEnd) ->
|
||||||
val selectionPath = measureResult.getPathForRange(localStart, localEnd)
|
val selectionPath = measureResult.getPathForRange(localStart, localEnd)
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f))
|
drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f))
|
||||||
|
|
@ -5724,6 +5725,7 @@ fun PdfRichTextLayer(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selection.collapsed && controller.isCursorVisible) {
|
if (selection.collapsed && controller.isCursorVisible) {
|
||||||
|
val localStart = selection.start.coerceIn(0, textToRender.length)
|
||||||
val alpha = if (isScrolling) {
|
val alpha = if (isScrolling) {
|
||||||
1f
|
1f
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -115,12 +115,48 @@ private fun sanitizePdfToolNameSet(
|
||||||
}.toSet()
|
}.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun sanitizePdfHiddenToolNames(toolNames: Collection<String>): Set<String> {
|
||||||
|
return sanitizePdfToolNameSet(toolNames.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun sanitizePdfBottomToolNames(toolNames: Collection<String>): Set<String> {
|
||||||
|
return sanitizePdfToolNameSet(
|
||||||
|
toolNames = toolNames.toSet(),
|
||||||
|
includeTool = ::isPdfToolbarPlacementTool
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun restorePdfToolOrderNames(toolNames: Collection<String>): List<PdfReaderTool> {
|
||||||
|
val savedTools = toolNames
|
||||||
|
.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
|
||||||
|
.filter(::isPdfReaderToolAvailable)
|
||||||
|
return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isPdfToolbarPlacementTool(tool: PdfReaderTool): Boolean {
|
||||||
|
return when (tool) {
|
||||||
|
PdfReaderTool.DICTIONARY,
|
||||||
|
PdfReaderTool.THEME,
|
||||||
|
PdfReaderTool.BRIGHTNESS,
|
||||||
|
PdfReaderTool.LOCK_PANNING,
|
||||||
|
PdfReaderTool.SLIDER,
|
||||||
|
PdfReaderTool.TOC,
|
||||||
|
PdfReaderTool.SEARCH,
|
||||||
|
PdfReaderTool.HIGHLIGHT_ALL,
|
||||||
|
PdfReaderTool.AI_FEATURES,
|
||||||
|
PdfReaderTool.EDIT_MODE,
|
||||||
|
PdfReaderTool.TTS_CONTROLS,
|
||||||
|
PdfReaderTool.SCREEN_ORIENTATION -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
val savedHiddenTools = sanitizePdfToolNameSet(prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty())
|
val savedHiddenTools = sanitizePdfHiddenToolNames(prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty())
|
||||||
val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
|
val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
|
||||||
if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) {
|
if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) {
|
||||||
val migratedHiddenTools = sanitizePdfToolNameSet(savedHiddenTools + pdfHiddenToolsIntroducedAfter(defaultsVersion))
|
val migratedHiddenTools = sanitizePdfHiddenToolNames(savedHiddenTools + pdfHiddenToolsIntroducedAfter(defaultsVersion))
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools)
|
putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools)
|
||||||
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||||
|
|
@ -143,28 +179,27 @@ private fun pdfHiddenToolsIntroducedAfter(defaultsVersion: Int): Set<String> {
|
||||||
internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
|
internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
putStringSet(PDF_HIDDEN_TOOLS_KEY, sanitizePdfToolNameSet(hiddenTools))
|
putStringSet(PDF_HIDDEN_TOOLS_KEY, sanitizePdfHiddenToolNames(hiddenTools))
|
||||||
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
|
internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
val savedTools = prefs.getString(PDF_TOOL_ORDER_KEY, null)
|
val savedToolNames = prefs.getString(PDF_TOOL_ORDER_KEY, null)
|
||||||
?.split(',')
|
?.split(',')
|
||||||
?.filter { it.isNotBlank() }
|
?.filter { it.isNotBlank() }
|
||||||
?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
|
|
||||||
?.filter(::isPdfReaderToolAvailable)
|
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct()
|
return restorePdfToolOrderNames(savedToolNames)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>) {
|
internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>) {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val sanitizedOrder = restorePdfToolOrderNames(toolOrder.map { it.name })
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
putString(
|
putString(
|
||||||
PDF_TOOL_ORDER_KEY,
|
PDF_TOOL_ORDER_KEY,
|
||||||
toolOrder.filter(::isPdfReaderToolAvailable).joinToString(",") { it.name }
|
sanitizedOrder.joinToString(",") { it.name }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -172,22 +207,15 @@ internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>)
|
||||||
internal fun loadPdfBottomTools(context: Context): Set<String> {
|
internal fun loadPdfBottomTools(context: Context): Set<String> {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
val defaultBottomTools = defaultPdfBottomTools()
|
val defaultBottomTools = defaultPdfBottomTools()
|
||||||
return sanitizePdfToolNameSet(
|
val savedBottomTools = prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, null) ?: return defaultBottomTools
|
||||||
toolNames = prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools,
|
val sanitizedBottomTools = sanitizePdfBottomToolNames(savedBottomTools)
|
||||||
includeTool = { it.category == "Bottom Bar" }
|
return if (savedBottomTools.isNotEmpty() && sanitizedBottomTools.isEmpty()) defaultBottomTools else sanitizedBottomTools
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun savePdfBottomTools(context: Context, bottomTools: Set<String>) {
|
internal fun savePdfBottomTools(context: Context, bottomTools: Set<String>) {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
putStringSet(
|
putStringSet(PDF_BOTTOM_TOOLS_KEY, sanitizePdfBottomToolNames(bottomTools))
|
||||||
PDF_BOTTOM_TOOLS_KEY,
|
|
||||||
sanitizePdfToolNameSet(
|
|
||||||
toolNames = bottomTools,
|
|
||||||
includeTool = { it.category == "Bottom Bar" }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,25 +116,17 @@ fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem>
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private val pdfReorderableToolbarTools = setOf(
|
|
||||||
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.BRIGHTNESS, PdfReaderTool.LOCK_PANNING,
|
|
||||||
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
|
|
||||||
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
|
|
||||||
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS,
|
|
||||||
PdfReaderTool.SCREEN_ORIENTATION
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun buildPdfToolbarItems(
|
internal fun buildPdfToolbarItems(
|
||||||
hiddenTools: Set<String>,
|
hiddenTools: Set<String>,
|
||||||
toolOrder: List<PdfReaderTool>,
|
toolOrder: List<PdfReaderTool>,
|
||||||
bottomTools: Set<String>
|
bottomTools: Set<String>
|
||||||
): List<PdfFlatToolItem> {
|
): List<PdfFlatToolItem> {
|
||||||
val availableToolOrder = toolOrder.filter(::isPdfReaderToolAvailable)
|
val availableToolOrder = toolOrder.filter(::isPdfReaderToolAvailable)
|
||||||
val toolbarTools = availableToolOrder.filter { it in pdfReorderableToolbarTools }
|
val toolbarTools = availableToolOrder.filter(::isPdfToolbarPlacementTool)
|
||||||
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||||
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||||
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
||||||
val moreTools = availableToolOrder.filter { it !in pdfReorderableToolbarTools }
|
val moreTools = availableToolOrder.filterNot(::isPdfToolbarPlacementTool)
|
||||||
|
|
||||||
val list = mutableListOf<PdfFlatToolItem>()
|
val list = mutableListOf<PdfFlatToolItem>()
|
||||||
|
|
||||||
|
|
@ -209,7 +201,7 @@ fun PdfCustomizeToolsSheet(
|
||||||
|
|
||||||
val commitDragDrop = {
|
val commitDragDrop = {
|
||||||
val newHidden = localHiddenTools.filter { toolName ->
|
val newHidden = localHiddenTools.filter { toolName ->
|
||||||
toolOrder.find { it.name == toolName } !in pdfReorderableToolbarTools
|
toolOrder.find { it.name == toolName }?.let(::isPdfToolbarPlacementTool) != true
|
||||||
}.toMutableSet()
|
}.toMutableSet()
|
||||||
|
|
||||||
val newBottom = mutableSetOf<String>()
|
val newBottom = mutableSetOf<String>()
|
||||||
|
|
|
||||||
|
|
@ -50,21 +50,6 @@ import kotlin.collections.isNotEmpty
|
||||||
|
|
||||||
internal val PdfTabStripHeight = 44.dp
|
internal val PdfTabStripHeight = 44.dp
|
||||||
|
|
||||||
private val pdfToolbarTools = setOf(
|
|
||||||
PdfReaderTool.DICTIONARY,
|
|
||||||
PdfReaderTool.THEME,
|
|
||||||
PdfReaderTool.BRIGHTNESS,
|
|
||||||
PdfReaderTool.LOCK_PANNING,
|
|
||||||
PdfReaderTool.SLIDER,
|
|
||||||
PdfReaderTool.TOC,
|
|
||||||
PdfReaderTool.SEARCH,
|
|
||||||
PdfReaderTool.HIGHLIGHT_ALL,
|
|
||||||
PdfReaderTool.AI_FEATURES,
|
|
||||||
PdfReaderTool.EDIT_MODE,
|
|
||||||
PdfReaderTool.TTS_CONTROLS,
|
|
||||||
PdfReaderTool.SCREEN_ORIENTATION
|
|
||||||
)
|
|
||||||
|
|
||||||
internal enum class PdfOverflowMenuSection {
|
internal enum class PdfOverflowMenuSection {
|
||||||
CUSTOMIZE_TOOLBAR,
|
CUSTOMIZE_TOOLBAR,
|
||||||
HIDDEN_TOOLS,
|
HIDDEN_TOOLS,
|
||||||
|
|
@ -243,6 +228,8 @@ internal fun PdfTopBar(
|
||||||
totalPages > 0 && pagerStatePageCount == 0 -> stringResource(R.string.loading_page)
|
totalPages > 0 && pagerStatePageCount == 0 -> stringResource(R.string.loading_page)
|
||||||
else -> stringResource(R.string.pdf_viewer)
|
else -> stringResource(R.string.pdf_viewer)
|
||||||
}
|
}
|
||||||
|
val topToolbarTools = toolOrder
|
||||||
|
.filter { isPdfToolbarPlacementTool(it) && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||||
Text(
|
Text(
|
||||||
text = titleText,
|
text = titleText,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
|
@ -251,117 +238,126 @@ internal fun PdfTopBar(
|
||||||
modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator")
|
modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator")
|
||||||
)
|
)
|
||||||
|
|
||||||
toolOrder
|
if (topToolbarTools.isNotEmpty() || BuildConfig.DEBUG) {
|
||||||
.filter { it in pdfToolbarTools && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
val topToolbarScrollState = rememberScrollState()
|
||||||
.forEach { tool ->
|
Row(
|
||||||
when (tool) {
|
modifier = Modifier
|
||||||
PdfReaderTool.THEME -> TooltipIconButton(
|
.weight(1f, fill = false)
|
||||||
text = stringResource(R.string.tooltip_theme),
|
.horizontalScroll(topToolbarScrollState),
|
||||||
description = stringResource(R.string.tooltip_theme_desc),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
onClick = onShowThemePanel
|
horizontalArrangement = Arrangement.End
|
||||||
) {
|
) {
|
||||||
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
topToolbarTools.forEach { tool ->
|
||||||
}
|
when (tool) {
|
||||||
PdfReaderTool.BRIGHTNESS -> TooltipIconButton(
|
PdfReaderTool.THEME -> TooltipIconButton(
|
||||||
text = stringResource(R.string.reader_brightness_title),
|
text = stringResource(R.string.tooltip_theme),
|
||||||
description = stringResource(R.string.reader_brightness_system_desc),
|
description = stringResource(R.string.tooltip_theme_desc),
|
||||||
onClick = onShowBrightnessControl
|
onClick = onShowThemePanel
|
||||||
) {
|
|
||||||
Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
|
|
||||||
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan),
|
|
||||||
description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc),
|
|
||||||
onClick = onToggleScrollLock
|
|
||||||
) {
|
|
||||||
Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
PdfReaderTool.DICTIONARY -> TooltipIconButton(
|
|
||||||
text = stringResource(R.string.tooltip_dictionary),
|
|
||||||
description = stringResource(R.string.tooltip_dictionary_desc),
|
|
||||||
onClick = onShowDictionarySettings
|
|
||||||
) {
|
|
||||||
Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
PdfReaderTool.SLIDER -> TooltipIconButton(
|
|
||||||
text = stringResource(R.string.tooltip_slider),
|
|
||||||
description = stringResource(R.string.tooltip_slider_desc),
|
|
||||||
onClick = onShowSlider,
|
|
||||||
enabled = !isTtsPlayingOrLoading
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painterResource(id = R.drawable.slider),
|
|
||||||
contentDescription = stringResource(R.string.content_desc_navigate_slider),
|
|
||||||
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
PdfReaderTool.TOC -> TooltipIconButton(
|
|
||||||
text = stringResource(R.string.tooltip_toc),
|
|
||||||
description = stringResource(R.string.tooltip_toc_desc),
|
|
||||||
onClick = onShowToc,
|
|
||||||
enabled = !isTtsPlayingOrLoading
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
|
|
||||||
}
|
|
||||||
PdfReaderTool.SEARCH -> TooltipIconButton(
|
|
||||||
text = stringResource(R.string.tooltip_search),
|
|
||||||
description = stringResource(R.string.tooltip_search_desc),
|
|
||||||
onClick = onSearchClick,
|
|
||||||
enabled = !isTtsPlayingOrLoading
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
|
|
||||||
}
|
|
||||||
PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton(
|
|
||||||
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
|
|
||||||
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
|
|
||||||
onClick = onToggleHighlights
|
|
||||||
) {
|
|
||||||
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
|
|
||||||
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
|
|
||||||
TooltipIconButton(
|
|
||||||
text = stringResource(R.string.tooltip_ai),
|
|
||||||
description = stringResource(R.string.tooltip_ai_desc),
|
|
||||||
onClick = onShowAiHub
|
|
||||||
) {
|
) {
|
||||||
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
|
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
|
PdfReaderTool.BRIGHTNESS -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.reader_brightness_title),
|
||||||
|
description = stringResource(R.string.reader_brightness_system_desc),
|
||||||
|
onClick = onShowBrightnessControl
|
||||||
|
) {
|
||||||
|
Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
|
||||||
|
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan),
|
||||||
|
description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc),
|
||||||
|
onClick = onToggleScrollLock
|
||||||
|
) {
|
||||||
|
Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
PdfReaderTool.DICTIONARY -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_dictionary),
|
||||||
|
description = stringResource(R.string.tooltip_dictionary_desc),
|
||||||
|
onClick = onShowDictionarySettings
|
||||||
|
) {
|
||||||
|
Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
PdfReaderTool.SLIDER -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_slider),
|
||||||
|
description = stringResource(R.string.tooltip_slider_desc),
|
||||||
|
onClick = onShowSlider,
|
||||||
|
enabled = !isTtsPlayingOrLoading
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painterResource(id = R.drawable.slider),
|
||||||
|
contentDescription = stringResource(R.string.content_desc_navigate_slider),
|
||||||
|
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PdfReaderTool.TOC -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_toc),
|
||||||
|
description = stringResource(R.string.tooltip_toc_desc),
|
||||||
|
onClick = onShowToc,
|
||||||
|
enabled = !isTtsPlayingOrLoading
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
|
||||||
|
}
|
||||||
|
PdfReaderTool.SEARCH -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_search),
|
||||||
|
description = stringResource(R.string.tooltip_search_desc),
|
||||||
|
onClick = onSearchClick,
|
||||||
|
enabled = !isTtsPlayingOrLoading
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
|
||||||
|
}
|
||||||
|
PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton(
|
||||||
|
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
|
||||||
|
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
|
||||||
|
onClick = onToggleHighlights
|
||||||
|
) {
|
||||||
|
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
|
||||||
|
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_ai),
|
||||||
|
description = stringResource(R.string.tooltip_ai_desc),
|
||||||
|
onClick = onShowAiHub
|
||||||
|
) {
|
||||||
|
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PdfReaderTool.EDIT_MODE -> TooltipIconButton(
|
||||||
|
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
|
||||||
|
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
|
||||||
|
onClick = onToggleEditMode
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
PdfReaderTool.TTS_CONTROLS -> TooltipIconButton(
|
||||||
|
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
|
||||||
|
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
|
||||||
|
onClick = onToggleTts
|
||||||
|
) {
|
||||||
|
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.menu_screen_orientation),
|
||||||
|
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
||||||
|
onClick = onShowScreenOrientation
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
PdfReaderTool.EDIT_MODE -> TooltipIconButton(
|
|
||||||
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
|
|
||||||
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
|
|
||||||
onClick = onToggleEditMode
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
PdfReaderTool.TTS_CONTROLS -> TooltipIconButton(
|
|
||||||
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
|
|
||||||
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
|
|
||||||
onClick = onToggleTts
|
|
||||||
) {
|
|
||||||
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
|
||||||
text = stringResource(R.string.menu_screen_orientation),
|
|
||||||
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
|
||||||
onClick = onShowScreenOrientation
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
else -> Unit
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
TooltipIconButton(text = stringResource(R.string.tooltip_demo_annotations), onClick = onGenerateDemoAnnotations) {
|
TooltipIconButton(text = stringResource(R.string.tooltip_demo_annotations), onClick = onGenerateDemoAnnotations) {
|
||||||
Icon(Icons.Default.BugReport, contentDescription = stringResource(R.string.content_desc_generate_demo_annotations), tint = MaterialTheme.colorScheme.secondary)
|
Icon(Icons.Default.BugReport, contentDescription = stringResource(R.string.content_desc_generate_demo_annotations), tint = MaterialTheme.colorScheme.secondary)
|
||||||
}
|
}
|
||||||
TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) {
|
TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) {
|
||||||
Icon(Icons.Default.Star, contentDescription = stringResource(R.string.content_desc_open_pen_playground), tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.Default.Star, contentDescription = stringResource(R.string.content_desc_open_pen_playground), tint = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
TooltipIconButton(text = stringResource(R.string.import_svg), onClick = onImportSvg) {
|
TooltipIconButton(text = stringResource(R.string.import_svg), onClick = onImportSvg) {
|
||||||
Icon(Icons.Default.Brush, contentDescription = stringResource(R.string.import_svg), tint = Color(0xFFE91E63))
|
Icon(Icons.Default.Brush, contentDescription = stringResource(R.string.import_svg), tint = Color(0xFFE91E63))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -394,7 +390,7 @@ internal fun PdfTopBar(
|
||||||
showMoreMenu = false
|
showMoreMenu = false
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
val hiddenToolbarTools = toolOrder.filter { it in pdfToolbarTools && hiddenTools.contains(it.name) }
|
val hiddenToolbarTools = toolOrder.filter { isPdfToolbarPlacementTool(it) && hiddenTools.contains(it.name) }
|
||||||
val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)
|
val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)
|
||||||
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
|
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
|
||||||
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
|
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
|
||||||
|
|
@ -970,7 +966,7 @@ fun PdfBottomBar(
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
toolOrder
|
toolOrder
|
||||||
.filter { it in pdfToolbarTools && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
.filter { isPdfToolbarPlacementTool(it) && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||||
.forEach { tool ->
|
.forEach { tool ->
|
||||||
when (tool) {
|
when (tool) {
|
||||||
PdfReaderTool.THEME -> TooltipIconButton(
|
PdfReaderTool.THEME -> TooltipIconButton(
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,8 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.NavigateBefore
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.NavigateNext
|
||||||
import androidx.compose.material.icons.filled.ArrowDownward
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
|
@ -114,7 +116,6 @@ import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
|
@ -213,6 +214,7 @@ import com.aryan.reader.AiDefinitionResult
|
||||||
import com.aryan.reader.AiFeature
|
import com.aryan.reader.AiFeature
|
||||||
import com.aryan.reader.AiHubBottomSheet
|
import com.aryan.reader.AiHubBottomSheet
|
||||||
import com.aryan.reader.BuildConfig
|
import com.aryan.reader.BuildConfig
|
||||||
|
import com.aryan.reader.COMIC_ARCHIVE_FILE_TYPES
|
||||||
import com.aryan.reader.FileType
|
import com.aryan.reader.FileType
|
||||||
import com.aryan.reader.HighlightColorPickerDialog
|
import com.aryan.reader.HighlightColorPickerDialog
|
||||||
import com.aryan.reader.MainViewModel
|
import com.aryan.reader.MainViewModel
|
||||||
|
|
@ -246,6 +248,7 @@ import com.aryan.reader.loadReaderBrightnessSettings
|
||||||
import com.aryan.reader.loadReaderScreenOrientationMode
|
import com.aryan.reader.loadReaderScreenOrientationMode
|
||||||
import com.aryan.reader.loadReaderSliderToggled
|
import com.aryan.reader.loadReaderSliderToggled
|
||||||
import com.aryan.reader.loadTtsReplacementPreferences
|
import com.aryan.reader.loadTtsReplacementPreferences
|
||||||
|
import com.aryan.reader.logCloudAnnotationSyncTrace
|
||||||
import com.aryan.reader.ml.SpeechBubble
|
import com.aryan.reader.ml.SpeechBubble
|
||||||
import com.aryan.reader.paginatedreader.TtsChunk
|
import com.aryan.reader.paginatedreader.TtsChunk
|
||||||
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
|
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
|
||||||
|
|
@ -260,6 +263,7 @@ import com.aryan.reader.pdf.data.TextStyleConfig
|
||||||
import com.aryan.reader.pdf.data.VirtualPage
|
import com.aryan.reader.pdf.data.VirtualPage
|
||||||
import com.aryan.reader.readerSliderBookmarkPosition
|
import com.aryan.reader.readerSliderBookmarkPosition
|
||||||
import com.aryan.reader.readerSliderChromeColors
|
import com.aryan.reader.readerSliderChromeColors
|
||||||
|
import com.aryan.reader.readerSliderStepPage
|
||||||
import com.aryan.reader.readerSliderToggleState
|
import com.aryan.reader.readerSliderToggleState
|
||||||
import com.aryan.reader.rememberSearchState
|
import com.aryan.reader.rememberSearchState
|
||||||
import com.aryan.reader.saveCustomThemes
|
import com.aryan.reader.saveCustomThemes
|
||||||
|
|
@ -273,11 +277,16 @@ import com.aryan.reader.scaledToCanvasLimit
|
||||||
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||||
import com.aryan.reader.shared.pdf.PdfSpreadLayout
|
import com.aryan.reader.shared.pdf.PdfSpreadLayout
|
||||||
import com.aryan.reader.shared.reader.ReaderSettings
|
import com.aryan.reader.shared.reader.ReaderSettings
|
||||||
|
import com.aryan.reader.shared.ui.ReaderMinimalSlider
|
||||||
import com.aryan.reader.shouldRenderReaderSlider
|
import com.aryan.reader.shouldRenderReaderSlider
|
||||||
import com.aryan.reader.summarizationUrl
|
import com.aryan.reader.summarizationUrl
|
||||||
|
import com.aryan.reader.tts.ReaderTtsOverlaySize
|
||||||
import com.aryan.reader.tts.SpeakerSamplePlayer
|
import com.aryan.reader.tts.SpeakerSamplePlayer
|
||||||
import com.aryan.reader.tts.TtsPlaybackManager
|
import com.aryan.reader.tts.TtsPlaybackManager
|
||||||
|
import com.aryan.reader.tts.loadReaderTtsOverlaySize
|
||||||
|
import com.aryan.reader.tts.readerTtsOverlayAlignmentBias
|
||||||
import com.aryan.reader.tts.rememberTtsController
|
import com.aryan.reader.tts.rememberTtsController
|
||||||
|
import com.aryan.reader.tts.saveReaderTtsOverlaySize
|
||||||
import com.aryan.reader.tts.splitTextIntoChunks
|
import com.aryan.reader.tts.splitTextIntoChunks
|
||||||
import com.aryan.reader.withTtsReplacements
|
import com.aryan.reader.withTtsReplacements
|
||||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||||
|
|
@ -387,23 +396,41 @@ fun PdfViewerScreen(
|
||||||
var pendingActionAfterOcrSelection by remember { mutableStateOf<(() -> Unit)?>(null) }
|
var pendingActionAfterOcrSelection by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
|
||||||
var showCustomizeToolsSheet by remember { mutableStateOf(false) }
|
var showCustomizeToolsSheet by remember { mutableStateOf(false) }
|
||||||
var hiddenTools by remember { mutableStateOf(loadPdfHiddenTools(context)) }
|
var hiddenToolNames by rememberSaveable {
|
||||||
var toolOrder by remember { mutableStateOf(loadPdfToolOrder(context)) }
|
mutableStateOf(loadPdfHiddenTools(context).toList())
|
||||||
var bottomTools by remember { mutableStateOf(loadPdfBottomTools(context)) }
|
}
|
||||||
|
var toolOrderNames by rememberSaveable {
|
||||||
|
mutableStateOf(loadPdfToolOrder(context).map { it.name })
|
||||||
|
}
|
||||||
|
var bottomToolNames by rememberSaveable {
|
||||||
|
mutableStateOf(loadPdfBottomTools(context).toList())
|
||||||
|
}
|
||||||
|
val hiddenTools = remember(hiddenToolNames) {
|
||||||
|
sanitizePdfHiddenToolNames(hiddenToolNames)
|
||||||
|
}
|
||||||
|
val toolOrder = remember(toolOrderNames) {
|
||||||
|
restorePdfToolOrderNames(toolOrderNames)
|
||||||
|
}
|
||||||
|
val bottomTools = remember(bottomToolNames) {
|
||||||
|
sanitizePdfBottomToolNames(bottomToolNames)
|
||||||
|
}
|
||||||
|
|
||||||
val onUpdateHiddenTools = { newSet: Set<String> ->
|
val onUpdateHiddenTools = { newSet: Set<String> ->
|
||||||
hiddenTools = newSet
|
val sanitized = sanitizePdfHiddenToolNames(newSet)
|
||||||
savePdfHiddenTools(context, newSet)
|
hiddenToolNames = sanitized.toList()
|
||||||
|
savePdfHiddenTools(context, sanitized)
|
||||||
}
|
}
|
||||||
|
|
||||||
val onUpdateToolOrder = { newOrder: List<PdfReaderTool> ->
|
val onUpdateToolOrder = { newOrder: List<PdfReaderTool> ->
|
||||||
toolOrder = newOrder
|
val sanitized = restorePdfToolOrderNames(newOrder.map { it.name })
|
||||||
savePdfToolOrder(context, newOrder)
|
toolOrderNames = sanitized.map { it.name }
|
||||||
|
savePdfToolOrder(context, sanitized)
|
||||||
}
|
}
|
||||||
|
|
||||||
val onUpdateBottomTools = { newBottomTools: Set<String> ->
|
val onUpdateBottomTools = { newBottomTools: Set<String> ->
|
||||||
bottomTools = newBottomTools
|
val sanitized = sanitizePdfBottomToolNames(newBottomTools)
|
||||||
savePdfBottomTools(context, newBottomTools)
|
bottomToolNames = sanitized.toList()
|
||||||
|
savePdfBottomTools(context, sanitized)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isOss = BuildConfig.FLAVOR == "oss"
|
val isOss = BuildConfig.FLAVOR == "oss"
|
||||||
|
|
@ -427,7 +454,7 @@ fun PdfViewerScreen(
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri
|
val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri
|
||||||
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
|
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
|
||||||
val isComicFile = effectiveFileType == FileType.CBZ || effectiveFileType == FileType.CBR || effectiveFileType == FileType.CB7
|
val isComicFile = effectiveFileType in COMIC_ARCHIVE_FILE_TYPES
|
||||||
|
|
||||||
var showNewTabSheet by remember { mutableStateOf(false) }
|
var showNewTabSheet by remember { mutableStateOf(false) }
|
||||||
var showFileInfoDialog by remember { mutableStateOf(false) }
|
var showFileInfoDialog by remember { mutableStateOf(false) }
|
||||||
|
|
@ -477,7 +504,7 @@ fun PdfViewerScreen(
|
||||||
var isAutoScrollTempPaused by remember { mutableStateOf(false) }
|
var isAutoScrollTempPaused by remember { mutableStateOf(false) }
|
||||||
val autoScrollResumeJob = remember { mutableStateOf<Job?>(null) }
|
val autoScrollResumeJob = remember { mutableStateOf<Job?>(null) }
|
||||||
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
|
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
|
||||||
var isTtsCollapsed by remember { mutableStateOf(false) }
|
var ttsOverlaySize by remember(context) { mutableStateOf(loadReaderTtsOverlaySize(context)) }
|
||||||
|
|
||||||
var isMusicianMode by remember { mutableStateOf(loadPdfMusicianMode(context)) }
|
var isMusicianMode by remember { mutableStateOf(loadPdfMusicianMode(context)) }
|
||||||
var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(context)) }
|
var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(context)) }
|
||||||
|
|
@ -1339,22 +1366,50 @@ fun PdfViewerScreen(
|
||||||
saveMutex.withLock {
|
saveMutex.withLock {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@Suppress("VariableNeverRead") var didSave = false
|
@Suppress("VariableNeverRead") var didSave = false
|
||||||
|
var sidecarsSaved = false
|
||||||
|
|
||||||
if (canSaveSidecarsSnapshot) {
|
if (canSaveSidecarsSnapshot) {
|
||||||
if (force || annotsHash != lastSavedHashes[0]) {
|
if (annotsHash != lastSavedHashes[0]) {
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.reader.save_ink book=$bookId force=$force oldHash=${lastSavedHashes[0]} " +
|
||||||
|
"newHash=$annotsHash pages=${annots.keys.sorted()} count=${annots.values.sumOf { it.size }}"
|
||||||
|
}
|
||||||
annotationRepository.saveAnnotations(bookId, annots)
|
annotationRepository.saveAnnotations(bookId, annots)
|
||||||
lastSavedHashes[0] = annotsHash
|
lastSavedHashes[0] = annotsHash
|
||||||
didSave = true
|
didSave = true
|
||||||
|
sidecarsSaved = true
|
||||||
|
} else if (force) {
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.reader.save_ink_noop book=$bookId force=true hash=$annotsHash"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (force || boxesHash != lastSavedHashes[1]) {
|
if (boxesHash != lastSavedHashes[1]) {
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.reader.save_textboxes book=$bookId force=$force oldHash=${lastSavedHashes[1]} " +
|
||||||
|
"newHash=$boxesHash count=${boxes.size}"
|
||||||
|
}
|
||||||
textBoxRepository.saveTextBoxes(bookId, boxes)
|
textBoxRepository.saveTextBoxes(bookId, boxes)
|
||||||
lastSavedHashes[1] = boxesHash
|
lastSavedHashes[1] = boxesHash
|
||||||
didSave = true
|
didSave = true
|
||||||
|
sidecarsSaved = true
|
||||||
|
} else if (force) {
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.reader.save_textboxes_noop book=$bookId force=true hash=$boxesHash"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (force || highlightsHash != lastSavedHashes[2]) {
|
if (highlightsHash != lastSavedHashes[2]) {
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.reader.save_highlights book=$bookId force=$force oldHash=${lastSavedHashes[2]} " +
|
||||||
|
"newHash=$highlightsHash count=${highlights.size}"
|
||||||
|
}
|
||||||
highlightRepository.saveHighlights(bookId, highlights)
|
highlightRepository.saveHighlights(bookId, highlights)
|
||||||
lastSavedHashes[2] = highlightsHash
|
lastSavedHashes[2] = highlightsHash
|
||||||
didSave = true
|
didSave = true
|
||||||
|
sidecarsSaved = true
|
||||||
|
} else if (force) {
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.reader.save_highlights_noop book=$bookId force=true hash=$highlightsHash"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.tag("PdfTabSync").d(
|
Timber.tag("PdfTabSync").d(
|
||||||
|
|
@ -1384,6 +1439,12 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
lastSavedHashes[4] = page
|
lastSavedHashes[4] = page
|
||||||
}
|
}
|
||||||
|
if (sidecarsSaved) {
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.reader.sidecar_upload_queue book=$bookId force=$force"
|
||||||
|
}
|
||||||
|
viewModel.queuePdfSidecarCloudUpload(bookId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1391,6 +1452,44 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val persistInkAnnotationsNow = remember(currentBookId, annotationRepository) {
|
||||||
|
{ annotationsSnapshot: Map<Int, List<PdfAnnotation>>, deletedAnnotations: Collection<PdfAnnotation>, reason: String ->
|
||||||
|
val bookIdSnapshot = currentBookId
|
||||||
|
val loadedSidecarBookIdSnapshot = currentLoadedSidecarBookId
|
||||||
|
val canSaveSidecarsSnapshot = canUsePdfSidecarsForBook(
|
||||||
|
bookIdSnapshot,
|
||||||
|
loadedSidecarBookIdSnapshot,
|
||||||
|
currentAreAnnotationsLoaded
|
||||||
|
)
|
||||||
|
viewModel.viewModelScope.launch {
|
||||||
|
val bookId = bookIdSnapshot ?: return@launch
|
||||||
|
if (!canSaveSidecarsSnapshot) {
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.reader.persist_ink_skip book=$bookId reason=$reason loadedSidecarBook=$loadedSidecarBookIdSnapshot"
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val deletedIds = deletedAnnotations.mapNotNull { it.id.takeIf(String::isNotBlank) }.toSet()
|
||||||
|
withContext(NonCancellable) {
|
||||||
|
saveMutex.withLock {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (deletedIds.isNotEmpty()) {
|
||||||
|
annotationRepository.markAnnotationsDeleted(bookId, deletedIds)
|
||||||
|
}
|
||||||
|
annotationRepository.saveAnnotations(bookId, annotationsSnapshot)
|
||||||
|
lastSavedHashes[0] = annotationsSnapshot.hashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.reader.persist_ink book=$bookId reason=$reason count=${annotationsSnapshot.values.sumOf { it.size }} " +
|
||||||
|
"deletedIds=${deletedIds.sorted()}"
|
||||||
|
}
|
||||||
|
viewModel.queuePdfSidecarCloudUpload(bookId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
|
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
|
||||||
|
|
@ -2477,8 +2576,16 @@ fun PdfViewerScreen(
|
||||||
allAnnotations = loaded
|
allAnnotations = loaded
|
||||||
textBoxes.addAll(loadedBoxes)
|
textBoxes.addAll(loadedBoxes)
|
||||||
userHighlights.addAll(loadedHighlights)
|
userHighlights.addAll(loadedHighlights)
|
||||||
|
lastSavedHashes[0] = loaded.hashCode()
|
||||||
|
lastSavedHashes[1] = loadedBoxes.hashCode()
|
||||||
|
lastSavedHashes[2] = loadedHighlights.hashCode()
|
||||||
loadedSidecarBookId = loadingBookId
|
loadedSidecarBookId = loadingBookId
|
||||||
areAnnotationsLoaded = true
|
areAnnotationsLoaded = true
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.reader.sidecar_load book=$loadingBookId inkPages=${loaded.keys.sorted()} " +
|
||||||
|
"inkCount=${loaded.values.sumOf { it.size }} textBoxes=${loadedBoxes.size} " +
|
||||||
|
"highlights=${loadedHighlights.size} hashes=${lastSavedHashes.copyOfRange(0, 3).joinToString()}"
|
||||||
|
}
|
||||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||||
"ui.sidecarLoad.done bookId=$loadingBookId annotationPages=${loaded.keys.sorted()} " +
|
"ui.sidecarLoad.done bookId=$loadingBookId annotationPages=${loaded.keys.sorted()} " +
|
||||||
"textBoxes=${loadedBoxes.size} highlights=${loadedHighlights.size}"
|
"textBoxes=${loadedBoxes.size} highlights=${loadedHighlights.size}"
|
||||||
|
|
@ -2646,7 +2753,6 @@ fun PdfViewerScreen(
|
||||||
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
|
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
|
||||||
var isFastScrubbing by remember { mutableStateOf(false) }
|
var isFastScrubbing by remember { mutableStateOf(false) }
|
||||||
val scrubDebounceJob = remember { mutableStateOf<Job?>(null) }
|
val scrubDebounceJob = remember { mutableStateOf<Job?>(null) }
|
||||||
var startPageThumbnail by remember { mutableStateOf<Bitmap?>(null) }
|
|
||||||
val pdfSliderChromeVisible = shouldRenderReaderSlider(
|
val pdfSliderChromeVisible = shouldRenderReaderSlider(
|
||||||
isToggledOn = isPageSliderVisible,
|
isToggledOn = isPageSliderVisible,
|
||||||
isBottomChromeVisible = showStandardBars,
|
isBottomChromeVisible = showStandardBars,
|
||||||
|
|
@ -3342,23 +3448,6 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(pdfSliderChromeVisible, sliderStartPage, pdfDocument, totalPages) {
|
|
||||||
startPageThumbnail?.recycle()
|
|
||||||
startPageThumbnail = null
|
|
||||||
if (pdfSliderChromeVisible) {
|
|
||||||
val doc = pdfDocument
|
|
||||||
if (doc != null && totalPages > 0) {
|
|
||||||
Timber.d("Slider visible. Rendering thumbnail for page $sliderStartPage")
|
|
||||||
startPageThumbnail = renderPageToBitmap(doc, sliderStartPage)
|
|
||||||
Timber.d(
|
|
||||||
"Thumbnail rendering complete. Is bitmap null: ${startPageThumbnail == null}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Timber.d("Slider hidden. Clearing thumbnail.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(ttsState.currentText, ttsPageData, ttsState.startOffsetInSource) {
|
LaunchedEffect(ttsState.currentText, ttsPageData, ttsState.startOffsetInSource) {
|
||||||
val currentText = ttsState.currentText
|
val currentText = ttsState.currentText
|
||||||
val currentTtsData = ttsPageData
|
val currentTtsData = ttsPageData
|
||||||
|
|
@ -5084,8 +5173,14 @@ fun PdfViewerScreen(
|
||||||
val pageIdx = finalAnnotation.pageIndex
|
val pageIdx = finalAnnotation.pageIndex
|
||||||
val existing =
|
val existing =
|
||||||
allAnnotations[pageIdx] ?: emptyList()
|
allAnnotations[pageIdx] ?: emptyList()
|
||||||
allAnnotations =
|
val nextAnnotations =
|
||||||
allAnnotations + (pageIdx to (existing + finalAnnotation))
|
allAnnotations + (pageIdx to (existing + finalAnnotation))
|
||||||
|
allAnnotations = nextAnnotations
|
||||||
|
persistInkAnnotationsNow(
|
||||||
|
nextAnnotations,
|
||||||
|
emptyList(),
|
||||||
|
"draw_end"
|
||||||
|
)
|
||||||
undoStack.add(
|
undoStack.add(
|
||||||
HistoryAction.Add(
|
HistoryAction.Add(
|
||||||
pageIdx, finalAnnotation
|
pageIdx, finalAnnotation
|
||||||
|
|
@ -5099,6 +5194,11 @@ fun PdfViewerScreen(
|
||||||
erasedAnnotationsFromStroke.mapValues {
|
erasedAnnotationsFromStroke.mapValues {
|
||||||
it.value.toList()
|
it.value.toList()
|
||||||
}
|
}
|
||||||
|
persistInkAnnotationsNow(
|
||||||
|
allAnnotations,
|
||||||
|
removalMap.values.flatten(),
|
||||||
|
"erase_end"
|
||||||
|
)
|
||||||
undoStack.add(
|
undoStack.add(
|
||||||
HistoryAction.Remove(removalMap)
|
HistoryAction.Remove(removalMap)
|
||||||
)
|
)
|
||||||
|
|
@ -5559,8 +5659,14 @@ fun PdfViewerScreen(
|
||||||
val pageIdx = finalAnnotation.pageIndex
|
val pageIdx = finalAnnotation.pageIndex
|
||||||
val existing =
|
val existing =
|
||||||
allAnnotations[pageIdx] ?: emptyList()
|
allAnnotations[pageIdx] ?: emptyList()
|
||||||
allAnnotations =
|
val nextAnnotations =
|
||||||
allAnnotations + (pageIdx to (existing + finalAnnotation))
|
allAnnotations + (pageIdx to (existing + finalAnnotation))
|
||||||
|
allAnnotations = nextAnnotations
|
||||||
|
persistInkAnnotationsNow(
|
||||||
|
nextAnnotations,
|
||||||
|
emptyList(),
|
||||||
|
"draw_end"
|
||||||
|
)
|
||||||
undoStack.add(
|
undoStack.add(
|
||||||
HistoryAction.Add(
|
HistoryAction.Add(
|
||||||
pageIdx, finalAnnotation
|
pageIdx, finalAnnotation
|
||||||
|
|
@ -5574,6 +5680,11 @@ fun PdfViewerScreen(
|
||||||
erasedAnnotationsFromStroke.mapValues {
|
erasedAnnotationsFromStroke.mapValues {
|
||||||
it.value.toList()
|
it.value.toList()
|
||||||
}
|
}
|
||||||
|
persistInkAnnotationsNow(
|
||||||
|
allAnnotations,
|
||||||
|
removalMap.values.flatten(),
|
||||||
|
"erase_end"
|
||||||
|
)
|
||||||
undoStack.add(
|
undoStack.add(
|
||||||
HistoryAction.Remove(removalMap)
|
HistoryAction.Remove(removalMap)
|
||||||
)
|
)
|
||||||
|
|
@ -5916,6 +6027,42 @@ fun PdfViewerScreen(
|
||||||
pageText = pdfSliderPageText,
|
pageText = pdfSliderPageText,
|
||||||
themePrimary = MaterialTheme.colorScheme.primary
|
themePrimary = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
|
val pdfSliderMaxPage = (totalDisplayPages - 1).coerceAtLeast(0)
|
||||||
|
val pdfSliderCurrentPage = sliderCurrentPage.roundToInt().coerceIn(0, pdfSliderMaxPage)
|
||||||
|
|
||||||
|
suspend fun scrollPdfSliderToPage(pageIndex: Int) {
|
||||||
|
val targetPage = pageIndex.coerceIn(0, pdfSliderMaxPage)
|
||||||
|
if (displayMode == DisplayMode.PAGINATION) {
|
||||||
|
scrollPaginationToDisplayPage(targetPage)
|
||||||
|
} else {
|
||||||
|
verticalReaderState.scrollToPage(targetPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun jumpPdfSliderToPage(pageIndex: Int) {
|
||||||
|
val targetPage = pageIndex.coerceIn(0, pdfSliderMaxPage)
|
||||||
|
scrubDebounceJob.value?.cancel()
|
||||||
|
sliderCurrentPage = targetPage.toFloat()
|
||||||
|
isFastScrubbing = false
|
||||||
|
coroutineScope.launch {
|
||||||
|
scrollPdfSliderToPage(targetPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scrubPdfSliderToPage(newValue: Float) {
|
||||||
|
sliderCurrentPage = newValue.coerceIn(0f, pdfSliderMaxPage.toFloat())
|
||||||
|
isFastScrubbing = true
|
||||||
|
scrubDebounceJob.value?.cancel()
|
||||||
|
scrubDebounceJob.value = coroutineScope.launch {
|
||||||
|
delay(200)
|
||||||
|
if (isActive) {
|
||||||
|
val targetPage = newValue.roundToInt().coerceIn(0, pdfSliderMaxPage)
|
||||||
|
scrollPdfSliderToPage(targetPage)
|
||||||
|
sliderCurrentPage = targetPage.toFloat()
|
||||||
|
isFastScrubbing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Slider UI attached to the bottom chrome ---
|
// --- Slider UI attached to the bottom chrome ---
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
|
|
@ -5926,144 +6073,79 @@ fun PdfViewerScreen(
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(bottom = pdfSliderBottomPadding)
|
.padding(bottom = pdfSliderBottomPadding)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Box(
|
||||||
Spacer(Modifier.height(72.dp))
|
modifier = Modifier
|
||||||
Box(
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
) {}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
indication = null,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {}
|
|
||||||
) {
|
) {
|
||||||
Row(
|
IconButton(
|
||||||
modifier = Modifier
|
onClick = {
|
||||||
.fillMaxWidth()
|
jumpPdfSliderToPage(
|
||||||
.padding(horizontal = 32.dp, vertical = 14.dp),
|
readerSliderStepPage(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
currentPage = pdfSliderCurrentPage,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
delta = -1,
|
||||||
) {
|
minPage = 0,
|
||||||
BoxWithConstraints(
|
maxPage = pdfSliderMaxPage
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Slider(
|
|
||||||
value = sliderCurrentPage,
|
|
||||||
onValueChange = { newValue ->
|
|
||||||
sliderCurrentPage = newValue
|
|
||||||
isFastScrubbing = true
|
|
||||||
scrubDebounceJob.value?.cancel()
|
|
||||||
scrubDebounceJob.value = coroutineScope.launch {
|
|
||||||
delay(200)
|
|
||||||
if (isActive) {
|
|
||||||
val targetPage = newValue.roundToInt()
|
|
||||||
if (displayMode == DisplayMode.PAGINATION) {
|
|
||||||
scrollPaginationToDisplayPage(targetPage)
|
|
||||||
} else {
|
|
||||||
verticalReaderState.scrollToPage(targetPage)
|
|
||||||
}
|
|
||||||
isFastScrubbing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
valueRange = 0f..(totalDisplayPages - 1).toFloat().coerceAtLeast(0f),
|
|
||||||
steps = if (totalDisplayPages > 2) totalDisplayPages - 2 else 0,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
thumb = {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
shape = CircleShape,
|
|
||||||
color = pdfReaderSliderColors.thumbColor,
|
|
||||||
tonalElevation = 0.dp,
|
|
||||||
shadowElevation = 0.dp
|
|
||||||
) {}
|
|
||||||
},
|
|
||||||
track = { sliderState ->
|
|
||||||
val trackHeight = 2.dp
|
|
||||||
val trackShape = RoundedCornerShape(trackHeight)
|
|
||||||
val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start
|
|
||||||
val fraction = if (range == 0f) 0f else {
|
|
||||||
((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(trackHeight)
|
|
||||||
.background(
|
|
||||||
color = pdfReaderSliderColors.inactiveTrackColor,
|
|
||||||
shape = trackShape
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(fraction)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.background(
|
|
||||||
color = pdfReaderSliderColors.activeTrackColor,
|
|
||||||
shape = trackShape
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val startPageOffsetFraction = if (totalDisplayPages > 1) {
|
|
||||||
sliderStartPage.toFloat() / (totalDisplayPages - 1)
|
|
||||||
} else {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
val thumbWidth = 20.dp
|
|
||||||
val trackWidth = maxWidth - thumbWidth
|
|
||||||
val startPagePixelPosition =
|
|
||||||
(trackWidth * startPageOffsetFraction) + (thumbWidth / 2)
|
|
||||||
|
|
||||||
val indicatorSize = 8.dp
|
|
||||||
val indicatorOffset = startPagePixelPosition - (indicatorSize / 2)
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.CenterStart)
|
|
||||||
.offset(x = indicatorOffset)
|
|
||||||
.size(indicatorSize),
|
|
||||||
shape = CircleShape,
|
|
||||||
color = pdfReaderSliderColors.bookmarkColor
|
|
||||||
) {}
|
|
||||||
|
|
||||||
startPageThumbnail?.let { thumbnail ->
|
|
||||||
ThumbnailWithIndicator(
|
|
||||||
thumbnail = thumbnail,
|
|
||||||
borderColor = pdfReaderSliderColors.bookmarkColor,
|
|
||||||
modifier = Modifier
|
|
||||||
.graphicsLayer { clip = false }
|
|
||||||
.align(Alignment.TopStart)
|
|
||||||
.offset(
|
|
||||||
x = startPagePixelPosition - (45.dp / 2),
|
|
||||||
y = (-72).dp
|
|
||||||
),
|
|
||||||
onClick = {
|
|
||||||
sliderCurrentPage = sliderStartPage.toFloat()
|
|
||||||
coroutineScope.launch {
|
|
||||||
if (displayMode == DisplayMode.PAGINATION) {
|
|
||||||
scrollPaginationToDisplayPage(sliderStartPage)
|
|
||||||
} else {
|
|
||||||
verticalReaderState.scrollToPage(sliderStartPage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
}
|
},
|
||||||
|
enabled = pdfSliderCurrentPage > 0,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.NavigateBefore,
|
||||||
|
contentDescription = stringResource(R.string.desktop_previous_page),
|
||||||
|
tint = pdfReaderSliderColors.contentColor.copy(
|
||||||
|
alpha = if (pdfSliderCurrentPage > 0) 0.9f else 0.32f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
ReaderMinimalSlider(
|
||||||
text = pdfPageRangeText(
|
value = sliderCurrentPage.coerceIn(0f, pdfSliderMaxPage.toFloat()),
|
||||||
pageIndex = sliderCurrentPage.roundToInt(),
|
onValueChange = ::scrubPdfSliderToPage,
|
||||||
pageCount = totalDisplayPages,
|
valueRange = 0f..pdfSliderMaxPage.toFloat(),
|
||||||
displayMode = displayMode,
|
enabled = pdfSliderMaxPage > 0,
|
||||||
settings = pdfSpreadSettings
|
activeColor = pdfReaderSliderColors.activeTrackColor,
|
||||||
),
|
inactiveColor = pdfReaderSliderColors.inactiveTrackColor,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
thumbColor = pdfReaderSliderColors.thumbColor,
|
||||||
color = pdfReaderSliderColors.contentColor,
|
markerValue = sliderStartPage.toFloat(),
|
||||||
fontSize = 18.sp
|
markerColor = pdfReaderSliderColors.bookmarkColor,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
jumpPdfSliderToPage(
|
||||||
|
readerSliderStepPage(
|
||||||
|
currentPage = pdfSliderCurrentPage,
|
||||||
|
delta = 1,
|
||||||
|
minPage = 0,
|
||||||
|
maxPage = pdfSliderMaxPage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = pdfSliderCurrentPage < pdfSliderMaxPage,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.NavigateNext,
|
||||||
|
contentDescription = stringResource(R.string.desktop_next_page),
|
||||||
|
tint = pdfReaderSliderColors.contentColor.copy(
|
||||||
|
alpha = if (pdfSliderCurrentPage < pdfSliderMaxPage) 0.9f else 0.32f
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7199,7 +7281,7 @@ fun PdfViewerScreen(
|
||||||
)
|
)
|
||||||
|
|
||||||
val ttsAlignmentBias by animateFloatAsState(
|
val ttsAlignmentBias by animateFloatAsState(
|
||||||
targetValue = if (isTtsCollapsed) 1f else 0f,
|
targetValue = readerTtsOverlayAlignmentBias(ttsOverlaySize),
|
||||||
label = "TtsAlignAnimation"
|
label = "TtsAlignAnimation"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -7216,8 +7298,11 @@ fun PdfViewerScreen(
|
||||||
ttsController = ttsController,
|
ttsController = ttsController,
|
||||||
ttsState = ttsState,
|
ttsState = ttsState,
|
||||||
currentTtsMode = currentTtsMode,
|
currentTtsMode = currentTtsMode,
|
||||||
isCollapsed = isTtsCollapsed,
|
overlaySize = ttsOverlaySize,
|
||||||
onCollapseChange = { isTtsCollapsed = it },
|
onOverlaySizeChange = { newSize ->
|
||||||
|
ttsOverlaySize = newSize
|
||||||
|
saveReaderTtsOverlaySize(context, newSize)
|
||||||
|
},
|
||||||
onLocateCurrentChunk = {
|
onLocateCurrentChunk = {
|
||||||
ttsDisplayPageIndex?.let { targetPage ->
|
ttsDisplayPageIndex?.let { targetPage ->
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,17 @@ private const val ZWSP = "\u200B"
|
||||||
internal fun String.hasRenderableRichText(): Boolean =
|
internal fun String.hasRenderableRichText(): Boolean =
|
||||||
any { it != PAGE_BREAK_CHAR && !it.isWhitespace() }
|
any { it != PAGE_BREAK_CHAR && !it.isWhitespace() }
|
||||||
|
|
||||||
|
internal fun androidPdfRichTextSelectionBounds(
|
||||||
|
selectionStart: Int,
|
||||||
|
selectionEnd: Int,
|
||||||
|
textLength: Int
|
||||||
|
): Pair<Int, Int>? {
|
||||||
|
val safeLength = textLength.coerceAtLeast(0)
|
||||||
|
val localStart = minOf(selectionStart, selectionEnd).coerceIn(0, safeLength)
|
||||||
|
val localEnd = maxOf(selectionStart, selectionEnd).coerceIn(0, safeLength)
|
||||||
|
return if (localStart < localEnd) localStart to localEnd else null
|
||||||
|
}
|
||||||
|
|
||||||
object PdfFontCache {
|
object PdfFontCache {
|
||||||
private val cache = ConcurrentHashMap<String, FontFamily>()
|
private val cache = ConcurrentHashMap<String, FontFamily>()
|
||||||
private var assetManager: android.content.res.AssetManager? = null
|
private var assetManager: android.content.res.AssetManager? = null
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import android.graphics.Rect
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import com.aryan.reader.COMIC_ARCHIVE_FILE_TYPES
|
||||||
import com.aryan.reader.FileType
|
import com.aryan.reader.FileType
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.pptx.PptxDocumentWrapper
|
import com.aryan.reader.pptx.PptxDocumentWrapper
|
||||||
|
|
@ -106,7 +107,7 @@ object DocumentFactory {
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
PptxDocumentWrapper(cacheFile, deleteOnClose = true)
|
PptxDocumentWrapper(cacheFile, deleteOnClose = true)
|
||||||
} else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
|
} else if (type in COMIC_ARCHIVE_FILE_TYPES) {
|
||||||
val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}")
|
val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}")
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
|
@ -534,7 +535,7 @@ class PdfTextPageWrapper(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================= CBZ, CBR, CB7 IMPLEMENTATION =================
|
// ================= CBZ, CBR, CB7, CBT IMPLEMENTATION =================
|
||||||
|
|
||||||
class DummyTextPage : ReaderTextPage {
|
class DummyTextPage : ReaderTextPage {
|
||||||
override suspend fun textPageCountChars() = 0
|
override suspend fun textPageCountChars() = 0
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import com.aryan.reader.pdf.PdfUserHighlight
|
||||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
|
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -143,7 +144,7 @@ object AnnotationSerializer {
|
||||||
resultMap[pageIndex]?.add(annotation)
|
resultMap[pageIndex]?.add(annotation)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
Timber.e(e, "Failed to parse PDF ink annotations")
|
||||||
}
|
}
|
||||||
return resultMap
|
return resultMap
|
||||||
}
|
}
|
||||||
|
|
@ -217,7 +218,7 @@ object TextBoxSerializer {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
Timber.e(e, "Failed to parse PDF text boxes")
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
@ -290,7 +291,7 @@ object HighlightSerializer {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
Timber.e(e, "Failed to parse PDF highlights")
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@
|
||||||
package com.aryan.reader.pdf.data
|
package com.aryan.reader.pdf.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.aryan.reader.logCloudAnnotationSyncTrace
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -34,6 +36,13 @@ class PdfAnnotationRepository(private val context: Context) {
|
||||||
return File(dir, "annotation_$safeBookId.json")
|
return File(dir, "annotation_$safeBookId.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getDeletedFile(bookId: String): File {
|
||||||
|
val safeBookId = bookId.replace("/", "_")
|
||||||
|
val dir = File(context.filesDir, "annotations")
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
return File(dir, "deleted_annotation_$safeBookId.json")
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun saveAnnotations(bookId: String, annotations: Map<Int, List<PdfAnnotation>>) {
|
suspend fun saveAnnotations(bookId: String, annotations: Map<Int, List<PdfAnnotation>>) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -47,7 +56,19 @@ class PdfAnnotationRepository(private val context: Context) {
|
||||||
|
|
||||||
val json = AnnotationSerializer.toJson(annotations)
|
val json = AnnotationSerializer.toJson(annotations)
|
||||||
val file = getFile(bookId)
|
val file = getFile(bookId)
|
||||||
|
if (file.exists() && file.readText() == json) {
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.repository.save_ink_noop book=$bookId count=${annotations.values.sumOf { it.size }} " +
|
||||||
|
"bytes=${file.length()} ts=${file.lastModified()}"
|
||||||
|
}
|
||||||
|
Timber.tag("AnnotationSync").d("Skipping unchanged annotation JSON for $bookId.")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
file.writeText(json)
|
file.writeText(json)
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.repository.save_ink book=$bookId count=${annotations.values.sumOf { it.size }} " +
|
||||||
|
"bytes=${file.length()} ts=${file.lastModified()}"
|
||||||
|
}
|
||||||
|
|
||||||
Timber.tag("AnnotationSync").d("Finished saving local JSON for $bookId. Path: ${file.absolutePath}, Size: ${file.length()}")
|
Timber.tag("AnnotationSync").d("Finished saving local JSON for $bookId. Path: ${file.absolutePath}, Size: ${file.length()}")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -83,4 +104,63 @@ class PdfAnnotationRepository(private val context: Context) {
|
||||||
|
|
||||||
return if (valid) file else null
|
return if (valid) file else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun markAnnotationsDeleted(
|
||||||
|
bookId: String,
|
||||||
|
annotationIds: Collection<String>,
|
||||||
|
deletedAt: Long = System.currentTimeMillis()
|
||||||
|
) {
|
||||||
|
val ids = annotationIds.mapNotNull { it.takeIf(String::isNotBlank) }.toSet()
|
||||||
|
if (ids.isEmpty()) return
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val file = getDeletedFile(bookId)
|
||||||
|
val existing = if (file.isFile) {
|
||||||
|
SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText())
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
val next = existing.toMutableMap()
|
||||||
|
ids.forEach { id -> next[id] = maxOf(next[id] ?: 0L, deletedAt) }
|
||||||
|
val json = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(next)
|
||||||
|
if (file.isFile && file.readText() == json) return@withContext
|
||||||
|
file.writeText(json)
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.repository.mark_deleted_ink book=$bookId ids=${ids.sorted()} " +
|
||||||
|
"bytes=${file.length()} ts=${file.lastModified()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun replaceDeletedAnnotations(
|
||||||
|
bookId: String,
|
||||||
|
deletions: Map<String, Long>,
|
||||||
|
timestamp: Long? = null
|
||||||
|
) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val file = getDeletedFile(bookId)
|
||||||
|
if (deletions.isEmpty()) {
|
||||||
|
if (file.exists()) file.delete()
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
val json = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(deletions)
|
||||||
|
if (!file.isFile || file.readText() != json) {
|
||||||
|
file.writeText(json)
|
||||||
|
}
|
||||||
|
timestamp?.takeIf { it > 0L }?.let(file::setLastModified)
|
||||||
|
logCloudAnnotationSyncTrace {
|
||||||
|
"android.repository.replace_deleted_ink book=$bookId count=${deletions.size} " +
|
||||||
|
"bytes=${file.length()} ts=${file.lastModified()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDeletedAnnotationsFileForSync(bookId: String): File? {
|
||||||
|
val file = getDeletedFile(bookId)
|
||||||
|
val deletions = if (file.isFile) {
|
||||||
|
SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText())
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
return if (deletions.isNotEmpty()) file else null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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