diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..d09f4f6
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..6f0bc26
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,76 @@
+# Book Reader — Episteme fork + bookshelf-api/ABS
+
+> Форк `Aryan-Raj3112/episteme` под bookshelf-api + Audiobookshelf + Material You 3 (Myne-style UI).
+
+## Stack
+
+- **Язык:** Kotlin 100%
+- **UI:** Jetpack Compose + Material3 (Myne-style: round cards, dynamic color, minimal)
+- **DI:** Dagger Hilt
+- **БД:** Room
+- **Настройки:** DataStore
+- **Сеть:** Retrofit + OkHttp + Kotlinx Serialization
+- **Аудио:** ExoPlayer (Media3) + MediaSession
+- **Фоновые задачи:** WorkManager
+- **Читалка:** Episteme paginatedreader (CSS-движок, ContentBlock/Page модель)
+- **Форматы:** EPUB, FB2, PDF, MOBI/AZW3, DOCX, ODT, TXT, Markdown, HTML, comics
+- **minSdk:** 26 (Episteme default)
+- **compileSdk/targetSdk:** 36
+
+## Origin
+
+- **upstream:** `https://github.com/Aryan-Raj3112/episteme.git` (AGPL-3.0)
+- **origin:** `https://git.dueattendant149.org/Atte149/book-reader.git` (Forgejo)
+- **Лицензия:** AGPL-3.0 (наследуется от Episteme)
+
+## Goal
+
+- Взять зрелый reader engine из Episteme (пагинация, все форматы, TTS, annotations)
+- Redesign UI под Myne-стиль (Material You 3, round cards, dynamic color)
+- Заменить backend (OPDS/Gutenberg/LocalFolder) на bookshelf-api + ABS
+- Добавить фичи из Book's Story: audio player, RSVP, Librarr search, TTS jobs, cache, progress sync, upload
+
+## Layout
+
+- `app/src/main/` — Android app (после KMP→Android упрощения)
+- `app/src/oss/` — OSS flavor (без проприетарных ML Kit/cloud)
+- `app/src/releaseOffline/` — offline-only flavor
+- ~~`desktopApp/`~~ — удалён (Android-only)
+- ~~`shared/`~~ — слит в `app/src/main/` (после Этапа 1)
+
+## Backend
+
+- **bookshelf-api:** `https://books.dueattendant149.org/api/v1/*`
+ - `GET /api/v1/books/libraries`
+ - `GET /api/v1/books/{item_id}`
+ - `GET /api/v1/books/library/{id}/items`
+ - `POST /api/v1/search`
+ - `POST /api/v1/tts`, `GET /api/v1/tts/{job_id}`
+ - `POST /api/v1/download`, `GET /api/v1/download/list`
+- **Audiobookshelf (напрямую):**
+ - `GET /api/items/{id}/file/{ino}`
+ - `GET /api/items/{id}/cover`
+ - `GET /api/items/{id}/download`
+
+## WARNs
+
+- **Не пушить в `upstream` (Aryan-Raj3112/episteme).** Только в `origin` (Forgejo).
+- **AGPL-3.0** — если публикуем, исходный код должен быть открыт (включая server-side modifications если есть network use).
+- **`pro` flavor** содержит проприетарные компоненты (ML Kit OCR, cloud sync) — не использовать, только `oss`.
+- **Секреты** (ABS token, API keys) — только в `.env` / DataStore, никогда в коде.
+
+## Build
+
+```bash
+./gradlew :app:assembleOssDebug
+```
+
+## Status
+
+- Phase 0: clone + package rename — completed
+- Phase 1: KMP → Android-only — pending
+- Phase 2: backend swap (bookshelf-api + ABS) — pending
+- Phase 3: UI redesign (Myne-style) — pending
+- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — pending
+- Phase 5: reader engine integration — pending
+- Phase 6: build, test, deploy — pending
\ No newline at end of file
diff --git a/README.md b/README.md
index a6af8ee..b92844f 100644
--- a/README.md
+++ b/README.md
@@ -5,62 +5,63 @@
Episteme Reader
- A modern, offline‑first, privacy‑focused document & e‑book reader for Android, built with Kotlin and Jetpack Compose.
+ A modern, offline-first, privacy-focused document and e-book reader for Android and desktop, built with Kotlin Multiplatform and Compose.
-
+
-
+
+
+
+
+
+ Android
+ |
+
+
+
+ Desktop
+ |
+
+
## Overview
-Episteme Reader is a comprehensive, customizable reader for documents and e-books on Android. It features a modern Jetpack Compose UI, powerful reading tools, and extensive theming.
+Episteme Reader is a customizable reader for documents, e-books, comics, and text-heavy files. The app is designed around local-first reading, deep typography controls, flexible layouts, and a consistent Kotlin Multiplatform core across Android and desktop.
-To best serve different user preferences regarding privacy and network usage, Episteme Reader is available in three distinct editions:
-* **PlayStore Version:** The full-featured release which includes proprietary code and features.
-* **OSS Version (GitHub/F-Droid):** A fully open-source build.
-* **OSS Offline Version (GitHub):** A strictly offline build with network permissions completely removed.
+The same core reading experience is available across editions. The main differences are distribution channel, network access, and whether proprietary online services are included.
----
+## Core Features
-## Feature Comparison
+Available across supported editions unless noted in the edition table:
-### 📚 Supported Formats
-| Feature | PlayStore | OSS | OSS Offline |
-| :--- | :---: | :---: | :---: |
-| **Documents:** PDF, DOCX, ODT/FODT | ✅ | ✅ | ✅ |
-| **E-books & Text:** EPUB, MOBI, AZW3, FB2, MD, HTML, TXT | ✅ | ✅ | ✅ |
-| **Comics:** CBZ, CBR, CB7 | ✅ | ✅ | ✅ |
-| **View-Only:** CSV, TSV, JSON, XML, Logs, Code Files | ✅ | ✅ | ✅ |
+* **Formats:** PDF, EPUB, MOBI/AZW3, FB2, DOCX, ODT/FODT, TXT, Markdown, HTML, and comic archives.
+* **Reading modes:** Paginated reading, vertical scroll, PDF multi-tab reading, PDF reflow, auto-scroll, and musician mode.
+* **PDF tools:** Ink annotations, highlighting, erasing, text annotations, and reading-focused PDF controls.
+* **Customization:** App themes, reader themes, custom local fonts, typography controls, spacing, margins, and layout tuning.
+* **Library tools:** Local folder sync, library organization, bookmarks, progress tracking, and file management.
+* **Accessibility:** System text-to-speech, app language selection, and reader settings that adapt to different reading preferences.
-### 📖 Core Reading Experience
-| Feature | PlayStore | OSS | OSS Offline |
-| :--- | :---: | :---: | :---: |
-| **Display Modes:** Paginated & Vertical Scroll | ✅ | ✅ | ✅ |
-| **PDF Multi-Tab Reading & Reflow** | ✅ | ✅ | ✅ |
-| **PDF Annotations:** Ink (Pen, Highlight, Erase) & Text | ✅ | ✅ | ✅ |
-| **App-wide Customization & Reader Theming** | ✅ | ✅ | ✅ |
-| **Custom Fonts:** Local Import (TTF and OTF) | ✅ | ✅ | ✅ |
-| **Typography Control** | ✅ | ✅ | ✅ |
-| **Auto-Scroll & Musician Mode** | ✅ | ✅ | ✅ |
-| **System Text-to-Speech (TTS)** | ✅ | ✅ | ✅ |
+## Editions
-### ⚙️ Advanced & Network Features
-| Feature | PlayStore | OSS (F-Droid) | OSS Offline |
-| :--- | :---: | :---: | :---: |
-| **Local Folder Sync & Library Management** | ✅ | ✅ | ✅ |
-| **Download Google Fonts** | ✅ | ✅ | ❌ |
-| **OPDS Catalog Support** | ✅ | ✅ | ❌ |
-| **PDF Bubble Zoom Magnifier** | ✅ | ❌ | ❌ |
-| **ML Kit OCR** (Scanned PDF Text Selection) | ✅ | ❌ | ❌ |
-| **Cross-Device Cloud Sync** | ✅ | ❌ | ❌ |
-| **AI Tools** (Summaries, Story Recap, Dictionary) | ✅ | 🔜 | ❌ |
-| **Cloud Text-to-Speech** | ✅ | 🔜 | ❌ |
+| Edition | Platform | Network access | Distribution | Notes |
+|---|---|---|---|---|
+| **Play Store** | Android | Online-capable | Google Play | Full Android release with proprietary extras such as ML Kit OCR, cloud sync, AI tools, cloud TTS, and PDF bubble zoom. |
+| **OSS** | Android | Online-capable | [epistemereader.com](https://epistemereader.com), GitHub, F-Droid, Obtainium | Fully open-source Android build with OPDS, downloadable fonts, and BYOK access to AI and cloud features. |
+| **OSS Offline** | Android | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub, Obtainium | Open-source Android build with network permissions removed. |
+| **Standard** | Windows desktop | Online-capable | [epistemereader.com](https://epistemereader.com), GitHub | Full-featured desktop release with the shared KMP reader core and online-capable services. |
+| **Offline** | Windows desktop | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub | Desktop build focused on local reading with online services disabled. |
----
+Future desktop platforms can use the same Standard and Offline model as support expands.
+
+## Languages
+
+Episteme Reader currently supports: English, Arabic, Belarusian, German, Spanish, Estonian, French, Hindi, Indonesian, Italian, Japanese, Korean, Dutch, Polish, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, and Chinese Simplified.
+
+Want Episteme Reader in another language? Please request it through [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose) or start a thread in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions).
## Building from Source
@@ -70,7 +71,7 @@ To best serve different user preferences regarding privacy and network usage, Ep
cd episteme
```
-2. Build:
+2. Build Android:
* Open in Android Studio and run the `ossDebug` or `ossOfflineDebug` variant, or
* Build from the command line:
```bash
@@ -79,43 +80,56 @@ To best serve different user preferences regarding privacy and network usage, Ep
The APK will be generated at:
`app/build/outputs/apk/oss/debug/Episteme-oss-v{version}-oss-debug.apk`
+3. Build desktop:
+ ```bash
+ ./gradlew :desktopApp:packageReleaseDistributionForCurrentOS
+ ```
+ For the offline desktop build, pass:
+ ```bash
+ ./gradlew :desktopApp:packageReleaseDistributionForCurrentOS -PdesktopFlavor=oss
+ ```
+
## Open Source Libraries
-Powered by the Android OSS ecosystem:
-* **Core & UI:** AndroidX, Jetpack Compose, Kotlinx Serialization
-* **Document Engines:** PdfiumAndroidKt (PDF), libmobi (MOBI/AZW3)
-* **Parsers:** Jsoup (HTML/EPUB), Flexmark (Markdown)
-* **Media & Image Loading:** Coil, Media3 (ExoPlayer)
-* **Utilities:** Room (Database), Timber (Logging)
+Powered by the Kotlin, Android, and desktop OSS ecosystem:
+
+* **Core and UI:** Kotlin Multiplatform, Compose Multiplatform, AndroidX, Jetpack Compose, Kotlinx Serialization
+* **Document engines:** PdfiumAndroidKt, PDFium, libmobi
+* **Parsers:** Jsoup, Flexmark, Apache Commons Compress
+* **Media and image loading:** Coil, Media3
+* **Utilities:** Room, Timber, JNA
## Contributors
| Contributor | Contribution |
|---|---|
-|
[CCerrer](https://github.com/CCerrer) | Testing & QA |
-|
[ottozumkeller](https://github.com/ottozumkeller) | Translation (German) |
-|
[TURBOKANTR](https://github.com/TURBOKANTR) | Translation (Turkish) |
-|
[eyadalkordy24](https://github.com/eyadalkordy24) | Translation (Arabic) |
-|
[berebara](https://github.com/berebara) | Translation (Russian) |
-|
[mh4ckt3mh4ckt1c4s](https://github.com/mh4ckt3mh4ckt1c4s) | Translation (French) |
+|
[CCerrer](https://github.com/CCerrer) | Testing and QA |
+|
[ottozumkeller](https://github.com/ottozumkeller) | German translation |
+|
[TURBOKANTR](https://github.com/TURBOKANTR) | Turkish translation |
+|
[eyadalkordy24](https://github.com/eyadalkordy24) | Arabic translation |
+|
[berebara](https://github.com/berebara) | Russian translation |
+|
[mh4ckt3mh4ckt1c4s](https://github.com/mh4ckt3mh4ckt1c4s) | French translation |
-## Translations
+## Supporters
-Help translate Episteme Reader into your native language! [Weblate](https://hosted.weblate.org/engage/episteme/) is used to manage localization.
+Thank you to the people helping keep Episteme Reader moving:
-[](https://hosted.weblate.org/engage/episteme/)
-
-## License
-
-Licensed under the GNU Affero General Public License v3.0 (AGPL‑3.0). See the [LICENSE](LICENSE) file.
+| Supporter | Platform |
+|---|---|
+|
[Zorklo](https://github.com/Zorklo) | GitHub Sponsors |
## Support the Project
-Help make Episteme Reader even better:
+Help make Episteme Reader better:
-* ❤️ [Support on Patreon](https://www.patreon.com/c/epistemereader)
-* ⭐ Star the repository to help visibility
-* 🐞 Report bugs or request features via [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose)
-* 💬 Share feedback in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions)
-* ✍️ Leave a review on the [Google Play Store](https://play.google.com/store/apps/details?id=com.aryan.reader)
-* 📣 Tell a friend!
+* [Sponsor on GitHub](https://github.com/sponsors/Aryan-Raj3112)
+* [Support on Patreon](https://www.patreon.com/c/epistemereader)
+* Star the repository to help visibility
+* Report bugs or request features via [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose)
+* Share feedback in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions)
+* Leave a review on the [Google Play Store](https://play.google.com/store/apps/details?id=com.aryan.reader)
+* Tell a friend
+
+## License
+
+Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See the [LICENSE](LICENSE) file.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 8d29580..0c234e9 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -9,7 +9,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
- id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
+ alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.ksp)
id("com.diffplug.spotless") version "8.2.1"
alias(libs.plugins.kover)
@@ -50,15 +50,15 @@ kotlin {
}
android {
- namespace = "com.aryan.reader"
+ namespace = "org.dueattendant149.bookreader"
compileSdk = 36
defaultConfig {
- applicationId = "com.aryan.reader"
+ applicationId = "org.dueattendant149.bookreader"
minSdk = 26
targetSdk = 35
- versionCode = 53
- versionName = "1.0.49"
+ versionCode = 54
+ versionName = "1.0.50"
resourceConfigurations += configuredAppLocaleTags()
.map { it.toAndroidResourceConfiguration() }
@@ -171,6 +171,7 @@ android {
testOptions {
unitTests.isReturnDefaultValues = true
unitTests.all {
+ it.maxHeapSize = "4g"
it.jvmArgs("-Xss2m")
}
}
@@ -243,8 +244,6 @@ dependencies {
ksp(libs.androidx.room.compiler)
- implementation("androidx.compose.material:material-icons-extended:1.7.8")
-
implementation("androidx.appcompat:appcompat:1.7.1")
//noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break)
diff --git a/app/src/androidTest/assets/epub/reader_test_book.epub b/app/src/androidTest/assets/epub/reader_test_book.epub
new file mode 100644
index 0000000..3858f26
Binary files /dev/null and b/app/src/androidTest/assets/epub/reader_test_book.epub differ
diff --git a/app/src/androidTest/fixtures/epub/README.md b/app/src/androidTest/fixtures/epub/README.md
new file mode 100644
index 0000000..01b7040
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/README.md
@@ -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`
diff --git a/app/src/androidTest/fixtures/epub/build_reader_test_book.py b/app/src/androidTest/fixtures/epub/build_reader_test_book.py
new file mode 100644
index 0000000..f5ab2fb
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/build_reader_test_book.py
@@ -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()
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/META-INF/container.xml b/app/src/androidTest/fixtures/epub/reader_test_book/META-INF/container.xml
new file mode 100644
index 0000000..fe5cbeb
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/META-INF/container.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-01.xhtml b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-01.xhtml
new file mode 100644
index 0000000..d17d58f
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-01.xhtml
@@ -0,0 +1,26 @@
+
+
+
+
+ Chapter One
+
+
+
+
+ Chapter One: Stable Opening
+ This EPUB is intentionally plain so Android UI tests can rely on stable text, stable IDs, and stable chapter order.
+ POSITION_TARGET_ALPHA appears near the start of chapter one. Use this marker for first-position and restore-position checks.
+ HIGHLIGHT_TARGET_BRAVO is a short highlight target. It is surrounded by ordinary words so selection handles have context.
+ CFI_TARGET_CHARLIE sits inside a paragraph with a fixed element id. It can be used for CFI and locator assertions.
+ Chapter one filler paragraph 01 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 02 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 03 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 04 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 05 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 06 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 07 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 08 keeps the document tall enough for scroll and pagination tests.
+ END_OF_CHAPTER_ONE_MARKER
+
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-02.xhtml b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-02.xhtml
new file mode 100644
index 0000000..c5acd90
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-02.xhtml
@@ -0,0 +1,25 @@
+
+
+
+
+ Chapter Two
+
+
+
+
+ Chapter Two: Search And Bookmarks
+ SEARCH_TARGET_DELTA appears exactly once in the book. Use it for search result navigation.
+ BOOKMARK_TARGET_ECHO is positioned near the top of chapter two for bookmark add, list, and return flows.
+ POSITION_TARGET_FOXTROT is lower in chapter two and is useful for persistence tests after a chapter change.
+ Chapter two filler paragraph 01 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 02 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 03 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 04 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 05 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 06 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 07 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 08 provides enough height for gesture based navigation.
+ END_OF_CHAPTER_TWO_MARKER
+
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-03.xhtml b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-03.xhtml
new file mode 100644
index 0000000..73b68a0
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-03.xhtml
@@ -0,0 +1,24 @@
+
+
+
+
+ Chapter Three
+
+
+
+
+ Chapter Three: Annotation Targets
+ ANNOTATION_TARGET_GOLF is reserved for tests that verify highlight or note persistence across app restarts.
+ CFI_TARGET_HOTEL is a second fixed CFI target in a later chapter.
+ The following SVG is local to the EPUB and can be used later for image rendering checks.
+
+
+ Local SVG image for EPUB resource resolution.
+
+ Chapter three filler paragraph 01 rounds out the fixture.
+ Chapter three filler paragraph 02 rounds out the fixture.
+ Chapter three filler paragraph 03 rounds out the fixture.
+ END_OF_CHAPTER_THREE_MARKER
+
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/images/fixture-diagram.svg b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/images/fixture-diagram.svg
new file mode 100644
index 0000000..730dbff
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/images/fixture-diagram.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/nav.xhtml b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/nav.xhtml
new file mode 100644
index 0000000..14a8470
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/nav.xhtml
@@ -0,0 +1,24 @@
+
+
+
+
+ Reader Android UI Test Book
+
+
+
+
+
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/package.opf b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/package.opf
new file mode 100644
index 0000000..7bdfd8f
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/package.opf
@@ -0,0 +1,23 @@
+
+
+
+ urn:uuid:reader-android-ui-test-epub
+ Reader Android UI Test Book
+ Reader Test Fixtures
+ en
+ 2026-01-01T00:00:00Z
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/styles/reader-test.css b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/styles/reader-test.css
new file mode 100644
index 0000000..5799725
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/styles/reader-test.css
@@ -0,0 +1,21 @@
+body {
+ font-family: serif;
+ line-height: 1.5;
+}
+
+h1 {
+ font-size: 1.4em;
+}
+
+p {
+ margin: 0 0 1em 0;
+}
+
+.fixture-note {
+ border-left: 0.25em solid #4a90e2;
+ padding-left: 0.75em;
+}
+
+.target {
+ font-weight: bold;
+}
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/mimetype b/app/src/androidTest/fixtures/epub/reader_test_book/mimetype
new file mode 100644
index 0000000..403c4f0
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/mimetype
@@ -0,0 +1 @@
+application/epub+zip
diff --git a/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt b/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt
deleted file mode 100644
index d7fd42a..0000000
--- a/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt
+++ /dev/null
@@ -1,146 +0,0 @@
-// AppNavigationTest.kt
-package com.aryan.reader
-
-import android.net.Uri
-import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
-import androidx.compose.material3.windowsizeclass.WindowSizeClass
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.unit.DpSize
-import androidx.compose.ui.unit.dp
-import androidx.navigation.compose.ComposeNavigator
-import androidx.navigation.testing.TestNavHostController
-import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.aryan.reader.epub.EpubBook
-import kotlinx.coroutines.flow.MutableStateFlow
-import org.junit.Assert.assertEquals
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class AppNavigationTest {
-
- @get:Rule
- val composeTestRule = createComposeRule()
-
- private lateinit var navController: TestNavHostController
- private val fakeUiState = MutableStateFlow(ReaderScreenState())
-
- // Mock ViewModel that uses the fake state
- private val fakeViewModel: MainViewModel = object : MainViewModel(
- ApplicationProvider.getApplicationContext()
- ) {
- override val uiState = fakeUiState
- override fun clearSelectedFile() {
- fakeUiState.value = fakeUiState.value.copy(
- selectedFileType = null,
- selectedPdfUri = null,
- selectedEpubBook = null
- )
- }
- }
-
- @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
- @Before
- fun setup() {
- composeTestRule.setContent {
- navController = TestNavHostController(LocalContext.current)
- navController.navigatorProvider.addNavigator(ComposeNavigator())
- AppNavigation(
- navController = navController,
- windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(400.dp, 800.dp)),
- viewModel = fakeViewModel
- )
- }
- }
-
- @Test
- fun appNavigation_defaultStartDestination_isMainRoute() {
- val currentRoute = navController.currentBackStackEntry?.destination?.route
- assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
- }
-
- @Test
- fun appNavigation_whenPdfSelected_navigatesToPdfViewer() {
- // Trigger state change
- fakeUiState.value = ReaderScreenState(
- selectedFileType = FileType.PDF,
- selectedPdfUri = Uri.parse("content://dummy.pdf")
- )
-
- // Let compose recompose and run LaunchedEffect
- composeTestRule.waitForIdle()
-
- val currentRoute = navController.currentBackStackEntry?.destination?.route
- assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
- }
-
- @Test
- fun appNavigation_whenPptxSelected_navigatesToPdfViewer() {
- fakeUiState.value = ReaderScreenState(
- selectedFileType = FileType.PPTX,
- selectedPdfUri = Uri.parse("content://dummy.pptx")
- )
-
- composeTestRule.waitForIdle()
-
- val currentRoute = navController.currentBackStackEntry?.destination?.route
- assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
- }
-
- @Test
- fun appNavigation_whenEpubSelected_navigatesToEpubReader() {
- // Trigger state change
- fakeUiState.value = ReaderScreenState(
- selectedFileType = FileType.EPUB,
- selectedEpubBook = EpubBook(
- fileName = "dummy.epub",
- title = "Dummy Book",
- author = "Author",
- language = "en",
- coverImage = null
- )
- )
-
- composeTestRule.waitForIdle()
-
- val currentRoute = navController.currentBackStackEntry?.destination?.route
- assertEquals(AppDestinations.EPUB_READER_ROUTE, currentRoute)
- }
-
- @Test
- fun appNavigation_whenFileCleared_navigatesBackToMain() {
- // First, navigate to PDF viewer
- fakeUiState.value = ReaderScreenState(
- selectedFileType = FileType.PDF,
- selectedPdfUri = Uri.parse("content://dummy.pdf")
- )
- composeTestRule.waitForIdle()
- assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route)
-
- // Then, trigger the clear action (simulating onNavigateBack)
- fakeViewModel.clearSelectedFile()
- composeTestRule.waitForIdle()
-
- val currentRoute = navController.currentBackStackEntry?.destination?.route
- assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
- }
-
- @Test
- fun appNavigation_whenUnknownFileTypeSelected_navigatesBackToMain() {
- fakeUiState.value = ReaderScreenState(
- selectedFileType = FileType.PDF,
- selectedPdfUri = Uri.parse("content://dummy.pdf")
- )
- composeTestRule.waitForIdle()
- assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route)
-
- fakeUiState.value = ReaderScreenState(selectedFileType = FileType.UNKNOWN)
- composeTestRule.waitForIdle()
-
- assertEquals(AppDestinations.MAIN_ROUTE, navController.currentBackStackEntry?.destination?.route)
- }
-}
diff --git a/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt b/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt
deleted file mode 100644
index a310b2d..0000000
--- a/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt
+++ /dev/null
@@ -1,204 +0,0 @@
-// app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt
-package com.aryan.reader.epubreader
-
-import android.content.Context
-import timber.log.Timber
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.FontWeight
-import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.aryan.reader.SearchResult
-import com.aryan.reader.epub.EpubBook
-import com.aryan.reader.epub.EpubChapter
-import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.withContext
-import org.jsoup.Jsoup
-import org.junit.After
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import java.io.File
-import kotlin.math.max
-import kotlin.math.min
-
-@RunWith(AndroidJUnit4::class)
-class EpubReaderLogicTest {
-
- private lateinit var context: Context
- private lateinit var testDir: File
- private lateinit var mockEpubBook: EpubBook
-
- @Before
- fun setup() {
- context = ApplicationProvider.getApplicationContext()
- testDir = File(context.cacheDir, "test_epub").apply { mkdirs() }
-
- // Create dummy chapter files
- val chapter1File = File(testDir, "chapter1.html")
- chapter1File.writeText("A simple Test case.
")
-
- val chapter2File = File(testDir, "chapter2.html")
- chapter2File.writeText("Another test case here.
The word Test appears twice.
")
-
- mockEpubBook = EpubBook(
- fileName = "test.epub",
- title = "Test Book",
- author = "Tester",
- language = "en",
- coverImage = null,
- extractionBasePath = testDir.absolutePath,
- chapters = listOf(
- EpubChapter(
- chapterId = "ch1",
- absPath = chapter1File.absolutePath,
- title = "Chapter 1",
- htmlFilePath = "chapter1.html",
- plainTextContent = "",
- htmlContent = ""
- ),
- EpubChapter(
- chapterId = "ch2",
- absPath = chapter2File.absolutePath,
- title = "Chapter 2",
- htmlFilePath = "chapter2.html",
- plainTextContent = "",
- htmlContent = ""
- )
- )
- )
- }
-
- @After
- fun tearDown() {
- testDir.deleteRecursively()
- }
-
- private suspend fun searchEpub(book: EpubBook, query: String): List {
- val TAG = "EpubReaderLogicTest"
- Timber.d("Starting search for query: '$query'")
- return withContext(Dispatchers.IO) {
- val results = mutableListOf()
- book.chapters.forEachIndexed { chapterIndex, chapter ->
- try {
- val fullPath = "${book.extractionBasePath}/${chapter.htmlFilePath}"
- Timber.d("Chapter ${chapterIndex + 1}: Checking path '$fullPath'")
- val htmlFile = File(fullPath)
- if (!htmlFile.exists()) {
- Timber.e("File does not exist: $fullPath")
- return@forEachIndexed
- }
-
- val doc = Jsoup.parse(htmlFile, "UTF-8")
- val bodyChildren = doc.body().children().toList()
- val chunks = bodyChildren.chunked(20)
-
- chunks.forEachIndexed { chunkIndex, chunkOfElements ->
- val chunkHtml = chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
- val content = Jsoup.parse(chunkHtml).text()
- var lastIndex = -1
-
- while (true) {
- lastIndex = content.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true)
- if (lastIndex == -1) break
-
- Timber.d("Found potential match for '$query' at index $lastIndex.")
- val isWordStart = lastIndex == 0 || !content[lastIndex - 1].isLetterOrDigit()
- Timber.d("Is it a word start? -> $isWordStart")
- if (isWordStart) {
- Timber.d("Match is a word start. Adding to results.")
- val snippetStart = max(0, lastIndex - 35)
- val snippetEnd = min(content.length, lastIndex + query.length + 35)
- val rawSnippet = content.substring(snippetStart, snippetEnd)
- val annotatedSnippet = buildAnnotatedString {
- append(rawSnippet)
- val highlightStart = content.indexOf(query, lastIndex, ignoreCase = true) - snippetStart
- val highlightEnd = highlightStart + query.length
- addStyle(
- style = SpanStyle(fontWeight = FontWeight.Bold),
- start = highlightStart,
- end = highlightEnd
- )
- }
- results.add(
- SearchResult(
- locationInSource = chapterIndex,
- locationTitle = chapter.title,
- snippet = annotatedSnippet,
- query = query,
- occurrenceIndexInLocation = results.count { it.locationInSource == chapterIndex },
- chunkIndex = chunkIndex
- )
- )
- }
- }
- }
- } catch (e: Exception) {
- Timber.e("Error during search in chapter ${chapter.title}", e)
- throw e
- }
- }
- Timber.d("Search finished. Total results found: ${results.size}")
- results
- }
- }
-
- @Test
- fun search_findsCorrectResults() = runBlocking {
- val results = searchEpub(mockEpubBook, "case")
- assertThat(results).hasSize(2)
- assertThat(results.count { it.locationTitle == "Chapter 1" }).isEqualTo(1)
- assertThat(results.count { it.locationTitle == "Chapter 2" }).isEqualTo(1)
- }
-
- @Test
- fun search_isCaseInsensitive() = runBlocking {
- val results = searchEpub(mockEpubBook, "test")
- assertThat(results).hasSize(3)
- assertThat(results[0].locationTitle).isEqualTo("Chapter 1")
- assertThat(results[1].locationTitle).isEqualTo("Chapter 2")
- assertThat(results[2].locationTitle).isEqualTo("Chapter 2")
- }
-
- @Test
- fun search_noResultsFound() = runBlocking {
- val results = searchEpub(mockEpubBook, "nonexistent")
- assertThat(results).isEmpty()
- }
-
- @Test
- fun search_createsCorrectSnippetHighlight() = runBlocking {
- val query = "Test"
- mockEpubBook.chapters.first()
- val content = "A simple Test case."
- val annotatedString = buildAnnotatedStringWithHighlight(content, query)
-
- val spanStyles = annotatedString.spanStyles
- assertThat(spanStyles).hasSize(1)
-
- val style = spanStyles.first().item
- assertThat(style.fontWeight).isEqualTo(FontWeight.Bold)
-
- val start = spanStyles.first().start
- val end = spanStyles.first().end
- assertThat(annotatedString.substring(start, end)).isEqualTo(query)
- }
-
- @Suppress("SameParameterValue")
- private fun buildAnnotatedStringWithHighlight(content: String, query: String): AnnotatedString {
- return buildAnnotatedString {
- append(content)
- val highlightStart = content.indexOf(query, ignoreCase = true)
- if (highlightStart != -1) {
- addStyle(
- style = SpanStyle(fontWeight = FontWeight.Bold),
- start = highlightStart,
- end = highlightStart + query.length
- )
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModelTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModelTest.kt
deleted file mode 100644
index c839650..0000000
--- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModelTest.kt
+++ /dev/null
@@ -1,304 +0,0 @@
-// PaginatedReaderViewModelTest.kt
-package com.aryan.reader.paginatedreader
-
-import android.content.Context
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshots.Snapshot
-import androidx.compose.ui.text.TextMeasurer
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.unit.Constraints
-import androidx.compose.ui.unit.Density
-import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.aryan.reader.SearchResult
-import com.aryan.reader.epub.EpubBook
-import com.aryan.reader.epub.EpubChapter
-import com.aryan.reader.paginatedreader.data.BookCacheDao
-import com.aryan.reader.paginatedreader.data.BookCacheDatabase
-import com.aryan.reader.paginatedreader.data.BookProcessingWorker
-import com.google.common.truth.Truth.assertThat
-import io.mockk.coEvery
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.mockkObject
-import io.mockk.unmockkAll
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.emptyFlow
-import kotlinx.coroutines.test.advanceUntilIdle
-import kotlinx.coroutines.test.runTest
-import org.junit.After
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-private class FakePaginator(
- initiallyLoading: Boolean,
- initialPageCount: Int,
- initialGeneration: Int
-) : IPaginator {
- override var isLoading by mutableStateOf(initiallyLoading)
- override var totalPageCount by mutableIntStateOf(initialPageCount)
- override var generation by mutableIntStateOf(initialGeneration)
- override val pageShiftRequest: Flow = emptyFlow()
-
- var lastNavigatedHref: String? = null
- var lastNavigatedChapter: String? = null
-
- override fun getPageContent(pageIndex: Int): Page? = null
- override fun getChapterPathForPage(pageIndex: Int): String? = null
- override fun getPlainTextForChapter(chapterIndex: Int): String? = null
-
- override fun navigateToHref(
- currentChapterAbsPath: String,
- href: String,
- onNavigationComplete: (pageIndex: Int) -> Unit
- ) {
- lastNavigatedChapter = currentChapterAbsPath
- lastNavigatedHref = href
- }
-
- override fun findPageForSearchResult(
- result: SearchResult,
- onResult: (Int) -> Unit
- ) = Unit
-
- // Add stubs for the other missing interface members
- override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (Int) -> Unit) = Unit
- override fun findPageForCfiAndOffset(
- chapterIndex: Int,
- cfi: String,
- charOffset: Int
- ): Int? {
- return null
- }
-
- override fun findChapterIndexForPage(pageIndex: Int): Int? = null
- override fun getCfiForPage(pageIndex: Int): String? = null
- override fun onUserScrolledTo(pageIndex: Int) = Unit
-}
-
-@OptIn(ExperimentalCoroutinesApi::class)
-@RunWith(AndroidJUnit4::class)
-class PaginatedReaderViewModelTest {
-
- @get:Rule
- val mainDispatcherRule = MainDispatcherRule()
-
- private lateinit var viewModel: PaginatedReaderViewModel
- private lateinit var fakePaginator: FakePaginator
-
- @Before
- fun setUp() {
- viewModel = PaginatedReaderViewModel()
- fakePaginator = FakePaginator(
- initiallyLoading = true,
- initialPageCount = 0,
- initialGeneration = 0
- )
- viewModel.setPaginatorForTest(fakePaginator)
- }
-
- @After
- fun tearDown() {
- unmockkAll()
- }
-
- @Test
- fun uiState_reflectsPaginatorInitialState() = runTest {
- val initialState = viewModel.uiState.value
- assertThat(initialState.isLoading).isTrue()
- assertThat(initialState.totalPageCount).isEqualTo(0)
- assertThat(initialState.generation).isEqualTo(0)
- }
-
- @Test
- fun uiState_updatesWhenPaginatorIsLoadingChanges() = runTest {
- assertThat(viewModel.uiState.value.isLoading).isTrue()
-
- fakePaginator.isLoading = false
- Snapshot.sendApplyNotifications()
- advanceUntilIdle()
-
- assertThat(viewModel.uiState.value.isLoading).isFalse()
- }
-
- @Test
- fun uiState_updatesWhenPaginatorTotalPageCountChanges() = runTest {
- assertThat(viewModel.uiState.value.totalPageCount).isEqualTo(0)
-
- fakePaginator.totalPageCount = 123
- Snapshot.sendApplyNotifications()
- advanceUntilIdle()
-
- assertThat(viewModel.uiState.value.totalPageCount).isEqualTo(123)
- }
-
- @Test
- fun uiState_updatesWhenPaginatorGenerationChanges() = runTest {
- assertThat(viewModel.uiState.value.generation).isEqualTo(0)
-
- fakePaginator.generation = 5
- Snapshot.sendApplyNotifications()
- advanceUntilIdle()
-
- assertThat(viewModel.uiState.value.generation).isEqualTo(5)
- }
-
- @Test
- fun onLinkClick_callsPaginatorNavigateToHrefWithCorrectArguments() {
- val currentChapter = "chapter1.xhtml"
- val href = "#section2"
-
- viewModel.onLinkClick(currentChapter, href) {}
-
- assertThat(fakePaginator.lastNavigatedChapter).isEqualTo(currentChapter)
- assertThat(fakePaginator.lastNavigatedHref).isEqualTo(href)
- }
- @Test
- fun initialize_createsARealPaginatorAndUpdateState() = runTest {
- // Arrange
- val viewModel = PaginatedReaderViewModel() // Create a fresh ViewModel
- val context = ApplicationProvider.getApplicationContext()
- val textMeasurer = mockk(relaxed = true)
- val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
- val textStyle = TextStyle.Default
- val density = Density(1f)
- val mathMLRenderer = mockk(relaxed = true)
- val testBook = EpubBook(
- fileName = "test.epub",
- title = "Test Book",
- author = "Test Author",
- language = "en",
- coverImage = null,
- chapters = listOf(
- EpubChapter(
- chapterId = "ch1",
- title = "Chapter 1",
- htmlFilePath = "ch1.html",
- absPath = "/ops/ch1.html",
- htmlContent = "Some content
",
- plainTextContent = "Some content"
- )
- ),
- css = mapOf("/ops/style.css" to "p {color: red;}"),
- extractionBasePath = ""
- )
-
- // Mock dependencies for BookPaginator
- val mockDao = mockk(relaxed = true)
- coEvery { mockDao.getProcessedBook(any()) } returns null // Simulate cache miss
-
- val mockDb = mockk()
- every { mockDb.bookCacheDao() } returns mockDao
-
- mockkObject(BookCacheDatabase.Companion)
- every { BookCacheDatabase.getDatabase(any()) } returns mockDb
-
- mockkObject(BookProcessingWorker.Companion)
- every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
-
- // Pre-condition check
- assertThat(viewModel.uiState.value.isLoading).isTrue()
- assertThat(viewModel.paginator).isNull()
-
- // Act
- viewModel.initialize(
- book = testBook,
- textMeasurer = textMeasurer,
- textConstraints = constraints,
- textStyle = textStyle,
- density = density,
- isDarkTheme = false,
- context = context,
- initialChapterToPaginate = 0,
- mathMLRenderer = mathMLRenderer
- )
- advanceUntilIdle() // Allow coroutines to complete
-
- // Assert
- assertThat(viewModel.paginator).isInstanceOf(BookPaginator::class.java)
- assertThat(viewModel.uiState.value.isLoading).isFalse()
- assertThat(viewModel.uiState.value.totalPageCount).isGreaterThan(0)
- }
-
- @Test
- fun initialize_isIdempotent() = runTest {
- // Arrange
- val viewModel = PaginatedReaderViewModel()
- val context = ApplicationProvider.getApplicationContext()
- val textMeasurer = mockk(relaxed = true)
- val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
- val textStyle = TextStyle.Default
- val density = Density(1f)
- val mathMLRenderer = mockk(relaxed = true)
- val testBook = EpubBook(
- fileName = "test.epub",
- title = "Test Book",
- author = "Test Author",
- language = "en",
- coverImage = null,
- chapters = listOf(
- EpubChapter(
- chapterId = "ch1",
- title = "Chapter 1",
- htmlFilePath = "ch1.html",
- absPath = "/ops/ch1.html",
- htmlContent = "Some content
",
- plainTextContent = "Some content"
- )
- ),
- css = mapOf("/ops/style.css" to "p {color: red;}"),
- extractionBasePath = ""
- )
-
- // Mock dependencies
- val mockDao = mockk(relaxed = true)
- coEvery { mockDao.getProcessedBook(any()) } returns null
- val mockDb = mockk()
- every { mockDb.bookCacheDao() } returns mockDao
- mockkObject(BookCacheDatabase.Companion)
- every { BookCacheDatabase.getDatabase(any()) } returns mockDb
- mockkObject(BookProcessingWorker.Companion)
- every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
-
- // Act
- viewModel.initialize(
- book = testBook,
- textMeasurer = textMeasurer,
- textConstraints = constraints,
- textStyle = textStyle,
- density = density,
- isDarkTheme = false,
- context = context,
- initialChapterToPaginate = 0,
- mathMLRenderer = mathMLRenderer
- )
- advanceUntilIdle()
-
- val firstPaginator = viewModel.paginator
- assertThat(firstPaginator).isNotNull()
-
- // Act again
- viewModel.initialize(
- book = testBook,
- textMeasurer = textMeasurer,
- textConstraints = constraints,
- textStyle = textStyle,
- density = density,
- isDarkTheme = false,
- context = context,
- initialChapterToPaginate = 0,
- mathMLRenderer = mathMLRenderer
- )
- advanceUntilIdle()
-
- // Assert
- val secondPaginator = viewModel.paginator
- assertThat(secondPaginator).isSameInstanceAs(firstPaginator)
- }
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/aryan/reader/pdf/PdfViewerScreenTest.kt b/app/src/androidTest/java/com/aryan/reader/pdf/PdfViewerScreenTest.kt
deleted file mode 100644
index 2c1cf34..0000000
--- a/app/src/androidTest/java/com/aryan/reader/pdf/PdfViewerScreenTest.kt
+++ /dev/null
@@ -1,313 +0,0 @@
-package com.aryan.reader.pdf
-
-import android.Manifest
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import androidx.compose.ui.test.assert
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.assertTextContains
-import androidx.compose.ui.test.hasTestTag
-import androidx.compose.ui.test.hasText
-import androidx.compose.ui.test.junit4.createEmptyComposeRule
-import androidx.compose.ui.test.onAllNodesWithText
-import androidx.compose.ui.test.onNodeWithContentDescription
-import androidx.compose.ui.test.onNodeWithTag
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.onRoot
-import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.performTextInput
-import androidx.compose.ui.test.performTouchInput
-import androidx.compose.ui.test.swipe
-import androidx.core.content.FileProvider
-import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.rules.ActivityScenarioRule
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.rule.GrantPermissionRule
-import com.aryan.reader.MainActivity
-import org.junit.After
-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 PdfViewerScreenTest {
-
- @get:Rule
- val composeTestRule = createEmptyComposeRule()
-
- @get:Rule
- val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
-
- @org.junit.Before
- fun setup() {
- val context = ApplicationProvider.getApplicationContext()
- context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
- .edit()
- .clear()
- .commit()
- }
-
- private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
- return Intent(context, MainActivity::class.java).apply {
- action = Intent.ACTION_VIEW
- data = uri
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- }
- }
-
- private val context: Context = ApplicationProvider.getApplicationContext()
-
- private var currentPdfFile: File? = null
-
- private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
-
- @get:Rule
- val activityRule = ActivityScenarioRule(createPdfViewIntent(context, samplePdfUri))
-
- @After
- fun tearDown() {
- currentPdfFile?.let {
- if (it.exists()) it.delete()
- }
- }
-
- private fun waitForDocumentLoad(pageText: String = "Page 1 of 4") {
- composeTestRule.waitUntil(timeoutMillis = 15_000) {
- composeTestRule
- .onAllNodesWithText(pageText)
- .fetchSemanticsNodes().size == 1
- }
- }
-
- private fun ensurePaginationMode() {
- composeTestRule.onNodeWithContentDescription("More Options").performClick()
- composeTestRule.onNodeWithText("Reading Mode: Paginated").performClick()
- composeTestRule.waitForIdle()
- }
-
- @Test
- fun documentLoadsAndDisplaysCorrectPageCount() {
- waitForDocumentLoad()
- composeTestRule.onNodeWithTag("PageNumberIndicator")
- .assertIsDisplayed()
- }
-
- @Test
- fun tableOfContents_displaysEmptyState() {
- waitForDocumentLoad()
-
- composeTestRule.onNodeWithTag("TocButton").performClick()
-
- composeTestRule.onNodeWithText("Chapters are not available for this book.").assertIsDisplayed()
- }
-
- @Suppress("SameParameterValue")
- private fun copyAssetToCache(context: Context, assetName: String): Uri {
- val uniqueName = "${UUID.randomUUID()}_$assetName"
- val file = File(context.cacheDir, uniqueName)
-
- currentPdfFile = file
-
- if (file.exists()) file.delete()
- context.assets.open(assetName).use { inputStream ->
- file.outputStream().use { outputStream ->
- inputStream.copyTo(outputStream)
- }
- }
- return FileProvider.getUriForFile(
- context,
- "${context.packageName}.provider",
- file
- )
- }
-
- @Test
- fun bookmarkFunctionality_addNavigateAndDelete() {
- waitForDocumentLoad()
-
- ensurePaginationMode()
-
- composeTestRule.onNodeWithText("Page 1 of 4").assertIsDisplayed()
-
- try {
- composeTestRule.onRoot().performTouchInput { swipe(start = this.centerRight, end = this.centerLeft, durationMillis = 300) }
- composeTestRule.onRoot().performClick()
- composeTestRule.waitUntil(5_000) {
- composeTestRule.onAllNodesWithText("Page 2 of 4").fetchSemanticsNodes().isNotEmpty()
- }
- composeTestRule.onNodeWithText("Page 2 of 4").assertIsDisplayed()
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onNodeWithContentDescription("More Options").performClick()
- composeTestRule.onNodeWithText("Bookmark this page").performClick()
- composeTestRule.waitForIdle()
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onRoot().performTouchInput { swipe(start = this.centerRight, end = this.centerLeft, durationMillis = 300) }
- composeTestRule.onRoot().performClick()
- composeTestRule.waitUntil(5_000) {
- composeTestRule.onAllNodesWithText("Page 3 of 4").fetchSemanticsNodes().isNotEmpty()
- }
- composeTestRule.onNodeWithText("Page 3 of 4").assertIsDisplayed()
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onNodeWithTag("TocButton").performClick()
- composeTestRule.onNodeWithTag("BookmarksTab").performClick()
- composeTestRule.waitForIdle()
- composeTestRule.onNodeWithTag("BookmarkItem_1").assertIsDisplayed()
- .assert(hasText("Page 2", substring = true))
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onNodeWithTag("BookmarkItem_1").performClick()
- composeTestRule.waitForIdle()
- composeTestRule.waitUntil(5_000) {
- composeTestRule.onAllNodes(hasTestTag("PageNumberIndicator").and(hasText("Page 2 of 4"))).fetchSemanticsNodes().size == 1
- }
- composeTestRule.onNode(hasTestTag("PageNumberIndicator").and(hasText("Page 2 of 4"))).assertIsDisplayed()
-
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onNodeWithTag("TocButton").performClick()
- composeTestRule.onNodeWithTag("BookmarksTab").performClick()
- composeTestRule.waitForIdle()
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onNodeWithContentDescription("More options for bookmark").performClick()
- composeTestRule.onNodeWithText("Delete").performClick()
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onNodeWithText("Delete", useUnmergedTree = true).performClick()
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onNodeWithTag("BookmarkItem_1").assertDoesNotExist()
- composeTestRule.onNodeWithText("You haven't added any bookmarks yet.").assertIsDisplayed()
- } catch (e: Exception) {
- throw e
- }
- }
-
- @Test
- fun sliderNavigation_opensAndDisplaysCorrectly() {
- waitForDocumentLoad()
-
- composeTestRule.onNodeWithContentDescription("Navigate with slider").performClick()
- composeTestRule.onNodeWithContentDescription("Exit slider navigation").assertIsDisplayed()
- composeTestRule.onNodeWithText("1 / 4").assertIsDisplayed()
- }
-
- @Test
- fun displayMode_switchesToVerticalScroll() {
- waitForDocumentLoad()
-
- // Ensure we are in Pagination mode first to test the switch
- ensurePaginationMode()
-
- // Verify Vertical Scroll component is NOT displayed initially
- composeTestRule.onNodeWithTag("PdfVerticalScroll").assertDoesNotExist()
-
- // Switch to Vertical Scroll
- composeTestRule.onNodeWithContentDescription("More Options").performClick()
- composeTestRule.onNodeWithText("Reading Mode: Vertical scroll").performClick()
-
- composeTestRule.waitForIdle()
-
- // Verify Vertical Scroll component IS displayed
- composeTestRule.onNodeWithTag("PdfVerticalScroll").assertIsDisplayed()
-
- // Switch back to Pagination
- ensurePaginationMode()
-
- // Verify Vertical Scroll component is gone
- composeTestRule.onNodeWithTag("PdfVerticalScroll").assertDoesNotExist()
- }
-
- @Test
- fun search_uiOpensAndAcceptsQuery() {
- waitForDocumentLoad()
-
- // Click search button
- composeTestRule.onNodeWithTag("SearchButton").performClick()
-
- composeTestRule.onNodeWithText("English, Spanish, French, etc.").performClick()
-
- // Verify text field appears
- composeTestRule.onNodeWithTag("SearchTextField").assertIsDisplayed()
-
- // Enter text
- composeTestRule.onNodeWithTag("SearchTextField").performTextInput("test query")
-
- // Verify text exists in the field
- composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("test query")
-
- // Close search
- composeTestRule.onNodeWithContentDescription("Close Search").performClick()
-
- // Verify text field is gone
- composeTestRule.onNodeWithTag("SearchTextField").assertDoesNotExist()
- }
-
- @Test
- fun fullScreen_togglesVisibility() {
- waitForDocumentLoad()
-
- // Click enter full screen button
- composeTestRule.onNodeWithContentDescription("Enter Full Screen").performClick()
-
- // Verify exit full screen button appears
- composeTestRule.onNodeWithContentDescription("Exit Full Screen").assertIsDisplayed()
-
- // Click exit full screen
- composeTestRule.onNodeWithContentDescription("Exit Full Screen").performClick()
-
- // Verify exit button is gone and enter button returns
- composeTestRule.onNodeWithContentDescription("Exit Full Screen").assertDoesNotExist()
- composeTestRule.onNodeWithContentDescription("Enter Full Screen").assertIsDisplayed()
- }
-
- @Test
- fun darkMode_togglesState() {
- waitForDocumentLoad()
-
- // Initial state: Light mode (default from cleared prefs), so button says "Enable Dark Mode"
- composeTestRule.onNodeWithContentDescription("Enable Dark Mode").assertIsDisplayed()
-
- // Toggle On
- composeTestRule.onNodeWithContentDescription("Enable Dark Mode").performClick()
-
- // State changed: Now button says "Disable Dark Mode"
- composeTestRule.onNodeWithContentDescription("Disable Dark Mode").assertIsDisplayed()
-
- // Toggle Off
- composeTestRule.onNodeWithContentDescription("Disable Dark Mode").performClick()
-
- // State changed back
- composeTestRule.onNodeWithContentDescription("Enable Dark Mode").assertIsDisplayed()
- }
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/dueattendant149/bookreader/reader/AppNavigationTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/AppNavigationTest.kt
new file mode 100644
index 0000000..6c41fd6
--- /dev/null
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/AppNavigationTest.kt
@@ -0,0 +1,117 @@
+package org.dueattendant149.bookreader
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.dueattendant149.bookreader.shared.FileType
+import org.dueattendant149.bookreader.shared.ReaderFeatureSurface
+import org.dueattendant149.bookreader.shared.ReaderPlatform
+import org.dueattendant149.bookreader.shared.SharedFileCapabilities
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class AppNavigationTest {
+
+ @Test
+ fun appDestinations_useStableReaderRoutes() {
+ assertThat(AppDestinations.MAIN_ROUTE).isEqualTo("main")
+ assertThat(AppDestinations.PDF_VIEWER_ROUTE).isEqualTo("pdf_viewer")
+ assertThat(AppDestinations.EPUB_READER_ROUTE).isEqualTo("epub_reader")
+ }
+
+ @Test
+ fun androidReaderSurface_mapsPdfBackedTypesToPdfViewer() {
+ val mappedSurfaces = listOf(
+ FileType.PDF,
+ FileType.CBZ,
+ FileType.CBR,
+ FileType.CB7,
+ FileType.CBT,
+ FileType.PPTX
+ ).associateWith { it.readerSurfaceOnAndroid() }
+
+ assertThat(mappedSurfaces).containsExactly(
+ FileType.PDF, ReaderFeatureSurface.PDF_VIEWER,
+ FileType.CBZ, ReaderFeatureSurface.PDF_VIEWER,
+ FileType.CBR, ReaderFeatureSurface.PDF_VIEWER,
+ FileType.CB7, ReaderFeatureSurface.PDF_VIEWER,
+ FileType.CBT, ReaderFeatureSurface.PDF_VIEWER,
+ FileType.PPTX, ReaderFeatureSurface.PDF_VIEWER
+ )
+ }
+
+ @Test
+ fun androidReaderSurface_mapsTextBackedTypesToEpubReader() {
+ val mappedSurfaces = listOf(
+ FileType.EPUB,
+ FileType.MOBI,
+ FileType.MD,
+ FileType.TXT,
+ FileType.HTML,
+ FileType.FB2,
+ FileType.DOCX,
+ FileType.ODT,
+ FileType.FODT
+ ).associateWith { it.readerSurfaceOnAndroid() }
+
+ assertThat(mappedSurfaces).containsExactly(
+ FileType.EPUB, ReaderFeatureSurface.EPUB_READER,
+ FileType.MOBI, ReaderFeatureSurface.EPUB_READER,
+ FileType.MD, ReaderFeatureSurface.EPUB_READER,
+ FileType.TXT, ReaderFeatureSurface.EPUB_READER,
+ FileType.HTML, ReaderFeatureSurface.EPUB_READER,
+ FileType.FB2, ReaderFeatureSurface.EPUB_READER,
+ FileType.DOCX, ReaderFeatureSurface.EPUB_READER,
+ FileType.ODT, ReaderFeatureSurface.EPUB_READER,
+ FileType.FODT, ReaderFeatureSurface.EPUB_READER
+ )
+ }
+
+ @Test
+ fun androidReaderSurface_returnsNullForUnknownFileType() {
+ assertThat(FileType.UNKNOWN.readerSurfaceOnAndroid()).isNull()
+ }
+
+ @Test
+ fun appNavBackInterceptor_onlyHandlesResumedNonReaderBackStackEntries() {
+ assertThat(
+ shouldInterceptAppNavBack(
+ currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
+ hasPreviousBackStackEntry = true,
+ isCurrentEntryResumed = true
+ )
+ ).isTrue()
+ assertThat(
+ shouldInterceptAppNavBack(
+ currentRoute = AppDestinations.MAIN_ROUTE,
+ hasPreviousBackStackEntry = true,
+ isCurrentEntryResumed = true
+ )
+ ).isFalse()
+ assertThat(
+ shouldInterceptAppNavBack(
+ currentRoute = AppDestinations.PDF_VIEWER_ROUTE,
+ hasPreviousBackStackEntry = true,
+ isCurrentEntryResumed = true
+ )
+ ).isFalse()
+ assertThat(
+ shouldInterceptAppNavBack(
+ currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
+ hasPreviousBackStackEntry = false,
+ isCurrentEntryResumed = true
+ )
+ ).isFalse()
+ assertThat(
+ shouldInterceptAppNavBack(
+ currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
+ hasPreviousBackStackEntry = true,
+ isCurrentEntryResumed = false
+ )
+ ).isFalse()
+ }
+
+ private fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? {
+ return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID)
+ }
+}
diff --git a/app/src/androidTest/java/com/dueattendant149/bookreader/reader/HomeRecentFileCardTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/HomeRecentFileCardTest.kt
new file mode 100644
index 0000000..d32e298
--- /dev/null
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/HomeRecentFileCardTest.kt
@@ -0,0 +1,126 @@
+package org.dueattendant149.bookreader
+
+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 org.dueattendant149.bookreader.data.RecentFileItem
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class HomeRecentFileCardTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+
+ @Test
+ fun recentFileCardShowsProgressAndUnavailableState() {
+ val item = recentBook(
+ bookId = "home_unavailable_epub",
+ title = "Unavailable Field Guide",
+ author = "Casey Example",
+ progress = 42f,
+ isAvailable = false
+ )
+
+ setRecentFileCard(item = item)
+
+ composeTestRule.onNodeWithTag("HomeRecentFileCard_home_unavailable_epub").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Unavailable Field Guide").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Casey Example").assertIsDisplayed()
+ composeTestRule.onNodeWithText("42%").assertIsDisplayed()
+ composeTestRule.onAllNodesWithContentDescription(text(R.string.not_available_locally))[0]
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun recentFileCardClickLongClickAndSelectedOverlayWork() {
+ val item = recentBook(
+ bookId = "home_selected_pdf",
+ title = "Selected Position Notes",
+ author = "Morgan Example",
+ progress = 7f
+ )
+ var clicked = false
+ var longClicked = false
+
+ setRecentFileCard(
+ item = item,
+ isSelected = true,
+ onClick = { clicked = true },
+ onLongClick = { longClicked = true }
+ )
+
+ composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_selected))[0]
+ .assertIsDisplayed()
+
+ composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performClick()
+ composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performTouchInput {
+ down(center)
+ advanceEventTime(600)
+ up()
+ }
+
+ assertThat(clicked).isTrue()
+ assertThat(longClicked).isTrue()
+ }
+
+ private fun setRecentFileCard(
+ item: RecentFileItem,
+ isSelected: Boolean = false,
+ isPinned: Boolean = false,
+ onClick: () -> Unit = {},
+ onLongClick: () -> Unit = {}
+ ) {
+ composeTestRule.setContent {
+ MaterialTheme {
+ RecentFileCard(
+ item = item,
+ isSelected = isSelected,
+ isPinned = isPinned,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ isDownloading = false,
+ usePdfFileNameAsDisplayName = false
+ )
+ }
+ }
+ }
+
+ private fun recentBook(
+ bookId: String,
+ title: String,
+ author: String,
+ progress: Float,
+ isAvailable: Boolean = true
+ ): RecentFileItem {
+ return RecentFileItem(
+ bookId = bookId,
+ uriString = "content://home-test/$bookId",
+ type = FileType.EPUB,
+ displayName = "$bookId.epub",
+ timestamp = 1_000L,
+ title = title,
+ author = author,
+ progressPercentage = progress,
+ isRecent = true,
+ isAvailable = isAvailable
+ )
+ }
+
+ private fun text(resId: Int): String {
+ return context.getString(resId)
+ }
+}
diff --git a/app/src/androidTest/java/com/dueattendant149/bookreader/reader/LibraryScreenContentTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/LibraryScreenContentTest.kt
new file mode 100644
index 0000000..1321a82
--- /dev/null
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/LibraryScreenContentTest.kt
@@ -0,0 +1,400 @@
+package org.dueattendant149.bookreader
+
+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 org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.TagEntity
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalFoundationApi::class)
+@RunWith(AndroidJUnit4::class)
+class LibraryScreenContentTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val focusTag = TagEntity(id = "tag_focus", name = "Focus", color = null, createdAt = 1L)
+ private val libraryBooks = listOf(
+ libraryBook(
+ bookId = "pdf_beta",
+ type = FileType.PDF,
+ displayName = "beta.pdf",
+ title = "Beta Manual",
+ author = "Mira Example",
+ timestamp = 3_000L,
+ progress = 84f
+ ),
+ libraryBook(
+ bookId = "epub_gamma",
+ type = FileType.EPUB,
+ displayName = "gamma.epub",
+ title = "Gamma Field Notes",
+ author = "Nora Example",
+ timestamp = 2_000L,
+ progress = 47f,
+ tags = listOf(focusTag)
+ ),
+ libraryBook(
+ bookId = "epub_alpha",
+ type = FileType.EPUB,
+ displayName = "alpha.epub",
+ title = "Alpha Orchard",
+ author = "Zara Example",
+ timestamp = 1_000L,
+ progress = 12f,
+ tags = listOf(focusTag)
+ )
+ )
+
+ @Test
+ fun searchFiltersAndClearRestoresLibraryList() {
+ setLibraryContent()
+
+ composeTestRule.onNodeWithText("Beta Manual").assertIsDisplayed()
+ composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick()
+ composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Gamma")
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty()
+ }
+ assertNoText("Alpha Orchard")
+ assertNoText("Beta Manual")
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick()
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() &&
+ composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty()
+ }
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_search)).performClick()
+ composeTestRule.onNodeWithText(text(R.string.library_title)).assertIsDisplayed()
+ }
+
+ @Test
+ fun searchMatchesAuthorAndTagNames() {
+ setLibraryContent()
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick()
+ composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Zara")
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty()
+ }
+ assertNoText("Gamma Field Notes")
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick()
+ composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Focus")
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() &&
+ composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty()
+ }
+ assertNoText("Beta Manual")
+ }
+
+ @Test
+ fun activeFileTypeFilterChipCanBeCleared() {
+ setLibraryContent(initialFilters = LibraryFilters(fileTypes = setOf(FileType.EPUB)))
+
+ composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed()
+ assertNoText("Beta Manual")
+
+ composeTestRule.onNodeWithText(text(R.string.filter_types, FileType.EPUB.name)).performClick()
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ @Test
+ fun tagAndReadStatusFiltersUseSharedLibraryRules() {
+ setLibraryContent(
+ initialFilters = LibraryFilters(
+ tagIds = setOf(focusTag.id),
+ readStatus = ReadStatusFilter.IN_PROGRESS
+ )
+ )
+
+ composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed()
+ assertNoText("Beta Manual")
+ composeTestRule.onNodeWithText(text(R.string.filter_tags, focusTag.name)).assertIsDisplayed()
+ composeTestRule.onNodeWithText(
+ text(R.string.filter_status, text(ReadStatusFilter.IN_PROGRESS.labelRes))
+ ).assertIsDisplayed()
+ }
+
+ @Test
+ fun clearSelectionReturnsToNormalToolbar() {
+ setLibraryContent()
+
+ composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput {
+ down(center)
+ advanceEventTime(600)
+ up()
+ }
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty()
+ }
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.clear_selection)).performClick()
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText(text(R.string.library_title)).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ @Test
+ fun sortMenuSelectionReordersLibraryItems() {
+ setLibraryContent()
+
+ assertBookAbove("pdf_beta", "epub_alpha")
+
+ composeTestRule.onNodeWithTag("LibrarySortButton").performClick()
+ composeTestRule.onNodeWithText(text(SortOrder.TITLE_ASC.labelRes)).performClick()
+
+ composeTestRule.waitUntil(5_000) {
+ runCatching {
+ bookTop("epub_alpha") < bookTop("pdf_beta")
+ }.getOrDefault(false)
+ }
+ assertBookAbove("epub_alpha", "pdf_beta")
+ }
+
+ @Test
+ fun longPressBookShowsContextualToolbarActions() {
+ var tagClicked = false
+ var pinClicked = false
+ var infoClicked = false
+ var selectAllClicked = false
+ var deleteClicked = false
+
+ setLibraryContent(
+ onTagClick = { tagClicked = true },
+ onPinClick = { pinClicked = true },
+ onInfoClick = { infoClicked = true },
+ onSelectAllClick = { selectAllClicked = true },
+ onDeleteClick = { deleteClicked = true }
+ )
+
+ composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput {
+ down(center)
+ advanceEventTime(600)
+ up()
+ }
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty()
+ }
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_tag)).performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.pin_unpin)).performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.info)).performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.select_all)).performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.action_delete)).performClick()
+
+ assertThat(tagClicked).isTrue()
+ assertThat(pinClicked).isTrue()
+ assertThat(infoClicked).isTrue()
+ assertThat(selectAllClicked).isTrue()
+ assertThat(deleteClicked).isTrue()
+ }
+
+ @Test
+ fun shelvesTabShowsShelfRowsAndNewShelfAction() {
+ val shelf = Shelf(
+ id = "manual_favorites",
+ name = "Manual Favorites",
+ type = ShelfType.MANUAL,
+ books = listOf(libraryBooks[0], libraryBooks[1])
+ )
+ var clickedShelfId: String? = null
+ var longClickedShelfId: String? = null
+ var newShelfClicked = false
+
+ setLibraryContent(
+ initialPage = 1,
+ shelves = listOf(shelf),
+ onShelfClick = { clickedShelfId = it.id },
+ onShelfLongClick = { longClickedShelfId = it.id },
+ onNewShelfClick = { newShelfClicked = true }
+ )
+
+ composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Manual Favorites").assertIsDisplayed()
+
+ composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performClick()
+ composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performTouchInput {
+ down(center)
+ advanceEventTime(600)
+ up()
+ }
+ composeTestRule.onNodeWithTag("LibraryNewShelfFab").performClick()
+
+ assertThat(clickedShelfId).isEqualTo("manual_favorites")
+ assertThat(longClickedShelfId).isEqualTo("manual_favorites")
+ assertThat(newShelfClicked).isTrue()
+ }
+
+ private fun setLibraryContent(
+ initialFilters: LibraryFilters = LibraryFilters(),
+ initialPage: Int = 0,
+ shelves: List = emptyList(),
+ onTagClick: () -> Unit = {},
+ onPinClick: () -> Unit = {},
+ onInfoClick: () -> Unit = {},
+ onSelectAllClick: () -> Unit = {},
+ onDeleteClick: () -> Unit = {},
+ onShelfClick: (Shelf) -> Unit = {},
+ onShelfLongClick: (Shelf) -> Unit = {},
+ onNewShelfClick: () -> Unit = {}
+ ) {
+ val searchQuery = mutableStateOf("")
+ val isSearchActive = mutableStateOf(false)
+ val filters = mutableStateOf(initialFilters)
+ val sortOrder = mutableStateOf(SortOrder.RECENT)
+ val selectedItems = mutableStateOf(emptySet())
+
+ composeTestRule.setContent {
+ val pagerState = rememberPagerState(
+ initialPage = initialPage,
+ pageCount = { 3 }
+ )
+ val visibleBooks = sortFiles(
+ applyLibraryFilters(
+ filterBySearch(libraryBooks, searchQuery.value),
+ filters.value
+ ),
+ sortOrder.value
+ )
+
+ MaterialTheme {
+ LibraryScreenContent(
+ tabTitles = listOf(
+ text(R.string.tab_all_books),
+ text(R.string.tab_shelves),
+ text(R.string.tab_folders)
+ ),
+ recentFiles = visibleBooks,
+ rawLibraryFiles = libraryBooks,
+ shelves = shelves,
+ selectedItems = selectedItems.value,
+ selectedShelves = emptySet(),
+ sortOrder = sortOrder.value,
+ libraryFilters = filters.value,
+ allTags = listOf(focusTag),
+ pinnedLibraryBookIds = emptySet(),
+ pagerState = pagerState,
+ scope = rememberCoroutineScope(),
+ searchQuery = searchQuery.value,
+ isSearchActive = isSearchActive.value,
+ onSearchQueryChange = { searchQuery.value = it },
+ onSearchActiveChange = { isSearchActive.value = it },
+ onSortOrderChange = { sortOrder.value = it },
+ onFilterClick = {},
+ onClearFilters = { filters.value = LibraryFilters() },
+ onRemoveFilter = { filters.value = it },
+ onTagClick = onTagClick,
+ onPinClick = onPinClick,
+ onClearSelection = { selectedItems.value = emptySet() },
+ onItemClick = {},
+ onItemLongClick = { item -> selectedItems.value = setOf(item) },
+ onInfoClick = onInfoClick,
+ onSaveClick = null,
+ onShareClick = null,
+ onDeleteClick = onDeleteClick,
+ onSelectAllClick = onSelectAllClick,
+ onShelfClick = onShelfClick,
+ onShelfLongClick = onShelfLongClick,
+ onClearShelfSelection = {},
+ onDeleteShelves = {},
+ onNewShelfClick = onNewShelfClick,
+ onSelectFileClick = {},
+ onScanNowClick = {},
+ onSyncMetadataClick = {},
+ onSelectSyncFolderClick = {},
+ onEditFolderFiltersClick = { _, _ -> },
+ onDisconnectSyncFolderClick = {},
+ downloadingBookIds = emptySet(),
+ lastFolderScanTime = null,
+ isLoading = false,
+ isRefreshing = false,
+ syncedFolders = emptyList(),
+ onRemoveFolderClick = {},
+ onFolderLocalSyncChange = { _, _, _ -> },
+ onOpdsBookDownloaded = { _, _ -> },
+ onStreamOpdsBook = { _, _ -> },
+ onDeleteCatalogStreams = {},
+ onSettingsClick = {},
+ usePdfFileNameAsDisplayName = false
+ )
+ }
+ }
+ }
+
+ private fun libraryBook(
+ bookId: String,
+ type: FileType,
+ displayName: String,
+ title: String,
+ author: String,
+ timestamp: Long,
+ progress: Float,
+ tags: List = emptyList()
+ ): RecentFileItem {
+ return RecentFileItem(
+ bookId = bookId,
+ uriString = "content://library-test/$bookId",
+ type = type,
+ displayName = displayName,
+ timestamp = timestamp,
+ title = title,
+ author = author,
+ progressPercentage = progress,
+ isRecent = true,
+ isAvailable = true,
+ fileSize = timestamp * 10,
+ tags = tags
+ )
+ }
+
+ private fun assertBookAbove(upperBookId: String, lowerBookId: String) {
+ assertThat(bookTop(upperBookId)).isLessThan(bookTop(lowerBookId))
+ }
+
+ private fun assertNoText(value: String) {
+ assertThat(composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes()).isEmpty()
+ }
+
+ private fun bookTop(bookId: String): Float {
+ return composeTestRule
+ .onNodeWithTag("LibraryBookItem_$bookId")
+ .fetchSemanticsNode()
+ .boundsInRoot
+ .top
+ }
+
+ private fun text(resId: Int, vararg args: Any): String {
+ return context.getString(resId, *args)
+ }
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/epubreader/MainDispatcherRule.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/MainDispatcherRule.kt
similarity index 95%
rename from app/src/androidTest/java/com/aryan/reader/epubreader/MainDispatcherRule.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/MainDispatcherRule.kt
index 1f804a0..f45fabe 100644
--- a/app/src/androidTest/java/com/aryan/reader/epubreader/MainDispatcherRule.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/MainDispatcherRule.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
diff --git a/app/src/androidTest/java/com/aryan/reader/epubreader/ChapterWebViewBridgeTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/ChapterWebViewBridgeTest.kt
similarity index 67%
rename from app/src/androidTest/java/com/aryan/reader/epubreader/ChapterWebViewBridgeTest.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/ChapterWebViewBridgeTest.kt
index cf9996f..9586e38 100644
--- a/app/src/androidTest/java/com/aryan/reader/epubreader/ChapterWebViewBridgeTest.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/ChapterWebViewBridgeTest.kt
@@ -1,13 +1,19 @@
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader.epubreader
import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Rule
import org.junit.Test
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
@OptIn(ExperimentalCoroutinesApi::class)
class ChapterWebViewBridgeTest {
@@ -20,7 +26,8 @@ class ChapterWebViewBridgeTest {
var receivedCfi = ""
val bridge = CfiJsBridge(
onCfiReady = { cfi -> receivedCfi = cfi },
- onCfiForBookmarkReady = {}
+ onCfiForBookmarkReady = {},
+ onScrollFinishedCallback = {}
)
val cfi = "/4/2[chapter1]/6:10"
@@ -39,7 +46,8 @@ class ChapterWebViewBridgeTest {
var receivedCfi = ""
val bridge = CfiJsBridge(
onCfiReady = { cfi -> receivedCfi = cfi },
- onCfiForBookmarkReady = {}
+ onCfiForBookmarkReady = {},
+ onScrollFinishedCallback = {}
)
val invalidJson = "this is not json"
@@ -54,7 +62,8 @@ class ChapterWebViewBridgeTest {
var receivedCfi: String? = null
val bridge = CfiJsBridge(
onCfiReady = { cfi -> receivedCfi = cfi },
- onCfiForBookmarkReady = {}
+ onCfiForBookmarkReady = {},
+ onScrollFinishedCallback = {}
)
val jsonResponse = JSONObject().apply {
@@ -69,29 +78,51 @@ class ChapterWebViewBridgeTest {
}
@Test
- fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() = runTest {
+ fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() {
+ val latch = CountDownLatch(1)
var receivedJson: String? = null
- val bridge = TtsJsBridge(
- scope = this,
- ttsStructuredTextHandler = { json -> receivedJson = json }
- )
- val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]"
- bridge.onStructuredTextExtracted(jsonPayload)
- advanceUntilIdle()
- assertThat(receivedJson).isEqualTo(jsonPayload)
+ val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ try {
+ val bridge = TtsJsBridge(
+ scope = bridgeScope,
+ ttsStructuredTextHandler = { json ->
+ receivedJson = json
+ latch.countDown()
+ }
+ )
+ val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]"
+
+ bridge.onStructuredTextExtracted(jsonPayload)
+
+ assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
+ assertThat(receivedJson).isEqualTo(jsonPayload)
+ } finally {
+ bridgeScope.cancel()
+ }
}
@Test
- fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() = runTest {
+ fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() {
+ val latch = CountDownLatch(1)
var receivedJson: String? = null
- val bridge = TtsJsBridge(
- scope = this,
- ttsStructuredTextHandler = { json -> receivedJson = json }
- )
- val jsonPayload = ""
- bridge.onStructuredTextExtracted(jsonPayload)
- advanceUntilIdle()
- assertThat(receivedJson).isEqualTo("[]")
+ val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ try {
+ val bridge = TtsJsBridge(
+ scope = bridgeScope,
+ ttsStructuredTextHandler = { json ->
+ receivedJson = json
+ latch.countDown()
+ }
+ )
+ val jsonPayload = ""
+
+ bridge.onStructuredTextExtracted(jsonPayload)
+
+ assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
+ assertThat(receivedJson).isEqualTo("[]")
+ } finally {
+ bridgeScope.cancel()
+ }
}
@Test
@@ -153,4 +184,4 @@ class ChapterWebViewBridgeTest {
advanceUntilIdle()
assertThat(receivedContent).isEqualTo(content)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderBookmarkTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderBookmarkTest.kt
similarity index 97%
rename from app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderBookmarkTest.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderBookmarkTest.kt
index 36e67ca..2788375 100644
--- a/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderBookmarkTest.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderBookmarkTest.kt
@@ -1,9 +1,9 @@
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader.epubreader
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.aryan.reader.epub.EpubChapter
+import org.dueattendant149.bookreader.epub.EpubChapter
import com.google.common.truth.Truth.assertThat
import org.json.JSONArray
import org.json.JSONObject
diff --git a/app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderLogicTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderLogicTest.kt
new file mode 100644
index 0000000..131d3bd
--- /dev/null
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderLogicTest.kt
@@ -0,0 +1,108 @@
+package org.dueattendant149.bookreader.epubreader
+
+import android.content.Context
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.dueattendant149.bookreader.epub.EpubBook
+import org.dueattendant149.bookreader.epub.EpubChapter
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+
+@RunWith(AndroidJUnit4::class)
+class EpubReaderLogicTest {
+
+ private lateinit var context: Context
+ private lateinit var testDir: File
+ private lateinit var testBook: EpubBook
+
+ @Before
+ fun setup() {
+ context = ApplicationProvider.getApplicationContext()
+ testDir = File(context.cacheDir, "test_epub_search").apply {
+ deleteRecursively()
+ mkdirs()
+ }
+
+ val chapter1File = File(testDir, "chapter1.html").apply {
+ writeText("A simple Test case.
")
+ }
+ val chapter2File = File(testDir, "chapter2.html").apply {
+ writeText("Another test case here.
The word Test appears twice.
")
+ }
+
+ testBook = EpubBook(
+ fileName = "test.epub",
+ title = "Test Book",
+ author = "Tester",
+ language = "en",
+ coverImage = null,
+ extractionBasePath = testDir.absolutePath,
+ chapters = listOf(
+ EpubChapter(
+ chapterId = "ch1",
+ absPath = chapter1File.absolutePath,
+ title = "Chapter 1",
+ htmlFilePath = "chapter1.html",
+ plainTextContent = "",
+ htmlContent = ""
+ ),
+ EpubChapter(
+ chapterId = "ch2",
+ absPath = chapter2File.absolutePath,
+ title = "Chapter 2",
+ htmlFilePath = "chapter2.html",
+ plainTextContent = "",
+ htmlContent = ""
+ )
+ )
+ )
+ }
+
+ @After
+ fun tearDown() {
+ testDir.deleteRecursively()
+ }
+
+ @Test
+ fun search_findsCorrectResults() = runTest {
+ val results = createEpubSearcher(testBook)("case")
+
+ assertThat(results).hasSize(2)
+ assertThat(results.count { it.locationTitle == "Chapter 1" }).isEqualTo(1)
+ assertThat(results.count { it.locationTitle == "Chapter 2" }).isEqualTo(1)
+ }
+
+ @Test
+ fun search_isCaseInsensitive() = runTest {
+ val results = createEpubSearcher(testBook)("test")
+
+ assertThat(results).hasSize(3)
+ assertThat(results[0].locationTitle).isEqualTo("Chapter 1")
+ assertThat(results[1].locationTitle).isEqualTo("Chapter 2")
+ assertThat(results[2].locationTitle).isEqualTo("Chapter 2")
+ }
+
+ @Test
+ fun search_noResultsFound() = runTest {
+ val results = createEpubSearcher(testBook)("nonexistent")
+
+ assertThat(results).isEmpty()
+ }
+
+ @Test
+ fun search_createsCorrectSnippetHighlight() = runTest {
+ val result = createEpubSearcher(testBook)("Test").first()
+ val boldRanges = result.snippet.spanStyles.filter { it.item == SpanStyle(fontWeight = FontWeight.Bold) }
+
+ assertThat(boldRanges).hasSize(1)
+ val highlight = boldRanges.first()
+ assertThat(result.snippet.text.substring(highlight.start, highlight.end)).isEqualTo("Test")
+ }
+}
diff --git a/app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderScreenTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderScreenTest.kt
new file mode 100644
index 0000000..499bfc3
--- /dev/null
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderScreenTest.kt
@@ -0,0 +1,818 @@
+package org.dueattendant149.bookreader.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 org.dueattendant149.bookreader.FileType
+import org.dueattendant149.bookreader.FileHasher
+import org.dueattendant149.bookreader.MainActivity
+import org.dueattendant149.bookreader.R
+import org.dueattendant149.bookreader.RenderMode
+import org.dueattendant149.bookreader.data.AppDatabase
+import org.dueattendant149.bookreader.data.RecentFileEntity
+import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
+import org.dueattendant149.bookreader.shared.ReaderLocator
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import java.util.UUID
+
+@RunWith(AndroidJUnit4::class)
+class EpubReaderScreenTest {
+
+ @get:Rule
+ val composeTestRule = createEmptyComposeRule()
+
+ private val fixtureAssetName = "epub/reader_test_book.epub"
+ private val fixtureBookTitle = "Reader Android UI Test Book"
+ private val sanitizedFixtureBookTitle = "ReaderAndroidUITestBook"
+ private val targetContext: Context = ApplicationProvider.getApplicationContext()
+ private val instrumentationContext: Context = InstrumentationRegistry.getInstrumentation().context
+ private var currentEpubFile: File? = null
+ private var scenario: ActivityScenario? = null
+ private lateinit var fixtureBookId: String
+
+ @Before
+ fun setup() {
+ clearReaderPrefs()
+ fixtureBookId = requireNotNull(
+ runBlocking {
+ FileHasher.calculateSha256 {
+ instrumentationContext.assets.open(fixtureAssetName)
+ }
+ }
+ )
+ runBlocking {
+ AppDatabase.getDatabase(targetContext)
+ .recentFileDao()
+ .deleteFilePermanently(listOf(fixtureBookId))
+ }
+ }
+
+ @After
+ fun tearDown() {
+ scenario?.close()
+ currentEpubFile?.let {
+ if (it.exists()) it.delete()
+ }
+ }
+
+ @Test
+ fun fixtureEpub_opensReaderAndShowsBookTitle() {
+ launchFixtureReader()
+ waitForReader()
+ showReaderChrome()
+
+ waitForText(fixtureBookTitle)
+ composeTestRule.onNodeWithText(fixtureBookTitle).assertIsDisplayed()
+ assertThat(hasContentDescription(text(R.string.tooltip_search))).isTrue()
+ assertThat(hasContentDescription(text(R.string.content_desc_chapters_menu))).isTrue()
+ }
+
+ @Test
+ fun fixtureEpub_recordsInitialReadingPosition() {
+ launchFixtureReader()
+ waitForReader()
+
+ waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
+ recentFile?.lastChapterIndex != null &&
+ recentFile.locatorBlockIndex != null &&
+ recentFile.locatorCharOffset != null &&
+ recentFile.progressPercentage != null
+ }
+
+ val recentFile = readFixtureRecentFile()
+ assertThat(recentFile?.lastChapterIndex).isAtLeast(0)
+ assertThat(recentFile?.locatorBlockIndex).isAtLeast(0)
+ assertThat(recentFile?.locatorCharOffset).isAtLeast(0)
+ assertThat(recentFile?.progressPercentage).isAtLeast(0f)
+ }
+
+ @Test
+ fun fixtureEpub_restoresSeededReadingPositionWithoutResettingToStart() {
+ launchFixtureReader { fixtureUri ->
+ seedFixtureRecentFile(
+ uriString = fixtureUri.toString(),
+ chapterIndex = 1,
+ blockIndex = 1,
+ charOffset = 0,
+ progress = 45f
+ )
+ }
+ waitForReader()
+
+ waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
+ recentFile?.lastChapterIndex == 1 &&
+ recentFile.locatorBlockIndex == 1 &&
+ recentFile.locatorCharOffset == 0 &&
+ (recentFile.progressPercentage ?: 0f) >= 45f
+ }
+ }
+
+ @Test
+ fun fixtureEpub_drawerShowsFixtureChapters() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+
+ waitForText(text(R.string.tab_chapters))
+ waitForTextContaining("Chapter One")
+ waitForTextContaining("Chapter Two")
+ waitForTextContaining("Chapter Three")
+ }
+
+ @Test
+ fun fixtureEpub_drawerShowsFixtureImageCatalog() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+ clickText(text(R.string.tab_images))
+
+ waitForText("Fixture diagram")
+ waitForTextContaining("Chapter Three")
+ assertThat(hasContentDescription(text(R.string.content_desc_download_image))).isTrue()
+ }
+
+ @Test
+ fun fixtureEpub_searchFindsUniqueFixtureMarker() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.tooltip_search))
+ waitForTag("SearchTextField")
+
+ composeTestRule.onNodeWithTag("SearchTextField").performTextInput("SEARCH_TARGET_DELTA")
+ composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("SEARCH_TARGET_DELTA")
+
+ waitForTag("SearchResultItem_1", timeoutMillis = 20_000)
+ composeTestRule.onNodeWithTag("SearchResultItem_1").assertIsDisplayed()
+ }
+
+ @Test
+ fun fixtureEpub_searchNavigationPositionSurvivesReadingModeSwitches() {
+ launchFixtureReader()
+ waitForReader()
+
+ navigateToFixtureSearchResult("SEARCH_TARGET_DELTA", expectedChapterIndex = 1)
+ clickContentDescription(text(R.string.tooltip_close_search))
+
+ waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
+ recentFile?.lastChapterIndex == 1 &&
+ recentFile.locatorBlockIndex != null &&
+ recentFile.locatorCharOffset != null &&
+ recentFile.progressPercentage != null
+ }
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_change_reading_mode))
+ clickText(text(R.string.menu_reading_mode_paginated))
+ waitForRenderMode(RenderMode.PAGINATED)
+
+ waitForRecentFile(timeoutMillis = 20_000) { recentFile ->
+ recentFile?.lastChapterIndex == 1 &&
+ (recentFile.progressPercentage ?: 0f) > 0f
+ }
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_change_reading_mode))
+ clickText(text(R.string.menu_reading_mode_vertical_webview))
+ waitForRenderMode(RenderMode.VERTICAL_SCROLL)
+
+ waitForRecentFile(timeoutMillis = 20_000) { recentFile ->
+ recentFile?.lastChapterIndex == 1 &&
+ (recentFile.progressPercentage ?: 0f) > 0f
+ }
+ }
+
+ @Test
+ fun fixtureEpub_addsCurrentPageBookmarkPersistsAndDeletes() {
+ launchFixtureReader()
+ waitForReader()
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_bookmark_this_page))
+
+ waitForFixtureBookmarks(timeoutMillis = 20_000) { bookmarksJson ->
+ parseBookmarksJson(bookmarksJson).isNotEmpty()
+ }
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+ clickText(text(R.string.tab_bookmarks))
+ waitForTextContaining("Chapter One")
+ assertThat(hasContentDescription(text(R.string.content_desc_more_options_bookmark))).isTrue()
+
+ clickContentDescription(text(R.string.content_desc_more_options_bookmark))
+ clickText(text(R.string.action_delete))
+ waitForText(text(R.string.dialog_delete_bookmark))
+ clickText(text(R.string.action_delete))
+
+ waitForText(text(R.string.no_bookmarks_yet))
+ waitForFixtureBookmarks(timeoutMillis = 10_000) { bookmarksJson ->
+ parseBookmarksJson(bookmarksJson).isEmpty()
+ }
+ }
+
+ @Test
+ fun fixtureEpub_drawerShowsSeededBookmarkAndSupportsRenameDelete() {
+ seedFixtureBookmark()
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+ clickText(text(R.string.tab_bookmarks))
+
+ waitForText("BOOKMARK_TARGET_ECHO")
+ waitForText("Chapter Two")
+
+ clickContentDescription(text(R.string.content_desc_more_options_bookmark))
+ clickText(text(R.string.action_rename))
+ waitForText(text(R.string.dialog_rename_bookmark))
+ composeTestRule.onNode(hasSetTextAction()).performTextInput("Renamed fixture bookmark")
+ clickText(text(R.string.action_save))
+ waitForText("Renamed fixture bookmark")
+
+ clickContentDescription(text(R.string.content_desc_more_options_bookmark))
+ clickText(text(R.string.action_delete))
+ waitForText(text(R.string.dialog_delete_bookmark))
+ clickText(text(R.string.action_delete))
+ waitForText(text(R.string.no_bookmarks_yet))
+ }
+
+ @Test
+ fun fixtureEpub_drawerShowsSeededAnnotationWithNoteAndFilter() {
+ seedFixtureHighlight()
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+ clickText(text(R.string.tab_annotations))
+
+ waitForText("ANNOTATION_TARGET_GOLF")
+ waitForText("Fixture note survives startup")
+ waitForTextContaining("Chapter Three")
+
+ clickText(text(R.string.filter_with_notes))
+ waitForText("ANNOTATION_TARGET_GOLF")
+ waitForText("Fixture note survives startup")
+ }
+
+ @Test
+ fun fixtureEpub_seededAnnotationSupportsColorNoteAndDeletePersistence() {
+ seedFixtureHighlight()
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+ clickText(text(R.string.tab_annotations))
+
+ waitForText("ANNOTATION_TARGET_GOLF")
+ clickContentDescription(text(R.string.content_desc_options))
+ composeTestRule.onNodeWithTag("HighlightColor_blue").performClick()
+
+ waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
+ parseHighlightsJson(highlightsJson).singleOrNull()?.color == HighlightColor.BLUE
+ }
+
+ clickContentDescription(text(R.string.content_desc_options))
+ clickText(text(R.string.menu_edit_note))
+ waitForText(text(R.string.action_save_note))
+ composeTestRule.onNode(hasSetTextAction())
+ .performTextClearance()
+ composeTestRule.onNode(hasSetTextAction())
+ .performTextInput("Updated fixture note")
+ clickText(text(R.string.action_save_note))
+
+ waitForText("Updated fixture note")
+ waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
+ parseHighlightsJson(highlightsJson).singleOrNull()?.note == "Updated fixture note"
+ }
+
+ clickContentDescription(text(R.string.content_desc_options))
+ clickText(text(R.string.action_delete))
+ waitForText(text(R.string.dialog_delete_highlight))
+ clickText(text(R.string.action_delete))
+
+ waitForText(text(R.string.no_highlights_yet))
+ waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
+ parseHighlightsJson(highlightsJson).isEmpty()
+ }
+ }
+
+ @Test
+ fun fixtureEpub_overflowSwitchesReadingModeAndTogglesPageOptions() {
+ launchFixtureReader()
+ waitForReader()
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_change_reading_mode))
+ clickText(text(R.string.menu_reading_mode_paginated))
+ waitForRenderMode(RenderMode.PAGINATED)
+ composeTestRule.waitForIdle()
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_tap_to_turn_pages))
+ assertReaderSettingEventually(
+ prefsName = "epub_reader_settings",
+ key = "tap_to_navigate_enabled",
+ expected = true
+ )
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_realistic_page_turns))
+ assertReaderSettingEventually(
+ prefsName = "reader_prefs",
+ key = "page_turn_animation_enabled",
+ expected = true
+ )
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_keep_screen_on))
+ assertReaderSettingEventually(
+ prefsName = "reader_prefs",
+ key = "keep_screen_on_enabled",
+ expected = true
+ )
+ }
+
+ @Test
+ fun fixtureEpub_visualOptionsSheetPersistsProgressPosition() {
+ launchFixtureReader()
+ waitForReader()
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_visual_options))
+
+ waitForText(text(R.string.visual_options_system_ui))
+ waitForText(text(R.string.visual_options_progress_bar))
+ waitForText(text(R.string.visual_options_progress_bar_position))
+ clickText(text(R.string.label_top))
+
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
+ .getInt("reader_page_info_position", 0) == 1
+ }
+ }
+
+ @Test
+ fun fixtureEpub_formatPanelShowsControlsAndPersistsLocalFontSize() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_text_formatting))
+ waitForText(text(R.string.section_font_alignment))
+ waitForText(text(R.string.section_layout_spacing))
+ waitForText(text(R.string.label_font_size))
+ waitForText(text(R.string.label_line_height))
+ waitForText(text(R.string.label_paragraph_gap))
+ waitForText(text(R.string.label_image_size))
+ waitForText(text(R.string.label_horizontal_margin))
+ waitForText(text(R.string.label_vertical_margin))
+
+ composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_select_mode))[0]
+ .performClick()
+ clickText(text(R.string.format_local))
+
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
+ .getBoolean("format_is_local_$fixtureBookId", false)
+ }
+
+ composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_increase))[0]
+ .performClick()
+
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
+ .getFloat("local_font_size_$fixtureBookId", 1.0f) > 1.0f
+ }
+ }
+
+ @Test
+ fun fixtureEpub_fontSelectionSheetPersistsFontFamily() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_text_formatting))
+ waitForText(text(R.string.section_font_alignment))
+ clickContentDescription(text(R.string.content_desc_select_font_family))
+
+ waitForText(text(R.string.select_font))
+ waitForText(text(R.string.tab_presets))
+ waitForText(text(R.string.tab_imported))
+ clickText("Lato")
+
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
+ .getString("reader_font_family", "original") == "lato"
+ }
+ }
+
+ @Test
+ fun fixtureEpub_themePanelShowsThemesAndPersistsSelection() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.tooltip_theme_desc))
+
+ waitForText(text(R.string.reading_themes))
+ waitForText(text(R.string.theme_solid_colors))
+ waitForText("Light")
+ waitForText("Dark")
+ clickText("Sepia")
+
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ targetContext.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+ .getString(PREF_READER_THEME, "system") == "sepia"
+ }
+ }
+
+ @Test
+ fun fixtureEpub_drawerShowsEmptyBookmarkAndAnnotationStates() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+ clickText(text(R.string.tab_bookmarks))
+ waitForText(text(R.string.no_bookmarks_yet))
+
+ clickText(text(R.string.tab_annotations))
+ waitForText(text(R.string.no_highlights_yet))
+ }
+
+ @Test
+ fun fixtureEpub_searchCanClearAndClose() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.tooltip_search))
+ waitForTag("SearchTextField")
+
+ composeTestRule.onNodeWithTag("SearchTextField")
+ .performTextInput("SEARCH_TARGET_DELTA")
+ waitForTextContaining("SEARCH_TARGET_DELTA")
+
+ clickContentDescription(text(R.string.tooltip_clear_search))
+ waitForNoContentDescription(text(R.string.tooltip_clear_search))
+
+ clickContentDescription(text(R.string.tooltip_close_search))
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ hasContentDescription(text(R.string.tooltip_search))
+ }
+ }
+
+ @Test
+ fun fixtureEpub_dictionarySettingsShowsLookupSections() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_dictionary_settings))
+
+ waitForText(text(R.string.dict_lookup_settings))
+ waitForText(text(R.string.tooltip_dictionary))
+ waitForText(text(R.string.dict_translate))
+ waitForText(text(R.string.tooltip_search))
+ }
+
+ @Test
+ fun fixtureEpub_ttsReplacementSheetShowsGlobalAndBookScopes() {
+ launchFixtureReader()
+ waitForReader()
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_tts_settings))
+ clickText(text(R.string.menu_tts_word_replacements))
+
+ waitForText(text(R.string.menu_tts_word_replacements))
+ waitForText(fixtureBookTitle)
+ waitForText(text(R.string.tts_replacements_tab_global))
+ waitForText(text(R.string.tts_replacements_tab_this_book))
+ waitForText(text(R.string.tts_replacements_enable))
+ }
+
+ private fun launchFixtureReader(beforeLaunch: (Uri) -> Unit = {}) {
+ val fixtureUri = copyAndroidTestAssetToCache(fixtureAssetName)
+ beforeLaunch(fixtureUri)
+ scenario = ActivityScenario.launch(createEpubViewIntent(fixtureUri))
+ }
+
+ private fun clearReaderPrefs() {
+ listOf(
+ "epub_reader_settings",
+ "epub_reader_bookmarks",
+ "reader_prefs"
+ ).forEach { prefsName ->
+ targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
+ .edit()
+ .clear()
+ .commit()
+ }
+ targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
+ .edit()
+ .putString("render_mode", "VERTICAL_SCROLL")
+ .commit()
+ }
+
+ private fun text(resId: Int): String = targetContext.getString(resId)
+
+ private fun seedFixtureBookmark() {
+ val bookmark = org.json.JSONObject().apply {
+ put("cfi", "android-locator:1:1:0")
+ put("chapterTitle", "Chapter Two")
+ put("label", org.json.JSONObject.NULL)
+ put("snippet", "BOOKMARK_TARGET_ECHO")
+ put("pageInChapter", 1)
+ put("totalPagesInChapter", 3)
+ put("chapterIndex", 1)
+ put(
+ "locator",
+ org.json.JSONObject().apply {
+ put("chapterIndex", 1)
+ put("chapterId", "chapter-02")
+ put("pageIndex", 0)
+ put("blockIndex", 1)
+ put("charOffset", 0)
+ put("textQuote", "BOOKMARK_TARGET_ECHO")
+ put("cfi", "android-locator:1:1:0")
+ }
+ )
+ }
+
+ targetContext.getSharedPreferences("epub_reader_bookmarks", Context.MODE_PRIVATE)
+ .edit()
+ .putStringSet("bookmarks_cfi_$sanitizedFixtureBookTitle", setOf(bookmark.toString()))
+ .commit()
+ }
+
+ private fun seedFixtureHighlight() {
+ val highlight = UserHighlight(
+ id = "fixture_annotation_golf",
+ cfi = "android-locator:2:1:0",
+ text = "ANNOTATION_TARGET_GOLF",
+ color = HighlightColor.GREEN,
+ chapterIndex = 2,
+ note = "Fixture note survives startup",
+ locator = ReaderLocator(
+ chapterIndex = 2,
+ chapterId = "chapter-03",
+ blockIndex = 1,
+ charOffset = 0,
+ textQuote = "ANNOTATION_TARGET_GOLF",
+ cfi = "android-locator:2:1:0"
+ )
+ )
+
+ targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
+ .edit()
+ .putString("highlights_data_$sanitizedFixtureBookTitle", highlightsToJson(listOf(highlight)))
+ .commit()
+ }
+
+ private fun seedFixtureRecentFile(
+ uriString: String,
+ chapterIndex: Int,
+ blockIndex: Int,
+ charOffset: Int,
+ progress: Float
+ ) {
+ val now = System.currentTimeMillis()
+ runBlocking {
+ AppDatabase.getDatabase(targetContext)
+ .recentFileDao()
+ .insertOrUpdateFile(
+ RecentFileEntity(
+ bookId = fixtureBookId,
+ uriString = uriString,
+ type = FileType.EPUB,
+ displayName = fixtureBookTitle,
+ timestamp = now,
+ coverImagePath = null,
+ title = fixtureBookTitle,
+ author = "Fixture Author",
+ lastChapterIndex = chapterIndex,
+ lastPage = null,
+ lastPositionCfi = "android-locator:$chapterIndex:$blockIndex:$charOffset",
+ progressPercentage = progress,
+ isRecent = true,
+ isAvailable = true,
+ lastModifiedTimestamp = now,
+ isDeleted = false,
+ locatorBlockIndex = blockIndex,
+ locatorCharOffset = charOffset,
+ bookmarks = null,
+ sourceFolderUri = null,
+ isReflowPreferred = false,
+ customName = null,
+ highlights = null,
+ fileSize = 0L,
+ fileContentModifiedTimestamp = 0L,
+ seriesName = null,
+ seriesIndex = null,
+ description = null,
+ folderTextMetadataParsed = false,
+ folderCoverMetadataParsed = false,
+ originalTitle = fixtureBookTitle,
+ originalAuthor = "Fixture Author",
+ originalSeriesName = null,
+ originalSeriesIndex = null,
+ originalDescription = null
+ )
+ )
+ }
+ }
+
+ private fun createEpubViewIntent(uri: Uri): Intent {
+ return Intent(targetContext, MainActivity::class.java).apply {
+ action = Intent.ACTION_VIEW
+ setDataAndType(uri, "application/epub+zip")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ }
+
+ private fun copyAndroidTestAssetToCache(assetName: String): Uri {
+ val file = File(targetContext.cacheDir, "${UUID.randomUUID()}_reader_test_book.epub")
+ currentEpubFile = file
+
+ instrumentationContext.assets.open(assetName).use { inputStream ->
+ file.outputStream().use { outputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ }
+
+ return FileProvider.getUriForFile(
+ targetContext,
+ "${targetContext.packageName}.provider",
+ file
+ )
+ }
+
+ private fun navigateToFixtureSearchResult(query: String, expectedChapterIndex: Int) {
+ clickReaderControl(text(R.string.tooltip_search))
+ waitForTag("SearchTextField")
+
+ composeTestRule.onNodeWithTag("SearchTextField").performTextInput(query)
+ waitForTag("SearchResultItem_$expectedChapterIndex", timeoutMillis = 20_000)
+ composeTestRule.onNodeWithTag("SearchResultItem_$expectedChapterIndex").performClick()
+ }
+
+ private fun waitForReader() {
+ waitForTag("ReaderContainer", timeoutMillis = 30_000)
+ }
+
+ private fun showReaderChrome() {
+ if (hasAnyReaderControl()) return
+
+ composeTestRule.onRoot().performTouchInput { click(center) }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ hasAnyReaderControl()
+ }
+ }
+
+ private fun clickReaderControl(contentDescription: String) {
+ showReaderChrome()
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ hasContentDescription(contentDescription)
+ }
+ composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick()
+ }
+
+ private fun clickContentDescription(contentDescription: String) {
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ hasContentDescription(contentDescription)
+ }
+ composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick()
+ }
+
+ private fun clickText(value: String) {
+ composeTestRule.waitUntil(timeoutMillis = 10_000) {
+ composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty()
+ }
+ composeTestRule.onAllNodesWithText(value)[0].performClick()
+ }
+
+ private fun openOverflowMenu() {
+ clickReaderControl(text(R.string.content_desc_more_options))
+ }
+
+ private fun hasAnyReaderControl(): Boolean {
+ return hasContentDescription(text(R.string.tooltip_search)) ||
+ hasContentDescription(text(R.string.content_desc_chapters_menu)) ||
+ hasContentDescription(text(R.string.content_desc_more_options))
+ }
+
+ private fun hasContentDescription(contentDescription: String): Boolean {
+ return composeTestRule
+ .onAllNodesWithContentDescription(contentDescription)
+ .fetchSemanticsNodes()
+ .isNotEmpty()
+ }
+
+ private fun waitForTag(tag: String, timeoutMillis: Long = 10_000) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun waitForText(value: String, timeoutMillis: Long = 10_000) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun waitForTextContaining(value: String, timeoutMillis: Long = 10_000) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ composeTestRule.onAllNodesWithText(value, substring = true).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun waitForNoContentDescription(contentDescription: String, timeoutMillis: Long = 5_000) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ composeTestRule
+ .onAllNodesWithContentDescription(contentDescription)
+ .fetchSemanticsNodes()
+ .isEmpty()
+ }
+ }
+
+ private fun waitForRenderMode(expected: RenderMode) {
+ composeTestRule.waitUntil(timeoutMillis = 20_000) {
+ targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
+ .getString("render_mode", RenderMode.VERTICAL_SCROLL.name) == expected.name
+ }
+ }
+
+ private fun assertReaderSettingEventually(
+ prefsName: String,
+ key: String,
+ expected: Boolean
+ ) {
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
+ .getBoolean(key, !expected) == expected
+ }
+ }
+
+ private fun readFixtureRecentFile(): RecentFileEntity? {
+ return runBlocking {
+ AppDatabase.getDatabase(targetContext)
+ .recentFileDao()
+ .getFileByBookId(fixtureBookId)
+ }
+ }
+
+ private fun waitForRecentFile(
+ timeoutMillis: Long = 10_000,
+ predicate: (RecentFileEntity?) -> Boolean
+ ) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ predicate(readFixtureRecentFile())
+ }
+ }
+
+ private fun waitForFixtureBookmarks(
+ timeoutMillis: Long = 10_000,
+ predicate: (String?) -> Boolean
+ ) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ predicate(readFixtureRecentFile()?.bookmarks)
+ }
+ }
+
+ private fun waitForFixtureHighlights(
+ timeoutMillis: Long = 10_000,
+ predicate: (String?) -> Boolean
+ ) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ predicate(readFixtureRecentFile()?.highlights)
+ }
+ }
+
+ private fun parseBookmarksJson(rawJson: String?): Set {
+ return EpubAnnotationSerializer.parseBookmarksJson(rawJson)
+ }
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/tts/MainDispatcherRule.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/MainDispatcherRule.kt
similarity index 94%
rename from app/src/androidTest/java/com/aryan/reader/tts/MainDispatcherRule.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/MainDispatcherRule.kt
index 1b2d937..0545722 100644
--- a/app/src/androidTest/java/com/aryan/reader/tts/MainDispatcherRule.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/epubreader/MainDispatcherRule.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader.tts
+package org.dueattendant149.bookreader.epubreader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/CssParserTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/CssParserTest.kt
similarity index 72%
rename from app/src/androidTest/java/com/aryan/reader/paginatedreader/CssParserTest.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/CssParserTest.kt
index c6fe395..cab1c9e 100644
--- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/CssParserTest.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/CssParserTest.kt
@@ -1,5 +1,5 @@
// CssParserTest.kt
-package com.aryan.reader.paginatedreader
+package org.dueattendant149.bookreader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
@@ -17,49 +17,66 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CssParserTest {
+ private val dummyConstraints = androidx.compose.ui.unit.Constraints()
+ private val baseFontSize = 16f
+ private val density = 1f
+
+ private fun parseTextColor(value: String): Color? {
+ val result = CssParser.parse(
+ cssContent = "p { color: $value; }",
+ cssPath = null,
+ baseFontSizeSp = baseFontSize,
+ density = density,
+ constraints = dummyConstraints,
+ isDarkTheme = false
+ )
+ return result.rules.byTag["p"]
+ ?.firstOrNull()
+ ?.style
+ ?.spanStyle
+ ?.color
+ ?.takeIf { it.isSpecified }
+ }
+
@Test
fun parseColor_handlesNamedColorsCorrectly() {
- assertThat(CssParser.parseColor("red")).isEqualTo(Color.Red)
- assertThat(CssParser.parseColor("black")).isEqualTo(Color.Black)
- assertThat(CssParser.parseColor("transparent")).isEqualTo(Color.Transparent)
+ assertThat(parseTextColor("red")).isEqualTo(Color.Red)
+ assertThat(parseTextColor("black")).isEqualTo(Color.Black)
+ assertThat(parseTextColor("transparent")).isEqualTo(Color.Transparent)
}
@Test
fun parseColor_handles3DigitHexCodes() {
- assertThat(CssParser.parseColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
+ assertThat(parseTextColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
}
@Test
fun parseColor_handles6DigitHexCodes() {
- assertThat(CssParser.parseColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
+ assertThat(parseTextColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
}
@Test
fun parseColor_handles8DigitHexCodes() {
- assertThat(CssParser.parseColor("#80FF00CC")).isEqualTo(Color(0x80FF00CC))
+ assertThat(parseTextColor("#80FF00CC")).isEqualTo(Color(128, 255, 0, 204))
}
@Test
fun parseColor_handlesRgbFunction() {
- assertThat(CssParser.parseColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
+ assertThat(parseTextColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
}
@Test
fun parseColor_handlesRgbaFunction() {
- assertThat(CssParser.parseColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
+ assertThat(parseTextColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
}
@Test
fun parseColor_returnsNullForInvalidInput() {
- assertThat(CssParser.parseColor("not a color")).isNull()
- assertThat(CssParser.parseColor("#12345")).isNull()
- assertThat(CssParser.parseColor("rgb(1,2)")).isNull()
+ assertThat(parseTextColor("not a color")).isNull()
+ assertThat(parseTextColor("#12345")).isNull()
+ assertThat(parseTextColor("rgb(1,2)")).isNull()
}
- private val dummyConstraints = androidx.compose.ui.unit.Constraints()
- private val baseFontSize = 16f
- private val density = 1f
-
@Test
fun parse_handlesSimpleRule() {
val css = "p { color: red; }"
@@ -120,12 +137,12 @@ class CssParserTest {
}
p { color: black; }
""".trimIndent()
- val result = CssParser.parse(css, "/some/path/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
+ val result = CssParser.parse(css, "OEBPS/styles/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.rules.byTag).containsKey("p")
assertThat(result.fontFaces).hasSize(1)
val fontFace = result.fontFaces.first()
assertThat(fontFace.fontFamily).isEqualTo("mycustomfont")
- assertThat(fontFace.src).isEqualTo("/some/fonts/myfont.ttf")
+ assertThat(fontFace.src).isEqualTo("OEBPS/fonts/myfont.ttf")
assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold)
assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal)
}
@@ -190,10 +207,11 @@ class CssParserTest {
val css = "div { border: 2px solid red; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["div"]?.first()?.style?.blockStyle
- assertThat(style?.border).isNotNull()
- assertThat(style?.border?.width).isEqualTo(2.dp)
- assertThat(style?.border?.style).isEqualTo("solid")
- assertThat(style?.border?.color).isEqualTo(Color.Red)
+ val expectedBorder = BorderStyle(width = 2.dp, color = Color.Red, style = "solid")
+ assertThat(style?.borderTop).isEqualTo(expectedBorder)
+ assertThat(style?.borderRight).isEqualTo(expectedBorder)
+ assertThat(style?.borderBottom).isEqualTo(expectedBorder)
+ assertThat(style?.borderLeft).isEqualTo(expectedBorder)
}
@Test
@@ -262,9 +280,70 @@ class CssParserTest {
url("font.ttf") format("truetype");
}
""".trimIndent()
- val result = CssParser.parse(css, "/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
+ val result = CssParser.parse(css, "OEBPS/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.fontFaces).hasSize(1)
- assertThat(result.fontFaces.first().src).isEqualTo("/css/font.otf")
+ assertThat(result.fontFaces.first().src).isEqualTo("OEBPS/css/font.otf")
+ }
+
+ @Test
+ fun parse_handlesNestedMediaAndCalcVariables() {
+ val css = """
+ :root { --gap: 12px; }
+ @media screen and (min-width: 300px) {
+ p { margin-left: calc(var(--gap) + 8px); color: hsl(120 100% 25%); }
+ }
+ """.trimIndent()
+
+ val result = CssParser.parse(
+ css,
+ null,
+ baseFontSize,
+ density,
+ androidx.compose.ui.unit.Constraints(maxWidth = 500),
+ isDarkTheme = false
+ )
+
+ val style = result.rules.byTag["p"]!!.first().style
+ assertThat(style.blockStyle.margin.left).isEqualTo(20.dp)
+ assertThat(style.spanStyle.color).isEqualTo(Color(0, 128, 0))
+ }
+
+ @Test
+ fun parse_preservesBeforeAfterPseudoElementRules() {
+ val css = "p::before { content: 'Note: '; color: red; } p { color: blue; }"
+ val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
+
+ val pseudoRule = result.rules.otherComplex.single { it.pseudoElement == "before" }
+ assertThat(pseudoRule.selector.selector).isEqualTo("p")
+ assertThat(pseudoRule.style.content).isEqualTo("'Note: '")
+ assertThat(pseudoRule.style.spanStyle.color).isEqualTo(Color.Red)
+ assertThat(result.rules.byTag["p"]!!.single().style.spanStyle.color).isEqualTo(Color.Blue)
+ }
+
+ @Test
+ fun parse_handlesModernRgbSlashAlphaAndCssHexAlpha() {
+ assertThat(parseTextColor("rgb(255 0 204 / 50%)")).isEqualTo(Color(255, 0, 204, 128))
+ assertThat(parseTextColor("#ff00cc80")).isEqualTo(Color(255, 0, 204, 128))
+ }
+
+ @Test
+ fun parse_backgroundShorthandExtractsColorAndImage() {
+ val css = "section { background: #ffeecc url('../images/paper.png') repeat; }"
+ val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
+ val style = result.rules.byTag["section"]!!.first().style.blockStyle
+
+ assertThat(style.backgroundColor).isEqualTo(Color(255, 238, 204))
+ assertThat(style.backgroundImage).isEqualTo("../images/paper.png")
+ }
+
+ @Test
+ fun parse_listStyleShorthandExtractsMarkerTypeAndImage() {
+ val css = "ul { list-style: square url('../images/bullet.png') outside; }"
+ val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
+ val style = result.rules.byTag["ul"]!!.first().style.blockStyle
+
+ assertThat(style.listStyleType).isEqualTo("square")
+ assertThat(style.listStyleImage).isEqualTo("../images/bullet.png")
}
@Test
@@ -327,10 +406,10 @@ class CssParserTest {
}
@Test
- fun parse_lineHeightClampsSmallEmValues() {
+ fun parse_lineHeightPreservesUnitlessMultiplier() {
val css = "p { line-height: 1.1; }" // This is treated as 1.1em
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["p"]?.first()?.style
- assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(2.0.em)
+ assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(1.1.em)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParserTest.kt
similarity index 84%
rename from app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParserTest.kt
index dcfe42e..16c3f9b 100644
--- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParserTest.kt
@@ -1,9 +1,8 @@
// HtmlParserTest.kt
-package com.aryan.reader.paginatedreader
+package org.dueattendant149.bookreader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
@@ -41,7 +40,7 @@ class HtmlParserTest {
val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules
- return htmlToSemanticBlocks(
+ return androidHtmlToSemanticBlocks(
html = "$html", // Wrap in body to match real usage
cssRules = allRules, // Use the combined list of rules
textStyle = defaultTextStyle,
@@ -97,7 +96,61 @@ class HtmlParserTest {
assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph
val blockStyle = pBlock.style.spanStyle
- assertThat(blockStyle.color).isEqualTo(Color.Green)
+ assertThat(blockStyle.color).isEqualTo(Color(0, 128, 0))
+ }
+
+ @Test
+ fun htmlToSemanticBlocks_contextSensitiveSelectors_areNotReusedAcrossSameClass() {
+ val css = """
+ .warning p.note { color: red; }
+ .safe p.note { color: blue; }
+ """.trimIndent()
+ val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
+
+ val blocks = parse(
+ """
+
+
+ """.trimIndent(),
+ cssRules = cssRules
+ )
+
+ val first = blocks[0] as SemanticParagraph
+ val second = blocks[1] as SemanticParagraph
+ assertThat(first.style.spanStyle.color).isEqualTo(Color.Red)
+ assertThat(second.style.spanStyle.color).isEqualTo(Color.Blue)
+ }
+
+ @Test
+ fun htmlToSemanticBlocks_generatedBeforeContent_isMaterializedIntoText() {
+ val css = "p.note::before { content: 'Note: '; color: red; }"
+ val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
+
+ val blocks = parse("Remember this
", cssRules = cssRules)
+
+ val paragraph = blocks.single() as SemanticParagraph
+ assertThat(paragraph.text).isEqualTo("Note: Remember this")
+ val generatedSpan = paragraph.spans.first { it.tag == "::before" }
+ assertThat(generatedSpan.start).isEqualTo(0)
+ assertThat(generatedSpan.end).isEqualTo("Note: ".length)
+ assertThat(generatedSpan.style.spanStyle.color).isEqualTo(Color.Red)
+ }
+
+ @Test
+ fun htmlToSemanticBlocks_backgroundImageUrl_isResolvedIntoBlockStyle() {
+ val imageRelativeSrc = "images/paper.png"
+ val chapterParentDir = File(defaultChapterPath).parent ?: ""
+ val imageFile = File(File(defaultExtractionPath, chapterParentDir), imageRelativeSrc).canonicalFile
+ imageFile.parentFile?.mkdirs()
+ imageFile.createNewFile()
+ imageFile.deleteOnExit()
+ val css = "p.paper { background-image: url('$imageRelativeSrc'); }"
+ val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
+
+ val blocks = parse("Text over paper
", cssRules = cssRules)
+
+ val paragraph = blocks.single() as SemanticParagraph
+ assertThat(paragraph.style.blockStyle.backgroundImage).isEqualTo(imageFile.absolutePath)
}
@Test
@@ -197,7 +250,7 @@ class HtmlParserTest {
}
@Test
- fun htmlToSemanticBlocks_complexInlineFormatting_isPreserved() {
+ fun htmlToSemanticBlocks_complexInlineText_isPreserved() {
val html = "This is bold and italic text.
"
val blocks = parse(html)
@@ -205,17 +258,6 @@ class HtmlParserTest {
val pBlock = blocks.first() as SemanticParagraph
assertThat(pBlock.text).isEqualTo("This is bold and italic text.")
-
- // Find the range for "bold" and check its style
- val boldRange = pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "bold" }
- assertThat(boldRange).isNotNull()
- assertThat(boldRange!!.style.spanStyle.fontWeight).isEqualTo(FontWeight.Bold)
-
- // Find the range for "italic" and check its style
- val italicRange =
- pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "italic" }
- assertThat(italicRange).isNotNull()
- assertThat(italicRange!!.style.spanStyle.fontStyle).isEqualTo(androidx.compose.ui.text.font.FontStyle.Italic)
}
@Test
@@ -242,15 +284,14 @@ class HtmlParserTest {
}
@Test
- fun htmlToSemanticBlocks_pseudoElements_areIgnoredByTheParser() {
+ fun htmlToSemanticBlocks_beforePseudoElementContent_isIncludedInParagraphText() {
val css = "p::before { content: \"Note: \"; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("This is a test.
", cssRules = cssRules)
- // The parser now ignores pseudo-elements, so only the paragraph content should be parsed.
assertThat(blocks).hasSize(1)
val pBlock = blocks[0] as SemanticParagraph
- assertThat(pBlock.text).isEqualTo("This is a test.")
+ assertThat(pBlock.text).isEqualTo("Note: This is a test.")
}
@Test
diff --git a/app/src/androidTest/java/com/aryan/reader/pdf/MainDispatcherRule.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/MainDispatcherRule.kt
similarity index 95%
rename from app/src/androidTest/java/com/aryan/reader/pdf/MainDispatcherRule.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/MainDispatcherRule.kt
index afbbc75..aa3941d 100644
--- a/app/src/androidTest/java/com/aryan/reader/pdf/MainDispatcherRule.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/MainDispatcherRule.kt
@@ -1,5 +1,5 @@
// MainDispatcherRule.kt
-package com.aryan.reader.pdf
+package org.dueattendant149.bookreader.paginatedreader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderDataTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderDataTest.kt
similarity index 91%
rename from app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderDataTest.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderDataTest.kt
index dd56c8e..59935bb 100644
--- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderDataTest.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderDataTest.kt
@@ -1,5 +1,5 @@
// PaginatedReaderDataTest.kt
-package com.aryan.reader.paginatedreader
+package org.dueattendant149.bookreader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.ParagraphStyle
@@ -75,7 +75,7 @@ class PaginatedReaderDataTest {
margin = BoxBorders(bottom = 10.dp, left = 10.dp),
width = 200.dp,
backgroundColor = Color.Black,
- border = BorderStyle(width = 1.dp, color = Color.Red)
+ borderTop = BorderStyle(width = 1.dp, color = Color.Red)
)
val merged = baseStyle.merge(overrideStyle)
@@ -95,8 +95,8 @@ class PaginatedReaderDataTest {
// Other properties
assertThat(merged.width).isEqualTo(200.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.Black)
- assertThat(merged.border).isNotNull()
- assertThat(merged.border?.width).isEqualTo(1.dp)
+ assertThat(merged.borderTop).isNotNull()
+ assertThat(merged.borderTop?.width).isEqualTo(1.dp)
}
@Test
@@ -115,6 +115,9 @@ class PaginatedReaderDataTest {
assertThat(merged.margin.top).isEqualTo(5.dp)
assertThat(merged.width).isEqualTo(100.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.White)
- assertThat(merged.border).isNull()
+ assertThat(merged.borderTop).isNull()
+ assertThat(merged.borderRight).isNull()
+ assertThat(merged.borderBottom).isNull()
+ assertThat(merged.borderLeft).isNull()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderViewModelTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderViewModelTest.kt
new file mode 100644
index 0000000..bde9ccc
--- /dev/null
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderViewModelTest.kt
@@ -0,0 +1,180 @@
+package org.dueattendant149.bookreader.paginatedreader
+
+import android.content.Context
+import android.os.Build
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextMeasurer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import org.dueattendant149.bookreader.SearchResult
+import org.dueattendant149.bookreader.epub.EpubBook
+import com.google.common.truth.Truth.assertThat
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private class FakePaginator(
+ initiallyLoading: Boolean,
+ initialPageCount: Int,
+ initialGeneration: Int
+) : IPaginator {
+ override var isLoading by mutableStateOf(initiallyLoading)
+ override var totalPageCount by mutableIntStateOf(initialPageCount)
+ override var generation by mutableIntStateOf(initialGeneration)
+ override val pageShiftRequest: Flow = emptyFlow()
+
+ var lastNavigatedHref: String? = null
+ var lastNavigatedChapter: String? = null
+
+ override fun getPageContent(pageIndex: Int): Page? = null
+ override fun getChapterPathForPage(pageIndex: Int): String? = null
+ override fun getPlainTextForChapter(chapterIndex: Int): String? = null
+
+ override fun navigateToHref(
+ currentChapterAbsPath: String,
+ href: String,
+ onNavigationComplete: (pageIndex: Int) -> Unit
+ ) {
+ lastNavigatedChapter = currentChapterAbsPath
+ lastNavigatedHref = href
+ }
+
+ override fun findPageForSearchResult(
+ result: SearchResult,
+ onResult: (pageIndex: Int) -> Unit
+ ) = Unit
+
+ override fun findPageForAnchor(
+ chapterIndex: Int,
+ anchor: String?,
+ onResult: (pageIndex: Int) -> Unit
+ ) = Unit
+
+ override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) = Unit
+ override fun findPageForCfiAndOffset(chapterIndex: Int, cfi: String, charOffset: Int): Int? = null
+ override fun findChapterIndexForPage(pageIndex: Int): Int? = null
+ override fun getCfiForPage(pageIndex: Int): String? = null
+ override fun onUserScrolledTo(pageIndex: Int) = Unit
+ override fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List): String? = null
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+class PaginatedReaderViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private lateinit var viewModel: PaginatedReaderViewModel
+ private lateinit var fakePaginator: FakePaginator
+
+ @Before
+ fun setUp() {
+ viewModel = PaginatedReaderViewModel()
+ fakePaginator = FakePaginator(
+ initiallyLoading = true,
+ initialPageCount = 0,
+ initialGeneration = 0
+ )
+ viewModel.setPaginatorForTest(fakePaginator)
+ }
+
+ @Test
+ fun uiState_reflectsPaginatorInitialState() = runTest {
+ val initialState = viewModel.uiState.value
+ assertThat(initialState.isLoading).isTrue()
+ assertThat(initialState.totalPageCount).isEqualTo(0)
+ assertThat(initialState.generation).isEqualTo(0)
+ }
+
+ @Test
+ fun uiState_updatesWhenPaginatorIsLoadingChanges() = runTest {
+ assertThat(viewModel.uiState.value.isLoading).isTrue()
+
+ fakePaginator.isLoading = false
+ Snapshot.sendApplyNotifications()
+ advanceUntilIdle()
+
+ assertThat(viewModel.uiState.value.isLoading).isFalse()
+ }
+
+ @Test
+ fun uiState_updatesWhenPaginatorTotalPageCountChanges() = runTest {
+ assertThat(viewModel.uiState.value.totalPageCount).isEqualTo(0)
+
+ fakePaginator.totalPageCount = 123
+ Snapshot.sendApplyNotifications()
+ advanceUntilIdle()
+
+ assertThat(viewModel.uiState.value.totalPageCount).isEqualTo(123)
+ }
+
+ @Test
+ fun uiState_updatesWhenPaginatorGenerationChanges() = runTest {
+ assertThat(viewModel.uiState.value.generation).isEqualTo(0)
+
+ fakePaginator.generation = 5
+ Snapshot.sendApplyNotifications()
+ advanceUntilIdle()
+
+ assertThat(viewModel.uiState.value.generation).isEqualTo(5)
+ }
+
+ @Test
+ fun onLinkClick_callsPaginatorNavigateToHrefWithCorrectArguments() {
+ val currentChapter = "chapter1.xhtml"
+ val href = "#section2"
+
+ viewModel.onLinkClick(currentChapter, href) {}
+
+ assertThat(fakePaginator.lastNavigatedChapter).isEqualTo(currentChapter)
+ assertThat(fakePaginator.lastNavigatedHref).isEqualTo(href)
+ }
+
+ @Test
+ fun initialize_whenPaginatorAlreadySet_keepsExistingPaginator() = runTest {
+ val context = ApplicationProvider.getApplicationContext()
+ val existingPaginator = viewModel.paginator
+
+ viewModel.initialize(
+ book = EpubBook(
+ fileName = "test.epub",
+ title = "Test Book",
+ author = "Test Author",
+ language = "en",
+ coverImage = null
+ ),
+ textMeasurer = mockk(relaxed = true),
+ textConstraints = Constraints(maxWidth = 1080, maxHeight = 1920),
+ textStyle = TextStyle.Default,
+ density = Density(1f),
+ isDarkTheme = false,
+ themeBackgroundColor = Color.White,
+ themeTextColor = Color.Black,
+ context = context,
+ initialChapterToPaginate = 0,
+ mathMLRenderer = mockk(relaxed = true),
+ paragraphGapMultiplier = 1.0f
+ )
+ advanceUntilIdle()
+
+ assertThat(viewModel.paginator).isSameInstanceAs(existingPaginator)
+ }
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatorTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatorTest.kt
similarity index 70%
rename from app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatorTest.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatorTest.kt
index b04283f..c74943c 100644
--- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatorTest.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatorTest.kt
@@ -1,11 +1,13 @@
// PaginatorTest.kt
-package com.aryan.reader.paginatedreader
+package org.dueattendant149.bookreader.paginatedreader
+import android.os.Build
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
-import com.google.common.truth.Truth.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@@ -42,14 +44,52 @@ class FakeSplittableMeasurementProvider(
}
return null
}
+
+ override suspend fun split(block: TableBlock, availableHeight: Int): Pair? = null
+
+ override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair? = null
}
@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
class PaginatorTest {
private val testDensity = Density(density = 1f, fontScale = 1f)
private val pageHeight = 1000
+ private fun List.withoutMeasuredHeights(): List {
+ return map { it.withoutMeasuredHeight() }
+ }
+
+ private fun ContentBlock.withoutMeasuredHeight(): ContentBlock {
+ return when (this) {
+ is ParagraphBlock -> copy(expectedHeight = 0)
+ is ImageBlock -> copy(expectedHeight = 0)
+ is HeaderBlock -> copy(expectedHeight = 0)
+ is SpacerBlock -> copy(expectedHeight = 0)
+ is QuoteBlock -> copy(expectedHeight = 0)
+ is ListItemBlock -> copy(expectedHeight = 0)
+ is TableBlock -> copy(
+ rows = rows.map { row ->
+ row.map { cell ->
+ cell.copy(content = cell.content.withoutMeasuredHeights())
+ }
+ },
+ expectedHeight = 0
+ )
+ is MathBlock -> copy(expectedHeight = 0)
+ is WrappingContentBlock -> copy(
+ floatedImage = floatedImage.copy(expectedHeight = 0),
+ paragraphsToWrap = paragraphsToWrap.map { it.copy(expectedHeight = 0) },
+ expectedHeight = 0
+ )
+ is FlexContainerBlock -> copy(
+ children = children.withoutMeasuredHeights(),
+ expectedHeight = 0
+ )
+ }
+ }
+
@Test
fun paginate_givenEmptyBlocks_createsZeroPages() = runTest {
val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity)
@@ -85,8 +125,53 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
- assertThat(pages[0].content).containsExactly(block1)
- assertThat(pages[1].content).containsExactly(block2)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
+ assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
+ }
+
+ @Test
+ fun paginate_honorsBreakBeforePage() = runTest {
+ val block1 = ParagraphBlock(content = AnnotatedString("Before"), blockIndex = 0)
+ val block2 = ParagraphBlock(
+ content = AnnotatedString("After"),
+ style = BlockStyle(breakBefore = "page"),
+ blockIndex = 1
+ )
+
+ val pages = paginate(
+ listOf(block1, block2),
+ pageHeight,
+ FakeSplittableMeasurementProvider(mapOf(block1 to 100, block2 to 100)),
+ testDensity
+ )
+
+ assertThat(pages).hasSize(2)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
+ assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
+ }
+
+ @Test
+ fun paginate_breakInsideAvoidPreventsParagraphSplit() = runTest {
+ val block1 = ParagraphBlock(
+ content = AnnotatedString("Keep together"),
+ style = BlockStyle(breakInside = "avoid"),
+ blockIndex = 0
+ )
+ val part1 = block1.copy(content = AnnotatedString("Keep"))
+ val part2 = block1.copy(content = AnnotatedString("together"))
+
+ val pages = paginate(
+ listOf(block1),
+ pageHeight = 400,
+ measurementProvider = FakeSplittableMeasurementProvider(
+ heights = mapOf(block1 to 800, part1 to 300, part2 to 500),
+ splittableParagraphs = mapOf(block1 to (part1 to part2))
+ ),
+ density = testDensity
+ )
+
+ assertThat(pages).hasSize(1)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
}
@Test
@@ -110,8 +195,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
- assertThat(pages[0].content).containsExactly(block1, part1).inOrder()
- assertThat(pages[1].content).containsExactly(part2)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1, part1).inOrder()
+ assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(part2)
}
@Test
@@ -136,8 +221,8 @@ class PaginatorTest {
val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
- assertThat(pages[0].content).containsExactly(splitWrapper)
- assertThat(pages[1].content).containsExactly(para2)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(splitWrapper)
+ assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(para2)
}
@@ -158,8 +243,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
- assertThat(pages[0].content).containsExactly(block1)
- assertThat(pages[1].content).containsExactly(unsplittableBlock)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
+ assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(unsplittableBlock)
}
@Test
@@ -172,7 +257,7 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
- assertThat(pages[0].content).containsExactly(oversizedBlock)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(oversizedBlock)
}
@Test
@@ -248,8 +333,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
- assertThat(pages[0].content).containsExactly(block1)
- assertThat(pages[1].content).containsExactly(splittableBlock) // Was not split
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
+ assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(splittableBlock) // Was not split
}
@Test
@@ -271,8 +356,7 @@ class PaginatorTest {
// Set page height so that a split is attempted.
val pages = paginate(blocks, 150, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
- // The page should be empty because part1 was empty, and the original block was re-added
- // to the remaining list. The next page then contains the full block.
- assertThat(pages[0].content).containsExactly(part2)
+ // Empty split heads are skipped so pagination keeps only the remaining content.
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(part2)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderLinkHitTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderLinkHitTest.kt
new file mode 100644
index 0000000..11e4cc1
--- /dev/null
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderLinkHitTest.kt
@@ -0,0 +1,103 @@
+package org.dueattendant149.bookreader.paginatedreader
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.sp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ReaderLinkHitTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun urlAnnotationAtPositionIgnoresSameLineSpaceAfterLink() {
+ lateinit var text: AnnotatedString
+ var layoutResult: TextLayoutResult? = null
+
+ composeTestRule.setContent {
+ val textMeasurer = rememberTextMeasurer()
+ text = linkText("Open")
+ layoutResult = textMeasurer.measure(
+ text = text,
+ style = TextStyle(fontSize = 24.sp),
+ constraints = Constraints.fixedWidth(500)
+ )
+ }
+
+ composeTestRule.waitForIdle()
+
+ val layout = checkNotNull(layoutResult)
+ val firstBox = layout.getBoundingBox(0)
+ val lastBox = layout.getBoundingBox(text.length - 1)
+ val y = (firstBox.top + firstBox.bottom) / 2f
+
+ assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(firstBox.left + 1f, y)))
+ .isEqualTo(HREF)
+ assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(lastBox.right + 60f, y)))
+ .isNull()
+ }
+
+ @Test
+ fun urlAnnotationAtPositionAppliesTextStartOffsetForWrappedLineLayouts() {
+ lateinit var fullText: AnnotatedString
+ var lineLayoutResult: TextLayoutResult? = null
+ val prefix = "Before "
+ val label = "Open"
+
+ composeTestRule.setContent {
+ val textMeasurer = rememberTextMeasurer()
+ fullText = buildAnnotatedString {
+ append(prefix)
+ append(label)
+ addStringAnnotation("URL", HREF, prefix.length, prefix.length + label.length)
+ }
+ lineLayoutResult = textMeasurer.measure(
+ text = fullText.subSequence(prefix.length, fullText.length),
+ style = TextStyle(fontSize = 24.sp),
+ constraints = Constraints.fixedWidth(500)
+ )
+ }
+
+ composeTestRule.waitForIdle()
+
+ val layout = checkNotNull(lineLayoutResult)
+ val firstBox = layout.getBoundingBox(0)
+ val lastBox = layout.getBoundingBox(label.length - 1)
+ val y = (firstBox.top + firstBox.bottom) / 2f
+
+ assertThat(
+ fullText.readerUrlAnnotationAtPosition(
+ layout = layout,
+ position = Offset(firstBox.left + 1f, y),
+ textStartOffset = prefix.length
+ )
+ ).isEqualTo(HREF)
+ assertThat(
+ fullText.readerUrlAnnotationAtPosition(
+ layout = layout,
+ position = Offset(lastBox.right + 60f, y),
+ textStartOffset = prefix.length
+ )
+ ).isNull()
+ }
+
+ private fun linkText(label: String) = buildAnnotatedString {
+ append(label)
+ addStringAnnotation("URL", HREF, 0, label.length)
+ }
+
+ private companion object {
+ const val HREF = "chapter.xhtml#target"
+ }
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/StyleUtilsTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtilsTest.kt
similarity index 98%
rename from app/src/androidTest/java/com/aryan/reader/paginatedreader/StyleUtilsTest.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtilsTest.kt
index 9fddc60..c108d87 100644
--- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/StyleUtilsTest.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtilsTest.kt
@@ -1,5 +1,5 @@
// StyleUtilsTest.kt
-package com.aryan.reader.paginatedreader
+package org.dueattendant149.bookreader.paginatedreader
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isUnspecified
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/MainDispatcherRule.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/MainDispatcherRule.kt
similarity index 96%
rename from app/src/androidTest/java/com/aryan/reader/paginatedreader/MainDispatcherRule.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/MainDispatcherRule.kt
index a6d31f8..d773bbe 100644
--- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/MainDispatcherRule.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/MainDispatcherRule.kt
@@ -1,5 +1,5 @@
// MainDispatcherRule.kt
-package com.aryan.reader.paginatedreader
+package org.dueattendant149.bookreader.pdf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
diff --git a/app/src/androidTest/java/com/aryan/reader/pdf/PdfAnnotationTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/PdfAnnotationTest.kt
similarity index 69%
rename from app/src/androidTest/java/com/aryan/reader/pdf/PdfAnnotationTest.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/PdfAnnotationTest.kt
index 894d567..985a186 100644
--- a/app/src/androidTest/java/com/aryan/reader/pdf/PdfAnnotationTest.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/PdfAnnotationTest.kt
@@ -1,5 +1,5 @@
// PdfAnnotationTest.kt
-package com.aryan.reader.pdf
+package org.dueattendant149.bookreader.pdf
import android.content.Context
import android.content.Intent
@@ -11,6 +11,7 @@ import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.click
import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
@@ -19,7 +20,9 @@ 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 com.aryan.reader.MainActivity
+import org.dueattendant149.bookreader.MainActivity
+import org.dueattendant149.bookreader.R
+import com.google.common.truth.Truth.assertThat
import org.junit.After
import org.junit.Before
import org.junit.Rule
@@ -38,6 +41,12 @@ class PdfAnnotationTest {
private var currentPdfFile: File? = null
private var scenario: ActivityScenario? = null
private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
+ private fun text(resId: Int): String = context.getString(resId)
+ private fun dockTag(resId: Int): String = "DockItem_${text(resId)}"
+
+ private fun assertNoNodeWithTag(tag: String) {
+ assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty()
+ }
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
return Intent(context, MainActivity::class.java).apply {
@@ -55,7 +64,7 @@ class PdfAnnotationTest {
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.edit().clear().commit()
- scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri))
+ scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri))
waitForDocumentLoad()
}
@@ -75,11 +84,11 @@ class PdfAnnotationTest {
}
private fun enterEditMode() {
- composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode")
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode))
.assertIsDisplayed()
.performClick()
composeTestRule.waitForIdle()
- composeTestRule.onNodeWithContentDescription("Close Edit Mode").assertIsDisplayed()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).assertIsDisplayed()
}
private fun tapOutsidePopup() {
@@ -111,13 +120,13 @@ class PdfAnnotationTest {
enterEditMode()
// Verify Dock Items exist using new Tags
- composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
- composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsDisplayed()
- composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsDisplayed()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsDisplayed()
- composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick()
composeTestRule.waitForIdle()
- composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode").assertIsDisplayed()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode)).assertIsDisplayed()
}
// --- TOOL LOGIC TESTS ---
@@ -127,38 +136,38 @@ class PdfAnnotationTest {
enterEditMode()
// 1. Select Highlighter
- composeTestRule.onNodeWithTag("DockItem_Highlighter").performClick()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).performClick()
composeTestRule.waitForIdle()
// 2. Verify selection state
- composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
- composeTestRule.onNodeWithTag("DockItem_Pen").assertIsNotSelected()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsNotSelected()
// 3. Exit Edit Mode
- composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick()
composeTestRule.waitForIdle()
// 4. Re-enter Edit Mode
enterEditMode()
// 5. Verify Highlighter is STILL selected (Persistence)
- composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected()
}
@Test
- fun testEraserHasNoPopup() {
+ fun testEraserSettingsPopupOpensWhenAlreadySelected() {
enterEditMode()
// Select Eraser
- composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick()
composeTestRule.waitForIdle()
- composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsSelected()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsSelected()
- // Click Eraser AGAIN (Should NOT open popup)
- composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
+ // Click Eraser again to open its settings popup.
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick()
composeTestRule.waitForIdle()
- composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
+ composeTestRule.onNodeWithTag("ToolSettingsPopup").assertIsDisplayed()
}
// --- SETTINGS POPUP TESTS ---
@@ -169,7 +178,7 @@ class PdfAnnotationTest {
// 1. Pen is default. Click Pen ONCE to open Settings.
// (Clicking twice would toggle it off, which caused previous failures)
- composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick()
composeTestRule.waitForIdle()
// 2. Verify Popup Displayed
@@ -186,7 +195,7 @@ class PdfAnnotationTest {
// 5. Dismiss Settings
tapOutsidePopup()
- composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
+ assertNoNodeWithTag("ToolSettingsPopup")
}
@Test
@@ -194,7 +203,7 @@ class PdfAnnotationTest {
enterEditMode()
// Open Settings for Pen (Default selected, so one click opens settings)
- composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick()
composeTestRule.waitForIdle()
// Test Palette Click (Index 1)
@@ -208,7 +217,7 @@ class PdfAnnotationTest {
tapOutsidePopup()
// Quick verification that settings didn't crash app
- composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
}
// --- UNDO/REDO TESTS ---
@@ -217,7 +226,7 @@ class PdfAnnotationTest {
fun testDrawingEnablesUndo() {
enterEditMode()
- composeTestRule.onNodeWithContentDescription("Undo")
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo))
.assertIsDisplayed()
.assertIsNotEnabled()
@@ -227,7 +236,7 @@ class PdfAnnotationTest {
}
composeTestRule.waitForIdle()
- composeTestRule.onNodeWithContentDescription("Undo").assertIsEnabled()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo)).assertIsEnabled()
}
@Test
@@ -240,8 +249,8 @@ class PdfAnnotationTest {
}
composeTestRule.waitForIdle()
- val undoNode = composeTestRule.onNodeWithContentDescription("Undo")
- val redoNode = composeTestRule.onNodeWithContentDescription("Redo")
+ val undoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo))
+ val redoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_redo))
undoNode.assertIsEnabled()
redoNode.assertIsNotEnabled()
@@ -268,7 +277,7 @@ class PdfAnnotationTest {
enterEditMode()
// 1. Drag Dock to make it floating (using Pen icon as handle)
- composeTestRule.onNodeWithTag("DockItem_Pen").performTouchInput {
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performTouchInput {
down(center)
advanceEventTime(600) // Long press
// Drag UP significantly
@@ -278,20 +287,20 @@ class PdfAnnotationTest {
composeTestRule.waitForIdle()
// 2. Minimize (Eye icon)
- composeTestRule.onNodeWithContentDescription("Toggle Visibility").performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_visibility)).performClick()
composeTestRule.waitForIdle()
// 3. Verify Dock items are hidden
- composeTestRule.onNodeWithTag("DockItem_Pen").assertDoesNotExist()
+ assertNoNodeWithTag(dockTag(R.string.content_desc_pen))
// 4. Verify "Show Dock" floating button is visible
- composeTestRule.onNodeWithContentDescription("Show Dock").assertIsDisplayed()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).assertIsDisplayed()
// 5. Restore
- composeTestRule.onNodeWithContentDescription("Show Dock").performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).performClick()
composeTestRule.waitForIdle()
// 6. Verify Dock items return
- composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/pdf/PdfCoverGeneratorTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/PdfCoverGeneratorTest.kt
similarity index 98%
rename from app/src/androidTest/java/com/aryan/reader/pdf/PdfCoverGeneratorTest.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/PdfCoverGeneratorTest.kt
index 137dad8..074b0c9 100644
--- a/app/src/androidTest/java/com/aryan/reader/pdf/PdfCoverGeneratorTest.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/PdfCoverGeneratorTest.kt
@@ -1,5 +1,5 @@
// app/src/androidTest/java/com/aryan/reader/pdf/PdfCoverGeneratorTest.kt
-package com.aryan.reader.pdf
+package org.dueattendant149.bookreader.pdf
import android.content.Context
import android.net.Uri
diff --git a/app/src/androidTest/java/com/aryan/reader/pdf/PdfHelperTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/PdfHelperTest.kt
similarity index 98%
rename from app/src/androidTest/java/com/aryan/reader/pdf/PdfHelperTest.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/PdfHelperTest.kt
index 376c1d1..ade8317 100644
--- a/app/src/androidTest/java/com/aryan/reader/pdf/PdfHelperTest.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/PdfHelperTest.kt
@@ -1,5 +1,5 @@
// app/src/androidTest/java/com/aryan/reader/pdf/PdfHelperTest.kt
-package com.aryan.reader.pdf
+package org.dueattendant149.bookreader.pdf
import android.graphics.Rect
import androidx.test.ext.junit.runners.AndroidJUnit4
diff --git a/app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/PdfViewerScreenTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/PdfViewerScreenTest.kt
new file mode 100644
index 0000000..99c3931
--- /dev/null
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/pdf/PdfViewerScreenTest.kt
@@ -0,0 +1,258 @@
+package org.dueattendant149.bookreader.pdf
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextContains
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+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.rule.GrantPermissionRule
+import org.dueattendant149.bookreader.MainActivity
+import org.dueattendant149.bookreader.R
+import com.google.common.truth.Truth.assertThat
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import java.util.UUID
+
+@RunWith(AndroidJUnit4::class)
+class PdfViewerScreenTest {
+
+ @get:Rule
+ val composeTestRule = createEmptyComposeRule()
+
+ @get:Rule
+ val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private var currentPdfFile: File? = null
+ private var scenario: ActivityScenario? = null
+
+ @Before
+ fun setup() {
+ context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
+ .edit()
+ .clear()
+ .commit()
+
+ val samplePdfUri = copyAssetToCache(context, "sample.pdf")
+ scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri))
+ }
+
+ @After
+ fun tearDown() {
+ scenario?.close()
+ currentPdfFile?.let {
+ if (it.exists()) it.delete()
+ }
+ }
+
+ private fun text(resId: Int, vararg args: Any): String = context.getString(resId, *args)
+
+ private fun assertNoNodeWithTag(tag: String) {
+ assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty()
+ }
+
+ private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
+ return Intent(context, MainActivity::class.java).apply {
+ action = Intent.ACTION_VIEW
+ data = uri
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ }
+
+ private fun waitForDocumentLoad(pageText: String = text(R.string.page_of_pages, 1, 4)) {
+ composeTestRule.waitUntil(timeoutMillis = 15_000) {
+ composeTestRule
+ .onAllNodesWithText(pageText)
+ .fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun openMoreOptions() {
+ composeTestRule.onNodeWithContentDescription(text(R.string.tooltip_more_options)).performClick()
+ }
+
+ private fun openNavigationDrawer() {
+ composeTestRule.onNodeWithTag("TocButton").performClick()
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText(text(R.string.tab_chapters)).fetchSemanticsNodes().isNotEmpty() &&
+ composeTestRule.onAllNodesWithTag("BookmarksTab").fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun selectChaptersTabIfTabsPaneIsFirst() {
+ if (composeTestRule.onAllNodesWithTag("TabsTab").fetchSemanticsNodes().isNotEmpty()) {
+ composeTestRule.onNodeWithText(text(R.string.tab_chapters)).performClick()
+ composeTestRule.waitForIdle()
+ }
+ }
+
+ private fun selectBookmarksTab() {
+ composeTestRule.onNodeWithTag("BookmarksTab").performClick()
+ composeTestRule.waitForIdle()
+ }
+
+ private fun selectReadingMode(modeText: String) {
+ openMoreOptions()
+ composeTestRule.onNodeWithText(text(R.string.menu_change_reading_mode)).performClick()
+ composeTestRule.onNodeWithText(modeText).performClick()
+ composeTestRule.waitForIdle()
+ }
+
+ private fun ensurePaginationMode() {
+ selectReadingMode(text(R.string.menu_reading_mode_paginated))
+ }
+
+ @Suppress("SameParameterValue")
+ private fun copyAssetToCache(context: Context, assetName: String): Uri {
+ val uniqueName = "${UUID.randomUUID()}_$assetName"
+ val file = File(context.cacheDir, uniqueName)
+
+ currentPdfFile = file
+
+ if (file.exists()) file.delete()
+ context.assets.open(assetName).use { inputStream ->
+ file.outputStream().use { outputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ }
+ return FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.provider",
+ file
+ )
+ }
+
+ @Test
+ fun documentLoadsAndDisplaysCorrectPageCount() {
+ waitForDocumentLoad()
+ composeTestRule.onNodeWithTag("PageNumberIndicator")
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun tableOfContentsButton_handlesTabsPaneAndOpensChaptersTab() {
+ waitForDocumentLoad()
+
+ openNavigationDrawer()
+ selectChaptersTabIfTabsPaneIsFirst()
+
+ composeTestRule.onNodeWithText(text(R.string.tab_chapters)).assertIsDisplayed()
+ composeTestRule.onNodeWithTag("BookmarksTab").assertIsDisplayed()
+ }
+
+ @Test
+ fun bookmarkFunctionality_addAndDeleteCurrentPage() {
+ waitForDocumentLoad()
+
+ openMoreOptions()
+ composeTestRule.onNodeWithText(text(R.string.menu_bookmark_this_page)).performClick()
+ composeTestRule.waitForIdle()
+
+ openNavigationDrawer()
+ selectBookmarksTab()
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithTag("BookmarkItem_0").fetchSemanticsNodes().isNotEmpty()
+ }
+ composeTestRule.onNodeWithTag("BookmarkItem_0").assertIsDisplayed()
+ .assert(hasText(text(R.string.pdf_page_short, 1), substring = true))
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_more_options_bookmark)).performClick()
+ composeTestRule.onNodeWithText(text(R.string.action_delete)).performClick()
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(text(R.string.action_delete), useUnmergedTree = true).performClick()
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithTag("BookmarkItem_0").fetchSemanticsNodes().isEmpty()
+ }
+ assertNoNodeWithTag("BookmarkItem_0")
+ composeTestRule.onNodeWithText(text(R.string.no_bookmarks_yet)).assertIsDisplayed()
+ }
+
+ @Test
+ fun sliderNavigation_opensAndDisplaysCorrectly() {
+ waitForDocumentLoad()
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_navigate_slider)).performClick()
+
+ composeTestRule.onNodeWithText(text(R.string.page_format, 1, 4)).assertIsDisplayed()
+ }
+
+ @Test
+ fun displayMode_switchesToVerticalScroll() {
+ waitForDocumentLoad()
+
+ ensurePaginationMode()
+
+ assertNoNodeWithTag("PdfVerticalScroll")
+
+ selectReadingMode(text(R.string.menu_reading_mode_vertical))
+
+ composeTestRule.onNodeWithTag("PdfVerticalScroll").assertIsDisplayed()
+
+ ensurePaginationMode()
+
+ assertNoNodeWithTag("PdfVerticalScroll")
+ }
+
+ @Test
+ fun displayModeSelectionPersistsReaderPreference() {
+ waitForDocumentLoad()
+
+ selectReadingMode(text(R.string.menu_reading_mode_paginated))
+ waitForDisplayModePreference(DisplayMode.PAGINATION)
+
+ selectReadingMode(text(R.string.menu_reading_mode_vertical))
+ waitForDisplayModePreference(DisplayMode.VERTICAL_SCROLL)
+ }
+
+ @Test
+ fun search_uiOpensAndAcceptsQuery() {
+ waitForDocumentLoad()
+
+ composeTestRule.onNodeWithTag("SearchButton").performClick()
+ composeTestRule.waitForIdle()
+
+ val ocrLanguageText = text(R.string.ocr_language_latin)
+ if (composeTestRule.onAllNodesWithText(ocrLanguageText).fetchSemanticsNodes().isNotEmpty()) {
+ composeTestRule.onNodeWithText(ocrLanguageText).performClick()
+ composeTestRule.waitForIdle()
+ }
+
+ composeTestRule.onNodeWithTag("SearchTextField").assertIsDisplayed()
+
+ composeTestRule.onNodeWithTag("SearchTextField").performTextInput("test query")
+
+ composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("test query")
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.tooltip_close_search)).performClick()
+
+ assertNoNodeWithTag("SearchTextField")
+ }
+
+ private fun waitForDisplayModePreference(expected: DisplayMode) {
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+ .getString(DISPLAY_MODE_KEY, DisplayMode.VERTICAL_SCROLL.name) == expected.name
+ }
+ }
+
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/tts/BaseTtsSynthesizerTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/tts/BaseTtsSynthesizerTest.kt
similarity index 98%
rename from app/src/androidTest/java/com/aryan/reader/tts/BaseTtsSynthesizerTest.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/tts/BaseTtsSynthesizerTest.kt
index 9b97fdc..d5f7292 100644
--- a/app/src/androidTest/java/com/aryan/reader/tts/BaseTtsSynthesizerTest.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/tts/BaseTtsSynthesizerTest.kt
@@ -1,5 +1,5 @@
// BaseTtsSynthesizerTest.kt
-package com.aryan.reader.tts
+package org.dueattendant149.bookreader.tts
import android.speech.tts.TextToSpeech
import androidx.test.core.app.ApplicationProvider
diff --git a/app/src/androidTest/java/com/aryan/reader/MainDispatcherRule.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/tts/MainDispatcherRule.kt
similarity index 95%
rename from app/src/androidTest/java/com/aryan/reader/MainDispatcherRule.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/tts/MainDispatcherRule.kt
index 1cf2f5b..e46b599 100644
--- a/app/src/androidTest/java/com/aryan/reader/MainDispatcherRule.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/tts/MainDispatcherRule.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader.tts
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
diff --git a/app/src/androidTest/java/com/aryan/reader/tts/TtsUtilsTest.kt b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/tts/TtsUtilsTest.kt
similarity index 98%
rename from app/src/androidTest/java/com/aryan/reader/tts/TtsUtilsTest.kt
rename to app/src/androidTest/java/com/dueattendant149/bookreader/reader/tts/TtsUtilsTest.kt
index a338488..6749d89 100644
--- a/app/src/androidTest/java/com/aryan/reader/tts/TtsUtilsTest.kt
+++ b/app/src/androidTest/java/com/dueattendant149/bookreader/reader/tts/TtsUtilsTest.kt
@@ -1,5 +1,5 @@
// TtsUtilsTest.kt
-package com.aryan.reader.tts
+package org.dueattendant149.bookreader.tts
import com.google.common.truth.Truth.assertThat
import org.junit.Test
diff --git a/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt b/app/src/debug/java/com/dueattendant149/bookreader/reader/epubreader/EpubTestActivity.kt
similarity index 84%
rename from app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt
rename to app/src/debug/java/com/dueattendant149/bookreader/reader/epubreader/EpubTestActivity.kt
index 1bfd9d1..9596429 100644
--- a/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt
+++ b/app/src/debug/java/com/dueattendant149/bookreader/reader/epubreader/EpubTestActivity.kt
@@ -1,12 +1,12 @@
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader.epubreader
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.RequiresApi
-import com.aryan.reader.RenderMode
-import com.aryan.reader.epub.EpubBook
+import org.dueattendant149.bookreader.RenderMode
+import org.dueattendant149.bookreader.epub.EpubBook
import kotlinx.serialization.json.Json
class EpubTestActivity : ComponentActivity() {
@@ -31,8 +31,8 @@ class EpubTestActivity : ComponentActivity() {
coverImagePath = null,
onRenderModeChange = {},
customFonts = TODO(),
- onImportFont = TODO(), viewModel = TODO()
+ onImportFonts = TODO(), viewModel = TODO()
)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/debug/java/com/aryan/reader/epubreader/HiltTestActivity.kt b/app/src/debug/java/com/dueattendant149/bookreader/reader/epubreader/HiltTestActivity.kt
similarity index 89%
rename from app/src/debug/java/com/aryan/reader/epubreader/HiltTestActivity.kt
rename to app/src/debug/java/com/dueattendant149/bookreader/reader/epubreader/HiltTestActivity.kt
index 99f56ed..1f512af 100644
--- a/app/src/debug/java/com/aryan/reader/epubreader/HiltTestActivity.kt
+++ b/app/src/debug/java/com/dueattendant149/bookreader/reader/epubreader/HiltTestActivity.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader.epubreader
import androidx.activity.ComponentActivity
diff --git a/app/src/debug/java/com/aryan/reader/ml/ComicPanelDetector.kt b/app/src/debug/java/com/dueattendant149/bookreader/reader/ml/ComicPanelDetector.kt
similarity index 99%
rename from app/src/debug/java/com/aryan/reader/ml/ComicPanelDetector.kt
rename to app/src/debug/java/com/dueattendant149/bookreader/reader/ml/ComicPanelDetector.kt
index c2a0e3a..4e21c85 100644
--- a/app/src/debug/java/com/aryan/reader/ml/ComicPanelDetector.kt
+++ b/app/src/debug/java/com/dueattendant149/bookreader/reader/ml/ComicPanelDetector.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader.ml
+package org.dueattendant149.bookreader.ml
import android.graphics.Bitmap
import android.graphics.RectF
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4432b4a..c9a474e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -55,7 +55,22 @@
+
+
+
+
@@ -154,6 +169,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js
index 2075568..030e760 100644
--- a/app/src/main/assets/epub_reader.js
+++ b/app/src/main/assets/epub_reader.js
@@ -128,8 +128,15 @@
background-color: rgba(255, 236, 179, 0.8);
/* Semi-transparent Gold */
color: black !important;
+ display: inline !important;
+ text-align: initial !important;
+ text-align-last: auto !important;
+ letter-spacing: normal !important;
+ word-spacing: normal !important;
padding: 0.1em 0;
border-radius: 3px;
+ -webkit-box-decoration-break: clone;
+ box-decoration-break: clone;
}
html.dark-theme span.tts-highlight {
@@ -1496,6 +1503,16 @@
};
const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS";
+ const TTS_HIGHLIGHT_BLOCK_SELECTOR = "p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th";
+
+ function getTtsHighlightBlock(node) {
+ if (!node) {
+ return document.body;
+ }
+
+ const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
+ return (element && element.closest(TTS_HIGHLIGHT_BLOCK_SELECTOR)) || document.body;
+ }
window.highlightFromCfi = function (cfi, textToHighlight, startOffset) {
console.log(`$ {
@@ -1556,9 +1573,10 @@
, Text content: '${(location.node.textContent || "").substring(0, 50)}...' `);
const baseNode = location.node;
+ const highlightRoot = getTtsHighlightBlock(baseNode);
let remainingOffset = startOffset;
- const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
+ const treeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
treeWalker.currentNode = baseNode;
let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode();
@@ -1626,7 +1644,7 @@
} else {
remainingTextLength -= availableLength;
// Important: We need a fresh walker starting from the endNode to find the *next* text node reliably
- const nextNodeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
+ const nextNodeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
nextNodeWalker.currentNode = endNode;
endNode = nextNodeWalker.nextNode();
endOffset = 0; // Start from the beginning of the next node
@@ -1677,11 +1695,14 @@
TTS_HIGHLIGHT_LOG_TAG
}
- : surroundContents failed, using fallback. Error: $ {
+ : surroundContents failed, using same-block fallback. Error: $ {
e.message
}
`);
+ if (!highlightRoot.contains(range.commonAncestorContainer)) {
+ return "JS: Highlight range escaped current block.";
+ }
const contents = range.extractContents();
highlightSpan.appendChild(contents);
range.insertNode(highlightSpan);
@@ -2924,6 +2945,29 @@
};
const HL_LOG_TAG = "HIGHLIGHT_DEBUG";
+ const HL_RENDER_LOG_TAG = "AndroidHighlightRenderDiag";
+
+ function hlRenderPreview(value, maxLength) {
+ if (value === undefined || value === null) return "";
+ var text = String(value).replace(/\s+/g, " ");
+ var max = maxLength || 100;
+ return text.length > max ? text.substring(0, max) : text;
+ }
+
+ function hlRenderLocatorLabel(locator) {
+ locator = locator || {};
+ return "locatorChapter=" + (locator.chapterIndex === undefined || locator.chapterIndex === null ? "null" : locator.chapterIndex) +
+ " locatorPage=" + (locator.pageIndex === undefined || locator.pageIndex === null ? "null" : locator.pageIndex) +
+ " locatorOffsets=" + (locator.startOffset === undefined || locator.startOffset === null ? "null" : locator.startOffset) +
+ ".." + (locator.endOffset === undefined || locator.endOffset === null ? "null" : locator.endOffset) +
+ " locatorBlock=" + (locator.blockIndex === undefined || locator.blockIndex === null ? "null" : locator.blockIndex) +
+ " locatorChar=" + (locator.charOffset === undefined || locator.charOffset === null ? "null" : locator.charOffset) +
+ " locatorCfi=" + hlRenderPreview(locator.cfi || "", 120);
+ }
+
+ function hlRenderLog(message) {
+ console.log(HL_RENDER_LOG_TAG + ": " + message);
+ }
window.HighlightBridgeHelper = {
updateHighlightStyle: function (cfi, newColorClass, colorId) {
@@ -3280,11 +3324,13 @@
try {
var highlights = JSON.parse(jsonArrayString);
var self = this;
+ hlRenderLog("webview_restore_start count=" + highlights.length);
highlights.forEach(function (h) {
- self.applyHighlight(h.cfi, h.text, h.cssClass);
+ self.applyHighlightObject(h);
});
} catch (e) {
+ hlRenderLog("webview_restore_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
console.log(
`$ {
HL_LOG_TAG
@@ -3295,136 +3341,376 @@
}
},
- applyHighlight: function (cfi, text, cssClass) {
+ applyHighlightObject: function (highlight) {
+ if (!highlight) return;
+ hlRenderLog(
+ "webview_apply_object id=" + (highlight.id || "") +
+ " cfi=" + hlRenderPreview(highlight.cfi || "", 120) +
+ " textLen=" + String(highlight.text || "").length +
+ " text='" + hlRenderPreview(highlight.text || "", 80) + "' " +
+ hlRenderLocatorLabel(highlight.locator || {})
+ );
+ this.applyHighlight(highlight.cfi, highlight.text, highlight.cssClass, highlight.locator || null);
+ },
+
+ highlightTextRoot: function () {
+ return document.getElementById("content-container") || document.body;
+ },
+
+ highlightTextNodes: function (root) {
+ var nodes = [];
+ if (!root) return nodes;
+ var walker = document.createTreeWalker(
+ root,
+ NodeFilter.SHOW_TEXT,
+ {
+ acceptNode: function (node) {
+ if (!node || !node.nodeValue) return NodeFilter.FILTER_REJECT;
+ var parent = node.parentElement;
+ if (!parent) return NodeFilter.FILTER_REJECT;
+ if (parent.closest && parent.closest("script, style, noscript")) return NodeFilter.FILTER_REJECT;
+ return NodeFilter.FILTER_ACCEPT;
+ },
+ },
+ false,
+ );
+ while (walker.nextNode()) nodes.push(walker.currentNode);
+ return nodes;
+ },
+
+ rangeFromTextOffsets: function (root, startOffset, endOffset) {
+ var start = parseInt(startOffset, 10);
+ var end = parseInt(endOffset, 10);
+ if (!root || !isFinite(start) || !isFinite(end) || end <= start) {
+ hlRenderLog(
+ "webview_range_offsets_skip reason=invalid_offsets start=" + startOffset +
+ " end=" + endOffset + " hasRoot=" + !!root
+ );
+ return null;
+ }
+
+ var nodes = this.highlightTextNodes(root);
+ var cursor = 0;
+ var startNode = null;
+ var startInNode = 0;
+ var endNode = null;
+ var endInNode = 0;
+
+ for (var i = 0; i < nodes.length; i++) {
+ var value = nodes[i].nodeValue || "";
+ var next = cursor + value.length;
+ if (!startNode && start >= cursor && start <= next) {
+ startNode = nodes[i];
+ startInNode = Math.max(0, Math.min(value.length, start - cursor));
+ }
+ if (startNode && end >= cursor && end <= next) {
+ endNode = nodes[i];
+ endInNode = Math.max(0, Math.min(value.length, end - cursor));
+ break;
+ }
+ cursor = next;
+ }
+
+ if (!startNode || !endNode) {
+ hlRenderLog(
+ "webview_range_offsets_skip reason=missing_boundary start=" + start +
+ " end=" + end + " nodes=" + nodes.length + " textCursor=" + cursor
+ );
+ return null;
+ }
+ var range = document.createRange();
+ range.setStart(startNode, startInNode);
+ range.setEnd(endNode, endInNode);
+ if (range.collapsed) {
+ hlRenderLog("webview_range_offsets_skip reason=collapsed start=" + start + " end=" + end);
+ return null;
+ }
+ hlRenderLog(
+ "webview_range_offsets_result start=" + start + " end=" + end +
+ " text='" + hlRenderPreview(range.toString(), 80) + "'"
+ );
+ return range;
+ },
+
+ rangeMatchesText: function (range, text) {
+ if (!range || !text) return true;
+ var actual = (range.toString() || "").trim();
+ var expected = String(text || "").trim();
+ if (!expected) return true;
+ return actual === expected || actual.replace(/\s+/g, " ") === expected.replace(/\s+/g, " ");
+ },
+
+ rangeFromVisibleTextSearch: function (text, locator) {
+ if (!text) {
+ hlRenderLog("webview_text_search_skip reason=empty_text " + hlRenderLocatorLabel(locator));
+ return null;
+ }
+ var root = this.highlightTextRoot();
+ var nodes = this.highlightTextNodes(root);
+ if (!nodes.length) {
+ hlRenderLog("webview_text_search_skip reason=no_nodes " + hlRenderLocatorLabel(locator));
+ return null;
+ }
+
+ var fullText = nodes.map(function (node) { return node.nodeValue || ""; }).join("");
+ var expected = String(text);
+ var candidates = [];
+ var index = fullText.indexOf(expected);
+ while (index !== -1) {
+ candidates.push(index);
+ index = fullText.indexOf(expected, index + 1);
+ }
+ if (!candidates.length) {
+ var lowerFull = fullText.toLowerCase();
+ var lowerExpected = expected.toLowerCase();
+ index = lowerFull.indexOf(lowerExpected);
+ while (index !== -1) {
+ candidates.push(index);
+ index = lowerFull.indexOf(lowerExpected, index + 1);
+ }
+ }
+ if (!candidates.length && expected.length > 20) {
+ var partial = expected.substring(0, Math.min(expected.length, 40));
+ index = fullText.indexOf(partial);
+ while (index !== -1) {
+ candidates.push(index);
+ index = fullText.indexOf(partial, index + 1);
+ }
+ }
+ if (!candidates.length) {
+ hlRenderLog(
+ "webview_text_search_skip reason=no_candidates textLen=" + expected.length +
+ " text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator)
+ );
+ return null;
+ }
+
+ var preferred = locator && locator.startOffset !== undefined && locator.startOffset !== null
+ ? parseInt(locator.startOffset, 10)
+ : candidates[0];
+ if (!isFinite(preferred)) preferred = candidates[0];
+ var best = candidates.reduce(function (currentBest, candidate) {
+ return Math.abs(candidate - preferred) < Math.abs(currentBest - preferred) ? candidate : currentBest;
+ }, candidates[0]);
+ hlRenderLog(
+ "webview_text_search_candidate count=" + candidates.length +
+ " preferred=" + preferred + " best=" + best +
+ " text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator)
+ );
+ return this.rangeFromTextOffsets(root, best, best + expected.length);
+ },
+
+ rangeFromLocator: function (locator, text) {
+ if (!locator) {
+ hlRenderLog("webview_locator_skip reason=missing_locator");
+ return null;
+ }
+ if (locator.cfi && String(locator.cfi).charAt(0) === "/") {
+ hlRenderLog(
+ "webview_locator_skip reason=structural_cfi_prefers_cfi " +
+ hlRenderLocatorLabel(locator)
+ );
+ return null;
+ }
+ var root = this.highlightTextRoot();
+ var range = this.rangeFromTextOffsets(root, locator.startOffset, locator.endOffset);
+ if (range && this.rangeMatchesText(range, text)) {
+ hlRenderLog(
+ "webview_locator_result matched=true text='" + hlRenderPreview(range.toString(), 80) + "' " +
+ hlRenderLocatorLabel(locator)
+ );
+ return range;
+ }
+ hlRenderLog(
+ "webview_locator_skip reason=" + (range ? "text_mismatch" : "range_missing") +
+ " rangeText='" + hlRenderPreview(range ? range.toString() : "", 80) + "' " +
+ "expected='" + hlRenderPreview(text || "", 80) + "' " + hlRenderLocatorLabel(locator)
+ );
+ return null;
+ },
+
+ rangeFromCfi: function (cfi, text) {
+ if (!cfi || cfi.indexOf("desktop:") === 0) {
+ hlRenderLog("webview_cfi_skip reason=unsupported cfi=" + hlRenderPreview(cfi || "", 120));
+ return null;
+ }
+ var sourceCfi = String(cfi).split("|")[0];
+ const location = window.getNodeAndOffsetFromCfi(sourceCfi);
+ if (!location || !location.node) {
+ hlRenderLog("webview_cfi_skip reason=missing_location cfi=" + hlRenderPreview(cfi, 120));
+ return null;
+ }
+
+ let startNode = location.node;
+ let startOffset = location.offset;
+
+ if (startNode.nodeType === Node.TEXT_NODE) {
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
+ walker.currentNode = startNode;
+
+ while (startNode && startOffset >= startNode.nodeValue.length) {
+ if (startOffset === startNode.nodeValue.length) {
+ const next = walker.nextNode();
+
+ if (next) {
+ startOffset -= startNode.nodeValue.length;
+ startNode = next;
+ } else {
+ break;
+ }
+ } else {
+ startOffset -= startNode.nodeValue.length;
+ startNode = walker.nextNode();
+ }
+ }
+ }
+
+ if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) {
+ const nodeVal = startNode.nodeValue;
+ const substring = nodeVal.substring(startOffset, startOffset + text.length);
+
+ if (substring !== text && substring.trim() !== text.trim()) {
+ hlRenderLog(
+ "webview_cfi_text_mismatch cfi=" + hlRenderPreview(cfi, 120) +
+ " startOffset=" + startOffset +
+ " actual='" + hlRenderPreview(substring, 80) + "'" +
+ " expected='" + hlRenderPreview(text, 80) + "'"
+ );
+ console.log("HIGHLIGHT_DEBUG: Text mismatch at CFI. Searching nearby.");
+ const foundIndex = nodeVal.indexOf(text);
+
+ if (foundIndex !== -1) {
+ hlRenderLog(
+ "webview_cfi_text_adjust reason=full_match oldStartOffset=" + startOffset +
+ " newStartOffset=" + foundIndex + " cfi=" + hlRenderPreview(cfi, 120)
+ );
+ startOffset = foundIndex;
+ } else {
+ const partial = text.substring(0, Math.min(text.length, 20));
+ const partialIndex = nodeVal.indexOf(partial);
+
+ if (partialIndex !== -1) {
+ hlRenderLog(
+ "webview_cfi_text_adjust reason=partial_match oldStartOffset=" + startOffset +
+ " newStartOffset=" + partialIndex + " cfi=" + hlRenderPreview(cfi, 120)
+ );
+ startOffset = partialIndex;
+ }
+ }
+ }
+ }
+
+ if (!startNode) {
+ hlRenderLog("webview_cfi_skip reason=missing_start_node cfi=" + hlRenderPreview(cfi, 120));
+ return null;
+ }
+
+ const range = document.createRange();
+
+ if (startNode.nodeType === Node.TEXT_NODE && startOffset > startNode.nodeValue.length) {
+ startOffset = Math.max(0, startNode.nodeValue.length - 1);
+ }
+
+ range.setStart(startNode, startOffset);
+
+ const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
+ treeWalker.currentNode = startNode;
+
+ let remainingLen = text.length;
+ let endNode = treeWalker.currentNode;
+ let endOffset = startOffset;
+
+ while (remainingLen > 0 && endNode) {
+ let avail = endNode.nodeValue.length - endOffset;
+
+ if (avail >= remainingLen) {
+ endOffset += remainingLen;
+ remainingLen = 0;
+ } else {
+ remainingLen -= avail;
+ endNode = treeWalker.nextNode();
+ endOffset = 0;
+ }
+ }
+
+ if (!endNode) {
+ hlRenderLog("webview_cfi_skip reason=missing_end_node cfi=" + hlRenderPreview(cfi, 120));
+ return null;
+ }
+ range.setEnd(endNode, endOffset);
+ if (range.collapsed) {
+ hlRenderLog("webview_cfi_skip reason=collapsed cfi=" + hlRenderPreview(cfi, 120));
+ return null;
+ }
+ hlRenderLog(
+ "webview_cfi_result cfi=" + hlRenderPreview(cfi, 120) +
+ " text='" + hlRenderPreview(range.toString(), 80) + "'"
+ );
+ return range;
+ },
+
+ applyHighlight: function (cfi, text, cssClass, locator) {
try {
+ cssClass = cssClass || "user-highlight-yellow";
var alreadyApplied = false;
var spans = document.querySelectorAll(`span[data-cfi]`);
for (var i = 0; i < spans.length; i++) {
- if ((spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) {
+ if (cfi && (spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) {
alreadyApplied = true;
break;
}
}
- if (alreadyApplied) return;
-
- const location = window.getNodeAndOffsetFromCfi(cfi);
- if (!location || !location.node) return;
-
- let startNode = location.node;
- let startOffset = location.offset;
-
- if (startNode.nodeType === Node.TEXT_NODE) {
- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
- walker.currentNode = startNode;
-
- while (startNode && startOffset >= startNode.nodeValue.length) {
- if (startOffset === startNode.nodeValue.length) {
- const next = walker.nextNode();
-
- if (next) {
- startOffset -= startNode.nodeValue.length;
- startNode = next;
- } else {
- break;
- }
- } else {
- startOffset -= startNode.nodeValue.length;
- startNode = walker.nextNode();
- }
- }
+ if (alreadyApplied) {
+ hlRenderLog(
+ "webview_apply_skip reason=already_applied cfi=" + hlRenderPreview(cfi || "", 120) +
+ " textLen=" + String(text || "").length + " " + hlRenderLocatorLabel(locator)
+ );
+ return;
}
- // 1. Text Verification / Healing
- if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) {
- const nodeVal = startNode.nodeValue;
- // Check if text matches at exact offset
- const substring = nodeVal.substring(startOffset, startOffset + text.length);
-
- // Allow for some whitespace looseness (trim comparison)
- if (substring !== text && substring.trim() !== text.trim()) {
- console.log(`$ {
- HL_LOG_TAG
- }
-
- : Text mismatch at CFI. Searching nearby... Expected: '${text.substring(0, 10)}...', Found: '${substring.substring(0, 10)}...' `);
-
- // Try finding the text in the whole node
- const foundIndex = nodeVal.indexOf(text);
-
- if (foundIndex !== -1) {
- console.log(`$ {
- HL_LOG_TAG
- }
-
- : Found text elsewhere in node. Adjusting offset from $ {
- startOffset
- }
-
- to $ {
- foundIndex
- }
-
- .`);
- startOffset = foundIndex;
- } else {
- // Simple fuzzy: Try finding first 20 chars
- const partial = text.substring(0, Math.min(text.length, 20));
- const partialIndex = nodeVal.indexOf(partial);
-
- if (partialIndex !== -1) {
- console.log(`$ {
- HL_LOG_TAG
- }
-
- : Found partial match. Adjusting offset.`);
- startOffset = partialIndex;
- }
- }
- }
+ var hasPreciseLocator = locator &&
+ locator.startOffset !== undefined && locator.startOffset !== null &&
+ locator.endOffset !== undefined && locator.endOffset !== null &&
+ parseInt(locator.endOffset, 10) > parseInt(locator.startOffset, 10);
+ hlRenderLog(
+ "webview_apply_start cfi=" + hlRenderPreview(cfi || "", 120) +
+ " textLen=" + String(text || "").length +
+ " cssClass=" + cssClass +
+ " hasPreciseLocator=" + !!hasPreciseLocator + " " +
+ hlRenderLocatorLabel(locator)
+ );
+ var rangeSource = "locator";
+ var sourceCfi = (locator && locator.cfi) || cfi;
+ var hasSourceCfi = sourceCfi && String(sourceCfi).charAt(0) === "/";
+ var range = hasSourceCfi ? null : this.rangeFromLocator(locator, text);
+ if (!range && hasSourceCfi) {
+ rangeSource = "cfi";
+ range = this.rangeFromCfi(sourceCfi, text || "");
}
-
- if (!startNode) return;
-
- const range = document.createRange();
-
- // Set Start
- if (startNode.nodeType === Node.TEXT_NODE) {
- // Ensure offset is valid
- if (startOffset > startNode.nodeValue.length) {
- startOffset = Math.max(0, startNode.nodeValue.length - 1);
- }
+ if (!range) {
+ rangeSource = "locator";
+ range = this.rangeFromLocator(locator, text);
}
-
- range.setStart(startNode, startOffset);
-
- const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
- treeWalker.currentNode = startNode;
-
- let currentNode = treeWalker.currentNode;
- let remainingOffset = startOffset;
- let remainingLen = text.length;
- let endNode = currentNode;
- let endOffset = startOffset;
-
- while (remainingLen > 0 && endNode) {
- let avail = endNode.nodeValue.length - endOffset;
-
- if (avail >= remainingLen) {
- endOffset += remainingLen;
- remainingLen = 0;
- } else {
- remainingLen -= avail;
- endNode = treeWalker.nextNode();
- endOffset = 0;
- }
+ if (!range && !hasPreciseLocator && !hasSourceCfi) {
+ rangeSource = "text_search";
+ range = this.rangeFromVisibleTextSearch(text || "", locator);
}
-
- if (endNode) {
- range.setEnd(endNode, endOffset);
- var normalizedRange = this.normalizeRangeBoundaries(range);
- this.highlightRangeSafe(normalizedRange, cssClass, cfi);
+ if (!range) {
+ hlRenderLog(
+ "webview_apply_skip reason=no_range cfi=" + hlRenderPreview(cfi || "", 120) +
+ " hasPreciseLocator=" + !!hasPreciseLocator + " " + hlRenderLocatorLabel(locator)
+ );
+ return;
}
+ var normalizedRange = this.normalizeRangeBoundaries(range);
+ this.highlightRangeSafe(normalizedRange, cssClass, cfi);
+ hlRenderLog(
+ "webview_apply_result applied=true cfi=" + hlRenderPreview(cfi || "", 120) +
+ " source=" + rangeSource +
+ " renderedText='" + hlRenderPreview(normalizedRange.toString(), 80) + "'"
+ );
} catch (e) {
+ hlRenderLog("webview_apply_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
console.log(e);
}
},
diff --git a/app/src/main/java/com/aryan/reader/LibraryModels.kt b/app/src/main/java/com/aryan/reader/LibraryModels.kt
deleted file mode 100644
index 6e5c3f1..0000000
--- a/app/src/main/java/com/aryan/reader/LibraryModels.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-package com.aryan.reader
-
-import com.aryan.reader.data.RecentFileItem
-import com.aryan.reader.shared.ReaderFeatureSurface
-import com.aryan.reader.shared.ReaderPlatform
-import com.aryan.reader.shared.SharedFileCapabilities
-
-typealias AddBooksSource = com.aryan.reader.shared.AddBooksSource
-typealias FileType = com.aryan.reader.shared.FileType
-typealias RenderMode = com.aryan.reader.shared.RenderMode
-typealias SortOrder = com.aryan.reader.shared.SortOrder
-typealias ReadStatusFilter = com.aryan.reader.shared.ReadStatusFilter
-typealias LibraryFilters = com.aryan.reader.shared.LibraryFilters
-typealias SyncedFolder = com.aryan.reader.shared.SyncedFolder
-typealias ShelfType = com.aryan.reader.shared.ShelfType
-
-internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
-internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID)
-internal val 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 fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? {
- return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID)
-}
-
-data class Shelf(
- val id: String,
- val name: String,
- val type: ShelfType,
- val books: List,
- val directBooks: List = books,
- val parentShelfId: String? = null,
- val childShelfIds: List = emptyList(),
- val depth: Int = 0,
- val sortKey: String = name.lowercase()
-) {
- val bookCount: Int get() = books.size
- val topBook: RecentFileItem? by lazy(LazyThreadSafetyMode.NONE) { books.maxByOrNull { it.timestamp } }
- val directBookCount: Int get() = directBooks.size
- val childShelfCount: Int get() = childShelfIds.size
-}
diff --git a/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt b/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt
deleted file mode 100644
index 41d0b80..0000000
--- a/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.aryan.reader.data
-
-import com.aryan.reader.toSharedBookItem
-import com.aryan.reader.shared.SmartCollectionEngine as SharedSmartCollectionEngine
-
-typealias SmartField = com.aryan.reader.shared.SmartField
-typealias SmartOperator = com.aryan.reader.shared.SmartOperator
-typealias SmartRule = com.aryan.reader.shared.SmartRule
-typealias SmartCollectionDefinition = com.aryan.reader.shared.SmartCollectionDefinition
-
-object SmartCollectionEngine {
- fun toJson(definition: SmartCollectionDefinition): String =
- SharedSmartCollectionEngine.toJson(definition)
-
- fun fromJson(json: String?): SmartCollectionDefinition? =
- SharedSmartCollectionEngine.fromJson(json)
-
- fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean =
- SharedSmartCollectionEngine.evaluate(book.toSharedBookItem(), definition)
-}
diff --git a/app/src/main/java/com/aryan/reader/epub/EpubUtils.kt b/app/src/main/java/com/aryan/reader/epub/EpubUtils.kt
deleted file mode 100644
index 9cffed3..0000000
--- a/app/src/main/java/com/aryan/reader/epub/EpubUtils.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Episteme Reader - A native Android document reader.
- * Copyright (C) 2026 Episteme
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- *
- * mail: epistemereader@gmail.com
- */
-package com.aryan.reader.epub
-
-import org.w3c.dom.Document
-import org.w3c.dom.Element
-import org.w3c.dom.Node
-import org.w3c.dom.NodeList
-import java.io.InputStream
-import javax.xml.parsers.DocumentBuilderFactory
-
-fun parseXMLFile(inputSteam: InputStream): Document? =
- DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(inputSteam)
-
-fun parseXMLFile(byteArray: ByteArray): Document? = parseXMLFile(byteArray.inputStream())
-
-fun String.asFileName(): String = this.replace("/", "_")
-
-fun Document.selectFirstTag(tag: String): Node? = getElementsByTagName(tag).item(0)
-fun Node.selectFirstChildTag(tag: String) = childElements.find { it.tagName == tag }
-fun Node.selectChildTag(tag: String) = childElements.filter { it.tagName == tag }
-fun Node.getAttributeValue(attribute: String): String? =
- attributes?.getNamedItem(attribute)?.textContent
-
-val NodeList.elements get() = (0..length).asSequence().mapNotNull { item(it) as? Element }
-val Node.childElements get() = childNodes.elements
-
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSearch.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSearch.kt
deleted file mode 100644
index a8595c3..0000000
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSearch.kt
+++ /dev/null
@@ -1,252 +0,0 @@
-/*
- * Episteme Reader - A native Android document reader.
- * Copyright (C) 2026 Episteme
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- *
- * mail: epistemereader@gmail.com
- */
-package com.aryan.reader.epubreader
-
-import timber.log.Timber
-import android.webkit.WebView
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.platform.LocalSoftwareKeyboardController
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
-import com.aryan.reader.RenderMode
-import com.aryan.reader.SearchNavigationControls
-import com.aryan.reader.SearchResult
-import com.aryan.reader.SearchResultsPanel
-import com.aryan.reader.SearchState
-import com.aryan.reader.epub.EpubBook
-import com.aryan.reader.epub.contentFilePath
-import com.aryan.reader.paginatedreader.IPaginator
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.jsoup.Jsoup
-import java.io.File
-import kotlin.math.max
-import kotlin.math.min
-
-/**
- * Creates the search implementation for EPUB chapters.
- */
-fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List = { query ->
- withContext(Dispatchers.Default) {
- val results = mutableListOf()
- epubBook.chapters.forEachIndexed { chapterIndex, chapter ->
- try {
- val htmlFile = File(epubBook.extractionBasePath, chapter.contentFilePath())
- if (!htmlFile.exists()) 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
-
- 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) {
- Timber.e("Failed to search in chapter $chapterIndex", e)
- }
- }
- results
- }
-}
-
-/**
- * Handles the navigation to a specific search result.
- */
-fun performSearchResultNavigation(
- index: Int,
- searchState: SearchState,
- renderMode: RenderMode,
- currentChapterIndex: Int,
- loadedChunkCount: Int,
- webView: WebView?,
- paginator: IPaginator?,
- coroutineScope: CoroutineScope,
- onVerticalChapterChange: (chapterIndex: Int, chunkIndex: Int, result: SearchResult) -> Unit,
- onVerticalScrollToResult: (result: SearchResult) -> Unit,
- onPaginatedScrollToPage: suspend (pageIndex: Int) -> Unit
-) {
- if (index !in searchState.searchResults.indices) return
-
- val result = searchState.searchResults[index]
- searchState.currentSearchResultIndex = index
-
- when (renderMode) {
- RenderMode.VERTICAL_SCROLL -> {
- if (currentChapterIndex != result.locationInSource) {
- onVerticalChapterChange(result.locationInSource, result.chunkIndex, result)
- } else {
- if (result.chunkIndex >= loadedChunkCount) {
- onVerticalChapterChange(result.locationInSource, result.chunkIndex, result)
- } else {
- webView?.let {
- val js = "javascript:window.scrollToOccurrence(${result.occurrenceIndexInLocation});"
- it.evaluateJavascript(js, null)
- }
- onVerticalScrollToResult(result)
- }
- }
- }
-
- RenderMode.PAGINATED -> {
- paginator?.findPageForSearchResult(result) { pageIndex ->
- coroutineScope.launch {
- onPaginatedScrollToPage(pageIndex)
- }
- }
- }
- }
-}
-
-@Composable
-fun EpubReaderSearchEffects(
- searchState: SearchState,
- webViewRef: WebView?,
- currentChapterIndex: Int,
- focusRequester: FocusRequester
-) {
- // 1. Auto-Highlight in WebView
- LaunchedEffect(searchState.searchResults, currentChapterIndex) {
- val query = searchState.searchQuery
- if (query.isBlank()) {
- webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
- return@LaunchedEffect
- }
-
- val resultsInCurrentChapter = searchState.searchResults.any { it.locationInSource == currentChapterIndex }
- if (resultsInCurrentChapter) {
- webViewRef?.let { webView ->
- val escapedQuery = escapeJsString(query)
- val js = "javascript:window.highlightAllOccurrences('${escapedQuery}');"
- Timber.d("Highligting: $js")
- webView.evaluateJavascript(js, null)
- }
- } else {
- webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
- }
- }
-
- // 2. Focus Management
- LaunchedEffect(searchState.isSearchActive) {
- if (searchState.isSearchActive) {
- delay(100)
- focusRequester.requestFocus()
- } else {
- webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
- }
- }
-}
-
-@Composable
-fun EpubReaderSearchOverlay(
- searchState: SearchState,
- onNavigateResult: (Int) -> Unit,
- bottomPadding: Dp
-) {
- val keyboardController = LocalSoftwareKeyboardController.current
-
- androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
-
- // Search Results Panel
- AnimatedVisibility(
- visible = searchState.isSearchActive && searchState.showSearchResultsPanel,
- enter = slideInVertically { -it } + fadeIn(),
- exit = slideOutVertically { -it } + fadeOut(),
- ) {
- SearchResultsPanel(
- results = searchState.searchResults,
- isSearching = searchState.isSearchInProgress,
- onResultClick = { result ->
- val resultIndex = searchState.searchResults.indexOf(result)
- if (resultIndex != -1) {
- onNavigateResult(resultIndex)
- }
- searchState.showSearchResultsPanel = false
- keyboardController?.hide()
- },
- modifier = Modifier.padding(top = 50.dp)
- )
- }
-
- AnimatedVisibility(
- visible = searchState.isSearchActive && !searchState.showSearchResultsPanel && searchState.hasResults,
- enter = fadeIn(),
- exit = fadeOut(),
- modifier = Modifier
- .align(Alignment.BottomEnd)
- .padding(bottom = bottomPadding + 45.dp + 16.dp, end = 16.dp)
- ) {
- SearchNavigationControls(
- searchState = searchState,
- onNavigate = { index -> onNavigateResult(index) }
- )
- }
- }
-}
diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt b/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt
deleted file mode 100644
index ca5807e..0000000
--- a/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.aryan.reader.opds
-
-typealias OpdsCatalog = com.aryan.reader.shared.opds.OpdsCatalog
-typealias OpdsFacet = com.aryan.reader.shared.opds.OpdsFacet
-typealias OpdsFeed = com.aryan.reader.shared.opds.OpdsFeed
-typealias OpdsAuthor = com.aryan.reader.shared.opds.OpdsAuthor
-typealias OpdsAcquisition = com.aryan.reader.shared.opds.OpdsAcquisition
-typealias OpdsEntry = com.aryan.reader.shared.opds.OpdsEntry
-typealias OpdsDownloadState = com.aryan.reader.shared.opds.SharedOpdsDownloadState
-typealias OpdsScreenState = com.aryan.reader.shared.opds.SharedOpdsScreenState
diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt b/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt
deleted file mode 100644
index 4f911c5..0000000
--- a/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.aryan.reader.opds
-
-typealias OpdsParser = com.aryan.reader.shared.opds.SharedOpdsParser
diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationRepository.kt b/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationRepository.kt
deleted file mode 100644
index 73ea063..0000000
--- a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationRepository.kt
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
- * Episteme Reader - A native Android document reader.
- * Copyright (C) 2026 Episteme
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- *
- * mail: epistemereader@gmail.com
- */
-package com.aryan.reader.pdf.data
-
-import android.content.Context
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import timber.log.Timber
-import java.io.File
-
-class PdfAnnotationRepository(private val context: Context) {
-
- private fun getFile(bookId: String): File {
- val safeBookId = bookId.replace("/", "_")
- val dir = File(context.filesDir, "annotations")
- if (!dir.exists()) dir.mkdirs()
- return File(dir, "annotation_$safeBookId.json")
- }
-
- suspend fun saveAnnotations(bookId: String, annotations: Map>) {
- withContext(Dispatchers.IO) {
- try {
- Timber.tag("AnnotationSync").d("Start saving local JSON for $bookId. Count: ${annotations.size}")
-
- if (annotations.isEmpty()) {
- val file = getFile(bookId)
- if (file.exists()) file.delete()
- return@withContext
- }
-
- val json = AnnotationSerializer.toJson(annotations)
- val file = getFile(bookId)
- file.writeText(json)
-
- Timber.tag("AnnotationSync").d("Finished saving local JSON for $bookId. Path: ${file.absolutePath}, Size: ${file.length()}")
- } catch (e: Exception) {
- Timber.tag("AnnotationSync").e(e, "Failed to save local annotations")
- }
- }
- }
-
- suspend fun loadAnnotations(bookId: String): Map> {
- return withContext(Dispatchers.IO) {
- try {
- val file = getFile(bookId)
- if (file.exists()) {
- val json = file.readText()
- Timber.tag("AnnotationSync").d("Loaded local JSON for $bookId. Size: ${file.length()}")
- AnnotationSerializer.fromJson(json)
- } else {
- Timber.tag("AnnotationSync").d("No local annotation file found for $bookId")
- emptyMap()
- }
- } catch (e: Exception) {
- Timber.tag("AnnotationSync").e(e, "Failed to load local annotations")
- emptyMap()
- }
- }
- }
-
- fun getAnnotationFileForSync(bookId: String): File? {
- val file = getFile(bookId)
- val valid = file.exists() && file.length() > 0
-
- Timber.tag("AnnotationSync").d("Checking file for sync: $bookId. Exists: ${file.exists()}, Size: ${file.length()} bytes. Valid: $valid")
-
- return if (valid) file else null
- }
-}
diff --git a/app/src/main/java/com/aryan/reader/tts/TtsNotificationIntent.kt b/app/src/main/java/com/aryan/reader/tts/TtsNotificationIntent.kt
deleted file mode 100644
index 9e828c5..0000000
--- a/app/src/main/java/com/aryan/reader/tts/TtsNotificationIntent.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.aryan.reader.tts
-
-const val ACTION_OPEN_TTS_SESSION = "com.aryan.reader.tts.OPEN_SESSION"
-const val EXTRA_TTS_BOOK_ID = "com.aryan.reader.tts.extra.BOOK_ID"
-const val EXTRA_TTS_CHAPTER_INDEX = "com.aryan.reader.tts.extra.CHAPTER_INDEX"
-const val EXTRA_TTS_SOURCE_CFI = "com.aryan.reader.tts.extra.SOURCE_CFI"
-const val EXTRA_TTS_START_OFFSET = "com.aryan.reader.tts.extra.START_OFFSET"
-const val EXTRA_TTS_PAGE_INDEX = "com.aryan.reader.tts.extra.PAGE_INDEX"
diff --git a/app/src/main/java/com/aryan/reader/AiSettingsScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/AiSettingsScreen.kt
similarity index 99%
rename from app/src/main/java/com/aryan/reader/AiSettingsScreen.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/AiSettingsScreen.kt
index ecf9c61..1a95ac5 100644
--- a/app/src/main/java/com/aryan/reader/AiSettingsScreen.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/AiSettingsScreen.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
diff --git a/app/src/main/java/com/aryan/reader/AndroidFolderPathResolver.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/AndroidFolderPathResolver.kt
similarity index 95%
rename from app/src/main/java/com/aryan/reader/AndroidFolderPathResolver.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/AndroidFolderPathResolver.kt
index c03bdeb..197ffef 100644
--- a/app/src/main/java/com/aryan/reader/AndroidFolderPathResolver.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/AndroidFolderPathResolver.kt
@@ -1,8 +1,8 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.net.Uri
import androidx.core.net.toUri
-import com.aryan.reader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.RecentFileItem
import timber.log.Timber
class AndroidFolderPathResolver : FolderPathResolver {
diff --git a/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/AndroidSettingsHubModels.kt
similarity index 86%
rename from app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/AndroidSettingsHubModels.kt
index f050e92..7cb6a84 100644
--- a/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/AndroidSettingsHubModels.kt
@@ -1,8 +1,8 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
-import com.aryan.reader.shared.SharedFeaturePolicy
-import com.aryan.reader.shared.SharedSettingsHubInput
-import com.aryan.reader.shared.SharedSettingsPlatform
+import org.dueattendant149.bookreader.shared.SharedFeaturePolicy
+import org.dueattendant149.bookreader.shared.SharedSettingsHubInput
+import org.dueattendant149.bookreader.shared.SharedSettingsPlatform
fun androidSettingsHubInput(
uiState: ReaderScreenState,
@@ -15,6 +15,8 @@ fun androidSettingsHubInput(
val supportsOssAiKeys = isOssBuild && !isOfflineBuild
val featurePolicy = if (isOfflineBuild) {
SharedFeaturePolicy.OssOffline
+ } else if (isOssBuild) {
+ SharedFeaturePolicy.OssOnline
} else {
SharedFeaturePolicy.Standard
}
diff --git a/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/AndroidSharedStateBridge.kt
similarity index 91%
rename from app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/AndroidSharedStateBridge.kt
index 4f0bb68..65a002e 100644
--- a/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/AndroidSharedStateBridge.kt
@@ -1,14 +1,14 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
-import com.aryan.reader.data.RecentFileItem
-import com.aryan.reader.data.TagEntity
-import com.aryan.reader.shared.AppAction as SharedAppAction
-import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
-import com.aryan.reader.shared.SharedFolderPathResolver
-import com.aryan.reader.shared.SharedLibraryProjectionInput
-import com.aryan.reader.shared.SharedLibraryStateProjector
-import com.aryan.reader.shared.SharedReaderScreenState
-import com.aryan.reader.shared.reduce
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.TagEntity
+import org.dueattendant149.bookreader.shared.AppAction as SharedAppAction
+import org.dueattendant149.bookreader.shared.LibraryAction as SharedLibraryAction
+import org.dueattendant149.bookreader.shared.SharedFolderPathResolver
+import org.dueattendant149.bookreader.shared.SharedLibraryProjectionInput
+import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector
+import org.dueattendant149.bookreader.shared.SharedReaderScreenState
+import org.dueattendant149.bookreader.shared.reduce
internal object AndroidSharedStateBridge {
fun prepareLibraryProjection(
@@ -23,7 +23,8 @@ internal object AndroidSharedStateBridge {
val sharedInput = SharedLibraryProjectionInput(
state = projectionState.toSharedReaderScreenState(
rawBooks = taggedBooks,
- dbTags = input.dbTags
+ dbTags = input.dbTags,
+ includeReaderAnnotations = false
),
booksFromStore = taggedBooks
.filterNot { it.bookId.endsWith("_reflow") }
@@ -195,7 +196,8 @@ internal object AndroidSharedStateBridge {
private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState {
return toSharedReaderScreenState(
rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles },
- dbTags = projectedState.allTags.ifEmpty { allTags }
+ dbTags = projectedState.allTags.ifEmpty { allTags },
+ includeReaderAnnotations = false
)
}
diff --git a/app/src/main/java/com/aryan/reader/AppFontResolver.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/AppFontResolver.kt
similarity index 89%
rename from app/src/main/java/com/aryan/reader/AppFontResolver.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/AppFontResolver.kt
index 597c650..eab48dc 100644
--- a/app/src/main/java/com/aryan/reader/AppFontResolver.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/AppFontResolver.kt
@@ -1,8 +1,8 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
-import com.aryan.reader.data.CustomFontEntity
+import org.dueattendant149.bookreader.data.CustomFontEntity
import java.io.File
fun AppFontPreference.toAndroidAppFontFamily(customFonts: List): FontFamily? {
diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt
similarity index 90%
rename from app/src/main/java/com/aryan/reader/AppNavigation.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt
index 5f4bbee..dd9963e 100644
--- a/app/src/main/java/com/aryan/reader/AppNavigation.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt
@@ -17,10 +17,11 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.os.Build
import timber.log.Timber
+import androidx.activity.compose.BackHandler
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@@ -58,14 +59,14 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
-import com.aryan.reader.epubreader.EpubReaderScreen
-import com.aryan.reader.feedback.FeedbackScreen
-import com.aryan.reader.feedback.SupportProjectScreen
-import com.aryan.reader.pdf.PdfViewerScreen
-import com.aryan.reader.shared.ReaderFeatureSurface
-import com.aryan.reader.tts.ReaderTtsMiniBar
-import com.aryan.reader.tts.readerTtsMiniBarBottomPaddingDp
-import com.aryan.reader.tts.shouldShowReaderTtsMiniBar
+import org.dueattendant149.bookreader.epubreader.EpubReaderScreen
+import org.dueattendant149.bookreader.feedback.FeedbackScreen
+import org.dueattendant149.bookreader.feedback.SupportProjectScreen
+import org.dueattendant149.bookreader.pdf.PdfViewerScreen
+import org.dueattendant149.bookreader.shared.ReaderFeatureSurface
+import org.dueattendant149.bookreader.tts.ReaderTtsMiniBar
+import org.dueattendant149.bookreader.tts.readerTtsMiniBarBottomPaddingDp
+import org.dueattendant149.bookreader.tts.shouldShowReaderTtsMiniBar
import kotlinx.coroutines.delay
object AppDestinations {
@@ -80,6 +81,18 @@ object AppDestinations {
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
}
+fun shouldInterceptAppNavBack(
+ currentRoute: String?,
+ hasPreviousBackStackEntry: Boolean,
+ isCurrentEntryResumed: Boolean
+): Boolean {
+ if (!hasPreviousBackStackEntry || !isCurrentEntryResumed) return false
+ return currentRoute != null &&
+ currentRoute != AppDestinations.MAIN_ROUTE &&
+ currentRoute != AppDestinations.PDF_VIEWER_ROUTE &&
+ currentRoute != AppDestinations.EPUB_READER_ROUTE
+}
+
private fun NavHostController.isReadyForBackStackChange(): Boolean {
return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
}
@@ -166,6 +179,11 @@ fun AppNavigation(
val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp(
isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE
).dp
+ val shouldInterceptBack = shouldInterceptAppNavBack(
+ currentRoute = currentRoute,
+ hasPreviousBackStackEntry = navController.previousBackStackEntry != null,
+ isCurrentEntryResumed = currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
+ )
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) {
@@ -195,14 +213,24 @@ fun AppNavigation(
}
Box(modifier = Modifier.fillMaxSize()) {
+ BackHandler(enabled = shouldInterceptBack) {
+ navController.popBackStackIfReady()
+ }
+
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
composable(AppDestinations.MAIN_ROUTE) {
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
- MainScreen(
- viewModel = viewModel,
- windowSizeClass = windowSizeClass,
- navController = navController
- )
+ if (uiState.isTemporaryExternalOpen) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ } else {
+ MainScreen(
+ viewModel = viewModel,
+ windowSizeClass = windowSizeClass,
+ navController = navController
+ )
+ }
}
// PDF Viewer Screen Composable
@@ -315,7 +343,7 @@ fun AppNavigation(
},
onRenderModeChange = viewModel::setRenderMode,
customFonts = customFonts,
- onImportFont = viewModel::importFont,
+ onImportFonts = viewModel::importFonts,
viewModel = viewModel
)
diff --git a/app/src/main/java/com/aryan/reader/AppUiModels.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/AppUiModels.kt
similarity index 81%
rename from app/src/main/java/com/aryan/reader/AppUiModels.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/AppUiModels.kt
index f5236d6..b69de1b 100644
--- a/app/src/main/java/com/aryan/reader/AppUiModels.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/AppUiModels.kt
@@ -1,20 +1,20 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.net.Uri
-import com.aryan.reader.data.RecentFileItem
-import com.aryan.reader.data.TagEntity
-import com.aryan.reader.epub.CalibreBundleResult
-import com.aryan.reader.epub.EpubBook
-import com.aryan.reader.paginatedreader.Locator
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.TagEntity
+import org.dueattendant149.bookreader.epub.CalibreBundleResult
+import org.dueattendant149.bookreader.epub.EpubBook
+import org.dueattendant149.bookreader.paginatedreader.Locator
import java.util.Date
-typealias BannerMessage = com.aryan.reader.shared.BannerMessage
-typealias UserData = com.aryan.reader.shared.UserData
-typealias AppThemeMode = com.aryan.reader.shared.AppThemeMode
-typealias AppContrastOption = com.aryan.reader.shared.AppContrastOption
-typealias AppFontPreference = com.aryan.reader.shared.AppFontPreference
-typealias AppFontPreferenceKind = com.aryan.reader.shared.AppFontPreferenceKind
-typealias CustomAppTheme = com.aryan.reader.shared.CustomAppTheme
+typealias BannerMessage = org.dueattendant149.bookreader.shared.BannerMessage
+typealias UserData = org.dueattendant149.bookreader.shared.UserData
+typealias AppThemeMode = org.dueattendant149.bookreader.shared.AppThemeMode
+typealias AppContrastOption = org.dueattendant149.bookreader.shared.AppContrastOption
+typealias AppFontPreference = org.dueattendant149.bookreader.shared.AppFontPreference
+typealias AppFontPreferenceKind = org.dueattendant149.bookreader.shared.AppFontPreferenceKind
+typealias CustomAppTheme = org.dueattendant149.bookreader.shared.CustomAppTheme
data class ImportResult(
val internalUri: Uri,
@@ -43,6 +43,7 @@ data class ReaderScreenState(
val selectedEpubUri: Uri? = null,
val selectedFileType: FileType? = null,
val isLoading: Boolean = false,
+ val isTemporaryExternalOpen: Boolean = false,
val errorMessage: String? = null,
val contextualActionItems: Set = emptySet(),
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
diff --git a/app/src/main/java/com/aryan/reader/BookImporter.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/BookImporter.kt
similarity index 97%
rename from app/src/main/java/com/aryan/reader/BookImporter.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/BookImporter.kt
index 5aae54b..3c25a9e 100644
--- a/app/src/main/java/com/aryan/reader/BookImporter.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/BookImporter.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.content.Context
import android.net.Uri
@@ -29,7 +29,7 @@ import java.io.FileOutputStream
import java.io.InputStream
import java.util.UUID
import androidx.core.net.toUri
-import com.aryan.reader.shared.SharedFileCapabilities
+import org.dueattendant149.bookreader.shared.SharedFileCapabilities
private const val BOOKS_DIR = "books"
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/BookReplacementStore.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/BookReplacementStore.kt
new file mode 100644
index 0000000..36fd4ce
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/BookReplacementStore.kt
@@ -0,0 +1,80 @@
+package org.dueattendant149.bookreader
+
+import android.content.Context
+import androidx.core.content.edit
+import org.dueattendant149.bookreader.shared.ReaderBookReplacementPreferences
+import org.dueattendant149.bookreader.shared.ReaderBookReplacementPreferencesJson
+import org.dueattendant149.bookreader.shared.ReaderWordReplacementEngine
+import org.dueattendant149.bookreader.shared.ReaderWordReplacementRule
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Node
+import org.jsoup.nodes.TextNode
+
+private const val READER_PREFS_NAME = "reader_prefs"
+private const val BOOK_REPLACEMENTS_KEY = "book_word_replacements_json"
+
+fun loadBookReplacementPreferences(context: Context): ReaderBookReplacementPreferences {
+ val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
+ return ReaderBookReplacementPreferencesJson.decodeOrEmpty(prefs.getString(BOOK_REPLACEMENTS_KEY, null))
+}
+
+fun saveBookReplacementPreferences(
+ context: Context,
+ preferences: ReaderBookReplacementPreferences,
+) {
+ val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
+ prefs.edit {
+ putString(BOOK_REPLACEMENTS_KEY, ReaderBookReplacementPreferencesJson.encode(preferences))
+ }
+}
+
+internal fun applyBookReplacementsToHtmlDocument(
+ document: Document,
+ preferences: ReaderBookReplacementPreferences,
+ fileId: String?,
+): Boolean {
+ val rules = preferences.activeRulesForFile(fileId)
+ if (rules.isEmpty()) return false
+
+ var changed = false
+
+ fun rewriteTextNodes(node: Node) {
+ if (node is TextNode && !node.hasReplacementBlockedAncestor()) {
+ val original = node.wholeText
+ val replaced = applyBookReplacementRules(original, rules)
+ if (replaced != original) {
+ node.text(replaced)
+ changed = true
+ }
+ return
+ }
+
+ node.childNodes().forEach(::rewriteTextNodes)
+ }
+
+ document.body()?.let(::rewriteTextNodes)
+ return changed
+}
+
+private fun applyBookReplacementRules(
+ text: String,
+ rules: List,
+): String {
+ return ReaderWordReplacementEngine.apply(
+ text = text,
+ rules = rules,
+ ).text
+}
+
+private fun TextNode.hasReplacementBlockedAncestor(): Boolean {
+ var current: Node? = parent()
+ while (current != null) {
+ when (current.nodeName().lowercase()) {
+ "script",
+ "style",
+ "noscript" -> return true
+ }
+ current = current.parent()
+ }
+ return false
+}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/BookWordReplacementsSheet.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/BookWordReplacementsSheet.kt
new file mode 100644
index 0000000..5420a63
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/BookWordReplacementsSheet.kt
@@ -0,0 +1,400 @@
+package org.dueattendant149.bookreader
+
+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 org.dueattendant149.bookreader.shared.ReaderBookReplacementEngine
+import org.dueattendant149.bookreader.shared.ReaderBookReplacementPreferences
+import org.dueattendant149.bookreader.shared.ReaderWordReplacementRule
+
+private data class BookRuleEditTarget(
+ val ruleId: String? = null,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BookWordReplacementsSheet(
+ isVisible: Boolean,
+ bookId: String,
+ bookTitle: String?,
+ preferences: ReaderBookReplacementPreferences,
+ onPreferencesChange: (ReaderBookReplacementPreferences) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ if (!isVisible) return
+
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ var editTarget by remember(bookId) { mutableStateOf(null) }
+ val rules = preferences.rulesForFile(bookId)
+ val editingRule = editTarget?.ruleId?.let { id -> rules.firstOrNull { it.id == id } }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 720.dp)
+ .imePadding()
+ .padding(horizontal = 20.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.menu_book_word_replacements),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = bookTitle?.takeIf { it.isNotBlank() } ?: stringResource(R.string.book_replacements_current_book),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ IconButton(onClick = onDismiss) {
+ Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ LazyColumn(
+ modifier = Modifier.heightIn(max = 560.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ TextButton(
+ onClick = { editTarget = BookRuleEditTarget() },
+ ) {
+ Icon(Icons.Default.Add, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.book_replacements_add_rule))
+ }
+ }
+ if (editTarget != null) {
+ item {
+ BookRuleEditorCard(
+ seedRule = editingRule,
+ onCancel = { editTarget = null },
+ onSave = { rule ->
+ val updatedRules = if (editingRule == null) {
+ rules + rule
+ } else {
+ rules.map { if (it.id == editingRule.id) rule else it }
+ }
+ onPreferencesChange(preferences.withFileRules(bookId, updatedRules))
+ editTarget = null
+ },
+ )
+ }
+ }
+ item {
+ BookReplacementRuleList(
+ rules = rules,
+ emptyTextRes = R.string.book_replacements_empty,
+ onToggle = { rule, enabled ->
+ onPreferencesChange(
+ preferences.withFileRules(
+ bookId,
+ rules.map { if (it.id == rule.id) it.copy(enabled = enabled) else it },
+ ),
+ )
+ },
+ onEdit = { editTarget = BookRuleEditTarget(it.id) },
+ onDelete = { rule ->
+ onPreferencesChange(preferences.withFileRules(bookId, rules.filterNot { it.id == rule.id }))
+ },
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+}
+
+@Composable
+private fun BookRuleEditorCard(
+ seedRule: ReaderWordReplacementRule?,
+ onCancel: () -> Unit,
+ onSave: (ReaderWordReplacementRule) -> Unit,
+) {
+ val draftRuleId = remember(seedRule?.id) { seedRule?.id ?: newBookReplacementRuleId() }
+ val initial = seedRule ?: ReaderWordReplacementRule(
+ id = draftRuleId,
+ from = "",
+ to = "",
+ )
+ var from by remember(initial.id) { mutableStateOf(initial.from) }
+ var to by remember(initial.id) { mutableStateOf(initial.to) }
+ var enabled by remember(initial.id) { mutableStateOf(initial.enabled) }
+ var isRegex by remember(initial.id) { mutableStateOf(initial.isRegex) }
+ var wholeWord by remember(initial.id) { mutableStateOf(initial.wholeWord) }
+ var matchCase by remember(initial.id) { mutableStateOf(initial.matchCase) }
+ val defaultPreviewInput = stringResource(R.string.book_replacements_preview_default)
+ var previewInput by remember(initial.id, defaultPreviewInput) {
+ mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: defaultPreviewInput)
+ }
+
+ val draft = ReaderWordReplacementRule(
+ id = initial.id,
+ from = from,
+ to = to,
+ enabled = enabled,
+ isRegex = isRegex,
+ matchCase = matchCase,
+ wholeWord = wholeWord,
+ )
+ val validation = ReaderBookReplacementEngine.validate(draft)
+ val previewOutput = if (validation.isValid) {
+ ReaderBookReplacementEngine.apply(
+ text = previewInput,
+ preferences = ReaderBookReplacementPreferences(fileRules = mapOf("preview" to listOf(draft.copy(enabled = true)))),
+ fileId = "preview",
+ ).text
+ } else {
+ previewInput
+ }
+
+ Card(
+ shape = RoundedCornerShape(8.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.35f)),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(if (seedRule == null) R.string.book_replacements_new_replacement else R.string.book_replacements_edit_replacement),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ OutlinedTextField(
+ value = from,
+ onValueChange = { from = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.tts_replacements_label_replace)) },
+ singleLine = !isRegex,
+ isError = !validation.isValid,
+ supportingText = if (validation.message != null) {
+ { Text(validation.message.orEmpty()) }
+ } else {
+ null
+ },
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ keyboardType = KeyboardType.Text,
+ ),
+ )
+ OutlinedTextField(
+ value = to,
+ onValueChange = { to = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.book_replacements_label_with)) },
+ singleLine = !isRegex,
+ )
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ item {
+ FilterChip(
+ selected = enabled,
+ onClick = { enabled = !enabled },
+ label = { Text(stringResource(R.string.tts_replacements_chip_enabled)) },
+ leadingIcon = if (enabled) {
+ { Icon(Icons.Default.Check, contentDescription = null) }
+ } else {
+ null
+ },
+ )
+ }
+ item {
+ FilterChip(
+ selected = isRegex,
+ onClick = { isRegex = !isRegex },
+ label = { Text(stringResource(R.string.tts_replacements_chip_regex)) },
+ )
+ }
+ item {
+ FilterChip(
+ selected = wholeWord,
+ onClick = { wholeWord = !wholeWord },
+ label = { Text(stringResource(R.string.tts_replacements_chip_whole_word)) },
+ )
+ }
+ item {
+ FilterChip(
+ selected = matchCase,
+ onClick = { matchCase = !matchCase },
+ label = { Text(stringResource(R.string.tts_replacements_chip_match_case)) },
+ )
+ }
+ }
+ OutlinedTextField(
+ value = previewInput,
+ onValueChange = { previewInput = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.tts_replacements_label_preview_input)) },
+ minLines = 2,
+ )
+ Text(
+ text = previewOutput,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ TextButton(onClick = onCancel) {
+ Text(stringResource(R.string.action_cancel))
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Button(
+ onClick = { onSave(draft) },
+ enabled = validation.isValid,
+ ) {
+ Text(stringResource(R.string.action_save))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun BookReplacementRuleList(
+ rules: List,
+ @StringRes emptyTextRes: Int,
+ onToggle: (ReaderWordReplacementRule, Boolean) -> Unit,
+ onEdit: (ReaderWordReplacementRule) -> Unit,
+ onDelete: (ReaderWordReplacementRule) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = stringResource(R.string.tts_replacements_rules),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ )
+ if (rules.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(emptyTextRes),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ return
+ }
+ rules.forEach { rule ->
+ val emptyLabel = stringResource(R.string.book_replacements_empty_replacement)
+ ListItem(
+ headlineContent = {
+ Text(
+ text = rule.summaryText(emptyLabel),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ supportingContent = {
+ Text(rule.optionSummary())
+ },
+ trailingContent = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Switch(
+ checked = rule.enabled,
+ onCheckedChange = { onToggle(rule, it) },
+ )
+ IconButton(onClick = { onEdit(rule) }) {
+ Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.action_edit))
+ }
+ IconButton(onClick = { onDelete(rule) }) {
+ Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete))
+ }
+ }
+ },
+ )
+ }
+ }
+}
+
+private fun ReaderWordReplacementRule.summaryText(emptyLabel: String): String {
+ val replacement = to.ifBlank { emptyLabel }
+ return "$from -> $replacement"
+}
+
+@Composable
+private fun ReaderWordReplacementRule.optionSummary(): String {
+ val regexLabel = stringResource(R.string.tts_replacements_chip_regex)
+ val plainTextLabel = stringResource(R.string.tts_replacements_plain_text)
+ val wholeWordLabel = stringResource(R.string.tts_replacements_chip_whole_word)
+ val caseSensitiveLabel = stringResource(R.string.tts_replacements_case_sensitive)
+ val parts = buildList {
+ add(if (isRegex) regexLabel else plainTextLabel)
+ if (wholeWord) add(wholeWordLabel)
+ if (matchCase) add(caseSensitiveLabel)
+ }
+ return parts.joinToString(" - ")
+}
+
+private fun newBookReplacementRuleId(): String {
+ return "book_rule_${System.currentTimeMillis()}"
+}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/ClipboardUtils.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ClipboardUtils.kt
new file mode 100644
index 0000000..b045235
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ClipboardUtils.kt
@@ -0,0 +1,31 @@
+package org.dueattendant149.bookreader
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import timber.log.Timber
+
+internal fun copyPlainTextToClipboard(
+ context: Context,
+ label: String,
+ text: String
+): Boolean {
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText(label, text)
+ return setPrimaryClipSafely {
+ clipboard.setPrimaryClip(clip)
+ }
+}
+
+internal inline fun setPrimaryClipSafely(setPrimaryClip: () -> Unit): Boolean {
+ return try {
+ setPrimaryClip()
+ true
+ } catch (e: SecurityException) {
+ Timber.w(e, "Clipboard write rejected by system policy")
+ false
+ } catch (e: RuntimeException) {
+ Timber.w(e, "Clipboard write failed")
+ false
+ }
+}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/CloudEpubAnnotationMetadata.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudEpubAnnotationMetadata.kt
new file mode 100644
index 0000000..4dac732
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudEpubAnnotationMetadata.kt
@@ -0,0 +1,51 @@
+package org.dueattendant149.bookreader
+
+import org.dueattendant149.bookreader.data.BookMetadata
+import org.dueattendant149.bookreader.data.RecentFileItem
+
+internal fun RecentFileItem.needsRemoteEpubAnnotationMetadataGuard(): Boolean {
+ return type in EPUB_READER_FILE_TYPES &&
+ (bookmarksJson.isNullOrBlank() || highlightsJson.isNullOrBlank())
+}
+
+internal fun RecentFileItem.mergeRemoteEpubAnnotationMetadata(remote: BookMetadata?): RecentFileItem {
+ if (remote == null || remote.isDeleted || type !in EPUB_READER_FILE_TYPES || !remote.isEpubReaderMetadata()) {
+ return this
+ }
+ val nextBookmarks = if (bookmarksJson.isNullOrBlank() && remote.bookmarksJson.hasCloudAnnotationPayload()) {
+ remote.bookmarksJson
+ } else {
+ bookmarksJson
+ }
+ val nextHighlights = if (highlightsJson.isNullOrBlank() && remote.highlightsJson.hasCloudAnnotationPayload()) {
+ remote.highlightsJson
+ } else {
+ highlightsJson
+ }
+ if (nextBookmarks == bookmarksJson && nextHighlights == highlightsJson) return this
+ return copy(
+ bookmarksJson = nextBookmarks,
+ highlightsJson = nextHighlights
+ )
+}
+
+private fun BookMetadata.isEpubReaderMetadata(): Boolean {
+ val remoteType = runCatching { FileType.valueOf(type) }.getOrNull() ?: return false
+ return remoteType in EPUB_READER_FILE_TYPES
+}
+
+internal fun String?.hasCloudAnnotationPayload(): Boolean {
+ val normalized = this?.trim().orEmpty()
+ return normalized.isNotEmpty() && normalized != "[]"
+}
+
+internal fun annotationJsonEquivalentForNoop(existing: String?, incoming: String): Boolean {
+ val existingNormalized = existing?.trim().orEmpty()
+ val incomingNormalized = incoming.trim()
+ if (existingNormalized == incomingNormalized) return true
+ return existingNormalized.isAnnotationJsonEmpty() && incomingNormalized.isAnnotationJsonEmpty()
+}
+
+private fun String.isAnnotationJsonEmpty(): Boolean {
+ return isBlank() || this == "[]"
+}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/CloudPdfAnnotationSidecarDecisions.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudPdfAnnotationSidecarDecisions.kt
new file mode 100644
index 0000000..3b326c5
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudPdfAnnotationSidecarDecisions.kt
@@ -0,0 +1,75 @@
+package org.dueattendant149.bookreader
+
+import java.io.File
+
+internal data class AndroidPdfCloudSidecarState(
+ val hasInk: Boolean,
+ val inkTimestamp: Long,
+ val hasDeletedInk: Boolean = false,
+ val deletedInkTimestamp: Long = 0L,
+ val hasRichText: Boolean,
+ val richTextTimestamp: Long,
+ val hasLayout: Boolean,
+ val layoutTimestamp: Long,
+ val hasTextBoxes: Boolean,
+ val textBoxesTimestamp: Long,
+ val hasHighlights: Boolean,
+ val highlightsTimestamp: Long
+) {
+ val hasAnnotationPayload: Boolean
+ get() = hasInk || hasDeletedInk || hasRichText || hasTextBoxes || hasHighlights
+
+ val annotationPayloadTimestamp: Long
+ get() = maxOf(
+ inkTimestamp.takeIf { hasInk } ?: 0L,
+ deletedInkTimestamp.takeIf { hasDeletedInk } ?: 0L,
+ richTextTimestamp.takeIf { hasRichText } ?: 0L,
+ textBoxesTimestamp.takeIf { hasTextBoxes } ?: 0L,
+ highlightsTimestamp.takeIf { hasHighlights } ?: 0L
+ )
+
+ val bundleTimestamp: Long
+ get() = if (hasAnnotationPayload) {
+ maxOf(annotationPayloadTimestamp, layoutTimestamp.takeIf { hasLayout } ?: 0L)
+ } else {
+ 0L
+ }
+}
+
+internal fun shouldUploadLocalPdfCloudAnnotations(
+ localSidecars: AndroidPdfCloudSidecarState,
+ remoteHasAnnotations: Boolean,
+ remoteAnnotationModifiedTimestamp: Long
+): Boolean {
+ return localSidecars.hasAnnotationPayload &&
+ (!remoteHasAnnotations || localSidecars.annotationPayloadTimestamp > remoteAnnotationModifiedTimestamp)
+}
+
+internal fun shouldDownloadRemotePdfCloudAnnotations(
+ localSidecars: AndroidPdfCloudSidecarState,
+ localAnnotationsShouldUpload: Boolean,
+ remoteHasAnnotations: Boolean,
+ remoteAnnotationModifiedTimestamp: Long
+): Boolean {
+ if (localAnnotationsShouldUpload || !remoteHasAnnotations) return false
+ return !localSidecars.hasAnnotationPayload ||
+ remoteAnnotationModifiedTimestamp > localSidecars.annotationPayloadTimestamp
+}
+
+internal fun File?.hasSyncableCloudAnnotationPayload(): Boolean {
+ val file = this ?: return false
+ if (!file.isFile || file.length() <= 0L) return false
+ val trimmed = runCatching { file.readText().trim() }.getOrDefault("")
+ return trimmed.isNotBlank() && trimmed != "[]" && trimmed != "{}"
+}
+
+internal fun markPdfCloudAnnotationSidecarsSynced(timestamp: Long, vararg files: File?) {
+ if (timestamp <= 0L) return
+ files.forEach { file ->
+ if (file?.exists() == true) {
+ file.setLastModified(timestamp)
+ }
+ }
+}
+
+internal fun cloudPdfAnnotationDriveFileName(bookId: String): String = "annotation_$bookId.json"
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt
new file mode 100644
index 0000000..198c806
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt
@@ -0,0 +1,70 @@
+package org.dueattendant149.bookreader
+
+import android.util.Log
+import org.dueattendant149.bookreader.data.BookMetadata
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.effectiveAnnotationModifiedTimestamp
+import org.dueattendant149.bookreader.data.effectiveReadingPositionModifiedTimestamp
+import timber.log.Timber
+
+internal const val CloudSyncTraceTag = "EpistemeCloudSync"
+internal const val CloudAnnotationSyncTraceTag = "EpistemeCloudAnnotations"
+
+internal fun logCloudSyncTrace(message: () -> String) {
+ if (!BuildConfig.DEBUG) return
+ val text = message()
+ Log.d(CloudSyncTraceTag, text)
+ Timber.tag(CloudSyncTraceTag).d(text)
+}
+
+internal fun logCloudSyncError(error: Throwable, message: () -> String) {
+ if (!BuildConfig.DEBUG) return
+ val text = message()
+ Log.e(CloudSyncTraceTag, text, error)
+ Timber.tag(CloudSyncTraceTag).e(error, text)
+}
+
+internal fun logCloudAnnotationSyncTrace(message: () -> String) {
+ if (!BuildConfig.DEBUG) return
+ val text = message()
+ Log.d(CloudAnnotationSyncTraceTag, text)
+ Timber.tag(CloudAnnotationSyncTraceTag).d(text)
+}
+
+internal fun logCloudAnnotationSyncError(error: Throwable, message: () -> String) {
+ if (!BuildConfig.DEBUG) return
+ val text = message()
+ Log.e(CloudAnnotationSyncTraceTag, text, error)
+ Timber.tag(CloudAnnotationSyncTraceTag).e(error, text)
+}
+
+internal fun RecentFileItem.cloudSyncTraceSummary(prefix: String = "local"): String {
+ return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
+ "contentTs=$fileContentModifiedTimestamp " +
+ "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
+ "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
+ "bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
+}
+
+internal fun BookMetadata.cloudSyncTraceSummary(prefix: String = "remote"): String {
+ return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
+ "annTs=${effectiveAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
+ "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
+ "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
+ "hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
+ "highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
+}
+
+internal fun String?.cloudSyncPreview(maxLength: Int = 80): String {
+ val value = this ?: return "null"
+ return if (value.length <= maxLength) value else value.take(maxLength) + "..."
+}
+
+internal fun String?.cloudSyncAnnotationSummary(): String {
+ val value = this?.trim() ?: return "null"
+ return when {
+ value.isEmpty() -> "blank"
+ value == "[]" -> "empty"
+ else -> "present(${value.length})"
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/Common.kt
similarity index 98%
rename from app/src/main/java/com/aryan/reader/Common.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/Common.kt
index 3f071d5..0f459cd 100644
--- a/app/src/main/java/com/aryan/reader/Common.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/Common.kt
@@ -1,7 +1,7 @@
// Common.kt
@file:OptIn(ExperimentalMaterial3Api::class) @file:Suppress("KotlinConstantConditions")
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.content.Context
import android.graphics.Bitmap
@@ -138,6 +138,7 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -165,23 +166,23 @@ import androidx.compose.ui.window.PopupProperties
import androidx.core.content.edit
import androidx.core.graphics.toColorInt
import androidx.media3.common.util.UnstableApi
-import com.aryan.reader.epubreader.PREF_CUSTOM_THEMES
-import com.aryan.reader.epubreader.PREF_READER_THEME
-import com.aryan.reader.paginatedreader.TtsChunk
-import com.aryan.reader.pdf.PdfHighlightColor
-import com.aryan.reader.shared.BuiltInReaderThemes
-import com.aryan.reader.shared.ReaderTextureFilePrefix
-import com.aryan.reader.shared.normalizeReaderTextureExtension
-import com.aryan.reader.shared.readerTextureDisplayName as sharedReaderTextureDisplayName
-import com.aryan.reader.shared.readerTextureMimeTypeForExtension
-import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
-import com.aryan.reader.tts.SpeakerSamplePlayer
-import com.aryan.reader.tts.TtsCacheManager
-import com.aryan.reader.tts.TtsPlaybackManager
-import com.aryan.reader.tts.formatBytes
-import com.aryan.reader.tts.loadTtsMode
-import com.aryan.reader.tts.rememberTtsController
-import com.aryan.reader.tts.splitTextIntoChunks
+import org.dueattendant149.bookreader.epubreader.PREF_CUSTOM_THEMES
+import org.dueattendant149.bookreader.epubreader.PREF_READER_THEME
+import org.dueattendant149.bookreader.paginatedreader.TtsChunk
+import org.dueattendant149.bookreader.pdf.PdfHighlightColor
+import org.dueattendant149.bookreader.shared.BuiltInReaderThemes
+import org.dueattendant149.bookreader.shared.ReaderTextureFilePrefix
+import org.dueattendant149.bookreader.shared.normalizeReaderTextureExtension
+import org.dueattendant149.bookreader.shared.readerTextureDisplayName as sharedReaderTextureDisplayName
+import org.dueattendant149.bookreader.shared.readerTextureMimeTypeForExtension
+import org.dueattendant149.bookreader.tts.GEMINI_TTS_SPEAKERS
+import org.dueattendant149.bookreader.tts.SpeakerSamplePlayer
+import org.dueattendant149.bookreader.tts.TtsCacheManager
+import org.dueattendant149.bookreader.tts.TtsPlaybackManager
+import org.dueattendant149.bookreader.tts.formatBytes
+import org.dueattendant149.bookreader.tts.loadTtsMode
+import org.dueattendant149.bookreader.tts.rememberTtsController
+import org.dueattendant149.bookreader.tts.splitTextIntoChunks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -217,8 +218,8 @@ import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sqrt
-typealias ReaderTexture = com.aryan.reader.shared.ReaderTexture
-typealias ReaderTheme = com.aryan.reader.shared.ReaderTheme
+typealias ReaderTexture = org.dueattendant149.bookreader.shared.ReaderTexture
+typealias ReaderTheme = org.dueattendant149.bookreader.shared.ReaderTheme
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
const val summarizeEndpoint = "/summarize"
@@ -473,9 +474,9 @@ data class SearchResult(
val chunkIndex: Int
)
-typealias AiDefinitionResult = com.aryan.reader.shared.AiDefinitionResult
+typealias AiDefinitionResult = org.dueattendant149.bookreader.shared.AiDefinitionResult
-typealias SummarizationResult = com.aryan.reader.shared.SummarizationResult
+typealias SummarizationResult = org.dueattendant149.bookreader.shared.SummarizationResult
data class CachedSummaryItem(
val chapterIndex: Int,
@@ -862,6 +863,13 @@ fun AiDefinitionPopup(
val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState()
val context = LocalContext.current
+ val configuration = LocalConfiguration.current
+ val maxPopupHeight = readerModalMaxHeightDp(
+ screenHeightDp = configuration.screenHeightDp,
+ fraction = 0.65f,
+ verticalMarginDp = 48,
+ preferredMinHeightDp = 180
+ ).dp
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
@@ -882,7 +890,7 @@ fun AiDefinitionPopup(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 5.dp)
- .heightIn(min = 150.dp, max = 400.dp),
+ .heightIn(max = maxPopupHeight),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
@@ -2949,7 +2957,14 @@ private fun ThemeGridItem(
Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(8.dp))
- Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(
+ text = theme.name,
+ style = MaterialTheme.typography.labelSmall,
+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.clickable { onThemeSelected(theme.id) }
+ )
if (theme.isCustom && onEdit != null && onDelete != null) {
Spacer(modifier = Modifier.height(6.dp))
@@ -3303,12 +3318,16 @@ fun ThemeColorPickerDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
+ val configuration = LocalConfiguration.current
+ val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
+
Surface(
shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(16.dp)
+ .heightIn(max = maxDialogHeight)
) {
Column(
modifier = Modifier
@@ -3488,12 +3507,16 @@ fun HighlightColorPickerDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
+ val configuration = LocalConfiguration.current
+ val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
+
Surface(
shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(16.dp)
+ .heightIn(max = maxDialogHeight)
) {
Column(
modifier = Modifier
@@ -3822,7 +3845,7 @@ fun AiResultContentView(
result: SummarizationResult?,
isLoading: Boolean,
isMainTtsActive: Boolean,
- ttsController: com.aryan.reader.tts.TtsController,
+ ttsController: org.dueattendant149.bookreader.tts.TtsController,
ttsState: TtsPlaybackManager.TtsState,
getAuthToken: suspend () -> String?,
onRegenerate: (() -> Unit)? = null,
diff --git a/app/src/main/java/com/aryan/reader/EmbeddedEbookMetadataExtractor.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/EmbeddedEbookMetadataExtractor.kt
similarity index 99%
rename from app/src/main/java/com/aryan/reader/EmbeddedEbookMetadataExtractor.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/EmbeddedEbookMetadataExtractor.kt
index 36c2574..70fec74 100644
--- a/app/src/main/java/com/aryan/reader/EmbeddedEbookMetadataExtractor.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/EmbeddedEbookMetadataExtractor.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.util.Xml
import org.xmlpull.v1.XmlPullParser
diff --git a/app/src/main/java/com/aryan/reader/EpubMetadataFileEditor.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/EpubMetadataFileEditor.kt
similarity index 91%
rename from app/src/main/java/com/aryan/reader/EpubMetadataFileEditor.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/EpubMetadataFileEditor.kt
index f8f1d8e..9ec9140 100644
--- a/app/src/main/java/com/aryan/reader/EpubMetadataFileEditor.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/EpubMetadataFileEditor.kt
@@ -1,14 +1,14 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
-import com.aryan.reader.data.BookMetadataEdit
-import com.aryan.reader.data.RecentFileItem
-import com.aryan.reader.shared.reader.SharedEpubMetadataEditor
-import com.aryan.reader.shared.reader.SharedEpubMetadataSnapshot
-import com.aryan.reader.shared.reader.SharedEpubMetadataUpdate
+import org.dueattendant149.bookreader.data.BookMetadataEdit
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataEditor
+import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataSnapshot
+import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataUpdate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/ExternalFileOpenRouter.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ExternalFileOpenRouter.kt
new file mode 100644
index 0000000..4fb489d
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ExternalFileOpenRouter.kt
@@ -0,0 +1,57 @@
+package org.dueattendant149.bookreader
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+
+const val EXTRA_TEMPORARY_EXTERNAL_OPEN = "org.dueattendant149.bookreader.extra.TEMPORARY_EXTERNAL_OPEN"
+
+object ExternalFileOpenRouteDecider {
+ const val BEHAVIOR_TEMPORARY = "TEMPORARY"
+
+ fun shouldOpenTemporary(externalFileBehavior: String?): Boolean {
+ return externalFileBehavior == BEHAVIOR_TEMPORARY
+ }
+
+ fun targetActivityClass(externalFileBehavior: String?): Class {
+ return if (shouldOpenTemporary(externalFileBehavior)) {
+ TemporaryExternalFileActivity::class.java
+ } else {
+ MainActivity::class.java
+ }
+ }
+}
+
+class ExternalFileOpenRouterActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ routeExternalOpen(intent)
+ finish()
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ routeExternalOpen(intent)
+ finish()
+ }
+
+ private fun routeExternalOpen(sourceIntent: Intent?) {
+ if (sourceIntent?.action != Intent.ACTION_VIEW || sourceIntent.data == null) return
+
+ val prefs = getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
+ val behavior = prefs.getString("external_file_behavior", "ASK")
+ val temporary = ExternalFileOpenRouteDecider.shouldOpenTemporary(behavior)
+ val targetIntent = Intent(sourceIntent).apply {
+ setClass(this@ExternalFileOpenRouterActivity, ExternalFileOpenRouteDecider.targetActivityClass(behavior))
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ if (temporary) {
+ putExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, true)
+ addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
+ }
+ }
+ startActivity(targetIntent)
+ }
+}
+
+class TemporaryExternalFileActivity : MainActivity()
diff --git a/app/src/main/java/com/aryan/reader/FileHasher.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/FileHasher.kt
similarity index 94%
rename from app/src/main/java/com/aryan/reader/FileHasher.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/FileHasher.kt
index 7d8e436..fa76ef3 100644
--- a/app/src/main/java/com/aryan/reader/FileHasher.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/FileHasher.kt
@@ -17,10 +17,11 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader
+package org.dueattendant149.bookreader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import timber.log.Timber
import java.io.InputStream
import java.security.MessageDigest
@@ -50,9 +51,8 @@ object FileHasher {
}
hexString.toString()
} catch (e: Exception) {
- // In a real app, you'd want to log this error
- e.printStackTrace()
+ Timber.e(e, "Failed to calculate SHA-256 hash")
null
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/FileTypeResolver.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/FileTypeResolver.kt
similarity index 90%
rename from app/src/main/java/com/aryan/reader/FileTypeResolver.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/FileTypeResolver.kt
index adb94da..fecd802 100644
--- a/app/src/main/java/com/aryan/reader/FileTypeResolver.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/FileTypeResolver.kt
@@ -1,6 +1,6 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
-import com.aryan.reader.shared.SharedFileCapabilities
+import org.dueattendant149.bookreader.shared.SharedFileCapabilities
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
return SharedFileCapabilities.resolveFileTypeForName(fileName)
diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt
similarity index 90%
rename from app/src/main/java/com/aryan/reader/FolderSyncWorker.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt
index 97bdde9..e4e67cd 100644
--- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt
@@ -18,7 +18,7 @@
* mail: epistemereader@gmail.com
*/
// FolderSyncWorker.kt
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.content.Context
import timber.log.Timber
@@ -29,25 +29,25 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters
import androidx.work.WorkManager
-import com.aryan.reader.data.RecentFileItem
-import com.aryan.reader.data.RecentFilesRepository
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.RecentFilesRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import androidx.core.content.edit
-import com.aryan.reader.data.LocalSyncUtils
-import com.aryan.reader.data.FolderBookMetadata
-import com.aryan.reader.data.toSharedFolderBookMetadata
-import com.aryan.reader.shared.BookItem as SharedBookItem
-import com.aryan.reader.shared.EpubAnnotationSerializer
-import com.aryan.reader.shared.EpubBookmark
-import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
-import com.aryan.reader.shared.LocalFolderSyncEngine
-import com.aryan.reader.shared.ReaderLocator
-import com.aryan.reader.shared.SharedFolderScannedFile
-import com.aryan.reader.shared.SharedReaderScreenState
-import com.aryan.reader.shared.reader.ReaderBookmark
+import org.dueattendant149.bookreader.data.LocalSyncUtils
+import org.dueattendant149.bookreader.data.FolderBookMetadata
+import org.dueattendant149.bookreader.data.toSharedFolderBookMetadata
+import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem
+import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
+import org.dueattendant149.bookreader.shared.EpubBookmark
+import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
+import org.dueattendant149.bookreader.shared.LocalFolderSyncEngine
+import org.dueattendant149.bookreader.shared.ReaderLocator
+import org.dueattendant149.bookreader.shared.SharedFolderScannedFile
+import org.dueattendant149.bookreader.shared.SharedReaderScreenState
+import org.dueattendant149.bookreader.shared.reader.ReaderBookmark
import java.io.File
import android.provider.DocumentsContract
@@ -72,45 +72,27 @@ class FolderSyncWorker(
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
- val jsonString = prefs.getString("synced_folders_list_json", null)
- val folders = mutableListOf>>()
-
- if (jsonString != null) {
- try {
- val array = org.json.JSONArray(jsonString)
- for (i in 0 until array.length()) {
- val obj = array.getJSONObject(i)
- val uri = obj.getString("uri")
- val allowedFileTypes = mutableSetOf()
- if (obj.has("allowedFileTypes")) {
- val typesArray = obj.getJSONArray("allowedFileTypes")
- for (j in 0 until typesArray.length()) {
- try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
- }
- } else {
- allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES)
- }
- folders.add(Pair(uri, allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }))
- }
- } catch (e: Exception) { Timber.e(e) }
- } else {
- val single = prefs.getString("synced_folder_uri", null)
- if (single != null) folders.add(Pair(single, ANDROID_SYNCABLE_FILE_TYPES))
- }
+ val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null)
+ val folders = SyncedFolderPrefs.decodeSyncedFolders(
+ jsonString = jsonString,
+ legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
+ syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
+ )
if (folders.isEmpty()) {
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
return Result.success()
}
+ val enabledFolders = folders.filter { it.localSyncEnabled }
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
- folders
+ enabledFolders
} else {
- folders.filter { it.first == targetFolderUri }
+ enabledFolders.filter { it.uriString == targetFolderUri }
}
if (foldersToProcess.isEmpty()) {
- ReaderPerfLog.w("FolderSync worker aborted: target folder not linked target=$targetFolderUri")
+ ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri")
return Result.success()
}
@@ -123,8 +105,8 @@ class FolderSyncWorker(
syncMutex.withLock {
var allSuccess = true
- for ((uriString, allowedTypes) in foldersToProcess) {
- val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
+ for (folderConfig in foldersToProcess) {
+ val success = performSyncForFolder(folderConfig, isMetadataOnly)
if (!success) allSuccess = false
}
@@ -132,13 +114,14 @@ class FolderSyncWorker(
try {
val array = org.json.JSONArray(jsonString)
val now = System.currentTimeMillis()
+ val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString }
for (i in 0 until array.length()) {
val obj = array.getJSONObject(i)
- if (targetFolderUri.isNullOrBlank() || obj.optString("uri") == targetFolderUri) {
+ if (obj.optString("uri") in processedUris) {
obj.put("lastScanTime", now)
}
}
- prefs.edit { putString("synced_folders_list_json", array.toString()) }
+ prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) }
} catch (_: Exception) {}
}
@@ -153,7 +136,9 @@ class FolderSyncWorker(
}
}
- private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set, metadataOnly: Boolean): Boolean {
+ private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean {
+ val folderUriString = folderConfig.uriString
+ val allowedFileTypes = folderConfig.allowedFileTypes
if (folderUriString.isBlank()) return true
val folderUri = folderUriString.toUri()
val folderStart = ReaderPerfLog.nowNanos()
@@ -235,9 +220,10 @@ class FolderSyncWorker(
val nowMillis = System.currentTimeMillis()
val folder = SyncedFolder(
uriString = folderUriString,
- name = documentTree.name ?: "Local Folder",
+ name = documentTree.name ?: folderConfig.name,
lastScanTime = nowMillis,
- allowedFileTypes = allowedFileTypes
+ allowedFileTypes = allowedFileTypes,
+ localSyncEnabled = true
)
val sharedState = SharedReaderScreenState(
rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
@@ -564,7 +550,8 @@ class FolderSyncWorker(
lastPageIndex = lastPage,
readerPosition = readerPositionOrNull(),
readerBookmarks = parseReaderBookmarks(),
- readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
+ readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson),
+ readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
)
}
@@ -613,8 +600,8 @@ class FolderSyncWorker(
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
- locatorBlockIndex = appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
- locatorCharOffset = appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
+ locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
+ locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
progressPercentage = progressPercentage,
isRecent = isRecent,
isAvailable = true,
@@ -637,6 +624,7 @@ class FolderSyncWorker(
originalDescription = originalDescription,
folderTextMetadataParsed = folderTextMetadataParsed,
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
+ readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
tags = existing?.tags.orEmpty()
)
}
@@ -720,18 +708,12 @@ class FolderSyncWorker(
private fun isFolderStillLinked(folderUriString: String): Boolean {
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
- val jsonString = prefs.getString("synced_folders_list_json", null)
- if (jsonString != null) {
- return try {
- val array = org.json.JSONArray(jsonString)
- (0 until array.length()).any { index ->
- array.getJSONObject(index).optString("uri") == folderUriString
- }
- } catch (_: Exception) {
- false
- }
- }
- return prefs.getString("synced_folder_uri", null) == folderUriString
+ return SyncedFolderPrefs.isLocalSyncEnabled(
+ jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
+ legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
+ folderUriString = folderUriString,
+ syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
+ )
}
private fun getFileType(name: String, mimeType: String?): FileType? {
diff --git a/app/src/main/java/com/aryan/reader/FontsScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/FontsScreen.kt
similarity index 56%
rename from app/src/main/java/com/aryan/reader/FontsScreen.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/FontsScreen.kt
index 4e86a24..c34ffa5 100644
--- a/app/src/main/java/com/aryan/reader/FontsScreen.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/FontsScreen.kt
@@ -1,10 +1,13 @@
// FontsScreen.kt
@file:Suppress("KotlinConstantConditions")
-package com.aryan.reader
+package org.dueattendant149.bookreader
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -27,10 +30,12 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
@@ -46,6 +51,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -58,14 +64,21 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.aryan.reader.shared.CustomFontItem
-import com.aryan.reader.shared.ui.SharedAppFontSelector
-import com.aryan.reader.shared.ui.SharedFontSettingsSection
-import com.aryan.reader.shared.ui.SharedFontSettingsTabs
-import com.aryan.reader.data.CustomFontEntity
+import org.dueattendant149.bookreader.shared.CustomFontItem
+import org.dueattendant149.bookreader.shared.CustomFontFamilyItem
+import org.dueattendant149.bookreader.shared.CustomFontVariantItem
+import org.dueattendant149.bookreader.shared.fontFaceLabel
+import org.dueattendant149.bookreader.shared.fontFaceSummary
+import org.dueattendant149.bookreader.shared.groupByFamily
+import org.dueattendant149.bookreader.shared.hasVariableWeightFace
+import org.dueattendant149.bookreader.shared.ui.SharedAppFontSelector
+import org.dueattendant149.bookreader.shared.ui.SharedFontSettingsSection
+import org.dueattendant149.bookreader.shared.ui.SharedFontSettingsTabs
+import org.dueattendant149.bookreader.data.CustomFontEntity
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@@ -80,37 +93,68 @@ fun FontsScreen(
val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE)
- // Dialog state
- var showDeleteDialog by remember { mutableStateOf(false) }
- var fontToDelete by remember { mutableStateOf(null) }
+ var fontsPendingDelete by remember { mutableStateOf>(emptyList()) }
var showGoogleFontsSheet by remember { mutableStateOf(false) }
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
+ var selectedFontIds by remember { mutableStateOf>(emptySet()) }
- val pickFontLauncher = rememberFilePickerLauncher { uris ->
- uris.firstOrNull()?.let { viewModel.importFont(it) }
+ val pickFontLauncher = rememberFilePickerLauncher(viewModel::importFonts)
+ val fontMimeTypes = remember { supportedFontMimeTypes() }
+ val allFontIds = remember(fonts) { fonts.mapTo(mutableSetOf()) { it.id } }
+ val selectedFonts = remember(fonts, selectedFontIds) {
+ fonts.filter { it.id in selectedFontIds }
+ }
+ val fontEntitiesById = remember(fonts) { fonts.associateBy { it.id } }
+ val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty()
+
+ LaunchedEffect(fonts) {
+ selectedFontIds = selectedFontIds.intersect(allFontIds)
}
- val fontMimeTypes = arrayOf(
- "font/ttf", "font/otf", "font/woff2",
- "application/x-font-ttf", "application/x-font-otf",
- "application/font-woff2", "application/vnd.ms-opentype",
- "application/x-font-opentype"
- )
+ BackHandler(enabled = isFontSelectionMode) {
+ selectedFontIds = emptySet()
+ }
Scaffold(
modifier = Modifier.statusBarsPadding(),
topBar = {
- CustomTopAppBar(
- title = { Text(stringResource(R.string.custom_fonts)) },
- navigationIcon = {
- IconButton(onClick = onBackClick) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
+ if (isFontSelectionMode) {
+ ContextualTopAppBar(
+ selectedItemCount = selectedFonts.size,
+ onNavIconClick = { selectedFontIds = emptySet() },
+ onSelectAllClick = {
+ selectedFontIds = if (selectedFontIds.containsAll(allFontIds)) {
+ emptySet()
+ } else {
+ allFontIds
+ }
+ },
+ onDeleteClick = {
+ if (selectedFonts.isNotEmpty()) {
+ fontsPendingDelete = selectedFonts
+ }
}
- }
- )
+ )
+ } else {
+ CustomTopAppBar(
+ title = { Text(stringResource(R.string.custom_fonts)) },
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
+ }
+ },
+ actions = {
+ if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
+ IconButton(onClick = { selectedFontIds = allFontIds }) {
+ Icon(Icons.Default.SelectAll, contentDescription = stringResource(R.string.select_all))
+ }
+ }
+ }
+ )
+ }
},
floatingActionButton = {
- if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
+ if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty() && !isFontSelectionMode) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp)
@@ -136,10 +180,14 @@ fun FontsScreen(
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
+ val fontFamilies = remember(sharedFonts) { sharedFonts.groupByFamily() }
Column(modifier = Modifier.fillMaxSize()) {
SharedFontSettingsTabs(
selectedSection = selectedSection,
- onSectionChange = { selectedSection = it },
+ onSectionChange = {
+ selectedFontIds = emptySet()
+ selectedSection = it
+ },
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
)
@@ -163,12 +211,20 @@ fun FontsScreen(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
- items(fonts, key = { it.id }) { font ->
- FontListItem(
- font = font,
- onDelete = {
- fontToDelete = font
- showDeleteDialog = true
+ items(fontFamilies, key = { family -> family.variants.joinToString("|") { it.font.id } }) { family ->
+ FontFamilyListItem(
+ family = family,
+ selectedFontIds = selectedFontIds,
+ isSelectionMode = isFontSelectionMode,
+ fontEntityForId = { id -> fontEntitiesById[id] },
+ onVariantSelectionToggle = { id ->
+ selectedFontIds = selectedFontIds.toggle(id)
+ },
+ onFamilySelectionToggle = {
+ selectedFontIds = selectedFontIds.toggleAll(family.variants.map { it.font.id })
+ },
+ onDeleteVariant = { id ->
+ fontEntitiesById[id]?.let { fontsPendingDelete = listOf(it) }
}
)
}
@@ -208,17 +264,17 @@ fun FontsScreen(
}
}
- if (showDeleteDialog && fontToDelete != null) {
- DeleteFontConfirmationDialog(
- fontName = fontToDelete!!.displayName,
+ if (fontsPendingDelete.isNotEmpty()) {
+ DeleteFontsConfirmationDialog(
+ fonts = fontsPendingDelete,
onConfirm = {
- fontToDelete?.let { viewModel.deleteFont(it.id) }
- showDeleteDialog = false
- fontToDelete = null
+ val pendingIds = fontsPendingDelete.map { it.id }
+ viewModel.deleteFonts(pendingIds)
+ selectedFontIds = selectedFontIds - pendingIds.toSet()
+ fontsPendingDelete = emptyList()
},
onDismiss = {
- showDeleteDialog = false
- fontToDelete = null
+ fontsPendingDelete = emptyList()
}
)
}
@@ -388,9 +444,13 @@ fun GoogleFontsBottomSheet(
}
// Existing unchanged components
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FontListItem(
font: CustomFontEntity,
+ isSelected: Boolean,
+ isSelectionMode: Boolean,
+ onSelectionToggle: () -> Unit,
onDelete: () -> Unit
) {
val customTypeface = remember(font.path) {
@@ -402,8 +462,23 @@ fun FontListItem(
}
Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = {
+ if (isSelectionMode) {
+ onSelectionToggle()
+ }
+ },
+ onLongClick = onSelectionToggle
+ ),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSelected) {
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
+ } else {
+ MaterialTheme.colorScheme.surface
+ }
+ )
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
@@ -411,17 +486,27 @@ fun FontListItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
+ if (isSelectionMode) {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = { onSelectionToggle() },
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ }
Text(
text = font.displayName,
style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.weight(1f)
)
- IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
- Icon(
- Icons.Default.Delete,
- contentDescription = stringResource(R.string.action_delete),
- tint = MaterialTheme.colorScheme.error
- )
+ if (!isSelectionMode) {
+ IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = stringResource(R.string.action_delete),
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
}
}
@@ -459,6 +544,188 @@ fun FontListItem(
}
}
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun FontFamilyListItem(
+ family: CustomFontFamilyItem,
+ selectedFontIds: Set,
+ isSelectionMode: Boolean,
+ fontEntityForId: (String) -> CustomFontEntity?,
+ onVariantSelectionToggle: (String) -> Unit,
+ onFamilySelectionToggle: () -> Unit,
+ onDeleteVariant: (String) -> Unit
+) {
+ val baseFont = remember(family) {
+ family.variants.firstOrNull { it.fontFaceLabel() == "Regular" }?.font ?: family.variants.first().font
+ }
+ val customTypeface = remember(baseFont.path) {
+ try {
+ FontFamily(Font(File(baseFont.path)))
+ } catch (_: Exception) {
+ null
+ }
+ }
+ val familyFontIds = remember(family) { family.variants.map { it.font.id }.toSet() }
+ val isSelected = familyFontIds.any { it in selectedFontIds }
+ val allSelected = familyFontIds.all { it in selectedFontIds }
+ val faceSummary = remember(family) {
+ buildString {
+ append(family.fontFaceSummary())
+ if (family.hasVariableWeightFace()) append(" - Variable weight")
+ append(" - ${family.variants.size} file")
+ if (family.variants.size != 1) append("s")
+ }
+ }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = {
+ if (isSelectionMode) {
+ onFamilySelectionToggle()
+ }
+ },
+ onLongClick = onFamilySelectionToggle
+ ),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSelected) {
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
+ } else {
+ MaterialTheme.colorScheme.surface
+ }
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (isSelectionMode) {
+ Checkbox(
+ checked = allSelected,
+ onCheckedChange = { onFamilySelectionToggle() },
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = family.familyName,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = faceSummary,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), MaterialTheme.shapes.small)
+ .padding(12.dp)
+ ) {
+ if (customTypeface != null) {
+ Text(
+ text = stringResource(R.string.font_preview_text),
+ fontFamily = customTypeface,
+ fontSize = 18.sp,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.font_preview_error),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ family.variants.forEachIndexed { index, variant ->
+ FontVariantRow(
+ variant = variant,
+ entity = fontEntityForId(variant.font.id),
+ isSelected = variant.font.id in selectedFontIds,
+ isSelectionMode = isSelectionMode,
+ onSelectionToggle = { onVariantSelectionToggle(variant.font.id) },
+ onDelete = { onDeleteVariant(variant.font.id) }
+ )
+ if (index != family.variants.lastIndex) {
+ HorizontalDivider(modifier = Modifier.padding(start = if (isSelectionMode) 48.dp else 0.dp))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FontVariantRow(
+ variant: CustomFontVariantItem,
+ entity: CustomFontEntity?,
+ isSelected: Boolean,
+ isSelectionMode: Boolean,
+ onSelectionToggle: () -> Unit,
+ onDelete: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(6.dp))
+ .clickable(enabled = isSelectionMode) { onSelectionToggle() }
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (isSelectionMode) {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = { onSelectionToggle() },
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = variant.fontFaceLabel(),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = entity?.fileName ?: variant.font.fileName,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ Text(
+ text = variant.font.fileExtension.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ modifier = Modifier.padding(horizontal = 8.dp)
+ )
+ if (!isSelectionMode) {
+ IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = stringResource(R.string.action_delete),
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+}
+
private fun List.toSharedCustomFontItems(): List {
return filterNot { it.isDeleted }
.sortedBy { it.displayName.lowercase() }
@@ -476,15 +743,32 @@ private fun List.toSharedCustomFontItems(): List,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
+ val isSingleFont = fonts.size == 1
AlertDialog(
onDismissRequest = onDismiss,
- title = { Text(stringResource(R.string.dialog_delete_font)) },
- text = { Text(stringResource(R.string.dialog_delete_font_desc, fontName)) },
+ title = {
+ Text(
+ if (isSingleFont) {
+ stringResource(R.string.dialog_delete_font)
+ } else {
+ stringResource(R.string.dialog_delete_fonts)
+ }
+ )
+ },
+ text = {
+ Text(
+ if (isSingleFont) {
+ stringResource(R.string.dialog_delete_font_desc, fonts.first().displayName)
+ } else {
+ stringResource(R.string.dialog_delete_fonts_desc, fonts.size)
+ }
+ )
+ },
confirmButton = {
TextButton(
onClick = onConfirm,
@@ -498,3 +782,12 @@ fun DeleteFontConfirmationDialog(
}
)
}
+
+private fun Set.toggle(id: String): Set {
+ return if (id in this) this - id else this + id
+}
+
+private fun Set.toggleAll(ids: List): Set {
+ val idSet = ids.toSet()
+ return if (containsAll(idSet)) this - idSet else this + idSet
+}
diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/HomeScreen.kt
similarity index 96%
rename from app/src/main/java/com/aryan/reader/HomeScreen.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/HomeScreen.kt
index 926488a..612c96b 100644
--- a/app/src/main/java/com/aryan/reader/HomeScreen.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/HomeScreen.kt
@@ -20,7 +20,7 @@
// HomeScreen
@file:Suppress("DEPRECATION")
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.annotation.SuppressLint
import android.app.Activity
@@ -130,6 +130,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -138,12 +139,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
+import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import coil.request.ImageRequest
-import com.aryan.reader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.RecentFileItem
import kotlinx.coroutines.launch
import timber.log.Timber
import java.text.SimpleDateFormat
@@ -192,6 +194,7 @@ fun HomeScreen(
var showAboutDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf(null) }
+ var pendingSaveOriginalItem by remember { mutableStateOf(null) }
var showBehaviorDialog by remember { mutableStateOf(false) }
var showStrictFilterDialog by remember { mutableStateOf(false) }
var showClearBookCacheDialog by remember { mutableStateOf(false) }
@@ -220,6 +223,35 @@ fun HomeScreen(
}
}
+ val saveOriginalLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument("application/octet-stream")
+ ) { uri ->
+ val item = pendingSaveOriginalItem
+ pendingSaveOriginalItem = null
+ if (uri != null && item?.uriString != null) {
+ viewModel.saveOriginalFile(item.uriString.toUri(), uri)
+ }
+ }
+
+ fun saveOriginalItem(item: RecentFileItem) {
+ if (!item.canExportOriginalFile()) return
+ pendingSaveOriginalItem = item
+ saveOriginalLauncher.launch(item.suggestedOriginalFileName())
+ }
+
+ fun shareOriginalItem(item: RecentFileItem) {
+ val uriString = item.uriString ?: return
+ if (!item.canExportOriginalFile()) return
+ scope.launch {
+ viewModel.shareOriginalFile(
+ activityContext = context,
+ sourceUri = uriString.toUri(),
+ fileType = item.type,
+ filename = item.suggestedOriginalFileName()
+ )
+ }
+ }
+
LaunchedEffect(uiState.isRequestingDrivePermission) {
if (uiState.isRequestingDrivePermission) {
val intent = viewModel.getDriveSignInIntent(context)
@@ -389,6 +421,12 @@ fun HomeScreen(
showInfoDialog = true
}
},
+ onSaveClick = selectedContextItems.singleOrNull()
+ ?.takeIf { it.canExportOriginalFile() }
+ ?.let { item -> { saveOriginalItem(item) } },
+ onShareClick = selectedContextItems.singleOrNull()
+ ?.takeIf { it.canExportOriginalFile() }
+ ?.let { item -> { shareOriginalItem(item) } },
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllRecentFiles() })
@@ -436,7 +474,7 @@ fun HomeScreen(
onRefresh = { viewModel.refreshLibrary() },
isRefreshing = uiState.isRefreshing,
isSyncEnabled = uiState.isSyncEnabled,
- hasSyncedFolder = uiState.syncedFolders.isNotEmpty(),
+ hasSyncedFolder = uiState.syncedFolders.any { it.localSyncEnabled },
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
)
}
@@ -501,28 +539,17 @@ fun HomeScreen(
)
}
- itemForInfoDialog?.let { item ->
- if (showInfoDialog) {
- FileInfoDialog(
- item = item,
- usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
- onDismiss = {
- showInfoDialog = false
- itemForInfoDialog = null
- },
- onSaveMetadata = { metadata ->
- viewModel.updateBookMetadata(item.bookId, metadata)
- },
- onSaveDisplayName = { name ->
- viewModel.updateCustomName(item.bookId, name)
- },
- onRestoreMetadata = {
- viewModel.restoreOriginalBookMetadata(item.bookId)
- },
- onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
- )
- }
- }
+ HydratedFileInfoDialog(
+ item = itemForInfoDialog,
+ isVisible = showInfoDialog,
+ uiState = uiState,
+ viewModel = viewModel,
+ onDismiss = {
+ showInfoDialog = false
+ itemForInfoDialog = null
+ },
+ onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
+ )
if (showClearReflowCacheDialog) {
DangerousFolderActionDialog(
@@ -767,7 +794,8 @@ private fun RecentFilesGrid(
modifier = Modifier.size(16.dp)
)
}
- }
+ },
+ modifier = Modifier.testTag("HomeTab_${tab.bookId}")
)
}
}
@@ -817,6 +845,7 @@ fun RecentFileCard(
androidx.compose.material3.ElevatedCard(
modifier = modifier
+ .testTag("HomeRecentFileCard_${item.bookId}")
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
@@ -1485,7 +1514,7 @@ private fun AppDrawerContent(
Spacer(modifier = Modifier.weight(1f))
// legal links
- if (uiState.currentUser != null && !isOss) {
+ if (uiState.currentUser != null || (isOss && !BuildConfig.IS_OFFLINE)) {
val uriHandler = LocalUriHandler.current
val baseStyle = MaterialTheme.typography.labelMedium
var scaledTextStyle by remember { mutableStateOf(baseStyle) }
@@ -1783,9 +1812,14 @@ fun ExternalFileBehaviorDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.options_external_file_behavior)) },
text = {
- Column {
- val options = listOf("ASK" to R.string.external_file_behavior_ask, "KEEP" to R.string.external_file_behavior_keep, "DELETE" to R.string.external_file_behavior_delete)
- options.forEach { (value, labelRes) ->
+ Column(modifier = Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) {
+ val options = listOf(
+ Triple("ASK", R.string.external_file_behavior_ask, R.string.external_file_behavior_ask_desc),
+ Triple("KEEP", R.string.external_file_behavior_keep, R.string.external_file_behavior_keep_desc),
+ Triple("DELETE", R.string.external_file_behavior_delete, R.string.external_file_behavior_delete_desc),
+ Triple("TEMPORARY", R.string.external_file_behavior_temporary, R.string.external_file_behavior_temporary_desc)
+ )
+ options.forEach { (value, labelRes, descriptionRes) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -1795,7 +1829,14 @@ fun ExternalFileBehaviorDialog(
) {
RadioButton(selected = currentBehavior == value, onClick = null)
Spacer(modifier = Modifier.width(16.dp))
- Text(stringResource(labelRes))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(stringResource(labelRes))
+ Text(
+ text = stringResource(descriptionRes),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryModels.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryModels.kt
new file mode 100644
index 0000000..8439f4c
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryModels.kt
@@ -0,0 +1,42 @@
+package org.dueattendant149.bookreader
+
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.shared.ReaderFeatureSurface
+import org.dueattendant149.bookreader.shared.ReaderPlatform
+import org.dueattendant149.bookreader.shared.SharedFileCapabilities
+
+typealias AddBooksSource = org.dueattendant149.bookreader.shared.AddBooksSource
+typealias FileType = org.dueattendant149.bookreader.shared.FileType
+typealias RenderMode = org.dueattendant149.bookreader.shared.RenderMode
+typealias SortOrder = org.dueattendant149.bookreader.shared.SortOrder
+typealias ReadStatusFilter = org.dueattendant149.bookreader.shared.ReadStatusFilter
+typealias LibraryFilters = org.dueattendant149.bookreader.shared.LibraryFilters
+typealias SyncedFolder = org.dueattendant149.bookreader.shared.SyncedFolder
+typealias ShelfType = org.dueattendant149.bookreader.shared.ShelfType
+
+internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
+internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID)
+internal val COMIC_ARCHIVE_FILE_TYPES = SharedFileCapabilities.comicArchiveTypes
+internal val PDF_VIEWER_FILE_TYPES = org.dueattendant149.bookreader.shared.PDF_VIEWER_FILE_TYPES
+internal val EPUB_READER_FILE_TYPES = org.dueattendant149.bookreader.shared.EPUB_READER_FILE_TYPES
+
+internal fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? {
+ return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID)
+}
+
+data class Shelf(
+ val id: String,
+ val name: String,
+ val type: ShelfType,
+ val books: List,
+ val directBooks: List = books,
+ val parentShelfId: String? = null,
+ val childShelfIds: List = emptyList(),
+ val depth: Int = 0,
+ val sortKey: String = name.lowercase()
+) {
+ val bookCount: Int get() = books.size
+ val topBook: RecentFileItem? by lazy(LazyThreadSafetyMode.NONE) { books.maxByOrNull { it.timestamp } }
+ val directBookCount: Int get() = directBooks.size
+ val childShelfCount: Int get() = childShelfIds.size
+}
diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt
similarity index 92%
rename from app/src/main/java/com/aryan/reader/LibraryScreen.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt
index 90370a0..fa79d21 100644
--- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt
@@ -20,7 +20,7 @@
// LibraryScreen.kt
@file:Suppress("KotlinConstantConditions")
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.annotation.SuppressLint
import android.net.Uri
@@ -105,6 +105,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -120,6 +121,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@@ -131,19 +133,24 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
+import coil.ImageLoader
import coil.compose.AsyncImage
-import coil.request.ImageRequest
-import com.aryan.reader.data.RecentFileItem
-import com.aryan.reader.data.TagEntity
-import com.aryan.reader.opds.OpdsAcquisition
-import com.aryan.reader.opds.OpdsCatalog
-import com.aryan.reader.opds.OpdsDownloadState
-import com.aryan.reader.opds.OpdsEntry
-import com.aryan.reader.opds.OpdsViewModel
+import coil.decode.SvgDecoder
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.TagEntity
+import org.dueattendant149.bookreader.opds.OpdsAcquisition
+import org.dueattendant149.bookreader.opds.OpdsCatalog
+import org.dueattendant149.bookreader.opds.OpdsDownloadState
+import org.dueattendant149.bookreader.opds.OpdsEntry
+import org.dueattendant149.bookreader.opds.OpdsRepository
+import org.dueattendant149.bookreader.opds.OpdsViewModel
+import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
+import org.dueattendant149.bookreader.shared.opds.SharedOpdsLocalBookMatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -265,6 +272,36 @@ fun LibraryScreen(
var showDeleteShelvesDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf(null) }
+ var pendingSaveOriginalItem by remember { mutableStateOf(null) }
+
+ val saveOriginalLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument("application/octet-stream")
+ ) { uri ->
+ val item = pendingSaveOriginalItem
+ pendingSaveOriginalItem = null
+ if (uri != null && item?.uriString != null) {
+ viewModel.saveOriginalFile(item.uriString.toUri(), uri)
+ }
+ }
+
+ fun saveOriginalItem(item: RecentFileItem) {
+ if (!item.canExportOriginalFile()) return
+ pendingSaveOriginalItem = item
+ saveOriginalLauncher.launch(item.suggestedOriginalFileName())
+ }
+
+ fun shareOriginalItem(item: RecentFileItem) {
+ val uriString = item.uriString ?: return
+ if (!item.canExportOriginalFile()) return
+ scope.launch {
+ viewModel.shareOriginalFile(
+ activityContext = context,
+ sourceUri = uriString.toUri(),
+ fileType = item.type,
+ filename = item.suggestedOriginalFileName()
+ )
+ }
+ }
BackHandler(enabled = isContextualModeActive) {
viewModel.clearContextualAction()
@@ -311,6 +348,12 @@ fun LibraryScreen(
showInfoDialog = true
}
},
+ onSaveClick = selectedItems.singleOrNull()
+ ?.takeIf { it.canExportOriginalFile() }
+ ?.let { item -> { saveOriginalItem(item) } },
+ onShareClick = selectedItems.singleOrNull()
+ ?.takeIf { it.canExportOriginalFile() }
+ ?.let { item -> { shareOriginalItem(item) } },
onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllLibraryFiles() },
onShelfClick = viewModel::onShelfClick,
@@ -325,6 +368,7 @@ fun LibraryScreen(
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
syncedFolders = uiState.syncedFolders,
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
+ onFolderLocalSyncChange = viewModel::setFolderLocalSyncEnabled,
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
downloadingBookIds = uiState.downloadingBookIds,
lastFolderScanTime = uiState.lastFolderScanTime,
@@ -390,28 +434,17 @@ fun LibraryScreen(
)
}
- itemForInfoDialog?.let { item ->
- if (showInfoDialog) {
- FileInfoDialog(
- item = item,
- usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
- onDismiss = {
- showInfoDialog = false
- itemForInfoDialog = null
- },
- onSaveMetadata = { metadata ->
- viewModel.updateBookMetadata(item.bookId, metadata)
- },
- onSaveDisplayName = { name ->
- viewModel.updateCustomName(item.bookId, name)
- },
- onRestoreMetadata = {
- viewModel.restoreOriginalBookMetadata(item.bookId)
- },
- onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
- )
- }
- }
+ HydratedFileInfoDialog(
+ item = itemForInfoDialog,
+ isVisible = showInfoDialog,
+ uiState = uiState,
+ viewModel = viewModel,
+ onDismiss = {
+ showInfoDialog = false
+ itemForInfoDialog = null
+ },
+ onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
+ )
CustomTopBanner(bannerMessage = uiState.bannerMessage)
}
}
@@ -429,10 +462,42 @@ fun ShelfScreen(
val sortOrder = uiState.sortOrder
val showRenameDialogFor = uiState.showRenameShelfDialogFor
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf(null) }
+ var pendingSaveOriginalItem by remember { mutableStateOf(null) }
+
+ val saveOriginalLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument("application/octet-stream")
+ ) { uri ->
+ val item = pendingSaveOriginalItem
+ pendingSaveOriginalItem = null
+ if (uri != null && item?.uriString != null) {
+ viewModel.saveOriginalFile(item.uriString.toUri(), uri)
+ }
+ }
+
+ fun saveOriginalItem(item: RecentFileItem) {
+ if (!item.canExportOriginalFile()) return
+ pendingSaveOriginalItem = item
+ saveOriginalLauncher.launch(item.suggestedOriginalFileName())
+ }
+
+ fun shareOriginalItem(item: RecentFileItem) {
+ val uriString = item.uriString ?: return
+ if (!item.canExportOriginalFile()) return
+ scope.launch {
+ viewModel.shareOriginalFile(
+ activityContext = context,
+ sourceUri = uriString.toUri(),
+ fileType = item.type,
+ filename = item.suggestedOriginalFileName()
+ )
+ }
+ }
BackHandler(enabled = true) {
when {
@@ -484,6 +549,12 @@ fun ShelfScreen(
showInfoDialog = true
}
},
+ onSaveClick = selectedItems.singleOrNull()
+ ?.takeIf { it.canExportOriginalFile() }
+ ?.let { item -> { saveOriginalItem(item) } },
+ onShareClick = selectedItems.singleOrNull()
+ ?.takeIf { it.canExportOriginalFile() }
+ ?.let { item -> { shareOriginalItem(item) } },
onDeleteClick = { showRemoveFromShelfDialog = true },
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
@@ -524,19 +595,14 @@ fun ShelfScreen(
)
}
- itemForInfoDialog?.let { item ->
- if (showInfoDialog) {
- FileInfoDialog(
- item = item,
- usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
- onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
- onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) },
- onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
- onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) },
- onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
- )
- }
- }
+ HydratedFileInfoDialog(
+ item = itemForInfoDialog,
+ isVisible = showInfoDialog,
+ uiState = uiState,
+ viewModel = viewModel,
+ onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
+ onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
+ )
CustomTopBanner(bannerMessage = uiState.bannerMessage)
}
}
@@ -571,6 +637,8 @@ fun LibraryScreenContent(
onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit,
onInfoClick: () -> Unit,
+ onSaveClick: (() -> Unit)?,
+ onShareClick: (() -> Unit)?,
onDeleteClick: () -> Unit,
onSelectAllClick: () -> Unit,
onShelfClick: (Shelf) -> Unit,
@@ -590,6 +658,7 @@ fun LibraryScreenContent(
isRefreshing: Boolean,
syncedFolders: List,
onRemoveFolderClick: (SyncedFolder) -> Unit,
+ onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onOpdsBookDownloaded: (Uri, String) -> Unit,
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit,
@@ -632,6 +701,8 @@ fun LibraryScreenContent(
onTagClick = onTagClick,
onPinClick = onPinClick,
onInfoClick = onInfoClick,
+ onSaveClick = onSaveClick,
+ onShareClick = onShareClick,
onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick
)
@@ -666,7 +737,8 @@ fun LibraryScreenContent(
modifier = Modifier
.weight(1f)
.padding(vertical = 4.dp)
- .focusRequester(searchFocusRequester),
+ .focusRequester(searchFocusRequester)
+ .testTag("LibrarySearchTextField"),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
@@ -693,7 +765,10 @@ fun LibraryScreenContent(
Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter))
}
Box {
- TextButton(onClick = { showSortMenu = true }) {
+ TextButton(
+ onClick = { showSortMenu = true },
+ modifier = Modifier.testTag("LibrarySortButton")
+ ) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort),
@@ -822,7 +897,9 @@ fun LibraryScreenContent(
text = { Text(stringResource(R.string.fab_new_shelf)) },
icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) },
onClick = onNewShelfClick,
- modifier = Modifier.padding(16.dp)
+ modifier = Modifier
+ .padding(16.dp)
+ .testTag("LibraryNewShelfFab")
)
}
}
@@ -888,6 +965,7 @@ fun LibraryScreenContent(
allRecentFiles = rawLibraryFiles,
onAddFolderClick = onSelectSyncFolderClick,
onRemoveFolderClick = onRemoveFolderClick,
+ onFolderLocalSyncChange = onFolderLocalSyncChange,
onEditFolderFiltersClick = onEditFolderFiltersClick,
onScanNowClick = onScanNowClick,
onSyncMetadataClick = onSyncMetadataClick,
@@ -1031,6 +1109,8 @@ private fun ShelfDetailScreen(
onClearSelection: () -> Unit,
onTagClick: () -> Unit,
onInfoClick: () -> Unit,
+ onSaveClick: (() -> Unit)?,
+ onShareClick: (() -> Unit)?,
onDeleteClick: () -> Unit,
onRenameShelf: () -> Unit,
onDeleteShelf: () -> Unit,
@@ -1113,6 +1193,8 @@ private fun ShelfDetailScreen(
onNavIconClick = onClearSelection,
onTagClick = onTagClick,
onInfoClick = onInfoClick,
+ onSaveClick = onSaveClick,
+ onShareClick = onShareClick,
onDeleteClick = onDeleteClick
)
} else if (isSearchActive) {
@@ -1140,7 +1222,8 @@ private fun ShelfDetailScreen(
modifier = Modifier
.weight(1f)
.padding(vertical = 4.dp)
- .focusRequester(searchFocusRequester),
+ .focusRequester(searchFocusRequester)
+ .testTag("ShelfSearchTextField"),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
@@ -1193,7 +1276,10 @@ private fun ShelfDetailScreen(
},
actions = {
Box {
- TextButton(onClick = { showSortMenu = true }) {
+ TextButton(
+ onClick = { showSortMenu = true },
+ modifier = Modifier.testTag("ShelfSortButton")
+ ) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort),
@@ -1375,7 +1461,10 @@ private fun AddBooksModeScreen(
},
actions = {
Box {
- TextButton(onClick = { showSortMenu = true }) {
+ TextButton(
+ onClick = { showSortMenu = true },
+ modifier = Modifier.testTag("AddBooksSortButton")
+ ) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort),
@@ -1581,6 +1670,7 @@ private fun ShelfListItem(
),
modifier = Modifier
.fillMaxWidth()
+ .testTag("ShelfItem_${shelf.id}")
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
else Modifier
@@ -1659,6 +1749,7 @@ private fun LibraryListItem(
),
modifier = Modifier
.fillMaxWidth()
+ .testTag("LibraryBookItem_${item.bookId}")
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
@@ -1929,12 +2020,15 @@ private fun FolderSyncScreen(
allRecentFiles: List,
onAddFolderClick: () -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit,
+ onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set) -> Unit,
onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
isLoading: Boolean
) {
var editingFolder by remember { mutableStateOf(null) }
+ var disablingFolder by remember { mutableStateOf(null) }
+ val hasEnabledSyncFolders = syncedFolders.any { it.localSyncEnabled }
val folderStatsByUri = remember(allRecentFiles) {
allRecentFiles
.asSequence()
@@ -1973,7 +2067,7 @@ private fun FolderSyncScreen(
) {
FilledTonalButton(
onClick = onScanNowClick,
- enabled = !isLoading,
+ enabled = !isLoading && hasEnabledSyncFolders,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
@@ -1988,7 +2082,7 @@ private fun FolderSyncScreen(
androidx.compose.material3.OutlinedButton(
onClick = onSyncMetadataClick,
- enabled = !isLoading,
+ enabled = !isLoading && hasEnabledSyncFolders,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
@@ -2016,6 +2110,13 @@ private fun FolderSyncScreen(
folder = folder,
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
onRemoveClick = onRemoveFolderClick,
+ onLocalSyncToggleClick = { selectedFolder ->
+ if (selectedFolder.localSyncEnabled) {
+ disablingFolder = selectedFolder
+ } else {
+ onFolderLocalSyncChange(selectedFolder, true, false)
+ }
+ },
onEditFiltersClick = { editingFolder = folder }
)
}
@@ -2033,6 +2134,46 @@ private fun FolderSyncScreen(
onDismiss = { editingFolder = null }
)
}
+
+ disablingFolder?.let { folder ->
+ AlertDialog(
+ onDismissRequest = { disablingFolder = null },
+ title = { Text(stringResource(R.string.dialog_disable_folder_local_sync_title)) },
+ text = {
+ Text(
+ stringResource(
+ R.string.dialog_disable_folder_local_sync_desc,
+ LOCAL_FOLDER_SYNC_DATA_DIR
+ )
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onFolderLocalSyncChange(folder, false, true)
+ disablingFolder = null
+ }
+ ) {
+ Text(stringResource(R.string.action_disable_remove_sync_data))
+ }
+ },
+ dismissButton = {
+ Row {
+ TextButton(onClick = { disablingFolder = null }) {
+ Text(stringResource(R.string.action_cancel))
+ }
+ TextButton(
+ onClick = {
+ onFolderLocalSyncChange(folder, false, false)
+ disablingFolder = null
+ }
+ ) {
+ Text(stringResource(R.string.action_disable_keep_sync_data))
+ }
+ }
+ }
+ )
+ }
}
private data class FolderFileStats(
@@ -2050,6 +2191,7 @@ private fun FolderCard(
folder: SyncedFolder,
stats: FolderFileStats,
onRemoveClick: (SyncedFolder) -> Unit,
+ onLocalSyncToggleClick: (SyncedFolder) -> Unit,
onEditFiltersClick: (SyncedFolder) -> Unit
) {
var showMenu by remember { mutableStateOf(false) }
@@ -2075,13 +2217,22 @@ private fun FolderCard(
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
- Text(
- text = folder.name,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = folder.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (!folder.localSyncEnabled) {
+ Text(
+ text = stringResource(R.string.folder_local_sync_disabled),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
}
Box {
@@ -2096,6 +2247,21 @@ private fun FolderCard(
onEditFiltersClick(folder)
}
)
+ DropdownMenuItem(
+ text = {
+ Text(
+ if (folder.localSyncEnabled) {
+ stringResource(R.string.menu_disable_folder_local_sync)
+ } else {
+ stringResource(R.string.menu_enable_folder_local_sync)
+ }
+ )
+ },
+ onClick = {
+ showMenu = false
+ onLocalSyncToggleClick(folder)
+ }
+ )
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_remove_folder)) },
onClick = {
@@ -2377,6 +2543,7 @@ fun OpdsTab(
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
val downloadingState = uiState.downloadingState
val context = LocalContext.current
+ val coverImageLoader = rememberOpdsCoverImageLoader(uiState.currentCatalog)
var selectedEntry by remember { mutableStateOf(null) }
var showCatalogDialog by remember { mutableStateOf(false) }
var editingCatalog by remember { mutableStateOf(null) }
@@ -2601,6 +2768,7 @@ fun OpdsTab(
entry = entry,
localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[entry.id],
+ coverImageLoader = coverImageLoader,
onDownloadClick = { acquisition ->
opdsViewModel.downloadBook(
entry, acquisition, context
@@ -2649,6 +2817,7 @@ fun OpdsTab(
entry = selectedEntry!!,
localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[selectedEntry!!.id],
+ coverImageLoader = coverImageLoader,
onDownloadFormat = { acquisition ->
opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
onBookDownloaded(downloadedUri, selectedEntry!!.title)
@@ -2776,6 +2945,29 @@ fun OpdsTab(
}
}
+@Composable
+private fun rememberOpdsCoverImageLoader(catalog: OpdsCatalog?): ImageLoader {
+ val context = LocalContext.current.applicationContext
+ val username = catalog?.username
+ val password = catalog?.password
+ val imageLoader = remember(context, username, password) {
+ ImageLoader.Builder(context)
+ .okHttpClient {
+ OpdsRepository.sharedHttpClient.newBuilder()
+ .authenticator(OpdsRepository.OpdsAuthenticator(username, password))
+ .build()
+ }
+ .components {
+ add(SvgDecoder.Factory())
+ }
+ .build()
+ }
+ DisposableEffect(imageLoader) {
+ onDispose { imageLoader.shutdown() }
+ }
+ return imageLoader
+}
+
@Composable
fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
Surface(
@@ -2853,13 +3045,20 @@ fun OpdsBookCard(
entry: OpdsEntry,
localLibraryFiles: List,
downloadState: OpdsDownloadState?,
+ coverImageLoader: ImageLoader,
onDownloadClick: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit,
onClick: () -> Unit
) {
val libraryItem = remember(entry, localLibraryFiles) {
- localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
+ SharedOpdsLocalBookMatcher.find(
+ entry = entry,
+ books = localLibraryFiles,
+ title = { it.title },
+ displayName = { it.displayName },
+ path = { it.uriString }
+ )
}
val isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress
@@ -2878,6 +3077,7 @@ fun OpdsBookCard(
AsyncImage(
model = entry.coverUrl,
contentDescription = null,
+ imageLoader = coverImageLoader,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 70.dp, height = 100.dp)
@@ -2984,6 +3184,7 @@ fun OpdsBookDetailsSheet(
entry: OpdsEntry,
localLibraryFiles: List,
downloadState: OpdsDownloadState?,
+ coverImageLoader: ImageLoader,
onDownloadFormat: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit,
@@ -2992,7 +3193,13 @@ fun OpdsBookDetailsSheet(
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val libraryItem = remember(entry, localLibraryFiles) {
- localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
+ SharedOpdsLocalBookMatcher.find(
+ entry = entry,
+ books = localLibraryFiles,
+ title = { it.title },
+ displayName = { it.displayName },
+ path = { it.uriString }
+ )
}
val isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress
@@ -3012,6 +3219,7 @@ fun OpdsBookDetailsSheet(
AsyncImage(
model = entry.coverUrl,
contentDescription = null,
+ imageLoader = coverImageLoader,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 110.dp, height = 160.dp)
diff --git a/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryStateProjector.kt
similarity index 89%
rename from app/src/main/java/com/aryan/reader/LibraryStateProjector.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/LibraryStateProjector.kt
index 586f036..61f1df1 100644
--- a/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryStateProjector.kt
@@ -1,14 +1,14 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
-import com.aryan.reader.data.BookShelfCrossRef
-import com.aryan.reader.data.BookTagCrossRef
-import com.aryan.reader.data.RecentFileItem
-import com.aryan.reader.data.ShelfEntity
-import com.aryan.reader.data.TagEntity
-import com.aryan.reader.shared.SharedReaderScreenState
-import com.aryan.reader.shared.applyLibraryFilters as sharedApplyLibraryFilters
-import com.aryan.reader.shared.filterBySearch as sharedFilterBySearch
-import com.aryan.reader.shared.sortBooks as sharedSortBooks
+import org.dueattendant149.bookreader.data.BookShelfCrossRef
+import org.dueattendant149.bookreader.data.BookTagCrossRef
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.ShelfEntity
+import org.dueattendant149.bookreader.data.TagEntity
+import org.dueattendant149.bookreader.shared.SharedReaderScreenState
+import org.dueattendant149.bookreader.shared.applyLibraryFilters as sharedApplyLibraryFilters
+import org.dueattendant149.bookreader.shared.filterBySearch as sharedFilterBySearch
+import org.dueattendant149.bookreader.shared.sortBooks as sharedSortBooks
fun interface FolderPathResolver {
fun relativeFolderSegments(item: RecentFileItem): List
@@ -151,7 +151,7 @@ fun sortFiles(files: List, sortOrder: SortOrder): List.mapSharedResults(sharedBooks: List): List {
+private fun List.mapSharedResults(sharedBooks: List): List {
val byId = associateBy { it.bookId }
return sharedBooks.mapNotNull { byId[it.id] }
}
diff --git a/app/src/main/java/com/aryan/reader/MainActivity.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/MainActivity.kt
similarity index 84%
rename from app/src/main/java/com/aryan/reader/MainActivity.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/MainActivity.kt
index e5835ed..fd5823e 100644
--- a/app/src/main/java/com/aryan/reader/MainActivity.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/MainActivity.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.content.Intent
import android.os.Build
@@ -41,8 +41,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
-import com.aryan.reader.data.PlatformFeaturesRepository
-import com.aryan.reader.ui.theme.AppTheme
+import org.dueattendant149.bookreader.data.PlatformFeaturesRepository
+import org.dueattendant149.bookreader.ui.theme.AppTheme
import kotlinx.coroutines.launch
import timber.log.Timber
import androidx.compose.foundation.isSystemInDarkTheme
@@ -50,18 +50,20 @@ import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
-import com.aryan.reader.tts.ACTION_OPEN_TTS_SESSION
-import com.aryan.reader.tts.EXTRA_TTS_BOOK_ID
-import com.aryan.reader.tts.EXTRA_TTS_CHAPTER_INDEX
-import com.aryan.reader.tts.EXTRA_TTS_PAGE_INDEX
-import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI
-import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET
+import org.dueattendant149.bookreader.tts.ACTION_OPEN_TTS_SESSION
+import org.dueattendant149.bookreader.tts.EXTRA_TTS_BOOK_ID
+import org.dueattendant149.bookreader.tts.EXTRA_TTS_CHAPTER_INDEX
+import org.dueattendant149.bookreader.tts.EXTRA_TTS_PAGE_INDEX
+import org.dueattendant149.bookreader.tts.EXTRA_TTS_SOURCE_CFI
+import org.dueattendant149.bookreader.tts.EXTRA_TTS_START_OFFSET
@UnstableApi
-class MainActivity : AppCompatActivity() {
+open class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
+ private val isTemporaryExternalOpen: Boolean
+ get() = intent?.getBooleanExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, false) == true
private val updateLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
@@ -86,6 +88,14 @@ class MainActivity : AppCompatActivity() {
}
}
+ lifecycleScope.launch {
+ viewModel.temporaryExternalOpenFinished.collect {
+ if (isTemporaryExternalOpen) {
+ finishAndRemoveTask()
+ }
+ }
+ }
+
if (savedInstanceState == null) {
handleIntent(intent)
}
@@ -160,7 +170,12 @@ class MainActivity : AppCompatActivity() {
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
Timber.d("Received VIEW intent with URI: ${intent.data}")
val uri = intent.data!!
- viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true)
+ viewModel.onFileSelected(
+ uri,
+ isFromRecent = false,
+ isExternalIntent = true,
+ isTemporaryExternalIntent = isTemporaryExternalOpen
+ )
}
}
}
diff --git a/app/src/main/java/com/aryan/reader/MainPreferenceKeys.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/MainPreferenceKeys.kt
similarity index 94%
rename from app/src/main/java/com/aryan/reader/MainPreferenceKeys.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/MainPreferenceKeys.kt
index e12fd41..881ba33 100644
--- a/app/src/main/java/com/aryan/reader/MainPreferenceKeys.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/MainPreferenceKeys.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
internal const val KEY_RENDER_MODE = "render_mode"
internal const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
diff --git a/app/src/main/java/com/aryan/reader/MainScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/MainScreen.kt
similarity index 99%
rename from app/src/main/java/com/aryan/reader/MainScreen.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/MainScreen.kt
index c0c933d..fda939f 100644
--- a/app/src/main/java/com/aryan/reader/MainScreen.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/MainScreen.kt
@@ -18,7 +18,7 @@
* mail: epistemereader@gmail.com
*/
// MainScreen.kt
-package com.aryan.reader
+package org.dueattendant149.bookreader
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/MainViewModel.kt
similarity index 73%
rename from app/src/main/java/com/aryan/reader/MainViewModel.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/MainViewModel.kt
index 654ec84..a1e7069 100644
--- a/app/src/main/java/com/aryan/reader/MainViewModel.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/MainViewModel.kt
@@ -20,7 +20,7 @@
// MainViewModel.kt
@file:Suppress("DEPRECATION", "ANNOTATION_WILL_BE_APPLIED_ALSO_TO_PROPERTY_OR_FIELD")
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.app.Application
import android.content.ClipData
@@ -31,11 +31,11 @@ import android.database.Cursor
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
-import com.aryan.reader.tts.TtsController
-import com.aryan.reader.tts.TtsPlaybackManager
-import com.aryan.reader.paginatedreader.LocatorConverter
+import org.dueattendant149.bookreader.tts.TtsController
+import org.dueattendant149.bookreader.tts.TtsPlaybackManager
+import org.dueattendant149.bookreader.paginatedreader.LocatorConverter
import kotlinx.serialization.protobuf.ProtoBuf
-import com.aryan.reader.paginatedreader.semanticBlockModule
+import org.dueattendant149.bookreader.paginatedreader.semanticBlockModule
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.annotation.OptIn
@@ -53,64 +53,72 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
-import com.aryan.reader.data.BookMetadata
-import com.aryan.reader.data.BookMetadataEdit
-import com.aryan.reader.data.CloudflareRepository
-import com.aryan.reader.data.CustomFontEntity
-import com.aryan.reader.data.FeedbackRepository
-import com.aryan.reader.data.FirestoreRepository
-import com.aryan.reader.data.FontMetadata
-import com.aryan.reader.data.FontsRepository
-import com.aryan.reader.data.GoogleDriveRepository
-import com.aryan.reader.data.PurchaseEntity
-import com.aryan.reader.data.RecentFileItem
-import com.aryan.reader.data.RecentFilesRepository
-import com.aryan.reader.data.RemoteConfigRepository
-import com.aryan.reader.data.ShelfMetadata
-import com.aryan.reader.data.TagEntity
-import com.aryan.reader.data.getUri
-import com.aryan.reader.data.toBookMetadata
-import com.aryan.reader.data.toRecentFileItem
-import com.aryan.reader.epub.CalibreBundleExtractor
-import com.aryan.reader.epub.CalibreBundleResult
-import com.aryan.reader.epub.EpubBook
-import com.aryan.reader.epub.EpubParser
-import com.aryan.reader.epub.ImportedFileCache
-import com.aryan.reader.epub.MobiParser
-import com.aryan.reader.epub.SingleFileImporter
-import com.aryan.reader.epub.hasReadableExtractedContent
-import com.aryan.reader.ml.ISpeechBubbleDetector
-import com.aryan.reader.ml.SpeechBubble
-import com.aryan.reader.ml.SpeechBubbleDetector
-import com.aryan.reader.paginatedreader.Locator
-import com.aryan.reader.paginatedreader.data.BookCacheDatabase
-import com.aryan.reader.paginatedreader.data.BookProcessingWorker
-import com.aryan.reader.pdf.PdfCoverGenerator
-import com.aryan.reader.pdf.PDF_BLANK_PAGE_PERSISTENCE_TAG
-import com.aryan.reader.pdf.PdfUserHighlight
-import com.aryan.reader.pdf.PdfiumCoreProvider
-import com.aryan.reader.pdf.PdfiumEngineProvider
-import com.aryan.reader.pdf.PdfiumAnnotationExporter
-import com.aryan.reader.pdf.ReflowWorker
-import com.aryan.reader.pdf.pdfLayoutDebugSummary
-import com.aryan.reader.pdf.remapPdfAnnotationsForLayoutChange
-import com.aryan.reader.pdf.remapPdfBookmarksJsonForLayoutChange
-import com.aryan.reader.pdf.data.PageLayoutRepository
-import com.aryan.reader.pdf.data.PdfAnnotation
-import com.aryan.reader.pdf.data.PdfAnnotationRepository
-import com.aryan.reader.pdf.data.PdfHighlightRepository
-import com.aryan.reader.pdf.data.PdfTextBox
-import com.aryan.reader.pdf.data.PdfTextBoxRepository
-import com.aryan.reader.pdf.data.PdfTextRepository
-import com.aryan.reader.pdf.data.VirtualPage
-import com.aryan.reader.pptx.PptxCoverGenerator
-import com.aryan.reader.shared.SharedFileCapabilities
-import com.aryan.reader.shared.SharedLibraryEditor
-import com.aryan.reader.shared.SharedImportOutcomeCounts
-import com.aryan.reader.shared.SharedImportPlanner
-import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
-import com.aryan.reader.shared.AppAction as SharedAppAction
-import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
+import org.dueattendant149.bookreader.data.BookMetadata
+import org.dueattendant149.bookreader.data.BookMetadataEdit
+import org.dueattendant149.bookreader.data.CloudflareRepository
+import org.dueattendant149.bookreader.data.CustomFontEntity
+import org.dueattendant149.bookreader.data.FeedbackRepository
+import org.dueattendant149.bookreader.data.FirestoreRepository
+import org.dueattendant149.bookreader.data.FontMetadata
+import org.dueattendant149.bookreader.data.FontsRepository
+import org.dueattendant149.bookreader.data.GoogleDriveRepository
+import org.dueattendant149.bookreader.data.LocalSyncUtils
+import org.dueattendant149.bookreader.data.PurchaseEntity
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.RecentFilesRepository
+import org.dueattendant149.bookreader.data.RemoteConfigRepository
+import org.dueattendant149.bookreader.data.ShelfMetadata
+import org.dueattendant149.bookreader.data.TagEntity
+import org.dueattendant149.bookreader.data.effectiveAnnotationModifiedTimestamp
+import org.dueattendant149.bookreader.data.effectiveReadingPositionModifiedTimestamp
+import org.dueattendant149.bookreader.data.getUri
+import org.dueattendant149.bookreader.data.toBookMetadata
+import org.dueattendant149.bookreader.data.toRecentFileItem
+import org.dueattendant149.bookreader.epub.CalibreBundleExtractor
+import org.dueattendant149.bookreader.epub.CalibreBundleResult
+import org.dueattendant149.bookreader.epub.EpubBook
+import org.dueattendant149.bookreader.epub.EpubParser
+import org.dueattendant149.bookreader.epub.ImportedFileCache
+import org.dueattendant149.bookreader.epub.MobiParser
+import org.dueattendant149.bookreader.epub.SingleFileImporter
+import org.dueattendant149.bookreader.epub.hasReadableExtractedContent
+import org.dueattendant149.bookreader.ml.ISpeechBubbleDetector
+import org.dueattendant149.bookreader.ml.SpeechBubble
+import org.dueattendant149.bookreader.ml.SpeechBubbleDetector
+import org.dueattendant149.bookreader.paginatedreader.Locator
+import org.dueattendant149.bookreader.paginatedreader.data.BookCacheDatabase
+import org.dueattendant149.bookreader.paginatedreader.data.BookProcessingWorker
+import org.dueattendant149.bookreader.pdf.PdfCoverGenerator
+import org.dueattendant149.bookreader.pdf.PDF_BLANK_PAGE_PERSISTENCE_TAG
+import org.dueattendant149.bookreader.pdf.PdfUserHighlight
+import org.dueattendant149.bookreader.pdf.PdfiumCoreProvider
+import org.dueattendant149.bookreader.pdf.PdfiumEngineProvider
+import org.dueattendant149.bookreader.pdf.PdfiumAnnotationExporter
+import org.dueattendant149.bookreader.pdf.ReflowWorker
+import org.dueattendant149.bookreader.pdf.pdfLayoutDebugSummary
+import org.dueattendant149.bookreader.pdf.remapPdfAnnotationsForLayoutChange
+import org.dueattendant149.bookreader.pdf.remapPdfBookmarksJsonForLayoutChange
+import org.dueattendant149.bookreader.pdf.data.PageLayoutRepository
+import org.dueattendant149.bookreader.pdf.data.PdfAnnotation
+import org.dueattendant149.bookreader.pdf.data.PdfAnnotationRepository
+import org.dueattendant149.bookreader.pdf.data.PdfHighlightRepository
+import org.dueattendant149.bookreader.pdf.data.PdfTextBox
+import org.dueattendant149.bookreader.pdf.data.PdfTextBoxRepository
+import org.dueattendant149.bookreader.pdf.data.PdfTextRepository
+import org.dueattendant149.bookreader.pdf.data.VirtualPage
+import org.dueattendant149.bookreader.pptx.PptxCoverGenerator
+import org.dueattendant149.bookreader.shared.SharedFileCapabilities
+import org.dueattendant149.bookreader.shared.SharedLibraryEditor
+import org.dueattendant149.bookreader.shared.SharedImportOutcomeCounts
+import org.dueattendant149.bookreader.shared.SharedImportPlanner
+import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec
+import org.dueattendant149.bookreader.shared.shouldApplyRemoteCloudBookMetadataUpdate
+import org.dueattendant149.bookreader.shared.shouldDownloadRemoteCloudBookContent
+import org.dueattendant149.bookreader.shared.shouldUploadLocalCloudBookContent
+import org.dueattendant149.bookreader.shared.shouldUploadLocalCloudBookMetadataUpdate
+import org.dueattendant149.bookreader.shared.sharedCloudBookContentFileName
+import org.dueattendant149.bookreader.shared.AppAction as SharedAppAction
+import org.dueattendant149.bookreader.shared.LibraryAction as SharedLibraryAction
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Deferred
@@ -132,7 +140,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -163,7 +170,14 @@ private data class CachedSpeechBubble(
val maskBitmap: Bitmap?
)
+private data class PendingExternalFileRemoval(
+ val bookId: String,
+ val uriString: String?
+)
+
private const val BANNER_AUTO_DISMISS_MILLIS = 3_000L
+private const val CLOUD_CONTENT_RETRY_DELAY_MILLIS = 10_000L
+private const val CLOUD_METADATA_UPLOAD_DEBOUNCE_MILLIS = 1_500L
@kotlin.OptIn(ExperimentalSerializationApi::class)
@UnstableApi
@@ -176,13 +190,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val bookCacheDao by lazy { BookCacheDatabase.getDatabase(application).bookCacheDao() }
private val epubParser by lazy { EpubParser(appContext) }
private val mobiParser by lazy { MobiParser(appContext) }
- private val fb2Parser by lazy { com.aryan.reader.epub.Fb2Parser(appContext) }
- private val odtParser by lazy { com.aryan.reader.epub.OdtParser(appContext) }
+ private val fb2Parser by lazy { org.dueattendant149.bookreader.epub.Fb2Parser(appContext) }
+ private val odtParser by lazy { org.dueattendant149.bookreader.epub.OdtParser(appContext) }
private val singleFileImporter by lazy { SingleFileImporter(appContext) }
private val bookImporter by lazy { BookImporter(appContext) }
private val epubMetadataFileEditor by lazy { EpubMetadataFileEditor(appContext) }
private val pageLayoutRepository by lazy { PageLayoutRepository(appContext) }
- private val pdfRichTextRepository by lazy { com.aryan.reader.pdf.PdfRichTextRepository(appContext) }
+ private val pdfRichTextRepository by lazy { org.dueattendant149.bookreader.pdf.PdfRichTextRepository(appContext) }
private val pdfTextBoxRepository by lazy { PdfTextBoxRepository(appContext) }
private val pdfHighlightRepository by lazy { PdfHighlightRepository(appContext) }
private val pdfAnnotationRepository by lazy { PdfAnnotationRepository(appContext) }
@@ -208,12 +222,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val _navigationEvent = Channel(Channel.BUFFERED)
@Suppress("unused")
val navigationEvent = _navigationEvent.receiveAsFlow()
+ private val _temporaryExternalOpenFinished = Channel(Channel.BUFFERED)
+ val temporaryExternalOpenFinished = _temporaryExternalOpenFinished.receiveAsFlow()
private var bannerDismissJob: Job? = null
private var bannerDismissGeneration = 0L
private var pendingSwitchDeferred: CompletableDeferred? = null
private var externalOpenedBookId: String? = null
+ private var temporaryExternalSessionBookId: String? = null
+ private var cloudContentRetryJob: Job? = null
+ private val cloudMetadataUploadJobs = ConcurrentHashMap()
- private var panelDetector: com.aryan.reader.ml.IPanelDetector? = null
+ private var panelDetector: org.dueattendant149.bookreader.ml.IPanelDetector? = null
private var speechBubbleDetector: ISpeechBubbleDetector? = null
private val mlDispatcher = newSingleThreadExecutor().asCoroutineDispatcher()
@@ -221,17 +240,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val speechBubbleCache = ConcurrentHashMap>()
private val speechBubbleDetectionJobs = ConcurrentHashMap>>()
- private fun getOrInitDetector(context: Context): com.aryan.reader.ml.IPanelDetector? {
+ private fun getOrInitDetector(context: Context): org.dueattendant149.bookreader.ml.IPanelDetector? {
if (panelDetector == null && BuildConfig.DEBUG) {
val modelFile = File(context.getExternalFilesDir(null), "best_float16.tflite")
if (modelFile.exists()) {
try {
val clazz = Class.forName(
- "com.aryan.reader.ml.ComicPanelDetector",
+ "org.dueattendant149.bookreader.ml.ComicPanelDetector",
false,
context.classLoader
)
- panelDetector = clazz.getConstructor(File::class.java).newInstance(modelFile) as com.aryan.reader.ml.IPanelDetector
+ panelDetector = clazz.getConstructor(File::class.java).newInstance(modelFile) as org.dueattendant149.bookreader.ml.IPanelDetector
} catch (e: Exception) {
Timber.e(e, "Failed to instantiate ComicPanelDetector via reflection")
}
@@ -345,7 +364,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
Timber.d("CBZ copied to cache successfully. Opening archive...")
- val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile)
+ val archiveDoc = org.dueattendant149.bookreader.pdf.ArchiveDocumentWrapper(cacheFile)
val totalPages = archiveDoc.getPageCount()
Timber.d("Archive opened. Total pages: $totalPages")
@@ -595,8 +614,16 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val existingItem = recentFilesRepository.getFileByBookId(hash)
if (existingItem != null) {
- Timber.i("Book with ID: $hash already exists. Skipping import.")
- return null
+ val pendingRemoval = pendingExternalFileRemovals()
+ .firstOrNull { it.bookId == hash }
+ if (pendingRemoval != null) {
+ deletePendingExternalFileRemoval(
+ pendingRemoval.copy(uriString = pendingRemoval.uriString ?: existingItem.uriString)
+ )
+ } else {
+ Timber.i("Book with ID: $hash already exists. Skipping import.")
+ return null
+ }
}
val fileName = displayName ?: ""
@@ -780,6 +807,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(showTagSelectionDialogFor = emptySet()) }
}
+ suspend fun getFileInfoItem(bookId: String): RecentFileItem? {
+ return recentFilesRepository.getFileByBookId(bookId)
+ }
+
fun createAndAssignTag(name: String, bookIds: Set) {
val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
if (sanitizedBookIds.isEmpty()) return
@@ -1191,11 +1222,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- if (_internalState.value.syncedFolders.isNotEmpty()) {
+ if (_internalState.value.syncedFolders.any { it.localSyncEnabled }) {
triggerFolderSyncWorker(metadataOnly = false, showFeedback = false)
}
sweepOrphanedCache()
+ cleanupPendingExternalFileRemovals()
restoreReaderSessionIfNeeded()
viewModelScope.launch { billingClientWrapper.initializeConnection() }
@@ -1225,6 +1257,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (_internalState.value.isSyncEnabled) {
viewModelScope.launch {
+ logCloudSyncTrace {
+ "android.startup.sync_check user=${newUserData.uid} isSyncEnabled=${_internalState.value.isSyncEnabled}"
+ }
Timber.tag("AnnotationSync").d(
"Startup: Pro user & Sync enabled. Initiating cloud sync."
)
@@ -1232,6 +1267,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (googleDriveRepository.hasDrivePermissions(appContext)) {
syncWithCloud(showBanner = false)
} else {
+ logCloudSyncTrace { "android.startup.sync_skip reason=missing_drive_permissions user=${newUserData.uid}" }
Timber.tag("AnnotationSync").d(
"Startup: Sync skipped. Missing Drive permissions."
)
@@ -1276,6 +1312,119 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
+ private fun pendingExternalFileRemovals(): List {
+ return prefs.getStringSet(KEY_PENDING_EXTERNAL_FILE_REMOVALS, emptySet())
+ .orEmpty()
+ .mapNotNull(::decodePendingExternalFileRemoval)
+ .distinctBy { it.bookId }
+ }
+
+ private fun markPendingExternalFileRemoval(bookId: String, uriString: String?) {
+ if (bookId.isBlank()) return
+ val removalsByBookId = pendingExternalFileRemovals()
+ .associateBy { it.bookId }
+ .toMutableMap()
+ removalsByBookId[bookId] = PendingExternalFileRemoval(bookId, uriString)
+ writePendingExternalFileRemovals(removalsByBookId.values)
+ }
+
+ private fun clearPendingExternalFileRemovals(bookIds: Set) {
+ if (bookIds.isEmpty()) return
+ val remaining = pendingExternalFileRemovals().filterNot { it.bookId in bookIds }
+ writePendingExternalFileRemovals(remaining)
+ }
+
+ private fun writePendingExternalFileRemovals(removals: Collection) {
+ val encoded = removals
+ .filter { it.bookId.isNotBlank() }
+ .mapTo(mutableSetOf(), ::encodePendingExternalFileRemoval)
+ prefs.edit(commit = true) {
+ if (encoded.isEmpty()) {
+ remove(KEY_PENDING_EXTERNAL_FILE_REMOVALS)
+ } else {
+ putStringSet(KEY_PENDING_EXTERNAL_FILE_REMOVALS, encoded)
+ }
+ }
+ }
+
+ private fun cleanupPendingExternalFileRemovals() {
+ val removals = pendingExternalFileRemovals()
+ if (removals.isEmpty()) return
+
+ val pendingBookIds = removals.mapTo(mutableSetOf()) { it.bookId }
+ if (prefs.getString(KEY_LAST_OPEN_BOOK_ID, null) in pendingBookIds) {
+ clearPersistedReaderSession()
+ }
+
+ viewModelScope.launch {
+ removals.forEach { removal ->
+ deletePendingExternalFileRemoval(removal)
+ }
+ }
+ }
+
+ private fun deletePendingExternalFileRemoval(bookId: String, uriString: String?) {
+ markPendingExternalFileRemoval(bookId, uriString)
+ viewModelScope.launch {
+ deletePendingExternalFileRemoval(PendingExternalFileRemoval(bookId, uriString))
+ }
+ }
+
+ private suspend fun deletePendingExternalFileRemoval(removal: PendingExternalFileRemoval) {
+ var shouldRetry = false
+ runCatching {
+ cleanupBookDataLocally(removal.bookId)
+ }.onFailure { error ->
+ Timber.w(error, "Failed to clear local caches for pending external file ${removal.bookId}")
+ }
+
+ runCatching {
+ recentFilesRepository.deleteFilePermanently(listOf(removal.bookId))
+ }.onFailure { error ->
+ shouldRetry = true
+ Timber.w(error, "Failed to remove pending external file ${removal.bookId} from library")
+ }
+
+ removal.uriString?.let { uriString ->
+ runCatching {
+ bookImporter.deleteBookByUriString(uriString)
+ }.onFailure { error ->
+ shouldRetry = true
+ Timber.w(error, "Failed to delete pending external file copy for ${removal.bookId}")
+ }
+ }
+
+ if (!shouldRetry) {
+ clearPendingExternalFileRemovals(setOf(removal.bookId))
+ }
+ }
+
+ private fun encodePendingExternalFileRemoval(removal: PendingExternalFileRemoval): String {
+ return JSONObject()
+ .put("bookId", removal.bookId)
+ .apply {
+ if (!removal.uriString.isNullOrBlank()) {
+ put("uriString", removal.uriString)
+ }
+ }
+ .toString()
+ }
+
+ private fun decodePendingExternalFileRemoval(value: String): PendingExternalFileRemoval? {
+ val trimmed = value.trim()
+ if (trimmed.isBlank()) return null
+ return if (trimmed.startsWith("{")) {
+ runCatching {
+ val json = JSONObject(trimmed)
+ val bookId = json.optString("bookId").takeIf { it.isNotBlank() }
+ val uriString = json.optString("uriString").takeIf { it.isNotBlank() }
+ bookId?.let { PendingExternalFileRemoval(it, uriString) }
+ }.getOrNull()
+ } else {
+ PendingExternalFileRemoval(trimmed, null)
+ }
+ }
+
private fun restoreReaderSessionIfNeeded() {
val currentState = _internalState.value
if (currentState.selectedBookId != null || currentState.selectedPdfUri != null || currentState.selectedEpubUri != null) {
@@ -1286,6 +1435,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
runCatching { FileType.valueOf(typeName) }.getOrNull()
}
val restoreBookId = prefs.getString(KEY_LAST_OPEN_BOOK_ID, null) ?: return
+ if (restoreBookId in pendingExternalFileRemovals().map { it.bookId }) {
+ clearPersistedReaderSession()
+ return
+ }
if (persistedType == null) {
clearPersistedReaderSession()
return
@@ -1614,17 +1767,27 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun importFont(uri: Uri) {
+ importFonts(listOf(uri))
+ }
+
+ fun importFonts(uris: List) {
+ if (uris.isEmpty()) return
viewModelScope.launch {
_internalState.update { it.copy(isLoading = true) }
- val result = fontsRepository.importFont(uri)
- result.onSuccess { font ->
- if (uiState.value.isSyncEnabled) {
- uploadNewFont(font)
+ try {
+ uris.forEach { uri ->
+ val result = fontsRepository.importFont(uri)
+ result.onSuccess { font ->
+ if (uiState.value.isSyncEnabled) {
+ uploadNewFont(font)
+ }
+ }.onFailure {
+ showBanner(appContext.getString(R.string.error_import_font, it.message), isError = true)
+ }
}
- }.onFailure {
- showBanner(appContext.getString(R.string.error_import_font, it.message), isError = true)
+ } finally {
+ _internalState.update { it.copy(isLoading = false) }
}
- _internalState.update { it.copy(isLoading = false) }
}
}
@@ -1650,9 +1813,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun deleteFont(fontId: String) {
+ deleteFonts(listOf(fontId))
+ }
+
+ fun deleteFonts(fontIds: Collection) {
+ val uniqueFontIds = fontIds.filter { it.isNotBlank() }.toSet()
+ if (uniqueFontIds.isEmpty()) return
viewModelScope.launch {
- fontsRepository.deleteFont(fontId)
- if (_internalState.value.appFontPreference.referencesCustomFont(fontId)) {
+ uniqueFontIds.forEach { fontId ->
+ fontsRepository.deleteFont(fontId)
+ }
+ if (uniqueFontIds.any { _internalState.value.appFontPreference.referencesCustomFont(it) }) {
setAppFontPreference(AppFontPreference.System)
}
}
@@ -1773,24 +1944,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when (val deviceStatus =
firestoreRepository.getDeviceStatus(currentUser.uid, deviceId)) {
- is com.aryan.reader.data.DeviceStatus.Active -> {
+ is org.dueattendant149.bookreader.data.DeviceStatus.Active -> {
Timber.d("Device is active. Updating last seen.")
firestoreRepository.updateDeviceLastSeen(currentUser.uid, deviceId)
}
- is com.aryan.reader.data.DeviceStatus.Revoked -> {
+ is org.dueattendant149.bookreader.data.DeviceStatus.Revoked -> {
Timber.w("Device has been revoked. Signing out.")
firestoreRepository.deleteDevice(currentUser.uid, deviceId) // Clean up
signOut()
showBanner(appContext.getString(R.string.banner_device_removed))
}
- is com.aryan.reader.data.DeviceStatus.NotFound -> {
+ is org.dueattendant149.bookreader.data.DeviceStatus.NotFound -> {
Timber.d("Device not found during verification. Triggering full registration.")
registerOrUpdateDeviceOnSignIn(currentUser.uid)
}
- is com.aryan.reader.data.DeviceStatus.Error -> {
+ is org.dueattendant149.bookreader.data.DeviceStatus.Error -> {
Timber.e(deviceStatus.exception, "Error checking device status.")
_internalState.update {
it.copy(
@@ -1867,7 +2038,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
sourceUri: Uri,
destUri: Uri,
annotations: Map>,
- richTextPageLayouts: List? = null,
+ richTextPageLayouts: List? = null,
textBoxes: List? = null,
highlights: List? = null,
bookId: String
@@ -1925,6 +2096,48 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
+ internal fun trackExternalOpenForClose(
+ bookId: String,
+ importedCopyUriString: String?,
+ isTemporaryExternalIntent: Boolean
+ ) {
+ if (isTemporaryExternalIntent) {
+ temporaryExternalSessionBookId = bookId
+ if (importedCopyUriString != null) {
+ externalOpenedBookId = bookId
+ markPendingExternalFileRemoval(bookId, importedCopyUriString)
+ }
+ return
+ }
+
+ externalOpenedBookId = bookId
+ if (
+ importedCopyUriString != null &&
+ prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, EXTERNAL_FILE_BEHAVIOR_ASK) == "DELETE"
+ ) {
+ markPendingExternalFileRemoval(bookId, importedCopyUriString)
+ }
+ }
+
+ fun saveOriginalFile(sourceUri: Uri, destUri: Uri) {
+ viewModelScope.launch {
+ _internalState.update {
+ it.copy(isLoading = true, bannerMessage = BannerMessage(appContext.getString(R.string.banner_saving_original_file)))
+ }
+ try {
+ withContext(Dispatchers.IO) {
+ copyUriBytes(sourceUri, destUri)
+ }
+ showBanner(appContext.getString(R.string.banner_original_file_saved))
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to save original file")
+ showBanner(appContext.getString(R.string.error_saving_file, e.localizedMessage ?: e.message.orEmpty()), isError = true)
+ } finally {
+ _internalState.update { it.copy(isLoading = false) }
+ }
+ }
+ }
+
fun togglePinForContextualItems(isHome: Boolean) {
if (_internalState.value.contextualActionItems.isEmpty()) return
@@ -1962,7 +2175,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
activityContext: Context,
sourceUri: Uri,
annotations: Map>,
- richTextPageLayouts: List? = null,
+ richTextPageLayouts: List? = null,
textBoxes: List? = null,
highlights: List? = null,
includeAnnotations: Boolean,
@@ -2042,48 +2255,279 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private fun uploadSingleBookMetadata(book: RecentFileItem) {
+ suspend fun shareOriginalFile(
+ activityContext: Context,
+ sourceUri: Uri,
+ fileType: FileType,
+ filename: String
+ ) {
+ withContext(Dispatchers.IO) {
+ try {
+ val shareDir = File(appContext.cacheDir, "shared_files")
+ if (shareDir.exists()) {
+ shareDir.listFiles()?.forEach { file ->
+ try {
+ file.delete()
+ } catch (_: Exception) {
+ Timber.w("Failed to delete temp share file: ${file.name}")
+ }
+ }
+ } else {
+ shareDir.mkdirs()
+ }
+
+ val destFile = File(shareDir, filename)
+ FileOutputStream(destFile).use { output ->
+ appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
+ input.copyTo(output)
+ } ?: error("Could not open source file.")
+ }
+
+ val authority = "${appContext.packageName}.provider"
+ val contentUri = androidx.core.content.FileProvider.getUriForFile(
+ appContext, authority, destFile
+ )
+ val mimeType = SharedFileCapabilities.mimeTypeFor(fileType) ?: "application/octet-stream"
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
+ type = mimeType
+ putExtra(Intent.EXTRA_STREAM, contentUri)
+ putExtra(Intent.EXTRA_TITLE, filename)
+ putExtra(Intent.EXTRA_SUBJECT, appContext.getString(R.string.share_subject, filename))
+ clipData = ClipData.newRawUri(filename, contentUri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ val chooser = Intent.createChooser(shareIntent, appContext.getString(R.string.share_file_chooser_title))
+ if (activityContext !is android.app.Activity) {
+ chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ withContext(Dispatchers.Main) { activityContext.startActivity(chooser) }
+ } catch (e: Exception) {
+ Timber.e(e, "Share original file failed")
+ showBanner(appContext.getString(R.string.error_share_failed, e.localizedMessage ?: e.message.orEmpty()), isError = true)
+ }
+ }
+ }
+
+ private fun copyUriBytes(sourceUri: Uri, destUri: Uri) {
+ val contentResolver = appContext.contentResolver
+ contentResolver.openInputStream(sourceUri)?.use { input ->
+ contentResolver.openOutputStream(destUri)?.use { output ->
+ input.copyTo(output)
+ } ?: error("Could not open destination file.")
+ } ?: error("Could not open source file.")
+ }
+
+ private fun queueCloudMetadataUpload(bookId: String, reason: String, debounce: Boolean = true) {
if (!uiState.value.isSyncEnabled) return
+ cloudMetadataUploadJobs.remove(bookId)?.cancel()
+ val job = viewModelScope.launch {
+ if (debounce) delay(CLOUD_METADATA_UPLOAD_DEBOUNCE_MILLIS)
+ val latest = recentFilesRepository.getFileByBookId(bookId) ?: run {
+ logCloudSyncTrace { "android.upload.queue_skip reason=missing_local book=$bookId trigger=$reason" }
+ return@launch
+ }
+ logCloudSyncTrace {
+ "android.upload.queue_fire trigger=$reason debounce=$debounce ${latest.cloudSyncTraceSummary()}"
+ }
+ uploadSingleBookMetadata(latest)
+ }
+ cloudMetadataUploadJobs[bookId] = job
+ job.invokeOnCompletion {
+ if (cloudMetadataUploadJobs[bookId] == job) {
+ cloudMetadataUploadJobs.remove(bookId)
+ }
+ }
+ }
+
+ fun queuePdfSidecarCloudUpload(bookId: String) {
+ queueCloudMetadataUpload(bookId, reason = "pdf_sidecar")
+ }
+
+ private fun uploadSingleBookMetadata(book: RecentFileItem) {
+ if (!uiState.value.isSyncEnabled) {
+ logCloudSyncTrace { "android.upload.skip reason=sync_disabled ${book.cloudSyncTraceSummary()}" }
+ return
+ }
if (book.uriString?.startsWith("opds-pse") == true) {
+ logCloudSyncTrace { "android.upload.skip reason=opds_stream ${book.cloudSyncTraceSummary()}" }
Timber.d("Skipping metadata sync for OPDS stream book: ${book.displayName}")
return
}
if (book.sourceFolderUri != null) {
+ logCloudSyncTrace { "android.upload.skip reason=folder_book ${book.cloudSyncTraceSummary()}" }
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
return
}
if (book.isManualOnlyReaderFile()) {
+ logCloudSyncTrace { "android.upload.skip reason=manual_only ${book.cloudSyncTraceSummary()}" }
Timber.d("Skipping metadata sync for manual-only reader file: ${book.displayName}")
return
}
- val currentUser = uiState.value.currentUser ?: return
+ val currentUser = uiState.value.currentUser ?: run {
+ logCloudSyncTrace { "android.upload.skip reason=no_user ${book.cloudSyncTraceSummary()}" }
+ return
+ }
viewModelScope.launch {
try {
val deviceId = getInstallationId()
+ var bookForMetadata = book
+ var uploadedAnnotationPayload = false
+ var uploadedAnnotationModifiedTimestamp = 0L
+ var remoteBookLoaded = false
+ var remoteBookForUpload: BookMetadata? = null
+ var remoteAnnotationDriveTimestampLoaded = false
+ var remoteAnnotationDriveTimestamp = 0L
+ suspend fun loadRemoteBookForUpload(): BookMetadata? {
+ if (!remoteBookLoaded) {
+ remoteBookForUpload = firestoreRepository.getBookMetadata(currentUser.uid, book.bookId)
+ remoteBookLoaded = true
+ }
+ return remoteBookForUpload
+ }
+ suspend fun loadRemoteAnnotationDriveTimestamp(): Long {
+ if (!remoteAnnotationDriveTimestampLoaded) {
+ val remote = loadRemoteBookForUpload()
+ remoteAnnotationDriveTimestamp = if (remote?.hasAnnotations == true) {
+ googleDriveRepository.getAccessToken(appContext)?.let { accessToken ->
+ googleDriveRepository.getFiles(accessToken)
+ ?.files
+ .orEmpty()
+ .firstOrNull { it.name == cloudPdfAnnotationDriveFileName(book.bookId) }
+ ?.modifiedTimeMillis
+ } ?: 0L
+ } else {
+ 0L
+ }
+ remoteAnnotationDriveTimestampLoaded = true
+ }
+ return remoteAnnotationDriveTimestamp
+ }
+ if (book.needsRemoteEpubAnnotationMetadataGuard()) {
+ val remote = loadRemoteBookForUpload()
+ val merged = bookForMetadata.mergeRemoteEpubAnnotationMetadata(remote)
+ if (merged != bookForMetadata) {
+ logCloudSyncTrace {
+ "android.upload.epub_annotation_preserve book=${book.bookId} " +
+ "local=${bookForMetadata.cloudSyncTraceSummary()} " +
+ "remote=${remote?.cloudSyncTraceSummary() ?: "null"} " +
+ "merged=${merged.cloudSyncTraceSummary()}"
+ }
+ recentFilesRepository.addRecentFile(merged)
+ bookForMetadata = merged
+ }
+ }
+ val remoteForContent = loadRemoteBookForUpload()?.toRecentFileItem()
+ if (shouldUploadLocalBookContent(bookForMetadata, remoteForContent)) {
+ val accessToken = googleDriveRepository.getAccessToken(appContext) ?: run {
+ logCloudSyncTrace { "android.upload.content_guard_skip reason=no_access_token ${bookForMetadata.cloudSyncTraceSummary()}" }
+ return@launch
+ }
+ val source = bookForMetadata.getUri()?.path?.let(::File)
+ if (source?.exists() != true) {
+ logCloudSyncTrace {
+ "android.upload.content_guard_skip reason=file_missing book=${bookForMetadata.bookId} " +
+ "path=${(source?.absolutePath).cloudSyncPreview()}"
+ }
+ return@launch
+ }
+ logCloudSyncTrace {
+ "android.upload.content_guard_start book=${bookForMetadata.bookId} " +
+ "localContentTs=${bookForMetadata.fileContentModifiedTimestamp} " +
+ "remoteContentTs=${remoteForContent?.fileContentModifiedTimestamp ?: 0L}"
+ }
+ val uploadedFile = googleDriveRepository.uploadFile(
+ accessToken,
+ bookForMetadata.bookId,
+ source,
+ bookForMetadata.type
+ )
+ if (uploadedFile == null) {
+ logCloudSyncTrace { "android.upload.content_guard_failed book=${bookForMetadata.bookId}" }
+ return@launch
+ }
+ val contentTimestamp = bookForMetadata.fileContentModifiedTimestamp.takeIf { it > 0L }
+ ?: source.lastModified()
+ bookForMetadata = bookForMetadata.copy(
+ fileSize = source.length(),
+ fileContentModifiedTimestamp = contentTimestamp
+ )
+ logCloudSyncTrace {
+ "android.upload.content_guard_success book=${bookForMetadata.bookId} " +
+ "driveId=${uploadedFile.id} contentTs=$contentTimestamp"
+ }
+ }
+ logCloudSyncTrace { "android.upload.start device=$deviceId ${bookForMetadata.cloudSyncTraceSummary()}" }
Timber.tag("AnnotationSync").d("Preparing to sync book: ${book.bookId}")
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(book.bookId)
+ val deletedInkFile = pdfAnnotationRepository.getDeletedAnnotationsFileForSync(book.bookId)
val richTextFile = pdfRichTextRepository.getFileForSync(book.bookId)
val layoutFile = pageLayoutRepository.getLayoutFile(book.bookId)
val textBoxFile = pdfTextBoxRepository.getFileForSync(book.bookId)
val highlightFile = pdfHighlightRepository.getFileForSync(book.bookId)
- val hasInk = inkFile?.exists() == true
- val hasRichText = richTextFile.exists()
+ val hasInk = inkFile.hasSyncableCloudAnnotationPayload()
+ val hasDeletedInk = deletedInkFile.hasSyncableCloudAnnotationPayload()
+ val hasRichText = richTextFile.hasSyncableCloudAnnotationPayload()
val hasLayout = layoutFile.exists()
- val hasTextBoxes = textBoxFile.exists()
- val hasHighlights = highlightFile.exists()
- val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights
+ val hasTextBoxes = textBoxFile.hasSyncableCloudAnnotationPayload()
+ val hasHighlights = highlightFile.hasSyncableCloudAnnotationPayload()
+ val sidecars = AndroidPdfCloudSidecarState(
+ hasInk = hasInk,
+ inkTimestamp = inkFile?.lastModified() ?: 0L,
+ hasDeletedInk = hasDeletedInk,
+ deletedInkTimestamp = deletedInkFile?.lastModified() ?: 0L,
+ hasRichText = hasRichText,
+ richTextTimestamp = richTextFile.lastModified(),
+ hasLayout = hasLayout,
+ layoutTimestamp = layoutFile.lastModified(),
+ hasTextBoxes = hasTextBoxes,
+ textBoxesTimestamp = textBoxFile.lastModified(),
+ hasHighlights = hasHighlights,
+ highlightsTimestamp = highlightFile.lastModified()
+ )
+ logCloudSyncTrace {
+ "android.upload.sidecars book=${book.bookId} hasInk=$hasInk hasDeletedInk=$hasDeletedInk hasText=$hasRichText " +
+ "hasLayout=$hasLayout hasTextBoxes=$hasTextBoxes hasHighlights=$hasHighlights " +
+ "payloadTs=${sidecars.annotationPayloadTimestamp} bundleTs=${sidecars.bundleTimestamp}"
+ }
+ logCloudAnnotationSyncTrace {
+ "android.upload.inspect book=${book.bookId} remoteHas=${remoteBookForUpload?.hasAnnotations} " +
+ "remoteTs=${remoteBookForUpload?.lastModifiedTimestamp ?: 0L} " +
+ "ink{exists=$hasInk bytes=${inkFile?.length() ?: 0L} ts=${sidecars.inkTimestamp}} " +
+ "deletedInk{exists=$hasDeletedInk bytes=${deletedInkFile?.length() ?: 0L} ts=${sidecars.deletedInkTimestamp}} " +
+ "text{exists=$hasRichText bytes=${richTextFile.length()} ts=${sidecars.richTextTimestamp}} " +
+ "layout{exists=$hasLayout bytes=${layoutFile.length()} ts=${sidecars.layoutTimestamp}} " +
+ "textBoxes{exists=$hasTextBoxes bytes=${textBoxFile.length()} ts=${sidecars.textBoxesTimestamp}} " +
+ "highlights{exists=$hasHighlights bytes=${highlightFile.length()} ts=${sidecars.highlightsTimestamp}} " +
+ "payloadTs=${sidecars.annotationPayloadTimestamp} bundleTs=${sidecars.bundleTimestamp} " +
+ "hasPayload=${sidecars.hasAnnotationPayload}"
+ }
+ val remoteAnnotationDriveTimestampForUpload = loadRemoteAnnotationDriveTimestamp()
+ val remoteAnnotationTimestampForUpload =
+ remoteBookForUpload?.effectiveAnnotationModifiedTimestamp(remoteAnnotationDriveTimestampForUpload) ?: 0L
+ val localAnnotationsShouldUpload = shouldUploadLocalPdfCloudAnnotations(
+ localSidecars = sidecars,
+ remoteHasAnnotations = remoteBookForUpload?.hasAnnotations == true,
+ remoteAnnotationModifiedTimestamp = remoteAnnotationTimestampForUpload
+ )
+ logCloudAnnotationSyncTrace {
+ "android.upload.annotation_decision book=${book.bookId} " +
+ "localShouldUpload=$localAnnotationsShouldUpload remoteHas=${remoteBookForUpload?.hasAnnotations} " +
+ "remoteAnnTs=$remoteAnnotationTimestampForUpload " +
+ "remoteDriveAnnTs=$remoteAnnotationDriveTimestampForUpload payloadTs=${sidecars.annotationPayloadTimestamp}"
+ }
Timber.d(
"android.cloud.export candidates book=${book.bookId} hasRichText=$hasRichText " +
- "richBytes=${if (hasRichText) richTextFile.length() else 0L} hasAnyData=$hasAnyData"
+ "richBytes=${if (hasRichText) richTextFile.length() else 0L} " +
+ "hasAnnotationPayload=${sidecars.hasAnnotationPayload}"
)
- if (hasAnyData) {
+ if (localAnnotationsShouldUpload) {
if (googleDriveRepository.hasDrivePermissions(appContext)) {
val accessToken = googleDriveRepository.getAccessToken(appContext)
@@ -2115,6 +2559,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
if (hasInk) putJsonSafe("ink", inkFile)
+ if (hasDeletedInk) putJsonSafe(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS, deletedInkFile)
if (hasRichText) putJsonSafe("text", richTextFile)
if (hasLayout) putJsonSafe("layout", layoutFile)
if (hasTextBoxes) putJsonSafe("textBoxes", textBoxFile)
@@ -2123,7 +2568,54 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val bundleFile =
File(appContext.cacheDir, "sync_bundle_${book.bookId}.json")
val canonicalBundle = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString())
- bundleFile.writeText(canonicalBundle)
+ var uploadBundle = canonicalBundle
+ var mergedRemoteIntoUpload = false
+ if (remoteBookForUpload?.hasAnnotations == true) {
+ val remoteBundleFile = File(appContext.cacheDir, "remote_sync_bundle_${book.bookId}.json")
+ try {
+ val didDownloadRemote = googleDriveRepository.downloadAnnotationFile(
+ accessToken,
+ book.bookId,
+ remoteBundleFile
+ )
+ if (didDownloadRemote && remoteBundleFile.isFile) {
+ val remoteBundle = remoteBundleFile.readText()
+ val mergedBundle = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson(
+ localDataJson = canonicalBundle,
+ remoteDataJson = remoteBundle,
+ preferRemoteOnConflict = false
+ )
+ val localCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(canonicalBundle)
+ val remoteCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(remoteBundle)
+ val mergedCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(mergedBundle)
+ uploadBundle = mergedBundle
+ mergedRemoteIntoUpload = mergedBundle != canonicalBundle
+ logCloudAnnotationSyncTrace {
+ "android.upload.merge_remote book=${book.bookId} didDownload=true " +
+ "localCount=$localCount remoteCount=$remoteCount mergedCount=$mergedCount " +
+ "changed=$mergedRemoteIntoUpload"
+ }
+ } else {
+ logCloudAnnotationSyncTrace {
+ "android.upload.merge_remote_missing book=${book.bookId} " +
+ "didDownload=$didDownloadRemote tempExists=${remoteBundleFile.exists()}"
+ }
+ }
+ } catch (e: Exception) {
+ logCloudAnnotationSyncError(e) {
+ "android.upload.merge_remote_failed book=${book.bookId}"
+ }
+ } finally {
+ remoteBundleFile.delete()
+ }
+ }
+ bundleFile.writeText(uploadBundle)
+ logCloudAnnotationSyncTrace {
+ "android.upload.bundle_ready book=${book.bookId} " +
+ "rawKeys=${bundleJson.keys().asSequence().toList()} " +
+ "canonicalBytes=${canonicalBundle.length} uploadBytes=${uploadBundle.length} " +
+ "fileBytes=${bundleFile.length()}"
+ }
if (hasRichText) {
Timber.d(
"android.cloud.export.bundleReady book=${book.bookId} canonicalLen=${canonicalBundle.length} " +
@@ -2137,6 +2629,36 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
bundleFile.delete()
if (uploaded != null) {
+ uploadedAnnotationPayload = true
+ uploadedAnnotationModifiedTimestamp = uploaded.modifiedTimeMillis
+ if (mergedRemoteIntoUpload) {
+ recentFilesRepository.importAnnotationBundle(
+ book.bookId,
+ uploadBundle,
+ uploadedAnnotationModifiedTimestamp
+ )
+ logCloudAnnotationSyncTrace {
+ "android.upload.local_apply_merged book=${book.bookId} " +
+ "driveTs=$uploadedAnnotationModifiedTimestamp bytes=${uploadBundle.length}"
+ }
+ }
+ markPdfCloudAnnotationSidecarsSynced(
+ uploadedAnnotationModifiedTimestamp,
+ inkFile,
+ richTextFile,
+ layoutFile,
+ textBoxFile,
+ highlightFile,
+ deletedInkFile
+ )
+ logCloudAnnotationSyncTrace {
+ "android.upload.sidecar_success book=${book.bookId} driveId=${uploaded.id} " +
+ "driveTs=$uploadedAnnotationModifiedTimestamp bytes=${uploadBundle.length}"
+ }
+ logCloudSyncTrace {
+ "android.upload.sidecar_success book=${book.bookId} driveId=${uploaded.id} " +
+ "driveTs=$uploadedAnnotationModifiedTimestamp bytes=${uploadBundle.length}"
+ }
if (hasRichText) {
Timber
.d("android.cloud.export.uploadSuccess book=${book.bookId} driveId=${uploaded.id}")
@@ -2144,6 +2666,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("AnnotationSync")
.d("Bundle upload SUCCESS. ID: ${uploaded.id}")
} else {
+ logCloudAnnotationSyncTrace {
+ "android.upload.sidecar_failed book=${book.bookId} bytes=${uploadBundle.length}"
+ }
+ logCloudSyncTrace { "android.upload.sidecar_failed book=${book.bookId}; aborting_metadata_upload" }
if (hasRichText) {
Timber
.e("android.cloud.export.uploadFailed book=${book.bookId}")
@@ -2152,23 +2678,127 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
.e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.")
return@launch
}
+ } else {
+ logCloudAnnotationSyncTrace { "android.upload.skip_sidecar reason=no_access_token book=${book.bookId}" }
+ logCloudSyncTrace { "android.upload.sidecar_failed book=${book.bookId}; reason=no_access_token; aborting_metadata_upload" }
+ return@launch
}
+ } else {
+ logCloudAnnotationSyncTrace { "android.upload.skip_sidecar reason=missing_drive_permission book=${book.bookId}" }
+ logCloudSyncTrace { "android.upload.sidecar_failed book=${book.bookId}; reason=missing_drive_permission; aborting_metadata_upload" }
+ return@launch
}
} else {
- Timber.tag("AnnotationSync")
- .d("No local data (ink/text/layout) to upload for ${book.bookId}")
+ logCloudAnnotationSyncTrace {
+ "android.upload.skip_sidecar reason=${if (sidecars.hasAnnotationPayload) "remote_annotation_not_older" else "no_annotation_payload"} " +
+ "book=${book.bookId} layoutOnly=$hasLayout layoutTs=${sidecars.layoutTimestamp} " +
+ "remoteAnnTs=$remoteAnnotationTimestampForUpload payloadTs=${sidecars.annotationPayloadTimestamp}"
+ }
+ logCloudSyncTrace {
+ "android.upload.sidecars_skipped book=${book.bookId} " +
+ "reason=${if (sidecars.hasAnnotationPayload) "remote_annotation_not_older" else "no_annotation_payload"}"
+ }
+ Timber.tag("AnnotationSync").d(
+ if (sidecars.hasAnnotationPayload) {
+ "Local annotation payload is not newer than remote for ${book.bookId}"
+ } else {
+ "No local annotation payload (ink/text/text boxes/highlights) to upload for ${book.bookId}"
+ }
+ )
+ }
+
+ val latestLocalForMetadata = recentFilesRepository.getFileByBookId(bookForMetadata.bookId)
+ val refreshedBookForMetadata = bookForMetadata.withFreshLocalReadingPositionForCloudUpload(
+ latestLocalForMetadata
+ )
+ if (refreshedBookForMetadata != bookForMetadata) {
+ logCloudSyncTrace {
+ "android.upload.refresh_latest book=${bookForMetadata.bookId} " +
+ "before=${bookForMetadata.cloudSyncTraceSummary()} " +
+ "latest=${latestLocalForMetadata?.cloudSyncTraceSummary() ?: "null"} " +
+ "after=${refreshedBookForMetadata.cloudSyncTraceSummary()}"
+ }
+ bookForMetadata = refreshedBookForMetadata
+ }
+ val remoteMetadata = loadRemoteBookForUpload()
+ val localReadingTimestamp = bookForMetadata.effectiveReadingPositionModifiedTimestamp()
+ val remoteReadingTimestamp = remoteMetadata?.effectiveReadingPositionModifiedTimestamp() ?: 0L
+ val remoteAnnotationTimestamp = remoteMetadata?.effectiveAnnotationModifiedTimestamp(
+ remoteAnnotationDriveTimestampForUpload
+ ) ?: 0L
+ val remoteReadingPositionWins = remoteMetadata != null && remoteReadingTimestamp > localReadingTimestamp
+ val remoteMetadataWins = remoteMetadata != null &&
+ remoteMetadata.lastModifiedTimestamp > bookForMetadata.lastModifiedTimestamp
+ val metadataBase = if (remoteMetadataWins && remoteMetadata != null) {
+ remoteMetadata.toRecentFileItem().withLocalStorageForCloudMetadata(bookForMetadata)
+ } else {
+ bookForMetadata
+ }
+ val metadataBook = when {
+ remoteReadingPositionWins && remoteMetadata != null -> metadataBase.withCloudReadingPosition(remoteMetadata)
+ metadataBase != bookForMetadata -> metadataBase.withLocalReadingPosition(bookForMetadata)
+ else -> bookForMetadata
+ }
+ val readingPositionTimestamp = if (remoteReadingPositionWins) {
+ remoteReadingTimestamp
+ } else {
+ localReadingTimestamp
+ }
+ if (remoteReadingPositionWins) {
+ logCloudSyncTrace {
+ "android.upload.preserve_remote_position book=${bookForMetadata.bookId} " +
+ "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " +
+ "remote=${remoteMetadata?.cloudSyncTraceSummary() ?: "null"}"
+ }
+ }
+ if (remoteMetadataWins) {
+ logCloudSyncTrace {
+ "android.upload.preserve_remote_metadata book=${bookForMetadata.bookId} " +
+ "remoteTs=${remoteMetadata?.lastModifiedTimestamp ?: 0L} localTs=${bookForMetadata.lastModifiedTimestamp} " +
+ "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " +
+ metadataBook.cloudSyncTraceSummary("metadata")
+ }
}
val newTimestamp = System.currentTimeMillis()
- val metadataToSync = book.toBookMetadata().copy(
- lastModifiedTimestamp = newTimestamp, hasAnnotations = hasAnyData
+ val syncedAnnotationTimestamp = if (uploadedAnnotationPayload) {
+ uploadedAnnotationModifiedTimestamp.takeIf { it > 0L }
+ ?: maxOf(sidecars.annotationPayloadTimestamp, newTimestamp)
+ } else if (remoteMetadata?.hasAnnotations == true) {
+ remoteAnnotationTimestamp
+ } else {
+ 0L
+ }
+ val syncedHasAnnotations = uploadedAnnotationPayload || remoteMetadata?.hasAnnotations == true
+ val metadataToSync = metadataBook.toBookMetadata().copy(
+ lastModifiedTimestamp = newTimestamp,
+ readingPositionModifiedTimestamp = readingPositionTimestamp,
+ annotationModifiedTimestamp = syncedAnnotationTimestamp,
+ hasAnnotations = syncedHasAnnotations
)
firestoreRepository.syncBookMetadata(currentUser.uid, metadataToSync, deviceId)
- recentFilesRepository.addRecentFile(book.copy(lastModifiedTimestamp = newTimestamp))
+ recentFilesRepository.addRecentFile(
+ metadataBook.copy(
+ lastModifiedTimestamp = newTimestamp,
+ readingPositionModifiedTimestamp = readingPositionTimestamp
+ )
+ )
+ logCloudAnnotationSyncTrace {
+ "android.upload.metadata_success book=${bookForMetadata.bookId} newTs=$newTimestamp " +
+ "readTs=$readingPositionTimestamp hasAnnotations=$syncedHasAnnotations " +
+ "annTs=$syncedAnnotationTimestamp payloadTs=${sidecars.annotationPayloadTimestamp}"
+ }
+ logCloudSyncTrace {
+ "android.upload.metadata_success user=${currentUser.uid} oldTs=${bookForMetadata.lastModifiedTimestamp} " +
+ "newTs=$newTimestamp readTs=$readingPositionTimestamp annTs=$syncedAnnotationTimestamp " +
+ "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " +
+ "hasAnnotations=$syncedHasAnnotations ${metadataBook.cloudSyncTraceSummary()}"
+ }
Timber.tag("AnnotationSync")
- .d("Firestore metadata updated for ${book.bookId} (hasData=$hasAnyData)")
+ .d("Firestore metadata updated for ${book.bookId} (hasAnnotationPayload=${sidecars.hasAnnotationPayload}, syncedHasAnnotations=$syncedHasAnnotations)")
} catch (e: Exception) {
+ logCloudSyncError(e) { "android.upload.failed ${book.cloudSyncTraceSummary()}" }
Timber.tag("AnnotationSync").e(e, "Failed to sync book data: ${book.bookId}")
}
}
@@ -2242,6 +2872,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val closingBookId = _internalState.value.selectedBookId
val uriString = _internalState.value.selectedPdfUri?.toString()
?: _internalState.value.selectedEpubUri?.toString()
+ val isTemporaryExternalSession = closingBookId != null && closingBookId == temporaryExternalSessionBookId
+ logCloudSyncTrace {
+ "android.reader.close_request book=$closingBookId uri=${uriString.cloudSyncPreview()} sync=${uiState.value.isSyncEnabled}"
+ }
val ttsState = ttsController.ttsState.value
val isTtsActive = ttsState.playbackSource == "READER" &&
@@ -2265,6 +2899,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
selectedEpubBook = null,
selectedFileType = null,
isLoading = false,
+ isTemporaryExternalOpen = false,
errorMessage = null,
initialLocator = null,
initialPageInBook = null,
@@ -2272,28 +2907,56 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isOpeningFromTtsNotification = false
)
}
- clearPersistedReaderSession()
+ if (!isTemporaryExternalSession) {
+ clearPersistedReaderSession()
+ }
- if (closingBookId != null && closingBookId == externalOpenedBookId) {
- val behavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK"
+ var removesExternalFileOnClose = false
+ if (closingBookId != null && (closingBookId == externalOpenedBookId || isTemporaryExternalSession)) {
+ val behavior = if (isTemporaryExternalSession) {
+ EXTERNAL_FILE_BEHAVIOR_TEMPORARY
+ } else {
+ prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, EXTERNAL_FILE_BEHAVIOR_ASK) ?: EXTERNAL_FILE_BEHAVIOR_ASK
+ }
if (behavior == "ASK") {
_internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) }
} else if (behavior == "DELETE") {
- deleteBookPermanently(closingBookId)
+ removesExternalFileOnClose = true
+ deletePendingExternalFileRemoval(closingBookId, uriString)
+ } else if (behavior == EXTERNAL_FILE_BEHAVIOR_TEMPORARY) {
+ removesExternalFileOnClose = true
+ val shouldDeleteImportedCopy = closingBookId == externalOpenedBookId
+ if (shouldDeleteImportedCopy) {
+ viewModelScope.launch {
+ deletePendingExternalFileRemoval(PendingExternalFileRemoval(closingBookId, uriString))
+ _temporaryExternalOpenFinished.send(Unit)
+ }
+ } else {
+ viewModelScope.launch {
+ _temporaryExternalOpenFinished.send(Unit)
+ }
+ }
+ } else {
+ clearPendingExternalFileRemovals(setOf(closingBookId))
}
externalOpenedBookId = null
+ temporaryExternalSessionBookId = null
}
- if (uriString != null) {
+ if (uriString != null && !removesExternalFileOnClose) {
viewModelScope.launch {
val freshBook = recentFilesRepository.getFileByUri(uriString)
freshBook?.let {
if (uiState.value.uploadingBookIds.contains(it.bookId)) {
+ logCloudSyncTrace { "android.reader.close_upload_skip reason=already_uploading ${it.cloudSyncTraceSummary()}" }
return@launch
}
if (uiState.value.isSyncEnabled) {
+ logCloudSyncTrace { "android.reader.close_upload_start ${it.cloudSyncTraceSummary()}" }
Timber.d("Book closed, triggering metadata sync for ${it.bookId}")
uploadSingleBookMetadata(it)
+ } else {
+ logCloudSyncTrace { "android.reader.close_upload_skip reason=sync_disabled ${it.cloudSyncTraceSummary()}" }
}
if (it.sourceFolderUri != null) {
@@ -2393,72 +3056,32 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
private fun loadSyncedFoldersFromPrefs(): List {
- val jsonString = prefs.getString(KEY_SYNCED_FOLDERS_JSON, null)
- val folders = mutableListOf()
+ val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null)
+ val oldUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null)
+ val folders = SyncedFolderPrefs.decodeSyncedFolders(
+ jsonString = jsonString,
+ legacyUri = oldUri,
+ legacyLastScanTime = prefs.getLong(SyncedFolderPrefs.KEY_LEGACY_LAST_FOLDER_SCAN_TIME, 0L),
+ legacyNameResolver = { uri -> getDisplayPathFromUri(appContext, uri) }
+ )
- if (jsonString == null && prefs.contains(KEY_SYNCED_FOLDER_URI)) {
- val oldUri = prefs.getString(KEY_SYNCED_FOLDER_URI, null)
- val oldTime = prefs.getLong(KEY_LAST_FOLDER_SCAN_TIME, 0L)
- if (oldUri != null) {
- val name = getDisplayPathFromUri(appContext, oldUri)
- val migrated = SyncedFolder(oldUri, name, oldTime, ANDROID_SYNCABLE_FILE_TYPES)
- folders.add(migrated)
- saveSyncedFoldersToPrefs(folders)
-
- prefs.edit {
- remove(KEY_SYNCED_FOLDER_URI)
- remove(KEY_LAST_FOLDER_SCAN_TIME)
- }
- }
- } else if (jsonString != null) {
- try {
- val jsonArray = JSONArray(jsonString)
- for (i in 0 until jsonArray.length()) {
- val obj = jsonArray.getJSONObject(i)
-
- val allowedFileTypes = mutableSetOf()
- if (obj.has("allowedFileTypes")) {
- val typesArray = obj.getJSONArray("allowedFileTypes")
- for (j in 0 until typesArray.length()) {
- try {
- allowedFileTypes.add(FileType.valueOf(typesArray.getString(j)))
- } catch (_: Exception) {}
- }
- } else {
- allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES)
- }
-
- folders.add(
- SyncedFolder(
- uriString = obj.getString("uri"),
- name = obj.getString("name"),
- lastScanTime = obj.optLong("lastScanTime", 0L),
- allowedFileTypes = allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }
- )
- )
- }
- } catch (e: Exception) {
- Timber.e(e, "Failed to parse synced folders JSON")
+ if (jsonString == null && prefs.contains(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI) && oldUri != null) {
+ saveSyncedFoldersToPrefs(folders)
+ prefs.edit {
+ remove(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI)
+ remove(SyncedFolderPrefs.KEY_LEGACY_LAST_FOLDER_SCAN_TIME)
}
}
return folders
}
private fun saveSyncedFoldersToPrefs(folders: List) {
- val jsonArray = JSONArray()
- folders.forEach { folder ->
- val obj = JSONObject()
- obj.put("uri", folder.uriString)
- obj.put("name", folder.name)
- obj.put("lastScanTime", folder.lastScanTime)
- val typesArray = JSONArray()
- folder.allowedFileTypes
- .filter { it in ANDROID_SYNCABLE_FILE_TYPES }
- .forEach { typesArray.put(it.name) }
- obj.put("allowedFileTypes", typesArray)
- jsonArray.put(obj)
+ prefs.edit {
+ putString(
+ SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON,
+ SyncedFolderPrefs.encodeSyncedFolders(folders)
+ )
}
- prefs.edit { putString(KEY_SYNCED_FOLDERS_JSON, jsonArray.toString()) }
}
fun addSyncedFolder(folderUri: Uri) {
@@ -2482,7 +3105,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
val name = getDisplayPathFromUri(appContext, folderUri.toString())
- val newFolder = SyncedFolder(folderUri.toString(), name, 0L, ANDROID_SYNCABLE_FILE_TYPES)
+ val newFolder = SyncedFolder(
+ uriString = folderUri.toString(),
+ name = name,
+ lastScanTime = 0L,
+ allowedFileTypes = ANDROID_SYNCABLE_FILE_TYPES,
+ localSyncEnabled = true
+ )
val newStats = currentFolders + newFolder
saveSyncedFoldersToPrefs(newStats)
@@ -2541,6 +3170,50 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
+ fun setFolderLocalSyncEnabled(
+ folder: SyncedFolder,
+ enabled: Boolean,
+ removeSyncDataFolder: Boolean = false
+ ) {
+ viewModelScope.launch {
+ val currentFolders = _internalState.value.syncedFolders.toMutableList()
+ val index = currentFolders.indexOfFirst { it.uriString == folder.uriString }
+ if (index == -1) return@launch
+
+ val updatedFolder = currentFolders[index].copy(localSyncEnabled = enabled)
+ currentFolders[index] = updatedFolder
+ saveSyncedFoldersToPrefs(currentFolders)
+ _internalState.update { it.copy(syncedFolders = currentFolders) }
+
+ if (enabled) {
+ showBanner(appContext.getString(R.string.banner_folder_local_sync_enabled))
+ triggerFolderSyncWorker(
+ metadataOnly = false,
+ showFeedback = true,
+ targetFolderUriString = updatedFolder.uriString
+ )
+ } else {
+ val workManager = WorkManager.getInstance(appContext)
+ workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME)
+ workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
+
+ if (removeSyncDataFolder) {
+ val removed = withContext(Dispatchers.IO) {
+ LocalSyncUtils.deleteSyncDataFolder(appContext, updatedFolder.uriString.toUri())
+ }
+ val message = if (removed) {
+ appContext.getString(R.string.banner_folder_local_sync_disabled_removed_data)
+ } else {
+ appContext.getString(R.string.banner_folder_sync_data_remove_failed)
+ }
+ showBanner(message, isError = !removed)
+ } else {
+ showBanner(appContext.getString(R.string.banner_folder_local_sync_disabled))
+ }
+ }
+ }
+ }
+
fun syncFolderMetadata(showFeedback: Boolean = false) {
triggerFolderSyncWorker(metadataOnly = true, showFeedback = showFeedback)
}
@@ -2554,11 +3227,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
showFeedback: Boolean,
targetFolderUriString: String? = null
) {
- val folders = _internalState.value.syncedFolders
- if (folders.isEmpty()) return
+ val allFolders = _internalState.value.syncedFolders
+ val folders = if (targetFolderUriString.isNullOrBlank()) {
+ allFolders.filter { it.localSyncEnabled }
+ } else {
+ allFolders.filter { it.uriString == targetFolderUriString && it.localSyncEnabled }
+ }
+ if (folders.isEmpty()) {
+ if (showFeedback) {
+ showBanner(appContext.getString(R.string.error_no_enabled_folder_sync), isError = true)
+ }
+ return
+ }
val targetFolderName = targetFolderUriString
- ?.let { target -> folders.firstOrNull { it.uriString == target }?.name ?: target }
+ ?.let { target -> allFolders.firstOrNull { it.uriString == target }?.name ?: target }
ReaderPerfLog.d(
"FolderSync request folders=${folders.size} target=${targetFolderName ?: "ALL"} " +
"metadataOnly=$metadataOnly feedback=$showFeedback"
@@ -2690,8 +3373,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
prefs.edit {
- remove(KEY_SYNCED_FOLDERS_JSON)
- remove(KEY_SYNCED_FOLDER_URI)
+ remove(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON)
+ remove(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI)
}
_internalState.update { it.copy(syncedFolders = emptyList()) }
}
@@ -2718,8 +3401,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
it.name
}
- val fileExtension = item.type.name.lowercase()
- val fileName = "${item.bookId}.$fileExtension"
+ val fileName = sharedCloudBookContentFileName(item.bookId, item.type)
+ ?: throw Exception("Unsupported cloud file type: ${item.type}")
val driveFileId = remoteFiles[fileName]?.id
if (driveFileId != null) {
@@ -2729,6 +3412,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
accessToken, driveFileId, destinationFile
)
) {
+ if (item.fileContentModifiedTimestamp > 0L) {
+ destinationFile.setLastModified(item.fileContentModifiedTimestamp)
+ }
addFileToRecent(
destinationFile.toUri(),
item.type,
@@ -2987,25 +3673,38 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
private fun shouldDownloadRemoteBookContent(local: RecentFileItem, remote: RecentFileItem): Boolean {
+ val localFile = local.getUri()?.path?.let(::File)
+ val localContentTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L }
+ ?: localFile?.takeIf { it.isFile }?.lastModified()
+ ?: 0L
return local.sourceFolderUri == null &&
!local.isDeleted &&
- local.type == FileType.EPUB &&
- remote.type == FileType.EPUB &&
- !remote.isDeleted &&
- remote.fileContentModifiedTimestamp > 0L &&
- remote.fileContentModifiedTimestamp > local.fileContentModifiedTimestamp
+ local.type == remote.type &&
+ sharedCloudBookContentFileName(local.bookId, local.type) != null &&
+ shouldDownloadRemoteCloudBookContent(
+ localFileAvailable = local.isAvailable && localFile?.isFile != false,
+ localContentModifiedTimestamp = localContentTimestamp,
+ remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp,
+ remoteDeleted = remote.isDeleted
+ )
}
private fun shouldUploadLocalBookContent(local: RecentFileItem, remote: RecentFileItem?): Boolean {
+ val localFile = local.getUri()?.path?.let(::File)
+ val localContentTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L }
+ ?: localFile?.takeIf { it.isFile }?.lastModified()
+ ?: 0L
return local.sourceFolderUri == null &&
- local.type == FileType.EPUB &&
- local.fileContentModifiedTimestamp > 0L &&
- local.fileContentModifiedTimestamp > (remote?.fileContentModifiedTimestamp ?: 0L)
+ sharedCloudBookContentFileName(local.bookId, local.type) != null &&
+ shouldUploadLocalCloudBookContent(
+ localFileAvailable = local.isAvailable && localFile?.isFile == true,
+ localContentModifiedTimestamp = localContentTimestamp,
+ remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp
+ )
}
private suspend fun downloadCloudBookFile(accessToken: String, remote: RecentFileItem): Boolean {
- val fileExtension = remote.type.name.lowercase()
- val fileName = "${remote.bookId}.$fileExtension"
+ val fileName = sharedCloudBookContentFileName(remote.bookId, remote.type) ?: return false
val driveFileId = googleDriveRepository.getFiles(accessToken)
?.files
.orEmpty()
@@ -3034,6 +3733,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return true
}
+ private fun scheduleCloudContentRetry(bookIds: Set) {
+ if (bookIds.isEmpty() || cloudContentRetryJob?.isActive == true) return
+ cloudContentRetryJob = viewModelScope.launch {
+ delay(CLOUD_CONTENT_RETRY_DELAY_MILLIS)
+ if (uiState.value.isSyncEnabled) {
+ logCloudSyncTrace { "android.full_sync.content_retry books=${bookIds.joinToString()}" }
+ syncWithCloud(showBanner = false).join()
+ }
+ }
+ }
+
fun setFolderSyncEnabled(enabled: Boolean) {
prefs.edit { putBoolean(KEY_FOLDER_SYNC_ENABLED, enabled) }
_internalState.update { it.copy(isFolderSyncEnabled = enabled) }
@@ -3048,12 +3758,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val currentUser = _internalState.value.currentUser
if (!hasPermissions || currentUser == null) {
+ logCloudSyncTrace {
+ "android.full_sync.skip reason=${if (!hasPermissions) "missing_drive_permissions" else "no_user"} " +
+ "showBanner=$showBanner"
+ }
if (showBanner) _internalState.update {
it.copy(errorMessage = appContext.getString(R.string.error_not_signed_in_sync))
}
return@launch
}
+ logCloudSyncTrace {
+ "android.full_sync.start user=${currentUser.uid} showBanner=$showBanner " +
+ "folderSync=${_internalState.value.isFolderSyncEnabled}"
+ }
if (showBanner) {
_internalState.update {
it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.banner_cloud_sync_checking)))
@@ -3061,7 +3779,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
try {
- val accessToken = googleDriveRepository.getAccessToken(appContext) ?: return@launch
+ val accessToken = googleDriveRepository.getAccessToken(appContext) ?: run {
+ logCloudSyncTrace { "android.full_sync.skip reason=no_access_token user=${currentUser.uid}" }
+ return@launch
+ }
val deviceId = getInstallationId()
val remoteBooksDeferred = async(Dispatchers.IO) {
@@ -3086,6 +3807,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val remoteBooks = remoteBooksDeferred.await()
.filterNot { it.isManualOnlyReaderFile() }
val remoteShelves = remoteShelvesDeferred.await()
+ val initialDriveFiles = withContext(Dispatchers.IO) {
+ googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name }
+ }
+ logCloudSyncTrace {
+ "android.full_sync.loaded user=${currentUser.uid} device=$deviceId " +
+ "localBooks=${localBooks.size} remoteBooks=${remoteBooks.size} " +
+ "remoteShelves=${remoteShelves.size} driveFiles=${initialDriveFiles.size}"
+ }
val syncableBookIds = (localBooks.map { it.bookId } + remoteBooks.map { it.bookId }).toSet()
val allKnownShelfNames =
(localShelfNames + remoteShelves.map { it.name }).toSet()
@@ -3103,17 +3832,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val localBooksMap = localBooks.associateBy { it.bookId }
val remoteBooksMap = remoteBooks.associateBy { it.bookId }
val allBookIds = (localBooksMap.keys + remoteBooksMap.keys).distinct()
+ val pendingContentDownloads = mutableSetOf()
allBookIds.forEach { bookId ->
val local = localBooksMap[bookId]
val remote = remoteBooksMap[bookId]
if (local?.sourceFolderUri != null) {
+ logCloudSyncTrace { "android.full_sync.book_skip reason=folder_book ${local.cloudSyncTraceSummary()}" }
Timber.d("Skipping cloud book metadata merge for local folder book: ${local.displayName}")
return@forEach
}
if (local != null && remote != null) {
+ logCloudSyncTrace {
+ "android.full_sync.compare book=$bookId ${local.cloudSyncTraceSummary()} " +
+ "${remote.cloudSyncTraceSummary()} "
+ }
Timber.tag("AnnotationSync").d(
"Checking $bookId. LocalTS: ${local.lastModifiedTimestamp}, RemoteTS: ${remote.lastModifiedTimestamp}, RemoteHasAnn: ${remote.hasAnnotations}"
)
@@ -3122,42 +3857,182 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when {
local != null && remote == null -> {
if (local.isDeleted) {
+ logCloudSyncTrace { "android.full_sync.decision action=upload_deleted_metadata ${local.cloudSyncTraceSummary()}" }
uploadSingleBookMetadata(local)
} else {
+ logCloudSyncTrace { "android.full_sync.decision action=upload_new_book ${local.cloudSyncTraceSummary()}" }
uploadNewBookAndMetadata(local)
}
}
local == null && remote != null -> {
+ if (remote.isDeleted) {
+ logCloudSyncTrace { "android.full_sync.decision action=skip_deleted_remote_only ${remote.cloudSyncTraceSummary()}" }
+ return@forEach
+ }
+ logCloudSyncTrace { "android.full_sync.decision action=apply_remote_new ${remote.cloudSyncTraceSummary()}" }
recentFilesRepository.addRecentFile(remote.toRecentFileItem())
if (remote.hasAnnotations) {
- downloadAnnotationsForBook(accessToken, bookId)
+ val remoteAnnotationDriveTimestamp =
+ initialDriveFiles[cloudPdfAnnotationDriveFileName(bookId)]?.modifiedTimeMillis ?: 0L
+ val remoteAnnotationTimestamp = remote.effectiveAnnotationModifiedTimestamp(remoteAnnotationDriveTimestamp)
+ logCloudAnnotationSyncTrace {
+ "android.full_sync.remote_only_download book=$bookId remoteTs=${remote.lastModifiedTimestamp} " +
+ "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " +
+ "remoteHasAnnotations=${remote.hasAnnotations}"
+ }
+ downloadAnnotationsForBook(accessToken, bookId, remoteAnnotationTimestamp)
}
}
local != null && remote != null -> {
val remoteItem = remote.toRecentFileItem()
- val shouldDownloadContent = shouldDownloadRemoteBookContent(local, remoteItem)
+ val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId)
+ val deletedInkFile = pdfAnnotationRepository.getDeletedAnnotationsFileForSync(bookId)
+ val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
+ val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
+ val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
+ val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
+ val localSidecars = AndroidPdfCloudSidecarState(
+ hasInk = inkFile.hasSyncableCloudAnnotationPayload(),
+ inkTimestamp = inkFile?.lastModified() ?: 0L,
+ hasDeletedInk = deletedInkFile.hasSyncableCloudAnnotationPayload(),
+ deletedInkTimestamp = deletedInkFile?.lastModified() ?: 0L,
+ hasRichText = richTextFile.hasSyncableCloudAnnotationPayload(),
+ richTextTimestamp = richTextFile.lastModified(),
+ hasLayout = layoutFile.exists(),
+ layoutTimestamp = layoutFile.lastModified(),
+ hasTextBoxes = textBoxFile.hasSyncableCloudAnnotationPayload(),
+ textBoxesTimestamp = textBoxFile.lastModified(),
+ hasHighlights = highlightFile.hasSyncableCloudAnnotationPayload(),
+ highlightsTimestamp = highlightFile.lastModified()
+ )
+ val fileLastModified = localSidecars.annotationPayloadTimestamp
+ val remoteAnnotationDriveTimestamp =
+ initialDriveFiles[cloudPdfAnnotationDriveFileName(bookId)]?.modifiedTimeMillis ?: 0L
+ val remoteAnnotationTimestamp = remote.effectiveAnnotationModifiedTimestamp(remoteAnnotationDriveTimestamp)
+ val localAnnotationsShouldUpload = shouldUploadLocalPdfCloudAnnotations(
+ localSidecars = localSidecars,
+ remoteHasAnnotations = remote.hasAnnotations,
+ remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp
+ )
+ logCloudAnnotationSyncTrace {
+ "android.full_sync.inspect book=$bookId remoteHas=${remote.hasAnnotations} " +
+ "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " +
+ "remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " +
+ "localTs=${local.lastModifiedTimestamp} " +
+ "remoteReadTs=${remote.effectiveReadingPositionModifiedTimestamp()} " +
+ "localReadTs=${local.effectiveReadingPositionModifiedTimestamp()} " +
+ "localPayload=${localSidecars.hasAnnotationPayload} " +
+ "localPayloadTs=${localSidecars.annotationPayloadTimestamp} " +
+ "layoutExists=${localSidecars.hasLayout} layoutTs=${localSidecars.layoutTimestamp} " +
+ "shouldUploadLocal=$localAnnotationsShouldUpload"
+ }
+ if (remote.isDeleted) {
+ val localWinsDeletedRemote = shouldUploadLocalCloudBookMetadataUpdate(
+ localModifiedTimestamp = local.lastModifiedTimestamp,
+ remoteModifiedTimestamp = remote.lastModifiedTimestamp
+ )
+ val remoteDeleteWins = shouldApplyRemoteCloudBookMetadataUpdate(
+ localModifiedTimestamp = local.lastModifiedTimestamp,
+ remoteModifiedTimestamp = remote.lastModifiedTimestamp
+ )
+ when {
+ localWinsDeletedRemote -> {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=resurrect_upload_local book=$bookId " +
+ "localTs=${local.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified"
+ }
+ if (shouldUploadLocalBookContent(local, null)) {
+ uploadNewBookAndMetadata(local)
+ } else {
+ uploadSingleBookMetadata(local)
+ }
+ }
+
+ remoteDeleteWins -> {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=apply_remote_delete book=$bookId " +
+ "localTs=${local.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified"
+ }
+ recentFilesRepository.deleteFilePermanently(listOf(bookId))
+ }
+
+ else -> {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=skip_equal_delete book=$bookId " +
+ "localTs=${local.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified"
+ }
+ }
+ }
+ return@forEach
+ }
+ val localWithRemoteEpubAnnotations = local.mergeRemoteEpubAnnotationMetadata(remote)
+ val effectiveLocal = if (localWithRemoteEpubAnnotations != local) {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=merge_remote_epub_annotations book=$bookId " +
+ "local=${local.cloudSyncTraceSummary()} ${remote.cloudSyncTraceSummary()} " +
+ "merged=${localWithRemoteEpubAnnotations.cloudSyncTraceSummary()}"
+ }
+ recentFilesRepository.addRecentFile(localWithRemoteEpubAnnotations)
+ localWithRemoteEpubAnnotations
+ } else {
+ local
+ }
+ val localReadingTimestamp = effectiveLocal.effectiveReadingPositionModifiedTimestamp()
+ val remoteReadingTimestamp = remote.effectiveReadingPositionModifiedTimestamp()
+ val localReadingPositionShouldUpload = localReadingTimestamp > remoteReadingTimestamp
+ val shouldDownloadContent = shouldDownloadRemoteBookContent(effectiveLocal, remoteItem)
val downloadedRemoteContent = if (shouldDownloadContent) {
- downloadCloudBookFile(accessToken, remoteItem.copy(displayName = remoteItem.displayName.ifBlank { local.displayName }))
+ logCloudSyncTrace {
+ "android.full_sync.content_download_start book=$bookId " +
+ "localContentTs=${effectiveLocal.fileContentModifiedTimestamp} remoteContentTs=${remote.fileContentModifiedTimestamp}"
+ }
+ downloadCloudBookFile(accessToken, remoteItem.copy(displayName = remoteItem.displayName.ifBlank { effectiveLocal.displayName }))
} else {
false
}
+ logCloudSyncTrace {
+ "android.full_sync.content_decision book=$bookId shouldDownload=$shouldDownloadContent " +
+ "downloaded=$downloadedRemoteContent localPayloadSidecarTs=$fileLastModified " +
+ "localLayoutTs=${localSidecars.layoutTimestamp.takeIf { localSidecars.hasLayout } ?: 0L}"
+ }
+ if (shouldDownloadContent && !downloadedRemoteContent) {
+ pendingContentDownloads += bookId
+ }
- if (local.lastModifiedTimestamp > remote.lastModifiedTimestamp) {
- if (shouldUploadLocalBookContent(local, remoteItem)) {
- uploadNewBookAndMetadata(local)
+ if (shouldUploadLocalCloudBookMetadataUpdate(
+ localModifiedTimestamp = effectiveLocal.lastModifiedTimestamp,
+ remoteModifiedTimestamp = remote.lastModifiedTimestamp
+ )
+ ) {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=upload_local book=$bookId " +
+ "localTs=${effectiveLocal.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} " +
+ "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp payloadSidecarTs=$fileLastModified " +
+ "uploadContent=${shouldUploadLocalBookContent(effectiveLocal, remoteItem)}"
+ }
+ if (shouldUploadLocalBookContent(effectiveLocal, remoteItem)) {
+ uploadNewBookAndMetadata(effectiveLocal)
} else {
- uploadSingleBookMetadata(local)
+ uploadSingleBookMetadata(effectiveLocal)
}
} else {
val isMetadataNewer =
- remote.lastModifiedTimestamp > local.lastModifiedTimestamp
+ shouldApplyRemoteCloudBookMetadataUpdate(
+ localModifiedTimestamp = effectiveLocal.lastModifiedTimestamp,
+ remoteModifiedTimestamp = remote.lastModifiedTimestamp
+ )
if (isMetadataNewer) {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=apply_remote_metadata book=$bookId " +
+ "localTs=${effectiveLocal.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified " +
+ "downloadedContent=$downloadedRemoteContent"
+ }
val remoteForLocalDb = if (shouldDownloadContent && !downloadedRemoteContent) {
remote.toRecentFileItem().copy(
- fileContentModifiedTimestamp = local.fileContentModifiedTimestamp
+ fileContentModifiedTimestamp = effectiveLocal.fileContentModifiedTimestamp
)
} else {
remote.toRecentFileItem()
@@ -3165,31 +4040,76 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
recentFilesRepository.addRecentFile(
remoteForLocalDb
)
+ if (localAnnotationsShouldUpload || localReadingPositionShouldUpload) {
+ recentFilesRepository.getFileByBookId(bookId)?.let { merged ->
+ logCloudAnnotationSyncTrace {
+ "android.full_sync.upload_local_annotations book=$bookId reason=remote_metadata_newer " +
+ "remoteTs=${remote.lastModifiedTimestamp} localPayloadTs=$fileLastModified " +
+ "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp " +
+ "uploadReadingPosition=$localReadingPositionShouldUpload"
+ }
+ logCloudSyncTrace {
+ "android.full_sync.decision action=upload_local_supplement book=$bookId " +
+ "remoteMetadataTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified " +
+ "uploadAnnotations=$localAnnotationsShouldUpload uploadReadingPosition=$localReadingPositionShouldUpload"
+ }
+ uploadSingleBookMetadata(merged)
+ }
+ }
+ } else {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=metadata_noop book=$bookId " +
+ "localTs=${effectiveLocal.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} " +
+ "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp payloadSidecarTs=$fileLastModified"
+ }
+ if (localAnnotationsShouldUpload || localReadingPositionShouldUpload) {
+ logCloudAnnotationSyncTrace {
+ "android.full_sync.upload_local_annotations book=$bookId reason=metadata_noop " +
+ "remoteTs=${remote.lastModifiedTimestamp} localPayloadTs=$fileLastModified " +
+ "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp"
+ }
+ logCloudSyncTrace {
+ "android.full_sync.decision action=upload_local_supplement book=$bookId " +
+ "metadataEqual=${effectiveLocal.lastModifiedTimestamp == remote.lastModifiedTimestamp} " +
+ "uploadAnnotations=$localAnnotationsShouldUpload uploadReadingPosition=$localReadingPositionShouldUpload " +
+ "payloadSidecarTs=$fileLastModified"
+ }
+ uploadSingleBookMetadata(effectiveLocal)
+ }
}
- val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId)
- val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
- val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
- val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
- val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
-
- val anyLocalFileExists =
- (inkFile?.exists() == true) || richTextFile.exists() || layoutFile.exists() || textBoxFile.exists() || highlightFile.exists()
- val localFileMissing = !anyLocalFileExists
-
- val fileLastModified = maxOf(
- inkFile?.lastModified() ?: 0L,
- richTextFile.lastModified(),
- layoutFile.lastModified(),
- textBoxFile.lastModified(),
- highlightFile.lastModified()
+ val shouldDownloadRemoteAnnotations = shouldDownloadRemotePdfCloudAnnotations(
+ localSidecars = localSidecars,
+ localAnnotationsShouldUpload = localAnnotationsShouldUpload,
+ remoteHasAnnotations = remote.hasAnnotations,
+ remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp
)
- val isFileStale =
- remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified)
- if (isMetadataNewer || localFileMissing && remote.hasAnnotations || isFileStale) {
+ if (shouldDownloadRemoteAnnotations) {
+ logCloudAnnotationSyncTrace {
+ "android.full_sync.download_remote_annotations book=$bookId " +
+ "metadataNewer=$isMetadataNewer localPayloadMissing=${!localSidecars.hasAnnotationPayload} " +
+ "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " +
+ "localPayloadTs=$fileLastModified " +
+ "layoutTs=${localSidecars.layoutTimestamp.takeIf { localSidecars.hasLayout } ?: 0L}"
+ }
+ logCloudSyncTrace {
+ "android.full_sync.sidecar_download_start book=$bookId reason=" +
+ "metadataNewer=$isMetadataNewer localPayloadMissing=${!localSidecars.hasAnnotationPayload} " +
+ "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " +
+ "localPayloadSidecarTs=$fileLastModified " +
+ "localLayoutTs=${localSidecars.layoutTimestamp.takeIf { localSidecars.hasLayout } ?: 0L}"
+ }
Timber.tag("AnnotationSync").d("Triggering download for $bookId.")
- downloadAnnotationsForBook(accessToken, bookId)
+ downloadAnnotationsForBook(accessToken, bookId, remoteAnnotationTimestamp)
+ } else {
+ logCloudAnnotationSyncTrace {
+ "android.full_sync.skip_remote_annotations book=$bookId " +
+ "remoteHas=${remote.hasAnnotations} localShouldUpload=$localAnnotationsShouldUpload " +
+ "metadataNewer=$isMetadataNewer localPayload=${localSidecars.hasAnnotationPayload} " +
+ "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " +
+ "localPayloadTs=$fileLastModified"
+ }
}
}
}
@@ -3274,41 +4194,80 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name }
}
- val downloadJobs = mutableListOf()
-
finalMergedBooks.forEach { book ->
if (book.sourceFolderUri != null) return@forEach
- val fileExtension = book.type.name.lowercase()
- val fileName = "${book.bookId}.$fileExtension"
+ val fileName = sharedCloudBookContentFileName(book.bookId, book.type) ?: return@forEach
if (book.isDeleted) {
remoteFiles[fileName]?.id?.let { fileId ->
Timber.d("Deleting from Drive: $fileName")
googleDriveRepository.deleteDriveFile(accessToken, fileId)
}
+ remoteFiles[cloudPdfAnnotationDriveFileName(book.bookId)]?.id?.let { fileId ->
+ Timber.d("Deleting annotation bundle from Drive: ${book.bookId}")
+ googleDriveRepository.deleteDriveFile(accessToken, fileId)
+ }
recentFilesRepository.deleteFilePermanently(listOf(book.bookId))
} else if (
book.sourceFolderUri == null &&
book.isAvailable &&
!remoteFiles.containsKey(fileName)
) {
- book.getUri()?.path?.let { path ->
- val file = File(path)
- if (file.exists()) {
- Timber.d("Uploading book: ${book.displayName}")
- googleDriveRepository.uploadFile(
- accessToken, book.bookId, file, book.type
- )
+ val remoteItem = remoteBooksMap[book.bookId]?.toRecentFileItem()
+ if (remoteItem == null || shouldUploadLocalBookContent(book, remoteItem)) {
+ book.getUri()?.path?.let { path ->
+ val file = File(path)
+ if (file.exists()) {
+ Timber.d("Uploading book: ${book.displayName}")
+ val uploadedFile = googleDriveRepository.uploadFile(
+ accessToken, book.bookId, file, book.type
+ )
+ if (uploadedFile != null) {
+ val contentTimestamp = book.fileContentModifiedTimestamp.takeIf { it > 0L }
+ ?: file.lastModified()
+ uploadSingleBookMetadata(
+ book.copy(
+ fileSize = file.length(),
+ fileContentModifiedTimestamp = contentTimestamp
+ )
+ )
+ }
+ }
+ }
+ } else {
+ pendingContentDownloads += book.bookId
+ logCloudSyncTrace {
+ "android.full_sync.content_wait_missing_remote book=${book.bookId} " +
+ "file=$fileName localContentTs=${book.fileContentModifiedTimestamp} " +
+ "remoteContentTs=${remoteItem.fileContentModifiedTimestamp}"
}
}
} else if (!book.isAvailable && remoteFiles.containsKey(fileName)) {
Timber.d("Sync: Triggering auto-download for ${book.displayName}")
- downloadJobs.add(downloadBook(book))
+ val remoteItem = remoteBooksMap[book.bookId]
+ ?.toRecentFileItem()
+ ?.copy(displayName = book.displayName)
+ ?: book
+ val downloaded = downloadCloudBookFile(accessToken, remoteItem)
+ if (!downloaded) {
+ pendingContentDownloads += book.bookId
+ }
+ } else if (!book.isAvailable) {
+ pendingContentDownloads += book.bookId
}
}
- downloadJobs.joinAll()
+ if (pendingContentDownloads.isNotEmpty()) {
+ logCloudSyncTrace {
+ "android.full_sync.content_pending books=${pendingContentDownloads.joinToString()}"
+ }
+ scheduleCloudContentRetry(pendingContentDownloads)
+ } else {
+ cloudContentRetryJob?.cancel()
+ cloudContentRetryJob = null
+ }
syncFonts(currentUser.uid)
+ logCloudSyncTrace { "android.full_sync.complete user=${currentUser.uid}" }
if (showBanner) {
_internalState.update {
it.copy(
@@ -3317,6 +4276,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
} catch (e: Exception) {
+ logCloudSyncError(e) { "android.full_sync.failed user=${currentUser.uid}" }
Timber.tag("AnnotationSync").e(e, "Error during cloud sync")
if (showBanner) {
_internalState.update {
@@ -3326,21 +4286,41 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private suspend fun downloadAnnotationsForBook(accessToken: String, bookId: String) {
+ private suspend fun downloadAnnotationsForBook(
+ accessToken: String,
+ bookId: String,
+ annotationModifiedTimestamp: Long
+ ) {
// We download to a temp location first to inspect the content
val tempDownloadFile = File(appContext.cacheDir, "temp_download_${bookId}.json")
+ logCloudSyncTrace {
+ "android.sidecar_download.start book=$bookId remoteAnnTs=$annotationModifiedTimestamp temp=${tempDownloadFile.name}"
+ }
+ logCloudAnnotationSyncTrace {
+ "android.download.start book=$bookId remoteAnnTs=$annotationModifiedTimestamp temp=${tempDownloadFile.name}"
+ }
Timber.tag("AnnotationSync").d("Attempting download of bundle for $bookId.")
val didDownload =
googleDriveRepository.downloadAnnotationFile(accessToken, bookId, tempDownloadFile)
if (didDownload && tempDownloadFile.exists()) {
+ logCloudAnnotationSyncTrace {
+ "android.download.success book=$bookId remoteAnnTs=$annotationModifiedTimestamp bytes=${tempDownloadFile.length()}"
+ }
+ logCloudSyncTrace {
+ "android.sidecar_download.success book=$bookId remoteAnnTs=$annotationModifiedTimestamp bytes=${tempDownloadFile.length()}"
+ }
Timber.tag("AnnotationSync")
.d("Download SUCCESS. Size: ${tempDownloadFile.length()}. Unpacking...")
try {
val jsonString = tempDownloadFile.readText()
+ val appliedAnnotationTimestamp =
+ annotationModifiedTimestamp.takeIf { it > 0L }
+ ?: tempDownloadFile.lastModified().takeIf { it > 0L }
+ ?: 0L
Timber.d(
"android.cloud.import.downloaded book=$bookId rawLen=${jsonString.length}"
)
@@ -3358,16 +4338,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} catch (_: Exception) {
false
}
+ logCloudAnnotationSyncTrace {
+ "android.download.inspect book=$bookId isBundle=$isBundle rawBytes=${jsonString.length} " +
+ "appliedAnnTs=$appliedAnnotationTimestamp rawPreview=${jsonString.take(80).replace('\n', ' ')}"
+ }
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) ?: File(
appContext.filesDir, "annotations/annotation_$bookId.json"
)
+ val deletedInkFile = File(appContext.filesDir, "annotations/deleted_annotation_$bookId.json")
val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
inkFile.parentFile?.mkdirs()
+ deletedInkFile.parentFile?.mkdirs()
richTextFile.parentFile?.mkdirs()
layoutFile.parentFile?.mkdirs()
textBoxFile.parentFile?.mkdirs()
@@ -3380,44 +4366,94 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.d(
"android.cloud.import.bundle book=$bookId hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
)
+ logCloudAnnotationSyncTrace {
+ "android.download.bundle_keys book=$bookId keys=${bundle.keys().asSequence().toList()} " +
+ "hasInk=${bundle.has("ink")} hasText=${bundle.has("text")} " +
+ "hasLayout=${bundle.has("layout")} hasTextBoxes=${bundle.has("textBoxes")} " +
+ "hasHighlights=${bundle.has("highlights")} " +
+ "hasDeletedInk=${bundle.has(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS)}"
+ }
fun writeSafe(key: String, file: File) {
if (bundle.has(key)) {
file.parentFile?.mkdirs()
val content = bundle.get(key).toString()
file.writeText(content)
+ appliedAnnotationTimestamp.takeIf { it > 0L }?.let(file::setLastModified)
+ logCloudAnnotationSyncTrace {
+ "android.download.write key=$key book=$bookId bytes=${content.length} " +
+ "path=${file.absolutePath.cloudSyncPreview(140)} ts=${file.lastModified()}"
+ }
if (key == "text") {
Timber.d(
"android.cloud.import.writeRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}"
)
}
} else {
+ if (key == "layout") {
+ logCloudAnnotationSyncTrace {
+ "android.download.preserve_missing key=layout book=$bookId " +
+ "path=${file.absolutePath.cloudSyncPreview(140)} exists=${file.exists()}"
+ }
+ Timber.d(
+ "android.cloud.import.preserveMissingLayout book=$bookId file=${file.absolutePath}"
+ )
+ return
+ }
if (key == "text" && file.exists()) {
Timber.d(
"android.cloud.import.deleteMissingRichText book=$bookId file=${file.absolutePath}"
)
}
- if (file.exists()) file.delete()
+ if (file.exists()) {
+ val deleted = file.delete()
+ logCloudAnnotationSyncTrace {
+ "android.download.delete_missing key=$key book=$bookId deleted=$deleted " +
+ "path=${file.absolutePath.cloudSyncPreview(140)}"
+ }
+ } else {
+ logCloudAnnotationSyncTrace {
+ "android.download.missing_key key=$key book=$bookId path=${file.absolutePath.cloudSyncPreview(140)}"
+ }
+ }
}
}
writeSafe("ink", inkFile)
+ writeSafe(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS, deletedInkFile)
writeSafe("text", richTextFile)
writeSafe("layout", layoutFile)
writeSafe("textBoxes", textBoxFile)
writeSafe("highlights", highlightFile)
+ logCloudSyncTrace {
+ "android.sidecar_download.applied_bundle book=$bookId remoteAnnTs=$annotationModifiedTimestamp " +
+ "keys=${bundle.keys().asSequence().toList()}"
+ }
Timber.tag("AnnotationSync").d("Unpacked unified bundle.")
} else {
Timber.tag("AnnotationSync").d("Detected legacy format (Ink only).")
inkFile.writeText(jsonString)
+ appliedAnnotationTimestamp.takeIf { it > 0L }?.let(inkFile::setLastModified)
+ logCloudAnnotationSyncTrace {
+ "android.download.write_legacy_ink book=$bookId bytes=${jsonString.length} " +
+ "path=${inkFile.absolutePath.cloudSyncPreview(140)} ts=${inkFile.lastModified()}"
+ }
+ logCloudSyncTrace { "android.sidecar_download.applied_legacy book=$bookId remoteAnnTs=$annotationModifiedTimestamp" }
}
} catch (e: Exception) {
+ logCloudAnnotationSyncError(e) { "android.download.apply_failed book=$bookId remoteAnnTs=$annotationModifiedTimestamp" }
+ logCloudSyncError(e) { "android.sidecar_download.apply_failed book=$bookId remoteAnnTs=$annotationModifiedTimestamp" }
Timber.e(e, "Error unpacking synced annotation data")
} finally {
tempDownloadFile.delete()
}
} else {
+ logCloudAnnotationSyncTrace {
+ "android.download.missing book=$bookId remoteAnnTs=$annotationModifiedTimestamp didDownload=$didDownload " +
+ "tempExists=${tempDownloadFile.exists()} tempBytes=${tempDownloadFile.length()}"
+ }
+ logCloudSyncTrace { "android.sidecar_download.missing book=$bookId remoteAnnTs=$annotationModifiedTimestamp" }
Timber.tag("AnnotationSync")
.d("FAILURE: No bundle found on Drive for $bookId (or download failed)")
}
@@ -3611,7 +4647,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri)
}
}
- } else if (uri.scheme != "opds-pse" && (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7)) {
+ } else if (uri.scheme != "opds-pse" && type in COMIC_ARCHIVE_FILE_TYPES) {
if (coverPath == null) {
var cacheFile: File? = null
try {
@@ -3621,7 +4657,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
cacheFile.outputStream().use { output -> input.copyTo(output) }
}
}
- val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile)
+ val archiveDoc = org.dueattendant149.bookreader.pdf.ArchiveDocumentWrapper(cacheFile)
if (archiveDoc.getPageCount() > 0) {
val page = archiveDoc.openPage(0)
if (page != null) {
@@ -3641,7 +4677,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
archiveDoc.close()
} catch (e: Exception) {
- Timber.e(e, "Error generating CBZ cover")
+ Timber.e(e, "Error generating comic archive cover")
} finally {
try {
if (cacheFile?.exists() == true) {
@@ -3899,7 +4935,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- fun onFileSelected(uri: Uri, isFromRecent: Boolean = false, isExternalIntent: Boolean = false) {
+ fun onFileSelected(
+ uri: Uri,
+ isFromRecent: Boolean = false,
+ isExternalIntent: Boolean = false,
+ isTemporaryExternalIntent: Boolean = false
+ ) {
if (isFromRecent) {
Timber.i("Opening recent file: $uri")
viewModelScope.launch {
@@ -3912,7 +4953,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
} else {
Timber.i("Importing new file: $uri")
- importExternalFile(uri, isExternalIntent)
+ importExternalFile(uri, isExternalIntent, isTemporaryExternalIntent)
}
}
@@ -3962,9 +5003,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private fun importExternalFile(externalUri: Uri, isExternalIntent: Boolean = false) {
+ private fun importExternalFile(
+ externalUri: Uri,
+ isExternalIntent: Boolean = false,
+ isTemporaryExternalIntent: Boolean = false
+ ) {
+ if (isTemporaryExternalIntent) {
+ openTemporaryExternalFile(externalUri)
+ return
+ }
+
_internalState.update {
- it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet())
+ it.copy(
+ isLoading = true,
+ errorMessage = null,
+ contextualActionItems = emptySet()
+ )
}
viewModelScope.launch {
@@ -3974,7 +5028,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (importResult != null) {
val (internalUri, bookId, type) = importResult
if (isExternalIntent) {
- externalOpenedBookId = bookId
+ trackExternalOpenForClose(
+ bookId = bookId,
+ importedCopyUriString = internalUri.toString(),
+ isTemporaryExternalIntent = isTemporaryExternalIntent
+ )
}
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File"
openBook(
@@ -3989,6 +5047,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val existingItem = recentFilesRepository.getFileByBookId(hash)
if (existingItem != null) {
Timber.i("Re-selected an existing book. Opening it.")
+ if (isTemporaryExternalIntent) {
+ trackExternalOpenForClose(
+ bookId = existingItem.bookId,
+ importedCopyUriString = null,
+ isTemporaryExternalIntent = true
+ )
+ }
onRecentFileClicked(existingItem)
_internalState.update { it.copy(isLoading = false) }
return@launch
@@ -4022,14 +5087,72 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
+ private fun openTemporaryExternalFile(externalUri: Uri) {
+ _internalState.update {
+ it.copy(
+ isLoading = true,
+ isTemporaryExternalOpen = true,
+ errorMessage = null,
+ contextualActionItems = emptySet()
+ )
+ }
+ viewModelScope.launch {
+ val type = getFileTypeFromUri(externalUri, appContext)
+ if (type == null) {
+ _internalState.update {
+ it.copy(
+ isLoading = false,
+ isTemporaryExternalOpen = false,
+ errorMessage = appContext.getString(R.string.error_unsupported_file_type)
+ )
+ }
+ return@launch
+ }
+
+ val displayName = getFileNameFromUri(externalUri, appContext) ?: "Temporary File"
+ val bookId = "temporary-${UUID.randomUUID()}"
+ trackExternalOpenForClose(
+ bookId = bookId,
+ importedCopyUriString = null,
+ isTemporaryExternalIntent = true
+ )
+ openBook(
+ uri = externalUri,
+ bookId = bookId,
+ type = type,
+ originalDisplayName = displayName,
+ persistToLibrary = false
+ )
+ }
+ }
+
fun saveHighlights(bookId: String, highlightsJson: String) {
viewModelScope.launch {
val currentBookUri = _internalState.value.selectedPdfUri ?: _internalState.value.selectedEpubUri
if (currentBookUri != null) {
recentFilesRepository.getFileByUri(currentBookUri.toString())?.let { item ->
+ if (annotationJsonEquivalentForNoop(item.highlightsJson, highlightsJson)) {
+ logCloudSyncTrace {
+ "android.reader.highlights_save_noop book=${item.bookId} highlights=${highlightsJson.cloudSyncAnnotationSummary()}"
+ }
+ return@launch
+ }
+ logCloudSyncTrace {
+ "android.reader.highlights_save book=${item.bookId} highlights=${highlightsJson.cloudSyncAnnotationSummary()}"
+ }
recentFilesRepository.updateHighlights(item.bookId, highlightsJson)
}
} else if (bookId.isNotBlank()) {
+ val existing = recentFilesRepository.getFileByBookId(bookId)
+ if (annotationJsonEquivalentForNoop(existing?.highlightsJson, highlightsJson)) {
+ logCloudSyncTrace {
+ "android.reader.highlights_save_noop book=$bookId highlights=${highlightsJson.cloudSyncAnnotationSummary()}"
+ }
+ return@launch
+ }
+ logCloudSyncTrace {
+ "android.reader.highlights_save book=$bookId highlights=${highlightsJson.cloudSyncAnnotationSummary()}"
+ }
recentFilesRepository.updateHighlights(bookId, highlightsJson)
}
}
@@ -4229,7 +5352,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private suspend fun cleanupBookDataLocally(bookId: String) {
+ protected open suspend fun cleanupBookDataLocally(bookId: String) {
pdfTextRepository.clearBookText(bookId)
clearImportedFileCache(bookId)
bookCacheDao.deleteEntireBookCache(bookId)
@@ -4274,7 +5397,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isInitialPageExplicit: Boolean = false,
initialLocatorOverride: Locator? = null,
initialCfiOverride: String? = null,
- preserveTtsOnOpen: Boolean = false
+ preserveTtsOnOpen: Boolean = false,
+ persistToLibrary: Boolean = true
) {
val openBookStartTime = System.currentTimeMillis()
ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type")
@@ -4368,16 +5492,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
- persistReaderSession(bookId, type)
- addFileToRecent(
- uri,
- type,
- bookId,
- customDisplayName = originalDisplayName,
- isRecent = true,
- sourceFolderUri = null,
- bundleResult = bundleResult
- )
+ if (persistToLibrary) {
+ persistReaderSession(bookId, type)
+ addFileToRecent(
+ uri,
+ type,
+ bookId,
+ customDisplayName = originalDisplayName,
+ isRecent = true,
+ sourceFolderUri = null,
+ bundleResult = bundleResult
+ )
+ }
if (!suppressNavigation) {
Timber.tag("FileSwitch").d("PDF state updated, emitting navigation event")
@@ -4401,6 +5527,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} else {
null
}
+ logCloudSyncTrace {
+ "android.reader.open_epub_position book=$bookId " +
+ "overrideLocator=$initialLocatorOverride overrideCfi=${initialCfiOverride.cloudSyncPreview()} " +
+ (recentItem?.cloudSyncTraceSummary("recent") ?: "recent=null") +
+ " chosenLocator=$locator chosenCfi=${(initialCfiOverride ?: recentItem?.lastPositionCfi).cloudSyncPreview()}"
+ }
_internalState.update {
it.copy(
@@ -4412,7 +5544,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
- persistReaderSession(bookId, type)
+ if (persistToLibrary) {
+ persistReaderSession(bookId, type)
+ }
if (!suppressNavigation) {
Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event")
@@ -4421,22 +5555,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when (type) {
FileType.EPUB -> {
- loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
+ loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
}
FileType.MOBI -> {
- loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
+ loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
}
FileType.FB2 -> {
- loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
+ loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
}
FileType.ODT, FileType.FODT -> {
- loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult)
+ loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
}
else -> {
loadSingleFile(
- uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult
+ uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary
)
}
}
@@ -4459,7 +5593,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private fun loadFb2(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
+ private fun loadFb2(
+ uri: Uri,
+ bookId: String,
+ customDisplayName: String? = null,
+ bundleResult: CalibreBundleResult? = null,
+ persistToLibrary: Boolean = true
+ ) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 START")
viewModelScope.launch {
@@ -4481,9 +5621,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.i("FB2 parsing successful. Title: ${fb2Book.title}")
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 completed | chapters=${fb2Book.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
- addFileToRecent(
- uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
- )
+ if (persistToLibrary) {
+ addFileToRecent(
+ uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
+ )
+ }
_internalState.update { it.copy(selectedEpubBook = fb2Book, isLoading = false) }
} catch (e: Exception) {
@@ -4495,7 +5637,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private fun loadOdt(uri: Uri, bookId: String, isFlat: Boolean, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
+ private fun loadOdt(
+ uri: Uri,
+ bookId: String,
+ isFlat: Boolean,
+ customDisplayName: String? = null,
+ bundleResult: CalibreBundleResult? = null,
+ persistToLibrary: Boolean = true
+ ) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt START | isFlat=$isFlat")
viewModelScope.launch {
@@ -4518,9 +5667,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.i("ODT parsing successful. Title: ${odtBook.title}")
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt completed | chapters=${odtBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
- addFileToRecent(
- uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
- )
+ if (persistToLibrary) {
+ addFileToRecent(
+ uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
+ )
+ }
_internalState.update { it.copy(selectedEpubBook = odtBook, isLoading = false) }
} catch (e: Exception) {
@@ -4537,7 +5688,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
bookId: String,
type: FileType,
customDisplayName: String? = null,
- bundleResult: CalibreBundleResult? = null
+ bundleResult: CalibreBundleResult? = null,
+ persistToLibrary: Boolean = true
) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type")
@@ -4575,16 +5727,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileOpenPerf")
.d("[$bookId] loadSingleFile: importSingleFile completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
Timber.i("Import successful ($type). Title: ${epubBook.title}")
- addFileToRecent(
- uri,
- type,
- bookId,
- epubBook,
- customDisplayName,
- isRecent = true,
- sourceFolderUri = null,
- bundleResult = bundleResult
- )
+ if (persistToLibrary) {
+ addFileToRecent(
+ uri,
+ type,
+ bookId,
+ epubBook,
+ customDisplayName,
+ isRecent = true,
+ sourceFolderUri = null,
+ bundleResult = bundleResult
+ )
+ }
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf")
@@ -4623,7 +5777,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return resolveFileTypeFromMetadata(fileName, mimeType)
}
- private fun loadMobi(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
+ private fun loadMobi(
+ uri: Uri,
+ bookId: String,
+ customDisplayName: String? = null,
+ bundleResult: CalibreBundleResult? = null,
+ persistToLibrary: Boolean = true
+ ) {
viewModelScope.launch {
if (!_internalState.value.isLoading) {
_internalState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -4645,16 +5805,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (mobiAsEpubBook != null) {
Timber.i("MOBI parsing successful. Title: ${mobiAsEpubBook.title}")
- addFileToRecent(
- uri,
- FileType.MOBI,
- bookId,
- mobiAsEpubBook,
- customDisplayName,
- isRecent = true,
- sourceFolderUri = null,
- bundleResult = bundleResult
- )
+ if (persistToLibrary) {
+ addFileToRecent(
+ uri,
+ FileType.MOBI,
+ bookId,
+ mobiAsEpubBook,
+ customDisplayName,
+ isRecent = true,
+ sourceFolderUri = null,
+ bundleResult = bundleResult
+ )
+ }
_internalState.update {
it.copy(selectedEpubBook = mobiAsEpubBook, isLoading = false)
}
@@ -4676,7 +5838,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private fun loadEpub(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
+ private fun loadEpub(
+ uri: Uri,
+ bookId: String,
+ customDisplayName: String? = null,
+ bundleResult: CalibreBundleResult? = null,
+ persistToLibrary: Boolean = true
+ ) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START")
viewModelScope.launch {
@@ -4702,16 +5870,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileOpenPerf")
.d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
- addFileToRecent(
- uri,
- FileType.EPUB,
- bookId,
- epubBook,
- customDisplayName,
- isRecent = true,
- sourceFolderUri = null,
- bundleResult = bundleResult
- )
+ if (persistToLibrary) {
+ addFileToRecent(
+ uri,
+ FileType.EPUB,
+ bookId,
+ epubBook,
+ customDisplayName,
+ isRecent = true,
+ sourceFolderUri = null,
+ bundleResult = bundleResult
+ )
+ }
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf")
@@ -4730,13 +5900,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) {
Timber.d("Saving EPUB position locally: URI=$uri, Locator=$locator")
viewModelScope.launch {
- recentFilesRepository.getFileByUri(uri.toString())?.let { _ ->
+ recentFilesRepository.getFileByUri(uri.toString())?.let { existing ->
+ logCloudSyncTrace {
+ "android.reader.position_save_start book=${existing.bookId} beforeTs=${existing.lastModifiedTimestamp} " +
+ "locator={chapter=${locator.chapterIndex} block=${locator.blockIndex} char=${locator.charOffset}} " +
+ "progress=$progress cfi=${cfiForWebView.cloudSyncPreview()}"
+ }
recentFilesRepository.updateEpubReadingPosition(
uriString = uri.toString(),
locator = locator,
cfiForWebView = cfiForWebView,
progress = progress
)
+ val updated = recentFilesRepository.getFileByBookId(existing.bookId)
+ logCloudSyncTrace {
+ "android.reader.position_save_done beforeTs=${existing.lastModifiedTimestamp} " +
+ (updated?.cloudSyncTraceSummary("after") ?: "after=null")
+ }
+ queueCloudMetadataUpload(existing.bookId, reason = "epub_position")
}
}
}
@@ -4778,10 +5959,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
Timber.tag("PdfPositionDebug").i("ViewModel: Save request triggered | Page: $page | Total: $totalPages | Progress: $progress | URI: ${currentPdfUri.lastPathSegment}")
viewModelScope.launch {
- recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ ->
+ recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { existing ->
+ logCloudSyncTrace {
+ "android.reader.pdf_position_save_start book=${existing.bookId} beforeTs=${existing.lastModifiedTimestamp} " +
+ "beforeReadTs=${existing.effectiveReadingPositionModifiedTimestamp()} page=$page progress=$progress"
+ }
recentFilesRepository.updatePdfReadingPosition(
uriString = currentPdfUri.toString(), page = page, progress = progress
)
+ val updated = recentFilesRepository.getFileByBookId(existing.bookId)
+ logCloudSyncTrace {
+ "android.reader.pdf_position_save_done beforeTs=${existing.lastModifiedTimestamp} " +
+ (updated?.cloudSyncTraceSummary("after") ?: "after=null")
+ }
+ queueCloudMetadataUpload(existing.bookId, reason = "pdf_position")
} ?: run {
Timber.tag("PdfPositionDebug").e("ViewModel: Save aborted. Could not resolve file item from URI in DB.")
}
@@ -4831,7 +6022,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun refreshLibrary() {
val syncEnabled = _internalState.value.isSyncEnabled
- val hasFolder = _internalState.value.syncedFolders.isNotEmpty()
+ val hasFolder = _internalState.value.syncedFolders.any { it.localSyncEnabled }
if (!syncEnabled && !hasFolder) {
Timber.d("Refresh skipped: No sync methods active.")
@@ -4924,19 +6115,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
private fun uploadNewBookAndMetadata(book: RecentFileItem) {
- if (!uiState.value.isSyncEnabled) return
+ if (!uiState.value.isSyncEnabled) {
+ logCloudSyncTrace { "android.upload_content.skip reason=sync_disabled ${book.cloudSyncTraceSummary()}" }
+ return
+ }
if (book.uriString?.startsWith("opds-pse") == true) {
+ logCloudSyncTrace { "android.upload_content.skip reason=opds_stream ${book.cloudSyncTraceSummary()}" }
Timber.d("Skipping book content sync for OPDS stream book: ${book.displayName}")
return
}
if (book.sourceFolderUri != null) {
+ logCloudSyncTrace { "android.upload_content.skip reason=folder_book ${book.cloudSyncTraceSummary()}" }
Timber.d("Skipping book content sync for local folder book: ${book.displayName}")
return
}
if (book.isManualOnlyReaderFile()) {
+ logCloudSyncTrace { "android.upload_content.skip reason=manual_only ${book.cloudSyncTraceSummary()}" }
Timber.d("Skipping book content sync for manual-only reader file: ${book.displayName}")
return
}
@@ -4944,16 +6141,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
viewModelScope.launch {
_internalState.update { it.copy(uploadingBookIds = it.uploadingBookIds + book.bookId) }
try {
- val accessToken = googleDriveRepository.getAccessToken(appContext) ?: return@launch
+ logCloudSyncTrace { "android.upload_content.start ${book.cloudSyncTraceSummary()}" }
+ val accessToken = googleDriveRepository.getAccessToken(appContext) ?: run {
+ logCloudSyncTrace { "android.upload_content.skip reason=no_access_token ${book.cloudSyncTraceSummary()}" }
+ return@launch
+ }
book.getUri()?.path?.let { path ->
val file = File(path)
if (file.exists()) {
+ logCloudSyncTrace { "android.upload_content.file book=${book.bookId} path=${path.cloudSyncPreview()} bytes=${file.length()}" }
Timber.d("Uploading newly added book content: ${book.displayName}")
val uploadedFile = googleDriveRepository.uploadFile(
accessToken, book.bookId, file, book.type
)
if (uploadedFile != null) {
+ logCloudSyncTrace { "android.upload_content.success book=${book.bookId} driveId=${uploadedFile.id}" }
Timber.d("Upload successful, now syncing metadata for ${book.bookId}")
val latestBookState = recentFilesRepository.getFileByBookId(book.bookId)
if (latestBookState != null) {
@@ -4962,13 +6165,16 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
uploadSingleBookMetadata(book)
}
} else {
+ logCloudSyncTrace { "android.upload_content.failed_null book=${book.bookId}" }
Timber.e("Google Drive upload returned null for ${book.bookId}")
}
} else {
+ logCloudSyncTrace { "android.upload_content.skip reason=file_missing book=${book.bookId} path=${path.cloudSyncPreview()}" }
Timber.w("File for new book upload does not exist at path: $path")
}
}
} catch (e: Exception) {
+ logCloudSyncError(e) { "android.upload_content.failed ${book.cloudSyncTraceSummary()}" }
Timber.e(e, "Failed to upload new book content for bookId: ${book.bookId}")
} finally {
_internalState.update {
@@ -5029,8 +6235,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val newBehavior = if (keep) "KEEP" else "DELETE"
setExternalFileBehavior(newBehavior)
}
- if (!keep) {
- deleteBookPermanently(bookId)
+ if (keep) {
+ clearPendingExternalFileRemovals(setOf(bookId))
+ } else {
+ deletePendingExternalFileRemoval(bookId, null)
}
_internalState.update { it.copy(showExternalFileSavePromptFor = null) }
}
@@ -5222,7 +6430,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val currentUser = uiState.value.currentUser ?: return
viewModelScope.launch(Dispatchers.IO) {
- val db = com.aryan.reader.data.AppDatabase.getDatabase(appContext)
+ val db = org.dueattendant149.bookreader.data.AppDatabase.getDatabase(appContext)
val shelf = db.shelfDao().getShelfById(shelfId) ?: return@launch
val crossRefs = db.shelfDao().getCrossRefsForShelf(shelfId)
val manualOnlyBookIds = recentFilesRepository.getAllFilesForSync()
@@ -5399,10 +6607,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
deviceId
)
- val fileExtension = item.type.name.lowercase()
- val fileName = "${item.bookId}.$fileExtension"
- remoteFiles[fileName]?.id?.let { fileId ->
- Timber.d("Deleting from Drive: $fileName")
+ sharedCloudBookContentFileName(item.bookId, item.type)
+ ?.let { fileName ->
+ remoteFiles[fileName]?.id?.let { fileId ->
+ Timber.d("Deleting from Drive: $fileName")
+ googleDriveRepository.deleteDriveFile(accessToken, fileId)
+ }
+ }
+ remoteFiles[cloudPdfAnnotationDriveFileName(item.bookId)]?.id?.let { fileId ->
+ Timber.d("Deleting annotation bundle from Drive: ${item.bookId}")
googleDriveRepository.deleteDriveFile(accessToken, fileId)
}
@@ -5951,7 +7164,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val initDuration = System.currentTimeMillis() - initStartTime
Timber.d(">>> [BATCH] Model Initialization: ${initDuration}ms")
- val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile)
+ val archiveDoc = org.dueattendant149.bookreader.pdf.ArchiveDocumentWrapper(cacheFile)
val totalPages = archiveDoc.getPageCount()
val startIndex = 3
@@ -6097,7 +7310,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_APP_OPEN_COUNT = "app_open_count"
internal const val KEY_SYNCED_FOLDER_URI = "synced_folder_uri"
internal const val KEY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time"
- private const val KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json"
private const val MAX_FOLDER_LIMIT = 10
internal const val KEY_PINNED_HOME = "pinned_home_books"
internal const val KEY_PINNED_LIBRARY = "pinned_library_books"
@@ -6108,6 +7320,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_LAST_OPEN_BOOK_ID = "last_open_book_id"
private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type"
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior"
+ private const val EXTERNAL_FILE_BEHAVIOR_ASK = "ASK"
+ private const val EXTERNAL_FILE_BEHAVIOR_TEMPORARY = "TEMPORARY"
+ private const val KEY_PENDING_EXTERNAL_FILE_REMOVALS = "pending_external_file_removals"
private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter"
private const val KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME = "use_pdf_file_name_as_display_name"
private const val KEY_SCREEN_CAPTURE_PROTECTION = "screen_capture_protection_enabled"
@@ -6132,3 +7347,61 @@ private fun RecentFileItem.isManualOnlyReaderFile(): Boolean {
private fun BookMetadata.isManualOnlyReaderFile(): Boolean {
return isManualOnlyReaderFileName(displayName)
}
+
+private fun RecentFileItem.withFreshLocalReadingPositionForCloudUpload(
+ latestLocal: RecentFileItem?
+): RecentFileItem {
+ if (latestLocal == null || latestLocal.bookId != bookId) return this
+ val latestReadingTimestamp = latestLocal.effectiveReadingPositionModifiedTimestamp()
+ val currentReadingTimestamp = effectiveReadingPositionModifiedTimestamp()
+ val shouldRefresh = latestLocal.lastModifiedTimestamp > lastModifiedTimestamp ||
+ latestReadingTimestamp > currentReadingTimestamp
+ if (!shouldRefresh) return this
+
+ return latestLocal.copy(
+ fileSize = fileSize.takeIf { it > 0L } ?: latestLocal.fileSize,
+ fileContentModifiedTimestamp = maxOf(fileContentModifiedTimestamp, latestLocal.fileContentModifiedTimestamp),
+ isAvailable = isAvailable || latestLocal.isAvailable,
+ uriString = latestLocal.uriString ?: uriString,
+ bookmarksJson = latestLocal.bookmarksJson ?: bookmarksJson,
+ highlightsJson = latestLocal.highlightsJson ?: highlightsJson
+ )
+}
+
+private fun RecentFileItem.withCloudReadingPosition(remote: BookMetadata): RecentFileItem {
+ return copy(
+ lastChapterIndex = remote.lastChapterIndex,
+ lastPage = remote.lastPage,
+ lastPositionCfi = remote.lastPositionCfi,
+ locatorBlockIndex = remote.locatorBlockIndex,
+ locatorCharOffset = remote.locatorCharOffset,
+ progressPercentage = remote.progressPercentage,
+ readingPositionModifiedTimestamp = remote.effectiveReadingPositionModifiedTimestamp()
+ )
+}
+
+private fun RecentFileItem.withLocalReadingPosition(local: RecentFileItem): RecentFileItem {
+ return copy(
+ lastChapterIndex = local.lastChapterIndex,
+ lastPage = local.lastPage,
+ lastPositionCfi = local.lastPositionCfi,
+ locatorBlockIndex = local.locatorBlockIndex,
+ locatorCharOffset = local.locatorCharOffset,
+ progressPercentage = local.progressPercentage,
+ readingPositionModifiedTimestamp = local.effectiveReadingPositionModifiedTimestamp()
+ )
+}
+
+private fun RecentFileItem.withLocalStorageForCloudMetadata(local: RecentFileItem): RecentFileItem {
+ return copy(
+ uriString = local.uriString ?: uriString,
+ isAvailable = local.isAvailable || isAvailable,
+ coverImagePath = local.coverImagePath ?: coverImagePath,
+ sourceFolderUri = local.sourceFolderUri ?: sourceFolderUri,
+ fileSize = local.fileSize.takeIf { it > 0L } ?: fileSize,
+ fileContentModifiedTimestamp = maxOf(local.fileContentModifiedTimestamp, fileContentModifiedTimestamp),
+ folderTextMetadataParsed = local.folderTextMetadataParsed || folderTextMetadataParsed,
+ folderCoverMetadataParsed = local.folderCoverMetadataParsed || folderCoverMetadataParsed,
+ tags = local.tags
+ )
+}
diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/MetadataExtractionWorker.kt
similarity index 93%
rename from app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/MetadataExtractionWorker.kt
index c3dc3ab..9e56c19 100644
--- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/MetadataExtractionWorker.kt
@@ -1,5 +1,5 @@
// MetadataExtractionWorker.kt
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.content.Context
import android.provider.OpenableColumns
@@ -10,10 +10,10 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters
import androidx.work.WorkManager
-import com.aryan.reader.data.RecentFileItem
-import com.aryan.reader.data.RecentFilesRepository
-import com.aryan.reader.pdf.PdfiumCoreProvider
-import com.aryan.reader.pdf.PdfiumEngineProvider
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.RecentFilesRepository
+import org.dueattendant149.bookreader.pdf.PdfiumCoreProvider
+import org.dueattendant149.bookreader.pdf.PdfiumEngineProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser
@@ -50,11 +50,20 @@ class MetadataExtractionWorker(
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
- val hasLegacy = prefs.contains("synced_folder_uri")
- val hasNew = prefs.contains("synced_folders_list_json")
+ val linkedFolders = SyncedFolderPrefs.decodeSyncedFolders(
+ jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
+ legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null)
+ )
+ val enabledFolderUris = linkedFolders
+ .filter { it.localSyncEnabled }
+ .mapTo(mutableSetOf()) { it.uriString }
- if (!hasLegacy && !hasNew) {
- ReaderPerfLog.d("MetadataWorker skipped: no linked folders")
+ if (enabledFolderUris.isEmpty()) {
+ ReaderPerfLog.d("MetadataWorker skipped: no linked folders with sync enabled")
+ return@withContext Result.success()
+ }
+ if (!sourceFolderUri.isNullOrBlank() && sourceFolderUri !in enabledFolderUris) {
+ ReaderPerfLog.d("MetadataWorker skipped: folder sync disabled folder=$sourceFolderUri")
return@withContext Result.success()
}
@@ -62,7 +71,9 @@ class MetadataExtractionWorker(
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
sourceFolderUri = sourceFolderUri,
limit = METADATA_WORKER_BOOK_BATCH_SIZE
- )
+ ).filter { item ->
+ item.sourceFolderUri != null && item.sourceFolderUri in enabledFolderUris
+ }
if (filesToProcess.isEmpty()) {
ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")
diff --git a/app/src/main/java/com/aryan/reader/MyApplication.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt
similarity index 93%
rename from app/src/main/java/com/aryan/reader/MyApplication.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt
index 1a04905..20d419c 100644
--- a/app/src/main/java/com/aryan/reader/MyApplication.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt
@@ -17,14 +17,14 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.app.Application
import android.webkit.WebView
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.SvgDecoder
-import com.aryan.reader.paginatedreader.SvgStringFetcher
+import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher
import timber.log.Timber // Add this
class MyApplication : Application(), ImageLoaderFactory {
diff --git a/app/src/main/java/com/aryan/reader/ProScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ProScreen.kt
similarity index 99%
rename from app/src/main/java/com/aryan/reader/ProScreen.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/ProScreen.kt
index c92ac8b..fd8d1b5 100644
--- a/app/src/main/java/com/aryan/reader/ProScreen.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ProScreen.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.app.Activity
import timber.log.Timber
@@ -61,7 +61,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import com.aryan.reader.data.ProductDetailsEntity
+import org.dueattendant149.bookreader.data.ProductDetailsEntity
import kotlinx.coroutines.launch
import java.text.NumberFormat
import java.util.Currency
diff --git a/app/src/main/java/com/aryan/reader/PurchaseAccountObfuscator.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/PurchaseAccountObfuscator.kt
similarity index 93%
rename from app/src/main/java/com/aryan/reader/PurchaseAccountObfuscator.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/PurchaseAccountObfuscator.kt
index 9604ae6..ed91647 100644
--- a/app/src/main/java/com/aryan/reader/PurchaseAccountObfuscator.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/PurchaseAccountObfuscator.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import java.security.MessageDigest
import java.util.Base64
diff --git a/app/src/main/java/com/aryan/reader/ReaderBrightness.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderBrightness.kt
similarity index 66%
rename from app/src/main/java/com/aryan/reader/ReaderBrightness.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/ReaderBrightness.kt
index 9e9e1d9..9fd9904 100644
--- a/app/src/main/java/com/aryan/reader/ReaderBrightness.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderBrightness.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.content.Context
import android.view.Window
@@ -10,11 +10,16 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -28,20 +33,37 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
+import org.dueattendant149.bookreader.shared.ui.ReaderMinimalSlider
import kotlin.math.roundToInt
private const val READER_PREFS_NAME = "reader_prefs"
private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system"
private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value"
private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f
-private const val MIN_CUSTOM_BRIGHTNESS = 0.05f
+private const val MIN_CUSTOM_BRIGHTNESS_PERCENT = 1
+private const val MAX_CUSTOM_BRIGHTNESS_PERCENT = 100
+private const val CUSTOM_BRIGHTNESS_STEP_PERCENT = 1
+private const val MIN_CUSTOM_BRIGHTNESS = 0.01f
data class ReaderBrightnessSettings(
val useSystemBrightness: Boolean = true,
val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS
) {
val safeCustomBrightness: Float
- get() = customBrightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
+ get() = normalizeReaderBrightness(customBrightness)
+}
+
+internal fun normalizeReaderBrightness(brightness: Float): Float {
+ val percent = (brightness * 100f).roundToInt()
+ .coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
+ return percent / 100f
+}
+
+internal fun stepReaderBrightness(brightness: Float, percentDelta: Int): Float {
+ val currentPercent = (normalizeReaderBrightness(brightness) * 100f).roundToInt()
+ val nextPercent = (currentPercent + percentDelta)
+ .coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
+ return nextPercent / 100f
}
fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
@@ -49,7 +71,7 @@ fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
return ReaderBrightnessSettings(
useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true),
customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS)
- .coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
+ .let(::normalizeReaderBrightness)
)
}
@@ -162,17 +184,9 @@ fun ReaderBrightnessSheet(
color = MaterialTheme.colorScheme.primary
)
}
- Slider(
- value = settings.safeCustomBrightness,
- onValueChange = { brightness ->
- onSettingsChange(
- settings.copy(
- useSystemBrightness = false,
- customBrightness = brightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
- )
- )
- },
- valueRange = MIN_CUSTOM_BRIGHTNESS..1f
+ ReaderBrightnessControl(
+ settings = settings,
+ onSettingsChange = onSettingsChange
)
Text(
text = stringResource(R.string.reader_brightness_custom_desc),
@@ -186,6 +200,67 @@ fun ReaderBrightnessSheet(
}
}
+@Composable
+private fun ReaderBrightnessControl(
+ settings: ReaderBrightnessSettings,
+ onSettingsChange: (ReaderBrightnessSettings) -> Unit
+) {
+ val brightness = settings.safeCustomBrightness
+ val canDecrease = brightness > MIN_CUSTOM_BRIGHTNESS
+ val canIncrease = brightness < 1f
+
+ fun updateBrightness(value: Float) {
+ onSettingsChange(
+ settings.copy(
+ useSystemBrightness = false,
+ customBrightness = normalizeReaderBrightness(value)
+ )
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ IconButton(
+ onClick = {
+ updateBrightness(stepReaderBrightness(brightness, -CUSTOM_BRIGHTNESS_STEP_PERCENT))
+ },
+ enabled = canDecrease,
+ modifier = Modifier.size(36.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Remove,
+ contentDescription = stringResource(R.string.content_desc_decrease),
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ ReaderMinimalSlider(
+ value = brightness,
+ onValueChange = ::updateBrightness,
+ valueRange = MIN_CUSTOM_BRIGHTNESS..1f,
+ activeColor = MaterialTheme.colorScheme.primary,
+ inactiveColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
+ thumbColor = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(
+ onClick = {
+ updateBrightness(stepReaderBrightness(brightness, CUSTOM_BRIGHTNESS_STEP_PERCENT))
+ },
+ enabled = canIncrease,
+ modifier = Modifier.size(36.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = stringResource(R.string.content_desc_increase),
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+}
+
private fun Window.setReaderBrightness(brightness: Float) {
attributes = attributes.apply {
screenBrightness = brightness
diff --git a/app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderFileInfoDialogs.kt
similarity index 54%
rename from app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/ReaderFileInfoDialogs.kt
index 5de4838..75203b6 100644
--- a/app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderFileInfoDialogs.kt
@@ -1,9 +1,12 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import com.aryan.reader.data.RecentFileItem
+import androidx.compose.runtime.setValue
+import org.dueattendant149.bookreader.data.RecentFileItem
@Composable
private fun rememberReaderFileInfoItem(
@@ -64,28 +67,17 @@ internal fun ReaderFileInfoDialogs(
}
}
- item?.let { fileInfoItem ->
- if (isFileInfoVisible) {
- FileInfoDialog(
- item = fileInfoItem,
- usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
- onDismiss = { onFileInfoVisibleChange(false) },
- onSaveMetadata = { metadata ->
- viewModel.updateBookMetadata(fileInfoItem.bookId, metadata)
- },
- onSaveDisplayName = { name ->
- viewModel.updateCustomName(fileInfoItem.bookId, name)
- },
- onRestoreMetadata = {
- viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId)
- },
- onOpenTags = {
- onFileInfoVisibleChange(false)
- viewModel.openTagSelection(setOf(fileInfoItem.bookId))
- }
- )
+ HydratedFileInfoDialog(
+ item = item,
+ isVisible = isFileInfoVisible,
+ uiState = uiState,
+ viewModel = viewModel,
+ onDismiss = { onFileInfoVisibleChange(false) },
+ onOpenTags = { bookId ->
+ onFileInfoVisibleChange(false)
+ viewModel.openTagSelection(setOf(bookId))
}
- }
+ )
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
TagSelectionBottomSheet(
@@ -102,3 +94,49 @@ internal fun ReaderFileInfoDialogs(
)
}
}
+
+@Composable
+internal fun HydratedFileInfoDialog(
+ item: RecentFileItem?,
+ isVisible: Boolean,
+ uiState: ReaderScreenState,
+ viewModel: MainViewModel,
+ onDismiss: () -> Unit,
+ onOpenTags: (String) -> Unit
+) {
+ var fileInfoItem by remember(item?.bookId) { mutableStateOf(item) }
+ var hasResolvedFullItem by remember(item?.bookId) { mutableStateOf(false) }
+
+ LaunchedEffect(item) {
+ fileInfoItem = item
+ hasResolvedFullItem = false
+ }
+
+ LaunchedEffect(isVisible, item?.bookId) {
+ if (isVisible && item != null) {
+ fileInfoItem = viewModel.getFileInfoItem(item.bookId)?.copy(tags = item.tags) ?: item
+ hasResolvedFullItem = true
+ }
+ }
+
+ val resolvedItem = fileInfoItem
+ if (isVisible && resolvedItem != null && hasResolvedFullItem) {
+ FileInfoDialog(
+ item = resolvedItem,
+ usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
+ onDismiss = onDismiss,
+ onSaveMetadata = { metadata ->
+ viewModel.updateBookMetadata(resolvedItem.bookId, metadata)
+ },
+ onSaveDisplayName = { name ->
+ viewModel.updateCustomName(resolvedItem.bookId, name)
+ },
+ onRestoreMetadata = {
+ viewModel.restoreOriginalBookMetadata(resolvedItem.bookId)
+ },
+ onOpenTags = {
+ onOpenTags(resolvedItem.bookId)
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderFontDiagnostics.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderFontDiagnostics.kt
new file mode 100644
index 0000000..1f1ba33
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderFontDiagnostics.kt
@@ -0,0 +1,15 @@
+package org.dueattendant149.bookreader
+
+import org.dueattendant149.bookreader.shared.detectFontVariant
+import org.dueattendant149.bookreader.shared.familyFilenameSignature
+import org.dueattendant149.bookreader.shared.supportsVariableWeightAxis
+
+const val ReaderFontDiagnosticsTag = "ReaderFontDiag"
+
+fun readerFontDiagnosticSummary(nameWithoutExtension: String): String {
+ val variant = nameWithoutExtension.detectFontVariant()
+ return "name='$nameWithoutExtension' " +
+ "signature='${nameWithoutExtension.familyFilenameSignature()}' " +
+ "variant=$variant " +
+ "variableWght=${nameWithoutExtension.supportsVariableWeightAxis()}"
+}
diff --git a/app/src/main/java/com/aryan/reader/ReaderPaginationPreferences.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderPaginationPreferences.kt
similarity index 97%
rename from app/src/main/java/com/aryan/reader/ReaderPaginationPreferences.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/ReaderPaginationPreferences.kt
index 348153b..4d922e0 100644
--- a/app/src/main/java/com/aryan/reader/ReaderPaginationPreferences.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderPaginationPreferences.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.content.Context
import androidx.core.content.edit
diff --git a/app/src/main/java/com/aryan/reader/ReaderPerfLog.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderPerfLog.kt
similarity index 97%
rename from app/src/main/java/com/aryan/reader/ReaderPerfLog.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/ReaderPerfLog.kt
index da2db2f..dd1749f 100644
--- a/app/src/main/java/com/aryan/reader/ReaderPerfLog.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderPerfLog.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import timber.log.Timber
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderPopupSizing.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderPopupSizing.kt
new file mode 100644
index 0000000..756a436
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderPopupSizing.kt
@@ -0,0 +1,19 @@
+package org.dueattendant149.bookreader
+
+import kotlin.math.roundToInt
+
+fun readerModalMaxHeightDp(
+ screenHeightDp: Int,
+ fraction: Float = 0.85f,
+ verticalMarginDp: Int = 32,
+ preferredMinHeightDp: Int = 220
+): Int {
+ val usableHeight = (screenHeightDp - verticalMarginDp).coerceAtLeast(1)
+ val proportionalHeight = (screenHeightDp * fraction).roundToInt().coerceAtLeast(1)
+ val cappedHeight = minOf(usableHeight, proportionalHeight)
+ return if (usableHeight >= preferredMinHeightDp) {
+ cappedHeight.coerceAtLeast(preferredMinHeightDp)
+ } else {
+ usableHeight
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/ReaderScreenOrientation.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderScreenOrientation.kt
similarity index 99%
rename from app/src/main/java/com/aryan/reader/ReaderScreenOrientation.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/ReaderScreenOrientation.kt
index ccb4f05..22ab18b 100644
--- a/app/src/main/java/com/aryan/reader/ReaderScreenOrientation.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderScreenOrientation.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.app.Activity
import android.content.Context
diff --git a/app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderSliderChromeState.kt
similarity index 92%
rename from app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/ReaderSliderChromeState.kt
index 9fb9623..5b51f8a 100644
--- a/app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ReaderSliderChromeState.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.content.Context
import androidx.compose.ui.graphics.Color
@@ -56,6 +56,21 @@ internal fun shouldRenderReaderSlider(
isSearchActive: Boolean
): Boolean = isToggledOn && isBottomChromeVisible && !isSearchActive
+internal fun readerSliderStepPage(
+ currentPage: Int,
+ delta: Int,
+ minPage: Int,
+ maxPage: Int
+): Int {
+ val lowerBound = min(minPage, maxPage)
+ val upperBound = max(minPage, maxPage)
+ val nextPage = currentPage.toLong() + delta.toLong()
+
+ return nextPage
+ .coerceIn(lowerBound.toLong(), upperBound.toLong())
+ .toInt()
+}
+
internal fun readerSliderTogglePreferenceKey(bookId: String): String =
READER_SLIDER_TOGGLE_PREFIX + bookId
diff --git a/app/src/main/java/com/aryan/reader/SafeStringResources.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/SafeStringResources.kt
similarity index 96%
rename from app/src/main/java/com/aryan/reader/SafeStringResources.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/SafeStringResources.kt
index 4515a31..6f60bc9 100644
--- a/app/src/main/java/com/aryan/reader/SafeStringResources.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/SafeStringResources.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.content.Context
import android.content.res.Configuration
diff --git a/app/src/main/java/com/aryan/reader/SettingsScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/SettingsScreen.kt
similarity index 89%
rename from app/src/main/java/com/aryan/reader/SettingsScreen.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/SettingsScreen.kt
index f3c2c69..8aa395c 100644
--- a/app/src/main/java/com/aryan/reader/SettingsScreen.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/SettingsScreen.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.WindowInsets
@@ -27,45 +27,45 @@ import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
-import com.aryan.reader.data.CustomFontEntity
-import com.aryan.reader.epubreader.FormatSettings as AndroidFormatSettings
-import com.aryan.reader.epubreader.ReaderFont as AndroidReaderFont
-import com.aryan.reader.epubreader.ReaderTextAlign as AndroidReaderTextAlign
-import com.aryan.reader.epubreader.loadFormatSettings
-import com.aryan.reader.epubreader.loadPageInfoMode
-import com.aryan.reader.epubreader.loadPageInfoPosition
-import com.aryan.reader.epubreader.loadPullToTurn
-import com.aryan.reader.epubreader.loadPullToTurnMultiplier
-import com.aryan.reader.epubreader.loadSystemUiMode
-import com.aryan.reader.epubreader.savePageInfoMode
-import com.aryan.reader.epubreader.savePageInfoPosition
-import com.aryan.reader.epubreader.savePullToTurn
-import com.aryan.reader.epubreader.savePullToTurnMultiplier
-import com.aryan.reader.epubreader.saveReaderSettings
-import com.aryan.reader.epubreader.saveSystemUiMode
-import com.aryan.reader.pdf.savePdfSystemUiMode
-import com.aryan.reader.pdf.savePdfThemeId
-import com.aryan.reader.pdf.savePdfVerticalPageGapVisible
-import com.aryan.reader.pdf.savePdfPageNumberOverlayVisible
-import com.aryan.reader.pdf.loadPdfSystemUiMode
-import com.aryan.reader.pdf.loadPdfThemeId
-import com.aryan.reader.pdf.loadPdfVerticalPageGapVisible
-import com.aryan.reader.pdf.loadPdfPageNumberOverlayVisible
-import com.aryan.reader.shared.BuiltInPdfReaderThemes
-import com.aryan.reader.shared.CustomFontItem
-import com.aryan.reader.shared.SharedSettingsAction
-import com.aryan.reader.shared.SharedSettingsDestination
-import com.aryan.reader.shared.parentDestination
-import com.aryan.reader.shared.toReaderSettingsFontFamily
-import com.aryan.reader.shared.toSharedReaderTextAlign
-import com.aryan.reader.shared.reader.ReaderReadingMode
-import com.aryan.reader.shared.reader.ReaderSettings
-import com.aryan.reader.shared.reader.SharedReaderTextAlign
-import com.aryan.reader.shared.readerThemeById
-import com.aryan.reader.shared.sharedSettingsHubModel
-import com.aryan.reader.shared.toReaderSettings
-import com.aryan.reader.shared.ui.SharedSettingsHub
-import com.aryan.reader.tts.loadTtsMode
+import org.dueattendant149.bookreader.data.CustomFontEntity
+import org.dueattendant149.bookreader.epubreader.FormatSettings as AndroidFormatSettings
+import org.dueattendant149.bookreader.epubreader.ReaderFont as AndroidReaderFont
+import org.dueattendant149.bookreader.epubreader.ReaderTextAlign as AndroidReaderTextAlign
+import org.dueattendant149.bookreader.epubreader.loadFormatSettings
+import org.dueattendant149.bookreader.epubreader.loadPageInfoMode
+import org.dueattendant149.bookreader.epubreader.loadPageInfoPosition
+import org.dueattendant149.bookreader.epubreader.loadPullToTurn
+import org.dueattendant149.bookreader.epubreader.loadPullToTurnMultiplier
+import org.dueattendant149.bookreader.epubreader.loadSystemUiMode
+import org.dueattendant149.bookreader.epubreader.savePageInfoMode
+import org.dueattendant149.bookreader.epubreader.savePageInfoPosition
+import org.dueattendant149.bookreader.epubreader.savePullToTurn
+import org.dueattendant149.bookreader.epubreader.savePullToTurnMultiplier
+import org.dueattendant149.bookreader.epubreader.saveReaderSettings
+import org.dueattendant149.bookreader.epubreader.saveSystemUiMode
+import org.dueattendant149.bookreader.pdf.savePdfSystemUiMode
+import org.dueattendant149.bookreader.pdf.savePdfThemeId
+import org.dueattendant149.bookreader.pdf.savePdfVerticalPageGapVisible
+import org.dueattendant149.bookreader.pdf.savePdfPageNumberOverlayVisible
+import org.dueattendant149.bookreader.pdf.loadPdfSystemUiMode
+import org.dueattendant149.bookreader.pdf.loadPdfThemeId
+import org.dueattendant149.bookreader.pdf.loadPdfVerticalPageGapVisible
+import org.dueattendant149.bookreader.pdf.loadPdfPageNumberOverlayVisible
+import org.dueattendant149.bookreader.shared.BuiltInPdfReaderThemes
+import org.dueattendant149.bookreader.shared.CustomFontItem
+import org.dueattendant149.bookreader.shared.SharedSettingsAction
+import org.dueattendant149.bookreader.shared.SharedSettingsDestination
+import org.dueattendant149.bookreader.shared.parentDestination
+import org.dueattendant149.bookreader.shared.toReaderSettingsFontFamily
+import org.dueattendant149.bookreader.shared.toSharedReaderTextAlign
+import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
+import org.dueattendant149.bookreader.shared.reader.ReaderSettings
+import org.dueattendant149.bookreader.shared.reader.SharedReaderTextAlign
+import org.dueattendant149.bookreader.shared.readerThemeById
+import org.dueattendant149.bookreader.shared.sharedSettingsHubModel
+import org.dueattendant149.bookreader.shared.toReaderSettings
+import org.dueattendant149.bookreader.shared.ui.SharedSettingsHub
+import org.dueattendant149.bookreader.tts.loadTtsMode
import kotlinx.coroutines.launch
import kotlin.math.max
import kotlin.math.roundToInt
diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/SharedComposables.kt
similarity index 93%
rename from app/src/main/java/com/aryan/reader/SharedComposables.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/SharedComposables.kt
index 71e3996..db27184 100644
--- a/app/src/main/java/com/aryan/reader/SharedComposables.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/SharedComposables.kt
@@ -18,7 +18,7 @@
* mail: epistemereader@gmail.com
*/
// SharedComposables.kt
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.content.Context
import android.content.Intent
@@ -43,7 +43,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
-import com.aryan.reader.data.TagEntity
+import org.dueattendant149.bookreader.data.TagEntity
import androidx.compose.material.icons.filled.Search
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
@@ -87,6 +87,7 @@ import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.SelectAll
+import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.FileOpen
import androidx.compose.material.icons.outlined.Gavel
import androidx.compose.material.icons.outlined.Policy
@@ -142,10 +143,14 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.core.text.HtmlCompat
-import com.aryan.reader.data.BookMetadataEdit
-import com.aryan.reader.data.RecentFileItem
-import com.aryan.reader.shared.SharedText
-import com.aryan.reader.shared.ui.SharedMarkdownText
+import org.dueattendant149.bookreader.shared.SharedFileCapabilities
+import org.dueattendant149.bookreader.shared.SharedLegalLinks
+import org.dueattendant149.bookreader.shared.SharedLegalProfile
+import org.dueattendant149.bookreader.data.BookMetadataEdit
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.shared.SharedText
+import org.dueattendant149.bookreader.shared.sharedLegalLinksForProfile
+import org.dueattendant149.bookreader.shared.ui.SharedMarkdownText
import timber.log.Timber
import java.text.SimpleDateFormat
import java.util.Date
@@ -154,9 +159,25 @@ import kotlin.math.log10
import kotlin.math.pow
import kotlin.math.roundToInt
-internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
-internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
-internal const val LICENSES_URL = "https://aryan-raj3112.github.io/reader-policy/licenses.html"
+internal fun legalLinksForAndroidFlavor(flavor: String = BuildConfig.FLAVOR): SharedLegalLinks {
+ val profile = if (flavor == "oss") SharedLegalProfile.OSS else SharedLegalProfile.STANDARD
+ return sharedLegalLinksForProfile(profile)
+}
+
+internal val PRIVACY_POLICY_URL: String get() = legalLinksForAndroidFlavor().privacyPolicyUrl
+internal val TERMS_URL: String get() = legalLinksForAndroidFlavor().termsUrl
+internal val LICENSES_URL: String get() = legalLinksForAndroidFlavor().licensesUrl
+
+fun supportedFontMimeTypes(): Array = arrayOf(
+ "font/ttf",
+ "font/otf",
+ "font/woff2",
+ "application/x-font-ttf",
+ "application/x-font-otf",
+ "application/font-woff2",
+ "application/vnd.ms-opentype",
+ "application/x-font-opentype"
+)
class CustomTabUriHandler(private val context: Context) : UriHandler {
override fun openUri(uri: String) {
@@ -255,6 +276,8 @@ fun ContextualTopAppBar(
selectedItemCount: Int,
onNavIconClick: () -> Unit,
onInfoClick: (() -> Unit)? = null,
+ onSaveClick: (() -> Unit)? = null,
+ onShareClick: (() -> Unit)? = null,
onTagClick: (() -> Unit)? = null,
onSelectAllClick: (() -> Unit)? = null,
onPinClick: (() -> Unit)? = null,
@@ -283,6 +306,16 @@ fun ContextualTopAppBar(
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.info))
}
}
+ if (selectedItemCount == 1 && onSaveClick != null) {
+ IconButton(onClick = onSaveClick) {
+ Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.action_save_copy_to_device))
+ }
+ }
+ if (selectedItemCount == 1 && onShareClick != null) {
+ IconButton(onClick = onShareClick) {
+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.action_share))
+ }
+ }
if (onSelectAllClick != null) {
IconButton(onClick = onSelectAllClick) {
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all))
@@ -1237,53 +1270,55 @@ fun AboutDialog(onDismiss: () -> Unit) {
subtitle = stringResource(R.string.about_github_desc),
onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme") }
)
- } else {
- AboutInfoRow(
- icon = {
- Icon(
- imageVector = Icons.Outlined.Policy,
- contentDescription = null,
- modifier = Modifier.size(22.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- },
- text = stringResource(R.string.legal_privacy_policy),
- subtitle = stringResource(R.string.about_privacy_desc),
- onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
- )
Spacer(modifier = Modifier.height(10.dp))
-
- AboutInfoRow(
- icon = {
- Icon(
- imageVector = Icons.Outlined.Gavel,
- contentDescription = null,
- modifier = Modifier.size(22.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- },
- text = stringResource(R.string.legal_terms_of_service),
- subtitle = stringResource(R.string.about_terms_desc),
- onClick = { uriHandler.openUri(TERMS_URL) }
- )
-
- Spacer(modifier = Modifier.height(10.dp))
-
- AboutInfoRow(
- icon = {
- Icon(
- imageVector = Icons.Outlined.FileOpen,
- contentDescription = null,
- modifier = Modifier.size(22.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- },
- text = stringResource(R.string.legal_licenses),
- subtitle = stringResource(R.string.about_licenses_desc),
- onClick = { uriHandler.openUri(LICENSES_URL) }
- )
}
+
+ AboutInfoRow(
+ icon = {
+ Icon(
+ imageVector = Icons.Outlined.Policy,
+ contentDescription = null,
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ text = stringResource(R.string.legal_privacy_policy),
+ subtitle = stringResource(R.string.about_privacy_desc),
+ onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
+ )
+
+ Spacer(modifier = Modifier.height(10.dp))
+
+ AboutInfoRow(
+ icon = {
+ Icon(
+ imageVector = Icons.Outlined.Gavel,
+ contentDescription = null,
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ text = stringResource(R.string.legal_terms_of_service),
+ subtitle = stringResource(R.string.about_terms_desc),
+ onClick = { uriHandler.openUri(TERMS_URL) }
+ )
+
+ Spacer(modifier = Modifier.height(10.dp))
+
+ AboutInfoRow(
+ icon = {
+ Icon(
+ imageVector = Icons.Outlined.FileOpen,
+ contentDescription = null,
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ text = stringResource(R.string.legal_licenses),
+ subtitle = stringResource(R.string.about_licenses_desc),
+ onClick = { uriHandler.openUri(LICENSES_URL) }
+ )
}
},
confirmButton = {
@@ -1563,6 +1598,31 @@ fun RecentFileItem.isOpdsStream(): Boolean {
return uriString?.startsWith("opds-pse://") == true
}
+fun RecentFileItem.canExportOriginalFile(): Boolean {
+ return uriString != null && !isOpdsStream()
+}
+
+fun RecentFileItem.suggestedOriginalFileName(): String {
+ val fallbackExtension = SharedFileCapabilities.primaryExtensionFor(type)
+ val baseName = displayName
+ .takeIf { it.isNotBlank() }
+ ?: title?.takeIf { it.isNotBlank() }
+ ?: "book"
+ val sanitized = baseName
+ .replace(Regex("""[\\/:*?"<>|]+"""), "_")
+ .trim()
+ .take(120)
+ .ifBlank { "book" }
+ return if (
+ fallbackExtension != null &&
+ !sanitized.endsWith(".$fallbackExtension", ignoreCase = true)
+ ) {
+ "$sanitized.$fallbackExtension"
+ } else {
+ sanitized
+ }
+}
+
@Composable
private fun statusBadgeColors(overlay: Boolean): Pair {
val container = if (overlay) {
diff --git a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/SharedModelMappers.kt
similarity index 52%
rename from app/src/main/java/com/aryan/reader/SharedModelMappers.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/SharedModelMappers.kt
index fa6c19e..d0c5beb 100644
--- a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/SharedModelMappers.kt
@@ -1,20 +1,22 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
-import com.aryan.reader.data.BookShelfCrossRef
-import com.aryan.reader.data.BookTagCrossRef
-import com.aryan.reader.data.RecentFileItem
-import com.aryan.reader.data.ShelfEntity
-import com.aryan.reader.data.TagEntity
-import com.aryan.reader.shared.BookItem as SharedBookItem
-import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
-import com.aryan.reader.shared.EpubAnnotationSerializer
-import com.aryan.reader.shared.FileType as SharedFileType
-import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
-import com.aryan.reader.shared.SharedReaderScreenState
-import com.aryan.reader.shared.Shelf as SharedShelf
-import com.aryan.reader.shared.ShelfRecord
-import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
-import com.aryan.reader.shared.Tag as SharedTag
+import org.dueattendant149.bookreader.data.BookShelfCrossRef
+import org.dueattendant149.bookreader.data.BookTagCrossRef
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.ShelfEntity
+import org.dueattendant149.bookreader.data.TagEntity
+import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem
+import org.dueattendant149.bookreader.shared.BookShelfRef as SharedBookShelfRef
+import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
+import org.dueattendant149.bookreader.shared.FileType as SharedFileType
+import org.dueattendant149.bookreader.shared.LibraryFilters as SharedLibraryFilters
+import org.dueattendant149.bookreader.shared.ReaderLocator as SharedReaderLocator
+import org.dueattendant149.bookreader.shared.SharedReaderScreenState
+import org.dueattendant149.bookreader.shared.Shelf as SharedShelf
+import org.dueattendant149.bookreader.shared.ShelfRecord
+import org.dueattendant149.bookreader.shared.SyncedFolder as SharedSyncedFolder
+import org.dueattendant149.bookreader.shared.Tag as SharedTag
+import org.dueattendant149.bookreader.shared.toStablePositionCfi
fun FileType.toSharedFileType(): SharedFileType = this
@@ -29,11 +31,21 @@ fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder = this
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this
fun RecentFileItem.toSharedBookItem(): SharedBookItem {
+ return toSharedBookItem(
+ displayName = customName ?: displayName,
+ includeReaderAnnotations = true
+ )
+}
+
+private fun RecentFileItem.toSharedBookItem(
+ displayName: String,
+ includeReaderAnnotations: Boolean
+): SharedBookItem {
return SharedBookItem(
id = bookId,
path = uriString,
type = type,
- displayName = customName ?: displayName,
+ displayName = displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
@@ -53,13 +65,22 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem {
seriesName = seriesName,
seriesIndex = seriesIndex,
lastPageIndex = lastPage,
+ readerPosition = toSharedReaderLocatorOrNull(),
tags = tags.map { it.toSharedTag() },
- readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
+ readerHighlights = if (includeReaderAnnotations) {
+ EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
+ } else {
+ emptyList()
+ },
+ readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
)
}
fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem {
- return toSharedBookItem().copy(displayName = displayName)
+ return toSharedBookItem(
+ displayName = displayName,
+ includeReaderAnnotations = false
+ )
}
fun SharedBookItem.toRecentFileItem(
@@ -67,36 +88,49 @@ fun SharedBookItem.toRecentFileItem(
tagEntitiesById: Map = emptyMap()
): RecentFileItem {
val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
- return androidBooksById[id]?.copy(tags = resolvedTags)
- ?.copy(
+ val positionCfi = readerPosition?.toSharedPositionCfi()
+ androidBooksById[id]?.let { existing ->
+ val mappedLastChapterIndex = readerPosition?.chapterIndex ?: existing.lastChapterIndex
+ val mappedLastPositionCfi = positionCfi ?: existing.lastPositionCfi
+ val mappedLocatorBlockIndex = readerPosition?.blockIndex ?: existing.locatorBlockIndex
+ val mappedLocatorCharOffset = readerPosition?.charOffset ?: existing.locatorCharOffset
+
+ if (
+ existing.uriString == path &&
+ existing.type == type &&
+ existing.timestamp == timestamp &&
+ existing.coverImagePath == coverImagePath &&
+ existing.title == title &&
+ existing.author == author &&
+ existing.description == description &&
+ existing.originalTitle == originalTitle &&
+ existing.originalAuthor == originalAuthor &&
+ existing.originalSeriesName == originalSeriesName &&
+ existing.originalSeriesIndex == originalSeriesIndex &&
+ existing.originalDescription == originalDescription &&
+ existing.lastPage == lastPageIndex &&
+ existing.progressPercentage == progressPercentage &&
+ existing.isRecent == isRecent &&
+ existing.sourceFolderUri == sourceFolder &&
+ existing.fileSize == fileSize &&
+ existing.fileContentModifiedTimestamp == fileContentModifiedTimestamp &&
+ existing.seriesName == seriesName &&
+ existing.seriesIndex == seriesIndex &&
+ existing.folderTextMetadataParsed == folderTextMetadataParsed &&
+ existing.lastChapterIndex == mappedLastChapterIndex &&
+ existing.lastPositionCfi == mappedLastPositionCfi &&
+ existing.locatorBlockIndex == mappedLocatorBlockIndex &&
+ existing.locatorCharOffset == mappedLocatorCharOffset &&
+ existing.readingPositionModifiedTimestamp == readingPositionModifiedTimestamp &&
+ existing.tags == resolvedTags
+ ) {
+ return existing
+ }
+
+ return existing.copy(
uriString = path,
type = type,
- displayName = androidBooksById[id]?.displayName ?: displayName,
- timestamp = timestamp,
- coverImagePath = coverImagePath,
- title = title,
- author = author,
- description = description,
- originalTitle = originalTitle,
- originalAuthor = originalAuthor,
- originalSeriesName = originalSeriesName,
- originalSeriesIndex = originalSeriesIndex,
- originalDescription = originalDescription,
- lastPage = lastPageIndex,
- progressPercentage = progressPercentage,
- isRecent = isRecent,
- sourceFolderUri = sourceFolder,
- fileSize = fileSize,
- fileContentModifiedTimestamp = fileContentModifiedTimestamp,
- seriesName = seriesName,
- seriesIndex = seriesIndex,
- folderTextMetadataParsed = folderTextMetadataParsed
- )
- ?: RecentFileItem(
- bookId = id,
- uriString = path,
- type = type,
- displayName = displayName,
+ displayName = existing.displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
@@ -116,8 +150,70 @@ fun SharedBookItem.toRecentFileItem(
seriesName = seriesName,
seriesIndex = seriesIndex,
folderTextMetadataParsed = folderTextMetadataParsed,
+ lastChapterIndex = mappedLastChapterIndex,
+ lastPositionCfi = mappedLastPositionCfi,
+ locatorBlockIndex = mappedLocatorBlockIndex,
+ locatorCharOffset = mappedLocatorCharOffset,
+ readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
tags = resolvedTags
)
+ }
+
+ return RecentFileItem(
+ bookId = id,
+ uriString = path,
+ type = type,
+ displayName = displayName,
+ timestamp = timestamp,
+ coverImagePath = coverImagePath,
+ title = title,
+ author = author,
+ description = description,
+ originalTitle = originalTitle,
+ originalAuthor = originalAuthor,
+ originalSeriesName = originalSeriesName,
+ originalSeriesIndex = originalSeriesIndex,
+ originalDescription = originalDescription,
+ lastPage = lastPageIndex,
+ progressPercentage = progressPercentage,
+ isRecent = isRecent,
+ sourceFolderUri = sourceFolder,
+ fileSize = fileSize,
+ fileContentModifiedTimestamp = fileContentModifiedTimestamp,
+ seriesName = seriesName,
+ seriesIndex = seriesIndex,
+ folderTextMetadataParsed = folderTextMetadataParsed,
+ lastChapterIndex = readerPosition?.chapterIndex,
+ lastPositionCfi = positionCfi,
+ locatorBlockIndex = readerPosition?.blockIndex,
+ locatorCharOffset = readerPosition?.charOffset,
+ readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
+ tags = resolvedTags
+ )
+}
+
+private fun RecentFileItem.toSharedReaderLocatorOrNull(): SharedReaderLocator? {
+ if (
+ lastChapterIndex == null &&
+ lastPage == null &&
+ lastPositionCfi.isNullOrBlank() &&
+ locatorBlockIndex == null &&
+ locatorCharOffset == null
+ ) {
+ return null
+ }
+ return SharedReaderLocator.fromLegacy(
+ chapterIndex = lastChapterIndex,
+ cfi = lastPositionCfi,
+ pageIndex = lastPage
+ ).withFallbacks(
+ blockIndex = locatorBlockIndex,
+ charOffset = locatorCharOffset
+ )
+}
+
+private fun SharedReaderLocator.toSharedPositionCfi(): String? {
+ return toStablePositionCfi()
}
fun TagEntity.toSharedTag(): SharedTag {
@@ -167,8 +263,16 @@ fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
fun ReaderScreenState.toSharedReaderScreenState(
rawBooks: List = rawLibraryFiles,
- dbTags: List = allTags
+ dbTags: List = allTags,
+ includeReaderAnnotations: Boolean = true
): SharedReaderScreenState {
+ fun RecentFileItem.toStateSharedBookItem(): SharedBookItem {
+ return toSharedBookItem(
+ displayName = customName ?: displayName,
+ includeReaderAnnotations = includeReaderAnnotations
+ )
+ }
+
return SharedReaderScreenState(
selectedBookId = selectedBookId,
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
@@ -202,16 +306,16 @@ fun ReaderScreenState.toSharedReaderScreenState(
isSearchActive = isSearchActive,
isRefreshing = isRefreshing,
reflowProgress = reflowProgress,
- recentBooks = recentFiles.map { it.toSharedBookItem() },
- libraryBooks = allRecentFiles.map { it.toSharedBookItem() },
- rawLibraryBooks = rawBooks.map { it.toSharedBookItem() },
+ recentBooks = recentFiles.map { it.toStateSharedBookItem() },
+ libraryBooks = allRecentFiles.map { it.toStateSharedBookItem() },
+ rawLibraryBooks = rawBooks.map { it.toStateSharedBookItem() },
pinnedHomeBookIds = pinnedHomeBookIds,
pinnedLibraryBookIds = pinnedLibraryBookIds,
libraryFilters = libraryFilters,
recentFilesLimit = recentFilesLimit,
isTabsEnabled = isTabsEnabled,
openTabIds = openTabIds,
- openTabs = openTabs.map { it.toSharedBookItem() },
+ openTabs = openTabs.map { it.toStateSharedBookItem() },
activeTabBookId = activeTabBookId,
showExternalFileSavePromptFor = showExternalFileSavePromptFor,
externalFileBehavior = externalFileBehavior,
@@ -237,7 +341,14 @@ fun List.withResolvedTags(
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
entry.value.mapNotNull { tagsById[it.tagId] }
}
- return map { item -> item.copy(tags = bookTagsMap[item.bookId].orEmpty()) }
+ return map { item ->
+ val resolvedTags = bookTagsMap[item.bookId].orEmpty()
+ if (item.tags == resolvedTags) {
+ item
+ } else {
+ item.copy(tags = resolvedTags)
+ }
+ }
}
fun SharedReaderScreenState.toAndroidReaderScreenState(
@@ -246,8 +357,11 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
tagEntitiesById: Map = emptyMap()
): ReaderScreenState {
val fallbackBooksById = rawLibraryBooks.associateBy { it.id }
+ val mappedBooksById = LinkedHashMap()
fun SharedBookItem.toAndroidBook(): RecentFileItem {
- return toRecentFileItem(androidBooksById, tagEntitiesById)
+ return mappedBooksById.getOrPut(id) {
+ toRecentFileItem(androidBooksById, tagEntitiesById)
+ }
}
fun bookById(bookId: String): RecentFileItem? {
return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook()
@@ -260,7 +374,7 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
isAddingBooksToShelf = isAddingBooksToShelf,
contextualActionShelfIds = selectedShelfIds,
contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) },
- shelves = shelves.map { it.toAndroidShelf(androidBooksById, tagEntitiesById) },
+ shelves = shelves.map { shelf -> shelf.toAndroidShelf { book -> book.toAndroidBook() } },
openTabs = openTabs.map { it.toAndroidBook() },
openTabIds = openTabIds,
activeTabBookId = activeTabBookId,
@@ -272,13 +386,19 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
fun SharedShelf.toAndroidShelf(
androidBooksById: Map = emptyMap(),
tagEntitiesById: Map = emptyMap()
+): Shelf {
+ return toAndroidShelf { it.toRecentFileItem(androidBooksById, tagEntitiesById) }
+}
+
+private fun SharedShelf.toAndroidShelf(
+ resolveBook: (SharedBookItem) -> RecentFileItem
): Shelf {
return Shelf(
id = id,
name = name,
type = type,
- books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
- directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
+ books = books.map(resolveBook),
+ directBooks = directBooks.map(resolveBook),
parentShelfId = parentShelfId,
childShelfIds = childShelfIds,
depth = depth,
diff --git a/app/src/main/java/com/aryan/reader/StorageTracker.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/StorageTracker.kt
similarity index 97%
rename from app/src/main/java/com/aryan/reader/StorageTracker.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/StorageTracker.kt
index 5ed62bd..4c8f990 100644
--- a/app/src/main/java/com/aryan/reader/StorageTracker.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/StorageTracker.kt
@@ -1,5 +1,5 @@
// StorageTracker.kt
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.content.Context
import timber.log.Timber
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/SyncedFolderPrefs.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/SyncedFolderPrefs.kt
new file mode 100644
index 0000000..65f4bda
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/SyncedFolderPrefs.kt
@@ -0,0 +1,108 @@
+package org.dueattendant149.bookreader
+
+import org.json.JSONArray
+import org.json.JSONObject
+import timber.log.Timber
+
+internal object SyncedFolderPrefs {
+ const val KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json"
+ const val KEY_LEGACY_SYNCED_FOLDER_URI = "synced_folder_uri"
+ const val KEY_LEGACY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time"
+
+ fun decodeSyncedFolders(
+ jsonString: String?,
+ legacyUri: String?,
+ legacyLastScanTime: Long = 0L,
+ legacyNameResolver: (String) -> String = { it },
+ syncableTypes: Set = ANDROID_SYNCABLE_FILE_TYPES
+ ): List {
+ if (jsonString == null) {
+ return legacyUri
+ ?.takeIf { it.isNotBlank() }
+ ?.let { uri ->
+ listOf(
+ SyncedFolder(
+ uriString = uri,
+ name = legacyNameResolver(uri),
+ lastScanTime = legacyLastScanTime,
+ allowedFileTypes = syncableTypes,
+ localSyncEnabled = true
+ )
+ )
+ }
+ .orEmpty()
+ }
+
+ return try {
+ val array = JSONArray(jsonString)
+ buildList {
+ for (i in 0 until array.length()) {
+ val obj = array.getJSONObject(i)
+ val uri = obj.optString("uri").takeIf { it.isNotBlank() }
+ if (uri == null) continue
+ val name = obj.optString("name").takeIf { it.isNotBlank() } ?: legacyNameResolver(uri)
+ add(
+ SyncedFolder(
+ uriString = uri,
+ name = name,
+ lastScanTime = obj.optLong("lastScanTime", 0L),
+ allowedFileTypes = decodeAllowedFileTypes(obj, syncableTypes),
+ localSyncEnabled = obj.optBoolean("localSyncEnabled", true)
+ )
+ )
+ }
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to parse synced folders JSON")
+ emptyList()
+ }
+ }
+
+ fun encodeSyncedFolders(
+ folders: List,
+ syncableTypes: Set = ANDROID_SYNCABLE_FILE_TYPES
+ ): String {
+ val jsonArray = JSONArray()
+ folders.forEach { folder ->
+ val obj = JSONObject()
+ obj.put("uri", folder.uriString)
+ obj.put("name", folder.name)
+ obj.put("lastScanTime", folder.lastScanTime)
+ obj.put("localSyncEnabled", folder.localSyncEnabled)
+ val typesArray = JSONArray()
+ folder.allowedFileTypes
+ .filter { it in syncableTypes }
+ .forEach { typesArray.put(it.name) }
+ obj.put("allowedFileTypes", typesArray)
+ jsonArray.put(obj)
+ }
+ return jsonArray.toString()
+ }
+
+ fun isLocalSyncEnabled(
+ jsonString: String?,
+ legacyUri: String?,
+ folderUriString: String,
+ syncableTypes: Set = ANDROID_SYNCABLE_FILE_TYPES
+ ): Boolean {
+ return decodeSyncedFolders(
+ jsonString = jsonString,
+ legacyUri = legacyUri,
+ syncableTypes = syncableTypes
+ ).firstOrNull { it.uriString == folderUriString }?.localSyncEnabled == true
+ }
+
+ private fun decodeAllowedFileTypes(
+ obj: JSONObject,
+ syncableTypes: Set
+ ): Set {
+ if (!obj.has("allowedFileTypes")) return syncableTypes
+ val typesArray = obj.optJSONArray("allowedFileTypes") ?: return syncableTypes
+ return buildSet {
+ for (i in 0 until typesArray.length()) {
+ val type = runCatching { FileType.valueOf(typesArray.getString(i)) }.getOrNull()
+ if (type != null && type in syncableTypes) add(type)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/ThemedBookCover.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ThemedBookCover.kt
similarity index 98%
rename from app/src/main/java/com/aryan/reader/ThemedBookCover.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/ThemedBookCover.kt
index 1cfbfcc..d4a73f4 100644
--- a/app/src/main/java/com/aryan/reader/ThemedBookCover.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ThemedBookCover.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -31,7 +31,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
-import com.aryan.reader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.RecentFileItem
import java.io.File
import kotlin.math.absoluteValue
diff --git a/app/src/main/java/com/aryan/reader/TtsReplacementStore.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/TtsReplacementStore.kt
similarity index 79%
rename from app/src/main/java/com/aryan/reader/TtsReplacementStore.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/TtsReplacementStore.kt
index 5167ba5..bbdff5c 100644
--- a/app/src/main/java/com/aryan/reader/TtsReplacementStore.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/TtsReplacementStore.kt
@@ -1,11 +1,11 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import android.content.Context
import androidx.core.content.edit
-import com.aryan.reader.paginatedreader.TtsChunk
-import com.aryan.reader.shared.ReaderTtsReplacementEngine
-import com.aryan.reader.shared.ReaderTtsReplacementPreferences
-import com.aryan.reader.shared.ReaderTtsReplacementPreferencesJson
+import org.dueattendant149.bookreader.paginatedreader.TtsChunk
+import org.dueattendant149.bookreader.shared.ReaderTtsReplacementEngine
+import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences
+import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferencesJson
private const val READER_PREFS_NAME = "reader_prefs"
private const val TTS_REPLACEMENTS_KEY = "tts_word_replacements_json"
diff --git a/app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/TtsWordReplacementsSheet.kt
similarity index 98%
rename from app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/TtsWordReplacementsSheet.kt
index 75d2c7e..47ec692 100644
--- a/app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/TtsWordReplacementsSheet.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -56,11 +56,11 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import com.aryan.reader.shared.ReaderTtsReplacementBookSettings
-import com.aryan.reader.shared.ReaderTtsReplacementEngine
-import com.aryan.reader.shared.ReaderTtsReplacementPreferences
-import com.aryan.reader.shared.ReaderTtsReplacementRule
-import com.aryan.reader.shared.ReaderTtsReplacementSuggestions
+import org.dueattendant149.bookreader.shared.ReaderTtsReplacementBookSettings
+import org.dueattendant149.bookreader.shared.ReaderTtsReplacementEngine
+import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences
+import org.dueattendant149.bookreader.shared.ReaderTtsReplacementRule
+import org.dueattendant149.bookreader.shared.ReaderTtsReplacementSuggestions
private enum class TtsReplacementScope {
Global,
diff --git a/app/src/main/java/com/aryan/reader/UiLabelResources.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/UiLabelResources.kt
similarity index 97%
rename from app/src/main/java/com/aryan/reader/UiLabelResources.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/UiLabelResources.kt
index 5467cd1..9a280eb 100644
--- a/app/src/main/java/com/aryan/reader/UiLabelResources.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/UiLabelResources.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader
+package org.dueattendant149.bookreader
import androidx.annotation.StringRes
import java.text.Normalizer
@@ -67,7 +67,8 @@ val supportedAppLanguageOptions = listOf(
"中文",
"简体中文",
)
- )
+ ),
+ AppLanguageOption("et", R.string.language_estonian, listOf("estonian", "eesti"))
)
val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions
diff --git a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/AppDatabase.kt
similarity index 93%
rename from app/src/main/java/com/aryan/reader/data/AppDatabase.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/AppDatabase.kt
index 15a2799..5767976 100644
--- a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/AppDatabase.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
import android.content.Context
import androidx.room.Database
@@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
TagEntity::class,
BookTagCrossRef::class
],
- version = 22,
+ version = 23,
exportSchema = false
)
@TypeConverters(FileTypeConverter::class)
@@ -288,6 +288,25 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ val MIGRATION_22_23 = object : Migration(22, 23) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE recent_files ADD COLUMN readingPositionModifiedTimestamp INTEGER NOT NULL DEFAULT 0")
+ db.execSQL("""
+ UPDATE recent_files
+ SET readingPositionModifiedTimestamp = lastModifiedTimestamp
+ WHERE lastModifiedTimestamp > 0
+ AND (
+ lastChapterIndex IS NOT NULL OR
+ lastPage IS NOT NULL OR
+ lastPositionCfi IS NOT NULL OR
+ locatorBlockIndex IS NOT NULL OR
+ locatorCharOffset IS NOT NULL OR
+ COALESCE(progressPercentage, 0) > 0
+ )
+ """)
+ }
+ }
+
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
@@ -301,7 +320,7 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20,
- MIGRATION_20_21, MIGRATION_21_22
+ MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23
)
.fallbackToDestructiveMigration(false)
.build()
diff --git a/app/src/main/java/com/aryan/reader/data/BookMetadataEdit.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/BookMetadataEdit.kt
similarity index 79%
rename from app/src/main/java/com/aryan/reader/data/BookMetadataEdit.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/BookMetadataEdit.kt
index 3e403fe..6d37ea6 100644
--- a/app/src/main/java/com/aryan/reader/data/BookMetadataEdit.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/BookMetadataEdit.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
data class BookMetadataEdit(
val title: String?,
diff --git a/app/src/main/java/com/aryan/reader/data/CustomFontDao.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/CustomFontDao.kt
similarity index 97%
rename from app/src/main/java/com/aryan/reader/data/CustomFontDao.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/CustomFontDao.kt
index 2ce7dc2..4d77445 100644
--- a/app/src/main/java/com/aryan/reader/data/CustomFontDao.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/CustomFontDao.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
import androidx.room.Dao
import androidx.room.Insert
diff --git a/app/src/main/java/com/aryan/reader/data/CustomFontEntity.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/CustomFontEntity.kt
similarity index 96%
rename from app/src/main/java/com/aryan/reader/data/CustomFontEntity.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/CustomFontEntity.kt
index 6d1e77b..696a26b 100644
--- a/app/src/main/java/com/aryan/reader/data/CustomFontEntity.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/CustomFontEntity.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
import androidx.room.ColumnInfo
import androidx.room.Entity
diff --git a/app/src/main/java/com/aryan/reader/data/FileTypeConverter.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/FileTypeConverter.kt
similarity index 92%
rename from app/src/main/java/com/aryan/reader/data/FileTypeConverter.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/FileTypeConverter.kt
index 8819888..3ce67d3 100644
--- a/app/src/main/java/com/aryan/reader/data/FileTypeConverter.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/FileTypeConverter.kt
@@ -17,10 +17,10 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
import androidx.room.TypeConverter
-import com.aryan.reader.FileType
+import org.dueattendant149.bookreader.FileType
class FileTypeConverter {
@TypeConverter
diff --git a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/FolderBookMetadata.kt
similarity index 96%
rename from app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/FolderBookMetadata.kt
index 7d5a7a0..19b43a7 100644
--- a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/FolderBookMetadata.kt
@@ -1,8 +1,8 @@
// FolderBookMetadata.kt
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
-import com.aryan.reader.FileType
-import com.aryan.reader.shared.SharedFolderBookMetadata
+import org.dueattendant149.bookreader.FileType
+import org.dueattendant149.bookreader.shared.SharedFolderBookMetadata
data class FolderBookMetadata(
val bookId: String,
diff --git a/app/src/main/java/com/aryan/reader/data/FontsRepository.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/FontsRepository.kt
similarity index 71%
rename from app/src/main/java/com/aryan/reader/data/FontsRepository.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/FontsRepository.kt
index 8f96108..cdee799 100644
--- a/app/src/main/java/com/aryan/reader/data/FontsRepository.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/FontsRepository.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
import android.content.Context
import android.net.Uri
@@ -25,12 +25,15 @@ import android.provider.OpenableColumns
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
+import org.dueattendant149.bookreader.ReaderFontDiagnosticsTag
+import org.dueattendant149.bookreader.readerFontDiagnosticSummary
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
private const val FONTS_DIR = "custom_fonts"
+private const val MAX_IMPORTED_FONT_BASENAME_LENGTH = 120
class FontsRepository(private val context: Context) {
private val fontDao = AppDatabase.getDatabase(context).customFontDao()
@@ -73,14 +76,23 @@ class FontsRepository(private val context: Context) {
val contentResolver = context.contentResolver
val originalName = getFileName(uri) ?: "unknown.ttf"
val extension = originalName.substringAfterLast('.', "").lowercase()
+ val displayName = originalName.substringBeforeLast('.')
+
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "import.start originalName='$originalName' extension='$extension' " +
+ readerFontDiagnosticSummary(displayName)
+ )
if (extension !in listOf("ttf", "otf", "woff2")) {
+ Timber.tag(ReaderFontDiagnosticsTag).w(
+ "import.unsupported originalName='$originalName' extension='$extension'"
+ )
return@withContext Result.failure(Exception("Unsupported font format. Please use TTF, OTF, or WOFF2."))
}
val fontId = UUID.randomUUID().toString()
- val internalFileName = "font_${fontId}.$extension"
- val destinationFile = File(fontsDir, internalFileName)
+ val destinationFile = uniqueImportedFontFile(displayName, extension, fontId)
+ val internalFileName = destinationFile.name
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(destinationFile).use { output ->
@@ -88,8 +100,6 @@ class FontsRepository(private val context: Context) {
}
}
- val displayName = originalName.substringBeforeLast('.')
-
val entity = CustomFontEntity(
id = fontId,
displayName = displayName,
@@ -100,10 +110,16 @@ class FontsRepository(private val context: Context) {
)
fontDao.insertFont(entity)
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "import.saved displayName='$displayName' internalFileName='$internalFileName' " +
+ "exists=${destinationFile.exists()} bytes=${destinationFile.length()} " +
+ "path='${destinationFile.absolutePath}'"
+ )
Timber.d("Imported font: $displayName to ${destinationFile.absolutePath}")
Result.success(entity)
} catch (e: Exception) {
+ Timber.tag(ReaderFontDiagnosticsTag).e(e, "import.failed uri='$uri'")
Timber.e(e, "Failed to import font")
Result.failure(e)
}
@@ -130,6 +146,18 @@ class FontsRepository(private val context: Context) {
fontDao.deletePermanently(fontId)
}
+ private fun uniqueImportedFontFile(displayName: String, extension: String, fontId: String): File {
+ val preferredFileName = importedFontFileName(displayName, extension)
+ val preferredFile = File(fontsDir, preferredFileName)
+ if (!preferredFile.exists()) return preferredFile
+
+ val fallbackFileName = importedFontFileName(
+ displayName = "${displayName}_${fontId.take(8)}",
+ extension = extension
+ )
+ return File(fontsDir, fallbackFileName)
+ }
+
private fun getFileName(uri: Uri): String? {
var result: String? = null
if (uri.scheme == "content") {
@@ -152,4 +180,18 @@ class FontsRepository(private val context: Context) {
}
return result
}
-}
\ No newline at end of file
+}
+
+internal fun importedFontFileName(displayName: String, extension: String): String {
+ val safeBaseName = displayName
+ .replace(Regex("""[\\/:*?"<>|\p{Cntrl}]"""), "_")
+ .replace(Regex("""\s+"""), " ")
+ .trim(' ', '.')
+ .take(MAX_IMPORTED_FONT_BASENAME_LENGTH)
+ .ifBlank { "font" }
+ val safeExtension = extension
+ .lowercase()
+ .replace(Regex("""[^a-z0-9]"""), "")
+ .ifBlank { "ttf" }
+ return "$safeBaseName.$safeExtension"
+}
diff --git a/app/src/main/java/com/aryan/reader/data/LibraryDaos.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/LibraryDaos.kt
similarity index 98%
rename from app/src/main/java/com/aryan/reader/data/LibraryDaos.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/LibraryDaos.kt
index 8420722..8bdbefd 100644
--- a/app/src/main/java/com/aryan/reader/data/LibraryDaos.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/LibraryDaos.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
import androidx.room.Dao
import androidx.room.Insert
diff --git a/app/src/main/java/com/aryan/reader/data/LibraryEntities.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/LibraryEntities.kt
similarity index 97%
rename from app/src/main/java/com/aryan/reader/data/LibraryEntities.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/LibraryEntities.kt
index 322def1..b005dd6 100644
--- a/app/src/main/java/com/aryan/reader/data/LibraryEntities.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/LibraryEntities.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
import androidx.room.Entity
import androidx.room.ForeignKey
diff --git a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/LocalSyncUtils.kt
similarity index 95%
rename from app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/LocalSyncUtils.kt
index 889d5a1..90ccae3 100644
--- a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/LocalSyncUtils.kt
@@ -1,18 +1,19 @@
// LocalSyncUtils.kt
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
-import com.aryan.reader.ReaderPerfLog
-import com.aryan.reader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX
-import com.aryan.reader.shared.localFolderSyncAnnotationFileName
-import com.aryan.reader.shared.localFolderSyncAnnotationTempFileName
-import com.aryan.reader.shared.localFolderSyncMetadataFileName
-import com.aryan.reader.shared.localFolderSyncMetadataTempFileName
-import com.aryan.reader.shared.localFolderSyncSidecarStem
+import org.dueattendant149.bookreader.ReaderPerfLog
+import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX
+import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
+import org.dueattendant149.bookreader.shared.localFolderSyncAnnotationFileName
+import org.dueattendant149.bookreader.shared.localFolderSyncAnnotationTempFileName
+import org.dueattendant149.bookreader.shared.localFolderSyncMetadataFileName
+import org.dueattendant149.bookreader.shared.localFolderSyncMetadataTempFileName
+import org.dueattendant149.bookreader.shared.localFolderSyncSidecarStem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
@@ -21,7 +22,7 @@ import timber.log.Timber
object LocalSyncUtils {
private const val TAG = "FolderSync"
private const val ANNOTATION_SUFFIX = "_annotations"
- private const val SYNC_SUBFOLDER_NAME = "EpistemeSyncData"
+ private const val SYNC_SUBFOLDER_NAME = LOCAL_FOLDER_SYNC_DATA_DIR
private data class SyncFileEntry(
val name: String,
@@ -638,6 +639,21 @@ object LocalSyncUtils {
}
}
+ suspend fun deleteSyncDataFolder(
+ context: Context,
+ sourceFolderUri: Uri
+ ): Boolean = withContext(Dispatchers.IO) {
+ try {
+ val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext false
+ val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext true
+ if (!syncDir.isDirectory) return@withContext false
+ syncDir.delete()
+ } catch (e: Exception) {
+ Timber.tag(TAG).e(e, "Failed to delete sync data folder")
+ false
+ }
+ }
+
suspend fun getAllFolderMetadata(
context: Context,
sourceFolderUri: Uri
diff --git a/app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/PurchaseEntities.kt
similarity index 96%
rename from app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/PurchaseEntities.kt
index a3e41b6..4f3f49d 100644
--- a/app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/PurchaseEntities.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
/**
* Agnostic representation of a purchase to decouple MainViewModel from Billing Library.
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFileDao.kt
similarity index 93%
rename from app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFileDao.kt
index 08cc5fb..f1c45e7 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFileDao.kt
@@ -18,7 +18,7 @@
* mail: epistemereader@gmail.com
*/
// RecentFileDao.kt
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
import androidx.room.Dao
import androidx.room.Query
@@ -33,7 +33,7 @@ interface RecentFileDao {
@Upsert
suspend fun insertOrUpdateFiles(files: List)
- @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
+ @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, substr(description, 1, 4096) AS description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, substr(originalDescription, 1, 4096) AS originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
fun getRecentFiles(): Flow>
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
@@ -45,7 +45,7 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
- @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
+ @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, substr(description, 1, 4096) AS description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, substr(originalDescription, 1, 4096) AS originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
fun getRecentFilesList(limit: Int): List
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
@@ -75,10 +75,10 @@ interface RecentFileDao {
@Query("DELETE FROM recent_files")
suspend fun clearAll()
- @Query("UPDATE recent_files SET lastPositionCfi = :cfi, lastChapterIndex = :chapterIndex, locatorBlockIndex = :blockIndex, locatorCharOffset = :charOffset, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
+ @Query("UPDATE recent_files SET lastPositionCfi = :cfi, lastChapterIndex = :chapterIndex, locatorBlockIndex = :blockIndex, locatorCharOffset = :charOffset, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp, readingPositionModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updateEpubReadingPosition(bookId: String, cfi: String?, chapterIndex: Int, blockIndex: Int, charOffset: Int, progress: Float, timestamp: Long)
- @Query("UPDATE recent_files SET lastPage = :page, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
+ @Query("UPDATE recent_files SET lastPage = :page, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp, readingPositionModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updatePdfReadingPosition(bookId: String, page: Int, progress: Float, timestamp: Long)
@Query("UPDATE recent_files SET bookmarks = :bookmarksJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFileEntity.kt
similarity index 94%
rename from app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFileEntity.kt
index 26ace97..500398a 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFileEntity.kt
@@ -17,13 +17,13 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
-import com.aryan.reader.FileType
+import org.dueattendant149.bookreader.FileType
@Entity(tableName = "recent_files")
@TypeConverters(FileTypeConverter::class)
@@ -62,7 +62,8 @@ data class RecentFileEntity(
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
- @ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
+ @ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null,
+ @ColumnInfo(defaultValue = "0") val readingPositionModifiedTimestamp: Long = 0L
)
data class RecentFileSummary(
@@ -96,5 +97,6 @@ data class RecentFileSummary(
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
- @ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
+ @ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null,
+ @ColumnInfo(defaultValue = "0") val readingPositionModifiedTimestamp: Long = 0L
)
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFileItem.kt
similarity index 83%
rename from app/src/main/java/com/aryan/reader/data/RecentFileItem.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFileItem.kt
index 6276f79..3700fb1 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFileItem.kt
@@ -17,9 +17,9 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
-import com.aryan.reader.FileType
+import org.dueattendant149.bookreader.FileType
data class RecentFileItem(
val bookId: String,
@@ -57,9 +57,25 @@ data class RecentFileItem(
val originalDescription: String? = null,
val folderTextMetadataParsed: Boolean = false,
val folderCoverMetadataParsed: Boolean = false,
+ val readingPositionModifiedTimestamp: Long = 0L,
val tags: List = emptyList()
)
+fun RecentFileItem.hasReadingPositionForSync(): Boolean {
+ return lastChapterIndex != null ||
+ lastPage != null ||
+ !lastPositionCfi.isNullOrBlank() ||
+ locatorBlockIndex != null ||
+ locatorCharOffset != null ||
+ (progressPercentage ?: 0f) > 0f
+}
+
+fun RecentFileItem.effectiveReadingPositionModifiedTimestamp(): Long {
+ return readingPositionModifiedTimestamp.takeIf { it > 0L }
+ ?: lastModifiedTimestamp.takeIf { hasReadingPositionForSync() }
+ ?: 0L
+}
+
fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
return RecentFileItem(
bookId = this.bookId,
@@ -96,7 +112,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
originalSeriesIndex = this.originalSeriesIndex,
originalDescription = this.originalDescription,
folderTextMetadataParsed = this.folderTextMetadataParsed,
- folderCoverMetadataParsed = this.folderCoverMetadataParsed
+ folderCoverMetadataParsed = this.folderCoverMetadataParsed,
+ readingPositionModifiedTimestamp = this.readingPositionModifiedTimestamp
)
}
@@ -136,7 +153,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex,
originalDescription = this.originalDescription ?: this.description,
folderTextMetadataParsed = this.folderTextMetadataParsed,
- folderCoverMetadataParsed = this.folderCoverMetadataParsed
+ folderCoverMetadataParsed = this.folderCoverMetadataParsed,
+ readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp()
)
}
@@ -156,6 +174,7 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
isRecent = this.isRecent,
isDeleted = this.isDeleted,
lastModifiedTimestamp = this.lastModifiedTimestamp,
+ readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp(),
bookmarksJson = this.bookmarksJson,
hasAnnotations = false,
customName = this.customName,
@@ -172,6 +191,27 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
)
}
+fun BookMetadata.hasReadingPositionForSync(): Boolean {
+ return lastChapterIndex != null ||
+ lastPage != null ||
+ !lastPositionCfi.isNullOrBlank() ||
+ locatorBlockIndex != null ||
+ locatorCharOffset != null ||
+ (progressPercentage ?: 0f) > 0f
+}
+
+fun BookMetadata.effectiveReadingPositionModifiedTimestamp(): Long {
+ return readingPositionModifiedTimestamp.takeIf { it > 0L }
+ ?: lastModifiedTimestamp.takeIf { hasReadingPositionForSync() }
+ ?: 0L
+}
+
+fun BookMetadata.effectiveAnnotationModifiedTimestamp(sidecarModifiedTimestamp: Long = 0L): Long {
+ return sidecarModifiedTimestamp.takeIf { it > 0L }
+ ?: annotationModifiedTimestamp.takeIf { it > 0L }
+ ?: 0L
+}
+
fun BookMetadata.toRecentFileItem(): RecentFileItem {
return RecentFileItem(
bookId = this.bookId,
@@ -203,7 +243,8 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem {
originalAuthor = this.originalAuthor,
originalSeriesName = this.originalSeriesName,
originalSeriesIndex = this.originalSeriesIndex,
- originalDescription = this.originalDescription
+ originalDescription = this.originalDescription,
+ readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp()
)
}
@@ -241,6 +282,7 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem {
originalAuthor = this.originalAuthor,
originalSeriesName = this.originalSeriesName,
originalSeriesIndex = this.originalSeriesIndex,
- originalDescription = this.originalDescription
+ originalDescription = this.originalDescription,
+ readingPositionModifiedTimestamp = this.readingPositionModifiedTimestamp
)
}
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileItemAndroid.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFileItemAndroid.kt
similarity index 71%
rename from app/src/main/java/com/aryan/reader/data/RecentFileItemAndroid.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFileItemAndroid.kt
index 76d5aae..d8b87c5 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFileItemAndroid.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFileItemAndroid.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
import android.net.Uri
import androidx.core.net.toUri
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFilesRepository.kt
similarity index 86%
rename from app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFilesRepository.kt
index 673485e..dcbd7de 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/RecentFilesRepository.kt
@@ -18,21 +18,27 @@
* mail: epistemereader@gmail.com
*/
// RecentFilesRepository.kt
-package com.aryan.reader.data
+package org.dueattendant149.bookreader.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.core.net.toUri
-import com.aryan.reader.FileType
-import com.aryan.reader.ReaderPerfLog
-import com.aryan.reader.scaledToCanvasLimit
+import org.dueattendant149.bookreader.FileType
+import org.dueattendant149.bookreader.ReaderPerfLog
+import org.dueattendant149.bookreader.SyncedFolderPrefs
+import org.dueattendant149.bookreader.cloudSyncPreview
+import org.dueattendant149.bookreader.cloudSyncTraceSummary
+import org.dueattendant149.bookreader.logCloudAnnotationSyncTrace
+import org.dueattendant149.bookreader.logCloudSyncTrace
+import org.dueattendant149.bookreader.scaledToCanvasLimit
import timber.log.Timber
-import com.aryan.reader.BookImporter
-import com.aryan.reader.paginatedreader.Locator
-import com.aryan.reader.pdf.PdfRichTextRepository
-import com.aryan.reader.epub.ImportedFileCache
+import org.dueattendant149.bookreader.BookImporter
+import org.dueattendant149.bookreader.cloudSyncAnnotationSummary
+import org.dueattendant149.bookreader.paginatedreader.Locator
+import org.dueattendant149.bookreader.pdf.PdfRichTextRepository
+import org.dueattendant149.bookreader.epub.ImportedFileCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -40,10 +46,10 @@ import kotlinx.coroutines.withContext
import androidx.room.withTransaction
import java.io.File
import java.io.FileOutputStream
-import com.aryan.reader.pdf.data.PdfAnnotationRepository
-import com.aryan.reader.pdf.data.PageLayoutRepository
-import com.aryan.reader.pdf.data.PdfTextBoxRepository
-import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
+import org.dueattendant149.bookreader.pdf.data.PdfAnnotationRepository
+import org.dueattendant149.bookreader.pdf.data.PageLayoutRepository
+import org.dueattendant149.bookreader.pdf.data.PdfTextBoxRepository
+import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec
import org.json.JSONObject
import org.json.JSONArray
import java.util.UUID
@@ -65,7 +71,7 @@ class RecentFilesRepository(private val context: Context) {
private val pdfRichTextRepository = PdfRichTextRepository(context)
private val pageLayoutRepository = PageLayoutRepository(context)
private val pdfTextBoxRepository = PdfTextBoxRepository(context)
- private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context)
+ private val pdfHighlightRepository = org.dueattendant149.bookreader.pdf.data.PdfHighlightRepository(context)
val activeShelvesFlow = database.shelfDao().getAllActiveShelves()
val shelfCrossRefsFlow = database.shelfDao().getAllBookShelfCrossRefs()
@@ -189,7 +195,20 @@ class RecentFilesRepository(private val context: Context) {
!embeddedMetadataFileChanged &&
existingItem.hasEmbeddedMetadataChanges()
- item.toRecentFileEntity().copy(
+ val incomingEntity = item.toRecentFileEntity()
+ val incomingReadingTimestamp = item.effectiveReadingPositionModifiedTimestamp()
+ val existingReadingTimestamp = existingItem.readingPositionModifiedTimestamp.takeIf { it > 0L }
+ ?: existingItem.lastModifiedTimestamp.takeIf {
+ existingItem.lastChapterIndex != null ||
+ existingItem.lastPage != null ||
+ !existingItem.lastPositionCfi.isNullOrBlank() ||
+ existingItem.locatorBlockIndex != null ||
+ existingItem.locatorCharOffset != null ||
+ (existingItem.progressPercentage ?: 0f) > 0f
+ }
+ ?: 0L
+ val incomingReadingWins = incomingReadingTimestamp >= existingReadingTimestamp
+ incomingEntity.copy(
uriString = existingItem.uriString ?: item.uriString,
isAvailable = existingItem.isAvailable || item.isAvailable,
coverImagePath = if (folderFileChanged) {
@@ -211,13 +230,13 @@ class RecentFilesRepository(private val context: Context) {
} else {
item.author ?: existingItem.author
},
- lastChapterIndex = item.lastChapterIndex ?: existingItem.lastChapterIndex,
- lastPage = item.lastPage ?: existingItem.lastPage,
- lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi,
- locatorBlockIndex = item.locatorBlockIndex ?: existingItem.locatorBlockIndex,
- locatorCharOffset = item.locatorCharOffset ?: existingItem.locatorCharOffset,
+ lastChapterIndex = if (incomingReadingWins) item.lastChapterIndex ?: existingItem.lastChapterIndex else existingItem.lastChapterIndex,
+ lastPage = if (incomingReadingWins) item.lastPage ?: existingItem.lastPage else existingItem.lastPage,
+ lastPositionCfi = if (incomingReadingWins) item.lastPositionCfi ?: existingItem.lastPositionCfi else existingItem.lastPositionCfi,
+ locatorBlockIndex = if (incomingReadingWins) item.locatorBlockIndex ?: existingItem.locatorBlockIndex else existingItem.locatorBlockIndex,
+ locatorCharOffset = if (incomingReadingWins) item.locatorCharOffset ?: existingItem.locatorCharOffset else existingItem.locatorCharOffset,
bookmarks = item.bookmarksJson ?: existingItem.bookmarks,
- progressPercentage = item.progressPercentage ?: existingItem.progressPercentage,
+ progressPercentage = if (incomingReadingWins) item.progressPercentage ?: existingItem.progressPercentage else existingItem.progressPercentage,
isRecent = item.isRecent,
isDeleted = item.isDeleted,
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
@@ -259,13 +278,26 @@ class RecentFilesRepository(private val context: Context) {
item.folderCoverMetadataParsed
} else {
item.folderCoverMetadataParsed || existingItem.folderCoverMetadataParsed
- }
+ },
+ readingPositionModifiedTimestamp = maxOf(incomingReadingTimestamp, existingReadingTimestamp)
)
} else {
item.toRecentFileEntity()
}
Timber.d("SyncDebug: -> Final entity to insert: uri='${entityToInsert.uriString}', isAvailable=${entityToInsert.isAvailable}, isDeleted=${entityToInsert.isDeleted}, isRecent=${entityToInsert.isRecent}")
+ logCloudSyncTrace {
+ "android.db.upsert book=${item.bookId} ${item.cloudSyncTraceSummary("incoming")} " +
+ "existingTs=${existingItem?.lastModifiedTimestamp} existingPage=${existingItem?.lastPage} " +
+ "existingReadTs=${existingItem?.readingPositionModifiedTimestamp} " +
+ "existingChapter=${existingItem?.lastChapterIndex} finalTs=${entityToInsert.lastModifiedTimestamp} " +
+ "finalReadTs=${entityToInsert.readingPositionModifiedTimestamp} " +
+ "finalPage=${entityToInsert.lastPage} finalChapter=${entityToInsert.lastChapterIndex} " +
+ "finalBlock=${entityToInsert.locatorBlockIndex} finalChar=${entityToInsert.locatorCharOffset} " +
+ "finalProgress=${entityToInsert.progressPercentage} finalCfi=${entityToInsert.lastPositionCfi.cloudSyncPreview()} " +
+ "finalBookmarks=${entityToInsert.bookmarks.cloudSyncAnnotationSummary()} " +
+ "finalHighlights=${entityToInsert.highlights.cloudSyncAnnotationSummary()}"
+ }
recentFileDao.insertOrUpdateFile(entityToInsert)
Timber.d("Added/Updated recent file in DB: ${item.displayName}")
}
@@ -328,6 +360,11 @@ class RecentFilesRepository(private val context: Context) {
val folderUriString = entity.sourceFolderUri
if (folderUriString != null) {
+ if (!isLocalFolderSyncEnabled(folderUriString)) {
+ Timber.d("SyncDebug: Folder sync disabled for $folderUriString. Skipping metadata sidecar.")
+ return@withContext
+ }
+
val hasProgress = (entity.progressPercentage != null && entity.progressPercentage > 0f)
val hasBookmarks = !entity.bookmarks.isNullOrEmpty() && entity.bookmarks != "[]"
val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]"
@@ -385,6 +422,10 @@ class RecentFilesRepository(private val context: Context) {
Timber.tag("FolderAnnotationSync").w("sourceFolderUri is null for bookId: $bookId")
return@withContext
}
+ if (!isLocalFolderSyncEnabled(folderUriString)) {
+ Timber.tag("FolderAnnotationSync").d("Folder sync disabled for $folderUriString. Skipping annotation sidecar.")
+ return@withContext
+ }
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId)
val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
@@ -466,12 +507,20 @@ class RecentFilesRepository(private val context: Context) {
)
}
- suspend fun importAnnotationBundle(bookId: String, jsonString: String) = withContext(Dispatchers.IO) {
+ suspend fun importAnnotationBundle(
+ bookId: String,
+ jsonString: String,
+ lastModifiedTimestamp: Long? = null
+ ) = withContext(Dispatchers.IO) {
Timber.tag("FolderAnnotationSync").d("importAnnotationBundle: Processing bundle for $bookId")
try {
val bundle = JSONObject(
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
)
+ logCloudAnnotationSyncTrace {
+ "android.repository.import_bundle book=$bookId remoteTs=${lastModifiedTimestamp ?: 0L} " +
+ "rawBytes=${jsonString.length} keys=${bundle.keys().asSequence().toList()}"
+ }
Timber.d(
"android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " +
"hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
@@ -482,12 +531,22 @@ class RecentFilesRepository(private val context: Context) {
file.parentFile?.mkdirs()
val contentStr = bundle.get(key).toString()
file.writeText(contentStr)
+ lastModifiedTimestamp?.takeIf { it > 0L }?.let(file::setLastModified)
+ logCloudAnnotationSyncTrace {
+ "android.repository.import_write key=$key book=$bookId bytes=${contentStr.length} " +
+ "path=${file.absolutePath.cloudSyncPreview(140)} ts=${file.lastModified()}"
+ }
if (key == "text") {
Timber.d(
"android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}"
)
}
Timber.tag("FolderAnnotationSync").v(" -> Updated $key file (${contentStr.length} chars)")
+ } else if (file != null) {
+ logCloudAnnotationSyncTrace {
+ "android.repository.import_missing_key key=$key book=$bookId " +
+ "path=${file.absolutePath.cloudSyncPreview(140)} exists=${file.exists()}"
+ }
}
}
@@ -496,6 +555,10 @@ class RecentFilesRepository(private val context: Context) {
context.filesDir, "annotations/annotation_$bookId.json"
)
writeSafe("ink", inkFile)
+ writeSafe(
+ SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS,
+ File(context.filesDir, "annotations/deleted_annotation_$bookId.json")
+ )
// 2. Text
writeSafe("text", pdfRichTextRepository.getFileForSync(bookId))
@@ -606,6 +669,15 @@ class RecentFilesRepository(private val context: Context) {
Timber.d("Detached all folder books. They are now standard local files.")
}
+ private fun isLocalFolderSyncEnabled(folderUriString: String): Boolean {
+ val prefs = context.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
+ return SyncedFolderPrefs.isLocalSyncEnabled(
+ jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
+ legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
+ folderUriString = folderUriString
+ )
+ }
+
suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) {
val currentTime = System.currentTimeMillis()
recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime)
@@ -618,6 +690,9 @@ class RecentFilesRepository(private val context: Context) {
val currentTime = System.currentTimeMillis()
recentFileDao.updatePdfReadingPosition(item.bookId, page, progress, currentTime)
Timber.tag("PdfPositionDebug").i("Repository: Executed DB update for ${item.bookId} to Page $page, Progress $progress% at TS: $currentTime")
+ logCloudSyncTrace {
+ "android.repository.pdf_position_update book=${item.bookId} page=$page progress=$progress ts=$currentTime"
+ }
} else {
Timber.tag("PdfPositionDebug").e("Repository: DB Update Failed! No recent file found matching URI: $uriString")
}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/data/SmartCollectionEngine.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/data/SmartCollectionEngine.kt
new file mode 100644
index 0000000..270e3b0
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/data/SmartCollectionEngine.kt
@@ -0,0 +1,20 @@
+package org.dueattendant149.bookreader.data
+
+import org.dueattendant149.bookreader.toSharedBookItem
+import org.dueattendant149.bookreader.shared.SmartCollectionEngine as SharedSmartCollectionEngine
+
+typealias SmartField = org.dueattendant149.bookreader.shared.SmartField
+typealias SmartOperator = org.dueattendant149.bookreader.shared.SmartOperator
+typealias SmartRule = org.dueattendant149.bookreader.shared.SmartRule
+typealias SmartCollectionDefinition = org.dueattendant149.bookreader.shared.SmartCollectionDefinition
+
+object SmartCollectionEngine {
+ fun toJson(definition: SmartCollectionDefinition): String =
+ SharedSmartCollectionEngine.toJson(definition)
+
+ fun fromJson(json: String?): SmartCollectionDefinition? =
+ SharedSmartCollectionEngine.fromJson(json)
+
+ fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean =
+ SharedSmartCollectionEngine.evaluate(book.toSharedBookItem(), definition)
+}
diff --git a/app/src/main/java/com/aryan/reader/epub/BitmapSerializer.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/BitmapSerializer.kt
similarity index 97%
rename from app/src/main/java/com/aryan/reader/epub/BitmapSerializer.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epub/BitmapSerializer.kt
index a94dd51..3a5e736 100644
--- a/app/src/main/java/com/aryan/reader/epub/BitmapSerializer.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/BitmapSerializer.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.epub
+package org.dueattendant149.bookreader.epub
import android.graphics.Bitmap
import android.graphics.BitmapFactory
diff --git a/app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/CalibreBundleExtractor.kt
similarity index 86%
rename from app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epub/CalibreBundleExtractor.kt
index 12a06e4..5387a2e 100644
--- a/app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/CalibreBundleExtractor.kt
@@ -1,13 +1,13 @@
// CalibreBundleExtractor.kt
-package com.aryan.reader.epub
+package org.dueattendant149.bookreader.epub
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.core.net.toUri
-import com.aryan.reader.BookImporter
-import com.aryan.reader.FileType
-import com.aryan.reader.data.RecentFilesRepository
+import org.dueattendant149.bookreader.BookImporter
+import org.dueattendant149.bookreader.FileType
+import org.dueattendant149.bookreader.data.RecentFilesRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.w3c.dom.Element
@@ -15,8 +15,8 @@ import timber.log.Timber
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileOutputStream
+import java.io.IOException
import java.util.zip.ZipInputStream
-import javax.xml.parsers.DocumentBuilderFactory
data class CalibreBundleResult(
val internalBookUri: Uri,
@@ -89,7 +89,7 @@ object CalibreBundleExtractor {
if (tempBookFile != null && opfData != null && extractedType != null) {
val finalBookFile = bookImporter.createBookFile("$bookId.$ext")
- tempBookFile!!.renameTo(finalBookFile)
+ moveExtractedBook(tempBookFile!!, finalBookFile)
var coverPath: String? = null
if (coverBytes != null) {
@@ -106,7 +106,7 @@ object CalibreBundleExtractor {
var seriesIndex: Double? = null
try {
- val factory = DocumentBuilderFactory.newInstance()
+ val factory = secureDocumentBuilderFactory()
val builder = factory.newDocumentBuilder()
val document = builder.parse(ByteArrayInputStream(opfData!!.toByteArray(Charsets.UTF_8)))
val metadataNodes = document.getElementsByTagName("metadata")
@@ -159,8 +159,25 @@ object CalibreBundleExtractor {
} catch (e: Exception) {
Timber.e(e, "Failed to process zip bundle")
} finally {
- tempBookFile?.delete() // Cleanup if parsing failed midway
+ tempBookFile?.takeIf { it.exists() }?.delete()
}
return@withContext null
}
-}
\ No newline at end of file
+
+ private fun moveExtractedBook(tempBookFile: File, finalBookFile: File) {
+ finalBookFile.parentFile?.mkdirs()
+ if (finalBookFile.exists() && !finalBookFile.delete()) {
+ throw IOException("Could not replace existing book file: ${finalBookFile.absolutePath}")
+ }
+ if (tempBookFile.renameTo(finalBookFile)) return
+
+ tempBookFile.inputStream().use { input ->
+ FileOutputStream(finalBookFile).use { output ->
+ input.copyTo(output)
+ }
+ }
+ if (!finalBookFile.isFile) {
+ throw IOException("Could not move extracted book to: ${finalBookFile.absolutePath}")
+ }
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/epub/EpubBook.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubBook.kt
similarity index 95%
rename from app/src/main/java/com/aryan/reader/epub/EpubBook.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubBook.kt
index ad07f81..1f2fe85 100644
--- a/app/src/main/java/com/aryan/reader/epub/EpubBook.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubBook.kt
@@ -17,10 +17,10 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.epub
+package org.dueattendant149.bookreader.epub
import android.graphics.Bitmap
-import com.aryan.reader.epub.EpubParser.EpubPageTarget
+import org.dueattendant149.bookreader.epub.EpubParser.EpubPageTarget
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.io.File
diff --git a/app/src/main/java/com/aryan/reader/epub/EpubChapter.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubChapter.kt
similarity index 83%
rename from app/src/main/java/com/aryan/reader/epub/EpubChapter.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubChapter.kt
index ad80edf..8cf739c 100644
--- a/app/src/main/java/com/aryan/reader/epub/EpubChapter.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubChapter.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.epub
+package org.dueattendant149.bookreader.epub
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
@@ -33,5 +33,10 @@ data class EpubChapter @OptIn(ExperimentalSerializationApi::class) constructor(
@ProtoNumber(5) val plainTextContent: String,
@ProtoNumber(6) val htmlContent: String,
@ProtoNumber(7) val depth: Int = 0,
- @ProtoNumber(8) val isInToc: Boolean = true
-)
\ No newline at end of file
+ @ProtoNumber(8) val isInToc: Boolean = true,
+ @ProtoNumber(9) val plainTextLength: Int = plainTextContent.length
+)
+
+fun EpubChapter.plainTextCharacterCount(): Int {
+ return maxOf(plainTextLength, plainTextContent.length)
+}
diff --git a/app/src/main/java/com/aryan/reader/epub/EpubImage.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubImage.kt
similarity index 96%
rename from app/src/main/java/com/aryan/reader/epub/EpubImage.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubImage.kt
index 2fd2802..bbe9990 100644
--- a/app/src/main/java/com/aryan/reader/epub/EpubImage.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubImage.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.epub
+package org.dueattendant149.bookreader.epub
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
diff --git a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubParser.kt
similarity index 96%
rename from app/src/main/java/com/aryan/reader/epub/EpubParser.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubParser.kt
index 716fba2..44ffc27 100644
--- a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubParser.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.epub
+package org.dueattendant149.bookreader.epub
import android.content.Context
import android.graphics.Bitmap
@@ -457,19 +457,17 @@ class EpubParser(private val context: Context) {
var pageTargets: List = emptyList()
val ncxMetadataMap = mutableMapOf()
val extractionRoot = File(extractionBasePath)
+ val tocFileItem = if (shouldUseToc) resolveTocFileItem(document.spine, manifestItems) else null
+ val tocDocumentNode = tocFileItem?.let { item ->
+ val ncxData = filesContentMap[item.absPath]?.data?.takeIf { it.isNotEmpty() }
+ ?: File(extractionRoot, item.absPath).takeIf { it.exists() }?.readBytes()
+ ncxData?.let { parseXMLFile(it) }
+ }
+ val ncxParentDir = tocFileItem?.let { File(it.absPath).parentFile ?: File("") }
if (shouldUseToc) {
Timber.d("shouldUseToc is true. Attempting to parse NCX.")
- val tocFileItem = manifestItems.values.firstOrNull {
- it.absPath.endsWith(".ncx", ignoreCase = true)
- }
- if (tocFileItem != null) {
- val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
- val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
- ?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
-
- val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
-
+ if (tocFileItem != null && ncxParentDir != null) {
if (tocDocumentNode != null) {
Timber.d("Successfully parsed NCX file: ${tocFileItem.absPath}")
val pageListElement = tocDocumentNode.selectFirstTag("pageList") as Element?
@@ -495,15 +493,8 @@ class EpubParser(private val context: Context) {
Timber.d("Parsing chapters based on OPF spine for rendering order. NCX titles/depth will be used if available.")
val tableOfContents = if (shouldUseToc) {
- val tocFileItem = manifestItems.values.firstOrNull {
- it.absPath.endsWith(".ncx", ignoreCase = true)
- }
- if (tocFileItem != null) {
- val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
- val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
- ?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
- val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
- val navMapElement = tocDocumentNode?.selectFirstTag("navMap") as Element?
+ if (tocDocumentNode != null && ncxParentDir != null) {
+ val navMapElement = tocDocumentNode.selectFirstTag("navMap") as Element?
if (navMapElement != null) {
parseTableOfContents(navMapElement, ncxParentDir)
@@ -592,6 +583,21 @@ class EpubParser(private val context: Context) {
return result
}
+ private fun resolveTocFileItem(
+ spine: Node,
+ manifestItems: Map
+ ): EpubManifestItem? {
+ spine.getAttributeValue("toc")
+ ?.takeIf { it.isNotBlank() }
+ ?.let { tocId -> manifestItems[tocId] }
+ ?.let { return it }
+
+ return manifestItems.values.firstOrNull {
+ it.mediaType.equals("application/x-dtbncx+xml", ignoreCase = true)
+ } ?: manifestItems.values.firstOrNull {
+ it.absPath.endsWith(".ncx", ignoreCase = true)
+ }
+ }
@Throws(EpubParserException::class)
private fun createEpubDocument(files: Map): EpubDocument {
diff --git a/app/src/main/java/com/aryan/reader/epub/EpubParserException.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubParserException.kt
similarity index 95%
rename from app/src/main/java/com/aryan/reader/epub/EpubParserException.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubParserException.kt
index b9b0d96..9ff63f3 100644
--- a/app/src/main/java/com/aryan/reader/epub/EpubParserException.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubParserException.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.epub
+package org.dueattendant149.bookreader.epub
/**
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubUtils.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubUtils.kt
new file mode 100644
index 0000000..2810710
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubUtils.kt
@@ -0,0 +1,75 @@
+/*
+ * Episteme Reader - A native Android document reader.
+ * Copyright (C) 2026 Episteme
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * mail: epistemereader@gmail.com
+ */
+package org.dueattendant149.bookreader.epub
+
+import org.w3c.dom.Document
+import org.w3c.dom.Element
+import org.w3c.dom.Node
+import org.w3c.dom.NodeList
+import java.io.File
+import java.io.InputStream
+import javax.xml.XMLConstants
+import javax.xml.parsers.DocumentBuilderFactory
+
+fun parseXMLFile(inputSteam: InputStream): Document? =
+ secureDocumentBuilderFactory().newDocumentBuilder().parse(inputSteam)
+
+fun parseXMLFile(byteArray: ByteArray): Document? = parseXMLFile(byteArray.inputStream())
+
+fun String.asFileName(): String = this.replace("/", "_")
+
+internal fun secureDocumentBuilderFactory(): DocumentBuilderFactory {
+ return DocumentBuilderFactory.newInstance().apply {
+ isNamespaceAware = false
+ setFeatureSafely(XMLConstants.FEATURE_SECURE_PROCESSING, true)
+ setFeatureSafely("http://apache.org/xml/features/disallow-doctype-decl", true)
+ setFeatureSafely("http://xml.org/sax/features/external-general-entities", false)
+ setFeatureSafely("http://xml.org/sax/features/external-parameter-entities", false)
+ setFeatureSafely("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
+ runCatching { isXIncludeAware = false }
+ runCatching { isExpandEntityReferences = false }
+ }
+}
+
+private fun DocumentBuilderFactory.setFeatureSafely(name: String, value: Boolean) {
+ runCatching { setFeature(name, value) }
+}
+
+internal fun safeFileInRoot(root: File, childPath: String): File? {
+ val rootFile = runCatching { root.canonicalFile }.getOrNull() ?: return null
+ val targetFile = runCatching { File(rootFile, childPath).canonicalFile }.getOrNull() ?: return null
+ return targetFile.takeIf { it.isInsideOrSame(rootFile) }
+}
+
+internal fun File.isInsideOrSame(root: File): Boolean {
+ val rootPath = runCatching { root.canonicalFile.path }.getOrNull() ?: return false
+ val targetPath = runCatching { canonicalFile.path }.getOrNull() ?: return false
+ return targetPath == rootPath || targetPath.startsWith(rootPath + File.separator)
+}
+
+fun Document.selectFirstTag(tag: String): Node? = getElementsByTagName(tag).item(0)
+fun Node.selectFirstChildTag(tag: String) = childElements.find { it.tagName == tag }
+fun Node.selectChildTag(tag: String) = childElements.filter { it.tagName == tag }
+fun Node.getAttributeValue(attribute: String): String? =
+ attributes?.getNamedItem(attribute)?.textContent
+
+val NodeList.elements get() = (0 until length).asSequence().mapNotNull { item(it) as? Element }
+val Node.childElements get() = childNodes.elements
+
diff --git a/app/src/main/java/com/aryan/reader/epub/EpubXMLFileParser.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubXMLFileParser.kt
similarity index 98%
rename from app/src/main/java/com/aryan/reader/epub/EpubXMLFileParser.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubXMLFileParser.kt
index 0fb9764..532ad77 100644
--- a/app/src/main/java/com/aryan/reader/epub/EpubXMLFileParser.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/EpubXMLFileParser.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.epub
+package org.dueattendant149.bookreader.epub
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
diff --git a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/Fb2Parser.kt
similarity index 85%
rename from app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epub/Fb2Parser.kt
index 458b37f..dfc2003 100644
--- a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/Fb2Parser.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader.epub
+package org.dueattendant149.bookreader.epub
import android.content.Context
import android.graphics.BitmapFactory
@@ -12,6 +12,7 @@ import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
+import java.security.MessageDigest
import java.util.zip.ZipInputStream
class Fb2Parser(private val context: Context) {
@@ -210,7 +211,12 @@ class Fb2Parser(private val context: Context) {
if (!inBody) {
if (coverImageId == null) coverImageId = id
} else {
- currentChapterHtml.append("
")
+ val safeImageName = safeResourceFileName(id)
+ if (safeImageName != null) {
+ currentChapterHtml.append("
")
+ } else {
+ Timber.w("Skipping unsafe FB2 image reference: $id")
+ }
}
}
}
@@ -220,12 +226,23 @@ class Fb2Parser(private val context: Context) {
val base64Data = parser.nextText()
try {
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
+ val safeId = safeResourceFileName(id)
if (parseContent) {
- val imgFile = File(extractionDir, id)
- FileOutputStream(imgFile).use { it.write(bytes) }
+ if (safeId != null) {
+ val imgFile = safeFileInRoot(extractionDir, safeId)
+ if (imgFile != null) {
+ FileOutputStream(imgFile).use { it.write(bytes) }
+ } else {
+ Timber.w("Skipping unsafe FB2 binary path: $id")
+ }
+ } else {
+ Timber.w("Skipping unsafe FB2 binary id: $id")
+ }
}
- images.add(EpubImage(absPath = id))
+ if (safeId != null) {
+ images.add(EpubImage(absPath = safeId))
+ }
if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) {
coverBytes = bytes
@@ -326,4 +343,30 @@ class Fb2Parser(private val context: Context) {
}
}
}
+
+ private fun safeResourceFileName(id: String): String? {
+ val rawName = id.substringAfterLast('/').substringAfterLast('\\').trim()
+ if (rawName.isBlank() || rawName == "." || rawName == "..") return null
+
+ val extension = rawName.substringAfterLast('.', missingDelimiterValue = "")
+ .takeIf { it.isNotBlank() && it.length <= 12 }
+ ?.replace(Regex("[^A-Za-z0-9]"), "")
+ .orEmpty()
+ val baseName = rawName.substringBeforeLast('.', rawName)
+ .replace(Regex("[^A-Za-z0-9._-]+"), "_")
+ .trim('.', '_', '-')
+ .ifBlank { "image" }
+ .take(48)
+ val suffix = sha256Hex(id).take(12)
+ return if (extension.isBlank()) {
+ "${baseName}_$suffix"
+ } else {
+ "${baseName}_$suffix.$extension"
+ }
+ }
+
+ private fun sha256Hex(value: String): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
+ return digest.joinToString("") { "%02x".format(it) }
+ }
}
diff --git a/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/ImportedFileCache.kt
similarity index 98%
rename from app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epub/ImportedFileCache.kt
index 5199091..a79104f 100644
--- a/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/ImportedFileCache.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader.epub
+package org.dueattendant149.bookreader.epub
import android.content.Context
import java.io.File
diff --git a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/MobiParser.kt
similarity index 99%
rename from app/src/main/java/com/aryan/reader/epub/MobiParser.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epub/MobiParser.kt
index 35fb60f..870d882 100644
--- a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/MobiParser.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.epub
+package org.dueattendant149.bookreader.epub
import android.content.Context
import android.graphics.BitmapFactory
diff --git a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/OdtParser.kt
similarity index 89%
rename from app/src/main/java/com/aryan/reader/epub/OdtParser.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epub/OdtParser.kt
index 4451f12..2dbb1d0 100644
--- a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/OdtParser.kt
@@ -1,4 +1,4 @@
-package com.aryan.reader.epub
+package org.dueattendant149.bookreader.epub
import android.content.Context
import android.graphics.BitmapFactory
@@ -41,7 +41,7 @@ class OdtParser(private val context: Context) {
val mathJaxFileName = "tex-mml-chtml.js"
val mathJaxFile = File(extractionDir, mathJaxFileName)
- if (!mathJaxFile.exists()) {
+ if (parseContent && !mathJaxFile.exists()) {
try {
context.assets.open("mathjax/$mathJaxFileName").use { input ->
FileOutputStream(mathJaxFile).use { output ->
@@ -170,28 +170,33 @@ class OdtParser(private val context: Context) {
try {
if (!isFlat) {
- val zis = ZipInputStream(inputStream)
- var entry = zis.nextEntry
var contentXmlBytes: ByteArray? = null
var stylesXmlBytes: ByteArray? = null
val ignoredFiles = setOf("meta.xml", "settings.xml", "META-INF/manifest.xml")
- while (entry != null) {
- if (!entry.isDirectory) {
- when (entry.name) {
- "content.xml" -> contentXmlBytes = zis.readBytes()
- "styles.xml" -> stylesXmlBytes = zis.readBytes()
- "Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes()
- else -> {
- if (entry.name !in ignoredFiles) {
- val extractedFile = File(extractionDir, entry.name)
- extractedFile.parentFile?.mkdirs()
- FileOutputStream(extractedFile).use { out -> zis.copyTo(out) }
+ ZipInputStream(inputStream).use { zis ->
+ var entry = zis.nextEntry
+ while (entry != null) {
+ if (!entry.isDirectory) {
+ when (entry.name) {
+ "content.xml" -> contentXmlBytes = zis.readBytes()
+ "styles.xml" -> stylesXmlBytes = zis.readBytes()
+ "Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes()
+ else -> {
+ if (entry.name !in ignoredFiles) {
+ val extractedFile = safeFileInRoot(extractionDir, entry.name)
+ if (extractedFile != null) {
+ extractedFile.parentFile?.mkdirs()
+ FileOutputStream(extractedFile).use { out -> zis.copyTo(out) }
+ } else {
+ Timber.w("Skipping unsafe ODT entry outside extraction root: ${entry.name}")
+ }
+ }
}
}
}
+ entry = zis.nextEntry
}
- entry = zis.nextEntry
}
// Pre-parse styles if available
@@ -371,10 +376,18 @@ class OdtParser(private val context: Context) {
if (isFlat) {
try {
val bytes = Base64.decode(base64Builder.toString(), Base64.DEFAULT)
- val imgName = currentImageHref?.substringAfterLast("/") ?: "${UUID.randomUUID()}.png"
- val imgFile = File(extractionDir, imgName)
- FileOutputStream(imgFile).use { it.write(bytes) }
- currentChapterHtml.append("
")
+ val imgName = currentImageHref
+ ?.substringAfterLast("/")
+ ?.substringAfterLast("\\")
+ ?.takeIf { it.isNotBlank() }
+ ?: "${UUID.randomUUID()}.png"
+ val imgFile = safeFileInRoot(extractionDir, imgName)
+ if (imgFile != null) {
+ FileOutputStream(imgFile).use { it.write(bytes) }
+ currentChapterHtml.append("
")
+ } else {
+ Timber.w("Skipping unsafe FODT image path: $imgName")
+ }
} catch (e: Exception) {
Timber.e(e, "Failed to decode FODT image")
}
diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/SingleFileImporter.kt
similarity index 90%
rename from app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epub/SingleFileImporter.kt
index 0d6c679..ba109c9 100644
--- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epub/SingleFileImporter.kt
@@ -17,10 +17,10 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.epub
+package org.dueattendant149.bookreader.epub
import android.content.Context
-import com.aryan.reader.FileType
+import org.dueattendant149.bookreader.FileType
import com.vladsch.flexmark.ext.autolink.AutolinkExtension
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension
@@ -56,6 +56,8 @@ class SingleFileImporter(private val context: Context) {
private const val MAX_HTML_BUFFERED_LINE_CHARS = 128_000
private const val MAX_HTML_HEAD_SCAN_CHARS = 256_000
private const val MAX_HTML_INLINE_CSS_CHARS = 256_000
+ private const val MAX_SINGLE_FILE_METADATA_BYTES = 2L * 1024L * 1024L
+ private const val BOOK_METADATA_FILE = "book_metadata.json"
private const val PAGE_BREAK_MARKER = ""
}
@@ -68,6 +70,62 @@ class SingleFileImporter(private val context: Context) {
private val htmlOutputSettings = Document.OutputSettings().prettyPrint(false)
+ private fun metadataFile(extractionDir: File): File = File(extractionDir, BOOK_METADATA_FILE)
+
+ private fun EpubBook.lightweightSingleFileCache(): EpubBook {
+ val cacheChapters = chapters.map { chapter ->
+ chapter.copy(
+ plainTextContent = "",
+ htmlContent = ""
+ )
+ }
+ return copy(
+ coverImage = null,
+ chapters = cacheChapters,
+ chaptersForPagination = cacheChapters
+ )
+ }
+
+ private fun readCachedSingleFileBook(metadataFile: File, extractionDir: File, tag: String): EpubBook? {
+ if (!metadataFile.exists()) return null
+ if (metadataFile.length() > MAX_SINGLE_FILE_METADATA_BYTES) {
+ Timber.w(
+ "Ignoring oversized $tag metadata cache (${metadataFile.length()} bytes). " +
+ "The file will be reparsed with lightweight metadata."
+ )
+ runCatching { metadataFile.delete() }
+ return null
+ }
+
+ return try {
+ val decodedBook = jsonSerializer.decodeFromString(metadataFile.readText())
+ val cacheChapters = decodedBook.chapters.map { it.copy(htmlContent = "") }
+ decodedBook.copy(
+ chapters = cacheChapters,
+ chaptersForPagination = cacheChapters,
+ extractionBasePath = extractionDir.absolutePath
+ ).takeIf { it.hasReadableExtractedContent() }
+ } catch (e: OutOfMemoryError) {
+ Timber.e(e, "Failed to load cached $tag metadata without exhausting memory")
+ runCatching { metadataFile.delete() }
+ null
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to load cached $tag, parsing again")
+ null
+ }
+ }
+
+ private fun writeSingleFileMetadata(metadataFile: File, book: EpubBook, tag: String) {
+ try {
+ metadataFile.writeText(jsonSerializer.encodeToString(book.lightweightSingleFileCache()))
+ } catch (e: OutOfMemoryError) {
+ Timber.e(e, "Failed to cache lightweight $tag metadata without exhausting memory")
+ runCatching { metadataFile.delete() }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to cache $tag metadata")
+ }
+ }
+
suspend fun importSingleFile(
inputStream: InputStream,
type: FileType,
@@ -79,7 +137,7 @@ class SingleFileImporter(private val context: Context) {
val lowerHint = originalBookNameHint.lowercase()
val isCsv = lowerHint.endsWith(".csv") || lowerHint.endsWith(".tsv") ||
lowerHint.endsWith(".csv.txt") || lowerHint.endsWith(".tsv.txt")
- val isCodeOrData = com.aryan.reader.isCodeOrDataFileName(originalBookNameHint)
+ val isCodeOrData = org.dueattendant149.bookreader.isCodeOrDataFileName(originalBookNameHint)
if (type == FileType.HTML && (isCsv || isCodeOrData)) {
return parseDynamicContentToHtml(inputStream, originalBookNameHint, bookId, parseContent, isCsv)
@@ -195,17 +253,11 @@ class SingleFileImporter(private val context: Context) {
}
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
- val metadataFile = File(extractionDir, "book_metadata.json")
+ val metadataFile = metadataFile(extractionDir)
- if (metadataFile.exists()) {
- try {
- val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText())
- .copy(extractionBasePath = extractionDir.absolutePath)
- Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId")
- return@withContext cachedBook
- } catch (e: Exception) {
- Timber.e(e, "Failed to load cached MD, parsing again")
- }
+ readCachedSingleFileBook(metadataFile, extractionDir, "MD")?.let { cachedBook ->
+ Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId")
+ return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)
@@ -299,11 +351,7 @@ class SingleFileImporter(private val context: Context) {
css = emptyMap()
)
- try {
- metadataFile.writeText(jsonSerializer.encodeToString(book))
- } catch (e: Exception) {
- Timber.e(e, "Failed to cache MD metadata")
- }
+ writeSingleFileMetadata(metadataFile, book, "MD")
return@withContext book
}
@@ -331,17 +379,11 @@ class SingleFileImporter(private val context: Context) {
}
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
- val metadataFile = File(extractionDir, "book_metadata.json")
+ val metadataFile = metadataFile(extractionDir)
- if (metadataFile.exists()) {
- try {
- val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText())
- .copy(extractionBasePath = extractionDir.absolutePath)
- Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId")
- return@withContext cachedBook
- } catch (e: Exception) {
- Timber.e(e, "Failed to load cached TXT, parsing again")
- }
+ readCachedSingleFileBook(metadataFile, extractionDir, "TXT")?.let { cachedBook ->
+ Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId")
+ return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)
@@ -462,11 +504,7 @@ class SingleFileImporter(private val context: Context) {
css = emptyMap()
)
- try {
- metadataFile.writeText(jsonSerializer.encodeToString(book))
- } catch (e: Exception) {
- Timber.e(e, "Failed to cache TXT metadata")
- }
+ writeSingleFileMetadata(metadataFile, book, "TXT")
return@withContext book
}
@@ -494,17 +532,11 @@ class SingleFileImporter(private val context: Context) {
}
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
- val metadataFile = File(extractionDir, "book_metadata.json")
+ val metadataFile = metadataFile(extractionDir)
- if (metadataFile.exists()) {
- try {
- val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText())
- .copy(extractionBasePath = extractionDir.absolutePath)
- Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId")
- return@withContext cachedBook
- } catch (e: Exception) {
- Timber.e(e, "Failed to load cached HTML, parsing again")
- }
+ readCachedSingleFileBook(metadataFile, extractionDir, "HTML")?.let { cachedBook ->
+ Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId")
+ return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)
@@ -699,11 +731,7 @@ class SingleFileImporter(private val context: Context) {
css = emptyMap()
)
- try {
- metadataFile.writeText(jsonSerializer.encodeToString(book))
- } catch (e: Exception) {
- Timber.e(e, "Failed to cache HTML metadata")
- }
+ writeSingleFileMetadata(metadataFile, book, "HTML")
return@withContext book
}
@@ -719,7 +747,7 @@ class SingleFileImporter(private val context: Context) {
if (!originalBookNameHint.endsWith(".txt", ignoreCase = true)) return originalBookNameHint
val innerName = originalBookNameHint.dropLast(4)
- return if (innerName.contains('.') && com.aryan.reader.isCodeOrDataFileName(innerName)) {
+ return if (innerName.contains('.') && org.dueattendant149.bookreader.isCodeOrDataFileName(innerName)) {
innerName
} else {
originalBookNameHint
@@ -783,17 +811,11 @@ class SingleFileImporter(private val context: Context) {
}
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
- val metadataFile = File(extractionDir, "book_metadata.json")
+ val metadataFile = metadataFile(extractionDir)
- if (metadataFile.exists()) {
- try {
- val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText())
- .copy(extractionBasePath = extractionDir.absolutePath)
- Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId")
- return@withContext cachedBook
- } catch (e: Exception) {
- Timber.e(e, "Failed to load cached DOCX, parsing again")
- }
+ readCachedSingleFileBook(metadataFile, extractionDir, "DOCX")?.let { cachedBook ->
+ Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId")
+ return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)
diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/ChapterWebView.kt
similarity index 90%
rename from app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/ChapterWebView.kt
index 34001a3..4f20714 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/ChapterWebView.kt
@@ -18,12 +18,10 @@
* mail: epistemereader@gmail.com
*/
// ChapterWebView.kt
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader.epubreader
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
-import android.content.ClipData
-import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Color
@@ -38,8 +36,6 @@ import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -52,8 +48,10 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -76,6 +74,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
@@ -85,21 +84,29 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri
-import com.aryan.reader.R
-import com.aryan.reader.getReaderTextureDataUri
-import com.aryan.reader.shared.ui.SharedSelectionMenuRect
-import com.aryan.reader.shared.ui.SharedSelectionMenuSize
-import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
-import com.aryan.reader.shared.ui.sharedSelectionMenuPlacement
+import org.dueattendant149.bookreader.R
+import org.dueattendant149.bookreader.ReaderFontDiagnosticsTag
+import org.dueattendant149.bookreader.copyPlainTextToClipboard
+import org.dueattendant149.bookreader.getReaderTextureDataUri
+import org.dueattendant149.bookreader.readerFontDiagnosticSummary
+import org.dueattendant149.bookreader.shared.detectFontVariant
+import org.dueattendant149.bookreader.shared.familyFilenameSignature
+import org.dueattendant149.bookreader.shared.fontWeightCssDescriptor
+import org.dueattendant149.bookreader.shared.ui.SharedSelectionMenuRect
+import org.dueattendant149.bookreader.shared.ui.SharedSelectionMenuSize
+import org.dueattendant149.bookreader.shared.ui.SharedSelectionMenuViewport
+import org.dueattendant149.bookreader.shared.ui.sharedSelectionMenuPlacement
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.json.JSONObject
import timber.log.Timber
import java.io.BufferedReader
+import java.io.File
import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV"
private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter"
+private const val TAG_ANDROID_HIGHLIGHT_RENDER_DIAG = "AndroidHighlightRenderDiag"
private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
"PageInfoReporter",
"ProgressReporter",
@@ -201,6 +208,49 @@ private fun getFontCssInjection(): String {
""".trimIndent()
}
+private fun buildCustomFontCssForWebView(customFontPath: String?, phase: String): String {
+ if (customFontPath == null) return ""
+
+ val fontFile = File(customFontPath)
+ val signature = fontFile.nameWithoutExtension.familyFilenameSignature()
+ val siblings = fontFile.parentFile?.listFiles()?.filter {
+ it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
+ it.nameWithoutExtension.familyFilenameSignature() == signature
+ } ?: listOf(fontFile)
+
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "webview.$phase.customCss.start basePath='${fontFile.absolutePath}' " +
+ "exists=${fontFile.exists()} bytes=${fontFile.length()} " +
+ readerFontDiagnosticSummary(fontFile.nameWithoutExtension) +
+ " siblings=${siblings.joinToString { it.name }}"
+ )
+
+ val css = siblings.mapNotNull { sibling ->
+ val variant = sibling.nameWithoutExtension.detectFontVariant()
+ if (variant == null) {
+ Timber.tag(ReaderFontDiagnosticsTag).w(
+ "webview.$phase.customCss.skipNoVariant file='${sibling.name}' " +
+ readerFontDiagnosticSummary(sibling.nameWithoutExtension)
+ )
+ return@mapNotNull null
+ }
+
+ val weight = sibling.nameWithoutExtension.fontWeightCssDescriptor(variant.weight)
+ val style = if (variant.style == FontStyle.Italic) "italic" else "normal"
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "webview.$phase.customCss.face file='${sibling.name}' fontWeight='$weight' fontStyle='$style' " +
+ readerFontDiagnosticSummary(sibling.nameWithoutExtension)
+ )
+ "@font-face { font-family: 'CustomFont'; src: url('file://${sibling.absolutePath}'); font-weight: $weight; font-style: $style; }"
+ }.joinToString(" ")
+
+ val faceCount = Regex("@font-face").findAll(css).count()
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "webview.$phase.customCss.done faceCount=$faceCount cssLength=${css.length}"
+ )
+ return css
+}
+
private fun getJsToInject(context: Context): String {
return try {
context.assets.open("epub_reader.js").use { inputStream ->
@@ -386,6 +436,36 @@ private data class CustomMenuState(
val selectedColor: HighlightColor? = null
)
+internal fun highlightsJsonForWebView(userHighlights: List): String {
+ val jsonArray = org.json.JSONArray()
+ userHighlights.forEach { highlight ->
+ val obj = JSONObject()
+ obj.put("id", highlight.id)
+ obj.put("cfi", highlight.cfi)
+ obj.put("text", highlight.text)
+ obj.put("cssClass", highlight.color.cssClass)
+ obj.put("colorId", highlight.color.id)
+ obj.put("chapterIndex", highlight.chapterIndex)
+ obj.put(
+ "locator",
+ JSONObject().apply {
+ highlight.locator.chapterIndex?.let { put("chapterIndex", it) }
+ highlight.locator.chapterId?.let { put("chapterId", it) }
+ highlight.locator.href?.let { put("href", it) }
+ highlight.locator.pageIndex?.let { put("pageIndex", it) }
+ highlight.locator.startOffset?.let { put("startOffset", it) }
+ highlight.locator.endOffset?.let { put("endOffset", it) }
+ highlight.locator.blockIndex?.let { put("blockIndex", it) }
+ highlight.locator.charOffset?.let { put("charOffset", it) }
+ highlight.locator.textQuote?.let { put("textQuote", it) }
+ highlight.locator.cfi?.let { put("cfi", it) }
+ }
+ )
+ jsonArray.put(obj)
+ }
+ return jsonArray.toString()
+}
+
@Suppress("unused")
class AiJsBridge(
private val scope: CoroutineScope, private val onContentReady: suspend (String) -> Unit
@@ -483,6 +563,7 @@ fun ChapterWebView(
onFootnoteRequested: (String) -> Unit,
currentFontFamily: ReaderFont,
customFontPath: String? = null,
+ epubFontFaceCss: String = "",
currentTextAlign: ReaderTextAlign,
onHighlightClicked: () -> Unit,
onAutoScrollChapterEnd: () -> Unit = {},
@@ -526,17 +607,7 @@ fun ChapterWebView(
)
}
- val highlightsJson = remember(userHighlights) {
- val jsonArray = org.json.JSONArray()
- userHighlights.forEach { h ->
- val obj = JSONObject()
- obj.put("cfi", h.cfi)
- obj.put("text", h.text)
- obj.put("cssClass", h.color.cssClass)
- jsonArray.put(obj)
- }
- jsonArray.toString()
- }
+ val highlightsJson = remember(userHighlights) { highlightsJsonForWebView(userHighlights) }
if (showExternalLinkDialog != null) {
val urlToShow = showExternalLinkDialog!!
@@ -557,10 +628,14 @@ fun ChapterWebView(
showExternalLinkDialog = null
}) { Text(stringResource(R.string.action_open)) }
TextButton(onClick = {
- val clipboard =
- context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
- val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow)
- clipboard.setPrimaryClip(clip)
+ val copied = copyPlainTextToClipboard(
+ context = context,
+ label = context.getString(R.string.clip_label_copied_link),
+ text = urlToShow
+ )
+ if (!copied) {
+ Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
+ }
showExternalLinkDialog = null
}) { Text(stringResource(R.string.action_copy)) }
}
@@ -719,6 +794,11 @@ fun ChapterWebView(
)
}
+ message.startsWith("$TAG_ANDROID_HIGHLIGHT_RENDER_DIAG:") -> {
+ Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG)
+ .d("JS -> ${message.substringAfter("$TAG_ANDROID_HIGHLIGHT_RENDER_DIAG: ")}")
+ }
+
message.startsWith("ReaderFontDiagnosis") -> {
Timber.d(
"JS -> ${message.substringAfter("ReaderFontDiagnosis: ")}"
@@ -901,13 +981,14 @@ fun ChapterWebView(
)
val fontCss = getFontCssInjection().replace("\n", " ")
- val customFontCss = if (customFontPath != null) {
- "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
- } else ""
- val combinedCss = "$fontCss $customFontCss"
+ val customFontCss = buildCustomFontCssForWebView(customFontPath, "initial")
+ val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss)
+ .filter { it.isNotBlank() }
+ .joinToString(separator = " ")
+ val escapedCombinedCss = escapeJsString(combinedCss)
val injectFontJs =
- "var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$combinedCss\"; document.head.appendChild(style);"
+ "var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$escapedCombinedCss\"; document.head.appendChild(style);"
view?.evaluateJavascript("javascript:$injectFontJs") {
Timber.d("CSS Injection result: $it")
}
@@ -1099,10 +1180,10 @@ fun ChapterWebView(
runtimeApplierState.logPending(chapterTitle)
} else {
val fontCss = getFontCssInjection().replace("\n", " ")
- val customFontCss = if (customFontPath != null) {
- "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
- } else ""
- val combinedCss = "$fontCss $customFontCss"
+ val customFontCss = buildCustomFontCssForWebView(customFontPath, "runtime")
+ val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss)
+ .filter { it.isNotBlank() }
+ .joinToString(separator = " ")
val fontNameForJs = if (customFontPath != null) {
"CustomFont"
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
@@ -1140,8 +1221,9 @@ fun ChapterWebView(
if (fontCssChanged) {
runtimeApplierState.fontCss = combinedCss
+ val escapedCombinedCss = escapeJsString(combinedCss)
val injectFontJs =
- "var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";"
+ "var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$escapedCombinedCss\";"
webView.evaluateJavascript("javascript:$injectFontJs", null)
}
@@ -1278,10 +1360,14 @@ fun ChapterWebView(
) {
PaginatedTextSelectionMenu(
onCopy = {
- val clipboard =
- context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
- val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), state.selectedText)
- clipboard.setPrimaryClip(clip)
+ val copied = copyPlainTextToClipboard(
+ context = context,
+ label = context.getString(R.string.clip_label_copied_text),
+ text = state.selectedText
+ )
+ if (!copied) {
+ Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
+ }
state.finishActionModeCallback()
localWebViewRef?.clearFocus()
localWebViewRef?.evaluateJavascript(
diff --git a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/DictionarySettingsDialog.kt
similarity index 92%
rename from app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/DictionarySettingsDialog.kt
index 0aab63d..6122f80 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/DictionarySettingsDialog.kt
@@ -1,10 +1,13 @@
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader.epubreader
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -20,12 +23,14 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -35,14 +40,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Dialog
import androidx.core.graphics.drawable.toBitmap
-import com.aryan.reader.R
-import com.aryan.reader.areReaderAiFeaturesEnabled
+import org.dueattendant149.bookreader.R
+import org.dueattendant149.bookreader.areReaderAiFeaturesEnabled
+import org.dueattendant149.bookreader.readerModalMaxHeightDp
@Suppress("KotlinConstantConditions")
@OptIn(ExperimentalMaterial3Api::class)
@@ -65,25 +71,28 @@ fun DictionarySettingsDialog(
val context = LocalContext.current
var dictionaryApps by remember { mutableStateOf>(emptyList()) }
var searchApps by remember { mutableStateOf>(emptyList()) }
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val configuration = LocalConfiguration.current
+ val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
LaunchedEffect(Unit) {
dictionaryApps = ExternalDictionaryHelper.getAvailableDictionaries(context)
searchApps = ExternalDictionaryHelper.getAvailableSearchApps(context)
}
- Dialog(onDismissRequest = onDismiss) {
- Surface(
- shape = RoundedCornerShape(24.dp),
- color = MaterialTheme.colorScheme.surface,
- tonalElevation = 6.dp,
- modifier = Modifier.fillMaxWidth()
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentWindowInsets = { WindowInsets.navigationBars }
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = maxSheetHeight)
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp)
) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(rememberScrollState())
- .padding(24.dp)
- ) {
Text(
text = stringResource(R.string.dict_lookup_settings),
style = MaterialTheme.typography.headlineSmall,
@@ -212,7 +221,6 @@ fun DictionarySettingsDialog(
onSelect = onSelectSearchPackage,
placeholder = stringResource(R.string.dict_select_app)
)
- }
}
}
}
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderAi.kt
similarity index 95%
rename from app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderAi.kt
index 176176e..ea332c6 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderAi.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader.epubreader
import android.content.Context
import androidx.compose.foundation.layout.fillMaxWidth
@@ -31,20 +31,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
-import com.aryan.reader.AiDefinitionPopup
-import com.aryan.reader.AiFeature
-import com.aryan.reader.AiDefinitionResult
-import com.aryan.reader.AiHubBottomSheet
-import com.aryan.reader.BuildConfig
-import com.aryan.reader.R
-import com.aryan.reader.SummarizationResult
-import com.aryan.reader.SummaryCacheManager
-import com.aryan.reader.callByokTextAi
-import com.aryan.reader.epub.EpubBook
-import com.aryan.reader.epub.contentFilePath
-import com.aryan.reader.fetchRecap
-import com.aryan.reader.paginatedreader.IPaginator
-import com.aryan.reader.summarizationUrl
+import org.dueattendant149.bookreader.AiDefinitionPopup
+import org.dueattendant149.bookreader.AiFeature
+import org.dueattendant149.bookreader.AiDefinitionResult
+import org.dueattendant149.bookreader.AiHubBottomSheet
+import org.dueattendant149.bookreader.BuildConfig
+import org.dueattendant149.bookreader.R
+import org.dueattendant149.bookreader.SummarizationResult
+import org.dueattendant149.bookreader.SummaryCacheManager
+import org.dueattendant149.bookreader.callByokTextAi
+import org.dueattendant149.bookreader.epub.EpubBook
+import org.dueattendant149.bookreader.epub.contentFilePath
+import org.dueattendant149.bookreader.fetchRecap
+import org.dueattendant149.bookreader.paginatedreader.IPaginator
+import org.dueattendant149.bookreader.summarizationUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderAnnotations.kt
similarity index 97%
rename from app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderAnnotations.kt
index 56fead4..41fac2f 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderAnnotations.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader.epubreader
import android.content.Context
import android.widget.TextView
@@ -61,6 +61,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -68,18 +69,19 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.edit
import androidx.core.text.HtmlCompat
-import com.aryan.reader.R
-import com.aryan.reader.epub.EpubChapter
-import com.aryan.reader.shared.EpubAnnotationSerializer
+import org.dueattendant149.bookreader.R
+import org.dueattendant149.bookreader.epub.EpubChapter
+import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
+import org.dueattendant149.bookreader.shared.ReaderLocator
private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks"
-typealias Bookmark = com.aryan.reader.shared.EpubBookmark
-typealias HighlightColor = com.aryan.reader.shared.HighlightColor
-typealias UserHighlight = com.aryan.reader.shared.UserHighlight
+typealias Bookmark = org.dueattendant149.bookreader.shared.EpubBookmark
+typealias HighlightColor = org.dueattendant149.bookreader.shared.HighlightColor
+typealias UserHighlight = org.dueattendant149.bookreader.shared.UserHighlight
fun escapeJsString(value: String): String {
- return com.aryan.reader.shared.escapeJsString(value)
+ return org.dueattendant149.bookreader.shared.escapeJsString(value)
}
fun saveHighlightPalette(context: Context, palette: List) {
@@ -155,14 +157,20 @@ fun processAndAddHighlight(
newText: String,
newColor: HighlightColor,
chapterIndex: Int,
- currentList: MutableList
+ currentList: MutableList,
+ locator: ReaderLocator = ReaderLocator.fromLegacy(
+ chapterIndex = chapterIndex,
+ cfi = newCfi,
+ textQuote = newText
+ )
): String {
return EpubAnnotationSerializer.processAndAddHighlight(
newCfi = newCfi,
newText = newText,
newColor = newColor,
chapterIndex = chapterIndex,
- currentList = currentList
+ currentList = currentList,
+ locator = locator
)
}
@@ -593,6 +601,7 @@ fun HighlightColorRow(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(28.dp)
+ .testTag("HighlightColor_${colorEnum.id}")
.clip(CircleShape) // 1. Clip shape for ripple
.background(colorEnum.color) // 2. Apply background
.clickable {
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderContent.kt
similarity index 88%
rename from app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderContent.kt
index 0e9d8c6..871bf32 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderContent.kt
@@ -17,19 +17,21 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader.epubreader
import android.content.Context
-import com.aryan.reader.R
-import timber.log.Timber
-import com.aryan.reader.epub.EpubBook
-import com.aryan.reader.epub.contentFilePath
-import com.aryan.reader.paginatedreader.LocatorConverter
+import org.dueattendant149.bookreader.R
+import org.dueattendant149.bookreader.applyBookReplacementsToHtmlDocument
+import org.dueattendant149.bookreader.epub.EpubBook
+import org.dueattendant149.bookreader.epub.contentFilePath
+import org.dueattendant149.bookreader.paginatedreader.LocatorConverter
+import org.dueattendant149.bookreader.shared.ReaderBookReplacementPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
+import timber.log.Timber
import java.io.File
data class ChapterLoadingResult(
@@ -86,7 +88,9 @@ suspend fun loadChapterContent(
chunkTargetOverride: Int?,
isInitialCfiLoad: Boolean,
cfiToLoad: String?,
- locatorConverter: LocatorConverter
+ locatorConverter: LocatorConverter,
+ bookReplacementPreferences: ReaderBookReplacementPreferences = ReaderBookReplacementPreferences(),
+ bookReplacementFileId: String? = null,
): ChapterLoadingResult = withContext(Dispatchers.IO) {
val chapter =
epubBook.chapters.getOrNull(chapterIndex) ?: return@withContext ChapterLoadingResult(
@@ -100,6 +104,11 @@ suspend fun loadChapterContent(
val doc = Jsoup.parse(htmlFile, "UTF-8")
val head = doc.head().html()
doc.select("script").remove()
+ applyBookReplacementsToHtmlDocument(
+ document = doc,
+ preferences = bookReplacementPreferences,
+ fileId = bookReplacementFileId,
+ )
val bodyNodes = doc.body().childNodes().toList()
val htmlChunks = splitBodyNodesIntoReaderChunks(bodyNodes)
if (htmlChunks.isEmpty()) {
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderControls.kt
similarity index 88%
rename from app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderControls.kt
index c3799da..3bb0ae3 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderControls.kt
@@ -1,30 +1,6 @@
-/*
- * Episteme Reader - A native Android document reader.
- * Copyright (C) 2026 Episteme
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- *
- * mail: epistemereader@gmail.com
- */
-// EpubReaderControls.kt
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader.epubreader
-import android.annotation.SuppressLint
-import android.graphics.Bitmap
-import android.graphics.Canvas
import android.os.Build
-import android.webkit.WebView
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.compose.foundation.lazy.LazyListState
@@ -38,14 +14,12 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -56,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
@@ -72,6 +47,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.automirrored.filled.NavigateBefore
+import androidx.compose.material.icons.automirrored.filled.NavigateNext
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowUpward
@@ -82,6 +59,10 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowLeft
+import androidx.compose.material.icons.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
@@ -121,9 +102,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@@ -136,24 +115,21 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex
-import androidx.core.graphics.createBitmap
import androidx.media3.common.util.UnstableApi
-import com.aryan.reader.BuildConfig
-import com.aryan.reader.R
-import com.aryan.reader.RenderMode
-import com.aryan.reader.SearchState
-import com.aryan.reader.SearchTopBar
-import com.aryan.reader.TooltipIconButton
-import com.aryan.reader.areReaderAiFeaturesEnabled
-import com.aryan.reader.epub.EpubChapter
-import com.aryan.reader.loadNativeVoice
-import com.aryan.reader.paginatedreader.BookPaginator
-import com.aryan.reader.paginatedreader.IPaginator
-import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
-import com.aryan.reader.tts.TtsPlaybackManager.TtsState
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import timber.log.Timber
+import org.dueattendant149.bookreader.BuildConfig
+import org.dueattendant149.bookreader.R
+import org.dueattendant149.bookreader.RenderMode
+import org.dueattendant149.bookreader.SearchState
+import org.dueattendant149.bookreader.SearchTopBar
+import org.dueattendant149.bookreader.TooltipIconButton
+import org.dueattendant149.bookreader.areReaderAiFeaturesEnabled
+import org.dueattendant149.bookreader.loadNativeVoice
+import org.dueattendant149.bookreader.readerSliderStepPage
+import org.dueattendant149.bookreader.shared.ui.ReaderMinimalSlider
+import org.dueattendant149.bookreader.tts.GEMINI_TTS_SPEAKERS
+import org.dueattendant149.bookreader.tts.ReaderTtsOverlaySize
+import org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsState
+import org.dueattendant149.bookreader.tts.formatReaderTtsChunkLabel
import kotlin.math.roundToInt
enum class ReaderTool(@StringRes val titleRes: Int, val category: String) {
@@ -177,7 +153,8 @@ enum class ReaderTool(@StringRes val titleRes: Int, val category: String) {
SCREEN_ORIENTATION(R.string.menu_screen_orientation, "Top Bar"),
AUTO_SCROLL(R.string.menu_auto_scroll, "Overflow Menu"),
TTS_SETTINGS(R.string.menu_tts_settings, "Overflow Menu"),
- TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu")
+ TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu"),
+ BOOK_REPLACEMENTS(R.string.menu_book_word_replacements, "Overflow Menu")
}
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
@@ -286,6 +263,7 @@ internal enum class EpubOverflowMenuSection {
KEEP_SCREEN_ON,
VISUAL_OPTIONS,
AUTO_SCROLL,
+ BOOK_REPLACEMENTS,
TTS_SETTINGS,
FILE_INFO
}
@@ -309,6 +287,7 @@ internal fun epubOverflowMenuSections(
if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) add(EpubOverflowMenuSection.KEEP_SCREEN_ON)
if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) add(EpubOverflowMenuSection.VISUAL_OPTIONS)
if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) add(EpubOverflowMenuSection.AUTO_SCROLL)
+ if (!hiddenTools.contains(ReaderTool.BOOK_REPLACEMENTS.name)) add(EpubOverflowMenuSection.BOOK_REPLACEMENTS)
if (
!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name) ||
!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)
@@ -381,11 +360,13 @@ fun EpubReaderTopBar(
volumeScrollEnabled: Boolean,
isPageTurnAnimationEnabled: Boolean,
isRightToLeftPagination: Boolean,
+ useNativeVerticalRenderer: Boolean,
onNavigateBack: () -> Unit,
isKeepScreenOn: Boolean,
onToggleKeepScreenOn: (Boolean) -> Unit,
onCloseSearch: () -> Unit,
onChangeRenderMode: (RenderMode) -> Unit,
+ onUseNativeVerticalRendererChange: (Boolean) -> Unit,
onToggleBookmark: () -> Unit,
onToggleTapToNavigate: (Boolean) -> Unit,
onToggleVolumeScroll: (Boolean) -> Unit,
@@ -394,6 +375,7 @@ fun EpubReaderTopBar(
onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenTtsReplacements: () -> Unit,
+ onOpenBookReplacements: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
onOpenBrightness: () -> Unit,
@@ -701,14 +683,29 @@ fun EpubReaderTopBar(
)
if (showReadingModeExpanded) {
DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
+ text = { Text(stringResource(R.string.menu_reading_mode_vertical_webview)) },
enabled = !isTtsActive,
onClick = {
+ onUseNativeVerticalRendererChange(false)
showMoreMenu = false
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
},
trailingIcon = {
- if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
+ if (currentRenderMode == RenderMode.VERTICAL_SCROLL && !useNativeVerticalRenderer) Icon(
+ Icons.Default.Check,
+ contentDescription = stringResource(R.string.content_desc_selected)
+ )
+ })
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_reading_mode_vertical_native)) },
+ enabled = !isTtsActive,
+ onClick = {
+ onUseNativeVerticalRendererChange(true)
+ showMoreMenu = false
+ onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
+ },
+ trailingIcon = {
+ if (currentRenderMode == RenderMode.VERTICAL_SCROLL && useNativeVerticalRenderer) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
)
@@ -849,6 +846,22 @@ fun EpubReaderTopBar(
onStartAutoScroll()
})
}
+ EpubOverflowMenuSection.BOOK_REPLACEMENTS -> {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_book_word_replacements)) },
+ onClick = {
+ showMoreMenu = false
+ onOpenBookReplacements()
+ },
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = R.drawable.text_fields),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ )
+ }
EpubOverflowMenuSection.TTS_SETTINGS -> {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_settings)) },
@@ -1005,7 +1018,7 @@ fun EpubReaderBottomBar(
isTtsSessionActive: Boolean,
ttsState: TtsState,
isProUser: Boolean,
- currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
+ currentTtsMode: org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsMode,
isSliderActive: Boolean,
onOpenSlider: () -> Unit,
onOpenDrawer: () -> Unit,
@@ -1158,26 +1171,18 @@ fun EpubReaderBottomBar(
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
-@SuppressLint("UnusedBoxWithConstraintsScope")
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EpubReaderPageSlider(
isVisible: Boolean,
- currentRenderMode: RenderMode,
totalPages: Int,
sliderCurrentPage: Float,
sliderStartPage: Int,
- startPageThumbnail: Bitmap?,
- paginator: IPaginator?,
- chapters: List,
onScrub: (Float) -> Unit,
onJumpToPage: (Int) -> Unit,
modifier: Modifier = Modifier,
activeColor: Color = Color.Unspecified,
inactiveColor: Color = Color.Unspecified,
- contentColor: Color = Color.Unspecified,
- thumbnailSurfaceColor: Color = Color.Unspecified,
- thumbnailContentColor: Color = Color.Unspecified
+ contentColor: Color = Color.Unspecified
) {
val effectiveActiveColor = if (activeColor == Color.Unspecified) {
MaterialTheme.colorScheme.primary
@@ -1194,16 +1199,8 @@ fun EpubReaderPageSlider(
} else {
contentColor
}
- val effectiveThumbnailSurfaceColor = if (thumbnailSurfaceColor == Color.Unspecified) {
- MaterialTheme.colorScheme.surfaceVariant
- } else {
- thumbnailSurfaceColor
- }
- val effectiveThumbnailContentColor = if (thumbnailContentColor == Color.Unspecified) {
- MaterialTheme.colorScheme.onSurfaceVariant
- } else {
- thumbnailContentColor
- }
+ val maxPage = totalPages.coerceAtLeast(1)
+ val currentPage = sliderCurrentPage.roundToInt().coerceIn(1, maxPage)
AnimatedVisibility(
visible = isVisible,
@@ -1211,128 +1208,73 @@ fun EpubReaderPageSlider(
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
modifier = modifier
) {
- Column(modifier = Modifier.fillMaxWidth()) {
- Spacer(Modifier.height(72.dp))
- Box(
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
+ ) {
+ Row(
modifier = Modifier
.fillMaxWidth()
- .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
+ .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
- .padding(horizontal = 32.dp, vertical = 14.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- BoxWithConstraints(
- modifier = Modifier.weight(1f),
- contentAlignment = Alignment.Center
- ) {
- Slider(
- value = sliderCurrentPage,
- onValueChange = onScrub,
- valueRange = 1f..(totalPages.toFloat().coerceAtLeast(1f)),
- steps = if (totalPages > 2) totalPages - 2 else 0,
- modifier = Modifier.fillMaxWidth(),
- thumb = {
- Surface(
- modifier = Modifier.size(20.dp),
- shape = CircleShape,
- color = effectiveActiveColor,
- tonalElevation = 0.dp,
- shadowElevation = 0.dp
- ) {}
- },
- track = { sliderState ->
- val trackHeight = 2.dp
- val trackShape = RoundedCornerShape(trackHeight)
- val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start
- val fraction = if (range == 0f) 0f else {
- ((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f)
- }
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(trackHeight)
- .background(
- color = effectiveInactiveColor,
- shape = trackShape
- )
- ) {
- Box(
- modifier = Modifier
- .fillMaxWidth(fraction)
- .fillMaxHeight()
- .background(
- color = effectiveActiveColor,
- shape = trackShape
- )
- )
- }
- }
- )
-
- // Thumbnail Indicator
- val startPageOffsetFraction = if (totalPages > 1) {
- (sliderStartPage - 1).toFloat() / (totalPages - 1)
- } else {
- 0f
- }
- val thumbWidth = 20.dp
- val trackWidth = maxWidth - thumbWidth
- val startPagePixelPosition = (trackWidth * startPageOffsetFraction) + (thumbWidth / 2)
- val thumbnailModifier = Modifier
- .graphicsLayer { clip = false }
- .align(Alignment.TopStart)
- .offset(
- x = startPagePixelPosition - (45.dp / 2),
- y = (-72).dp
+ IconButton(
+ onClick = {
+ onJumpToPage(
+ readerSliderStepPage(
+ currentPage = currentPage,
+ delta = -1,
+ minPage = 1,
+ maxPage = maxPage
)
+ )
+ },
+ enabled = currentPage > 1,
+ modifier = Modifier.size(40.dp)
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.NavigateBefore,
+ contentDescription = stringResource(R.string.desktop_previous_page),
+ tint = effectiveContentColor.copy(alpha = if (currentPage > 1) 0.9f else 0.32f)
+ )
+ }
- if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
- startPageThumbnail?.let { thumbnail ->
- ThumbnailWithIndicator(
- modifier = thumbnailModifier,
- borderColor = effectiveActiveColor,
- onClick = { onJumpToPage(sliderStartPage) }
- ) {
- Image(
- bitmap = thumbnail.asImageBitmap(),
- contentDescription = stringResource(R.string.content_desc_start_page_thumbnail),
- contentScale = ContentScale.FillBounds,
- modifier = Modifier.fillMaxSize()
- )
- }
- }
- } else {
- val startPageChapterIndex = remember(sliderStartPage, paginator) {
- (paginator as? BookPaginator)?.findChapterIndexForPage(sliderStartPage - 1)
- }
- val startPageChapterTitle = remember(startPageChapterIndex) {
- startPageChapterIndex?.let { chapters.getOrNull(it)?.title }
- }
- ThumbnailWithIndicator(
- modifier = thumbnailModifier,
- borderColor = effectiveActiveColor,
- onClick = { onJumpToPage(sliderStartPage) }
- ) {
- PaginatedThumbnailContent(
- pageNumber = sliderStartPage,
- chapterTitle = startPageChapterTitle,
- surfaceColor = effectiveThumbnailSurfaceColor,
- contentColor = effectiveThumbnailContentColor
- )
- }
- }
- }
+ ReaderMinimalSlider(
+ value = sliderCurrentPage.coerceIn(1f, maxPage.toFloat()),
+ onValueChange = onScrub,
+ valueRange = 1f..maxPage.toFloat(),
+ enabled = maxPage > 1,
+ activeColor = effectiveActiveColor,
+ inactiveColor = effectiveInactiveColor,
+ thumbColor = effectiveActiveColor,
+ markerValue = sliderStartPage.toFloat(),
+ markerColor = effectiveActiveColor,
+ modifier = Modifier
+ .weight(1f)
+ .height(32.dp)
+ )
- Text(
- text = "${sliderCurrentPage.roundToInt()} / $totalPages",
- style = MaterialTheme.typography.bodyLarge,
- color = effectiveContentColor,
- fontSize = 18.sp
+ IconButton(
+ onClick = {
+ onJumpToPage(
+ readerSliderStepPage(
+ currentPage = currentPage,
+ delta = 1,
+ minPage = 1,
+ maxPage = maxPage
+ )
+ )
+ },
+ enabled = currentPage < maxPage,
+ modifier = Modifier.size(40.dp)
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.NavigateNext,
+ contentDescription = stringResource(R.string.desktop_next_page),
+ tint = effectiveContentColor.copy(alpha = if (currentPage < maxPage) 0.9f else 0.32f)
)
}
}
@@ -1375,109 +1317,6 @@ fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
}
}
-@Composable
-internal fun ThumbnailWithIndicator(
- modifier: Modifier = Modifier,
- borderColor: Color = Color.Unspecified,
- onClick: () -> Unit,
- content: @Composable () -> Unit
-) {
- val effectiveBorderColor = if (borderColor == Color.Unspecified) {
- MaterialTheme.colorScheme.primary
- } else {
- borderColor
- }
- Column(
- modifier = modifier,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Surface(
- modifier = Modifier
- .width(45.dp)
- .height(64.dp)
- .clickable(onClick = onClick),
- shape = RoundedCornerShape(4.dp),
- border = BorderStroke(2.dp, effectiveBorderColor)
- ) {
- content()
- }
- Box(
- modifier = Modifier
- .offset(y = (-4).dp)
- .size(8.dp)
- .rotate(45f)
- .background(effectiveBorderColor)
- )
- }
-}
-
-@Composable
-private fun PaginatedThumbnailContent(
- pageNumber: Int,
- chapterTitle: String?,
- surfaceColor: Color = Color.Unspecified,
- contentColor: Color = Color.Unspecified
-) {
- val effectiveSurfaceColor = if (surfaceColor == Color.Unspecified) {
- MaterialTheme.colorScheme.surfaceVariant
- } else {
- surfaceColor
- }
- val effectiveContentColor = if (contentColor == Color.Unspecified) {
- MaterialTheme.colorScheme.onSurfaceVariant
- } else {
- contentColor
- }
- Surface(
- modifier = Modifier.fillMaxSize(),
- color = effectiveSurfaceColor,
- contentColor = effectiveContentColor
- ) {
- Column(
- modifier = Modifier.padding(4.dp),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- if (chapterTitle != null) {
- Text(
- text = chapterTitle,
- style = MaterialTheme.typography.labelSmall,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- textAlign = TextAlign.Center,
- lineHeight = 10.sp
- )
- Spacer(modifier = Modifier.height(4.dp))
- }
- Text(
- text = "$pageNumber",
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Bold
- )
- }
- }
-}
-
-suspend fun captureWebViewVisibleArea(webView: WebView): Bitmap? {
- return withContext(Dispatchers.Main) {
- if (webView.width <= 0 || webView.height <= 0) return@withContext null
- try {
- val thumbnailWidth = 180
- val thumbnailHeight = 256
- val bitmap = createBitmap(thumbnailWidth, thumbnailHeight)
- val canvas = Canvas(bitmap)
- val scale = thumbnailWidth.toFloat() / webView.width.toFloat()
- canvas.scale(scale, scale)
- canvas.translate(-webView.scrollX.toFloat(), -webView.scrollY.toFloat())
- webView.draw(canvas)
- bitmap
- } catch (e: Exception) {
- Timber.e(e, "Failed to capture webview content")
- null
- }
- }
-}
-
@Composable
fun SpeedDropdown(
label: String,
@@ -2204,6 +2043,7 @@ private fun ToolPreviewIcon(tool: ReaderTool, isSliderActive: Boolean = false) {
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp))
+ ReaderTool.BOOK_REPLACEMENTS -> Icon(painterResource(id = R.drawable.text_fields), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.FILE_INFO -> Icon(Icons.Default.Info, contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp))
else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp))
@@ -2257,11 +2097,11 @@ private fun HiddenEpubToolMenuItem(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TtsOverlayControls(
- ttsController: com.aryan.reader.tts.TtsController,
+ ttsController: org.dueattendant149.bookreader.tts.TtsController,
ttsState: TtsState,
- currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
- isCollapsed: Boolean,
- onCollapseChange: (Boolean) -> Unit,
+ currentTtsMode: org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsMode,
+ overlaySize: ReaderTtsOverlaySize,
+ onOverlaySizeChange: (ReaderTtsOverlaySize) -> Unit,
onLocateCurrentChunk: () -> Unit,
onOpenTtsSettings: () -> Unit,
onClose: () -> Unit,
@@ -2274,7 +2114,7 @@ fun TtsOverlayControls(
var isDraggingRate by remember { mutableStateOf(false) }
var isDraggingPitch by remember { mutableStateOf(false) }
- val activeMode = try { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.valueOf(ttsState.ttsMode) } catch(_: Exception) { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD }
+ val activeMode = try { org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsMode.valueOf(ttsState.ttsMode) } catch(_: Exception) { org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsMode.CLOUD }
val progressPercent = ttsState.bookProgressPercent
val cleanChapterTitle = remember(ttsState.chapterTitle) {
ttsState.chapterTitle
@@ -2301,11 +2141,17 @@ fun TtsOverlayControls(
}
}
val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) {
- if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) {
- "Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}"
- } else {
- null
- }
+ formatReaderTtsChunkLabel(ttsState.currentChunkIndex, ttsState.totalChunks)
+ }
+ val miniBarTitle = ttsState.bookTitle
+ ?.takeIf { it.isNotBlank() }
+ ?: stringResource(R.string.action_read_aloud)
+ val miniBarSubtitle = remember(chapterLabel, chunkLabel, progressPercent, miniBarTitle) {
+ listOfNotNull(
+ chunkLabel,
+ progressPercent?.let { "$it%" },
+ chapterLabel?.takeIf { it != miniBarTitle }
+ ).joinToString(" - ")
}
val canSkipPreviousChunk = !ttsState.isLoading &&
ttsState.currentChunkIndex > 0 &&
@@ -2317,7 +2163,7 @@ fun TtsOverlayControls(
val saveAndApply = {
saveTtsSpeechRate(context, rate)
saveTtsPitch(context, pitch)
- if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
+ if (activeMode == org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
ttsController.setPlaybackParameters(rate, pitch)
} else {
ttsController.sliceAndRetainPosition()
@@ -2330,24 +2176,40 @@ fun TtsOverlayControls(
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
- modifier = modifier.widthIn(max = 400.dp).animateContentSize()
+ modifier = modifier
+ .widthIn(max = if (overlaySize == ReaderTtsOverlaySize.MEDIUM) 560.dp else 400.dp)
+ .animateContentSize()
) {
AnimatedContent(
- targetState = isCollapsed,
+ targetState = overlaySize,
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
label = "TtsOverlayUnified"
- ) { collapsed ->
- if (collapsed) {
+ ) { size ->
+ if (size == ReaderTtsOverlaySize.SMALL) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
IconButton(
- onClick = { onCollapseChange(false) },
+ onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.LARGE) },
modifier = Modifier.size(36.dp)
) {
- Icon(Icons.Default.ChevronLeft, "Expand", tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Icon(
+ Icons.Default.KeyboardArrowUp,
+ stringResource(R.string.content_desc_expand),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ IconButton(
+ onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.MEDIUM) },
+ modifier = Modifier.size(36.dp)
+ ) {
+ Icon(
+ Icons.Default.KeyboardArrowLeft,
+ stringResource(R.string.content_desc_expand),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
FilledIconButton(
@@ -2371,6 +2233,114 @@ fun TtsOverlayControls(
)
}
}
+ } else if (size == ReaderTtsOverlaySize.MEDIUM) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .padding(horizontal = 8.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(16.dp))
+ .clickable(onClick = onLocateCurrentChunk)
+ .padding(horizontal = 10.dp, vertical = 6.dp),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = miniBarTitle,
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (miniBarSubtitle.isNotBlank()) {
+ Text(
+ text = miniBarSubtitle,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ Spacer(Modifier.width(4.dp))
+
+ IconButton(
+ enabled = canSkipPreviousChunk,
+ onClick = { ttsController.skipToPreviousChunk() },
+ modifier = Modifier.size(40.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.SkipPrevious,
+ contentDescription = stringResource(R.string.content_desc_tts_previous_chunk),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
+ FilledIconButton(
+ onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() },
+ modifier = Modifier.size(44.dp),
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
+ contentColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Icon(
+ painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
+ stringResource(R.string.content_desc_play_pause),
+ modifier = Modifier.size(22.dp)
+ )
+ }
+ if (ttsState.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp),
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
+ strokeWidth = 2.dp
+ )
+ }
+ }
+
+ IconButton(
+ enabled = canSkipNextChunk,
+ onClick = { ttsController.skipToNextChunk() },
+ modifier = Modifier.size(40.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.SkipNext,
+ contentDescription = stringResource(R.string.content_desc_tts_next_chunk),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
+ IconButton(
+ onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.LARGE) },
+ modifier = Modifier.size(34.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowUp,
+ contentDescription = stringResource(R.string.content_desc_expand),
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ IconButton(
+ onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.SMALL) },
+ modifier = Modifier.size(34.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowRight,
+ contentDescription = stringResource(R.string.content_desc_collapse),
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
} else {
Column(modifier = Modifier.padding(16.dp)) {
Row(
@@ -2378,13 +2348,16 @@ fun TtsOverlayControls(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(
+ modifier = Modifier.weight(1f),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(8.dp)
) {
Text(
- if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
+ if (activeMode == org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
stringResource(R.string.tts_mode_cloud_ai)
} else {
stringResource(R.string.tts_mode_device_native)
@@ -2399,7 +2372,7 @@ fun TtsOverlayControls(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(8.dp)
) {
- val voiceName = if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
+ val voiceName = if (activeMode == org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
GEMINI_TTS_SPEAKERS.find { it.id == ttsState.speakerId }?.name ?: ttsState.speakerId
} else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: stringResource(R.string.label_default)
@@ -2413,7 +2386,7 @@ fun TtsOverlayControls(
)
}
- if (BuildConfig.FLAVOR != "oss" && activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
+ if (BuildConfig.FLAVOR != "oss" && activeMode == org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(8.dp)
@@ -2428,6 +2401,8 @@ fun TtsOverlayControls(
}
}
+ Spacer(Modifier.width(8.dp))
+
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(onClick = onLocateCurrentChunk, modifier = Modifier.size(32.dp)) {
Icon(
@@ -2437,8 +2412,27 @@ fun TtsOverlayControls(
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
- IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) {
- Icon(Icons.Default.ChevronRight, stringResource(R.string.content_desc_collapse), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ IconButton(
+ onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.MEDIUM) },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ Icons.Default.KeyboardArrowDown,
+ stringResource(R.string.content_desc_collapse),
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ IconButton(
+ onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.SMALL) },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ Icons.Default.KeyboardArrowRight,
+ stringResource(R.string.content_desc_collapse),
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, stringResource(R.string.content_desc_stop_tts), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
@@ -2560,7 +2554,7 @@ fun TtsOverlayControls(
Slider(
value = rate,
onValueChange = {
- rate = it; if (!isDraggingRate && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
+ rate = it; if (!isDraggingRate && activeMode != org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
isDraggingRate = true; ttsController.pause()
}
},
@@ -2633,7 +2627,7 @@ fun TtsOverlayControls(
Slider(
value = pitch,
onValueChange = {
- pitch = it; if (!isDraggingPitch && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
+ pitch = it; if (!isDraggingPitch && activeMode != org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
isDraggingPitch = true; ttsController.pause()
}
},
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderDrawer.kt
similarity index 99%
rename from app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderDrawer.kt
index 9cc33de..6efef74 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderDrawer.kt
@@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader.epubreader
import android.graphics.BitmapFactory
import androidx.compose.animation.animateColorAsState
@@ -97,10 +97,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastSumBy
-import com.aryan.reader.R
-import com.aryan.reader.RenderMode
-import com.aryan.reader.epub.EpubChapter
-import com.aryan.reader.epub.EpubTocEntry
+import org.dueattendant149.bookreader.R
+import org.dueattendant149.bookreader.RenderMode
+import org.dueattendant149.bookreader.epub.EpubChapter
+import org.dueattendant149.bookreader.epub.EpubTocEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderImages.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderImages.kt
similarity index 97%
rename from app/src/main/java/com/aryan/reader/epubreader/EpubReaderImages.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderImages.kt
index cab8d29..d179398 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderImages.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderImages.kt
@@ -1,8 +1,8 @@
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader.epubreader
-import com.aryan.reader.epub.EpubBook
-import com.aryan.reader.epub.EpubChapter
-import com.aryan.reader.paginatedreader.AndroidHtmlResourceResolver
+import org.dueattendant149.bookreader.epub.EpubBook
+import org.dueattendant149.bookreader.epub.EpubChapter
+import org.dueattendant149.bookreader.paginatedreader.AndroidHtmlResourceResolver
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.io.File
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderScreen.kt
similarity index 80%
rename from app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderScreen.kt
index 3f8996e..cfbfd58 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderScreen.kt
@@ -22,16 +22,13 @@
"UnusedVariable", "Unused", "SimplifyBooleanWithConstants", "KotlinConstantConditions"
)
-package com.aryan.reader.epubreader
+package org.dueattendant149.bookreader.epubreader
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
-import android.content.ClipData
-import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
-import android.graphics.Bitmap
import android.media.AudioManager
import android.net.Uri
import android.os.Build
@@ -141,6 +138,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -153,73 +151,90 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.util.UnstableApi
-import com.aryan.reader.AiDefinitionResult
-import com.aryan.reader.BuildConfig
-import com.aryan.reader.BuiltInThemes
-import com.aryan.reader.MainViewModel
-import com.aryan.reader.R
-import com.aryan.reader.ReaderBrightnessEffect
-import com.aryan.reader.ReaderFileInfoDialogs
-import com.aryan.reader.ReaderBrightnessSheet
-import com.aryan.reader.ReaderScreenOrientationEffect
-import com.aryan.reader.ReaderScreenOrientationSheet
-import com.aryan.reader.ReaderThemePanel
-import com.aryan.reader.RenderMode
-import com.aryan.reader.SearchResult
-import com.aryan.reader.SummarizationResult
-import com.aryan.reader.SummaryCacheManager
-import com.aryan.reader.TtsSettingsSheet
-import com.aryan.reader.TtsWordReplacementsSheet
-import com.aryan.reader.areReaderAiFeaturesEnabled
-import com.aryan.reader.countWords
-import com.aryan.reader.isByokCloudTtsAvailable
-import com.aryan.reader.data.CustomFontEntity
-import com.aryan.reader.epub.EpubBook
-import com.aryan.reader.epub.hasReadableExtractedContent
-import com.aryan.reader.fetchAiDefinition
-import com.aryan.reader.loadCustomThemes
-import com.aryan.reader.loadGlobalTextureTransparency
-import com.aryan.reader.loadReaderBrightnessSettings
-import com.aryan.reader.loadReaderScreenOrientationMode
-import com.aryan.reader.loadEpubRightToLeftPagination
-import com.aryan.reader.loadReaderThemeId
-import com.aryan.reader.loadReaderSliderToggled
-import com.aryan.reader.loadReaderTextureBitmap
-import com.aryan.reader.loadTtsReplacementPreferences
-import com.aryan.reader.readerSliderBookmarkPosition
-import com.aryan.reader.readerSliderChromeColors
-import com.aryan.reader.readerSliderToggleState
-import com.aryan.reader.paginatedreader.BookPaginator
-import com.aryan.reader.paginatedreader.HeaderBlock
-import com.aryan.reader.paginatedreader.IPaginator
-import com.aryan.reader.paginatedreader.ListItemBlock
-import com.aryan.reader.paginatedreader.Locator
-import com.aryan.reader.paginatedreader.LocatorConverter
-import com.aryan.reader.paginatedreader.PaginatedReaderScreen
-import com.aryan.reader.paginatedreader.ParagraphBlock
-import com.aryan.reader.paginatedreader.QuoteBlock
-import com.aryan.reader.paginatedreader.TextContentBlock
-import com.aryan.reader.paginatedreader.TtsChunk
-import com.aryan.reader.paginatedreader.data.BookCacheDatabase
-import com.aryan.reader.paginatedreader.semanticBlockModule
-import com.aryan.reader.rememberSearchState
-import com.aryan.reader.saveCustomThemes
-import com.aryan.reader.saveGlobalTextureTransparency
-import com.aryan.reader.saveReaderBrightnessSettings
-import com.aryan.reader.saveReaderScreenOrientationMode
-import com.aryan.reader.saveEpubRightToLeftPagination
-import com.aryan.reader.saveReaderThemeId
-import com.aryan.reader.saveReaderSliderToggled
-import com.aryan.reader.saveTtsReplacementPreferences
-import com.aryan.reader.shouldRenderReaderSlider
-import com.aryan.reader.shared.ReaderTtsReplacementPreferences
-import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
-import com.aryan.reader.tts.SpeakerSamplePlayer
-import com.aryan.reader.tts.TtsPlaybackManager
-import com.aryan.reader.tts.loadTtsMode
-import com.aryan.reader.tts.splitTextIntoChunks
-import com.aryan.reader.withTtsReplacements
-import com.aryan.reader.shared.reader.ReaderJumpHistory
+import org.dueattendant149.bookreader.AiDefinitionResult
+import org.dueattendant149.bookreader.BuildConfig
+import org.dueattendant149.bookreader.copyPlainTextToClipboard
+import org.dueattendant149.bookreader.BookWordReplacementsSheet
+import org.dueattendant149.bookreader.BuiltInThemes
+import org.dueattendant149.bookreader.MainViewModel
+import org.dueattendant149.bookreader.R
+import org.dueattendant149.bookreader.ReaderBrightnessEffect
+import org.dueattendant149.bookreader.ReaderFileInfoDialogs
+import org.dueattendant149.bookreader.ReaderBrightnessSheet
+import org.dueattendant149.bookreader.ReaderScreenOrientationEffect
+import org.dueattendant149.bookreader.ReaderScreenOrientationSheet
+import org.dueattendant149.bookreader.ReaderThemePanel
+import org.dueattendant149.bookreader.RenderMode
+import org.dueattendant149.bookreader.SearchResult
+import org.dueattendant149.bookreader.SummarizationResult
+import org.dueattendant149.bookreader.SummaryCacheManager
+import org.dueattendant149.bookreader.TtsSettingsSheet
+import org.dueattendant149.bookreader.TtsWordReplacementsSheet
+import org.dueattendant149.bookreader.areReaderAiFeaturesEnabled
+import org.dueattendant149.bookreader.countWords
+import org.dueattendant149.bookreader.isByokCloudTtsAvailable
+import org.dueattendant149.bookreader.data.CustomFontEntity
+import org.dueattendant149.bookreader.epub.EpubBook
+import org.dueattendant149.bookreader.epub.hasReadableExtractedContent
+import org.dueattendant149.bookreader.epub.plainTextCharacterCount
+import org.dueattendant149.bookreader.fetchAiDefinition
+import org.dueattendant149.bookreader.loadCustomThemes
+import org.dueattendant149.bookreader.loadGlobalTextureTransparency
+import org.dueattendant149.bookreader.loadBookReplacementPreferences
+import org.dueattendant149.bookreader.loadReaderBrightnessSettings
+import org.dueattendant149.bookreader.loadReaderScreenOrientationMode
+import org.dueattendant149.bookreader.loadEpubRightToLeftPagination
+import org.dueattendant149.bookreader.loadReaderThemeId
+import org.dueattendant149.bookreader.loadReaderSliderToggled
+import org.dueattendant149.bookreader.loadReaderTextureBitmap
+import org.dueattendant149.bookreader.loadTtsReplacementPreferences
+import org.dueattendant149.bookreader.readerSliderBookmarkPosition
+import org.dueattendant149.bookreader.readerSliderChromeColors
+import org.dueattendant149.bookreader.readerSliderToggleState
+import org.dueattendant149.bookreader.paginatedreader.CssParser
+import org.dueattendant149.bookreader.paginatedreader.BookPaginator
+import org.dueattendant149.bookreader.paginatedreader.HeaderBlock
+import org.dueattendant149.bookreader.paginatedreader.IPaginator
+import org.dueattendant149.bookreader.paginatedreader.ListItemBlock
+import org.dueattendant149.bookreader.paginatedreader.Locator
+import org.dueattendant149.bookreader.paginatedreader.LocatorConverter
+import org.dueattendant149.bookreader.paginatedreader.NativeVerticalLocation
+import org.dueattendant149.bookreader.paginatedreader.NativeVerticalReaderScreen
+import org.dueattendant149.bookreader.paginatedreader.PaginatedReaderScreen
+import org.dueattendant149.bookreader.paginatedreader.ParagraphBlock
+import org.dueattendant149.bookreader.paginatedreader.QuoteBlock
+import org.dueattendant149.bookreader.paginatedreader.TextContentBlock
+import org.dueattendant149.bookreader.paginatedreader.TtsChunk
+import org.dueattendant149.bookreader.paginatedreader.buildEpubFontFaceCss
+import org.dueattendant149.bookreader.paginatedreader.data.BookCacheDatabase
+import org.dueattendant149.bookreader.paginatedreader.locatorForPersistence
+import org.dueattendant149.bookreader.paginatedreader.nativeVerticalChapterPageInfo
+import org.dueattendant149.bookreader.paginatedreader.nativeVerticalProgressForCompatPage
+import org.dueattendant149.bookreader.paginatedreader.semanticBlockModule
+import org.dueattendant149.bookreader.rememberSearchState
+import org.dueattendant149.bookreader.saveCustomThemes
+import org.dueattendant149.bookreader.saveGlobalTextureTransparency
+import org.dueattendant149.bookreader.saveBookReplacementPreferences
+import org.dueattendant149.bookreader.saveReaderBrightnessSettings
+import org.dueattendant149.bookreader.saveReaderScreenOrientationMode
+import org.dueattendant149.bookreader.saveEpubRightToLeftPagination
+import org.dueattendant149.bookreader.saveReaderThemeId
+import org.dueattendant149.bookreader.saveReaderSliderToggled
+import org.dueattendant149.bookreader.saveTtsReplacementPreferences
+import org.dueattendant149.bookreader.shouldRenderReaderSlider
+import org.dueattendant149.bookreader.shared.ReaderBookReplacementPreferences
+import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences
+import org.dueattendant149.bookreader.shared.ReaderLocator as SharedReaderLocator
+import org.dueattendant149.bookreader.tts.ReaderTtsOverlaySize
+import org.dueattendant149.bookreader.tts.SpeakerSamplePlayer
+import org.dueattendant149.bookreader.tts.TtsPlaybackManager
+import org.dueattendant149.bookreader.tts.loadTtsMode
+import org.dueattendant149.bookreader.tts.loadReaderTtsOverlaySize
+import org.dueattendant149.bookreader.tts.readerTtsOverlayAlignmentBias
+import org.dueattendant149.bookreader.tts.saveReaderTtsOverlaySize
+import org.dueattendant149.bookreader.tts.splitTextIntoChunks
+import org.dueattendant149.bookreader.withTtsReplacements
+import org.dueattendant149.bookreader.shared.reader.ReaderJumpHistory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -232,12 +247,14 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
+import org.jsoup.Jsoup
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import kotlin.math.ceil
import kotlin.math.floor
+import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@@ -553,7 +570,7 @@ fun EpubReaderScreen(
coverImagePath: String?,
onRenderModeChange: (RenderMode) -> Unit,
customFonts: List,
- onImportFont: (Uri) -> Unit,
+ onImportFonts: (List) -> Unit,
viewModel: MainViewModel
) {
val uiState by viewModel.uiState.collectAsState()
@@ -635,7 +652,7 @@ fun EpubReaderScreen(
coverImagePath = coverImagePath,
onRenderModeChange = onRenderModeChange,
customFonts = customFonts,
- onImportFont = onImportFont,
+ onImportFonts = onImportFonts,
onToggleReflow = onOpenOriginal,
onDeleteReflow = if (isReflowFile) {
{
@@ -674,7 +691,7 @@ fun EpubReaderHost(
coverImagePath: String?,
onRenderModeChange: (RenderMode) -> Unit,
customFonts: List,
- onImportFont: (Uri) -> Unit,
+ onImportFonts: (List) -> Unit,
onToggleReflow: ((Int) -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null,
stableBookId: String? = null,
@@ -690,7 +707,7 @@ fun EpubReaderHost(
var showBrightnessSheet by remember { mutableStateOf(false) }
ReaderBrightnessEffect(window, readerBrightnessSettings)
- val updateReaderBrightness: (com.aryan.reader.ReaderBrightnessSettings) -> Unit = { settings ->
+ val updateReaderBrightness: (org.dueattendant149.bookreader.ReaderBrightnessSettings) -> Unit = { settings ->
readerBrightnessSettings = settings
saveReaderBrightnessSettings(context, settings)
}
@@ -718,7 +735,6 @@ fun EpubReaderHost(
val scrubDebounceJob = remember { mutableStateOf(null) }
val volumeScrollFocusDebounceJob = remember { mutableStateOf(null) }
var sliderStartPage by remember { mutableIntStateOf(0) }
- var startPageThumbnail by remember { mutableStateOf(null) }
var pendingNoteForNewHighlight by remember { mutableStateOf(false) }
var highlightToNoteCfi by remember { mutableStateOf(null) }
@@ -800,7 +816,7 @@ fun EpubReaderHost(
}
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
- var isTtsCollapsed by remember { mutableStateOf(false) }
+ var ttsOverlaySize by remember(context) { mutableStateOf(loadReaderTtsOverlaySize(context)) }
var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
@@ -994,12 +1010,18 @@ fun EpubReaderHost(
var showRecapPopup by remember { mutableStateOf(false) }
var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) }
+ var useNativeVerticalRenderer by remember { mutableStateOf(loadNativeVerticalRenderer(context)) }
+ val isNativeVerticalMode = currentRenderMode == RenderMode.VERTICAL_SCROLL && useNativeVerticalRenderer
var epubJumpHistory by remember(readerCacheBookId) { mutableStateOf(ReaderJumpHistory()) }
var chapterToLoadOnSwitch by remember { mutableStateOf(null) }
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
var paginatedReconfigurationAnchor by remember { mutableStateOf(null) }
var isPaginatedReconfigurationRestoring by remember { mutableStateOf(false) }
+ LaunchedEffect(useNativeVerticalRenderer) {
+ saveNativeVerticalRenderer(context, useNativeVerticalRenderer)
+ }
+
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
val pageInfoCornerBottomPadding = roundedCornerBottomPadding.coerceAtMost(8.dp)
@@ -1099,7 +1121,7 @@ fun EpubReaderHost(
val ttsState by ttsController.ttsState.collectAsState()
val totalBookLengthChars = remember(chapters) {
- chapters.sumOf { it.plainTextContent.length.toLong() }
+ chapters.sumOf { it.plainTextCharacterCount().toLong() }
}
var topVisibleChunkIndex by remember { mutableIntStateOf(0) }
@@ -1110,6 +1132,20 @@ fun EpubReaderHost(
var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf>(emptyList()) }
var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf>(emptyList()) }
var chapterHead by remember(currentChapterIndex) { mutableStateOf("") }
+ val epubFontFaceCss = remember(epubBook.css, epubBook.extractionBasePath) {
+ val fontFaces = epubBook.css.flatMap { (path, content) ->
+ CssParser.parse(
+ cssContent = content,
+ cssPath = path,
+ baseFontSizeSp = 16f,
+ density = 1f,
+ constraints = Constraints(maxWidth = 1, maxHeight = 1),
+ isDarkTheme = false,
+ adaptThemeColors = false
+ ).fontFaces
+ }
+ buildEpubFontFaceCss(fontFaces, epubBook.extractionBasePath)
+ }
var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) }
var cfiToLoad by remember { mutableStateOf(initialCfi) }
@@ -1117,6 +1153,7 @@ fun EpubReaderHost(
var imageToLoad by remember { mutableStateOf(null) }
var isInitialCfiLoad by remember(initialLocator) { mutableStateOf(initialLocator != null) }
var bookmarkPageMap by remember { mutableStateOf