diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 0000000..a5fb138 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,409 @@ +name: Desktop release + +on: + workflow_dispatch: + inputs: + version: + description: Desktop version, for example 1.0.1 + required: true + default: "1.0.1" + release_tag: + description: GitHub release tag for desktop artifacts + required: true + default: "v1.0.1" + pdfium_tag: + description: GitHub release tag containing Pdfium zip assets + required: true + default: "pdfium-desktop-v1" + draft: + description: Create or update the desktop release as a draft + required: true + default: "true" + type: choice + options: + - "true" + - "false" + +permissions: + contents: write + +env: + DESKTOP_FIREBASE_PROJECT_ID: ${{ secrets.DESKTOP_FIREBASE_PROJECT_ID }} + DESKTOP_FIREBASE_WEB_API_KEY: ${{ secrets.DESKTOP_FIREBASE_WEB_API_KEY }} + DESKTOP_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.DESKTOP_GOOGLE_OAUTH_CLIENT_ID }} + DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET }} + +jobs: + windows-msi: + name: Windows MSI (${{ matrix.flavor.label }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + flavor: + - label: standard + gradle: standard + - label: oss + gradle: oss + steps: + - uses: actions/checkout@v4 + + - name: Configure Gradle + shell: pwsh + run: | + @' + kotlin.code.style=official + kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8 + kotlin.mpp.applyDefaultHierarchyTemplate=false + org.gradle.caching=true + org.gradle.configuration-cache=true + org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 + org.gradle.workers.max=2 + '@ | Set-Content -Path gradle.properties -NoNewline -Encoding UTF8 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Download Pdfium + shell: pwsh + run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}" + env: + GH_TOKEN: ${{ github.token }} + + - name: Install WiX + shell: pwsh + run: choco install wixtoolset --no-progress -y + + - name: Build MSI + shell: pwsh + env: + DESKTOP_FLAVOR: ${{ matrix.flavor.gradle }} + DESKTOP_VERSION: ${{ inputs.version }} + run: | + $gradleArgs = @( + "-PdesktopOnly=true", + "-PdesktopFlavor=$env:DESKTOP_FLAVOR", + "-PdesktopVersion=$env:DESKTOP_VERSION", + "-PdesktopPackageVersion=$env:DESKTOP_VERSION", + ":desktopApp:packageReleaseMsi" + ) + ./gradlew.bat @gradleArgs + + - name: Stage MSI + shell: pwsh + run: | + New-Item -ItemType Directory -Force release-assets | Out-Null + $source = Get-ChildItem desktopApp/build/compose/binaries/main-release/msi -Filter *.msi | Select-Object -First 1 + if ($null -eq $source) { throw "MSI output not found" } + Copy-Item $source.FullName "release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-windows-x64.msi" + + - uses: actions/upload-artifact@v4 + with: + name: windows-msi-${{ matrix.flavor.label }} + path: release-assets/* + + ubuntu-deb-tar: + name: Ubuntu DEB and tar (${{ matrix.flavor.label }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + flavor: + - label: standard + gradle: standard + - label: oss + gradle: oss + steps: + - uses: actions/checkout@v4 + + - name: Configure Gradle + run: | + cat > gradle.properties <<'EOF' + kotlin.code.style=official + kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8 + kotlin.mpp.applyDefaultHierarchyTemplate=false + org.gradle.caching=true + org.gradle.configuration-cache=true + org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 + org.gradle.workers.max=2 + EOF + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Make Gradle wrapper executable + run: chmod +x gradlew + + - name: Install packaging tools + run: sudo apt-get update && sudo apt-get install -y fakeroot dpkg-dev binutils + + - name: Download Pdfium + shell: pwsh + run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}" + env: + GH_TOKEN: ${{ github.token }} + + - name: Build DEB and Linux tarball + run: > + ./gradlew + -PdesktopOnly=true + -PdesktopFlavor=${{ matrix.flavor.gradle }} + -PdesktopVersion=${{ inputs.version }} + -PdesktopPackageVersion=${{ inputs.version }} + :desktopApp:packageDeb + :desktopApp:packageLinuxTar + + - name: Stage DEB and tarball + run: | + set -euo pipefail + mkdir -p release-assets + cp "$(find desktopApp/build/compose/binaries/main/deb -name '*.deb' -print -quit)" \ + "release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-amd64.deb" + cp "$(find desktopApp/build/compose/binaries/main/linux-tar -name '*.tar.gz' -print -quit)" \ + "release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x64.tar.gz" + + - uses: actions/upload-artifact@v4 + with: + name: ubuntu-deb-tar-${{ matrix.flavor.label }} + path: release-assets/* + + fedora-rpm: + name: Fedora RPM (${{ matrix.flavor.label }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + flavor: + - label: standard + gradle: standard + - label: oss + gradle: oss + steps: + - uses: actions/checkout@v4 + + - name: Configure Gradle + run: | + cat > gradle.properties <<'EOF' + kotlin.code.style=official + kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8 + kotlin.mpp.applyDefaultHierarchyTemplate=false + org.gradle.caching=true + org.gradle.configuration-cache=true + org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 + org.gradle.workers.max=2 + EOF + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Make Gradle wrapper executable + run: chmod +x gradlew + + - name: Download Pdfium + shell: pwsh + run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}" + env: + GH_TOKEN: ${{ github.token }} + + - name: Build RPM in Fedora + run: | + set -euo pipefail + docker run --rm \ + -v "$PWD:/workspace" \ + -v "$JAVA_HOME:/host-jdk-21:ro" \ + -w /workspace \ + -e DESKTOP_FIREBASE_PROJECT_ID \ + -e DESKTOP_FIREBASE_WEB_API_KEY \ + -e DESKTOP_GOOGLE_OAUTH_CLIENT_ID \ + -e DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET \ + fedora:44 bash -lc ' + set -euo pipefail + dnf install -y ca-certificates findutils git rpm-build unzip which + update-ca-trust + export JAVA_HOME=/host-jdk-21 + export PATH="$JAVA_HOME/bin:$PATH" + if [ -f /etc/pki/java/cacerts ]; then + export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/etc/pki/java/cacerts ${JAVA_TOOL_OPTIONS:-}" + elif [ -f /etc/pki/ca-trust/extracted/java/cacerts ]; then + export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/etc/pki/ca-trust/extracted/java/cacerts ${JAVA_TOOL_OPTIONS:-}" + fi + JPACKAGE_HOME="$JAVA_HOME" + ./gradlew \ + -PdesktopOnly=true \ + -PdesktopFlavor=${{ matrix.flavor.gradle }} \ + -PdesktopVersion=${{ inputs.version }} \ + -PdesktopPackageVersion=${{ inputs.version }} \ + -PdesktopPackagingJavaHome="$JPACKAGE_HOME" \ + :desktopApp:packageRpm + ' + sudo chown -R "$USER:$USER" desktopApp/build + + - name: Stage RPM + run: | + set -euo pipefail + mkdir -p release-assets + cp "$(find desktopApp/build/compose/binaries/main/rpm -name '*.rpm' -print -quit)" \ + "release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x86_64.rpm" + + - uses: actions/upload-artifact@v4 + with: + name: fedora-rpm-${{ matrix.flavor.label }} + path: release-assets/* + + arch-package: + name: Arch package (${{ matrix.flavor.label }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + flavor: + - label: standard + gradle: standard + aur: episteme-bin + - label: oss + gradle: oss + aur: episteme-oss-bin + steps: + - uses: actions/checkout@v4 + + - name: Configure Gradle + run: | + cat > gradle.properties <<'EOF' + kotlin.code.style=official + kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8 + kotlin.mpp.applyDefaultHierarchyTemplate=false + org.gradle.caching=true + org.gradle.configuration-cache=true + org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 + org.gradle.workers.max=2 + EOF + + - name: Make Gradle wrapper executable + run: chmod +x gradlew + + - name: Download Pdfium + shell: pwsh + run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}" + env: + GH_TOKEN: ${{ github.token }} + + - name: Build Arch package + run: | + set -euo pipefail + docker run --rm \ + -v "$PWD:/workspace" \ + -w /workspace \ + -e DESKTOP_FIREBASE_PROJECT_ID \ + -e DESKTOP_FIREBASE_WEB_API_KEY \ + -e DESKTOP_GOOGLE_OAUTH_CLIENT_ID \ + -e DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET \ + archlinux:latest bash -lc ' + set -euo pipefail + pacman -Syu --noconfirm + pacman -S --needed --noconfirm base-devel desktop-file-utils git jdk21-openjdk namcap unzip + export JAVA_HOME=/usr/lib/jvm/java-21-openjdk + export PATH="$JAVA_HOME/bin:$PATH" + useradd -m builder + chown -R builder:builder /workspace + runuser -u builder -- env \ + JAVA_HOME="$JAVA_HOME" \ + PATH="$PATH" \ + DESKTOP_FIREBASE_PROJECT_ID="$DESKTOP_FIREBASE_PROJECT_ID" \ + DESKTOP_FIREBASE_WEB_API_KEY="$DESKTOP_FIREBASE_WEB_API_KEY" \ + DESKTOP_GOOGLE_OAUTH_CLIENT_ID="$DESKTOP_GOOGLE_OAUTH_CLIENT_ID" \ + DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET="$DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET" \ + bash -lc "cd /workspace && ./gradlew \ + -PdesktopOnly=true \ + -PdesktopFlavor=${{ matrix.flavor.gradle }} \ + -PdesktopVersion=${{ inputs.version }} \ + -PdesktopPackageVersion=${{ inputs.version }} \ + -PdesktopAurPackageRelease=1 \ + :desktopApp:prepareAurPackage && \ + cd desktopApp/build/aur/${{ matrix.flavor.aur }} && \ + makepkg -sf --cleanbuild --nodeps && \ + makepkg --printsrcinfo > .SRCINFO.generated && \ + cmp -s .SRCINFO .SRCINFO.generated && \ + pkgfile=\$(find . -maxdepth 1 -name '*.pkg.tar.zst' -print -quit) && \ + desktop_file=${{ matrix.flavor.aur }} && \ + desktop_file=\${desktop_file%-bin}.desktop && \ + tmpdir=\$(mktemp -d) && \ + bsdtar -xOf \"\$pkgfile\" \"usr/share/applications/\$desktop_file\" > \"\$tmpdir/\$desktop_file\" && \ + desktop-file-validate \"\$tmpdir/\$desktop_file\" && \ + namcap PKGBUILD && \ + namcap \"\$pkgfile\" && \ + cd /workspace && \ + mkdir -p release-assets && \ + cp \$(find desktopApp/build/aur/${{ matrix.flavor.aur }} -maxdepth 1 -name '*.pkg.tar.zst' -print -quit) \ + release-assets/${{ matrix.flavor.aur }}-${{ inputs.version }}-1-x86_64.pkg.tar.zst && \ + ./gradlew \ + -PdesktopOnly=true \ + -PdesktopFlavor=${{ matrix.flavor.gradle }} \ + -PdesktopVersion=${{ inputs.version }} \ + -PdesktopPackageVersion=${{ inputs.version }} \ + -PdesktopAurPackageRelease=1 \ + -PdesktopAurSourceUrl=https://github.com/${{ github.repository }}/releases/download/${{ inputs.release_tag }}/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x64.tar.gz \ + :desktopApp:prepareAurPackage && \ + cd desktopApp/build/aur/${{ matrix.flavor.aur }} && \ + makepkg --printsrcinfo > .SRCINFO.generated && \ + cmp -s .SRCINFO .SRCINFO.generated" + ' + sudo chown -R "$USER:$USER" desktopApp/build release-assets + + - name: Stage Arch package and AUR metadata + run: | + set -euo pipefail + mkdir -p release-assets + tar -C "desktopApp/build/aur/${{ matrix.flavor.aur }}" \ + -czf "release-assets/aur-${{ matrix.flavor.aur }}-${{ inputs.version }}.tar.gz" \ + PKGBUILD .SRCINFO + + - uses: actions/upload-artifact@v4 + with: + name: arch-package-${{ matrix.flavor.label }} + path: release-assets/* + + publish-release: + name: Publish draft release + runs-on: ubuntu-latest + needs: + - windows-msi + - ubuntu-deb-tar + - fedora-rpm + - arch-package + steps: + - uses: actions/download-artifact@v4 + with: + path: downloaded-artifacts + + - name: Collect release assets + run: | + set -euo pipefail + mkdir -p release-assets + find downloaded-artifacts -type f -exec cp {} release-assets/ \; + (cd release-assets && sha256sum * > SHA256SUMS.txt) + + - name: Create or update GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if ! gh release view "${{ inputs.release_tag }}" --repo "${{ github.repository }}" >/dev/null 2>&1; then + draft_flag="" + if [ "${{ inputs.draft }}" = "true" ]; then + draft_flag="--draft" + fi + gh release create "${{ inputs.release_tag }}" \ + --repo "${{ github.repository }}" \ + --title "Episteme ${{ inputs.version }}" \ + --notes "Desktop release ${{ inputs.version }}" \ + $draft_flag + fi + gh release upload "${{ inputs.release_tag }}" release-assets/* \ + --repo "${{ github.repository }}" \ + --clobber diff --git a/.gitignore b/.gitignore index 31a2c96..e183ade 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,9 @@ google-services.json .gradle/ third_party/pdfium/ *.tgz -kcef-bundle/ -kcef-bundle-linux-x64/ cache/ worker/ -output/ \ No newline at end of file +output/ +policies/ +episteme-bin/ +episteme-oss-bin/ diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index b8dcf92..731e355 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -162,6 +162,19 @@ + + + + + + + @@ -309,6 +322,19 @@ + + + + + + + @@ -357,6 +383,7 @@ + @@ -534,6 +561,19 @@ + + + + + + + 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.

- Get it on F-Droid Get it on Google Play   Get it on Obtainium + Download from epistemereader.com  Get it on F-Droid Get it on Google Play   Get it on Obtainium
-![Episteme Reader Preview](docs/EPISTEME.png) + + + + + +
+ Episteme Reader on Android +
+ Android +
+ Episteme Reader on desktop +
+ 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 avatar[CCerrer](https://github.com/CCerrer) | Testing & QA | -| ottozumkeller avatar [ottozumkeller](https://github.com/ottozumkeller) | Translation (German) | -| TURBOKANTR avatar [TURBOKANTR](https://github.com/TURBOKANTR) | Translation (Turkish) | -| eyadalkordy24 avatar[eyadalkordy24](https://github.com/eyadalkordy24) | Translation (Arabic) | -| berebara avatar[berebara](https://github.com/berebara) | Translation (Russian) | -| mh4ckt3mh4ckt1c4s avatar[mh4ckt3mh4ckt1c4s](https://github.com/mh4ckt3mh4ckt1c4s) | Translation (French) | +| CCerrer avatar [CCerrer](https://github.com/CCerrer) | Testing and QA | +| ottozumkeller avatar [ottozumkeller](https://github.com/ottozumkeller) | German translation | +| TURBOKANTR avatar [TURBOKANTR](https://github.com/TURBOKANTR) | Turkish translation | +| eyadalkordy24 avatar [eyadalkordy24](https://github.com/eyadalkordy24) | Arabic translation | +| berebara avatar [berebara](https://github.com/berebara) | Russian translation | +| mh4ckt3mh4ckt1c4s avatar [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: -[![Translation status](https://hosted.weblate.org/widget/episteme/multi-auto.svg)](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 avatar [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.

+
+ Fixture diagram +
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 @@ + + + Fixture diagram + A simple local SVG used by EPUB UI tests. + + + + EPUB FIXTURE + 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( + """ +

Danger

+

Okay

+ """.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>(emptyMap()) } + var bookmarkLocatorMap by remember { mutableStateOf>(emptyMap()) } LaunchedEffect(Unit) { Timber.tag("POS_DIAG").d("Reader Opening: initialLocator=$initialLocator, initialCfi=$initialCfi") @@ -1140,13 +1177,71 @@ fun EpubReaderHost( var currentScrollHeightValue by remember { mutableIntStateOf(0) } var currentClientHeightValue by remember { mutableIntStateOf(0) } + var nativeVerticalCurrentPage by rememberSaveable(epubBook.title) { mutableIntStateOf(0) } + var nativeVerticalTotalPages by remember { mutableIntStateOf(0) } + var nativeVerticalProgress by remember { mutableFloatStateOf(0f) } + var nativeVerticalLocation by remember { mutableStateOf(null) } + var nativeVerticalScrollRequest by remember { mutableStateOf(null) } + var nativeVerticalLocatorScrollRequest by remember { mutableStateOf(null) } + var nativeVerticalLocatorScrollRequestId by remember { mutableLongStateOf(0L) } + var nativeVerticalLocatorScrollKeepVisible by remember { mutableStateOf(false) } + var nativeVerticalProgressScrollRequest by remember { mutableStateOf(null) } + var nativeVerticalProgressScrollRequestId by remember { mutableLongStateOf(0L) } + var nativeVerticalScrollDeltaRequest by remember { mutableStateOf(null) } + var nativeVerticalScrollDeltaRequestId by remember { mutableLongStateOf(0L) } + var nativeVerticalScrollDeltaAnimated by remember { mutableStateOf(true) } - val currentBookProgress by remember(currentChapterIndex, currentScrollYPosition, currentScrollHeightValue, currentClientHeightValue, totalBookLengthChars) { + fun currentNativeVerticalLocator(): Locator? { + val bookPaginator = paginator as? BookPaginator + val pageChapterIndex = bookPaginator?.findChapterIndexForPage(nativeVerticalCurrentPage) + return nativeVerticalLocation?.locatorForPersistence() + ?: lastKnownLocator?.takeIf { pageChapterIndex == null || it.chapterIndex == pageChapterIndex } + ?: bookPaginator?.getLocatorForPage(nativeVerticalCurrentPage) + } + + fun requestNativeVerticalLocatorScroll( + locator: Locator?, + fallbackPage: Int? = null, + fallbackChapterIndex: Int? = locator?.chapterIndex, + keepVisible: Boolean = false + ) { + if (locator != null) { + nativeVerticalScrollRequest = null + nativeVerticalProgressScrollRequest = null + nativeVerticalLocatorScrollRequest = locator + nativeVerticalLocatorScrollRequestId += 1L + nativeVerticalLocatorScrollKeepVisible = keepVisible + lastKnownLocator = locator + currentChapterIndex = locator.chapterIndex + } else if (fallbackPage != null) { + nativeVerticalScrollRequest = fallbackPage + nativeVerticalLocatorScrollKeepVisible = false + fallbackChapterIndex?.let { currentChapterIndex = it } + } + } + + fun requestNativeVerticalProgressScroll(progressPercent: Float) { + nativeVerticalProgressScrollRequest = progressPercent.coerceIn(0f, 100f) + nativeVerticalProgressScrollRequestId += 1L + } + + val currentBookProgress by remember( + currentChapterIndex, + currentScrollYPosition, + currentScrollHeightValue, + currentClientHeightValue, + totalBookLengthChars, + isNativeVerticalMode, + nativeVerticalProgress + ) { derivedStateOf { + if (isNativeVerticalMode) { + return@derivedStateOf nativeVerticalProgress.coerceIn(0f, 100f) + } if (totalBookLengthChars > 0) { val completedCharsInPreviousChapters = chapters.take(currentChapterIndex) - .sumOf { it.plainTextContent.length.toLong() } + .sumOf { it.plainTextCharacterCount().toLong() } val progressWithinChapter = if (currentScrollHeightValue > currentClientHeightValue) { @@ -1159,7 +1254,7 @@ fun EpubReaderHost( } val currentChapterLengthChars = - chapters.getOrNull(currentChapterIndex)?.plainTextContent?.length?.toLong() ?: 0L + chapters.getOrNull(currentChapterIndex)?.plainTextCharacterCount()?.toLong() ?: 0L val charsScrolledInCurrentChapter = (progressWithinChapter * currentChapterLengthChars).toLong() val totalCharsScrolled = completedCharsInPreviousChapters + charsScrolledInCurrentChapter val calculatedProgress = ((totalCharsScrolled.toDouble() / totalBookLengthChars.toDouble()) * 100.0).toFloat() @@ -1244,7 +1339,11 @@ fun EpubReaderHost( } val searchState = rememberSearchState(scope = scope, searcher = epubSearcher) - val isEpubSliderReady = currentRenderMode == RenderMode.VERTICAL_SCROLL || paginatedPagerState.pageCount > 0 + val isEpubSliderReady = when { + isNativeVerticalMode -> nativeVerticalTotalPages > 0 + currentRenderMode == RenderMode.VERTICAL_SCROLL -> true + else -> paginatedPagerState.pageCount > 0 + } val epubSliderChromeVisible = shouldRenderReaderSlider( isToggledOn = isPageSliderVisible, isBottomChromeVisible = showBars, @@ -1259,6 +1358,14 @@ fun EpubReaderHost( var isAutoScrollTempPaused by remember { mutableStateOf(false) } val autoScrollResumeJob = remember { mutableStateOf(null) } + LaunchedEffect(isNativeVerticalMode) { + if (isNativeVerticalMode) { + webViewRefForTts = null + } else { + nativeVerticalLocation = null + } + } + var isMusicianMode by remember { mutableStateOf(loadMusicianMode(context)) } var autoScrollUseSlider by remember { mutableStateOf(loadAutoScrollUseSlider(context)) } @@ -1300,17 +1407,34 @@ fun EpubReaderHost( } } - LaunchedEffect(isAutoScrollModeActive, isAutoScrollPlaying, autoScrollSpeed, isAutoScrollTempPaused) { - if (isAutoScrollModeActive) { + LaunchedEffect(isAutoScrollModeActive, isAutoScrollPlaying, autoScrollSpeed, isAutoScrollTempPaused, isNativeVerticalMode) { + if (isNativeVerticalMode) { + webViewRefForTts?.evaluateJavascript("javascript:window.autoScroll.stop();", null) + } else if (isAutoScrollModeActive) { updateAutoScrollState(isAutoScrollPlaying, autoScrollSpeed) } else { webViewRefForTts?.evaluateJavascript("javascript:window.autoScroll.stop();", null) } } + LaunchedEffect(isNativeVerticalMode, isAutoScrollModeActive, isAutoScrollPlaying, autoScrollSpeed, isAutoScrollTempPaused) { + if (!isNativeVerticalMode) return@LaunchedEffect + while (isActive && isAutoScrollModeActive && isAutoScrollPlaying && !isAutoScrollTempPaused) { + if (nativeVerticalLocation?.isAtEnd == true) { + isAutoScrollPlaying = false + break + } + nativeVerticalScrollDeltaRequestId += 1L + nativeVerticalScrollDeltaAnimated = false + nativeVerticalScrollDeltaRequest = autoScrollSpeed.coerceAtLeast(0f) * 0.5f + delay(16L) + } + } + var showPermissionRationaleDialog by remember { mutableStateOf(false) } var showTtsSettingsSheet by remember { mutableStateOf(false) } var showTtsReplacementsSheet by remember { mutableStateOf(false) } + var showBookReplacementsSheet by remember { mutableStateOf(false) } var showTtsControlsSheet by remember { mutableStateOf(false) } var showThemePanel by remember { mutableStateOf(false) } var showPaletteManager by remember { mutableStateOf(false) } @@ -1319,6 +1443,14 @@ fun EpubReaderHost( ttsReplacementPreferences = next saveTtsReplacementPreferences(context, next) } + var bookReplacementPreferences by remember { mutableStateOf(loadBookReplacementPreferences(context)) } + val updateBookReplacementPreferences: (ReaderBookReplacementPreferences) -> Unit = { next -> + bookReplacementPreferences = next + saveBookReplacementPreferences(context, next) + } + val bookReplacementSignature = remember(bookReplacementPreferences, bookId) { + bookReplacementPreferences.signatureForFile(bookId) + } var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) } var customThemes by remember { mutableStateOf(loadCustomThemes(context)) } @@ -1447,13 +1579,13 @@ fun EpubReaderHost( suspend fun saveResolvedLocatorPosition(locator: Locator, cfiForWebView: String?) { lastKnownLocator = locator - val chapterLengthChars = chapters.getOrNull(locator.chapterIndex)?.plainTextContent?.length?.toLong() ?: 0L + val chapterLengthChars = chapters.getOrNull(locator.chapterIndex)?.plainTextCharacterCount()?.toLong() ?: 0L val exactOffset = locatorConverter.getTextOffset(epubBook, locator)?.coerceAtLeast(0) ?: 0 val boundedOffset = exactOffset.coerceAtMost(chapterLengthChars.toInt()).toLong() val progress = if (totalBookLengthChars > 0) { val completedCharsInPreviousChapters = - chapters.take(locator.chapterIndex).sumOf { it.plainTextContent.length.toLong() } + chapters.take(locator.chapterIndex).sumOf { it.plainTextCharacterCount().toLong() } val totalCharsScrolled = completedCharsInPreviousChapters + boundedOffset val calculatedProgress = ((totalCharsScrolled.toDouble() / totalBookLengthChars.toDouble()) * 100.0).toFloat() @@ -1495,9 +1627,15 @@ fun EpubReaderHost( val chapterIndex = getActiveTtsChapterIndex() ?: return false val sourceCfi = (ttsState.currentWordSourceCfi ?: ttsState.sourceCfi)?.takeIf { it.isNotBlank() } ?: return false - val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, sourceCfi) ?: return false + val sourceOffset = ttsState.currentWordStartOffset.takeIf { it >= 0 } + ?: ttsState.startOffsetInSource.takeIf { it >= 0 } + val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, sourceCfi) + ?.let { baseLocator -> + sourceOffset?.let { baseLocator.copy(charOffset = it) } ?: baseLocator + } + ?: return false - logTtsChapterDiag("Persisting active TTS position. reason=$reason chapter=$chapterIndex cfi=${sourceCfi.take(48)}") + logTtsChapterDiag("Persisting active TTS position. reason=$reason chapter=$chapterIndex cfi=${sourceCfi.take(48)} sourceOffset=$sourceOffset") saveResolvedLocatorPosition(locator, sourceCfi) return true } @@ -1519,7 +1657,9 @@ fun EpubReaderHost( val sourceOffset = ttsState.currentWordStartOffset.takeIf { it >= 0 } ?: ttsState.startOffsetInSource.takeIf { it >= 0 } - val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, sourceCfi) ?: run { + val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, sourceCfi)?.let { baseLocator -> + sourceOffset?.let { baseLocator.copy(charOffset = it) } ?: baseLocator + } ?: run { logTtsChapterDiag("navigateToActiveTtsPosition aborted: locator conversion failed. reason=$reason chapter=$chapterIndex cfi=${sourceCfi.take(48)}") return false } @@ -1530,6 +1670,27 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator ?: run { + logTtsChapterDiag("Native vertical locate aborted: paginator unavailable. reason=$reason") + return false + } + val pageIndex = + bookPaginator.findStablePageForLocator(locator) + ?: bookPaginator.findStableChapterStartPage(chapterIndex) ?: run { + logTtsChapterDiag("Native vertical locate aborted: page lookup failed. reason=$reason chapter=$chapterIndex") + return false + } + logTtsChapterDiag("Native vertical locate scrolling to page=$pageIndex. reason=$reason") + isNavigatingToPosition = true + requestNativeVerticalLocatorScroll( + locator = locator, + fallbackPage = pageIndex, + fallbackChapterIndex = chapterIndex + ) + isNavigatingToPosition = false + return true + } isNavigatingToPosition = true initialScrollTargetForChapter = null isDetachedFromVerticalTts = false @@ -1626,20 +1787,57 @@ fun EpubReaderHost( userStoppedTts = false initiateTtsPlayback( - renderMode = currentRenderMode, - webView = webViewRefForTts, + renderMode = if (isNativeVerticalMode) RenderMode.PAGINATED else currentRenderMode, + webView = if (isNativeVerticalMode) null else webViewRefForTts, onPaginatedStart = { scope.launch { val token = viewModel.getAuthToken() - val currentPage = paginatedPagerState.currentPage - val bookPaginator = paginator as? BookPaginator - val chapterIndex = bookPaginator?.findChapterIndexForPage(currentPage) + val bookPaginator = paginator as? BookPaginator ?: return@launch + val nativeStartLocator = if (isNativeVerticalMode) currentNativeVerticalLocator() else null + val currentPage = nativeStartLocator + ?.let { locator -> bookPaginator.findStablePageForLocator(locator) } + ?: if (isNativeVerticalMode) { + nativeVerticalCurrentPage + } else { + paginatedPagerState.currentPage + } + val chapterIndex = nativeStartLocator?.chapterIndex + ?: bookPaginator.findChapterIndexForPage(currentPage) if (chapterIndex != null) { val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0 val pageInChapter = currentPage - chapterStartPage val allTtsChunks = bookPaginator.getTtsChunksForChapter(chapterIndex) - val firstChunkOnPage = if (pageInChapter > 0) { + val firstChunkOnPage = if (nativeStartLocator != null && !allTtsChunks.isNullOrEmpty()) { + val sourceCfi = locatorConverter.getCfiFromLocator(epubBook, nativeStartLocator, bookId) + val target = TtsChunk( + text = "", + sourceCfi = sourceCfi?.substringBefore(':').orEmpty(), + startOffsetInSource = nativeStartLocator.charOffset + ) + val nativeStartChunkIndex = findTtsChunkStartIndex(allTtsChunks, target) + ?: allTtsChunks.indexOfFirst { chunk -> + nativeStartLocator.charOffset >= chunk.startOffsetInSource && + nativeStartLocator.charOffset < chunk.startOffsetInSource + chunk.text.length + }.takeIf { it >= 0 } + val nativeStartChunk = nativeStartChunkIndex?.let { allTtsChunks.getOrNull(it) } + if (nativeStartChunk != null) { + val relativeOffset = nativeStartLocator.charOffset - nativeStartChunk.startOffsetInSource + val safeRelativeOffset = relativeOffset.coerceIn(0, nativeStartChunk.text.length) + if (safeRelativeOffset > 0) { + val slicedText = nativeStartChunk.text.substring(safeRelativeOffset) + nativeStartChunk.copy( + text = slicedText, + startOffsetInSource = nativeStartLocator.charOffset, + spokenText = slicedText + ) + } else { + nativeStartChunk + } + } else { + null + } + } else if (pageInChapter > 0) { bookPaginator.getTtsChunksForChapter( chapterIndex = chapterIndex, startingFromPageInChapter = pageInChapter @@ -1708,7 +1906,11 @@ fun EpubReaderHost( } ) - fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) { + fun startTtsFromSelectionPaginated( + baseCfi: String, + startOffset: Int, + chapterIndexOverride: Int? = null + ) { if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { showInsufficientCreditsDialog = true return @@ -1718,7 +1920,11 @@ fun EpubReaderHost( scope.launch { val token = viewModel.getAuthToken() val bookPaginator = paginator as? BookPaginator - val chapterIndex = currentChapterInPaginatedMode ?: return@launch + val chapterIndex = if (isNativeVerticalMode) { + chapterIndexOverride ?: currentChapterIndex + } else { + currentChapterInPaginatedMode ?: return@launch + } val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch val foundIdx = findTtsChunkStartIndex( chunks = chunks, @@ -1800,11 +2006,20 @@ fun EpubReaderHost( Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=TTS_CHAPTER_CHANGE, from=$currentChapterIndex, to=$nextIndex") Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("TtsSessionObserver triggered onNavigateToChapter to: $nextIndex") - initialScrollTargetForChapter = ChapterScrollPosition.START - cfiToLoad = null - currentScrollYPosition = 0 - currentScrollHeightValue = 0 - currentChapterIndex = nextIndex + if (isNativeVerticalMode) { + requestNativeVerticalLocatorScroll( + locator = Locator(nextIndex, 0, 0), + fallbackChapterIndex = nextIndex + ) + nativeVerticalProgressScrollRequest = null + webViewRefForTts = null + } else { + initialScrollTargetForChapter = ChapterScrollPosition.START + cfiToLoad = null + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex = nextIndex + } }, onToggleTtsStartOnLoad = { shouldStart -> Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ttsShouldStartOnChapterLoad set to: $shouldStart") @@ -1831,9 +2046,51 @@ fun EpubReaderHost( scope = scope ) + LaunchedEffect( + isNativeVerticalMode, + ttsState.currentText, + ttsState.sourceCfi, + ttsState.startOffsetInSource, + ttsState.chapterIndex, + ttsChapterIndex, + isDetachedFromVerticalTts + ) { + if (!isNativeVerticalMode) return@LaunchedEffect + if (isDetachedFromVerticalTts) return@LaunchedEffect + if (!isActiveReaderTtsForCurrentBook()) return@LaunchedEffect + if (ttsState.currentText.isNullOrBlank()) return@LaunchedEffect + + val activeTtsChapterIndex = getActiveTtsChapterIndex() ?: return@LaunchedEffect + val sourceCfi = ttsState.sourceCfi?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect + val sourceOffset = ttsState.startOffsetInSource.takeIf { it >= 0 } ?: return@LaunchedEffect + val baseLocator = locatorConverter.getLocatorFromCfi( + epubBook, + activeTtsChapterIndex, + sourceCfi, + bookId + ) ?: run { + logTtsChapterDiag("Native vertical TTS follow skipped: locator conversion failed. cfi=${sourceCfi.take(48)} offset=$sourceOffset") + return@LaunchedEffect + } + val locator = baseLocator.copy(charOffset = sourceOffset) + val fallbackPage = (paginator as? BookPaginator)?.findStablePageForLocator(locator) + ?: (paginator as? BookPaginator)?.findStableChapterStartPage(activeTtsChapterIndex) + + logTtsChapterDiag( + "Native vertical following TTS chunk. chapter=$activeTtsChapterIndex " + + "block=${locator.blockIndex} offset=${locator.charOffset} cfi=${sourceCfi.take(48)}" + ) + requestNativeVerticalLocatorScroll( + locator = locator, + fallbackPage = fallbackPage, + fallbackChapterIndex = activeTtsChapterIndex, + keepVisible = true + ) + } + EpubReaderSearchEffects( searchState = searchState, - webViewRef = webViewRefForTts, + webViewRef = if (isNativeVerticalMode) null else webViewRefForTts, currentChapterIndex = currentChapterIndex, focusRequester = searchFocusRequester ) @@ -1874,9 +2131,51 @@ fun EpubReaderHost( } } + val nativeVerticalDisplayPageInfo = remember( + isNativeVerticalMode, + nativeVerticalLocation, + nativeVerticalCurrentPage, + nativeVerticalTotalPages, + currentChapterIndex, + lastKnownLocator, + paginator + ) { + if (!isNativeVerticalMode) { + null + } else { + nativeVerticalLocation?.chapterPageInfo ?: run { + val bookPaginator = paginator as? BookPaginator + val locationLocator = nativeVerticalLocation?.locator + val chapterIndex = nativeVerticalLocation?.chapterIndex + ?: locationLocator?.chapterIndex + ?: lastKnownLocator?.chapterIndex + ?: currentChapterIndex + val locatorForChapter = locationLocator + ?.takeIf { it.chapterIndex == chapterIndex } + ?: lastKnownLocator?.takeIf { it.chapterIndex == chapterIndex } + val chapterLengthChars = chapters + .getOrNull(chapterIndex) + ?.plainTextCharacterCount() + ?: 0 + + nativeVerticalChapterPageInfo( + chapterCharOffset = locatorForChapter?.charOffset, + chapterLengthChars = chapterLengthChars, + chapterPageCount = bookPaginator?.chapterPageCounts?.get(chapterIndex), + compatPageIndex = nativeVerticalCurrentPage, + chapterStartPageIndex = bookPaginator?.chapterStartPageIndices?.get(chapterIndex) + ) + } + } + } + fun currentEpubSliderPage(): Int { return when (currentRenderMode) { - RenderMode.VERTICAL_SCROLL -> currentPageInChapter + RenderMode.VERTICAL_SCROLL -> if (isNativeVerticalMode) { + (nativeVerticalCurrentPage + 1).coerceAtLeast(1) + } else { + currentPageInChapter + } RenderMode.PAGINATED -> (paginatedPagerState.currentPage + 1).coerceAtLeast(1) } } @@ -1913,21 +2212,11 @@ fun EpubReaderHost( } } - LaunchedEffect(isPageSliderVisible, epubSliderChromeVisible, currentRenderMode, currentPageInChapter, paginatedPagerState.currentPage) { + LaunchedEffect(isPageSliderVisible, epubSliderChromeVisible, currentRenderMode, currentPageInChapter, nativeVerticalCurrentPage, paginatedPagerState.currentPage, isFastScrubbing) { if (isPageSliderVisible && !epubSliderChromeVisible) { resetEpubSliderBookmark() - } - } - - LaunchedEffect(epubSliderChromeVisible, currentRenderMode, sliderStartPage, webViewRefForTts) { - if (epubSliderChromeVisible && currentRenderMode == RenderMode.VERTICAL_SCROLL) { - startPageThumbnail?.recycle() - startPageThumbnail = webViewRefForTts?.let { webView -> - captureWebViewVisibleArea(webView) - } - } else if (!epubSliderChromeVisible || currentRenderMode == RenderMode.PAGINATED) { - startPageThumbnail?.recycle() - startPageThumbnail = null + } else if (epubSliderChromeVisible && !isFastScrubbing) { + sliderCurrentPage = currentEpubSliderPage().toFloat() } } @@ -2115,8 +2404,6 @@ fun EpubReaderHost( chapterChunks = emptyList() chapterChunkElementStartIndices = emptyList() chapterChunkElementCounts = emptyList() - startPageThumbnail?.recycle() - startPageThumbnail = null autoScrollResumeJob.value?.cancel() autoScrollResumeJob.value = null } @@ -2175,7 +2462,7 @@ fun EpubReaderHost( } } - LaunchedEffect(currentChapterIndex) { + LaunchedEffect(currentChapterIndex, bookReplacementSignature) { isChapterParsing = true isChapterReadyForBookmarkCheck = false activeFragmentId = null @@ -2187,7 +2474,9 @@ fun EpubReaderHost( chunkTargetOverride = chunkTargetOverride, isInitialCfiLoad = isInitialCfiLoad, cfiToLoad = cfiToLoad, - locatorConverter = locatorConverter + locatorConverter = locatorConverter, + bookReplacementPreferences = bookReplacementPreferences, + bookReplacementFileId = bookId ) chapterHead = result.head @@ -2256,6 +2545,7 @@ fun EpubReaderHost( @Suppress("SENSELESS_COMPARISON") if (pageToScrollTo != null) { Timber.d("Scrolling to page: $pageToScrollTo") + delay(16) paginatedPagerState.scrollToPage(pageToScrollTo) } else { Timber.w("Could not determine a page to scroll to.") @@ -2298,7 +2588,7 @@ fun EpubReaderHost( lastKnownLocator = locator val bookPaginator = paginator as? BookPaginator val progress = if (totalBookLengthChars > 0 && bookPaginator != null) { - val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() } + val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextCharacterCount().toLong() } val currentPageInChapter = (bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0).let { pageToSave - it } val charsScrolledInCurrentChapter = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter) val totalCharsScrolled = completedCharsInPreviousChapters + charsScrolledInCurrentChapter @@ -2321,10 +2611,18 @@ fun EpubReaderHost( val pageInfoBarHeight = PAGE_INFO_BAR_HEIGHT + pageInfoCornerBottomPadding - val isPageInfoVisible = when (pageInfoMode) { - PageInfoMode.DEFAULT -> !showBars - PageInfoMode.SYNC -> showBars - PageInfoMode.HIDDEN -> false + val isPageInfoVisible = shouldShowEpubPageInfoBar( + pageInfoMode = pageInfoMode, + showReaderChrome = showBars + ) + + fun androidLocatorCfiToLocator(cfi: String): Locator? { + val parts = cfi.takeIf { it.startsWith("android-locator:") }?.split(':') ?: return null + return Locator( + chapterIndex = parts.getOrNull(1)?.toIntOrNull() ?: return null, + blockIndex = parts.getOrNull(2)?.toIntOrNull() ?: return null, + charOffset = parts.getOrNull(3)?.toIntOrNull() ?: return null + ) } LaunchedEffect(bookmarks, paginator) { @@ -2335,20 +2633,25 @@ fun EpubReaderHost( return@LaunchedEffect } Timber.d("Paginator or bookmarks changed. Re-calculating bookmark page map for ${bookmarks.size} bookmarks.") - val newMap = bookmarkPageMap.toMutableMap() + val activeBookmarkCfis = bookmarks.map { it.cfi }.toSet() + val newMap = bookmarkPageMap.filterKeys { it in activeBookmarkCfis }.toMutableMap() + val newLocatorMap = bookmarkLocatorMap.filterKeys { it in activeBookmarkCfis }.toMutableMap() bookmarks.forEach { bookmark -> - if (newMap.containsKey(bookmark.cfi)) return@forEach + if (newMap.containsKey(bookmark.cfi) && newLocatorMap.containsKey(bookmark.cfi)) return@forEach scope.launch { - val locator = locatorConverter.getLocatorFromCfi( - book = epubBook, - chapterIndex = bookmark.chapterIndex, - cfi = bookmark.cfi - ) + val locator = androidLocatorCfiToLocator(bookmark.cfi) + ?: locatorConverter.getLocatorFromCfi( + book = epubBook, + chapterIndex = bookmark.chapterIndex, + cfi = bookmark.cfi + ) if (locator != null) { Timber.d("Bookmark map: Converted CFI '${bookmark.cfi}' to Locator: $locator") + newLocatorMap[bookmark.cfi] = locator + bookmarkLocatorMap = newLocatorMap.toMap() val pageIndex = bookPaginator.findPageForLocator(locator) if (pageIndex != null) { Timber.d("Bookmark map: Found page $pageIndex for locator.") @@ -2362,6 +2665,8 @@ fun EpubReaderHost( } } } + bookmarkPageMap = newMap.toMap() + bookmarkLocatorMap = newLocatorMap.toMap() } LaunchedEffect(paginatedPagerState.currentPage, paginator, currentRenderMode) { @@ -2396,6 +2701,58 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + scope.launch { + val pageToSave = nativeVerticalCurrentPage + val bookPaginator = paginator as? BookPaginator + val locator = currentNativeVerticalLocator() + val chapterIndex = locator?.chapterIndex ?: bookPaginator?.findChapterIndexForPage(pageToSave) + + if (locator != null) { + val progress = nativeVerticalLocation?.progressPercent ?: if (chapterIndex == null || bookPaginator == null) { + saveResolvedLocatorPosition(locator, null) + onNavigateBack() + return@launch + } else if (totalBookLengthChars > 0) { + val completedCharsInPreviousChapters = + chapters.take(chapterIndex).sumOf { it.plainTextCharacterCount().toLong() } + val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0 + val currentPageInChapter = pageToSave - chapterStartPage + val pageCharsScrolledInCurrentChapter = + bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter) + val chapterChars = + chapters.getOrNull(chapterIndex)?.plainTextCharacterCount()?.toLong() + ?: Long.MAX_VALUE + val locatorCharsScrolledInCurrentChapter = locator + .takeIf { it.chapterIndex == chapterIndex } + ?.charOffset + ?.toLong() + ?.coerceAtLeast(0L) + ?.coerceAtMost(chapterChars) + val charsScrolledInCurrentChapter = + locatorCharsScrolledInCurrentChapter + ?.coerceAtLeast(pageCharsScrolledInCurrentChapter) + ?: pageCharsScrolledInCurrentChapter + val totalCharsScrolled = + completedCharsInPreviousChapters + charsScrolledInCurrentChapter + val calculatedProgress = + ((totalCharsScrolled.toDouble() / totalBookLengthChars.toDouble()) * 100.0).toFloat() + val isLastPageOfBook = pageToSave == nativeVerticalTotalPages - 1 + if (isLastPageOfBook) 100f else calculatedProgress + } else { + nativeVerticalProgress + } + + Timber.d("Final save for native vertical view. Page: $pageToSave, Locator: $locator, Progress: $progress%") + onSavePosition(locator, null, progress) + } else { + Timber.w("Final save for native vertical view failed. Locator is null.") + } + isSavingAndExiting = false + onNavigateBack() + } + return + } webViewRefForTts?.evaluateJavascript( "javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null @@ -2420,7 +2777,7 @@ fun EpubReaderHost( onNavigateBack() return@launch } else if (totalBookLengthChars > 0 && bookPaginator != null) { - val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() } + val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextCharacterCount().toLong() } val currentPageInChapter = (bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0).let { pageToSave - it } val charsScrolledInCurrentChapter = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter) val totalCharsScrolled = completedCharsInPreviousChapters + charsScrolledInCurrentChapter @@ -2459,11 +2816,23 @@ fun EpubReaderHost( return SharedReaderLocator( chapterIndex = chapterIndex, pageIndex = pageIndex, + blockIndex = blockIndex, + charOffset = charOffset, cfi = cfiOverride ?: "android-locator:$chapterIndex:$blockIndex:$charOffset" ) } fun SharedReaderLocator.toAndroidLocatorOrNull(): Locator? { + val chapter = chapterIndex + val block = blockIndex + val offset = charOffset + if (chapter != null && block != null && offset != null) { + return Locator( + chapterIndex = chapter, + blockIndex = block, + charOffset = offset + ) + } val parts = cfi ?.takeIf { it.startsWith("android-locator:") } ?.split(':') @@ -2477,10 +2846,18 @@ fun EpubReaderHost( fun currentEpubJumpLocator(): SharedReaderLocator? { return when (currentRenderMode) { - RenderMode.VERTICAL_SCROLL -> SharedReaderLocator( - chapterIndex = currentChapterIndex, - cfi = "android-scroll:$currentScrollYPosition" - ) + RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + val pageIndex = nativeVerticalLocation?.compatPageIndex ?: nativeVerticalCurrentPage.takeIf { it >= 0 } + val locator = currentNativeVerticalLocator() + locator?.toEpubJumpLocator(pageIndex = pageIndex) + } else { + SharedReaderLocator( + chapterIndex = currentChapterIndex, + cfi = "android-scroll:$currentScrollYPosition" + ) + } + } RenderMode.PAGINATED -> { val pageIndex = paginatedPagerState.currentPage.takeIf { it >= 0 } val locator = (paginator as? BookPaginator)?.getLocatorForPage(paginatedPagerState.currentPage) @@ -2687,6 +3064,23 @@ fun EpubReaderHost( scope.launch { recordEpubJump(chapterStartJumpLocator(image.chapterIndex)) clearPendingTtsRelocationState("sidebar_image_vertical") + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator + val imagePage = bookPaginator?.findStablePageForImageSource( + chapterIndex = image.chapterIndex, + sourcePath = image.sourcePath, + elementId = image.elementId, + ordinalInChapter = image.ordinalInChapter + ) + val targetPage = imagePage?.first + ?: bookPaginator?.findStableChapterStartPage(image.chapterIndex) + requestNativeVerticalLocatorScroll( + locator = imagePage?.second, + fallbackPage = targetPage, + fallbackChapterIndex = image.chapterIndex + ) + return@launch + } imageToLoad = image cfiToLoad = null fragmentToLoad = null @@ -2709,6 +3103,17 @@ fun EpubReaderHost( fun navigateVerticalToCfi(chapterIndex: Int, cfi: String) { scope.launch { val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi) + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator + val targetPage = locator?.let { bookPaginator?.findStablePageForLocator(it) } + ?: bookPaginator?.findStableChapterStartPage(chapterIndex) + requestNativeVerticalLocatorScroll( + locator = locator, + fallbackPage = targetPage, + fallbackChapterIndex = chapterIndex + ) + return@launch + } val targetChunk = locator?.let { it.blockIndex / 20 } cfiToLoad = cfi initialScrollTargetForChapter = null @@ -2736,6 +3141,37 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { clearPendingTtsRelocationState("epub_jump_history") + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator + val directPage = locator.pageIndex?.takeIf { + nativeVerticalTotalPages <= 0 || it in 0 until nativeVerticalTotalPages + } + val targetLocator = when { + cfi.startsWith("android-locator:") -> locator.toAndroidLocatorOrNull() + cfi.isNotBlank() && !cfi.startsWith("android-") && chapterIndex != null -> { + locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi) + } + cfi.startsWith("android-search:") && chapterIndex != null -> { + val targetChunk = cfi.split(':').getOrNull(1)?.toIntOrNull() ?: 0 + Locator(chapterIndex, targetChunk.coerceAtLeast(0) * 20, 0) + } + cfi.startsWith("android-fragment:") && chapterIndex != null -> { + val fragment = cfi.substringAfter("android-fragment:") + bookPaginator?.findStableLocatorForAnchor(chapterIndex, fragment) + } + else -> null + } + val targetPage = targetLocator?.let { bookPaginator?.findStablePageForLocator(it) } + ?: directPage + ?: chapterIndex?.let { bookPaginator?.findStableChapterStartPage(it) } + requestNativeVerticalLocatorScroll( + locator = targetLocator, + fallbackPage = targetPage, + fallbackChapterIndex = chapterIndex + ) + if (showBars) showBars = false + return@launch + } when { cfi.startsWith("android-scroll:") -> { val scrollY = cfi.substringAfter("android-scroll:").toIntOrNull() ?: 0 @@ -2882,6 +3318,39 @@ fun EpubReaderHost( Timber.tag("NavDiag").d("navigateToSearchResult index: $index") val targetResult = searchState.searchResults.getOrNull(index) if (targetResult != null && currentRenderMode == RenderMode.VERTICAL_SCROLL) { + if (isNativeVerticalMode) { + scope.launch { + searchState.currentSearchResultIndex = index + val bookPaginator = paginator as? BookPaginator ?: return@launch + val exactLocator = bookPaginator.findStableLocatorForSearchResult(targetResult) + val pageIdx = exactLocator?.let { bookPaginator.findStablePageForLocator(it) } + ?: bookPaginator.findStablePageForSearchResult(targetResult) + ?: bookPaginator.findStablePageForLocator( + Locator( + targetResult.locationInSource, + targetResult.chunkIndex.coerceAtLeast(0) * 20, + 0 + ) + ) + ?: bookPaginator.findStableChapterStartPage(targetResult.locationInSource) + ?: return@launch + val scrollLocator = exactLocator + ?: bookPaginator.getLocatorForPage(pageIdx) + ?: Locator(targetResult.locationInSource, targetResult.chunkIndex.coerceAtLeast(0) * 20, 0) + recordEpubJump( + scrollLocator.toEpubJumpLocator(pageIndex = pageIdx) + .copy(textQuote = targetResult.snippet.text) + ) + requestNativeVerticalLocatorScroll( + locator = scrollLocator, + fallbackPage = pageIdx, + fallbackChapterIndex = targetResult.locationInSource + ) + searchHighlightTarget = targetResult + if (showBars) showBars = false + } + return + } recordEpubJump( SharedReaderLocator( chapterIndex = targetResult.locationInSource, @@ -3059,6 +3528,32 @@ fun EpubReaderHost( if (targetChapterIndex != -1) { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator + val targetLocator = bookPaginator?.findStableLocatorForAnchor( + targetChapterIndex, + entry.fragmentId + ) + val targetPage = targetLocator?.let { bookPaginator.findStablePageForLocator(it) } + ?: bookPaginator?.findStablePageForAnchor( + targetChapterIndex, + entry.fragmentId + ) + ?: bookPaginator?.findStableChapterStartPage(targetChapterIndex) + if (targetPage != null) { + recordEpubJump( + fragmentJumpLocator(targetChapterIndex, entry.fragmentId, entry.absolutePath) + .copy(pageIndex = targetPage) + ) + requestNativeVerticalLocatorScroll( + locator = targetLocator ?: bookPaginator?.getLocatorForPage(targetPage), + fallbackPage = targetPage, + fallbackChapterIndex = targetChapterIndex + ) + } + if (showBars) showBars = false + return@launch + } recordEpubJump(fragmentJumpLocator(targetChapterIndex, entry.fragmentId, entry.absolutePath)) clearPendingTtsRelocationState("toc_entry_vertical") fragmentToLoad = entry.fragmentId @@ -3185,6 +3680,20 @@ fun EpubReaderHost( drawerState.close() when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator + val targetPage = bookPaginator?.findStableChapterStartPage(index) + if (targetPage != null) { + recordEpubJump(chapterStartJumpLocator(index).copy(pageIndex = targetPage)) + requestNativeVerticalLocatorScroll( + locator = bookPaginator.getLocatorForPage(targetPage) ?: Locator(index, 0, 0), + fallbackPage = targetPage, + fallbackChapterIndex = index + ) + if (showBars) showBars = false + } + return@launch + } if (index != currentChapterIndex) { recordEpubJump(chapterStartJumpLocator(index)) clearPendingTtsRelocationState("sidebar_chapter_vertical") @@ -3230,6 +3739,24 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + recordEpubJump(cfiJumpLocator(bookmark.chapterIndex, bookmark.cfi, bookmark.snippet)) + val bookPaginator = paginator as? BookPaginator + val locator = androidLocatorCfiToLocator(bookmark.cfi) + ?: locatorConverter.getLocatorFromCfi( + epubBook, + bookmark.chapterIndex, + bookmark.cfi + ) + val targetPage = locator?.let { bookPaginator?.findStablePageForLocator(it) } + ?: bookPaginator?.findStableChapterStartPage(bookmark.chapterIndex) + requestNativeVerticalLocatorScroll( + locator = locator, + fallbackPage = targetPage, + fallbackChapterIndex = bookmark.chapterIndex + ) + return@launch + } recordEpubJump(cfiJumpLocator(bookmark.chapterIndex, bookmark.cfi, bookmark.snippet)) Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}") cfiToLoad = bookmark.cfi @@ -3306,11 +3833,12 @@ fun EpubReaderHost( isNavigatingToPosition = true try { val bookPaginator = paginator as? BookPaginator - val locator = locatorConverter.getLocatorFromCfi( - book = epubBook, - chapterIndex = bookmark.chapterIndex, - cfi = bookmark.cfi - ) + val locator = androidLocatorCfiToLocator(bookmark.cfi) + ?: locatorConverter.getLocatorFromCfi( + book = epubBook, + chapterIndex = bookmark.chapterIndex, + cfi = bookmark.cfi + ) if (locator != null && bookPaginator != null) { Timber.d("P-Mode Click: Successfully converted CFI to Locator: $locator") @@ -3347,6 +3875,19 @@ fun EpubReaderHost( drawerState.close() when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + recordEpubJump(cfiJumpLocator(highlight.chapterIndex, highlight.cfi, highlight.text)) + val bookPaginator = paginator as? BookPaginator + val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi) + val targetPage = locator?.let { bookPaginator?.findStablePageForLocator(it) } + ?: bookPaginator?.findStableChapterStartPage(highlight.chapterIndex) + requestNativeVerticalLocatorScroll( + locator = locator, + fallbackPage = targetPage, + fallbackChapterIndex = highlight.chapterIndex + ) + return@launch + } recordEpubJump(cfiJumpLocator(highlight.chapterIndex, highlight.cfi, highlight.text)) cfiToLoad = highlight.cfi val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi) @@ -3439,6 +3980,8 @@ fun EpubReaderHost( }, onDeleteBookmark = { bookmarkToDelete -> bookmarks = bookmarks - bookmarkToDelete + bookmarkPageMap = bookmarkPageMap - bookmarkToDelete.cfi + bookmarkLocatorMap = bookmarkLocatorMap - bookmarkToDelete.cfi }, onRenameBookmark = { bookmark, newLabel -> bookmarks = bookmarks.map { @@ -3497,6 +4040,89 @@ fun EpubReaderHost( } } + fun generateSummaryFromPlainChapter(chapterIndex: Int?, force: Boolean) { + scope.launch { + val resolvedChapterIndex = chapterIndex + if (resolvedChapterIndex == null) { + summarizationResult = + SummarizationResult(error = context.getString(R.string.error_could_not_determine_chapter)) + isSummarizationLoading = false + return@launch + } + + val cached = if (!force) summaryCacheManager.getSummary( + epubBook.title, + resolvedChapterIndex + ) else null + if (cached != null) { + summarizationResult = SummarizationResult(summary = cached, isCacheHit = true) + isSummarizationLoading = false + return@launch + } + + val token = viewModel.getAuthToken() + val text = paginator?.getPlainTextForChapter(resolvedChapterIndex) + if (!text.isNullOrBlank()) { + var currentCost: Double? = null + var currentFreeRemaining: Int? = null + val finalSummaryBuilder = StringBuilder() + summarizeBookContent( + content = text, + context = context, + authToken = token, + onUsageReceived = { cost, freeRemaining -> + currentCost = cost + currentFreeRemaining = freeRemaining + summarizationResult = summarizationResult?.copy( + cost = cost, + freeRemaining = freeRemaining + ) ?: SummarizationResult( + cost = cost, + freeRemaining = freeRemaining + ) + }, + onUpdate = { chunk -> + finalSummaryBuilder.append(chunk) + val currentSummary = summarizationResult?.summary ?: "" + summarizationResult = SummarizationResult( + summary = currentSummary + chunk, + cost = currentCost, + freeRemaining = currentFreeRemaining + ) + }, + onError = { error -> + if (error == "INSUFFICIENT_CREDITS") { + showInsufficientCreditsDialog = true + showAiHubSheet = false + isSummarizationLoading = false + } else { + summarizationResult = SummarizationResult(error = error) + } + }, + onFinish = { + isSummarizationLoading = false + val fullSummary = finalSummaryBuilder.toString() + if (fullSummary.isNotBlank()) { + val chapterTitle = + chapters.getOrNull(resolvedChapterIndex)?.title + ?: context.getString(R.string.chapter_number_format, resolvedChapterIndex + 1) + summaryCacheManager.saveSummary( + epubBook.title, + resolvedChapterIndex, + chapterTitle, + fullSummary + ) + } + } + ) + } else { + summarizationResult = + SummarizationResult(error = context.getString(R.string.error_could_not_get_chapter_content)) + isSummarizationLoading = false + } + } + } + val handleGenerateSummary: (Boolean) -> Unit = { force -> if (BuildConfig.FLAVOR != "oss" && !isProUser && credits <= 0) { showInsufficientCreditsDialog = true @@ -3507,21 +4133,28 @@ fun EpubReaderHost( summarizationResult = null when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { - val cached = if (!force) summaryCacheManager.getSummary( - epubBook.title, - currentChapterIndex - ) else null - if (cached != null) { - summarizationResult = - SummarizationResult(summary = cached, isCacheHit = true) - isSummarizationLoading = false + if (isNativeVerticalMode) { + generateSummaryFromPlainChapter( + currentNativeVerticalLocator()?.chapterIndex ?: currentChapterIndex, + force + ) } else { - webViewRefForTts?.evaluateJavascript("javascript:AiBridgeHelper.extractAndRelayTextForSummarization();") { result -> - Timber.d("JS summarization request: $result") - } ?: run { - isSummarizationLoading = false + val cached = if (!force) summaryCacheManager.getSummary( + epubBook.title, + currentChapterIndex + ) else null + if (cached != null) { summarizationResult = - SummarizationResult(error = context.getString(R.string.error_webview_not_available)) + SummarizationResult(summary = cached, isCacheHit = true) + isSummarizationLoading = false + } else { + webViewRefForTts?.evaluateJavascript("javascript:AiBridgeHelper.extractAndRelayTextForSummarization();") { result -> + Timber.d("JS summarization request: $result") + } ?: run { + isSummarizationLoading = false + summarizationResult = + SummarizationResult(error = context.getString(R.string.error_webview_not_available)) + } } } } @@ -3625,11 +4258,31 @@ fun EpubReaderHost( showAiHubSheet = true when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { - isRequestingRecapCfi = true - webViewRefForTts?.evaluateJavascript( - "javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", - null - ) + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator + val locator = currentNativeVerticalLocator() + val chapterIndex = locator?.chapterIndex ?: currentChapterIndex + if (bookPaginator != null) { + val charsScrolled = locator?.charOffset?.coerceAtLeast(0) + ?: run { + val startPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0 + val currentPageInChapter = nativeVerticalCurrentPage - startPage + bookPaginator.getCharactersScrolledInChapter( + chapterIndex, + currentPageInChapter + ).toInt() + } + runRecap(chapterIndex, charsScrolled) + } else { + showBanner("Wait for book to load fully.", isError = true) + } + } else { + isRequestingRecapCfi = true + webViewRefForTts?.evaluateJavascript( + "javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", + null + ) + } } RenderMode.PAGINATED -> { @@ -3686,6 +4339,74 @@ fun EpubReaderHost( val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel() val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel() val isEpubJumpHistoryVisible = showBars && !searchState.isSearchActive && (epubJumpBackLabel != null || epubJumpForwardLabel != null) + val keyboardLineScrollPx = with(density) { + (configuration.screenHeightDp.dp.toPx() * 0.16f).roundToInt().coerceAtLeast(96) + } + val keyboardPageScrollPx = with(density) { + (configuration.screenHeightDp.dp.toPx() * 0.82f).roundToInt().coerceAtLeast(keyboardLineScrollPx) + } + + fun scrollVerticalReaderBy(deltaPx: Int) { + if (isNativeVerticalMode) { + nativeVerticalScrollDeltaRequestId += 1L + nativeVerticalScrollDeltaAnimated = false + nativeVerticalScrollDeltaRequest = deltaPx.toFloat() + } else { + webViewRefForTts?.evaluateJavascript( + "window.scrollBy({ top: $deltaPx, behavior: 'smooth' });", + null + ) + } + } + + fun navigateReaderPage(targetPage: Int) { + when { + isNativeVerticalMode -> { + val lastPage = (nativeVerticalTotalPages - 1).coerceAtLeast(0) + nativeVerticalScrollRequest = targetPage.coerceIn(0, lastPage) + } + currentRenderMode == RenderMode.VERTICAL_SCROLL -> { + scrollVerticalReaderBy((targetPage - nativeVerticalCurrentPage).coerceIn(-1, 1) * keyboardPageScrollPx) + } + else -> { + scope.launch { + val pageCount = paginatedPagerState.pageCount + if (pageCount <= 0) return@launch + val page = targetPage.coerceIn(0, pageCount - 1) + if (page != paginatedPagerState.currentPage) { + if (isPageTurnAnimationEnabled) { + paginatedPagerState.animateScrollToPage(page, animationSpec = tween(700)) + } else { + paginatedPagerState.scrollToPage(page) + } + } + } + } + } + } + + fun navigateReaderPageBy(delta: Int) { + when { + isNativeVerticalMode -> navigateReaderPage(nativeVerticalCurrentPage + delta) + currentRenderMode == RenderMode.VERTICAL_SCROLL -> scrollVerticalReaderBy(delta * keyboardPageScrollPx) + else -> navigateReaderPage(paginatedPagerState.currentPage + delta) + } + } + + fun navigateReaderBoundary(first: Boolean) { + when { + isNativeVerticalMode -> navigateReaderPage(if (first) 0 else nativeVerticalTotalPages - 1) + currentRenderMode == RenderMode.VERTICAL_SCROLL -> { + val script = if (first) { + "window.scrollTo({ top: 0, behavior: 'smooth' });" + } else { + "window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });" + } + webViewRefForTts?.evaluateJavascript(script, null) + } + else -> navigateReaderPage(if (first) 0 else paginatedPagerState.pageCount - 1) + } + } Box( modifier = Modifier @@ -3706,18 +4427,39 @@ fun EpubReaderHost( currentChapterIndex = currentChapterIndex, totalChapters = chapters.size, onScrollBy = { amount -> - webViewRefForTts?.evaluateJavascript( - "window.scrollBy({ top: $amount, behavior: 'smooth' });", - null - ) + if (isNativeVerticalMode) { + nativeVerticalScrollDeltaRequestId += 1L + nativeVerticalScrollDeltaAnimated = false + nativeVerticalScrollDeltaRequest = amount.toFloat() + } else { + webViewRefForTts?.evaluateJavascript( + "window.scrollBy({ top: $amount, behavior: 'smooth' });", + null + ) + } }, onNavigateChapter = { offset, target -> scope.launch { clearPendingTtsRelocationState("manual_chapter_change") - initialScrollTargetForChapter = target - currentScrollYPosition = 0 - currentScrollHeightValue = 0 - currentChapterIndex += offset + if (isNativeVerticalMode) { + if (chapters.isNotEmpty()) { + val targetChapter = (currentChapterIndex + offset).coerceIn(0, chapters.lastIndex) + val targetPage = (paginator as? BookPaginator) + ?.findStableChapterStartPage(targetChapter) + if (targetPage != null) { + requestNativeVerticalLocatorScroll( + locator = Locator(targetChapter, 0, 0), + fallbackPage = targetPage, + fallbackChapterIndex = targetChapter + ) + } + } + } else { + initialScrollTargetForChapter = target + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex += offset + } logTtsChapterDiag( "Manual vertical chapter switch via volume/button nav. " + "offset=$offset target=$target newChapter=$currentChapterIndex" @@ -3748,10 +4490,21 @@ fun EpubReaderHost( } } ) + .epubReaderKeyboardNavigationHandler( + enabled = !searchState.isSearchActive, + renderMode = currentRenderMode, + isRightToLeftPagination = rightToLeftPagination, + verticalLineScrollPx = keyboardLineScrollPx, + onVerticalScrollBy = ::scrollVerticalReaderBy, + onNextPage = { navigateReaderPageBy(1) }, + onPreviousPage = { navigateReaderPageBy(-1) }, + onFirstPage = { navigateReaderBoundary(first = true) }, + onLastPage = { navigateReaderBoundary(first = false) } + ) ) { when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { - val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp + val pageInfoReserve = if (isPageInfoVisible) pageInfoBarHeight else 0.dp val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp @@ -3769,6 +4522,167 @@ fun EpubReaderHost( ) { Text(stringResource(R.string.no_chapters_available)) } + } else if (isNativeVerticalMode) { + LaunchedEffect(currentChapterIndex, isNativeVerticalMode) { + webViewRefForTts = null + isChapterParsing = false + isChapterReadyForBookmarkCheck = true + } + NativeVerticalReaderScreen( + book = epubBook, + bookId = readerCacheBookId, + isDarkTheme = isDarkTheme, + effectiveBg = effectiveBg, + effectiveText = effectiveText, + searchQuery = searchState.searchQuery, + fontSizeMultiplier = currentFontSizeEm, + lineHeightMultiplier = currentLineHeight, + paragraphGapMultiplier = currentParagraphGap, + imageSizeMultiplier = currentImageSize, + horizontalMarginMultiplier = currentHorizontalMargin, + verticalMarginMultiplier = currentVerticalMargin, + fontFamily = activeFontFamily, + textAlign = currentTextAlign, + bookReplacementPreferences = bookReplacementPreferences, + bookReplacementFileId = bookId, + activeHighlightPalette = currentHighlightPalette, + onUpdatePalette = onUpdateHighlightPalette, + ttsHighlightInfo = TtsHighlightInfo( + text = ttsState.currentText ?: "", + cfi = ttsState.sourceCfi ?: "", + offset = ttsState.startOffsetInSource + ).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 }, + activeTextureId = activeTextureId, + activeTextureAlpha = activeTextureAlpha, + initialLocator = lastKnownLocator, + initialPageIndexInBook = nativeVerticalCurrentPage, + scrollRequestPage = nativeVerticalScrollRequest, + scrollRequestLocator = nativeVerticalLocatorScrollRequest, + scrollRequestLocatorId = nativeVerticalLocatorScrollRequestId, + scrollRequestLocatorKeepVisible = nativeVerticalLocatorScrollKeepVisible, + scrollRequestProgressPercent = nativeVerticalProgressScrollRequest, + scrollRequestProgressId = nativeVerticalProgressScrollRequestId, + scrollDeltaRequest = nativeVerticalScrollDeltaRequest, + scrollDeltaRequestId = nativeVerticalScrollDeltaRequestId, + scrollDeltaRequestAnimated = nativeVerticalScrollDeltaAnimated, + onScrollRequestConsumed = { nativeVerticalScrollRequest = null }, + onScrollLocatorRequestConsumed = { + nativeVerticalLocatorScrollRequest = null + nativeVerticalLocatorScrollKeepVisible = false + }, + onScrollProgressRequestConsumed = { nativeVerticalProgressScrollRequest = null }, + onScrollDeltaConsumed = { nativeVerticalScrollDeltaRequest = null }, + modifier = Modifier.fillMaxSize(), + onPaginatorReady = { newPaginator -> + paginator = newPaginator + }, + onVisiblePageChanged = { pageIndex, chapterIndex, locator -> + nativeVerticalCurrentPage = pageIndex + if (chapterIndex != null) { + currentChapterIndex = chapterIndex + } + if (locator != null) { + lastKnownLocator = locator + } + currentScrollYPosition = pageIndex + currentClientHeightValue = 1 + currentScrollHeightValue = nativeVerticalTotalPages.coerceAtLeast(1) + }, + onProgressChanged = { pageIndex, totalPages, progressPercent -> + nativeVerticalCurrentPage = pageIndex + nativeVerticalTotalPages = totalPages + nativeVerticalProgress = progressPercent.coerceIn(0f, 100f) + currentScrollYPosition = pageIndex + currentClientHeightValue = 1 + currentScrollHeightValue = totalPages.coerceAtLeast(1) + }, + onLocationChanged = { location -> + nativeVerticalLocation = location + location.locatorForPersistence()?.let { lastKnownLocator = it } + }, + onTap = { + focusManager.clearFocus() + if (volumeScrollEnabled && !searchState.isSearchActive) { + containerFocusRequester.requestFocus() + } + if (showBars || showFormatAdjustmentBars) { + showBars = false + showFormatAdjustmentBars = false + } else { + showBars = true + } + }, + isProUser = isProUser, + isOss = BuildConfig.FLAVOR == "oss", + onShowDictionaryUpsellDialog = { + showDictionaryUpsellDialog = true + }, + onWordSelectedForAiDefinition = { text -> + onDictionaryLookup(text) + }, + onTranslate = { text -> + onTranslateLookup(text) + }, + onSearch = { text -> + onSearchLookup(text) + }, + onStartTtsFromSelection = { cfi, offset, chapterIndex -> + startTtsFromSelectionPaginated(cfi, offset, chapterIndex) + }, + userHighlights = userHighlights.filter { highlight -> + highlight.chapterIndex in (currentChapterIndex - 1)..(currentChapterIndex + 1) + }, + onHighlightCreated = { cfi, text, colorId, locator -> + val chapterIndex = locator.chapterIndex ?: currentChapterIndex + val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW + val finalCfi = processAndAddHighlight( + newCfi = cfi, + newText = text, + newColor = color, + chapterIndex = chapterIndex, + currentList = userHighlights, + locator = locator.withFallbacks( + chapterIndex = chapterIndex, + cfi = cfi, + textQuote = text + ) + ) + if (pendingNoteForNewHighlight) { + pendingNoteForNewHighlight = false + highlightToNoteCfi = finalCfi + } + }, + onNoteRequested = { cfi -> + if (cfi != null) { + highlightToNoteCfi = cfi + } else { + pendingNoteForNewHighlight = true + } + }, + onFootnoteRequested = { html -> + activeFootnoteHtml = html + }, + onInternalLinkNavigated = { targetPageIndex, targetLocatorFromLink -> + val bookPaginator = paginator as? BookPaginator + val targetChapter = targetLocatorFromLink?.chapterIndex + ?: bookPaginator?.findChapterIndexForPage(targetPageIndex) + val targetLocator = targetLocatorFromLink ?: bookPaginator?.getLocatorForPage(targetPageIndex) + if (targetChapter != null) { + currentChapterIndex = targetChapter + } + if (targetLocator != null) { + lastKnownLocator = targetLocator + } + paginatedJumpLocatorForPage( + pageIndex = targetPageIndex, + targetLocator = targetLocator, + fallbackChapterIndex = targetChapter + )?.let { recordEpubJump(it) } + }, + onHighlightDeleted = { cfi -> + userHighlights.find { it.cfi == cfi }?.let { userHighlights.remove(it) } + } + ) } else { AnimatedContent( targetState = currentChapterIndex, @@ -3838,15 +4752,37 @@ fun EpubReaderHost( """.trimIndent() val chapterToRender = chapters[targetChapterIndex] + val chapterFontFaceCss = remember( + chapterHead, + chapterToRender.absPath, + epubBook.extractionBasePath + ) { + val fontFaces = Jsoup.parse("$chapterHead") + .head() + .getElementsByTag("style") + .flatMap { styleElement -> + CssParser.parse( + cssContent = styleElement.data(), + cssPath = chapterToRender.absPath, + baseFontSizeSp = 16f, + density = 1f, + constraints = Constraints(maxWidth = 1, maxHeight = 1), + isDarkTheme = false, + adaptThemeColors = false + ).fontFaces + } + buildEpubFontFaceCss(fontFaces, epubBook.extractionBasePath) + } fun isCurrentRenderedChapter(): Boolean = targetChapterIndex == currentChapterIndex val chapterKeyForWebView = remember( chapterToRender.htmlFilePath, - epubBook.extractionBasePath + epubBook.extractionBasePath, + bookReplacementSignature ) { - "${epubBook.extractionBasePath}/${chapterToRender.htmlFilePath}" + "${epubBook.extractionBasePath}/${chapterToRender.htmlFilePath}?bookReplacements=${bookReplacementSignature.hashCode()}" } val chapterDirectoryPath = @@ -3883,7 +4819,7 @@ fun EpubReaderHost( .indexOf(target) .coerceAtLeast(0) - val js = "javascript:console.log('NavDiag: Executing robust search highlight JS'); window.CURRENT_SEARCH_QUERY = '${escapedQuery}'; window.highlightAllOccurrences('${escapedQuery}'); window.scrollToChunkOccurrence($targetChunk, $relativeIdx);" + val js = "javascript:window.CURRENT_SEARCH_QUERY = '${escapedQuery}'; window.highlightAllOccurrences('${escapedQuery}'); window.scrollToChunkOccurrence($targetChunk, $relativeIdx);" Timber.tag("NavDiag").d("Executing search highlight/scroll JS: $js") webView.evaluateJavascript(js) { result -> Timber.tag("NavDiag").d("JS highlight/scroll result: $result") @@ -4199,6 +5135,9 @@ fun EpubReaderHost( currentVerticalMargin = currentVerticalMargin, currentFontFamily = currentFontFamily, customFontPath = currentCustomFontPath, + epubFontFaceCss = listOf(epubFontFaceCss, chapterFontFaceCss) + .filter { it.isNotBlank() } + .joinToString(separator = " "), currentTextAlign = currentTextAlign, activeTextureId = activeTextureId, activeTextureAlpha = activeTextureAlpha, @@ -4574,7 +5513,7 @@ fun EpubReaderHost( val currentChapterLengthChars = chapters.getOrNull( latestChapterIndex - )?.plainTextContent?.length?.toLong() + )?.plainTextCharacterCount()?.toLong() ?: 0L // Handle Recap Request INTERCEPTION @@ -4604,7 +5543,7 @@ fun EpubReaderHost( val progress = if (totalBookLengthChars > 0) { val completedCharsInPreviousChapters = chapters.take(latestChapterIndex) - .sumOf { it.plainTextContent.length.toLong() } + .sumOf { it.plainTextCharacterCount().toLong() } val charsScrolledInCurrentChapter = (progressWithinChapter * currentChapterLengthChars).toLong() @@ -4752,7 +5691,7 @@ fun EpubReaderHost( } RenderMode.PAGINATED -> { - val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp + val pageInfoReserve = if (isPageInfoVisible) pageInfoBarHeight else 0.dp val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp @@ -4780,6 +5719,8 @@ fun EpubReaderHost( verticalMarginMultiplier = currentVerticalMargin, fontFamily = activeFontFamily, textAlign = currentTextAlign, + bookReplacementPreferences = bookReplacementPreferences, + bookReplacementFileId = bookId, activeHighlightPalette = currentHighlightPalette, onUpdatePalette = onUpdateHighlightPalette, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, @@ -4894,8 +5835,8 @@ fun EpubReaderHost( val currentChapter = currentChapterInPaginatedMode ?: return@filter false highlight.chapterIndex in (currentChapter - 1)..(currentChapter + 1) }, - onHighlightCreated = { cfi, text, colorId -> - val chapterIndex = currentChapterInPaginatedMode ?: 0 + onHighlightCreated = { cfi, text, colorId, locator -> + val chapterIndex = locator.chapterIndex ?: currentChapterInPaginatedMode ?: 0 Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "persist_request cfi=$cfi colorId=$colorId chapter=$chapterIndex " + "existingCount=${userHighlights.size} textLen=${text.length} " + @@ -4908,7 +5849,12 @@ fun EpubReaderHost( newText = text, newColor = color, chapterIndex = chapterIndex, - currentList = userHighlights + currentList = userHighlights, + locator = locator.withFallbacks( + chapterIndex = chapterIndex, + cfi = cfi, + textQuote = text + ) ) val savedHighlight = userHighlights.find { it.chapterIndex == chapterIndex && it.cfi == finalCfi @@ -4937,10 +5883,11 @@ fun EpubReaderHost( onFootnoteRequested = { html -> activeFootnoteHtml = html }, - onInternalLinkNavigated = { targetPageIndex -> + onInternalLinkNavigated = { targetPageIndex, targetLocatorFromLink -> val bookPaginator = paginator as? BookPaginator - val targetChapter = bookPaginator?.findChapterIndexForPage(targetPageIndex) - val targetLocator = bookPaginator?.getLocatorForPage(targetPageIndex) + val targetChapter = targetLocatorFromLink?.chapterIndex + ?: bookPaginator?.findChapterIndexForPage(targetPageIndex) + val targetLocator = targetLocatorFromLink ?: bookPaginator?.getLocatorForPage(targetPageIndex) val navigationEpoch = System.currentTimeMillis() paginatedExplicitNavigationEpoch = navigationEpoch paginatedExplicitNavigationAnchor = targetLocator @@ -4996,6 +5943,99 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + val currentNativeLocator = currentNativeVerticalLocator() + val bookmarkedOnPage = remember( + currentNativeLocator, + nativeVerticalLocation?.visibleTextRanges, + nativeVerticalCurrentPage, + bookmarkLocatorMap, + bookmarkPageMap, + bookmarks + ) { + val visibleRanges = nativeVerticalLocation?.visibleTextRanges.orEmpty() + val visibleRangeBookmark = bookmarks.find { bookmark -> + val bookmarkLocator = bookmarkLocatorMap[bookmark.cfi] ?: return@find false + visibleRanges.any { range -> + range.chapterIndex == bookmarkLocator.chapterIndex && + range.blockIndex == bookmarkLocator.blockIndex && + bookmarkLocator.charOffset in range.startCharOffset..range.endCharOffset + } + } + if (visibleRangeBookmark != null) return@remember visibleRangeBookmark + + val locator = currentNativeLocator + val locatorBookmark = if (locator != null) { + bookmarks.find { bookmark -> + val bookmarkLocator = bookmarkLocatorMap[bookmark.cfi] + bookmarkLocator != null && + bookmarkLocator.chapterIndex == locator.chapterIndex && + bookmarkLocator.blockIndex == locator.blockIndex && + abs(bookmarkLocator.charOffset - locator.charOffset) <= 160 + } + } else { + null + } + locatorBookmark ?: bookmarks.find { bookmark -> + bookmarkPageMap[bookmark.cfi] == nativeVerticalCurrentPage + } + } + + isBookmarked = bookmarkedOnPage != null + onBookmarkClick = { + if (isBookmarked) { + bookmarkedOnPage?.let { bookmarkToRemove -> + bookmarks = bookmarks - bookmarkToRemove + bookmarkPageMap = bookmarkPageMap - bookmarkToRemove.cfi + bookmarkLocatorMap = bookmarkLocatorMap - bookmarkToRemove.cfi + Timber.d("Native vertical click: Removing bookmark: $bookmarkToRemove") + } + } else { + val bookPaginator = paginator as? BookPaginator + val locator = currentNativeVerticalLocator() + if (locator != null && bookPaginator != null) { + scope.launch { + val finalCfi = locatorConverter.getCfiFromLocator( + epubBook, + locator + ) ?: "android-locator:${locator.chapterIndex}:${locator.blockIndex}:${locator.charOffset}" + val pageContent = bookPaginator.getPageContent(nativeVerticalCurrentPage) + val targetBlockForBookmark = + pageContent?.content?.firstOrNull { + it is TextContentBlock && it.blockIndex == locator.blockIndex && it.cfi != null + } + ?: pageContent?.content?.firstOrNull { it.blockIndex == locator.blockIndex && it.cfi != null } + ?: pageContent?.content?.firstOrNull { it is TextContentBlock && it.cfi != null } + ?: pageContent?.content?.firstOrNull { it.cfi != null } + val chapterTitle = + epubBook.chapters.getOrNull(locator.chapterIndex)?.title + ?: context.getString(R.string.unknown_chapter) + val snippet = + (targetBlockForBookmark as? TextContentBlock)?.content?.text?.take(150) + ?: chapterTitle + val chapterStartPage = bookPaginator.chapterStartPageIndices[locator.chapterIndex] + val totalPages = bookPaginator.chapterPageCounts[locator.chapterIndex] + val pageInChapter = chapterStartPage?.let { + nativeVerticalCurrentPage - it + 1 + } + val newBookmark = Bookmark( + cfi = finalCfi, + chapterTitle = chapterTitle, + label = null, + snippet = snippet, + pageInChapter = pageInChapter, + totalPagesInChapter = totalPages, + chapterIndex = locator.chapterIndex + ) + bookmarks = bookmarks + newBookmark + bookmarkPageMap = bookmarkPageMap + (finalCfi to nativeVerticalCurrentPage) + bookmarkLocatorMap = bookmarkLocatorMap + (finalCfi to locator) + Timber.d("Native vertical click: Adding bookmark: $newBookmark") + } + } + } + } + } else { val checkVisibleBookmarks = remember(webViewRefForTts, bookmarks, currentChapterIndex) { { val currentChapter = chapters.getOrNull(currentChapterIndex) @@ -5061,6 +6101,7 @@ fun EpubReaderHost( webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiForBookmarkExtracted(window.getCurrentCfi());", null) } } + } } RenderMode.PAGINATED -> { val pageContent = remember(paginatedPagerState.currentPage, paginator) { @@ -5147,14 +6188,23 @@ fun EpubReaderHost( .padding(end = 16.dp) ) + val pageInfoChromeTopPadding = + if (pageInfoPosition == PageInfoPosition.TOP && showBars) 55.dp else 0.dp + val pageInfoChromeBottomPadding = + if (pageInfoPosition == PageInfoPosition.BOTTOM && showBars) { + bottomPadding + 45.dp + if (isEpubJumpHistoryVisible) 40.dp else 0.dp + } else { + 0.dp + } + // Page Info Bar (Vertical) AnimatedVisibility( visible = currentRenderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible, enter = fadeIn(animationSpec = tween(200)), exit = fadeOut(animationSpec = tween(200)), - modifier = Modifier.align( - if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter - ) + modifier = Modifier + .align(if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter) + .padding(top = pageInfoChromeTopPadding, bottom = pageInfoChromeBottomPadding) ) { Box( modifier = Modifier @@ -5169,7 +6219,12 @@ fun EpubReaderHost( chapters.getOrNull(currentChapterIndex)?.title?.take(30)?.trim() ?: "Chapter" - val displayPageInfo = if (currentScrollHeightValue <= 0 || isChapterParsing) "" else " ($currentPageInChapter/$totalPagesInCurrentChapter)" + val displayPageInfo = when { + isNativeVerticalMode && nativeVerticalDisplayPageInfo != null -> + " (${nativeVerticalDisplayPageInfo.currentPage}/${nativeVerticalDisplayPageInfo.totalPages})" + currentScrollHeightValue <= 0 || isChapterParsing -> "" + else -> " ($currentPageInChapter/$totalPagesInCurrentChapter)" + } Text( text = "$chapterTitle$displayPageInfo", @@ -5183,7 +6238,7 @@ fun EpubReaderHost( .padding(horizontal = 48.dp) ) - if (totalBookLengthChars > 0 && currentScrollHeightValue > 0 && !isChapterParsing) { + if (totalBookLengthChars > 0 && currentScrollHeightValue > 0 && (!isChapterParsing || isNativeVerticalMode)) { Text( text = "%.1f%%".format(currentBookProgress), style = MaterialTheme.typography.bodySmall, @@ -5200,9 +6255,9 @@ fun EpubReaderHost( visible = currentRenderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0, enter = fadeIn(animationSpec = tween(200)), exit = fadeOut(animationSpec = tween(200)), - modifier = Modifier.align( - if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter - ) + modifier = Modifier + .align(if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter) + .padding(top = pageInfoChromeTopPadding, bottom = pageInfoChromeBottomPadding) ) { Box( modifier = Modifier @@ -5250,7 +6305,7 @@ fun EpubReaderHost( if (paginatedPagerState.pageCount > 0) { if (totalBookLengthChars > 0 && bookPaginator != null && chapterIndex != null) { val completedCharsInPreviousChapters = remember(chapters, chapterIndex) { - chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() } + chapters.take(chapterIndex).sumOf { it.plainTextCharacterCount().toLong() } } val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] val currentPageInChapter = if (chapterStartPage != null) { @@ -5502,6 +6557,7 @@ fun EpubReaderHost( volumeScrollEnabled = volumeScrollEnabled, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, isRightToLeftPagination = rightToLeftPagination, + useNativeVerticalRenderer = useNativeVerticalRenderer, hiddenTools = hiddenTools, toolOrder = toolOrder, bottomTools = bottomTools, @@ -5518,17 +6574,81 @@ fun EpubReaderHost( keyboardController?.hide() focusManager.clearFocus() containerFocusRequester.requestFocus() - webViewRefForTts?.evaluateJavascript("javascript:window.clearSearchHighlights();", null) + if (!isNativeVerticalMode) { + webViewRefForTts?.evaluateJavascript("javascript:window.clearSearchHighlights();", null) + } + }, + onUseNativeVerticalRendererChange = { enabled -> + val wasNativeVertical = isNativeVerticalMode + val nativeLocator = if (wasNativeVertical) { + currentNativeVerticalLocator() ?: lastKnownLocator + } else { + null + } + useNativeVerticalRenderer = enabled + if (enabled) { + if (currentRenderMode == RenderMode.VERTICAL_SCROLL && !wasNativeVertical) { + val bookPaginator = paginator as? BookPaginator + val chapterStartPage = bookPaginator?.chapterStartPageIndices?.get(currentChapterIndex) + val chapterPageCount = bookPaginator?.chapterPageCounts?.get(currentChapterIndex) + if (chapterStartPage != null && chapterPageCount != null && chapterPageCount > 0) { + val pageRatio = if (totalPagesInCurrentChapter > 1) { + (currentPageInChapter - 1).toFloat() / (totalPagesInCurrentChapter - 1).toFloat() + } else { + 0f + } + nativeVerticalScrollRequest = + chapterStartPage + (pageRatio * (chapterPageCount - 1)).roundToInt() + } + } + webViewRefForTts = null + isAutoScrollModeActive = false + isAutoScrollPlaying = false + } else if (wasNativeVertical && nativeLocator != null) { + lastKnownLocator = nativeLocator + initialScrollTargetForChapter = null + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex = nativeLocator.chapterIndex + scope.launch { + val cfi = locatorConverter.getCfiFromLocator(epubBook, nativeLocator) + cfiToLoad = cfi + } + } }, onChangeRenderMode = { newMode -> Timber.tag("NavDiag").d("onChangeRenderMode to $newMode") if (newMode != currentRenderMode) { if (newMode == RenderMode.PAGINATED) { isSwitchingToPaginated = true - webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) + if (isNativeVerticalMode) { + isSwitchingToPaginated = false + val locator = currentNativeVerticalLocator() ?: lastKnownLocator + if (locator != null) { + lastKnownLocator = locator + chapterToLoadOnSwitch = locator.chapterIndex + } + isPagerInitialized = false + currentRenderMode = RenderMode.PAGINATED + onRenderModeChange(RenderMode.PAGINATED) + } else { + webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) + } } else { scope.launch { Timber.tag("NavDiag").d("Mode changing to VERTICAL. lastKnownLocator=$lastKnownLocator") + if (useNativeVerticalRenderer) { + val locator = (paginator as? BookPaginator)?.getLocatorForPage(paginatedPagerState.currentPage) + ?: lastKnownLocator + if (locator != null) { + lastKnownLocator = locator + } + nativeVerticalScrollRequest = paginatedPagerState.currentPage + webViewRefForTts = null + currentRenderMode = RenderMode.VERTICAL_SCROLL + onRenderModeChange(RenderMode.VERTICAL_SCROLL) + return@launch + } lastKnownLocator?.let { locator -> val cfi = locatorConverter.getCfiFromLocator(epubBook, locator) Timber.tag("NavDiag").d("Converted locator to CFI: $cfi") @@ -5587,6 +6707,7 @@ fun EpubReaderHost( modifier = Modifier.align(Alignment.TopCenter), onOpenTtsSettings = { showTtsSettingsSheet = true }, onOpenTtsReplacements = { showTtsReplacementsSheet = true }, + onOpenBookReplacements = { showBookReplacementsSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenThemeSettings = { showThemePanel = true }, onOpenBrightness = { showBrightnessSheet = true }, @@ -5663,7 +6784,7 @@ fun EpubReaderHost( ) val ttsAlignmentBias by animateFloatAsState( - targetValue = if (isTtsCollapsed) 1f else 0f, + targetValue = readerTtsOverlayAlignmentBias(ttsOverlaySize), label = "TtsAlignAnimation" ) @@ -5680,8 +6801,11 @@ fun EpubReaderHost( ttsController = ttsController, ttsState = ttsState, currentTtsMode = currentTtsMode, - isCollapsed = isTtsCollapsed, - onCollapseChange = { isTtsCollapsed = it }, + overlaySize = ttsOverlaySize, + onOverlaySizeChange = { newSize -> + ttsOverlaySize = newSize + saveReaderTtsOverlaySize(context, newSize) + }, onLocateCurrentChunk = { logTtsChapterDiag("Locate current chunk requested from TTS overlay") queuePendingTtsLocate(TTS_LOCATE_REASON_OVERLAY) @@ -5785,7 +6909,16 @@ fun EpubReaderHost( triggerAutoScrollTempPause(1000L) } scope.launch { - webViewRefForTts?.evaluateJavascript("window.scrollTo({ top: 0, behavior: 'smooth' });", null) + if (isNativeVerticalMode) { + val chapterIndex = currentNativeVerticalLocator()?.chapterIndex ?: currentChapterIndex + requestNativeVerticalLocatorScroll( + locator = Locator(chapterIndex, 0, 0), + fallbackPage = (paginator as? BookPaginator)?.findStableChapterStartPage(chapterIndex), + fallbackChapterIndex = chapterIndex + ) + } else { + webViewRefForTts?.evaluateJavascript("window.scrollTo({ top: 0, behavior: 'smooth' });", null) + } } } ) @@ -6089,9 +7222,14 @@ fun EpubReaderHost( highlightToNoteCfi = null }, onCopy = { - val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), targetHighlight.text) - clipboardManager.setPrimaryClip(clip) + val copied = copyPlainTextToClipboard( + context = context, + label = context.getString(R.string.clip_label_copied_text), + text = targetHighlight.text + ) + if (!copied) { + Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show() + } highlightToNoteCfi = null }, onDictionary = { @@ -6121,13 +7259,13 @@ fun EpubReaderHost( EpubReaderPageSlider( isVisible = epubSliderChromeVisible, - currentRenderMode = currentRenderMode, - totalPages = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount, + totalPages = when { + isNativeVerticalMode -> nativeVerticalTotalPages + currentRenderMode == RenderMode.VERTICAL_SCROLL -> totalPagesInCurrentChapter + else -> paginatedPagerState.pageCount + }, sliderCurrentPage = sliderCurrentPage, sliderStartPage = sliderStartPage, - startPageThumbnail = startPageThumbnail, - paginator = paginator, - chapters = chapters, onScrub = { newValue -> sliderCurrentPage = newValue isFastScrubbing = true @@ -6136,7 +7274,14 @@ fun EpubReaderHost( delay(200) if (isActive) { val targetPage = newValue.roundToInt() - if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { + if (isNativeVerticalMode) { + requestNativeVerticalProgressScroll( + nativeVerticalProgressForCompatPage( + pageIndex = targetPage - 1, + totalPageCount = nativeVerticalTotalPages + ) + ) + } else if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { val scrollY = (targetPage - 1) * currentClientHeightValue webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null) } else { @@ -6148,7 +7293,15 @@ fun EpubReaderHost( }, onJumpToPage = { page -> scope.launch { - if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { + if (isNativeVerticalMode) { + sliderCurrentPage = page.toFloat() + requestNativeVerticalProgressScroll( + nativeVerticalProgressForCompatPage( + pageIndex = page - 1, + totalPageCount = nativeVerticalTotalPages + ) + ) + } else if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { sliderCurrentPage = page.toFloat() val scrollY = (page - 1) * currentClientHeightValue webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null) @@ -6164,13 +7317,15 @@ fun EpubReaderHost( .padding(bottom = bottomPadding + 45.dp + if (isEpubJumpHistoryVisible) 40.dp else 0.dp), activeColor = epubReaderSliderColors.activeTrackColor, inactiveColor = epubReaderSliderColors.inactiveTrackColor, - contentColor = epubReaderSliderColors.contentColor, - thumbnailSurfaceColor = epubReaderSliderColors.thumbnailSurfaceColor, - thumbnailContentColor = epubReaderSliderColors.thumbnailContentColor + contentColor = epubReaderSliderColors.contentColor ) if (epubSliderChromeVisible && isFastScrubbing) { - val total = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount + val total = when { + isNativeVerticalMode -> nativeVerticalTotalPages + currentRenderMode == RenderMode.VERTICAL_SCROLL -> totalPagesInCurrentChapter + else -> paginatedPagerState.pageCount + } PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total) } } @@ -6205,6 +7360,15 @@ fun EpubReaderHost( onDismiss = { showTtsReplacementsSheet = false }, ) + BookWordReplacementsSheet( + isVisible = showBookReplacementsSheet, + bookId = bookId, + bookTitle = epubBook.title, + preferences = bookReplacementPreferences, + onPreferencesChange = updateBookReplacementPreferences, + onDismiss = { showBookReplacementsSheet = false }, + ) + ReaderFileInfoDialogs( isFileInfoVisible = showFileInfoDialog, onFileInfoVisibleChange = { showFileInfoDialog = it }, @@ -6328,7 +7492,7 @@ fun EpubReaderHost( currentCustomFontPath = path }, customFonts = customFonts, - onImportFont = onImportFont, + onImportFonts = onImportFonts, onDismiss = { showFontSelectionSheet = false } ) Spacer(Modifier.height(16.dp)) diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderSearch.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderSearch.kt new file mode 100644 index 0000000..a26e58b --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderSearch.kt @@ -0,0 +1,455 @@ +/* + * 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.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 org.dueattendant149.bookreader.RenderMode +import org.dueattendant149.bookreader.SearchNavigationControls +import org.dueattendant149.bookreader.SearchResult +import org.dueattendant149.bookreader.SearchResultsPanel +import org.dueattendant149.bookreader.SearchState +import org.dueattendant149.bookreader.epub.EpubBook +import org.dueattendant149.bookreader.epub.contentFilePath +import org.dueattendant149.bookreader.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 org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import java.io.File +import kotlin.math.max +import kotlin.math.min + +private const val EPUB_SEARCH_WINDOW_CHARS = 32_768 +private const val EPUB_SEARCH_SNIPPET_RADIUS = 35 +private const val EPUB_SEARCH_MAX_OVERLAP_CHARS = 4_096 + +private val epubSearchSkippedTags = setOf("script", "style", "noscript") +private val epubSearchBlockBoundaryTags = setOf( + "address", + "article", + "aside", + "blockquote", + "br", + "caption", + "dd", + "div", + "dl", + "dt", + "figcaption", + "figure", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "li", + "main", + "nav", + "ol", + "p", + "pre", + "section", + "table", + "td", + "th", + "tr", + "ul" +) + +/** + * Creates the search implementation for EPUB chapters. + */ +fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List = { query -> + withContext(Dispatchers.Default) { + val searchQuery = query.trim() + if (searchQuery.isBlank()) { + return@withContext emptyList() + } + + 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") + doc.select("script, style, noscript").remove() + val bodyNodes = doc.body().childNodes().toList() + val chunks = bodyNodes.chunked(20) + var occurrenceIndexInChapter = 0 + + chunks.forEachIndexed { chunkIndex, chunkNodes -> + occurrenceIndexInChapter = appendSearchResultsFromNodes( + nodes = chunkNodes, + query = searchQuery, + chapterIndex = chapterIndex, + chapterTitle = chapter.title, + chunkIndex = chunkIndex, + occurrenceIndexInChapter = occurrenceIndexInChapter, + results = results + ) + } + } catch (e: Exception) { + Timber.e(e, "Failed to search in chapter $chapterIndex") + } catch (e: OutOfMemoryError) { + Timber.e(e, "Skipping search in chapter $chapterIndex after running out of memory") + } + } + results + } +} + +private fun appendSearchResultsFromNodes( + nodes: List, + query: String, + chapterIndex: Int, + chapterTitle: String, + chunkIndex: Int, + occurrenceIndexInChapter: Int, + results: MutableList +): Int { + val searchWindow = EpubSearchWindow( + query = query, + chapterIndex = chapterIndex, + chapterTitle = chapterTitle, + chunkIndex = chunkIndex, + initialOccurrenceIndex = occurrenceIndexInChapter, + results = results + ) + nodes.forEach { node -> + searchWindow.visit(node) + } + searchWindow.finish() + return searchWindow.occurrenceIndex +} + +private class EpubSearchWindow( + private val query: String, + private val chapterIndex: Int, + private val chapterTitle: String, + private val chunkIndex: Int, + initialOccurrenceIndex: Int, + private val results: MutableList +) { + private val buffer = StringBuilder() + private val overlapChars = (query.length + EPUB_SEARCH_SNIPPET_RADIUS) + .coerceIn(EPUB_SEARCH_SNIPPET_RADIUS * 2, EPUB_SEARCH_MAX_OVERLAP_CHARS) + private var lastAppendedWasWhitespace = true + private var previousCharBeforeBuffer: Char? = null + + var occurrenceIndex: Int = initialOccurrenceIndex + private set + + fun visit(node: Node) { + when (node) { + is TextNode -> appendNormalizedText(node.wholeText) + is Element -> { + val tagName = node.tagName().lowercase() + if (tagName in epubSearchSkippedTags) return + + if (tagName == "br") { + appendNormalizedWhitespace() + return + } + + node.childNodes().forEach(::visit) + if (tagName in epubSearchBlockBoundaryTags) { + appendNormalizedWhitespace() + } + } + else -> node.childNodes().forEach(::visit) + } + } + + fun finish() { + scanBuffer(buffer.length) + buffer.clear() + previousCharBeforeBuffer = null + } + + private fun appendNormalizedText(text: String) { + text.forEach { char -> + if (char.isWhitespace()) { + appendNormalizedWhitespace() + } else { + buffer.append(char) + lastAppendedWasWhitespace = false + trimScannedPrefixIfNeeded() + } + } + } + + private fun appendNormalizedWhitespace() { + if (buffer.isEmpty() || lastAppendedWasWhitespace) { + lastAppendedWasWhitespace = true + return + } + buffer.append(' ') + lastAppendedWasWhitespace = true + trimScannedPrefixIfNeeded() + } + + private fun trimScannedPrefixIfNeeded() { + if (buffer.length < EPUB_SEARCH_WINDOW_CHARS) return + + val scanEndExclusive = (buffer.length - overlapChars).coerceAtLeast(0) + if (scanEndExclusive <= 0) return + + scanBuffer(scanEndExclusive) + previousCharBeforeBuffer = buffer[scanEndExclusive - 1] + buffer.delete(0, scanEndExclusive) + } + + private fun scanBuffer(scanEndExclusive: Int) { + var searchFrom = 0 + while (searchFrom < scanEndExclusive) { + val matchStart = buffer.indexOfIgnoreCase(query, searchFrom, scanEndExclusive) + if (matchStart == -1) break + + if (isWordStart(matchStart)) { + addSearchResult(matchStart) + } + searchFrom = matchStart + 1 + } + } + + private fun isWordStart(matchStart: Int): Boolean { + val previousChar = if (matchStart > 0) { + buffer[matchStart - 1] + } else { + previousCharBeforeBuffer + } + return previousChar == null || !previousChar.isLetterOrDigit() + } + + private fun addSearchResult(matchStart: Int) { + val snippetStart = max(0, matchStart - EPUB_SEARCH_SNIPPET_RADIUS) + val snippetEnd = min(buffer.length, matchStart + query.length + EPUB_SEARCH_SNIPPET_RADIUS) + val rawSnippet = buffer.substring(snippetStart, snippetEnd) + val highlightStart = matchStart - snippetStart + val highlightEnd = highlightStart + query.length + val annotatedSnippet = buildAnnotatedString { + append(rawSnippet) + addStyle( + style = SpanStyle(fontWeight = FontWeight.Bold), + start = highlightStart, + end = highlightEnd + ) + } + + results.add( + SearchResult( + locationInSource = chapterIndex, + locationTitle = chapterTitle, + snippet = annotatedSnippet, + query = query, + occurrenceIndexInLocation = occurrenceIndex, + chunkIndex = chunkIndex + ) + ) + occurrenceIndex++ + } +} + +private fun CharSequence.indexOfIgnoreCase( + query: String, + startIndex: Int, + matchStartLimitExclusive: Int +): Int { + if (query.isEmpty()) return -1 + val lastStart = min(length - query.length, matchStartLimitExclusive - 1) + if (lastStart < startIndex) return -1 + + var index = startIndex.coerceAtLeast(0) + while (index <= lastStart) { + var queryIndex = 0 + while ( + queryIndex < query.length && + this[index + queryIndex].equals(query[queryIndex], ignoreCase = true) + ) { + queryIndex++ + } + if (queryIndex == query.length) return index + index++ + } + return -1 +} + +/** + * Handles the navigation to a specific search result. + */ +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/epubreader/EpubReaderSettings.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderSettings.kt similarity index 83% rename from app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderSettings.kt index ad354f6..1719297 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderSettings.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.net.Uri @@ -96,6 +96,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -104,17 +106,29 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.edit -import com.aryan.reader.R -import com.aryan.reader.data.CustomFontEntity +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.ReaderFontDiagnosticsTag +import org.dueattendant149.bookreader.data.CustomFontEntity +import org.dueattendant149.bookreader.readerModalMaxHeightDp +import org.dueattendant149.bookreader.readerFontDiagnosticSummary +import org.dueattendant149.bookreader.supportedFontMimeTypes +import org.dueattendant149.bookreader.shared.CustomFontItem +import org.dueattendant149.bookreader.shared.detectFontVariant +import org.dueattendant149.bookreader.shared.fontFaceSummary +import org.dueattendant149.bookreader.shared.familyFilenameSignature +import org.dueattendant149.bookreader.shared.groupByFamily +import org.dueattendant149.bookreader.shared.hasVariableWeightFace +import org.dueattendant149.bookreader.shared.supportsVariableWeightAxis +import timber.log.Timber import java.io.File import kotlin.math.roundToInt -typealias ReaderFont = com.aryan.reader.shared.ReaderFont -typealias ReaderTextAlign = com.aryan.reader.shared.ReaderTextAlign -typealias SystemUiMode = com.aryan.reader.shared.SystemUiMode -typealias PageInfoMode = com.aryan.reader.shared.PageInfoMode -typealias PageInfoPosition = com.aryan.reader.shared.PageInfoPosition -typealias FormatSettings = com.aryan.reader.shared.FormatSettings +typealias ReaderFont = org.dueattendant149.bookreader.shared.ReaderFont +typealias ReaderTextAlign = org.dueattendant149.bookreader.shared.ReaderTextAlign +typealias SystemUiMode = org.dueattendant149.bookreader.shared.SystemUiMode +typealias PageInfoMode = org.dueattendant149.bookreader.shared.PageInfoMode +typealias PageInfoPosition = org.dueattendant149.bookreader.shared.PageInfoPosition +typealias FormatSettings = org.dueattendant149.bookreader.shared.FormatSettings const val SETTINGS_PREFS_NAME = "epub_reader_settings" private const val TEXT_ALIGN_KEY = "reader_text_align" @@ -130,6 +144,7 @@ private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode" private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode" private const val PAGE_INFO_POSITION_KEY = "reader_page_info_position" private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled" +private const val NATIVE_VERTICAL_RENDERER_KEY = "reader_native_vertical_renderer" const val DEFAULT_FONT_SIZE_VAL = 1.0f const val DEFAULT_LINE_HEIGHT_VAL = 1.0f @@ -295,6 +310,16 @@ fun loadPullToTurn(context: Context): Boolean { return prefs.getBoolean(PULL_TO_TURN_ENABLED_KEY, true) } +fun saveNativeVerticalRenderer(context: Context, enabled: Boolean) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putBoolean(NATIVE_VERTICAL_RENDERER_KEY, enabled) } +} + +fun loadNativeVerticalRenderer(context: Context): Boolean { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(NATIVE_VERTICAL_RENDERER_KEY, false) +} + private const val PULL_TO_TURN_MULTIPLIER_KEY = "reader_pull_to_turn_multiplier" fun savePullToTurnMultiplier(context: Context, multiplier: Float) { @@ -398,8 +423,64 @@ fun getComposeFontFamily( ): FontFamily { if (customFontPath != null) { return try { - FontFamily(Font(File(customFontPath))) - } catch (_: Exception) { + val baseFile = File(customFontPath) + val signature = baseFile.nameWithoutExtension.familyFilenameSignature() + val siblings = baseFile.parentFile?.listFiles()?.filter { + it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") && + it.nameWithoutExtension.familyFilenameSignature() == signature + } ?: listOf(baseFile) + + Timber.tag(ReaderFontDiagnosticsTag).i( + "compose.custom.start basePath='${baseFile.absolutePath}' " + + "exists=${baseFile.exists()} bytes=${baseFile.length()} " + + readerFontDiagnosticSummary(baseFile.nameWithoutExtension) + + " siblings=${siblings.joinToString { it.name }}" + ) + + val seenVariants = mutableSetOf() + val fontList = siblings.flatMap { sibling -> + try { + val variant = sibling.nameWithoutExtension.detectFontVariant() + val weights = if (sibling.nameWithoutExtension.supportsVariableWeightAxis()) { + variableReaderFontWeights + } else { + listOf(variant?.weight ?: FontWeight.Normal) + } + Timber.tag(ReaderFontDiagnosticsTag).i( + "compose.custom.candidate file='${sibling.name}' " + + readerFontDiagnosticSummary(sibling.nameWithoutExtension) + + " style=${variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal} " + + "weights=${weights.joinToString { it.weight.toString() }}" + ) + weights.mapNotNull { weight -> + val style = variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal + if (seenVariants.add("${weight.weight}|$style")) { + Font(sibling, weight, style) + } else { + Timber.tag(ReaderFontDiagnosticsTag).i( + "compose.custom.skipDuplicate file='${sibling.name}' weight=${weight.weight} style=$style" + ) + null + } + } + } catch (e: Exception) { + Timber.tag(ReaderFontDiagnosticsTag).e(e, "compose.custom.candidateFailed file='${sibling.name}'") + emptyList() + } + } + if (fontList.isNotEmpty()) { + Timber.tag(ReaderFontDiagnosticsTag).i( + "compose.custom.loaded base='${baseFile.name}' registeredVariants=${seenVariants.joinToString()}" + ) + FontFamily(fontList) + } else { + Timber.tag(ReaderFontDiagnosticsTag).w( + "compose.custom.fallbackSingle base='${baseFile.name}' no inferred variants loaded" + ) + FontFamily(Font(baseFile)) + } + } catch (e: Exception) { + Timber.tag(ReaderFontDiagnosticsTag).e(e, "compose.custom.failed path='$customFontPath'") FontFamily.Default } } @@ -422,6 +503,18 @@ fun getComposeFontFamily( return FontFamily.Default } +private val variableReaderFontWeights = listOf( + FontWeight.Thin, + FontWeight.ExtraLight, + FontWeight.Light, + FontWeight.Normal, + FontWeight.Medium, + FontWeight.SemiBold, + FontWeight.Bold, + FontWeight.ExtraBold, + FontWeight.Black +) + fun saveReaderSettings( context: Context, fontSize: Float, @@ -603,6 +696,7 @@ fun ReaderTextFormatPanel( ) // Font Button + val fontSelectorDescription = stringResource(R.string.content_desc_select_font_family) Surface( onClick = onFontOptionClick, shape = RoundedCornerShape(12.dp), @@ -610,6 +704,9 @@ fun ReaderTextFormatPanel( modifier = Modifier .fillMaxWidth() .height(52.dp) + .semantics { + contentDescription = fontSelectorDescription + } ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -769,12 +866,12 @@ fun FontSelectionSheetContent( currentCustomFontPath: String?, onFontSelected: (ReaderFont, String?) -> Unit, customFonts: List, - onImportFont: (Uri) -> Unit, + onImportFonts: (List) -> Unit, onDismiss: () -> Unit ) { var selectedTabIndex by remember { mutableIntStateOf(0) } - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - uri?.let { onImportFont(it) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + if (uris.isNotEmpty()) onImportFonts(uris) } Column(modifier = Modifier.fillMaxWidth()) { @@ -817,7 +914,7 @@ fun FontSelectionSheetContent( Column(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Button( - onClick = { launcher.launch(arrayOf("font/ttf", "font/otf", "application/x-font-ttf")) }, + onClick = { launcher.launch(supportedFontMimeTypes()) }, modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Default.Add, contentDescription = null) @@ -835,16 +932,56 @@ fun FontSelectionSheetContent( ) } } else { + val customFontFamilies = remember(customFonts) { + val grouped = customFonts + .filterNot { it.isDeleted } + .map { it.toSharedCustomFontItem() } + .groupByFamily() + Timber.tag(ReaderFontDiagnosticsTag).i( + "picker.grouped count=${grouped.size} families=${ + grouped.joinToString { family -> + "${family.familyName} -> [" + + family.variants.joinToString { variantItem -> + val font = variantItem.font + "${font.displayName}:${variantItem.variant}" + } + "]" + } + }" + ) + grouped + } LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { - items(customFonts) { fontEntity -> - val isSelected = currentCustomFontPath == fontEntity.path - val fontFamily = remember(fontEntity.path) { - try { FontFamily(Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default } + items(customFontFamilies) { family -> + val baseFont = family.variants.firstOrNull { + val variant = it.variant + variant != null && + variant.weight == FontWeight.Normal && + variant.style == androidx.compose.ui.text.font.FontStyle.Normal + }?.font ?: family.variants.first().font + val isSelected = family.variants.any { it.font.path == currentCustomFontPath } + val fontFamily = remember(baseFont.path) { + getComposeFontFamily(ReaderFont.ORIGINAL, baseFont.path) } + Timber.tag(ReaderFontDiagnosticsTag).i( + "picker.row family='${family.familyName}' base='${baseFont.displayName}' " + + "selected=$isSelected variants=${ + family.variants.joinToString { "${it.font.displayName}:${it.variant}" } + }" + ) ListItem( headlineContent = { - Text(fontEntity.displayName, fontFamily = fontFamily) + Text(family.familyName, fontFamily = fontFamily) + }, + supportingContent = { + Text( + buildString { + append(family.fontFaceSummary()) + if (family.hasVariableWeightFace()) append(" - Variable weight") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) }, trailingContent = { if (isSelected) { @@ -855,7 +992,12 @@ fun FontSelectionSheetContent( ) } }, - modifier = Modifier.clickable { onFontSelected(ReaderFont.ORIGINAL, fontEntity.path) }, + modifier = Modifier.clickable { + Timber.tag(ReaderFontDiagnosticsTag).i( + "picker.selected family='${family.familyName}' base='${baseFont.displayName}' path='${baseFont.path}'" + ) + onFontSelected(ReaderFont.ORIGINAL, baseFont.path) + }, colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors() ) HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) @@ -869,6 +1011,18 @@ fun FontSelectionSheetContent( } } +private fun CustomFontEntity.toSharedCustomFontItem(): CustomFontItem { + return CustomFontItem( + id = id, + displayName = displayName, + fileName = fileName, + fileExtension = fileExtension, + path = path, + timestamp = timestamp, + isDeleted = isDeleted + ) +} + private const val REMOVE_EDGE_PADDING_KEY = "reader_remove_edge_padding" fun saveRemoveEdgePadding(context: Context, enabled: Boolean) { @@ -897,6 +1051,8 @@ fun VisualOptionsSheet( onDismiss: () -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val configuration = LocalConfiguration.current + val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, @@ -906,6 +1062,8 @@ fun VisualOptionsSheet( Column( modifier = Modifier .fillMaxWidth() + .heightIn(max = maxSheetHeight) + .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 8.dp) ) { Row( diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderSystem.kt similarity index 76% rename from app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderSystem.kt index 42ce113..024bbf2 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderSystem.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.epubreader +package org.dueattendant149.bookreader.epubreader import timber.log.Timber import android.graphics.Color @@ -34,7 +34,9 @@ import androidx.compose.ui.input.key.type import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat -import com.aryan.reader.RenderMode +import org.dueattendant149.bookreader.paginatedreader.AndroidEpubKeyCommand +import org.dueattendant149.bookreader.paginatedreader.androidEpubKeyCommandOrNull +import org.dueattendant149.bookreader.RenderMode @Composable fun EpubReaderSystemUiController( @@ -150,3 +152,45 @@ fun Modifier.volumeScrollHandler( } true } + +fun Modifier.epubReaderKeyboardNavigationHandler( + enabled: Boolean, + renderMode: RenderMode, + isRightToLeftPagination: Boolean, + verticalLineScrollPx: Int, + onVerticalScrollBy: (Int) -> Unit, + onNextPage: () -> Unit, + onPreviousPage: () -> Unit, + onFirstPage: () -> Unit, + onLastPage: () -> Unit +): Modifier = this.onPreviewKeyEvent { keyEvent -> + if (!enabled) return@onPreviewKeyEvent false + val command = androidEpubKeyCommandOrNull( + keyCode = keyEvent.nativeKeyEvent.keyCode, + rightToLeftPagination = isRightToLeftPagination, + isCtrlPressed = keyEvent.nativeKeyEvent.isCtrlPressed + ) ?: return@onPreviewKeyEvent false + if (keyEvent.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent when (command) { + AndroidEpubKeyCommand.SCROLL_UP, + AndroidEpubKeyCommand.SCROLL_DOWN -> renderMode == RenderMode.VERTICAL_SCROLL + else -> true + } + } + + when (command) { + AndroidEpubKeyCommand.SCROLL_UP -> { + if (renderMode != RenderMode.VERTICAL_SCROLL) return@onPreviewKeyEvent false + onVerticalScrollBy(-verticalLineScrollPx) + } + AndroidEpubKeyCommand.SCROLL_DOWN -> { + if (renderMode != RenderMode.VERTICAL_SCROLL) return@onPreviewKeyEvent false + onVerticalScrollBy(verticalLineScrollPx) + } + AndroidEpubKeyCommand.PREVIOUS_PAGE -> onPreviousPage() + AndroidEpubKeyCommand.NEXT_PAGE -> onNextPage() + AndroidEpubKeyCommand.FIRST_PAGE -> onFirstPage() + AndroidEpubKeyCommand.LAST_PAGE -> onLastPage() + } + true +} diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderTts.kt similarity index 94% rename from app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderTts.kt index 7781952..03f7c6e 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderTts.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.net.Uri @@ -33,15 +33,15 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberUpdatedState import androidx.core.content.edit import androidx.media3.common.util.UnstableApi -import com.aryan.reader.RenderMode -import com.aryan.reader.epub.EpubChapter -import com.aryan.reader.paginatedreader.BookPaginator -import com.aryan.reader.paginatedreader.IPaginator -import com.aryan.reader.shared.ReaderTtsReplacementPreferences -import com.aryan.reader.tts.TtsController -import com.aryan.reader.tts.TtsPlaybackManager -import com.aryan.reader.tts.TtsPlaybackManager.TtsMode -import com.aryan.reader.withTtsReplacements +import org.dueattendant149.bookreader.RenderMode +import org.dueattendant149.bookreader.epub.EpubChapter +import org.dueattendant149.bookreader.paginatedreader.BookPaginator +import org.dueattendant149.bookreader.paginatedreader.IPaginator +import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences +import org.dueattendant149.bookreader.tts.TtsController +import org.dueattendant149.bookreader.tts.TtsPlaybackManager +import org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsMode +import org.dueattendant149.bookreader.withTtsReplacements import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -116,8 +116,8 @@ fun TtsSessionObserver( scope: CoroutineScope, currentTtsMode: TtsMode, getAuthToken: suspend () -> String?, - locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter, // NEW - epubBook: com.aryan.reader.epub.EpubBook, // NEW + locatorConverter: org.dueattendant149.bookreader.paginatedreader.LocatorConverter, // NEW + epubBook: org.dueattendant149.bookreader.epub.EpubBook, // NEW ttsReplacementPreferences: ReaderTtsReplacementPreferences, ttsReplacementBookId: String? ) { @@ -312,8 +312,8 @@ private fun handleVerticalAutoAdvance( getAuthToken: suspend () -> String?, ttsController: TtsController, scope: CoroutineScope, - locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter, - epubBook: com.aryan.reader.epub.EpubBook, + locatorConverter: org.dueattendant149.bookreader.paginatedreader.LocatorConverter, + epubBook: org.dueattendant149.bookreader.epub.EpubBook, ttsReplacementPreferences: ReaderTtsReplacementPreferences, ttsReplacementBookId: String? ) { @@ -328,16 +328,15 @@ private fun handleVerticalAutoAdvance( val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex) if (!nativeChunks.isNullOrEmpty()) { - val resumeIdx = findTtsChunkResumeIndex( + val startChunkIndex = resolveTtsContinuationStartIndex( chunks = nativeChunks, + loadedChunkCount = loadedChunkCount, sourceCfi = lastReadCfi, startOffsetInSource = currentState.startOffsetInSource, - currentText = currentState.currentText, - currentChunkIndexFallback = currentState.currentChunkIndex + currentText = currentState.currentText ) - if (resumeIdx != null && resumeIdx + 1 < nativeChunks.size) { - val startChunkIndex = resumeIdx + 1 + if (startChunkIndex != null) { val token = getAuthToken() ttsController.start( chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId), diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderVertical.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderVertical.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/epubreader/EpubReaderVertical.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderVertical.kt index 742f599..94506d9 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderVertical.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderVertical.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.epubreader +package org.dueattendant149.bookreader.epubreader import timber.log.Timber import android.webkit.JavascriptInterface diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderVisualOptionsState.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderVisualOptionsState.kt new file mode 100644 index 0000000..e8f440e --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderVisualOptionsState.kt @@ -0,0 +1,14 @@ +package org.dueattendant149.bookreader.epubreader + +import org.dueattendant149.bookreader.shared.PageInfoMode + +internal fun shouldShowEpubPageInfoBar( + pageInfoMode: PageInfoMode, + showReaderChrome: Boolean +): Boolean { + return when (pageInfoMode) { + PageInfoMode.DEFAULT -> true + PageInfoMode.SYNC -> showReaderChrome + PageInfoMode.HIDDEN -> false + } +} diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubTtsChunkMatching.kt similarity index 81% rename from app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubTtsChunkMatching.kt index e02e82a..30cfdfe 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubTtsChunkMatching.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.epubreader +package org.dueattendant149.bookreader.epubreader -import com.aryan.reader.paginatedreader.CfiUtils -import com.aryan.reader.paginatedreader.TtsChunk +import org.dueattendant149.bookreader.paginatedreader.CfiUtils +import org.dueattendant149.bookreader.paginatedreader.TtsChunk import kotlin.math.abs private val TTS_WHITESPACE = Regex("\\s+") @@ -78,6 +78,29 @@ internal fun findTtsChunkResumeIndex( return currentChunkIndexFallback.takeIf { it in chunks.indices } } +internal fun resolveTtsContinuationStartIndex( + chunks: List, + loadedChunkCount: Int, + sourceCfi: String?, + startOffsetInSource: Int, + currentText: String? +): Int? { + val matchedResumeIndex = findTtsChunkResumeIndex( + chunks = chunks, + sourceCfi = sourceCfi, + startOffsetInSource = startOffsetInSource, + currentText = currentText, + currentChunkIndexFallback = -1 + ) + + val matchedNextIndex = matchedResumeIndex?.plus(1) + if (matchedNextIndex != null && matchedNextIndex in chunks.indices) { + return matchedNextIndex + } + + return loadedChunkCount.takeIf { it in chunks.indices } +} + private fun cfiPathContains(parentPath: String, childPath: String): Boolean { if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false val parentParts = parentPath.split('/').filter { it.isNotEmpty() } diff --git a/app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/ExternalDictionaryHelper.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/ExternalDictionaryHelper.kt index 1c07dae..64ab62a 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/ExternalDictionaryHelper.kt @@ -1,5 +1,5 @@ // ExternalDictionaryHelper.kt -package com.aryan.reader.epubreader +package org.dueattendant149.bookreader.epubreader import android.app.SearchManager import android.content.Context @@ -11,7 +11,7 @@ import android.os.Build import android.widget.Toast import timber.log.Timber import androidx.core.net.toUri -import com.aryan.reader.R +import org.dueattendant149.bookreader.R data class ExternalDictionaryApp( val label: String, diff --git a/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/InteractiveWebView.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/InteractiveWebView.kt index 6438001..6c21f6c 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/InteractiveWebView.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.epubreader +package org.dueattendant149.bookreader.epubreader import android.annotation.SuppressLint import android.content.Context diff --git a/app/src/main/java/com/aryan/reader/feedback/FeedbackScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/feedback/FeedbackScreen.kt similarity index 98% rename from app/src/main/java/com/aryan/reader/feedback/FeedbackScreen.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/feedback/FeedbackScreen.kt index c1952fa..e81a80d 100644 --- a/app/src/main/java/com/aryan/reader/feedback/FeedbackScreen.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/feedback/FeedbackScreen.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.feedback +package org.dueattendant149.bookreader.feedback import android.content.Intent import androidx.compose.foundation.layout.Column @@ -56,7 +56,7 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController -import com.aryan.reader.R +import org.dueattendant149.bookreader.R import timber.log.Timber private fun launchEmailFeedback(context: android.content.Context) { diff --git a/app/src/main/java/com/aryan/reader/feedback/FeedbackViewModel.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/feedback/FeedbackViewModel.kt similarity index 97% rename from app/src/main/java/com/aryan/reader/feedback/FeedbackViewModel.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/feedback/FeedbackViewModel.kt index 00d5541..d5784d8 100644 --- a/app/src/main/java/com/aryan/reader/feedback/FeedbackViewModel.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/feedback/FeedbackViewModel.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.feedback +package org.dueattendant149.bookreader.feedback import android.app.Application import android.content.Context @@ -26,11 +26,11 @@ import androidx.annotation.StringRes import timber.log.Timber import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import com.aryan.reader.AuthRepository -import com.aryan.reader.R -import com.aryan.reader.data.FeedbackMessage -import com.aryan.reader.data.FeedbackRepository -import com.aryan.reader.data.FeedbackThread +import org.dueattendant149.bookreader.AuthRepository +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.data.FeedbackMessage +import org.dueattendant149.bookreader.data.FeedbackRepository +import org.dueattendant149.bookreader.data.FeedbackThread import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update diff --git a/app/src/main/java/com/aryan/reader/feedback/FeedbackWorker.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/feedback/FeedbackWorker.kt similarity index 91% rename from app/src/main/java/com/aryan/reader/feedback/FeedbackWorker.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/feedback/FeedbackWorker.kt index 0d0a702..ef402dd 100644 --- a/app/src/main/java/com/aryan/reader/feedback/FeedbackWorker.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/feedback/FeedbackWorker.kt @@ -17,20 +17,20 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.feedback +package org.dueattendant149.bookreader.feedback import android.content.Context import android.os.Build import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import androidx.work.workDataOf -import com.aryan.reader.BuildConfig -import com.aryan.reader.data.FeedbackRepository -import com.aryan.reader.data.FeedbackTextPayload +import org.dueattendant149.bookreader.BuildConfig +import org.dueattendant149.bookreader.data.FeedbackRepository +import org.dueattendant149.bookreader.data.FeedbackTextPayload import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import androidx.core.net.toUri -import com.aryan.reader.AuthRepository +import org.dueattendant149.bookreader.AuthRepository class FeedbackWorker( appContext: Context, diff --git a/app/src/main/java/com/aryan/reader/feedback/SupportProjectScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/feedback/SupportProjectScreen.kt similarity index 98% rename from app/src/main/java/com/aryan/reader/feedback/SupportProjectScreen.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/feedback/SupportProjectScreen.kt index bf71e09..bee681e 100644 --- a/app/src/main/java/com/aryan/reader/feedback/SupportProjectScreen.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/feedback/SupportProjectScreen.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.feedback +package org.dueattendant149.bookreader.feedback import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -32,7 +32,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController -import com.aryan.reader.R +import org.dueattendant149.bookreader.R @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/com/aryan/reader/ml/IPanelDetector.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ml/IPanelDetector.kt similarity index 84% rename from app/src/main/java/com/aryan/reader/ml/IPanelDetector.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/ml/IPanelDetector.kt index fa3e86b..fe8dcd6 100644 --- a/app/src/main/java/com/aryan/reader/ml/IPanelDetector.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ml/IPanelDetector.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/java/com/aryan/reader/ml/ISpeechBubbleDetector.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ml/ISpeechBubbleDetector.kt similarity index 87% rename from app/src/main/java/com/aryan/reader/ml/ISpeechBubbleDetector.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/ml/ISpeechBubbleDetector.kt index 1f3dd9e..030cead 100644 --- a/app/src/main/java/com/aryan/reader/ml/ISpeechBubbleDetector.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ml/ISpeechBubbleDetector.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/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt new file mode 100644 index 0000000..2f53581 --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt @@ -0,0 +1,10 @@ +package org.dueattendant149.bookreader.opds + +typealias OpdsCatalog = org.dueattendant149.bookreader.shared.opds.OpdsCatalog +typealias OpdsFacet = org.dueattendant149.bookreader.shared.opds.OpdsFacet +typealias OpdsFeed = org.dueattendant149.bookreader.shared.opds.OpdsFeed +typealias OpdsAuthor = org.dueattendant149.bookreader.shared.opds.OpdsAuthor +typealias OpdsAcquisition = org.dueattendant149.bookreader.shared.opds.OpdsAcquisition +typealias OpdsEntry = org.dueattendant149.bookreader.shared.opds.OpdsEntry +typealias OpdsDownloadState = org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadState +typealias OpdsScreenState = org.dueattendant149.bookreader.shared.opds.SharedOpdsScreenState diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt new file mode 100644 index 0000000..d8af46f --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt @@ -0,0 +1,3 @@ +package org.dueattendant149.bookreader.opds + +typealias OpdsParser = org.dueattendant149.bookreader.shared.opds.SharedOpdsParser diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt similarity index 94% rename from app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt index 068869a..b19d280 100644 --- a/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt @@ -1,10 +1,10 @@ -package com.aryan.reader.opds +package org.dueattendant149.bookreader.opds import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit -import com.aryan.reader.shared.opds.SharedOpdsCatalogs -import com.aryan.reader.shared.opds.SharedOpdsRepository +import org.dueattendant149.bookreader.shared.opds.SharedOpdsCatalogs +import org.dueattendant149.bookreader.shared.opds.SharedOpdsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -116,7 +116,7 @@ class OpdsRepository(context: Context) : SharedOpdsRepository { if (wwwAuth.startsWith("Digest", ignoreCase = true)) { val realm = extractParam(wwwAuth, "realm") ?: "" val nonce = extractParam(wwwAuth, "nonce") ?: "" - val qop = extractParam(wwwAuth, "qop") + val qop = selectAuthQop(extractParam(wwwAuth, "qop")) val opaque = extractParam(wwwAuth, "opaque") cnonceCount++ @@ -162,6 +162,13 @@ class OpdsRepository(context: Context) : SharedOpdsRepository { return match?.groupValues?.get(1) } + private fun selectAuthQop(value: String?): String? { + return value + ?.split(',') + ?.map { it.trim().trim('"') } + ?.firstOrNull { it.equals("auth", ignoreCase = true) } + } + private fun md5(input: String): String { val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray()) return bytes.joinToString("") { "%02x".format(it) } diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt similarity index 96% rename from app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt index 08c1f36..55388c2 100644 --- a/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt @@ -1,13 +1,13 @@ -package com.aryan.reader.opds +package org.dueattendant149.bookreader.opds import android.app.Application import android.content.Context import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import com.aryan.reader.R -import com.aryan.reader.shared.opds.SharedOpdsController -import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.shared.opds.SharedOpdsController +import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadNamer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/AndroidEpubKeyCommands.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/AndroidEpubKeyCommands.kt new file mode 100644 index 0000000..545a622 --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/AndroidEpubKeyCommands.kt @@ -0,0 +1,39 @@ +package org.dueattendant149.bookreader.paginatedreader + +import android.view.KeyEvent + +internal enum class AndroidEpubKeyCommand { + PREVIOUS_PAGE, + NEXT_PAGE, + SCROLL_UP, + SCROLL_DOWN, + FIRST_PAGE, + LAST_PAGE +} + +internal fun androidEpubKeyCommandOrNull( + keyCode: Int, + rightToLeftPagination: Boolean = false, + isCtrlPressed: Boolean = false +): AndroidEpubKeyCommand? { + if (isCtrlPressed) return null + return when (keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> if (rightToLeftPagination) { + AndroidEpubKeyCommand.NEXT_PAGE + } else { + AndroidEpubKeyCommand.PREVIOUS_PAGE + } + KeyEvent.KEYCODE_DPAD_RIGHT -> if (rightToLeftPagination) { + AndroidEpubKeyCommand.PREVIOUS_PAGE + } else { + AndroidEpubKeyCommand.NEXT_PAGE + } + KeyEvent.KEYCODE_DPAD_UP -> AndroidEpubKeyCommand.SCROLL_UP + KeyEvent.KEYCODE_DPAD_DOWN -> AndroidEpubKeyCommand.SCROLL_DOWN + KeyEvent.KEYCODE_PAGE_UP -> AndroidEpubKeyCommand.PREVIOUS_PAGE + KeyEvent.KEYCODE_PAGE_DOWN -> AndroidEpubKeyCommand.NEXT_PAGE + KeyEvent.KEYCODE_MOVE_HOME -> AndroidEpubKeyCommand.FIRST_PAGE + KeyEvent.KEYCODE_MOVE_END -> AndroidEpubKeyCommand.LAST_PAGE + else -> null + } +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/AndroidHtmlParserPlatform.kt similarity index 84% rename from app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/AndroidHtmlParserPlatform.kt index 370b5f8..5e53f5d 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/AndroidHtmlParserPlatform.kt @@ -1,7 +1,8 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import android.graphics.BitmapFactory import androidx.compose.ui.text.font.FontFamily +import org.dueattendant149.bookreader.epub.safeFileInRoot import java.io.File import java.net.URLDecoder import java.nio.file.Paths @@ -16,12 +17,14 @@ object AndroidHtmlResourceResolver : HtmlResourceResolver { } val parentPath = File(chapterAbsPath).parent ?: "" val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString() - val fromRelativeFile = File(extractionBasePath, relativePath) return try { + val extractionRoot = File(extractionBasePath) + val fromRelativeFile = safeFileInRoot(extractionRoot, relativePath) + val fromRootFile = safeFileInRoot(extractionRoot, decodedSrc) when { - fromRelativeFile.exists() -> fromRelativeFile.canonicalFile.absolutePath - File(extractionBasePath, decodedSrc).exists() -> File(extractionBasePath, decodedSrc).canonicalFile.absolutePath + fromRelativeFile?.exists() == true -> fromRelativeFile.absolutePath + fromRootFile?.exists() == true -> fromRootFile.absolutePath else -> null } } catch (_: Exception) { diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/BookPaginator.kt similarity index 84% rename from app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/BookPaginator.kt index 302131b..deffd25 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/BookPaginator.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import android.content.Context import android.os.Build @@ -36,28 +36,36 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density -import com.aryan.reader.SearchResult -import com.aryan.reader.epub.EpubChapter -import com.aryan.reader.epub.contentFilePath -import com.aryan.reader.paginatedreader.data.BookCacheDao -import com.aryan.reader.paginatedreader.data.BookProcessingInput -import com.aryan.reader.paginatedreader.data.BookProcessingWorker -import com.aryan.reader.paginatedreader.data.ConfigurationCache -import com.aryan.reader.paginatedreader.data.LATEST_PAGE_CACHE_VERSION -import com.aryan.reader.paginatedreader.data.LATEST_PROCESSING_VERSION -import com.aryan.reader.paginatedreader.data.PageCacheEntry -import com.aryan.reader.paginatedreader.data.PageIndexEntry -import com.aryan.reader.paginatedreader.data.ProcessedBook -import com.aryan.reader.paginatedreader.data.ProcessedChapter -import com.aryan.reader.paginatedreader.data.SerializableEpubChapter -import com.aryan.reader.tts.PageCharacterRange -import com.aryan.reader.tts.splitTextIntoChunks +import org.dueattendant149.bookreader.SearchResult +import org.dueattendant149.bookreader.applyBookReplacementsToHtmlDocument +import org.dueattendant149.bookreader.epub.EpubChapter +import org.dueattendant149.bookreader.epub.contentFilePath +import org.dueattendant149.bookreader.epub.plainTextCharacterCount +import org.dueattendant149.bookreader.paginatedreader.data.BookCacheDao +import org.dueattendant149.bookreader.paginatedreader.data.BookProcessingInput +import org.dueattendant149.bookreader.paginatedreader.data.BookProcessingWorker +import org.dueattendant149.bookreader.paginatedreader.data.ConfigurationCache +import org.dueattendant149.bookreader.paginatedreader.data.LATEST_PAGE_CACHE_VERSION +import org.dueattendant149.bookreader.paginatedreader.data.LATEST_PROCESSING_VERSION +import org.dueattendant149.bookreader.paginatedreader.data.PageCacheEntry +import org.dueattendant149.bookreader.paginatedreader.data.PageIndexEntry +import org.dueattendant149.bookreader.paginatedreader.data.ProcessedBook +import org.dueattendant149.bookreader.paginatedreader.data.ProcessedChapter +import org.dueattendant149.bookreader.paginatedreader.data.SerializableEpubChapter +import org.dueattendant149.bookreader.shared.ReaderBookReplacementPreferences +import org.dueattendant149.bookreader.shared.ReaderBookReplacementPreferencesJson +import org.dueattendant149.bookreader.tts.PageCharacterRange +import org.dueattendant149.bookreader.tts.splitTextIntoChunks import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.cancel +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -74,6 +82,8 @@ import java.net.URI import java.net.URLDecoder import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.PriorityBlockingQueue +import java.util.concurrent.TimeUnit +import kotlin.coroutines.coroutineContext private const val PRIORITY_HIGHEST = 0 private const val PRIORITY_HIGH = 1 @@ -134,7 +144,7 @@ private data class PageNavigationEntry( @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Stable class BookPaginator( - private val coroutineScope: CoroutineScope, + coroutineScope: CoroutineScope, private val chapters: List, private val textMeasurer: TextMeasurer, private val constraints: Constraints, @@ -157,7 +167,9 @@ class BookPaginator( private val userTextAlign: TextAlign?, private val paragraphGapMultiplier: Float, private val imageSizeMultiplier: Float, - private val verticalMarginMultiplier: Float + private val verticalMarginMultiplier: Float, + private val bookReplacementPreferences: ReaderBookReplacementPreferences = ReaderBookReplacementPreferences(), + private val bookReplacementFileId: String? = null ) : IPaginator { override var totalPageCount by mutableIntStateOf(0) private set @@ -190,6 +202,7 @@ class BookPaginator( private val chapterTextRangeIndex = ConcurrentHashMap>() private val chapterPageNavigationIndex = ConcurrentHashMap>() private val chapterAnchorPageIndex = ConcurrentHashMap>() + private val expandedAllFontFaces = expandFontFacesWithSiblings(allFontFaces, extractionBasePath) private var pageCountsAreAccurate by mutableStateOf(false) private val finalizedChapterCounts = ConcurrentHashMap.newKeySet() @@ -200,8 +213,27 @@ class BookPaginator( private val paginationQueue = PriorityBlockingQueue() private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet() private val chapterPaginationLocks = ConcurrentHashMap() + private val chapterBlockLocks = ConcurrentHashMap() private val navigationCallbacks = ConcurrentHashMap) -> Unit>>() private var paginationWorker: Job? = null + private val paginatorJob = SupervisorJob(coroutineScope.coroutineContext[Job]) + private val paginatorScope = CoroutineScope(coroutineScope.coroutineContext + paginatorJob) + @Volatile + private var disposed = false + + override fun dispose() { + if (disposed) return + disposed = true + paginationQueue.clear() + navigationCallbacks.clear() + chaptersBeingProcessed.clear() + paginationWorker?.cancel() + paginatorJob.cancel(CancellationException("BookPaginator disposed")) + isLoading = false + Timber.i("BookPaginator disposed for book=$bookId configHash=$currentConfigHash") + } + + private fun isDisposed(): Boolean = disposed || !paginatorJob.isActive internal fun getCharactersScrolledInChapter(chapterIndex: Int, pageInChapter: Int): Long { val cumulativeCharsList = chapterCumulativeChars[chapterIndex] @@ -224,7 +256,7 @@ class BookPaginator( Timber.e("Paginator received UNBOUNDED HEIGHT. Pagination will fail.") } else { Timber.i("Paginator initializing with constraints: $constraints") - coroutineScope.launch { + paginatorScope.launch { isLoading = true Timber.d("Initialization started.") @@ -236,22 +268,46 @@ class BookPaginator( return@launch } - // 1. Book processing check (Keep existing logic) + // 1. Generate config hash before touching semantic cache; processed chapters are style-sensitive. + coroutineContext.ensureActive() + currentConfigHash = generateConfigurationHash() + coroutineContext.ensureActive() + + // 2. Book processing check (Keep existing logic) val bookRecord = bookCacheDao.getProcessedBook(bookId) + var shouldEnqueueBookProcessing = false if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) { Timber.i("Book cache is new or stale. Creating initial record.") bookCacheDao.deleteEntireBookCache(bookId) val initialBook = ProcessedBook(bookId, LATEST_PROCESSING_VERSION, 0) // Temp 0 bookCacheDao.insertProcessedBook(initialBook) - enqueueBookProcessingWork() + shouldEnqueueBookProcessing = true + } else if (bookCacheDao.getProcessedChapter( + bookId, + initialChapterToPaginate.coerceIn(0, chapters.lastIndex), + currentConfigHash + ) == null + ) { + Timber.i("Semantic chapter cache is missing for current style config. Enqueuing config-aware processing.") + shouldEnqueueBookProcessing = true } - // 2. GENERATE CONFIG HASH - currentConfigHash = generateConfigurationHash() + coroutineContext.ensureActive() + if (isDisposed()) return@launch + + if (shouldEnqueueBookProcessing) { + enqueueBookProcessingWork() + } else { + BookProcessingWorker.cancelForBook(context, bookId) + } // 3. TRY LOAD EXACT COUNTS FROM DB + coroutineContext.ensureActive() + if (isDisposed()) return@launch val cachedConfig = bookCacheDao.getConfigurationCache(bookId, currentConfigHash) + coroutineContext.ensureActive() + if (isDisposed()) return@launch if (cachedConfig != null) { Timber.i("Configuration Cache HIT. Using saved page counts.") applyAccuratePageCounts(cachedConfig.chapterPageCounts) @@ -308,15 +364,7 @@ class BookPaginator( } private fun getAllTextBlocks(blocks: List): List { - return blocks.flatMap { block -> - when (block) { - is WrappingContentBlock -> getAllTextBlocks(block.paragraphsToWrap) - is FlexContainerBlock -> getAllTextBlocks(block.children) - is TableBlock -> block.rows.flatten().flatMap { getAllTextBlocks(it.content) } - is TextContentBlock -> listOf(block) - else -> emptyList() - } - } + return flattenTextContentBlocksForNavigation(blocks) } private fun generateConfigurationHash(): Int { @@ -326,15 +374,19 @@ class BookPaginator( append("-fs:${textStyle.fontSize.value}") append("-lh:${textStyle.lineHeight.value}") append("-ff:${textStyle.fontFamily}") + append("-style:${textStyle.hashCode()}") + append("-density:${density.density}") + append("-fontScale:${density.fontScale}") append("-ta:$userTextAlign") append("-pg:$paragraphGapMultiplier") append("-img:$imageSizeMultiplier") append("-vm:$verticalMarginMultiplier") + append("-book-replacements:${bookReplacementPreferences.signatureForFile(bookReplacementFileId)}") append("-proc:$LATEST_PROCESSING_VERSION") append("-pageCache:$LATEST_PAGE_CACHE_VERSION") append("-ua:${userAgentStylesheet.hashCode()}") append("-css:${bookCss.hashCode()}") - append("-fonts:${allFontFaces.hashCode()}") + append("-fonts:${expandedAllFontFaces.hashCode()}") } val hash = configString.hashCode() return hash @@ -413,7 +465,7 @@ class BookPaginator( append('|') append(chapter.htmlContent.hashCode()) append('|') - append(chapter.plainTextContent.length) + append(chapter.plainTextCharacterCount()) append('|') append(chapter.plainTextContent.hashCode()) append('|') @@ -426,13 +478,24 @@ class BookPaginator( } private suspend fun loadCachedPagesForChapter(chapter: EpubChapter, chapterIndex: Int): List? { - val cachedPages = bookCacheDao.getPageCache(bookId, currentConfigHash, chapterIndex) ?: return null + val cachedPages = bookCacheDao.getPageCache(bookId, currentConfigHash, chapterIndex) ?: run { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_cache_lookup result=miss chapter=$chapterIndex configHash=$currentConfigHash" + ) + return null + } val expectedContentVersion = chapterContentVersion(chapter) val isCompatible = cachedPages.processingVersion == LATEST_PROCESSING_VERSION && cachedPages.pageCacheVersion == LATEST_PAGE_CACHE_VERSION && cachedPages.contentVersion == expectedContentVersion if (!isCompatible) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_cache_lookup result=stale chapter=$chapterIndex " + + "cachedProcessing=${cachedPages.processingVersion} expectedProcessing=$LATEST_PROCESSING_VERSION " + + "cachedPageCache=${cachedPages.pageCacheVersion} expectedPageCache=$LATEST_PAGE_CACHE_VERSION " + + "cachedContent=${cachedPages.contentVersion} expectedContent=$expectedContentVersion" + ) Timber.d("Page cache stale for chapter $chapterIndex. Ignoring cached pages.") return null } @@ -446,6 +509,10 @@ class BookPaginator( applyPageRuntimeIndexes(chapterIndex, pages) updatePageCountsOnMain(chapterIndex, pages.size) pageCache.put(chapterIndex, pages) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_cache_lookup result=hit chapter=$chapterIndex configHash=$currentConfigHash " + + pages.readerPagesLinkDiagSummary() + ) Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.") pages } @@ -456,8 +523,14 @@ class BookPaginator( } private fun savePageCacheAsync(chapter: EpubChapter, chapterIndex: Int, pages: List) { - coroutineScope.launch(Dispatchers.IO) { + if (isDisposed()) return + paginatorScope.launch(Dispatchers.IO) { + if (isDisposed()) return@launch try { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_cache_save chapter=$chapterIndex configHash=$currentConfigHash " + + pages.readerPagesLinkDiagSummary() + ) val pageIndexEntries = buildPersistentPageIndexEntries(chapterIndex, pages) val cacheEntry = PageCacheEntry( bookId = bookId, @@ -578,16 +651,19 @@ class BookPaginator( private suspend fun updatePageCountsOnMain(chapterIndex: Int, actualPageCount: Int) { withContext(Dispatchers.Main) { + if (isDisposed()) return@withContext if (chapterPageCounts[chapterIndex] != actualPageCount) { updatePageCounts(chapterIndex, actualPageCount) } else if (finalizedChapterCounts.add(chapterIndex)) { - coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() } + paginatorScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() } } generation++ } } private suspend fun ensureChapterPaginated(chapterIndex: Int): List? { + coroutineContext.ensureActive() + if (isDisposed()) return null if (chapterIndex !in chapters.indices) { Timber.w("ensureChapterPaginated: Ignoring invalid chapter index $chapterIndex.") return null @@ -612,6 +688,29 @@ class BookPaginator( } } + private suspend fun getCachedBlocksForChapter(chapter: EpubChapter, chapterIndex: Int): List { + blockCache[chapterIndex]?.let { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "content_l2_cache_hit chapter=$chapterIndex " + it.readerContentLinkDiagSummary() + ) + return it + } + + val lock = chapterBlockLocks.computeIfAbsent(chapterIndex) { Mutex() } + return lock.withLock { + blockCache[chapterIndex]?.also { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "content_l2_cache_hit_after_wait chapter=$chapterIndex " + it.readerContentLinkDiagSummary() + ) + } ?: run { + Timber.d("getCachedBlocksForChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.") + getBlocksForChapter(chapter, chapterIndex).also { blocks -> + blockCache.put(chapterIndex, blocks) + } + } + } + } + private suspend fun ensureStableStartPageForChapter(chapterIndex: Int): Int? { Timber.tag(TAG_STABLE_PAGE_NAV).d( "stable_start request chapter=$chapterIndex countsAccurate=$pageCountsAreAccurate finalized=${chapterIndex in finalizedChapterCounts}" @@ -672,7 +771,7 @@ class BookPaginator( ordinalInChapter: Int ): Pair? = withContext(Dispatchers.IO) { val chapter = chapters.getOrNull(chapterIndex) ?: return@withContext null - val imageBlocks = getAllBlocks(getBlocksForChapter(chapter, chapterIndex)) + val imageBlocks = getAllBlocks(getCachedBlocksForChapter(chapter, chapterIndex)) .filterIsInstance() if (imageBlocks.isEmpty()) return@withContext null @@ -756,6 +855,7 @@ class BookPaginator( } private fun enqueueBookProcessingWork() { + if (isDisposed()) return val serializableChapters = chapters.map { SerializableEpubChapter( htmlContent = it.htmlContent, @@ -773,9 +873,15 @@ class BookPaginator( density = density.density, constraintsMaxWidth = constraints.maxWidth, constraintsMaxHeight = constraints.maxHeight, - fontFaces = this.allFontFaces + fontFaces = expandedAllFontFaces, + styleConfigHash = currentConfigHash, + bookReplacementPreferencesJson = ReaderBookReplacementPreferencesJson.encode( + bookReplacementPreferences.scopedToFile(bookReplacementFileId), + ), + bookReplacementFileId = bookReplacementFileId.orEmpty() ) + if (isDisposed()) return BookProcessingWorker.enqueue( context = context, bookId = bookId, @@ -802,10 +908,14 @@ class BookPaginator( adaptThemeColors = false ) - bookCacheDao.getProcessedChapter(bookId, chapterIndex)?.let { cachedChapter -> + bookCacheDao.getProcessedChapter(bookId, chapterIndex, currentConfigHash)?.let { cachedChapter -> if (cachedChapter.contentBlocksProto.isNotEmpty()) { try { val semanticBlocks = proto.decodeFromByteArray>(cachedChapter.contentBlocksProto) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "semantic_cache_hit chapter=$chapterIndex configHash=$currentConfigHash " + + semanticBlocks.readerSemanticLinkDiagSummary() + ) val isCacheEmpty = semanticBlocks.isEmpty() val isLazyChapter = chapter.htmlContent.isEmpty() @@ -821,7 +931,12 @@ class BookPaginator( if (!shouldIgnoreCache) { Timber.d("getBlocksForChapter: Cache HIT for chapter $chapterIndex in DATABASE.") - return styler.style(semanticBlocks) + val styledBlocks = styler.style(semanticBlocks) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "content_from_semantic_cache chapter=$chapterIndex configHash=$currentConfigHash " + + styledBlocks.readerContentLinkDiagSummary() + ) + return styledBlocks } } catch (e: Exception) { Timber.e(e, "Failed to deserialize/style chapter $chapterIndex from DB. Reprocessing for this session.") @@ -849,6 +964,10 @@ class BookPaginator( } val document = Jsoup.parse(htmlToParse, chapter.absPath) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "html_parse_input chapter=$chapterIndex htmlChars=${htmlToParse.length} " + + document.readerHtmlLinkDiagSummary() + ) val mathElements = document.select("math") val svgResults = mutableMapOf() @@ -865,6 +984,11 @@ class BookPaginator( element.replaceWith(placeholder) } } + applyBookReplacementsToHtmlDocument( + document = document, + preferences = bookReplacementPreferences, + fileId = bookReplacementFileId, + ) val processedHtml = document.outerHtml() var parsingCssRules = OptimizedCssRules() @@ -887,11 +1011,15 @@ class BookPaginator( mathSvgCache = svgResults, adaptThemeColors = false ) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "semantic_parse_result chapter=$chapterIndex " + + semanticBlocks.readerSemanticLinkDiagSummary() + ) - coroutineScope.launch(Dispatchers.IO) { + if (!isDisposed()) paginatorScope.launch(Dispatchers.IO) { try { val protoBytes = proto.encodeToByteArray(semanticBlocks) - val newCacheEntry = ProcessedChapter(bookId, chapterIndex, protoBytes, chapterPageCounts[chapterIndex] ?: 0) + val newCacheEntry = ProcessedChapter(bookId, chapterIndex, protoBytes, chapterPageCounts[chapterIndex] ?: 0, currentConfigHash) bookCacheDao.insertProcessedChapters(listOf(newCacheEntry)) Timber.i("Successfully cached SEMANTIC content for chapter $chapterIndex.") } catch (e: Exception) { @@ -899,15 +1027,27 @@ class BookPaginator( } } - return styler.style(semanticBlocks) + val styledBlocks = styler.style(semanticBlocks) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "content_parse_result chapter=$chapterIndex " + + styledBlocks.readerContentLinkDiagSummary() + ) + return styledBlocks } - private fun startPaginationWorker(): Job = coroutineScope.launch(Dispatchers.IO) { + internal suspend fun getFlowBlocksForChapter(chapterIndex: Int): List? = withContext(Dispatchers.IO) { + coroutineContext.ensureActive() + if (isDisposed()) return@withContext null + val chapter = chapters.getOrNull(chapterIndex) ?: return@withContext null + getCachedBlocksForChapter(chapter, chapterIndex) + } + + private fun startPaginationWorker(): Job = paginatorScope.launch(Dispatchers.IO) { Timber.i("Pagination worker started.") while (isActive) { var request: PaginationRequest? = null try { - request = paginationQueue.take() + request = paginationQueue.poll(250, TimeUnit.MILLISECONDS) ?: continue val chapterIndex = request.chapterIndex Timber.d("Worker: Took chapter $chapterIndex from queue with priority ${request.priority}.") @@ -933,6 +1073,9 @@ class BookPaginator( } else { Timber.e("Worker: Pagination for chapter $chapterIndex resulted in null.") } + } catch (e: CancellationException) { + Timber.i("Pagination worker cancelled. Shutting down.") + throw e } catch (_: InterruptedException) { Timber.i("Pagination worker interrupted. Shutting down.") Thread.currentThread().interrupt() @@ -968,7 +1111,7 @@ class BookPaginator( "page_count_noop chapter=$chapterIndex count=$actualPageCount currentUserChapter=${currentUserChapterIndex.value}" ) if (!pageCountsAreAccurate && finalizedChapterCounts.add(chapterIndex)) { - coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() } + paginatorScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() } } return } @@ -1000,7 +1143,7 @@ class BookPaginator( if (!pageCountsAreAccurate) { if (finalizedChapterCounts.add(chapterIndex)) { - coroutineScope.launch(Dispatchers.IO) { + paginatorScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() } } @@ -1041,6 +1184,7 @@ class BookPaginator( } override fun getPageContent(pageIndex: Int): Page? { + if (isDisposed()) return null Timber.v("getPageContent requested for pageIndex $pageIndex") val chapterIndex = findChapterIndexForPage(pageIndex) if (chapterIndex == null) { @@ -1127,8 +1271,13 @@ class BookPaginator( } private suspend fun paginateChapter(chapterIndex: Int): List? { + coroutineContext.ensureActive() + if (isDisposed()) return null pageCache[chapterIndex]?.let { Timber.d("paginateChapter: L1 Cache HIT for chapter $chapterIndex in MEMORY, returning cached pages.") + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_memory_cache_hit chapter=$chapterIndex " + it.readerPagesLinkDiagSummary() + ) return it } @@ -1142,12 +1291,9 @@ class BookPaginator( return it } - val blocks = blockCache[chapterIndex] ?: run { - Timber.d("paginateChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.") - val blocksFromDb = getBlocksForChapter(chapter, chapterIndex) - blockCache.put(chapterIndex, blocksFromDb) // Store in L2 cache - blocksFromDb - } + val blocks = getCachedBlocksForChapter(chapter, chapterIndex) + coroutineContext.ensureActive() + if (isDisposed()) return null Timber.d("paginateChapter: Chapter $chapterIndex retrieved/parsed into ${blocks.size} content blocks.") @@ -1165,7 +1311,12 @@ class BookPaginator( measurementProvider = measurementProvider, density = density ) + coroutineContext.ensureActive() + if (isDisposed()) return null Timber.d("paginateChapter: PaginatorLogic returned ${pages.size} pages for chapter $chapterIndex.") + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "pagination_result chapter=$chapterIndex " + pages.readerPagesLinkDiagSummary() + ) applyPageRuntimeIndexes(chapterIndex, pages) savePageCacheAsync(chapter, chapterIndex, pages) @@ -1177,6 +1328,7 @@ class BookPaginator( } private fun triggerPagination(chapterIndex: Int, priority: Int) { + if (isDisposed()) return if (chapterIndex !in chapters.indices) { Timber.w("Trigger: Ignoring invalid chapter index $chapterIndex. Chapter count: ${chapters.size}.") return @@ -1209,6 +1361,7 @@ class BookPaginator( } private fun prefetchChapters(currentChapterIndex: Int) { + if (isDisposed()) return Timber.v("Prefetching chapters around index $currentChapterIndex.") for (offset in 1..2) { val nextChapterIndex = currentChapterIndex + offset @@ -1307,12 +1460,32 @@ class BookPaginator( finalPage } + suspend fun findStableLocatorForAnchor(chapterIndex: Int, anchor: String?): Locator? = withContext(Dispatchers.IO) { + if (anchor.isNullOrBlank()) return@withContext Locator(chapterIndex, 0, 0) + + val requestedChapter = chapters.getOrNull(chapterIndex) ?: return@withContext null + val requestedBlocks = getCachedBlocksForChapter(requestedChapter, chapterIndex) + findLocatorForAnchorInBlocks(chapterIndex, anchor, requestedBlocks)?.let { locator -> + return@withContext locator + } + + val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor) + val targetChapter = indexEntry?.chapterIndex ?: chapterIndex + val chapter = chapters.getOrNull(targetChapter) ?: return@withContext null + val blocks = getCachedBlocksForChapter(chapter, targetChapter) + + findLocatorForAnchorInBlocks(targetChapter, anchor, blocks) + ?: indexEntry?.let { Locator(it.chapterIndex, it.blockIndex, 0) } + } + override fun findPageForAnchor( chapterIndex: Int, anchor: String?, onResult: (pageIndex: Int) -> Unit ) { - coroutineScope.launch(Dispatchers.IO) { + if (isDisposed()) return + paginatorScope.launch(Dispatchers.IO) { + if (isDisposed()) return@launch val page = findStablePageForAnchor(chapterIndex, anchor) ?: return@launch withContext(Dispatchers.Main) { onResult(page) } } @@ -1365,7 +1538,9 @@ class BookPaginator( href: String, onNavigationComplete: (pageIndex: Int) -> Unit ) { - coroutineScope.launch(Dispatchers.IO) { + if (isDisposed()) return + paginatorScope.launch(Dispatchers.IO) { + if (isDisposed()) return@launch val targetPage = findStablePageForHref(currentChapterAbsPath, href) ?: return@launch withContext(Dispatchers.Main) { onNavigationComplete(targetPage) } } @@ -1389,10 +1564,33 @@ class BookPaginator( findStablePageForAnchor(targetChapterIndex, anchor) } + suspend fun findStableLocatorForHref(currentChapterAbsPath: String, href: String): Locator? = withContext(Dispatchers.IO) { + val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href) + if (targetChapterPath == null) { + Timber.w("Could not resolve href '$href' to a valid chapter path.") + return@withContext null + } + + val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath } + if (targetChapterIndex == -1) { + Timber.w("Could not find chapter for path: $targetChapterPath") + return@withContext null + } + + findStableLocatorForAnchor(targetChapterIndex, anchor) + } + suspend fun findStablePageForSearchResult(result: SearchResult): Int? = withContext(Dispatchers.IO) { val targetChapterIndex = result.locationInSource Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex") + findStableLocatorForSearchResult(result)?.let { locator -> + findStablePageForLocator(locator)?.let { page -> + Timber.i("Found exact search result locator $locator on absolute page $page") + return@withContext page + } + } + val chapterPages = ensureChapterPaginated(targetChapterIndex) val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex) @@ -1435,8 +1633,16 @@ class BookPaginator( finalPageIndex } + suspend fun findStableLocatorForSearchResult(result: SearchResult): Locator? = withContext(Dispatchers.IO) { + val chapter = chapters.getOrNull(result.locationInSource) ?: return@withContext null + val blocks = getCachedBlocksForChapter(chapter, result.locationInSource) + findLocatorForSearchResultInBlocks(result, blocks) + } + override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) { - coroutineScope.launch(Dispatchers.IO) { + if (isDisposed()) return + paginatorScope.launch(Dispatchers.IO) { + if (isDisposed()) return@launch val page = findStablePageForSearchResult(result) ?: return@launch withContext(Dispatchers.Main) { onResult(page) } } @@ -1617,7 +1823,9 @@ class BookPaginator( } override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) { - coroutineScope.launch(Dispatchers.IO) { + if (isDisposed()) return + paginatorScope.launch(Dispatchers.IO) { + if (isDisposed()) return@launch Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'") val chapterPages = ensureChapterPaginated(chapterIndex) diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/ContentStyler.kt similarity index 90% rename from app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/ContentStyler.kt index 6006836..f363596 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/ContentStyler.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import android.os.Build import timber.log.Timber @@ -122,7 +122,11 @@ class ContentStyler( return when (block) { is SemanticParagraph -> { - val computedTextAlign = userTextAlign ?: themedStyle.paragraphStyle.textAlign + val computedTextAlign = when { + userTextAlign != null -> userTextAlign + themedStyle.paragraphStyle.textAlign == TextAlign.Justify -> TextAlign.Left + else -> themedStyle.paragraphStyle.textAlign + } ParagraphBlock( content = buildAnnotatedString(block, themedStyle), @@ -411,7 +415,10 @@ class ContentStyler( withStyle(finalParagraphStyle) { withStyle(initialSpanStyle) { append(block.text) + val linkSpans = mutableListOf() block.spans.sortedBy { it.start }.forEach { span -> + val spanStart = span.start.coerceIn(0, block.text.length) + val spanEnd = span.end.coerceIn(spanStart, block.text.length) val themedSpanStyle = applyThemeToStyle(span.style) val spanFontFamily = findFirstAvailableFontFamily(themedSpanStyle.fontFamilies, fontFamilyMap) val effectiveSpanFontFamily = if (spanFontFamily == FontFamily.Monospace) { @@ -434,6 +441,7 @@ class ContentStyler( ) if (!span.linkHref.isNullOrBlank()) { + linkSpans.add(span) finalSpanStyle = finalSpanStyle.withReaderLinkStyle( isDarkTheme = isDarkTheme, themeBackgroundColor = themeBackgroundColor, @@ -459,31 +467,58 @@ class ContentStyler( val offsetStr = if (themedSpanStyle.textUnderlineOffset.isSpecified) themedSpanStyle.textUnderlineOffset.value.toString() else "0" val annotationData = "$styleStr|$colorStr|$offsetStr" - addStringAnnotation("CustomUnderline", annotationData, span.start, span.end) + if (spanStart < spanEnd) { + addStringAnnotation("CustomUnderline", annotationData, spanStart, spanEnd) + } } - addStyle(initialSpanStyle.merge(finalSpanStyle), span.start, span.end) + if (spanStart < spanEnd) { + addStyle(initialSpanStyle.merge(finalSpanStyle), spanStart, spanEnd) + } val ws = themedSpanStyle.wordSpacing - if (ws.isSpecified && ws.value != 0f) { - val textToStyle = block.text.substring(span.start, span.end) + if (ws.isSpecified && ws.value != 0f && spanStart < spanEnd) { + val textToStyle = block.text.substring(spanStart, spanEnd) for (i in textToStyle.indices) { if (textToStyle[i] == ' ') { - addStyle(SpanStyle(letterSpacing = ws), span.start + i, span.start + i + 1) + addStyle(SpanStyle(letterSpacing = ws), spanStart + i, spanStart + i + 1) } } } - span.linkHref?.let { linkHref -> - addStringAnnotation("URL", linkHref, span.start, span.end) + span.linkHref?.takeIf { it.isNotBlank() }?.let { linkHref -> + if (spanStart < spanEnd) { + addStringAnnotation("URL", linkHref, spanStart, spanEnd) + } } span.elementId?.let { elementId -> - addStringAnnotation("ID", elementId, span.start, span.end) + addStringAnnotation("ID", elementId, spanStart, spanEnd) + } + } + + val forcedLinkStyle = readerLinkSpanStyle( + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor + ) + linkSpans.forEach { span -> + val start = span.start.coerceIn(0, block.text.length) + val end = span.end.coerceIn(start, block.text.length) + if (start < end) { + addStyle(forcedLinkStyle, start, end) } } } } } + if (block.spans.any { !it.linkHref.isNullOrBlank() }) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "style_text_block type=${block::class.simpleName ?: "Text"} " + + "block=${block.blockIndex} cfi=${block.cfi} " + + "rawLinkSpans=${block.spans.count { !it.linkHref.isNullOrBlank() }} " + + builtString.readerAnnotatedLinkDiagSummary() + ) + } return builtString.maybeAdjustLineHeightForEmphasis() } @@ -562,7 +597,10 @@ class ContentStyler( fontFamilyMap: Map ): FontFamily? { if (fontFamilyNames.isEmpty()) return null - val specificFont = fontFamilyNames.firstNotNullOfOrNull { fontFamilyMap[it] } + val normalizedMap = fontFamilyMap.entries.associate { it.key.trim().lowercase() to it.value } + val specificFont = fontFamilyNames.firstNotNullOfOrNull { name -> + normalizedMap[name.trim().removeSurrounding("\"").removeSurrounding("'").lowercase()] + } if (specificFont != null) return specificFont return fontFamilyNames.firstNotNullOfOrNull { name -> FontFamilyMapper.nameToFontFamily(name) } } diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/EpubFontFaceSiblings.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/EpubFontFaceSiblings.kt new file mode 100644 index 0000000..628f7f6 --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/EpubFontFaceSiblings.kt @@ -0,0 +1,132 @@ +package org.dueattendant149.bookreader.paginatedreader + +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import org.dueattendant149.bookreader.ReaderFontDiagnosticsTag +import org.dueattendant149.bookreader.readerFontDiagnosticSummary +import org.dueattendant149.bookreader.shared.detectFontVariant +import org.dueattendant149.bookreader.shared.familyFilenameSignature +import org.dueattendant149.bookreader.shared.fontWeightCssDescriptor +import timber.log.Timber +import java.io.File + +private val supportedEpubFontExtensions = setOf("ttf", "otf", "woff", "woff2") + +fun expandFontFacesWithSiblings( + fontFaces: List, + extractionPath: String +): List { + if (fontFaces.isEmpty()) return emptyList() + + Timber.tag(ReaderFontDiagnosticsTag).i( + "epub.siblings.start inputCount=${fontFaces.size} extractionPath='$extractionPath'" + ) + + val result = fontFaces.toMutableList() + val existingKeys = result.mapTo(mutableSetOf()) { it.variantKey() } + val extractionRoot = File(extractionPath) + + fontFaces.forEach { fontFace -> + val sourceFile = fontFace.resolvedFile(extractionRoot).takeIf { it.isFile } ?: return@forEach + val sourceSignature = sourceFile.familyFilenameSignature() + if (sourceSignature.isBlank()) return@forEach + val parent = sourceFile.parentFile ?: return@forEach + + Timber.tag(ReaderFontDiagnosticsTag).i( + "epub.siblings.source family='${fontFace.fontFamily}' src='${fontFace.src}' " + + "file='${sourceFile.name}' " + + readerFontDiagnosticSummary(sourceFile.nameWithoutExtension) + ) + + parent.listFiles() + ?.asSequence() + ?.filter { candidate -> + candidate.isFile && + candidate.extension.lowercase() in supportedEpubFontExtensions && + candidate.nameWithoutExtension.familyFilenameSignature() == sourceSignature + } + ?.forEach { candidate -> + val variant = candidate.nameWithoutExtension.detectFontVariant() + if (variant == null) { + Timber.tag(ReaderFontDiagnosticsTag).w( + "epub.siblings.skipNoVariant file='${candidate.name}' " + + readerFontDiagnosticSummary(candidate.nameWithoutExtension) + ) + return@forEach + } + val src = candidate.toFontFaceSrc(extractionRoot) + val inferred = fontFace.copy( + src = src, + fontWeight = variant.weight, + fontStyle = variant.style + ) + if (existingKeys.add(inferred.variantKey())) { + Timber.tag(ReaderFontDiagnosticsTag).i( + "epub.siblings.add family='${fontFace.fontFamily}' src='$src' variant=$variant" + ) + result += inferred + } else { + Timber.tag(ReaderFontDiagnosticsTag).i( + "epub.siblings.skipDuplicate family='${fontFace.fontFamily}' src='$src' variant=$variant" + ) + } + } + } + + Timber.tag(ReaderFontDiagnosticsTag).i("epub.siblings.done outputCount=${result.size}") + return result +} + +fun buildEpubFontFaceCss( + fontFaces: List, + extractionPath: String +): String { + val extractionRoot = File(extractionPath) + return expandFontFacesWithSiblings(fontFaces, extractionPath) + .distinctBy { it.variantKey() } + .mapNotNull { fontFace -> + val file = fontFace.resolvedFile(extractionRoot).takeIf { it.isFile } ?: return@mapNotNull null + val family = fontFace.fontFamily.cssString() + val url = file.toURI().toString().cssUrlString() + val weight = file.nameWithoutExtension.fontWeightCssDescriptor(fontFace.fontWeight ?: FontWeight.Normal) + val style = if (fontFace.fontStyle == FontStyle.Italic) "italic" else "normal" + Timber.tag(ReaderFontDiagnosticsTag).i( + "epub.css.face family='$family' file='${file.name}' fontWeight='$weight' fontStyle='$style' " + + readerFontDiagnosticSummary(file.nameWithoutExtension) + ) + "@font-face { font-family: '$family'; src: url('$url'); font-weight: $weight; font-style: $style; }" + } + .joinToString(separator = " ") +} + +private fun FontFaceInfo.resolvedFile(extractionRoot: File): File { + val source = File(src) + return if (source.isAbsolute) source else File(extractionRoot, src) +} + +private fun FontFaceInfo.variantKey(): String { + return listOf( + fontFamily.trim().lowercase(), + src.replace('\\', '/').lowercase(), + fontWeight?.weight ?: FontWeight.Normal.weight, + fontStyle ?: FontStyle.Normal + ).joinToString(separator = "|") +} + +private fun File.toFontFaceSrc(extractionRoot: File): String { + val relative = runCatching { + extractionRoot.toPath().relativize(toPath()).toString() + }.getOrNull() + return relative + ?.takeIf { !it.startsWith("..") && it.isNotBlank() } + ?.replace(File.separatorChar, '/') + ?: absolutePath +} + +private fun File.familyFilenameSignature(): String { + return nameWithoutExtension.familyFilenameSignature() +} + +private fun String.cssString(): String = replace("\\", "\\\\").replace("'", "\\'") + +private fun String.cssUrlString(): String = replace("\\", "\\\\").replace("'", "%27") diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/FontLoader.kt similarity index 51% rename from app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/FontLoader.kt index ae1f204..31e4b0a 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/FontLoader.kt @@ -17,13 +17,16 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import timber.log.Timber import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import org.dueattendant149.bookreader.ReaderFontDiagnosticsTag +import org.dueattendant149.bookreader.readerFontDiagnosticSummary +import org.dueattendant149.bookreader.shared.supportsVariableWeightAxis import java.io.File import java.security.MessageDigest @@ -41,7 +44,11 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map if (fontFaces.isEmpty()) { return emptyMap() } - Timber.d("Loading ${fontFaces.size} font faces from extraction path: $extractionPath") + val expandedFontFaces = expandFontFacesWithSiblings(fontFaces, extractionPath) + Timber.tag(ReaderFontDiagnosticsTag).i( + "native.load.start inputCount=${fontFaces.size} expandedCount=${expandedFontFaces.size} extractionPath='$extractionPath'" + ) + Timber.d("Loading ${expandedFontFaces.size} font faces from extraction path: $extractionPath") // 1. Define a stable, global font cache directory. // This assumes the parent of the extraction path is a stable base directory for epubs. @@ -55,21 +62,39 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map // e.g., "d0e205bf-65cc-4ab4-93cc-cd2d613a7bb3.epub" from a longer temp path. val bookId = File(extractionPath).name.substringBeforeLast("_") - val fontsByFamily = fontFaces.groupBy { + val fontsByFamily = expandedFontFaces.groupBy { it.fontFamily.trim().removeSurrounding("'").removeSurrounding("\"").lowercase() } + Timber.tag(ReaderFontDiagnosticsTag).i( + "native.load.grouped families=${ + fontsByFamily.mapValues { (_, infos) -> + infos.joinToString { "${it.src}:${it.fontWeight}:${it.fontStyle}" } + } + }" + ) Timber.d("Grouped font faces by normalized family: ${fontsByFamily.keys}") return fontsByFamily.mapValues { (familyName, fontInfos) -> - val fontList = fontInfos.mapNotNull { fontInfo -> + val seenVariants = mutableSetOf() + val fontList = fontInfos.flatMap { fontInfo -> try { Timber.d("Attempting to load font '$familyName' from resolved src path: '${fontInfo.src}'") - var fontFile = File(extractionPath, fontInfo.src) + var fontFile = File(fontInfo.src).let { source -> + if (source.isAbsolute) source else File(extractionPath, fontInfo.src) + } if (!fontFile.exists()) { + Timber.tag(ReaderFontDiagnosticsTag).w( + "native.load.missing family='$familyName' src='${fontInfo.src}' resolved='${fontFile.absolutePath}'" + ) Timber.w("Font file not found at: ${fontFile.absolutePath}") - return@mapNotNull null + return@flatMap emptyList() } + val sourceName = fontFile.nameWithoutExtension + Timber.tag(ReaderFontDiagnosticsTag).i( + "native.load.candidate family='$familyName' src='${fontInfo.src}' file='${fontFile.name}' " + + readerFontDiagnosticSummary(sourceName) + ) // Handle WOFF2 conversion and global caching if (fontFile.extension.equals("woff2", ignoreCase = true)) { @@ -80,9 +105,15 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map if (cachedTtfFile.exists()) { // Use the globally cached TTF file if it exists fontFile = cachedTtfFile + Timber.tag(ReaderFontDiagnosticsTag).i( + "native.load.woff2CacheHit src='${fontInfo.src}' cached='${cachedTtfFile.absolutePath}'" + ) Timber.d("Using globally cached TTF for '${fontInfo.src}'") } else { // Convert and save the TTF to the global cache if it doesn't exist + Timber.tag(ReaderFontDiagnosticsTag).i( + "native.load.woff2Convert src='${fontInfo.src}' source='${fontFile.absolutePath}'" + ) Timber.d("Converting woff2 font: ${fontFile.name}") val woff2Data = fontFile.readBytes() val ttfData = Woff2Converter.convertWoff2ToTtf(woff2Data) @@ -90,31 +121,69 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map if (ttfData != null) { cachedTtfFile.writeBytes(ttfData) fontFile = cachedTtfFile // Use the newly created TTF file + Timber.tag(ReaderFontDiagnosticsTag).i( + "native.load.woff2Converted src='${fontInfo.src}' cached='${cachedTtfFile.absolutePath}' bytes=${cachedTtfFile.length()}" + ) Timber.d("Successfully converted and globally cached woff2 as '${cachedTtfFile.name}'") } else { + Timber.tag(ReaderFontDiagnosticsTag).e( + "native.load.woff2ConvertFailed src='${fontInfo.src}' source='${fontFile.absolutePath}'" + ) Timber.e("Failed to convert woff2 font: ${fontFile.name}") - return@mapNotNull null + return@flatMap emptyList() } } } - Font( - fontFile, - fontInfo.fontWeight ?: FontWeight.Normal, - fontInfo.fontStyle ?: FontStyle.Normal + val weights = if (sourceName.supportsVariableWeightAxis()) { + variableEpubFontWeights + } else { + listOf(fontInfo.fontWeight ?: FontWeight.Normal) + } + Timber.tag(ReaderFontDiagnosticsTag).i( + "native.load.registerPlan family='$familyName' file='${fontFile.name}' " + + "style=${fontInfo.fontStyle ?: FontStyle.Normal} weights=${weights.joinToString { it.weight.toString() }}" ) + weights.mapNotNull { weight -> + val style = fontInfo.fontStyle ?: FontStyle.Normal + if (seenVariants.add("${weight.weight}|$style")) { + Font(fontFile, weight, style) + } else { + Timber.tag(ReaderFontDiagnosticsTag).i( + "native.load.skipDuplicate family='$familyName' file='${fontFile.name}' weight=${weight.weight} style=$style" + ) + null + } + } } catch (e: Exception) { + Timber.tag(ReaderFontDiagnosticsTag).e(e, "native.load.failed family='$familyName' src='${fontInfo.src}'") Timber.e(e, "Error loading font: ${fontInfo.src}") - null + emptyList() } } if (fontList.isNotEmpty()) { + Timber.tag(ReaderFontDiagnosticsTag).i( + "native.load.loaded family='$familyName' registeredVariants=${seenVariants.joinToString()}" + ) Timber.d("Loaded family '$familyName' with ${fontList.size} font styles.") FontFamily(fontList) } else { + Timber.tag(ReaderFontDiagnosticsTag).w("native.load.empty family='$familyName'") Timber.w("Could not load any font styles for family '$familyName'.") null } }.filterValues { it != null }.mapValues { it.value!! } } + +private val variableEpubFontWeights = listOf( + FontWeight.Thin, + FontWeight.ExtraLight, + FontWeight.Light, + FontWeight.Normal, + FontWeight.Medium, + FontWeight.SemiBold, + FontWeight.Bold, + FontWeight.ExtraBold, + FontWeight.Black +) diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/IPaginator.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/IPaginator.kt similarity index 93% rename from app/src/main/java/com/aryan/reader/paginatedreader/IPaginator.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/IPaginator.kt index 3b84e67..442a8ed 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/IPaginator.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/IPaginator.kt @@ -17,10 +17,10 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import androidx.compose.runtime.Stable -import com.aryan.reader.SearchResult +import org.dueattendant149.bookreader.SearchResult import kotlinx.coroutines.flow.Flow @Stable @@ -53,4 +53,5 @@ interface IPaginator { fun getCfiForPage(pageIndex: Int): String? fun onUserScrolledTo(pageIndex: Int) fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List): String? -} \ No newline at end of file + fun dispose() = Unit +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/Locator.kt similarity index 69% rename from app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/Locator.kt index a721b46..04385ce 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/Locator.kt @@ -17,17 +17,18 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import android.content.Context import timber.log.Timber import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density -import com.aryan.reader.epub.EpubBook -import com.aryan.reader.epub.contentFilePath -import com.aryan.reader.paginatedreader.data.BookCacheDao -import com.aryan.reader.paginatedreader.data.ProcessedChapter +import org.dueattendant149.bookreader.epub.EpubBook +import org.dueattendant149.bookreader.epub.EpubChapter +import org.dueattendant149.bookreader.epub.contentFilePath +import org.dueattendant149.bookreader.paginatedreader.data.BookCacheDao +import org.dueattendant149.bookreader.paginatedreader.data.ProcessedChapter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi @@ -36,6 +37,9 @@ import kotlinx.serialization.encodeToByteArray import kotlinx.serialization.protobuf.ProtoBuf import java.io.File +private const val MAX_LOCATOR_ON_DEMAND_HTML_BYTES = 2L * 1024L * 1024L +private const val MAX_LOCATOR_ON_DEMAND_HTML_CHARS = 2 * 1024 * 1024 + data class Locator( val chapterIndex: Int, val blockIndex: Int, @@ -66,21 +70,9 @@ class LocatorConverter( try { val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null - val htmlToParse = chapter.htmlContent.ifBlank { - try { - val file = File(book.extractionBasePath, chapter.contentFilePath()) - if (file.exists()) { - val content = file.readText() - content - } else { - "" - } - } catch (_: Exception) { - "" - } - } + val htmlToParse = readChapterHtmlForLocator(book, chapter, chapterIndex) - if (htmlToParse.isBlank()) { + if (htmlToParse.isNullOrBlank()) { return@withContext null } @@ -149,22 +141,77 @@ class LocatorConverter( ) bookCacheDao.insertProcessedChapters(listOf(newCacheEntry)) semanticBlocks + } catch (e: OutOfMemoryError) { + Timber.e(e, "Out of memory while processing locator cache for chapter $chapterIndex") + null } catch (_: Exception) { null } } + private fun readChapterHtmlForLocator( + book: EpubBook, + chapter: EpubChapter, + chapterIndex: Int + ): String? { + chapter.htmlContent.takeIf { it.isNotBlank() }?.let { inlineHtml -> + if (inlineHtml.length > MAX_LOCATOR_ON_DEMAND_HTML_CHARS) { + Timber.w( + "Skipping on-demand locator processing for chapter $chapterIndex: " + + "inline HTML is ${inlineHtml.length} chars" + ) + return null + } + return inlineHtml + } + + return try { + val file = File(book.extractionBasePath, chapter.contentFilePath()) + if (!file.isFile) return null + if (file.length() > MAX_LOCATOR_ON_DEMAND_HTML_BYTES) { + Timber.w( + "Skipping on-demand locator processing for chapter $chapterIndex: " + + "HTML file is ${file.length()} bytes" + ) + return null + } + file.bufferedReader().use { it.readText() } + } catch (_: Exception) { + null + } + } + + private fun decodeCachedBlocks( + processedChapter: ProcessedChapter?, + chapterIndex: Int + ): List? { + if (processedChapter == null || processedChapter.contentBlocksProto.isEmpty()) { + return null + } + return try { + proto.decodeFromByteArray>(processedChapter.contentBlocksProto) + } catch (e: OutOfMemoryError) { + Timber.e(e, "Out of memory while decoding locator cache for chapter $chapterIndex") + null + } catch (_: Exception) { + null + } + } + + private suspend fun getProcessedChapterSafely(bookId: String, chapterIndex: Int): ProcessedChapter? { + return try { + bookCacheDao.getProcessedChapter(bookId = bookId, chapterIndex = chapterIndex) + } catch (e: OutOfMemoryError) { + Timber.e(e, "Out of memory while loading locator cache for chapter $chapterIndex") + null + } + } + suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String, bookId: String? = null): Locator? = withContext(Dispatchers.IO) { Timber.tag("POS_DIAG").d("getLocatorFromCfi: Input CFI='$cfi' for chapterIndex=$chapterIndex") - val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex) + val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex) - var allBlocks: List? = null - - if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) { - allBlocks = try { - proto.decodeFromByteArray>(processedChapter.contentBlocksProto) - } catch (_: Exception) { null } - } + var allBlocks = decodeCachedBlocks(processedChapter, chapterIndex) if (allBlocks.isNullOrEmpty()) { allBlocks = processAndCacheChapter(book, chapterIndex, bookId) @@ -174,17 +221,33 @@ class LocatorConverter( return@withContext null } - val (baseCfiPath, charOffset) = cfi.split(':').let { - it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 0) + val firstCfiPoint = cfi.substringBefore('|') + val cfiOffsetSeparator = firstCfiPoint.lastIndexOf(':') + val baseCfiPath = if (cfiOffsetSeparator > 0) { + firstCfiPoint.substring(0, cfiOffsetSeparator) + } else { + firstCfiPoint + } + val charOffset = if (cfiOffsetSeparator > 0 && cfiOffsetSeparator < firstCfiPoint.lastIndex) { + firstCfiPoint.substring(cfiOffsetSeparator + 1).toIntOrNull() ?: 0 + } else { + 0 } val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath) if (bestMatch != null) { + val absoluteCharOffset = when (bestMatch) { + is SemanticTextBlock -> { + val localOffset = charOffset.coerceIn(0, bestMatch.text.length) + bestMatch.startCharOffsetInSource + localOffset + } + else -> charOffset.coerceAtLeast(0) + } val locator = Locator( chapterIndex = chapterIndex, blockIndex = bestMatch.blockIndex, - charOffset = charOffset + charOffset = absoluteCharOffset ) Timber.tag("POS_DIAG").d("getLocatorFromCfi: Successfully resolved to $locator") locator @@ -238,14 +301,9 @@ class LocatorConverter( } suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int, bookId: String? = null): List? = withContext(Dispatchers.IO) { - val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex) + val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex) - var allBlocks: List? = null - if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) { - allBlocks = try { - proto.decodeFromByteArray>(processedChapter.contentBlocksProto) - } catch (_: Exception) { null } - } + var allBlocks = decodeCachedBlocks(processedChapter, chapterIndex) if (allBlocks.isNullOrEmpty()) { allBlocks = processAndCacheChapter(book, chapterIndex, bookId) @@ -258,7 +316,7 @@ class LocatorConverter( fun traverse(blocks: List) { for (block in blocks) { if (block is SemanticTextBlock && block.cfi != null && block.text.isNotBlank()) { - val subChunks = com.aryan.reader.tts.splitTextIntoChunks(block.text) + val subChunks = org.dueattendant149.bookreader.tts.splitTextIntoChunks(block.text) var currentSearchIndex = 0 for (chunkText in subChunks) { val firstWord = chunkText.trim().substringBefore(' ') @@ -294,14 +352,9 @@ class LocatorConverter( suspend fun getCfiFromLocator(book: EpubBook, locator: Locator, bookId: String? = null): String? = withContext(Dispatchers.IO) { Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator") - val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex) + val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex) - var blocks: List? = null - if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) { - blocks = try { - proto.decodeFromByteArray>(processedChapter.contentBlocksProto) - } catch (_: Exception) { null } - } + var blocks = decodeCachedBlocks(processedChapter, locator.chapterIndex) if (blocks.isNullOrEmpty()) { blocks = processAndCacheChapter(book, locator.chapterIndex, bookId) @@ -313,8 +366,20 @@ class LocatorConverter( val foundBlock = findBlockByBlockIndex(blocks, locator.blockIndex) val resultCfi = foundBlock?.cfi?.let { cfi -> - if (locator.charOffset > 0) { - "$cfi:${locator.charOffset}" + val localOffset = when (foundBlock) { + is SemanticTextBlock -> { + val start = foundBlock.startCharOffsetInSource + val end = start + foundBlock.text.length + if (locator.charOffset in start..end) { + locator.charOffset - start + } else { + locator.charOffset + }.coerceIn(0, foundBlock.text.length) + } + else -> locator.charOffset.coerceAtLeast(0) + } + if (localOffset > 0) { + "$cfi:$localOffset" } else { cfi } @@ -363,14 +428,9 @@ class LocatorConverter( } suspend fun getTextOffset(book: EpubBook, locator: Locator, bookId: String? = null): Int? = withContext(Dispatchers.IO) { - val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex) + val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex) - var allBlocks: List? = null - if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) { - allBlocks = try { - proto.decodeFromByteArray>(processedChapter.contentBlocksProto) - } catch(_: Exception) { null } - } + var allBlocks = decodeCachedBlocks(processedChapter, locator.chapterIndex) if (allBlocks.isNullOrEmpty()) { allBlocks = processAndCacheChapter(book, locator.chapterIndex, bookId) @@ -384,7 +444,19 @@ class LocatorConverter( fun traverse(blocks: List): Boolean { for (block in blocks) { if (block.blockIndex == locator.blockIndex) { - offset += locator.charOffset + val absoluteOffset = when (block) { + is SemanticTextBlock -> { + val start = block.startCharOffsetInSource + val end = start + block.text.length + locator.charOffset.takeIf { (start > 0 || offset == 0) && it in start..end } + } + else -> null + } + if (absoluteOffset != null) { + offset = absoluteOffset + } else { + offset += locator.charOffset + } return true } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/MathMLRenderer.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/MathMLRenderer.kt similarity index 89% rename from app/src/main/java/com/aryan/reader/paginatedreader/MathMLRenderer.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/MathMLRenderer.kt index 09d3c97..afa7ca5 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/MathMLRenderer.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/MathMLRenderer.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import android.annotation.SuppressLint import android.content.Context @@ -29,6 +29,7 @@ import android.webkit.JavascriptInterface import android.webkit.WebChromeClient import android.webkit.WebView import android.webkit.WebViewClient +import org.dueattendant149.bookreader.BuildConfig import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull @@ -182,11 +183,18 @@ class MathMLRenderer(private val context: Context) { val mathMLForJs = job.mathML.replace("`", "\\`") val script = """ (function() { - console.log("MATH_DIAGNOSTIC: Starting MathML to SVG conversion."); + var mathDiagnosticsEnabled = ${BuildConfig.DEBUG}; + function mathLog() { + if (mathDiagnosticsEnabled) console.log.apply(console, arguments); + } + function mathError() { + if (mathDiagnosticsEnabled) console.error.apply(console, arguments); + } + mathLog("MATH_DIAGNOSTIC: Starting MathML to SVG conversion."); const mathMLContent = `${mathMLForJs}`; - console.log("MATH_DIAGNOSTIC: Input MathML: " + mathMLContent); + mathLog("MATH_DIAGNOSTIC: Input MathML: " + mathMLContent); MathJax.mathml2svgPromise(mathMLContent).then(function (node) { - console.log("MATH_DIAGNOSTIC: mathml2svgPromise successful."); + mathLog("MATH_DIAGNOSTIC: mathml2svgPromise successful."); var svgElement = node.querySelector('svg'); if (svgElement) { svgElement.style.fill = 'currentColor'; @@ -194,15 +202,15 @@ class MathMLRenderer(private val context: Context) { var width = svgElement.getAttribute('width'); var height = svgElement.getAttribute('height'); var viewBox = svgElement.getAttribute('viewBox'); - console.log('MATH_SIZE_DIAGNOSTIC: Generated SVG details -> width: ' + width + ', height: ' + height + ', viewBox: ' + viewBox + ', length: ' + svgOutput.length); - console.log("MATH_DIAGNOSTIC: SVG generated: " + svgOutput); + mathLog('MATH_SIZE_DIAGNOSTIC: Generated SVG details -> width: ' + width + ', height: ' + height + ', viewBox: ' + viewBox + ', length: ' + svgOutput.length); + mathLog("MATH_DIAGNOSTIC: SVG generated: " + svgOutput); AndroidBridge.onSvgReady(svgOutput); } else { - console.error("MATH_DIAGNOSTIC: SVG element not found in MathJax output."); + mathError("MATH_DIAGNOSTIC: SVG element not found in MathJax output."); AndroidBridge.onSvgReady(''); } }).catch((err) => { - console.error("MATH_DIAGNOSTIC: MathJax conversion error:", err); + mathError("MATH_DIAGNOSTIC: MathJax conversion error:", err); AndroidBridge.onSvgReady(''); }); })(); diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PageCountEstimator.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/PageCountEstimator.kt similarity index 95% rename from app/src/main/java/com/aryan/reader/paginatedreader/PageCountEstimator.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/PageCountEstimator.kt index d3e0cc1..e5d5803 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PageCountEstimator.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/PageCountEstimator.kt @@ -17,13 +17,13 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.isSpecified -import com.aryan.reader.epub.EpubChapter +import org.dueattendant149.bookreader.epub.EpubChapter import kotlin.math.ceil import kotlin.math.max diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReader.kt similarity index 59% rename from app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReader.kt index 074509f..c1a369e 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReader.kt @@ -1,41 +1,50 @@ // PaginatedReader.kt @file:Suppress("VariableNeverRead") -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader 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.os.Build +import android.util.Log import android.widget.Toast +import org.dueattendant149.bookreader.BuildConfig +import org.dueattendant149.bookreader.copyPlainTextToClipboard import androidx.compose.ui.unit.isSpecified import androidx.annotation.RequiresApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -52,7 +61,6 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -64,6 +72,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed @@ -98,17 +107,20 @@ import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -148,17 +160,21 @@ import coil.ImageLoader import coil.compose.AsyncImage import coil.imageLoader import coil.request.ImageRequest.Builder -import com.aryan.reader.R -import com.aryan.reader.loadReaderTextureBitmap -import com.aryan.reader.countWords -import com.aryan.reader.epub.EpubBook -import com.aryan.reader.epubreader.HighlightColor -import com.aryan.reader.epubreader.PaginatedTextSelectionMenu -import com.aryan.reader.epubreader.PaletteManagerDialog -import com.aryan.reader.epubreader.ReaderTextAlign -import com.aryan.reader.epubreader.TtsHighlightInfo -import com.aryan.reader.epubreader.UserHighlight -import com.aryan.reader.paginatedreader.data.BookCacheDatabase +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.loadReaderTextureBitmap +import org.dueattendant149.bookreader.countWords +import org.dueattendant149.bookreader.epub.EpubBook +import org.dueattendant149.bookreader.epub.plainTextCharacterCount +import org.dueattendant149.bookreader.epubreader.HighlightColor +import org.dueattendant149.bookreader.epubreader.PaginatedTextSelectionMenu +import org.dueattendant149.bookreader.epubreader.PaletteManagerDialog +import org.dueattendant149.bookreader.epubreader.ReaderTextAlign +import org.dueattendant149.bookreader.epubreader.TtsHighlightInfo +import org.dueattendant149.bookreader.epubreader.UserHighlight +import org.dueattendant149.bookreader.paginatedreader.data.BookCacheDatabase +import org.dueattendant149.bookreader.shared.ReaderBookReplacementPreferences +import org.dueattendant149.bookreader.shared.ReaderLocator as SharedReaderLocator +import org.dueattendant149.bookreader.shared.ui.sharedAcceleratedLazyWheelScroll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay @@ -166,6 +182,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi @@ -175,6 +192,8 @@ import timber.log.Timber import java.io.File import java.net.URI import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.Base64 import kotlin.math.abs import kotlin.math.roundToInt import kotlin.math.sqrt @@ -195,18 +214,157 @@ data class PaginatedSelection( val textPerBlock: Map = emptyMap() ) +private fun PaginatedSelection.toSharedHighlightLocator( + chapterIndex: Int?, + cfi: String +): SharedReaderLocator { + val startAbsoluteOffset = startBlockCharOffset + startOffset + val endAbsoluteOffset = endBlockCharOffset + endOffset + val rangeStart = minOf(startAbsoluteOffset, endAbsoluteOffset) + val rangeEnd = maxOf(startAbsoluteOffset, endAbsoluteOffset) + return SharedReaderLocator( + chapterIndex = chapterIndex, + pageIndex = startPageIndex, + startOffset = rangeStart, + endOffset = rangeEnd, + blockIndex = startBlockIndex.takeIf { it >= 0 }, + charOffset = rangeStart, + textQuote = text, + cfi = cfi + ) +} + +data class NativeVerticalLocation( + val locator: Locator?, + val chapterIndex: Int?, + val progressPercent: Float, + val compatPageIndex: Int, + val compatTotalPages: Int, + val firstVisibleItemIndex: Int, + val firstVisibleItemScrollOffset: Int, + val firstVisibleItemSize: Int, + val isAtStart: Boolean, + val isAtEnd: Boolean, + val visibleTextRanges: List = emptyList(), + val chapterPageInfo: NativeVerticalChapterPageInfo? = null +) + +data class NativeVerticalVisibleTextRange( + val chapterIndex: Int, + val blockIndex: Int, + val startCharOffset: Int, + val endCharOffset: Int +) + +fun NativeVerticalLocation.locatorForPersistence(): Locator? { + val visibleRange = visibleTextRanges.firstOrNull() + return if (visibleRange != null) { + Locator( + chapterIndex = visibleRange.chapterIndex, + blockIndex = visibleRange.blockIndex, + charOffset = visibleRange.startCharOffset + ) + } else { + locator + } +} + +internal fun shouldFallbackNativeVerticalInitialScrollToCompatPage( + hasInitialLocator: Boolean, + didLocatorScroll: Boolean +): Boolean = !hasInitialLocator && !didLocatorScroll + +internal fun nativeVerticalCenteredScrollDelta( + targetOffsetInViewport: Float, + viewportHeight: Float +): Float = targetOffsetInViewport - (viewportHeight * 0.5f) + +data class NativeVerticalChapterPageInfo( + val currentPage: Int, + val totalPages: Int +) + private data class SelectionBlockKey( val pageIndex: Int, val blockIndex: Int, val blockCharOffset: Int ) +private data class NativeVerticalViewportSample( + val firstVisiblePageIndex: Int, + val firstVisiblePageScrollOffset: Int, + val firstVisibleItemSize: Int, + val isAtStart: Boolean, + val isAtEnd: Boolean, + val totalPageCount: Int, + val layoutTick: Int, + val initialScrollComplete: Boolean +) + +private data class AndroidEpubPageContentBounds( + val topPx: Int, + val bottomPx: Int, + val widthPx: Int, + val heightPx: Int, + val pageWidthPx: Int, + val pageHeightPx: Int, + val horizontalPaddingPx: Int, + val verticalPaddingPx: Int +) + +private val AndroidEpubPageContentBounds.pageClipBottomPx: Int + get() = bottomPx + verticalPaddingPx + +private data class NativeVerticalFlowChapter( + val chapterIndex: Int, + val title: String?, + val blocks: List, + val isLoaded: Boolean = true, + val estimatedLocationWeight: Int = 0 +) + +private enum class NativeVerticalFlowItemKind { + BLOCK, + CHAPTER_GAP, + EMPTY_CHAPTER, + UNLOADED_CHAPTER +} + +private data class NativeVerticalFlowItem( + val key: String, + val chapterIndex: Int, + val blockOrdinal: Int, + val block: ContentBlock?, + val kind: NativeVerticalFlowItemKind, + val locationWeight: Int +) + private fun buildSelectionBlockKey( pageIndex: Int, blockIndex: Int, blockCharOffset: Int ): String = "${pageIndex}_${blockIndex}_${blockCharOffset}" +internal fun nativeVerticalInitialChapterPrefetchOrder( + chapterCount: Int, + initialChapter: Int, + forwardCount: Int = 2, + backwardCount: Int = 0 +): List { + if (chapterCount <= 0) return emptyList() + val start = initialChapter.coerceIn(0, chapterCount - 1) + return buildList { + for (offset in 1..forwardCount.coerceAtLeast(0)) { + val chapterIndex = start + offset + if (chapterIndex < chapterCount) add(chapterIndex) + } + for (offset in 1..backwardCount.coerceAtLeast(0)) { + val chapterIndex = start - offset + if (chapterIndex >= 0) add(chapterIndex) + } + } +} + private fun parseSelectionBlockKey(key: String): SelectionBlockKey? { val parts = key.split("_") if (parts.size != 3) return null @@ -244,6 +402,14 @@ private fun getTextBlockCharOffset(block: TextContentBlock): Int = when (block) is ListItemBlock -> block.startCharOffsetInSource } +private fun textBlockLayoutKey( + cfi: String, + pageIndex: Int, + block: TextContentBlock +): String = "${cfi}_${block.blockIndex}_${getTextBlockCharOffset(block)}_${block.content.text.length}_$pageIndex" + +private fun legacyTextBlockLayoutKey(cfi: String, pageIndex: Int): String = "${cfi}_$pageIndex" + private fun headerFontScale(level: Int): Float = when (level) { 1 -> 1.5f 2 -> 1.4f @@ -254,9 +420,14 @@ private fun headerFontScale(level: Int): Float = when (level) { } private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f +private const val AndroidEpubCutoffLogTag = "EpistemeEpubCutoff" +private const val AndroidEpubCutoffTolerancePx = 1 +private const val AndroidEpubCutoffEdgeProbePx = 2 private const val TAG_STABLE_PAGE_NAV = "StablePageNav" private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag" +private const val TAG_ANDROID_HIGHLIGHT_RENDER_DIAG = "AndroidHighlightRenderDiag" private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L +private const val DEBUG_PAGE_TURN_DIAG = false private fun highlightDiagSnippet(text: String, maxLength: Int = 80): String { return text @@ -266,6 +437,17 @@ private fun highlightDiagSnippet(text: String, maxLength: Int = 80): String { .take(maxLength) } +private fun UserHighlight.androidHighlightRenderLabel(): String { + val highlightLocator = this.locator + return "highlightId=$id highlightChapter=$chapterIndex " + + "highlightCfi=${highlightDiagSnippet(cfi, 120)} textLen=${text.length} " + + "text='${highlightDiagSnippet(text)}' " + + "locatorChapter=${highlightLocator.chapterIndex} locatorPage=${highlightLocator.pageIndex} " + + "locatorOffsets=${highlightLocator.startOffset}..${highlightLocator.endOffset} " + + "locatorBlock=${highlightLocator.blockIndex} locatorChar=${highlightLocator.charOffset} " + + "locatorCfi=${highlightDiagSnippet(highlightLocator.cfi.orEmpty(), 120)}" +} + private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float { return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier } @@ -334,12 +516,390 @@ private fun isBlockSelectedOnPage( return afterStart && beforeEnd } +private fun isSelectionBlockKeyInsideSelection( + key: SelectionBlockKey, + selection: PaginatedSelection +): Boolean { + if (key.pageIndex < selection.startPageIndex || key.pageIndex > selection.endPageIndex) return false + if (key.pageIndex > selection.startPageIndex && key.pageIndex < selection.endPageIndex) return true + + val afterStart = if (key.pageIndex == selection.startPageIndex) { + compareBlockPositionsOnPage( + key.blockIndex, + key.blockCharOffset, + selection.startBlockIndex, + selection.startBlockCharOffset + ) >= 0 + } else { + true + } + val beforeEnd = if (key.pageIndex == selection.endPageIndex) { + compareBlockPositionsOnPage( + key.blockIndex, + key.blockCharOffset, + selection.endBlockIndex, + selection.endBlockCharOffset + ) <= 0 + } else { + true + } + + return afterStart && beforeEnd +} + +private data class AttachedSelectionBlock( + val pageIndex: Int, + val layout: TextLayoutResult, + val coords: LayoutCoordinates, + val block: TextContentBlock +) + +private fun attachedSelectionBlocks( + blockLayoutMap: Map>, + pageFilter: (Int) -> Boolean = { true } +): List { + return blockLayoutMap.entries + .asSequence() + .mapNotNull { (key, layoutInfo) -> + val pageIndex = key.substringAfterLast("_").toIntOrNull() + ?: return@mapNotNull null + if (!pageFilter(pageIndex)) return@mapNotNull null + val (layout, coords, block) = layoutInfo + if (!coords.isAttached || block.cfi == null) return@mapNotNull null + AttachedSelectionBlock( + pageIndex = pageIndex, + layout = layout, + coords = coords, + block = block + ) + } + .sortedWith( + compareBy { it.pageIndex } + .thenBy { it.block.blockIndex } + .thenBy { getTextBlockCharOffset(it.block) } + ) + .toList() +} + +private fun visibleSelectedBlocks( + blockLayoutMap: Map>, + selection: PaginatedSelection +): List { + return attachedSelectionBlocks(blockLayoutMap) { pageIndex -> + pageIndex in selection.startPageIndex..selection.endPageIndex + }.filter { blockInfo -> + isBlockSelectedOnPage(blockInfo.block, blockInfo.pageIndex, selection) + } +} + +private fun selectionWindowBounds( + selection: PaginatedSelection, + selectedBlocks: List, + extraBottomPaddingPx: Float = 0f +): Rect { + var minLeft = Float.POSITIVE_INFINITY + var minTop = Float.POSITIVE_INFINITY + var maxRight = Float.NEGATIVE_INFINITY + var maxBottom = Float.NEGATIVE_INFINITY + + selectedBlocks.forEach { blockInfo -> + val textLayout = blockInfo.layout + val coords = blockInfo.coords + val block = blockInfo.block + val currentBlockAbs = getTextBlockCharOffset(block) + val isStartBlockPart = + blockInfo.pageIndex == selection.startPageIndex && + block.blockIndex == selection.startBlockIndex && + currentBlockAbs == selection.startBlockCharOffset + val isEndBlockPart = + blockInfo.pageIndex == selection.endPageIndex && + block.blockIndex == selection.endBlockIndex && + currentBlockAbs == selection.endBlockCharOffset + + val blockStartOffset = if (isStartBlockPart) selection.startOffset else 0 + val blockEndOffset = if (isEndBlockPart) selection.endOffset else textLayout.layoutInput.text.length + + val textLen = textLayout.layoutInput.text.length + val safeStart = blockStartOffset.coerceIn(0, textLen) + val safeEnd = blockEndOffset.coerceIn(safeStart, textLen) + if (safeStart >= safeEnd) return@forEach + + try { + val localBounds = textLayout.getPathForRange(safeStart, safeEnd).getBounds() + val topLeftWin = coords.localToWindow(localBounds.topLeft) + val bottomRightWin = coords.localToWindow(localBounds.bottomRight) + minLeft = minOf(minLeft, topLeftWin.x, bottomRightWin.x) + minTop = minOf(minTop, topLeftWin.y, bottomRightWin.y) + maxRight = maxOf(maxRight, topLeftWin.x, bottomRightWin.x) + maxBottom = maxOf(maxBottom, topLeftWin.y, bottomRightWin.y) + } catch (e: Exception) { + Timber.e(e, "Error calculating exact selection bounds") + } + } + + return if (minTop != Float.POSITIVE_INFINITY && maxBottom != Float.NEGATIVE_INFINITY) { + Rect(minLeft, minTop, maxRight, maxBottom + extraBottomPaddingPx) + } else { + Rect( + selection.rect.left, + selection.rect.top, + selection.rect.right, + selection.rect.bottom + extraBottomPaddingPx + ) + } +} + +private fun findSelectionLayout( + blockLayoutMap: Map>, + cfi: String, + pageIndex: Int, + blockCharOffset: Int +): Triple? { + blockLayoutMap[legacyTextBlockLayoutKey(cfi, pageIndex)]?.takeIf { + getTextBlockCharOffset(it.third) == blockCharOffset + }?.let { return it } + + return blockLayoutMap.entries.firstOrNull { (key, layoutInfo) -> + key.substringAfterLast("_").toIntOrNull() == pageIndex && + layoutInfo.third.cfi == cfi && + getTextBlockCharOffset(layoutInfo.third) == blockCharOffset + }?.value +} + +private fun selectionHandleRootPosition( + selection: PaginatedSelection, + isStart: Boolean, + blockLayoutMap: Map>, + rootCoords: LayoutCoordinates? +): Offset { + val handlePageIndex = if (isStart) selection.startPageIndex else selection.endPageIndex + val selCfi = if (isStart) selection.startBaseCfi else selection.endBaseCfi + val selOffset = if (isStart) selection.startOffset else selection.endOffset + val targetBlockAbs = if (isStart) selection.startBlockCharOffset else selection.endBlockCharOffset + val layoutInfo = findSelectionLayout( + blockLayoutMap = blockLayoutMap, + cfi = selCfi, + pageIndex = handlePageIndex, + blockCharOffset = targetBlockAbs + ) + val root = rootCoords + + if (layoutInfo == null || !layoutInfo.second.isAttached || root == null || !root.isAttached) { + return Offset.Unspecified + } + + return try { + val textLayout = layoutInfo.first + val coords = layoutInfo.second + val maxIdx = maxOf(0, textLayout.layoutInput.text.length - 1) + val safeOffset = selOffset.coerceIn(0, textLayout.layoutInput.text.length) + val safeOffsetForLine = safeOffset.coerceIn(0, maxIdx) + val line = textLayout.getLineForOffset(safeOffsetForLine) + val x = textLayout.getHorizontalPosition(safeOffset, usePrimaryDirection = true) + val y = textLayout.getLineBottom(line) + val windowPos = coords.localToWindow(Offset(x, y)) + root.windowToLocal(windowPos) + } catch (_: Exception) { + Offset.Unspecified + } +} + +private fun updatedSelectionForHandleDrag( + selection: PaginatedSelection, + windowPos: Offset, + currentDragHandle: SelectionHandle, + attachedBlocks: List, + blockLayoutMap: Map> +): Pair? { + var activeDragHandle = currentDragHandle + if (attachedBlocks.isEmpty()) return null + + val targetBlockInfo = attachedBlocks.minByOrNull { blockInfo -> + val coords = blockInfo.coords + val rect = Rect(coords.positionInWindow(), coords.size.toSize()) + val dx = maxOf(rect.left - windowPos.x, 0f, windowPos.x - rect.right) + val dy = maxOf(rect.top - windowPos.y, 0f, windowPos.y - rect.bottom) + dx * dx + dy * dy + } ?: return null + + val textLayout = targetBlockInfo.layout + val coords = targetBlockInfo.coords + val block = targetBlockInfo.block + val localPos = coords.windowToLocal(windowPos) + val offset = textLayout.getOffsetForPosition(localPos) + .coerceIn(0, textLayout.layoutInput.text.length) + + val isStartHandle = activeDragHandle == SelectionHandle.START + var newStartIdx = if (isStartHandle) block.blockIndex else selection.startBlockIndex + var newEndIdx = if (isStartHandle) selection.endBlockIndex else block.blockIndex + var newStartOffset = if (isStartHandle) offset else selection.startOffset + var newEndOffset = if (isStartHandle) selection.endOffset else offset + var newStartCfi = if (isStartHandle) block.cfi!! else selection.startBaseCfi + var newEndCfi = if (isStartHandle) selection.endBaseCfi else block.cfi!! + var newStartPageIdx = if (isStartHandle) targetBlockInfo.pageIndex else selection.startPageIndex + var newEndPageIdx = if (isStartHandle) selection.endPageIndex else targetBlockInfo.pageIndex + + val currentBlockAbs = getTextBlockCharOffset(block) + var newStartBlockAbs = if (isStartHandle) currentBlockAbs else selection.startBlockCharOffset + var newEndBlockAbs = if (!isStartHandle) currentBlockAbs else selection.endBlockCharOffset + + val isReversed = when { + newStartPageIdx != newEndPageIdx -> newStartPageIdx > newEndPageIdx + else -> { + val blockCompare = compareBlockPositionsOnPage( + newStartIdx, + newStartBlockAbs, + newEndIdx, + newEndBlockAbs + ) + if (blockCompare != 0) blockCompare > 0 else newStartOffset > newEndOffset + } + } + + if (isReversed) { + newStartPageIdx = newEndPageIdx.also { newEndPageIdx = newStartPageIdx } + newStartIdx = newEndIdx.also { newEndIdx = newStartIdx } + newStartOffset = newEndOffset.also { newEndOffset = newStartOffset } + newStartCfi = newEndCfi.also { newEndCfi = newStartCfi } + newStartBlockAbs = newEndBlockAbs.also { newEndBlockAbs = newStartBlockAbs } + activeDragHandle = if (activeDragHandle == SelectionHandle.START) SelectionHandle.END else SelectionHandle.START + } + + if ( + newStartPageIdx == selection.startPageIndex && + newEndPageIdx == selection.endPageIndex && + newStartIdx == selection.startBlockIndex && + newEndIdx == selection.endBlockIndex && + newStartOffset == selection.startOffset && + newEndOffset == selection.endOffset + ) { + return null + } + + val tentativeSelection = selection.copy( + startBlockIndex = newStartIdx, + endBlockIndex = newEndIdx, + startBaseCfi = newStartCfi, + endBaseCfi = newEndCfi, + startOffset = newStartOffset, + endOffset = newEndOffset, + startPageIndex = newStartPageIdx, + endPageIndex = newEndPageIdx, + startBlockCharOffset = newStartBlockAbs, + endBlockCharOffset = newEndBlockAbs + ) + + val relevantBlocks = attachedBlocks + .filter { isBlockSelectedOnPage(it.block, it.pageIndex, tentativeSelection) } + .sortedWith( + compareBy { it.pageIndex } + .thenBy { it.block.blockIndex } + .thenBy { getTextBlockCharOffset(it.block) } + ) + + val attachedKeys = attachedBlocks.map { blockInfo -> + buildSelectionBlockKey( + pageIndex = blockInfo.pageIndex, + blockIndex = blockInfo.block.blockIndex, + blockCharOffset = getTextBlockCharOffset(blockInfo.block) + ) + }.toSet() + val newTextPerBlock = selection.textPerBlock.toMutableMap() + newTextPerBlock.keys.removeAll { keyStr -> + val key = parseSelectionBlockKey(keyStr) + keyStr in attachedKeys || + (key != null && !isSelectionBlockKeyInsideSelection(key, tentativeSelection)) + } + + for (blockInfo in relevantBlocks) { + val txt = blockInfo.block.content.text + val blockAbs = getTextBlockCharOffset(blockInfo.block) + val isStartBlockPart = + blockInfo.pageIndex == newStartPageIdx && + blockInfo.block.blockIndex == newStartIdx && + blockAbs == newStartBlockAbs + val isEndBlockPart = + blockInfo.pageIndex == newEndPageIdx && + blockInfo.block.blockIndex == newEndIdx && + blockAbs == newEndBlockAbs + + val start = if (isStartBlockPart) newStartOffset else 0 + val end = if (isEndBlockPart) newEndOffset else txt.length + val safeStart = start.coerceIn(0, txt.length) + val safeEnd = end.coerceIn(safeStart, txt.length) + val key = buildSelectionBlockKey( + pageIndex = blockInfo.pageIndex, + blockIndex = blockInfo.block.blockIndex, + blockCharOffset = blockAbs + ) + + if (safeStart < safeEnd) { + newTextPerBlock[key] = txt.substring(safeStart, safeEnd) + } else { + newTextPerBlock.remove(key) + } + } + + val newText = newTextPerBlock.entries + .sortedWith { first, second -> compareSelectionBlockKeys(first.key, second.key) } + .joinToString(" ") { it.value } + .ifEmpty { selection.text } + + val selectionWithText = tentativeSelection.copy( + text = newText, + textPerBlock = newTextPerBlock + ) + + val sLayout = findSelectionLayout(blockLayoutMap, newStartCfi, newStartPageIdx, newStartBlockAbs) + val eLayout = findSelectionLayout(blockLayoutMap, newEndCfi, newEndPageIdx, newEndBlockAbs) + val newRect = if (sLayout != null && eLayout != null && sLayout.second.isAttached && eLayout.second.isAttached) { + val sMaxIdx = maxOf(0, sLayout.first.layoutInput.text.length - 1) + val eMaxIdx = maxOf(0, eLayout.first.layoutInput.text.length - 1) + try { + val sRectLocal = sLayout.first.getBoundingBox(newStartOffset.coerceIn(0, sMaxIdx)) + val sRectWin = Rect( + sLayout.second.localToWindow(sRectLocal.topLeft), + sLayout.second.localToWindow(sRectLocal.bottomRight) + ) + val eRectLocal = eLayout.first.getBoundingBox((newEndOffset - 1).coerceIn(0, eMaxIdx)) + val eRectWin = Rect( + eLayout.second.localToWindow(eRectLocal.topLeft), + eLayout.second.localToWindow(eRectLocal.bottomRight) + ) + Rect( + minOf(sRectWin.left, eRectWin.left), + sRectWin.top, + maxOf(sRectWin.right, eRectWin.right), + eRectWin.bottom + ) + } catch (_: Exception) { + selectionWindowBounds(selectionWithText, relevantBlocks) + } + } else { + selectionWindowBounds(selectionWithText, relevantBlocks) + } + + return selectionWithText.copy(rect = newRect) to activeDragHandle +} + internal fun highlightsForPaginatedPage( pageChapterIndex: Int?, userHighlights: List ): List { - if (pageChapterIndex == null) return emptyList() - return userHighlights.filter { it.chapterIndex == pageChapterIndex } + if (pageChapterIndex == null) { + if (userHighlights.isNotEmpty()) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "page_scope_skip reason=null_page_chapter inputHighlightCount=${userHighlights.size}" + ) + } + return emptyList() + } + val scoped = userHighlights.filter { it.chapterIndex == pageChapterIndex } + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "page_scope pageChapter=$pageChapterIndex inputHighlightCount=${userHighlights.size} " + + "scopedHighlightCount=${scoped.size} scopedIds=${scoped.map { it.id }}" + ) + return scoped } class ReactiveBlockMap( @@ -361,6 +921,604 @@ class ReactiveBlockMap( tick++ delegate.clear() } + + fun pruneDetached() { + val detachedKeys = delegate + .filterValues { (_, coords, _) -> !coords.isAttached } + .keys + .toList() + if (detachedKeys.isEmpty()) return + detachedKeys.forEach { delegate.remove(it) } + tick++ + } +} + +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +private fun estimateNativeVerticalCompatPage( + book: EpubBook, + paginator: BookPaginator, + locator: Locator?, + fallbackPage: Int +): Int { + if (locator == null) return fallbackPage + val chapterStart = paginator.chapterStartPageIndices[locator.chapterIndex] ?: return fallbackPage + val chapterPageCount = paginator.chapterPageCounts[locator.chapterIndex] ?: 1 + if (chapterPageCount <= 1) return chapterStart + + val chapterChars = book.chaptersForPagination + .getOrNull(locator.chapterIndex) + ?.plainTextCharacterCount() + ?.coerceAtLeast(1) + ?: return fallbackPage + val ratio = locator.charOffset.toFloat().coerceAtLeast(0f) / chapterChars.toFloat() + val pageInChapter = (ratio.coerceIn(0f, 1f) * (chapterPageCount - 1)).roundToInt() + return chapterStart + pageInChapter +} + +private fun estimateNativeVerticalProgressPercent( + book: EpubBook, + locator: Locator? +): Float? { + if (locator == null) return null + val totalChars = book.chaptersForPagination + .sumOf { it.plainTextCharacterCount().toLong() } + .takeIf { it > 0L } + ?: return null + val completedChars = book.chaptersForPagination + .take(locator.chapterIndex) + .sumOf { it.plainTextCharacterCount().toLong() } + val chapterChars = book.chaptersForPagination + .getOrNull(locator.chapterIndex) + ?.plainTextCharacterCount() + ?.toLong() + ?: 0L + val chapterOffset = locator.charOffset + .toLong() + .coerceIn(0L, chapterChars.coerceAtLeast(0L)) + return (((completedChars + chapterOffset).toDouble() / totalChars.toDouble()) * 100.0) + .toFloat() + .coerceIn(0f, 100f) +} + +private fun locatorForNativeVerticalFlowBlock(chapterIndex: Int, block: ContentBlock): Locator { + val firstTextBlock = listOf(block) + .extractTextBlocks() + .firstOrNull { it.content.text.isNotBlank() } + ?: listOf(block).extractTextBlocks().firstOrNull() + + return if (firstTextBlock != null) { + Locator( + chapterIndex = chapterIndex, + blockIndex = firstTextBlock.blockIndex, + charOffset = getTextBlockCharOffset(firstTextBlock) + ) + } else { + Locator( + chapterIndex = chapterIndex, + blockIndex = block.blockIndex, + charOffset = 0 + ) + } +} + +private fun findNativeVerticalFlowTextBlockForLocator( + chapters: List, + locator: Locator +): TextContentBlock? { + val blocks = chapters.firstOrNull { it.chapterIndex == locator.chapterIndex }?.blocks + ?: return null + val textBlocks = blocks.extractTextBlocks() + return textBlocks.firstOrNull { block -> + val start = getTextBlockCharOffset(block) + val end = start + block.content.text.length + block.blockIndex == locator.blockIndex && locator.charOffset in start..end + } ?: textBlocks.firstOrNull { it.blockIndex >= locator.blockIndex } + ?: textBlocks.firstOrNull() +} + +private fun nativeVerticalFlowBlockMatchesLocator(block: ContentBlock, locator: Locator): Boolean { + if (block.blockIndex == locator.blockIndex) return true + return when (block) { + is FlexContainerBlock -> block.children.any { nativeVerticalFlowBlockMatchesLocator(it, locator) } + is TableBlock -> block.rows.flatten().any { cell -> + cell.content.any { nativeVerticalFlowBlockMatchesLocator(it, locator) } + } + is WrappingContentBlock -> + nativeVerticalFlowBlockMatchesLocator(block.floatedImage, locator) || + block.paragraphsToWrap.any { nativeVerticalFlowBlockMatchesLocator(it, locator) } + else -> false + } +} + +private fun nativeVerticalFlowItemWeight(block: ContentBlock?): Int { + if (block == null) return 0 + val textLength = listOf(block).extractTextBlocks() + .sumOf { it.content.text.length } + return textLength.coerceAtLeast( + when (block) { + is ImageBlock -> 250 + is MathBlock -> 80 + is SpacerBlock -> 1 + else -> 24 + } + ) +} + +internal fun nativeVerticalCompatPageForProgress(progressPercent: Float, totalPageCount: Int): Int { + if (totalPageCount <= 1) return 0 + return ((progressPercent.coerceIn(0f, 100f) / 100f) * (totalPageCount - 1)) + .roundToInt() + .coerceIn(0, totalPageCount - 1) +} + +internal fun nativeVerticalProgressForCompatPage(pageIndex: Int, totalPageCount: Int): Float { + if (totalPageCount <= 1) return 0f + return (pageIndex.coerceIn(0, totalPageCount - 1).toFloat() / (totalPageCount - 1).toFloat() * 100f) + .coerceIn(0f, 100f) +} + +internal fun nativeVerticalChapterPageInfo( + chapterCharOffset: Int?, + chapterLengthChars: Int, + chapterPageCount: Int?, + compatPageIndex: Int, + chapterStartPageIndex: Int? +): NativeVerticalChapterPageInfo? { + val total = chapterPageCount?.takeIf { it > 0 } ?: return null + val pageIndexInChapter = if (chapterCharOffset != null && chapterLengthChars > 0) { + ((chapterCharOffset.coerceIn(0, chapterLengthChars).toFloat() / chapterLengthChars.toFloat()) * (total - 1)) + .roundToInt() + } else if (chapterStartPageIndex != null) { + compatPageIndex - chapterStartPageIndex + } else { + 0 + }.coerceIn(0, total - 1) + return NativeVerticalChapterPageInfo( + currentPage = pageIndexInChapter + 1, + totalPages = total + ) +} + +internal fun nativeVerticalChapterPageInfoForScroll( + itemChapterIndices: List, + itemWeights: List, + firstVisibleItemIndex: Int, + firstVisibleItemScrollOffset: Int, + firstVisibleItemSize: Int, + chapterPageCount: Int? +): NativeVerticalChapterPageInfo? { + val total = chapterPageCount?.takeIf { it > 0 } ?: return null + if (itemChapterIndices.isEmpty() || itemWeights.isEmpty()) { + return NativeVerticalChapterPageInfo(currentPage = 1, totalPages = total) + } + val safeIndex = firstVisibleItemIndex.coerceIn(0, minOf(itemChapterIndices.lastIndex, itemWeights.lastIndex)) + val chapterIndex = itemChapterIndices[safeIndex] + val chapterItems = itemChapterIndices.indices.filter { index -> + index < itemWeights.size && itemChapterIndices[index] == chapterIndex + } + val totalChapterWeight = chapterItems.sumOf { itemWeights[it].coerceAtLeast(0) } + if (totalChapterWeight <= 0) { + return NativeVerticalChapterPageInfo(currentPage = 1, totalPages = total) + } + val completedWeight = chapterItems + .filter { it < safeIndex } + .sumOf { itemWeights[it].coerceAtLeast(0) } + val currentWeight = itemWeights[safeIndex].coerceAtLeast(0) + val currentFraction = if (firstVisibleItemSize > 0) { + (firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat()) + .coerceIn(0f, 1f) + } else { + 0f + } + val chapterProgress = ((completedWeight + currentWeight * currentFraction) / totalChapterWeight.toFloat()) + .coerceIn(0f, 1f) + val pageIndexInChapter = (chapterProgress * (total - 1)).roundToInt().coerceIn(0, total - 1) + return NativeVerticalChapterPageInfo( + currentPage = pageIndexInChapter + 1, + totalPages = total + ) +} + +internal fun nativeVerticalProgressToItemIndex( + itemWeights: List, + progressPercent: Float +): Int? { + if (itemWeights.isEmpty()) return null + val totalWeight = itemWeights.sumOf { it.coerceAtLeast(0) } + if (totalWeight <= 0) { + return ((progressPercent.coerceIn(0f, 100f) / 100f) * (itemWeights.size - 1)) + .roundToInt() + .coerceIn(0, itemWeights.lastIndex) + } + + val targetWeight = totalWeight * (progressPercent.coerceIn(0f, 100f) / 100f) + var accumulated = 0 + var lastWeightedIndex = 0 + itemWeights.forEachIndexed { index, rawWeight -> + val weight = rawWeight.coerceAtLeast(0) + if (weight <= 0) return@forEachIndexed + lastWeightedIndex = index + val next = accumulated + weight + if (targetWeight <= next || index == itemWeights.lastIndex) { + return index + } + accumulated = next + } + return lastWeightedIndex +} + +private fun buildNativeVerticalFlowItems( + chapters: List +): List { + return chapters.flatMapIndexed { chapterOrdinal, chapter -> + val boundary = if (chapterOrdinal > 0) { + listOf( + NativeVerticalFlowItem( + key = "chapter-${chapter.chapterIndex}-gap", + chapterIndex = chapter.chapterIndex, + blockOrdinal = -2, + block = null, + kind = NativeVerticalFlowItemKind.CHAPTER_GAP, + locationWeight = 0 + ) + ) + } else { + emptyList() + } + if (!chapter.isLoaded) { + boundary + listOf( + NativeVerticalFlowItem( + key = "chapter-${chapter.chapterIndex}-unloaded", + chapterIndex = chapter.chapterIndex, + blockOrdinal = -1, + block = null, + kind = NativeVerticalFlowItemKind.UNLOADED_CHAPTER, + locationWeight = chapter.estimatedLocationWeight.coerceAtLeast(24) + ) + ) + } else if (chapter.blocks.isEmpty()) { + boundary + listOf( + NativeVerticalFlowItem( + key = "chapter-${chapter.chapterIndex}-empty", + chapterIndex = chapter.chapterIndex, + blockOrdinal = -1, + block = null, + kind = NativeVerticalFlowItemKind.EMPTY_CHAPTER, + locationWeight = 0 + ) + ) + } else { + boundary + chapter.blocks.mapIndexed { ordinal, block -> + NativeVerticalFlowItem( + key = "chapter-${chapter.chapterIndex}-block-$ordinal-${block.blockIndex}", + chapterIndex = chapter.chapterIndex, + blockOrdinal = ordinal, + block = block, + kind = NativeVerticalFlowItemKind.BLOCK, + locationWeight = nativeVerticalFlowItemWeight(block) + ) + } + } + } +} + +private fun findNativeVerticalFlowItemIndexForProgress( + items: List, + progressPercent: Float +): Int? { + return nativeVerticalProgressToItemIndex( + itemWeights = items.map { it.locationWeight }, + progressPercent = progressPercent + ) +} + +internal fun estimateNativeVerticalWeightedScrollProgressPercent( + itemWeights: List, + firstVisibleItemIndex: Int, + firstVisibleItemScrollOffset: Int, + firstVisibleItemSize: Int +): Float? { + if (itemWeights.isEmpty()) return null + val totalWeight = itemWeights.sumOf { it }.takeIf { it > 0 } ?: return null + val safeIndex = firstVisibleItemIndex.coerceIn(0, itemWeights.lastIndex) + val completedWeight = itemWeights + .take(safeIndex) + .sum() + val currentItemWeight = itemWeights[safeIndex] + val currentFraction = if (firstVisibleItemSize > 0) { + (firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat()) + .coerceIn(0f, 1f) + } else { + 0f + } + val weightedPosition = completedWeight + (currentItemWeight * currentFraction) + return ((weightedPosition.toDouble() / totalWeight.toDouble()) * 100.0) + .toFloat() + .coerceIn(0f, 100f) +} + +private fun estimateNativeVerticalScrollProgressPercent( + items: List, + firstVisibleItemIndex: Int, + firstVisibleItemScrollOffset: Int, + firstVisibleItemSize: Int +): Float? { + return estimateNativeVerticalWeightedScrollProgressPercent( + itemWeights = items.map { it.locationWeight }, + firstVisibleItemIndex = firstVisibleItemIndex, + firstVisibleItemScrollOffset = firstVisibleItemScrollOffset, + firstVisibleItemSize = firstVisibleItemSize + ) +} + +private fun findNativeVerticalFlowItemIndexForLocator( + items: List, + chapters: List, + locator: Locator +): Int? { + val targetTextBlock = findNativeVerticalFlowTextBlockForLocator(chapters, locator) + if (targetTextBlock != null && targetTextBlock.blockIndex == locator.blockIndex) { + val exactIndex = items.indexOfFirst { item -> + item.chapterIndex == locator.chapterIndex && + item.block?.let { block -> + listOf(block).extractTextBlocks().any { textBlock -> + textBlock.cfi == targetTextBlock.cfi || + ( + textBlock.blockIndex == targetTextBlock.blockIndex && + getTextBlockCharOffset(textBlock) == getTextBlockCharOffset(targetTextBlock) + ) + } + } == true + } + if (exactIndex >= 0) return exactIndex + } + + val matchingContainerIndex = items.indexOfFirst { item -> + item.chapterIndex == locator.chapterIndex && + item.block?.let { nativeVerticalFlowBlockMatchesLocator(it, locator) } == true + } + if (matchingContainerIndex >= 0) return matchingContainerIndex + + val blockIndex = items.indexOfFirst { item -> + item.chapterIndex == locator.chapterIndex && + (item.block?.blockIndex ?: Int.MAX_VALUE) >= locator.blockIndex + } + if (blockIndex >= 0) return blockIndex + + return items.indexOfFirst { it.chapterIndex == locator.chapterIndex } + .takeIf { it >= 0 } +} + +private fun locatorForNativeVerticalFlowItem(item: NativeVerticalFlowItem): Locator? { + return item.block?.let { locatorForNativeVerticalFlowBlock(item.chapterIndex, it) } + ?: Locator(item.chapterIndex, 0, 0) +} + +private fun resolveNativeVerticalScrollDeltaForLocator( + rootWindowBounds: Rect, + chapterLayoutMap: Map, + flowItems: List, + flowItemLayoutMap: Map, + blockLayoutMap: Map>, + chapters: List, + locator: Locator, + allowChapterFallback: Boolean = true +): Float? { + if (rootWindowBounds == Rect.Zero) return null + + val targetTextBlock = findNativeVerticalFlowTextBlockForLocator(chapters, locator) + if (targetTextBlock?.cfi != null && targetTextBlock.blockIndex == locator.blockIndex) { + val layoutInfo = findSelectionLayout( + blockLayoutMap = blockLayoutMap, + cfi = targetTextBlock.cfi!!, + pageIndex = locator.chapterIndex, + blockCharOffset = getTextBlockCharOffset(targetTextBlock) + ) + if (layoutInfo != null) { + val (layout, coords, block) = layoutInfo + if (coords.isAttached && layout.lineCount > 0) { + val relativeOffset = (locator.charOffset - getTextBlockCharOffset(block)) + .coerceIn(0, block.content.text.length) + val lineIndex = runCatching { layout.getLineForOffset(relativeOffset) } + .getOrDefault(0) + .coerceIn(0, layout.lineCount - 1) + val localY = runCatching { layout.getLineTop(lineIndex) } + .getOrDefault(0f) + val targetWindowY = coords.localToWindow(Offset(0f, localY)).y + return targetWindowY - rootWindowBounds.top + } + } + } + + flowItems.firstOrNull { item -> + item.chapterIndex == locator.chapterIndex && + item.block?.let { nativeVerticalFlowBlockMatchesLocator(it, locator) } == true + }?.let { item -> + val coords = flowItemLayoutMap[item.key] + if (coords?.isAttached == true) { + return coords.positionInWindow().y - rootWindowBounds.top + } + } + + if (!allowChapterFallback) return null + + val chapterCoords = chapterLayoutMap[locator.chapterIndex] + if (chapterCoords?.isAttached == true) { + return chapterCoords.positionInWindow().y - rootWindowBounds.top + } + + return null +} + +private fun resolveNativeVerticalFlowVisibleLocator( + rootWindowBounds: Rect, + blockLayoutMap: Map> +): Locator? { + if (rootWindowBounds == Rect.Zero) return null + val viewportTop = rootWindowBounds.top + 8f + val viewportBottom = rootWindowBounds.bottom - 8f + + val visible = blockLayoutMap.entries + .asSequence() + .mapNotNull { (key, layoutInfo) -> + val chapterIndex = key.substringAfterLast("_").toIntOrNull() + ?: return@mapNotNull null + val (layout, coords, block) = layoutInfo + if (!coords.isAttached) return@mapNotNull null + val bounds = Rect(coords.positionInWindow(), coords.size.toSize()) + if (bounds.bottom <= viewportTop || bounds.top >= viewportBottom) { + null + } else { + Triple(chapterIndex, bounds, layoutInfo) + } + } + .sortedBy { it.second.top } + .firstOrNull { it.second.bottom > viewportTop } + ?: return null + + val chapterIndex = visible.first + val bounds = visible.second + val (layout, _, block) = visible.third + val blockStartOffset = getTextBlockCharOffset(block) + if (layout.lineCount <= 0) { + return Locator(chapterIndex, block.blockIndex, blockStartOffset) + } + + val maxLayoutY = (layout.size.height - 1).coerceAtLeast(0).toFloat() + val localY = (viewportTop - bounds.top).coerceIn(0f, maxLayoutY) + val lineIndex = runCatching { layout.getLineForVerticalPosition(localY) } + .getOrDefault(0) + .coerceIn(0, layout.lineCount - 1) + val relativeOffset = runCatching { layout.getLineStart(lineIndex) } + .getOrDefault(0) + .coerceIn(0, block.content.text.length) + + return Locator( + chapterIndex = chapterIndex, + blockIndex = block.blockIndex, + charOffset = blockStartOffset + relativeOffset + ) +} + +private fun resolveNativeVerticalVisibleTextRanges( + rootWindowBounds: Rect, + blockLayoutMap: Map> +): List { + if (rootWindowBounds == Rect.Zero) return emptyList() + val viewportTop = rootWindowBounds.top + 8f + val viewportBottom = rootWindowBounds.bottom - 8f + + return blockLayoutMap.entries + .asSequence() + .mapNotNull { (key, layoutInfo) -> + val chapterIndex = key.substringAfterLast("_").toIntOrNull() + ?: return@mapNotNull null + val (layout, coords, block) = layoutInfo + if (!coords.isAttached) return@mapNotNull null + val bounds = Rect(coords.positionInWindow(), coords.size.toSize()) + if (bounds.bottom <= viewportTop || bounds.top >= viewportBottom) { + null + } else { + val blockStart = getTextBlockCharOffset(block) + val visibleTopInText = (viewportTop - bounds.top).coerceAtLeast(0f) + val visibleBottomInText = (viewportBottom - bounds.top).coerceAtMost(bounds.height) + var firstVisibleOffset: Int? = null + var lastVisibleOffset: Int? = null + + for (lineIndex in 0 until layout.lineCount) { + val lineTop = runCatching { layout.getLineTop(lineIndex) }.getOrDefault(0f) + val lineBottom = runCatching { layout.getLineBottom(lineIndex) }.getOrDefault(lineTop) + if (lineBottom < visibleTopInText || lineTop > visibleBottomInText) continue + + val lineStart = runCatching { layout.getLineStart(lineIndex) }.getOrDefault(0) + .coerceIn(0, block.content.length) + val lineEnd = runCatching { layout.getLineEnd(lineIndex, visibleEnd = true) }.getOrDefault(lineStart) + .coerceIn(lineStart, block.content.length) + firstVisibleOffset = minOf(firstVisibleOffset ?: lineStart, lineStart) + lastVisibleOffset = maxOf(lastVisibleOffset ?: lineEnd, lineEnd) + } + + val start = blockStart + (firstVisibleOffset ?: 0) + val end = blockStart + (lastVisibleOffset ?: block.content.text.length) + bounds.top to NativeVerticalVisibleTextRange( + chapterIndex = chapterIndex, + blockIndex = block.blockIndex, + startCharOffset = start, + endCharOffset = end + ) + } + } + .sortedBy { it.first } + .map { it.second } + .toList() +} + +private fun resolveReaderFootnoteHtml( + book: EpubBook, + currentChapterPath: String, + href: String +): String? { + var isFootnote = href.contains("footnote", ignoreCase = true) || + href.contains("fn", ignoreCase = true) + var footnoteHtml: String? = null + + val decodedHref = try { + URLDecoder.decode(href, "UTF-8") + } catch (_: Exception) { + href + } + val parts = decodedHref.split('#', limit = 2) + val pathPart = parts[0] + val anchor = if (parts.size > 1) parts[1] else null + + if (anchor != null) { + val targetPath = if (pathPart.isBlank()) currentChapterPath else { + try { + URI(currentChapterPath).resolve(pathPart).normalize().path + } catch (_: Exception) { + null + } + } + + if (targetPath != null) { + val targetChapter = book.chaptersForPagination.find { + try { + URI(it.absPath).normalize().path == targetPath + } catch (_: Exception) { + false + } + } + + if (targetChapter != null) { + val targetHtml = targetChapter.htmlContent.ifEmpty { + try { + File(book.extractionBasePath, targetChapter.htmlFilePath).readText() + } catch (_: Exception) { + "" + } + } + if (targetHtml.isNotEmpty()) { + val doc = Jsoup.parse(targetHtml) + val noteEl = doc.getElementById(anchor) + if (noteEl != null) { + val targetType = noteEl.attr("epub:type") + val targetRole = noteEl.attr("role") + val targetClass = noteEl.className() + val targetLooksLikeFootnote = + targetType.contains("footnote", ignoreCase = true) || + targetRole.contains("doc-footnote", ignoreCase = true) || + targetClass.contains("footnote", ignoreCase = true) + if (isFootnote || targetLooksLikeFootnote) { + isFootnote = true + footnoteHtml = noteEl.html() + } + } + } + } + } + } + + return footnoteHtml.takeIf { isFootnote && !it.isNullOrBlank() } } data class PendingCrossPageSelection(val fromPageIndex: Int) @@ -478,6 +1636,319 @@ private fun highlightQueryInText( } } +internal fun AnnotatedString.readerUrlAnnotationAtOffset(offset: Int): String? { + if (length == 0) return null + + val safeOffset = offset.coerceIn(0, length) + getStringAnnotations("URL", safeOffset, safeOffset).firstOrNull()?.let { return it.item } + + if (safeOffset < length) { + getStringAnnotations("URL", safeOffset, safeOffset + 1).firstOrNull()?.let { return it.item } + } + + if (safeOffset > 0) { + getStringAnnotations("URL", safeOffset - 1, safeOffset).firstOrNull()?.let { return it.item } + } + + return null +} + +internal fun String.isReaderExternalHref(): Boolean { + val href = trim() + if (href.startsWith("//")) return true + + val schemeEnd = href.indexOf(':') + if (schemeEnd <= 0) return false + + val scheme = href.substring(0, schemeEnd) + if (!scheme.first().isLetter()) return false + if (!scheme.all { it.isLetterOrDigit() || it == '+' || it == '-' || it == '.' }) return false + + return scheme.lowercase() in setOf("http", "https", "mailto", "tel", "sms", "geo") +} + +private fun String.readerExternalHrefForDisplay(): String { + val href = trim() + return if (href.startsWith("//")) "https:$href" else href +} + +private const val READER_LINK_HIT_SLOP_PX = 2f + +internal fun AnnotatedString.readerUrlAnnotationAtPosition( + layout: TextLayoutResult, + position: Offset, + textStartOffset: Int = 0 +): String? { + if (length == 0 || layout.lineCount == 0) return null + + val localTextLength = layout.layoutInput.text.length + if (localTextLength == 0) return null + + val lineIndex = layout.getLineForVerticalPosition(position.y) + if (lineIndex !in 0 until layout.lineCount) return null + + val lineTop = layout.getLineTop(lineIndex) + val lineBottom = layout.getLineBottom(lineIndex) + if ( + position.y < lineTop - READER_LINK_HIT_SLOP_PX || + position.y > lineBottom + READER_LINK_HIT_SLOP_PX + ) { + return null + } + + val localLineStart = layout.getLineStart(lineIndex) + val localLineEnd = layout.getLineEnd(lineIndex, visibleEnd = true) + if (localLineStart >= localLineEnd) return null + + val globalLineStart = (textStartOffset + localLineStart).coerceIn(0, length) + val globalLineEnd = (textStartOffset + localLineEnd).coerceIn(globalLineStart, length) + if (globalLineStart >= globalLineEnd) return null + + return getStringAnnotations("URL", globalLineStart, globalLineEnd) + .firstOrNull { annotation -> + if (annotation.item.isBlank()) return@firstOrNull false + + val localStart = (annotation.start - textStartOffset).coerceIn(0, localTextLength) + val localEnd = (annotation.end - textStartOffset).coerceIn(0, localTextLength) + val segmentStart = maxOf(localStart, localLineStart) + val segmentEnd = minOf(localEnd, localLineEnd) + layout.readerTextRangeContainsPosition(segmentStart, segmentEnd, position) + } + ?.item +} + +private fun TextLayoutResult.readerTextRangeContainsPosition( + start: Int, + endExclusive: Int, + position: Offset +): Boolean { + val textLength = layoutInput.text.length + val safeStart = start.coerceIn(0, textLength) + val safeEnd = endExclusive.coerceIn(safeStart, textLength) + if (safeStart >= safeEnd) return false + + val lineIndex = getLineForVerticalPosition(position.y) + val startLine = getLineForOffset(safeStart) + val endLine = getLineForOffset((safeEnd - 1).coerceAtLeast(safeStart)) + if (lineIndex !in startLine..endLine) return false + + val lineStart = getLineStart(lineIndex) + val lineEnd = getLineEnd(lineIndex, visibleEnd = true) + val segmentStart = maxOf(safeStart, lineStart) + val segmentEnd = minOf(safeEnd, lineEnd) + if (segmentStart >= segmentEnd) return false + + var left = Float.POSITIVE_INFINITY + var right = Float.NEGATIVE_INFINITY + for (offset in segmentStart until segmentEnd) { + val box = getBoundingBox(offset) + left = minOf(left, box.left, box.right) + right = maxOf(right, box.left, box.right) + } + if (left == Float.POSITIVE_INFINITY || right == Float.NEGATIVE_INFINITY) return false + + return position.x >= left - READER_LINK_HIT_SLOP_PX && + position.x <= right + READER_LINK_HIT_SLOP_PX +} + +private data class ReaderPageLinkHit( + val href: String, + val blockIndex: Int, + val cfi: String? +) + +private fun ReactiveBlockMap.readerLinkAtPagePosition( + pageCoordinates: LayoutCoordinates, + pageIndex: Int, + position: Offset +): ReaderPageLinkHit? { + val windowPosition = pageCoordinates.localToWindow(position) + return entries.firstNotNullOfOrNull { (key, value) -> + if (!key.endsWith("_$pageIndex")) return@firstNotNullOfOrNull null + + val (layout, coordinates, block) = value + if (!coordinates.isAttached) return@firstNotNullOfOrNull null + + val localPosition = coordinates.windowToLocal(windowPosition) + if ( + localPosition.x < 0f || + localPosition.y < 0f || + localPosition.x > layout.size.width.toFloat() || + localPosition.y > layout.size.height.toFloat() + ) { + return@firstNotNullOfOrNull null + } + + layout.layoutInput.text + .readerUrlAnnotationAtPosition(layout, localPosition) + ?.let { href -> + ReaderPageLinkHit( + href = href, + blockIndex = block.blockIndex, + cfi = block.cfi + ) + } + } +} + +private suspend fun AwaitPointerEventScope.awaitReaderLinkTap( + source: String, + urlAtPosition: (Offset) -> String?, + touchSlop: Float, + onLinkClick: (String) -> Unit +) { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + if (down.isConsumed) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).v( + "tap_down_skip_consumed source=$source x=${down.position.x.roundToInt()} y=${down.position.y.roundToInt()}" + ) + return + } + val url = urlAtPosition(down.position) + if (url == null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).v( + "tap_down_miss source=$source x=${down.position.x.roundToInt()} y=${down.position.y.roundToInt()}" + ) + return + } + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "tap_down_hit source=$source x=${down.position.x.roundToInt()} y=${down.position.y.roundToInt()} " + + "href=${url.readerLinkDiagPreview()}" + ) + down.consume() + + var movedOutsideTapSlop = false + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: continue + val dx = change.position.x - down.position.x + val dy = change.position.y - down.position.y + if (sqrt(dx * dx + dy * dy) > touchSlop) { + movedOutsideTapSlop = true + } + + if (!change.pressed) { + if (!movedOutsideTapSlop) { + change.consume() + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "tap_up_open source=$source href=${url.readerLinkDiagPreview()}" + ) + onLinkClick(url) + } else { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "tap_cancel_slop source=$source href=${url.readerLinkDiagPreview()} " + + "dx=${dx.roundToInt()} dy=${dy.roundToInt()} slop=${touchSlop.roundToInt()}" + ) + } + break + } + + if (!movedOutsideTapSlop) { + change.consume() + } + } +} + +private fun AnnotatedString.withReaderLinkDisplayStyle( + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color +): AnnotatedString { + val urls = getStringAnnotations("URL", 0, length) + if (urls.isEmpty()) return this + + val linkStyle = readerLinkSpanStyle( + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor + ) + + return buildAnnotatedString { + append(this@withReaderLinkDisplayStyle) + urls.forEach { range -> + addStyle(linkStyle, range.start, range.end) + } + } +} + +@Composable +private fun LinkAwareText( + text: AnnotatedString, + style: TextStyle, + modifier: Modifier = Modifier, + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color, + onLinkClick: (String) -> Unit, + onGeneralTap: (Offset) -> Unit +) { + var layoutResult by remember { mutableStateOf(null) } + val viewConfiguration = LocalViewConfiguration.current + val latestLayoutResult = rememberUpdatedState(layoutResult) + val latestOnLinkClick = rememberUpdatedState(onLinkClick) + val latestOnGeneralTap = rememberUpdatedState(onGeneralTap) + val displayText = remember(text, isDarkTheme, themeBackgroundColor, themeTextColor, style.color) { + text.withReaderLinkDisplayStyle( + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = style.color.takeIf { it.isSpecified } ?: themeTextColor + ) + } + LaunchedEffect(displayText) { + if (displayText.getStringAnnotations("URL", 0, displayText.length).isNotEmpty()) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "compose_text source=LinkAwareText " + displayText.readerAnnotatedLinkDiagSummary() + ) + } + } + + Text( + text = displayText, + style = style, + modifier = modifier + .pointerInput(displayText, viewConfiguration.touchSlop) { + awaitEachGesture { + awaitReaderLinkTap( + source = "LinkAwareText", + urlAtPosition = { offset -> + latestLayoutResult.value?.let { layout -> + displayText.readerUrlAnnotationAtPosition(layout, offset) + } + }, + touchSlop = viewConfiguration.touchSlop, + onLinkClick = { latestOnLinkClick.value(it) } + ) + } + } + .pointerInput(displayText) { + detectTapGestures( + onTap = { offset -> + val url = latestLayoutResult.value?.let { layout -> + displayText.readerUrlAnnotationAtPosition(layout, offset) + } + if (url != null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "detect_tap_link source=LinkAwareText href=${url.readerLinkDiagPreview()}" + ) + latestOnLinkClick.value(url) + } else { + latestOnGeneralTap.value(offset) + } + } + ) + }, + onTextLayout = { + layoutResult = it + if (displayText.getStringAnnotations("URL", 0, displayText.length).isNotEmpty()) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "layout_text source=LinkAwareText size=${it.size.width}x${it.size.height} " + + "lines=${it.lineCount} " + displayText.readerAnnotatedLinkDiagSummary() + ) + } + } + ) +} + private fun computeImageRenderSizePx( block: ImageBlock, density: Density, @@ -532,6 +2003,46 @@ private fun imageBlockContentAlignment(style: BlockStyle): Alignment { } } +private fun imageContentScale(style: BlockStyle): ContentScale { + return when (style.objectFit) { + "cover" -> ContentScale.Crop + "fill" -> ContentScale.FillBounds + "contain", "scale-down" -> ContentScale.Fit + else -> ContentScale.Fit + } +} + +internal fun nativeVerticalSvgContentFromDataUri(source: String): String? { + if (!source.startsWith("data:image/svg+xml", ignoreCase = true)) return null + val commaIndex = source.indexOf(',') + if (commaIndex < 0) return null + val metadata = source.substring(0, commaIndex) + val payload = source.substring(commaIndex + 1) + return runCatching { + if (metadata.contains(";base64", ignoreCase = true)) { + String(Base64.getDecoder().decode(payload), StandardCharsets.UTF_8) + } else { + URLDecoder.decode(payload.replace("+", "%2B"), "UTF-8") + } + }.getOrNull() +} + +internal fun nativeVerticalImageModelData(source: String): Any { + val trimmed = source.trim() + return when { + trimmed.startsWith(" SvgData(trimmed) + trimmed.startsWith("data:image/svg+xml", ignoreCase = true) -> + nativeVerticalSvgContentFromDataUri(trimmed)?.let { SvgData(it) } ?: trimmed + trimmed.startsWith("file:", ignoreCase = true) || + trimmed.startsWith("content:", ignoreCase = true) || + trimmed.startsWith("android.resource:", ignoreCase = true) || + trimmed.startsWith("http://", ignoreCase = true) || + trimmed.startsWith("https://", ignoreCase = true) -> trimmed.toUri() + trimmed.startsWith("data:", ignoreCase = true) -> trimmed + else -> File(trimmed) + } +} + private fun tableCellImageModifier( block: ImageBlock, density: Density, @@ -578,7 +2089,12 @@ private fun WrappingContentLayout( searchQuery: String, ttsHighlightInfo: TtsHighlightInfo?, searchHighlightColor: Color, - ttsHighlightColor: Color + ttsHighlightColor: Color, + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color, + onLinkClick: (String) -> Unit, + onGeneralTap: (Offset) -> Unit ) { val textMeasurer = rememberTextMeasurer() val fullText = remember(block.paragraphsToWrap, searchQuery, ttsHighlightInfo) { @@ -614,6 +2130,13 @@ private fun WrappingContentLayout( } } } + val displayFullText = remember(fullText, isDarkTheme, themeBackgroundColor, themeTextColor, textStyle.color) { + fullText.withReaderLinkDisplayStyle( + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = textStyle.color.takeIf { it.isSpecified } ?: themeTextColor + ) + } val (paragraphStartOffsets, paragraphEndOffsetMap) = remember(block.paragraphsToWrap) { val starts = mutableSetOf() val endMap = mutableMapOf() @@ -629,22 +2152,87 @@ private fun WrappingContentLayout( starts to endMap } val density = LocalDensity.current + val viewConfiguration = LocalViewConfiguration.current var textLayouts by remember { - mutableStateOf>>(emptyList()) + mutableStateOf>>(emptyList()) } var totalHeight by remember { mutableIntStateOf(0) } + val latestTextLayouts = rememberUpdatedState(textLayouts) + val latestOnLinkClick = rememberUpdatedState(onLinkClick) + val latestOnGeneralTap = rememberUpdatedState(onGeneralTap) Layout(content = { AsyncImage( - model = Builder(LocalContext.current).data(File(block.floatedImage.path)).build(), + model = Builder(LocalContext.current) + .data(nativeVerticalImageModelData(block.floatedImage.path)) + .build(), contentDescription = block.floatedImage.altText, - contentScale = ContentScale.Fit + contentScale = imageContentScale(block.floatedImage.style) ) - }, modifier = modifier.drawBehind { - textLayouts.forEach { (layout, offset) -> - drawText(layout, topLeft = offset) + }, modifier = modifier + .drawBehind { + textLayouts.forEach { (layout, offset, _) -> + drawText(layout, topLeft = offset) + } } - }) { measurables, constraints -> + .pointerInput(displayFullText, viewConfiguration.touchSlop) { + awaitEachGesture { + awaitReaderLinkTap( + source = "WrappingContentLayout:block=${block.blockIndex}", + urlAtPosition = { offset -> + latestTextLayouts.value.firstNotNullOfOrNull { (layout, topLeft, textStartOffset) -> + val localOffset = Offset(offset.x - topLeft.x, offset.y - topLeft.y) + if ( + localOffset.x >= 0f && + localOffset.y >= 0f && + localOffset.x <= layout.size.width.toFloat() && + localOffset.y <= layout.size.height.toFloat() + ) { + displayFullText.readerUrlAnnotationAtPosition( + layout = layout, + position = localOffset, + textStartOffset = textStartOffset + ) + } else { + null + } + } + }, + touchSlop = viewConfiguration.touchSlop, + onLinkClick = { latestOnLinkClick.value(it) } + ) + } + } + .pointerInput(displayFullText) { + detectTapGestures( + onTap = { offset -> + for ((layout, topLeft, textStartOffset) in latestTextLayouts.value) { + val localOffset = Offset(offset.x - topLeft.x, offset.y - topLeft.y) + if ( + localOffset.x >= 0f && + localOffset.y >= 0f && + localOffset.x <= layout.size.width.toFloat() && + localOffset.y <= layout.size.height.toFloat() + ) { + val url = displayFullText.readerUrlAnnotationAtPosition( + layout = layout, + position = localOffset, + textStartOffset = textStartOffset + ) + if (url != null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "detect_tap_link source=WrappingContentLayout:block=${block.blockIndex} " + + "href=${url.readerLinkDiagPreview()}" + ) + latestOnLinkClick.value(url) + return@detectTapGestures + } + } + } + latestOnGeneralTap.value(offset) + } + ) + }) { measurables, constraints -> val (imageRenderWidthPx, imageRenderHeightPx) = run { computeImageRenderSizePx( block = block.floatedImage, @@ -669,9 +2257,9 @@ private fun WrappingContentLayout( var currentY = 0f var textOffset = 0 - val layouts = mutableListOf>() + val layouts = mutableListOf>() - while (textOffset < fullText.length) { + while (textOffset < displayFullText.length) { val isBesideImage = currentY < effectiveImageHeight val floatLeft = block.floatedImage.style.float == "left" @@ -684,7 +2272,7 @@ private fun WrappingContentLayout( if (currentMaxWidth <= 0) break val lineConstraints = constraints.copy(minWidth = 0, maxWidth = currentMaxWidth) - val remainingText = fullText.subSequence(textOffset, fullText.length) + val remainingText = displayFullText.subSequence(textOffset, displayFullText.length) val styleForMeasure = remainingText.spanStyles.firstOrNull { it.item.fontFamily != null }?.item?.fontFamily?.let { @@ -727,7 +2315,7 @@ private fun WrappingContentLayout( ) val xOffset = if (isBesideImage && floatLeft) effectiveImageWidth.toFloat() else 0f - layouts.add(lineLayout to Offset(xOffset, currentY)) + layouts.add(Triple(lineLayout, Offset(xOffset, currentY), textOffset)) currentY += lineLayout.size.height val endOfLineVisibleCharIndex = textOffset + firstLineEndOffset - 1 @@ -745,12 +2333,18 @@ private fun WrappingContentLayout( currentY += gap } textOffset += firstLineEndOffset - while (textOffset < fullText.length && fullText[textOffset].isWhitespace()) { + while (textOffset < displayFullText.length && displayFullText[textOffset].isWhitespace()) { textOffset++ } } textLayouts = layouts totalHeight = maxOf(currentY, effectiveImageHeight.toFloat()).roundToInt() + if (displayFullText.getStringAnnotations("URL", 0, displayFullText.length).isNotEmpty()) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "layout_wrapping block=${block.blockIndex} layouts=${layouts.size} totalHeight=$totalHeight " + + displayFullText.readerAnnotatedLinkDiagSummary() + ) + } layout(constraints.maxWidth, totalHeight) { if (imagePlacable != null) { val imageX = if (block.floatedImage.style.float == "left") 0 @@ -783,6 +2377,8 @@ fun PaginatedReaderScreen( verticalMarginMultiplier: Float, fontFamily: FontFamily, textAlign: ReaderTextAlign, + bookReplacementPreferences: ReaderBookReplacementPreferences = ReaderBookReplacementPreferences(), + bookReplacementFileId: String? = bookId, ttsHighlightInfo: TtsHighlightInfo?, initialChapterIndexInBook: Int?, fallbackLocatorForReconfiguration: Locator? = null, @@ -802,9 +2398,9 @@ fun PaginatedReaderScreen( onStartTtsFromSelection: (String, Int) -> Unit, onNoteRequested: (String?) -> Unit, onFootnoteRequested: (String) -> Unit, - onInternalLinkNavigated: (Int) -> Unit = {}, + onInternalLinkNavigated: (Int, Locator?) -> Unit = { _, _ -> }, userHighlights: List, - onHighlightCreated: (String, String, String) -> Unit, + onHighlightCreated: (String, String, String, SharedReaderLocator) -> Unit, onHighlightDeleted: (String) -> Unit, activeHighlightPalette: List, onUpdatePalette: (Int, HighlightColor) -> Unit, @@ -838,6 +2434,9 @@ fun PaginatedReaderScreen( val latestExternalNavigationAnchor by rememberUpdatedState(explicitNavigationAnchor) val latestExternalNavigationEpoch by rememberUpdatedState(explicitNavigationEpoch) val latestIsExternalNavigationInProgress by rememberUpdatedState(isExternalNavigationInProgress) + val bookReplacementSignature = remember(bookReplacementPreferences, bookReplacementFileId) { + bookReplacementPreferences.signatureForFile(bookReplacementFileId) + } BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) { val textMeasurer = rememberTextMeasurer() @@ -851,6 +2450,9 @@ fun PaginatedReaderScreen( var debouncedVerticalMarginMult by remember { mutableFloatStateOf(verticalMarginMultiplier) } var debouncedFontFamily by remember { mutableStateOf(fontFamily) } var debouncedTextAlign by remember { mutableStateOf(textAlign) } + var debouncedBookReplacementSignature by remember { mutableStateOf(bookReplacementSignature) } + var debouncedBookReplacementPreferences by remember { mutableStateOf(bookReplacementPreferences) } + var debouncedBookReplacementFileId by remember { mutableStateOf(bookReplacementFileId) } var anchorLocatorForReconfig by remember { mutableStateOf(null) } val currentPaginatorRef = remember { mutableStateOf(null) } @@ -905,19 +2507,21 @@ fun PaginatedReaderScreen( layoutTextStyle.copy(color = effectiveText) } - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.currentPage }.collect { page -> - Timber.tag("PageTurnDiag").i("Pager Settled: Now on page $page at ${System.currentTimeMillis()}") + if (DEBUG_PAGE_TURN_DIAG) { + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + Timber.tag("PageTurnDiag").i("Pager Settled: Now on page $page at ${System.currentTimeMillis()}") + } + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.isScrollInProgress }.collect { isScrolling -> + Timber.tag("PageTurnDiag").d("Pager Scroll State: isScrolling=$isScrolling") + } } } - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.isScrollInProgress }.collect { isScrolling -> - Timber.tag("PageTurnDiag").d("Pager Scroll State: isScrolling=$isScrolling") - } - } - - LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, verticalMarginMultiplier, fontFamily, textAlign) { + LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, verticalMarginMultiplier, fontFamily, textAlign, bookReplacementSignature, bookReplacementFileId) { if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || paragraphGapMultiplier != debouncedParagraphGapMult || @@ -925,7 +2529,9 @@ fun PaginatedReaderScreen( horizontalMarginMultiplier != debouncedHorizontalMarginMult || verticalMarginMultiplier != debouncedVerticalMarginMult || fontFamily != debouncedFontFamily || - textAlign != debouncedTextAlign + textAlign != debouncedTextAlign || + bookReplacementSignature != debouncedBookReplacementSignature || + bookReplacementFileId != debouncedBookReplacementFileId ) { Timber.d("Formatting changed. Waiting for debounce.") delay(400L) @@ -948,6 +2554,9 @@ fun PaginatedReaderScreen( debouncedVerticalMarginMult = verticalMarginMultiplier debouncedFontFamily = fontFamily debouncedTextAlign = textAlign + debouncedBookReplacementSignature = bookReplacementSignature + debouncedBookReplacementPreferences = bookReplacementPreferences + debouncedBookReplacementFileId = bookReplacementFileId Timber.d("Debounce complete. Applying new format settings.") } } @@ -1021,7 +2630,7 @@ fun PaginatedReaderScreen( } } - val paginator = remember(book, bookId, textConstraints, layoutTextStyle, userTextAlign, debouncedParagraphGapMult, debouncedImageSizeMult, debouncedVerticalMarginMult) { + val paginator = remember(book, bookId, textConstraints, layoutTextStyle, userTextAlign, debouncedParagraphGapMult, debouncedImageSizeMult, debouncedVerticalMarginMult, debouncedBookReplacementSignature, debouncedBookReplacementFileId) { val userAgentStylesheet = UserAgentStylesheet.default var allRules = OptimizedCssRules() val allFontFaces = mutableListOf() @@ -1087,7 +2696,9 @@ fun PaginatedReaderScreen( userTextAlign = userTextAlign, paragraphGapMultiplier = debouncedParagraphGapMult, imageSizeMultiplier = debouncedImageSizeMult, - verticalMarginMultiplier = debouncedVerticalMarginMult + verticalMarginMultiplier = debouncedVerticalMarginMult, + bookReplacementPreferences = debouncedBookReplacementPreferences, + bookReplacementFileId = debouncedBookReplacementFileId ) } @@ -1096,6 +2707,15 @@ fun PaginatedReaderScreen( currentPaginatorRef.value = paginator } + DisposableEffect(paginator) { + onDispose { + if (currentPaginatorRef.value === paginator) { + currentPaginatorRef.value = null + } + paginator.dispose() + } + } + LaunchedEffect(paginator) { if (anchorLocatorForReconfig != null) { Timber.tag("POS_DIAG").d("Restoration Triggered. Anchor Locator: $anchorLocatorForReconfig") @@ -1278,7 +2898,7 @@ fun PaginatedReaderScreen( val startTime = System.currentTimeMillis() val result = paginator.getPageContent(pageIndex) val duration = System.currentTimeMillis() - startTime - if (duration > 16) { + if (DEBUG_PAGE_TURN_DIAG && duration > 16) { Timber.tag("PageTurnDiag") .w("HEAVY TASK: paginator.getPageContent($pageIndex) took ${duration}ms on Thread ${Thread.currentThread().name}") } @@ -1300,6 +2920,10 @@ fun PaginatedReaderScreen( onInternalLinkNavigated = onInternalLinkNavigated, onLinkClick = { currentChapterPath, href, onNavComplete -> coroutineScope.launch(Dispatchers.IO) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "nav_request currentChapterPath=${currentChapterPath.readerLinkDiagPreview()} " + + "href=${href.readerLinkDiagPreview()}" + ) withContext(Dispatchers.Main) { isNavigatingByLink = true } try { var isFootnote = false @@ -1307,6 +2931,12 @@ fun PaginatedReaderScreen( val sourceChapter = book.chaptersForPagination.find { it.absPath == currentChapterPath } + if (sourceChapter == null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).w( + "nav_source_chapter_miss currentChapterPath=${currentChapterPath.readerLinkDiagPreview()} " + + "href=${href.readerLinkDiagPreview()}" + ) + } if (sourceChapter != null) { val sourceHtml = sourceChapter.htmlContent.ifEmpty { try { @@ -1394,8 +3024,15 @@ fun PaginatedReaderScreen( } if (!footnoteHtml.isNullOrBlank()) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "nav_footnote_open href=${href.readerLinkDiagPreview()} htmlChars=${footnoteHtml?.length ?: 0}" + ) withContext(Dispatchers.Main) { onFootnoteRequested(footnoteHtml) } } else { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "nav_resolve_start currentChapterPath=${currentChapterPath.readerLinkDiagPreview()} " + + "href=${href.readerLinkDiagPreview()}" + ) val targetPage = (paginator as? BookPaginator)?.findStablePageForHref(currentChapterPath, href) withContext(Dispatchers.Main) { if (targetPage != null) { @@ -1406,12 +3043,20 @@ fun PaginatedReaderScreen( Timber.tag(TAG_STABLE_PAGE_NAV).d( "link_resolved href=$href targetPage=$targetPage anchor=$targetAnchor epoch=$navigationEpoch" ) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "nav_resolve_success href=${href.readerLinkDiagPreview()} targetPage=$targetPage " + + "targetAnchor=$targetAnchor" + ) paginator.onUserScrolledTo(targetPage) onNavComplete(targetPage) } else { Timber.tag(TAG_STABLE_PAGE_NAV).w( "link_failed href=$href currentChapterPath=$currentChapterPath" ) + Timber.tag(TAG_PAGINATED_LINK_DIAG).w( + "nav_resolve_failed currentChapterPath=${currentChapterPath.readerLinkDiagPreview()} " + + "href=${href.readerLinkDiagPreview()}" + ) } } } @@ -1467,6 +3112,1462 @@ fun PaginatedReaderScreen( } } +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +@OptIn(ExperimentalSerializationApi::class) +@Composable +fun NativeVerticalReaderScreen( + modifier: Modifier = Modifier, + book: EpubBook, + bookId: String? = null, + isDarkTheme: Boolean, + effectiveBg: Color, + effectiveText: Color, + searchQuery: String, + fontSizeMultiplier: Float, + lineHeightMultiplier: Float, + paragraphGapMultiplier: Float, + imageSizeMultiplier: Float, + horizontalMarginMultiplier: Float, + verticalMarginMultiplier: Float, + fontFamily: FontFamily, + textAlign: ReaderTextAlign, + bookReplacementPreferences: ReaderBookReplacementPreferences = ReaderBookReplacementPreferences(), + bookReplacementFileId: String? = bookId, + ttsHighlightInfo: TtsHighlightInfo?, + initialLocator: Locator? = null, + initialPageIndexInBook: Int = 0, + scrollRequestPage: Int? = null, + scrollRequestLocator: Locator? = null, + scrollRequestLocatorId: Long = 0L, + scrollRequestLocatorKeepVisible: Boolean = false, + scrollRequestProgressPercent: Float? = null, + scrollRequestProgressId: Long = 0L, + scrollDeltaRequest: Float? = null, + scrollDeltaRequestId: Long = 0L, + scrollDeltaRequestAnimated: Boolean = true, + onScrollRequestConsumed: () -> Unit = {}, + onScrollLocatorRequestConsumed: () -> Unit = {}, + onScrollProgressRequestConsumed: () -> Unit = {}, + onScrollDeltaConsumed: () -> Unit = {}, + onPaginatorReady: (IPaginator) -> Unit, + onVisiblePageChanged: (pageIndex: Int, chapterIndex: Int?, locator: Locator?) -> Unit = { _, _, _ -> }, + onProgressChanged: (pageIndex: Int, totalPages: Int, progressPercent: Float) -> Unit = { _, _, _ -> }, + onLocationChanged: (NativeVerticalLocation) -> Unit = {}, + onTap: (Offset?) -> Unit, + isProUser: Boolean, + isOss: Boolean = false, + onShowDictionaryUpsellDialog: () -> Unit, + onWordSelectedForAiDefinition: (String) -> Unit, + onTranslate: (String) -> Unit, + onSearch: (String) -> Unit, + onStartTtsFromSelection: (String, Int, Int?) -> Unit, + onNoteRequested: (String?) -> Unit, + onFootnoteRequested: (String) -> Unit = {}, + onInternalLinkNavigated: (Int, Locator?) -> Unit = { _, _ -> }, + userHighlights: List, + onHighlightCreated: (String, String, String, SharedReaderLocator) -> Unit, + onHighlightDeleted: (String) -> Unit, + activeHighlightPalette: List, + onUpdatePalette: (Int, HighlightColor) -> Unit, + activeTextureId: String? = null, + activeTextureAlpha: Float = 0.55f +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val textureBitmap = remember(activeTextureId) { + loadReaderTextureBitmap(context, activeTextureId) + } + val textureModifier = if (textureBitmap != null) { + Modifier.drawBehind { + val brush = ShaderBrush( + ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated) + ) + drawRect(brush = brush, blendMode = BlendMode.SrcOver, alpha = activeTextureAlpha.coerceIn(0f, 1f)) + } + } else { + Modifier + } + val bookReplacementSignature = remember(bookReplacementPreferences, bookReplacementFileId) { + bookReplacementPreferences.signatureForFile(bookReplacementFileId) + } + var rootWindowBounds by remember { mutableStateOf(Rect.Zero) } + var rootCoords by remember { mutableStateOf(null) } + val hapticFeedback = LocalHapticFeedback.current + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(effectiveBg) + .then(textureModifier) + .onGloballyPositioned { coords -> + rootWindowBounds = Rect(coords.positionInWindow(), coords.size.toSize()) + } + .testTag("NativeVerticalReader") + ) { + val textMeasurer = rememberTextMeasurer() + val baseTextStyle = MaterialTheme.typography.bodyLarge + val density = LocalDensity.current + val layoutTextStyle = remember( + baseTextStyle, + fontSizeMultiplier, + lineHeightMultiplier, + fontFamily + ) { + val adjustedFontSize = baseTextStyle.fontSize * fontSizeMultiplier + val adjustedLineHeight = + adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(lineHeightMultiplier) + + baseTextStyle.copy( + color = Color.Unspecified, + fontSize = adjustedFontSize, + lineHeight = adjustedLineHeight, + fontFamily = fontFamily, + lineBreak = LineBreak.Paragraph, + letterSpacing = TextUnit.Unspecified, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Proportional, + trim = LineHeightStyle.Trim.None + ) + ) + } + val textStyle = remember(layoutTextStyle, effectiveText) { + layoutTextStyle.copy(color = effectiveText) + } + val userTextAlign = remember(textAlign) { + when (textAlign) { + ReaderTextAlign.JUSTIFY -> TextAlign.Justify + ReaderTextAlign.LEFT -> TextAlign.Left + ReaderTextAlign.RIGHT -> TextAlign.Right + ReaderTextAlign.DEFAULT -> null + } + } + val requestedHorizontalPadding = 16.dp * horizontalMarginMultiplier + val requestedVerticalPadding = 16.dp * verticalMarginMultiplier + val effectiveReaderPadding = + remember(this.constraints, density, requestedHorizontalPadding, requestedVerticalPadding) { + val requestedHorizontalPaddingPx = with(density) { requestedHorizontalPadding.roundToPx() } + val requestedVerticalPaddingPx = with(density) { requestedVerticalPadding.roundToPx() } + val minReadableWidthPx = with(density) { 96.dp.roundToPx() } + .coerceAtMost(this.constraints.maxWidth) + val minReadableHeightPx = with(density) { 160.dp.roundToPx() } + .coerceAtMost(this.constraints.maxHeight) + val horizontalPaddingPx = requestedHorizontalPaddingPx.coerceAtMost( + ((this.constraints.maxWidth - minReadableWidthPx) / 2).coerceAtLeast(0) + ) + val verticalPaddingPx = requestedVerticalPaddingPx.coerceAtMost( + ((this.constraints.maxHeight - minReadableHeightPx) / 2).coerceAtLeast(0) + ) + with(density) { + horizontalPaddingPx.toDp() to verticalPaddingPx.toDp() + } + } + val horizontalPadding = effectiveReaderPadding.first + val verticalPadding = effectiveReaderPadding.second + val textConstraints = + remember(this.constraints, density, horizontalPadding, verticalPadding) { + val horizontalPaddingPx = with(density) { horizontalPadding.roundToPx() } + val verticalPaddingPx = with(density) { verticalPadding.roundToPx() } + this.constraints.copy( + minWidth = 0, + maxWidth = (this.constraints.maxWidth - (2 * horizontalPaddingPx)).coerceAtLeast(1), + minHeight = 0, + maxHeight = (this.constraints.maxHeight - (2 * verticalPaddingPx)).coerceAtLeast(1) + ) + } + + val mathMLRenderer = remember { MathMLRenderer(context.applicationContext) } + DisposableEffect(Unit) { + onDispose { + mathMLRenderer.destroy() + Timber.d("NativeVerticalReaderScreen disposed, MathMLRenderer destroyed.") + } + } + + val paginator = remember( + book, + bookId, + textConstraints, + layoutTextStyle, + userTextAlign, + paragraphGapMultiplier, + imageSizeMultiplier, + verticalMarginMultiplier, + bookReplacementSignature, + bookReplacementFileId + ) { + val userAgentStylesheet = UserAgentStylesheet.default + var allRules = OptimizedCssRules() + val allFontFaces = mutableListOf() + + val uaResult = CssParser.parse( + cssContent = userAgentStylesheet, + cssPath = null, + baseFontSizeSp = layoutTextStyle.fontSize.value, + density = density.density, + constraints = textConstraints, + isDarkTheme = false, + adaptThemeColors = false + ) + allRules = allRules.merge(uaResult.rules) + allFontFaces.addAll(uaResult.fontFaces) + + book.css.forEach { (path, content) -> + val bookCssResult = CssParser.parse( + cssContent = content, + cssPath = path, + baseFontSizeSp = layoutTextStyle.fontSize.value, + density = density.density, + constraints = textConstraints, + isDarkTheme = false, + adaptThemeColors = false + ) + allRules = allRules.merge(bookCssResult.rules) + allFontFaces.addAll(bookCssResult.fontFaces) + } + + val fontFamilyMap = loadFontFamilies( + fontFaces = allFontFaces, + extractionPath = book.extractionBasePath + ) + val bookCacheDao = + BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao() + val proto = ProtoBuf { serializersModule = semanticBlockModule } + val uniqueBookId = bookId ?: if (book.fileName.length > 20) book.fileName else book.title + val initialChapter = initialLocator?.chapterIndex ?: 0 + + Timber.tag("NativeVerticalReader").d( + "Instantiating BookPaginator for native vertical. initialChapter=$initialChapter" + ) + BookPaginator( + coroutineScope = coroutineScope, + chapters = book.chaptersForPagination, + textMeasurer = textMeasurer, + constraints = textConstraints, + textStyle = layoutTextStyle, + extractionBasePath = book.extractionBasePath, + density = density, + fontFamilyMap = fontFamilyMap, + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + bookId = uniqueBookId, + bookCacheDao = bookCacheDao, + proto = proto, + initialChapterToPaginate = initialChapter, + bookCss = book.css, + userAgentStylesheet = userAgentStylesheet, + allFontFaces = allFontFaces, + context = context.applicationContext, + mathMLRenderer = mathMLRenderer, + userTextAlign = userTextAlign, + paragraphGapMultiplier = paragraphGapMultiplier, + imageSizeMultiplier = imageSizeMultiplier, + verticalMarginMultiplier = verticalMarginMultiplier, + bookReplacementPreferences = bookReplacementPreferences, + bookReplacementFileId = bookReplacementFileId + ) + } + + LaunchedEffect(paginator) { + onPaginatorReady(paginator) + } + + DisposableEffect(paginator) { + onDispose { + paginator.dispose() + } + } + + var isLoading by remember { mutableStateOf(true) } + var totalPageCount by remember { mutableIntStateOf(0) } + var generation by remember { mutableIntStateOf(0) } + + LaunchedEffect(paginator) { + launch { + snapshotFlow { paginator.isLoading }.collect { isLoading = it } + } + launch { + snapshotFlow { paginator.totalPageCount }.collect { totalPageCount = it } + } + launch { + snapshotFlow { paginator.generation }.collect { generation = it } + } + } + + val listState = rememberLazyListState() + val blockLayoutMap = remember(paginator) { ReactiveBlockMap() } + val chapterLayoutMap = remember(paginator) { mutableStateMapOf() } + val flowItemLayoutMap = remember(paginator) { mutableStateMapOf() } + var flowChapters by remember(paginator) { mutableStateOf?>(null) } + val flowItems = remember(flowChapters) { buildNativeVerticalFlowItems(flowChapters.orEmpty()) } + var isFlowLoading by remember(paginator) { mutableStateOf(true) } + val initialNativeLocator = remember(paginator) { initialLocator } + val initialNativePageIndex = remember(paginator) { initialPageIndexInBook } + var didInitialScroll by remember(paginator) { mutableStateOf(false) } + val placeholderFlowChapters = remember(book) { + book.chaptersForPagination.mapIndexed { chapterIndex, chapter -> + NativeVerticalFlowChapter( + chapterIndex = chapterIndex, + title = chapter.title, + blocks = emptyList(), + isLoaded = false, + estimatedLocationWeight = chapter.plainTextCharacterCount().coerceAtLeast(24) + ) + } + } + val flowChapterLoadsInFlight = remember(paginator) { mutableStateMapOf() } + + fun ensurePlaceholderFlowChapters() { + val current = flowChapters + if (current == null || current.size != placeholderFlowChapters.size) { + flowChapters = placeholderFlowChapters + } + } + + suspend fun loadFlowChapter(chapterIndex: Int): Boolean { + if (chapterIndex !in placeholderFlowChapters.indices) return false + flowChapters?.getOrNull(chapterIndex)?.takeIf { it.isLoaded }?.let { return true } + while (flowChapterLoadsInFlight[chapterIndex] == true) { + delay(16L) + flowChapters?.getOrNull(chapterIndex)?.takeIf { it.isLoaded }?.let { return true } + } + + flowChapterLoadsInFlight[chapterIndex] = true + return try { + val chapter = book.chaptersForPagination.getOrNull(chapterIndex) ?: return false + val blocks = try { + paginator.getFlowBlocksForChapter(chapterIndex).orEmpty() + } catch (e: Exception) { + Timber.e(e, "Native vertical flow failed to load chapter $chapterIndex") + emptyList() + } + val current = flowChapters ?: placeholderFlowChapters + val updated = current.toMutableList() + updated[chapterIndex] = NativeVerticalFlowChapter( + chapterIndex = chapterIndex, + title = chapter.title, + blocks = blocks, + isLoaded = true, + estimatedLocationWeight = chapter.plainTextCharacterCount().coerceAtLeast(24) + ) + flowChapters = updated + true + } finally { + flowChapterLoadsInFlight.remove(chapterIndex) + } + } + + @Suppress("UNUSED_PARAMETER") + suspend fun scrollToFlowLocator( + locator: Locator?, + animate: Boolean, + keepVisible: Boolean = false + ): Boolean { + if (locator == null) return false + ensurePlaceholderFlowChapters() + if (flowChapters?.getOrNull(locator.chapterIndex)?.isLoaded != true) { + loadFlowChapter(locator.chapterIndex) + withFrameNanos { } + } + val chapters = flowChapters ?: return false + val currentFlowItems = buildNativeVerticalFlowItems(chapters) + val exactDelta = resolveNativeVerticalScrollDeltaForLocator( + rootWindowBounds = rootWindowBounds, + chapterLayoutMap = chapterLayoutMap, + flowItems = currentFlowItems, + flowItemLayoutMap = flowItemLayoutMap, + blockLayoutMap = blockLayoutMap, + chapters = chapters, + locator = locator, + allowChapterFallback = false + ) + if (exactDelta != null) { + val scrollDelta = if (keepVisible) { + nativeVerticalCenteredScrollDelta( + targetOffsetInViewport = exactDelta, + viewportHeight = rootWindowBounds.height + ) + } else { + exactDelta + } + if (abs(scrollDelta) > 1f) { + if (animate) { + listState.animateScrollBy(scrollDelta) + } else { + listState.scrollBy(scrollDelta) + } + } + if (keepVisible || abs(exactDelta) > 1f) return true + } + + val targetIndex = findNativeVerticalFlowItemIndexForLocator( + items = currentFlowItems, + chapters = chapters, + locator = locator + ) ?: return false + if (animate) { + listState.animateScrollToItem(targetIndex) + } else { + listState.scrollToItem(targetIndex) + } + repeat(4) { + withFrameNanos { } + val refinedDelta = resolveNativeVerticalScrollDeltaForLocator( + rootWindowBounds = rootWindowBounds, + chapterLayoutMap = chapterLayoutMap, + flowItems = currentFlowItems, + flowItemLayoutMap = flowItemLayoutMap, + blockLayoutMap = blockLayoutMap, + chapters = chapters, + locator = locator, + allowChapterFallback = false + ) + if (refinedDelta != null) { + val scrollDelta = if (keepVisible) { + nativeVerticalCenteredScrollDelta( + targetOffsetInViewport = refinedDelta, + viewportHeight = rootWindowBounds.height + ) + } else { + refinedDelta + } + if (abs(scrollDelta) > 1f) { + if (animate) { + listState.animateScrollBy(scrollDelta) + } else { + listState.scrollBy(scrollDelta) + } + } + return true + } + } + return true + } + + suspend fun scrollToCompatPage(pageIndex: Int, animate: Boolean): Boolean { + val targetPage = pageIndex.coerceIn(0, (totalPageCount - 1).coerceAtLeast(0)) + val locator = paginator.getLocatorForPage(targetPage) + ?: paginator.findChapterIndexForPage(targetPage)?.let { Locator(it, 0, 0) } + ?: return false + val didScroll = scrollToFlowLocator(locator, animate) + if (didScroll) paginator.onUserScrolledTo(targetPage) + return didScroll + } + + suspend fun scrollToProgressPercent(progressPercent: Float): Boolean { + if (flowItems.isEmpty()) return false + val targetIndex = findNativeVerticalFlowItemIndexForProgress( + items = flowItems, + progressPercent = progressPercent + ) ?: return false + listState.scrollToItem(targetIndex) + paginator.onUserScrolledTo( + nativeVerticalCompatPageForProgress(progressPercent, totalPageCount) + ) + return true + } + + LaunchedEffect(paginator) { + snapshotFlow { paginator.isLoading }.filter { !it }.first() + isFlowLoading = true + if (placeholderFlowChapters.isEmpty()) { + flowChapters = emptyList() + isFlowLoading = false + return@LaunchedEffect + } + + flowChapters = placeholderFlowChapters + val initialChapter = ( + initialNativeLocator?.chapterIndex + ?: paginator.findChapterIndexForPage(initialNativePageIndex) + ?: 0 + ).coerceIn(0, placeholderFlowChapters.lastIndex) + val prefetchOrder = nativeVerticalInitialChapterPrefetchOrder( + chapterCount = placeholderFlowChapters.size, + initialChapter = initialChapter + ) + + loadFlowChapter(initialChapter) + isFlowLoading = false + + prefetchOrder.forEach { chapterIndex -> + if (!isActive) return@LaunchedEffect + while (isActive && listState.isScrollInProgress) { + delay(80L) + } + loadFlowChapter(chapterIndex) + delay(80L) + } + } + + LaunchedEffect(flowChapters, totalPageCount, rootWindowBounds) { + if (didInitialScroll || flowChapters == null || rootWindowBounds == Rect.Zero) return@LaunchedEffect + val targetLocator = initialNativeLocator ?: paginator.getLocatorForPage(initialNativePageIndex) + if (targetLocator == null) { + didInitialScroll = true + return@LaunchedEffect + } + val didLocatorScroll = scrollToFlowLocator(targetLocator, animate = false) + val didScroll = didLocatorScroll || + if (shouldFallbackNativeVerticalInitialScrollToCompatPage( + hasInitialLocator = initialNativeLocator != null, + didLocatorScroll = didLocatorScroll + ) + ) { + scrollToCompatPage(initialNativePageIndex, animate = false) + } else { + false + } + if (didScroll || initialNativeLocator != null) { + didInitialScroll = true + } + } + + LaunchedEffect(scrollRequestPage, totalPageCount, flowChapters, rootWindowBounds) { + val requestedPage = scrollRequestPage ?: return@LaunchedEffect + if (totalPageCount <= 0 || flowChapters == null || rootWindowBounds == Rect.Zero) return@LaunchedEffect + if (scrollToCompatPage(requestedPage, animate = true)) { + onScrollRequestConsumed() + } + } + + LaunchedEffect(scrollRequestLocatorId, scrollRequestLocator, scrollRequestLocatorKeepVisible, flowChapters, rootWindowBounds) { + val requestedLocator = scrollRequestLocator ?: return@LaunchedEffect + if (flowChapters == null || rootWindowBounds == Rect.Zero) return@LaunchedEffect + if (scrollToFlowLocator( + locator = requestedLocator, + animate = scrollRequestLocatorKeepVisible, + keepVisible = scrollRequestLocatorKeepVisible + ) + ) { + paginator.onUserScrolledTo( + nativeVerticalCompatPageForProgress( + estimateNativeVerticalProgressPercent(book, requestedLocator) ?: 0f, + totalPageCount + ) + ) + onScrollLocatorRequestConsumed() + } + } + + LaunchedEffect(scrollRequestProgressId, scrollRequestProgressPercent, flowChapters) { + val requestedProgress = scrollRequestProgressPercent ?: return@LaunchedEffect + if (flowChapters == null) return@LaunchedEffect + if (scrollToProgressPercent(requestedProgress)) { + onScrollProgressRequestConsumed() + } + } + + LaunchedEffect(scrollDeltaRequestId, scrollDeltaRequest, scrollDeltaRequestAnimated) { + val delta = scrollDeltaRequest ?: return@LaunchedEffect + if (delta != 0f) { + if (scrollDeltaRequestAnimated) { + listState.animateScrollBy(delta) + } else { + listState.scrollBy(delta) + } + } + onScrollDeltaConsumed() + } + + var lastReportedVisiblePage by remember { mutableIntStateOf(-1) } + var lastReportedTotalPageCount by remember { mutableIntStateOf(0) } + var lastReportedProgressPercent by remember { mutableFloatStateOf(-1f) } + var lastReportedLocator by remember { mutableStateOf(null) } + var lastReportedChapterPageInfo by remember { mutableStateOf(null) } + var lastReportedVisibleTextRanges by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(paginator, totalPageCount, rootWindowBounds, blockLayoutMap, flowChapters, flowItems) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val firstVisibleItemSize = visibleItems + .firstOrNull { it.index == listState.firstVisibleItemIndex } + ?.size + ?: 0 + val lastVisibleItem = visibleItems.lastOrNull() + val isAtEnd = layoutInfo.totalItemsCount > 0 && + lastVisibleItem?.index == layoutInfo.totalItemsCount - 1 && + lastVisibleItem.offset + lastVisibleItem.size <= layoutInfo.viewportEndOffset + NativeVerticalViewportSample( + firstVisiblePageIndex = listState.firstVisibleItemIndex, + firstVisiblePageScrollOffset = listState.firstVisibleItemScrollOffset, + firstVisibleItemSize = firstVisibleItemSize, + isAtStart = listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0, + isAtEnd = isAtEnd, + totalPageCount = totalPageCount.takeIf { it > 0 } ?: (flowChapters?.size ?: 0), + layoutTick = blockLayoutMap.tick, + initialScrollComplete = didInitialScroll + ) + } + .collectLatest { sample -> + if (!sample.initialScrollComplete) return@collectLatest + val total = sample.totalPageCount + if (total <= 0) return@collectLatest + blockLayoutMap.pruneDetached() + val locator = resolveNativeVerticalFlowVisibleLocator( + rootWindowBounds = rootWindowBounds, + blockLayoutMap = blockLayoutMap + ) ?: flowItems.getOrNull(sample.firstVisiblePageIndex) + ?.let { locatorForNativeVerticalFlowItem(it) } + val visibleTextRanges = resolveNativeVerticalVisibleTextRanges( + rootWindowBounds = rootWindowBounds, + blockLayoutMap = blockLayoutMap + ) + val progressPercent = when { + sample.isAtEnd -> 100f + sample.isAtStart -> 0f + else -> estimateNativeVerticalScrollProgressPercent( + items = flowItems, + firstVisibleItemIndex = sample.firstVisiblePageIndex, + firstVisibleItemScrollOffset = sample.firstVisiblePageScrollOffset, + firstVisibleItemSize = sample.firstVisibleItemSize + ) ?: estimateNativeVerticalProgressPercent( + book = book, + locator = locator + ) ?: 0f + } + val compatPage = nativeVerticalCompatPageForProgress(progressPercent, total) + paginator.onUserScrolledTo(compatPage) + val visibleChapterIndex = locator?.chapterIndex + ?: flowItems.getOrNull(sample.firstVisiblePageIndex)?.chapterIndex + val chapterPageInfo = visibleChapterIndex?.let { chapterIndex -> + nativeVerticalChapterPageInfoForScroll( + itemChapterIndices = flowItems.map { it.chapterIndex }, + itemWeights = flowItems.map { it.locationWeight }, + firstVisibleItemIndex = sample.firstVisiblePageIndex, + firstVisibleItemScrollOffset = sample.firstVisiblePageScrollOffset, + firstVisibleItemSize = sample.firstVisibleItemSize, + chapterPageCount = paginator.chapterPageCounts[chapterIndex] + ) + } + + if ( + compatPage != lastReportedVisiblePage || + total != lastReportedTotalPageCount || + abs(progressPercent - lastReportedProgressPercent) >= 0.05f || + locator != lastReportedLocator || + chapterPageInfo != lastReportedChapterPageInfo || + visibleTextRanges != lastReportedVisibleTextRanges + ) { + lastReportedVisiblePage = compatPage + lastReportedTotalPageCount = total + lastReportedProgressPercent = progressPercent + lastReportedLocator = locator + lastReportedChapterPageInfo = chapterPageInfo + lastReportedVisibleTextRanges = visibleTextRanges + onLocationChanged( + NativeVerticalLocation( + locator = locator, + chapterIndex = locator?.chapterIndex, + progressPercent = progressPercent, + compatPageIndex = compatPage, + compatTotalPages = total, + firstVisibleItemIndex = sample.firstVisiblePageIndex, + firstVisibleItemScrollOffset = sample.firstVisiblePageScrollOffset, + firstVisibleItemSize = sample.firstVisibleItemSize, + isAtStart = sample.isAtStart, + isAtEnd = sample.isAtEnd, + visibleTextRanges = visibleTextRanges, + chapterPageInfo = chapterPageInfo + ) + ) + onProgressChanged(compatPage, total, progressPercent) + onVisiblePageChanged(compatPage, locator?.chapterIndex, locator) + } + } + } + + val searchHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) + val ttsHighlightColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f) + var activeSelection by remember { mutableStateOf(null) } + var isDraggingHandle by remember { mutableStateOf(false) } + var selectionEdgeScrollDelta by remember { mutableFloatStateOf(0f) } + var selectionEdgeDragWindowPos by remember { mutableStateOf(Offset.Unspecified) } + var selectionEdgeDragHandle by remember { mutableStateOf(null) } + val activeDragHandleForDisplay = selectionEdgeDragHandle + var magnifierCenter by remember { mutableStateOf(Offset.Unspecified) } + val magnifierModifier = if (magnifierCenter.isSpecified) { + Modifier.magnifier( + sourceCenter = { magnifierCenter }, + zoom = 1.5f, + size = DpSize(140.dp, 48.dp), + cornerRadius = 24.dp, + elevation = 4.dp + ) + } else { + Modifier + } + var showPaletteManager by remember { mutableStateOf(false) } + var showExternalLinkDialog by remember { mutableStateOf(null) } + val imageLoader = context.imageLoader + + showExternalLinkDialog?.let { urlToShow -> + AlertDialog( + onDismissRequest = { showExternalLinkDialog = null }, + title = { Text(stringResource(R.string.dialog_external_link_title)) }, + text = { Text(urlToShow) }, + confirmButton = { + TextButton( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, urlToShow.toUri()) + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Timber.e(e, "No activity found to handle intent for URL: $urlToShow") + Toast.makeText( + context, + context.getString(R.string.error_no_browser), + Toast.LENGTH_LONG + ).show() + } + showExternalLinkDialog = null + } + ) { Text(stringResource(R.string.action_open)) } + }, + dismissButton = { + TextButton(onClick = { + val copied = copyPlainTextToClipboard( + context = context, + label = context.getString(R.string.clip_label_copied_text), + 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)) } + } + ) + } + + val renderedFlowChapters = flowChapters + + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { rootCoords = it } + .then(magnifierModifier) + ) { + if (isFlowLoading || renderedFlowChapters == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else if (renderedFlowChapters.isNotEmpty()) { + generation + val chapterBoundaryGap = 44.dp * verticalMarginMultiplier.coerceIn(0.75f, 2.5f) + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(listState), + contentPadding = PaddingValues(top = verticalPadding, bottom = verticalPadding) + ) { + itemsIndexed( + items = flowItems, + key = { _, item -> item.key } + ) { _, item -> + val chapterIndex = item.chapterIndex + val block = item.block + val onGeneralTapCallback: (Offset) -> Unit = { offset -> + activeSelection = null + onTap(offset) + } + val onLinkClickCallback: (String) -> Unit = { href -> + if (href.isReaderExternalHref()) { + showExternalLinkDialog = href.readerExternalHrefForDisplay() + } else { + val chapterPath = book.chaptersForPagination.getOrNull(chapterIndex)?.absPath + coroutineScope.launch { + val footnoteHtml = withContext(Dispatchers.IO) { + resolveReaderFootnoteHtml(book, chapterPath.orEmpty(), href) + } + if (!footnoteHtml.isNullOrBlank()) { + onFootnoteRequested(footnoteHtml) + return@launch + } + val targetLocator = paginator.findStableLocatorForHref(chapterPath.orEmpty(), href) + val targetPage = targetLocator?.let { paginator.findStablePageForLocator(it) } + ?: paginator.findStablePageForHref(chapterPath.orEmpty(), href) + if (targetPage != null) { + if (targetLocator != null) { + scrollToFlowLocator(targetLocator, animate = false) + paginator.onUserScrolledTo(targetPage) + } else { + scrollToCompatPage(targetPage, animate = true) + } + onInternalLinkNavigated(targetPage, targetLocator) + } else { + Timber.tag(TAG_PAGINATED_LINK_DIAG) + .w("Native vertical link failed href=$href currentChapterPath=$chapterPath") + } + } + } + } + + if (block == null) { + if (item.kind == NativeVerticalFlowItemKind.UNLOADED_CHAPTER) { + LaunchedEffect(chapterIndex, item.kind) { + loadFlowChapter(chapterIndex) + } + } + val spacerHeight = when (item.kind) { + NativeVerticalFlowItemKind.CHAPTER_GAP -> chapterBoundaryGap + NativeVerticalFlowItemKind.UNLOADED_CHAPTER -> 72.dp + else -> 24.dp + } + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(spacerHeight) + .onGloballyPositioned { coords -> + flowItemLayoutMap[item.key] = coords + chapterLayoutMap[chapterIndex] = coords + } + ) + } else { + val displayBlock = remember(block, isDarkTheme, effectiveBg, effectiveText) { + Page(listOf(block)).applyReaderThemeForDisplay( + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText + ).content.first() + } + val pageUserHighlights = highlightsForPaginatedPage( + pageChapterIndex = chapterIndex, + userHighlights = userHighlights + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding) + .background(effectiveBg) + .onGloballyPositioned { coords -> + flowItemLayoutMap[item.key] = coords + if (item.blockOrdinal <= 0) { + chapterLayoutMap[chapterIndex] = coords + } + } + .pointerInput(chapterIndex, item.blockOrdinal) { + detectTapGestures(onTap = { offset -> onTap(offset) }) + } + ) { + NativeVerticalContentBlock( + block = displayBlock, + pageIndex = chapterIndex, + textStyle = textStyle, + imageSizeMultiplier = imageSizeMultiplier, + searchQuery = searchQuery, + searchHighlightColor = searchHighlightColor, + ttsHighlightInfo = ttsHighlightInfo, + ttsHighlightColor = ttsHighlightColor, + textMeasurer = textMeasurer, + onLinkClickCallback = onLinkClickCallback, + onGeneralTapCallback = onGeneralTapCallback, + userHighlights = pageUserHighlights, + activeSelection = activeSelection, + onSelectionChange = { activeSelection = it }, + onHighlightClick = { highlight, _ -> + onNoteRequested(highlight.cfi) + activeSelection = null + }, + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + blockLayoutMap = blockLayoutMap, + density = density, + imageLoader = imageLoader, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + + if (activeSelection != null) { + val sel = activeSelection!! + @Suppress("UNUSED_VARIABLE") val selectionLayoutTick = blockLayoutMap.tick + val selectedBlocks = visibleSelectedBlocks(blockLayoutMap, sel) + + if (!isDraggingHandle && selectedBlocks.isNotEmpty()) { + val handleSizePx = with(density) { 36.dp.toPx() } + val menuAnchorRect = selectionWindowBounds(sel, selectedBlocks, handleSizePx) + Popup( + popupPositionProvider = remember(menuAnchorRect, density) { + SmartPopupPositionProvider(menuAnchorRect, density) + }, + onDismissRequest = { activeSelection = null }, + properties = PopupProperties(dismissOnClickOutside = false) + ) { + PaginatedTextSelectionMenu( + onCopy = { + val copied = copyPlainTextToClipboard( + context = context, + label = context.getString(R.string.clip_label_copied_text), + text = sel.text + ) + if (!copied) { + Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show() + } + activeSelection = null + }, + onSelectAll = null, + onDictionary = { + if (isProUser || countWords(sel.text) <= 1) { + onWordSelectedForAiDefinition(sel.text) + } else { + onShowDictionaryUpsellDialog() + } + activeSelection = null + }, + onTranslate = { + onTranslate(sel.text) + activeSelection = null + }, + onSearch = { + onSearch(sel.text) + activeSelection = null + }, + onHighlight = { color -> + val startAbsoluteOffset = sel.startBlockCharOffset + sel.startOffset + val endAbsoluteOffset = sel.endBlockCharOffset + sel.endOffset + val finalCfi = + "${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}" + val absoluteCandidateCfi = + "${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset" + val locator = sel.toSharedHighlightLocator( + chapterIndex = sel.startPageIndex, + cfi = finalCfi + ) + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "create_request source=native_vertical_highlight_menu color=${color.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "create_request surface=native_vertical action=highlight color=${color.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "locator=${locator} textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) + onHighlightCreated(finalCfi, sel.text, color.id, locator) + activeSelection = null + }, + onNote = { + onNoteRequested(null) + val startAbsoluteOffset = sel.startBlockCharOffset + sel.startOffset + val endAbsoluteOffset = sel.endBlockCharOffset + sel.endOffset + val finalCfi = + "${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}" + val absoluteCandidateCfi = + "${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset" + val locator = sel.toSharedHighlightLocator( + chapterIndex = sel.startPageIndex, + cfi = finalCfi + ) + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "create_request source=native_vertical_note_menu color=${HighlightColor.YELLOW.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "create_request surface=native_vertical action=note color=${HighlightColor.YELLOW.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "locator=${locator} textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) + onHighlightCreated(finalCfi, sel.text, HighlightColor.YELLOW.id, locator) + activeSelection = null + }, + onTts = { + val startAbs = sel.startOffset + sel.startBlockCharOffset + onStartTtsFromSelection(sel.startBaseCfi, startAbs, sel.startPageIndex) + activeSelection = null + }, + onDelete = null, + isProUser = isProUser, + isOss = isOss, + activeHighlightPalette = activeHighlightPalette, + onOpenPaletteManager = { showPaletteManager = true } + ) + } + } + + val latestActiveSelection by rememberUpdatedState(activeSelection) + val updateSelection: (Offset, SelectionHandle, Boolean) -> SelectionHandle = + updateSelection@ { windowPos, currentDragHandle, withHaptic -> + val currentSelection = latestActiveSelection ?: return@updateSelection currentDragHandle + val attachedBlocks = attachedSelectionBlocks(blockLayoutMap) + val updated = updatedSelectionForHandleDrag( + selection = currentSelection, + windowPos = windowPos, + currentDragHandle = currentDragHandle, + attachedBlocks = attachedBlocks, + blockLayoutMap = blockLayoutMap + ) + if (updated != null) { + if (withHaptic) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + activeSelection = updated.first + updated.second + } else { + currentDragHandle + } + } + + val latestUpdateSelection by rememberUpdatedState(updateSelection) + + LaunchedEffect(isDraggingHandle) { + while (isDraggingHandle && isActive) { + val delta = selectionEdgeScrollDelta + if (abs(delta) > 0.5f) { + listState.scrollBy(delta) + withFrameNanos { } + val handle = selectionEdgeDragHandle + val targetWindowPos = selectionEdgeDragWindowPos + if (handle != null && targetWindowPos.isSpecified) { + selectionEdgeDragHandle = latestUpdateSelection(targetWindowPos, handle, false) + } + } else { + withFrameNanos { } + } + } + selectionEdgeScrollDelta = 0f + selectionEdgeDragWindowPos = Offset.Unspecified + selectionEdgeDragHandle = null + } + + listOf(SelectionHandle.START, SelectionHandle.END).forEach { handleType -> + val isStart = handleType == SelectionHandle.START + var handleCoords by remember { mutableStateOf(null) } + + Box( + modifier = Modifier + .zIndex(8f) + .graphicsLayer { + @Suppress("UNUSED_VARIABLE") val tick = blockLayoutMap.tick + val pos = selectionHandleRootPosition( + selection = sel, + isStart = isStart, + blockLayoutMap = blockLayoutMap, + rootCoords = rootCoords + ) + val shouldShowHandle = !isDraggingHandle || + activeDragHandleForDisplay == null || + activeDragHandleForDisplay == handleType + + if (pos.isSpecified && shouldShowHandle) { + translationX = pos.x - 18.dp.toPx() + translationY = pos.y + alpha = 1f + } else { + alpha = 0f + } + } + .size(36.dp) + .onGloballyPositioned { handleCoords = it } + .pointerInput(handleType, listState) { + awaitEachGesture { + val down = awaitFirstDown() + down.consume() + if (isDraggingHandle && selectionEdgeDragHandle != null) { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + change.consume() + if (!change.pressed) break + } + return@awaitEachGesture + } + isDraggingHandle = true + var currentDragHandle = handleType + selectionEdgeDragHandle = currentDragHandle + selectionEdgeDragWindowPos = Offset.Unspecified + var downPointerRoot = Offset.Unspecified + var downHandleAnchorRoot = Offset.Unspecified + if ( + handleCoords != null && + rootCoords != null && + handleCoords!!.isAttached && + rootCoords!!.isAttached + ) { + try { + val pointerWindow = handleCoords!!.localToWindow(down.position) + downPointerRoot = rootCoords!!.windowToLocal(pointerWindow) + downHandleAnchorRoot = latestActiveSelection?.let { currentSelection -> + selectionHandleRootPosition( + selection = currentSelection, + isStart = isStart, + blockLayoutMap = blockLayoutMap, + rootCoords = rootCoords + ) + } ?: Offset.Unspecified + } catch (_: Exception) { + downPointerRoot = Offset.Unspecified + downHandleAnchorRoot = Offset.Unspecified + } + } + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + change.consume() + break + } + change.consume() + + if ( + handleCoords != null && + rootCoords != null && + handleCoords!!.isAttached && + rootCoords!!.isAttached + ) { + try { + selectionEdgeDragHandle?.let { currentDragHandle = it } + val pointerWindow = handleCoords!!.localToWindow(change.position) + val pointerRoot = rootCoords!!.windowToLocal(pointerWindow) + val edgeSize = 64.dp.toPx() + val maxScrollStep = 28.dp.toPx() + val rootHeight = rootCoords!!.size.height.toFloat() + val edgeScrollDelta = when { + pointerRoot.y < edgeSize -> + -(((edgeSize - pointerRoot.y) / edgeSize) * maxScrollStep) + .coerceIn(2.dp.toPx(), maxScrollStep) + pointerRoot.y > rootHeight - edgeSize -> + (((pointerRoot.y - (rootHeight - edgeSize)) / edgeSize) * maxScrollStep) + .coerceIn(2.dp.toPx(), maxScrollStep) + else -> 0f + } + selectionEdgeScrollDelta = edgeScrollDelta + + val targetRootPos = if ( + downPointerRoot.isSpecified && + downHandleAnchorRoot.isSpecified + ) { + downHandleAnchorRoot + (pointerRoot - downPointerRoot) + } else { + pointerRoot + } + magnifierCenter = targetRootPos + + val textHitRootPos = targetRootPos.copy( + y = targetRootPos.y - 2.dp.toPx() + ) + val targetWindowPos = rootCoords!!.localToWindow(textHitRootPos) + currentDragHandle = latestUpdateSelection(targetWindowPos, currentDragHandle, true) + selectionEdgeDragWindowPos = targetWindowPos + selectionEdgeDragHandle = currentDragHandle + } catch (_: Exception) { + // Ignore detachment during fast scroll/drag handoff. + } + } + } + isDraggingHandle = false + selectionEdgeScrollDelta = 0f + selectionEdgeDragWindowPos = Offset.Unspecified + selectionEdgeDragHandle = null + magnifierCenter = Offset.Unspecified + } + }, + contentAlignment = Alignment.TopCenter + ) { + Icon( + painter = painterResource(R.drawable.teardrop), + contentDescription = if (isStart) "Start handle" else "End handle", + modifier = Modifier + .size(36.dp) + .graphicsLayer { + rotationZ = if (isStart) 30f else -30f + transformOrigin = TransformOrigin(0.5f, 0f) + }, + tint = Color(0xFF1976D2) + ) + } + } + } + + if (showPaletteManager) { + PaletteManagerDialog( + currentPalette = activeHighlightPalette, + onDismiss = { showPaletteManager = false }, + onSave = { newPalette -> + newPalette.forEachIndexed { index, color -> + onUpdatePalette(index, color) + } + showPaletteManager = false + } + ) + } + } + } +} + +@Composable +private fun NativeVerticalPage( + page: Page, + pageIndex: Int, + textStyle: TextStyle, + imageSizeMultiplier: Float, + searchQuery: String, + searchHighlightColor: Color, + ttsHighlightInfo: TtsHighlightInfo?, + ttsHighlightColor: Color, + textMeasurer: TextMeasurer, + onLinkClickCallback: (String) -> Unit, + onGeneralTapCallback: (Offset) -> Unit, + userHighlights: List, + activeSelection: PaginatedSelection?, + onSelectionChange: (PaginatedSelection?) -> Unit, + onHighlightClick: (UserHighlight, Rect) -> Unit, + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color, + blockLayoutMap: MutableMap>, + density: Density, + imageLoader: ImageLoader, + horizontalPadding: Dp, + effectiveBg: Color, + onTap: (Offset?) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(effectiveBg) + .padding(horizontal = horizontalPadding) + .pointerInput(pageIndex) { + detectTapGestures(onTap = { offset -> onTap(offset) }) + } + ) { + page.content.forEach { block -> + NativeVerticalContentBlock( + block = block, + pageIndex = pageIndex, + textStyle = textStyle, + imageSizeMultiplier = imageSizeMultiplier, + searchQuery = searchQuery, + searchHighlightColor = searchHighlightColor, + ttsHighlightInfo = ttsHighlightInfo, + ttsHighlightColor = ttsHighlightColor, + textMeasurer = textMeasurer, + onLinkClickCallback = onLinkClickCallback, + onGeneralTapCallback = onGeneralTapCallback, + userHighlights = userHighlights, + activeSelection = activeSelection, + onSelectionChange = onSelectionChange, + onHighlightClick = onHighlightClick, + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, + blockLayoutMap = blockLayoutMap, + density = density, + imageLoader = imageLoader, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun NativeVerticalContentBlock( + block: ContentBlock, + pageIndex: Int, + textStyle: TextStyle, + imageSizeMultiplier: Float, + searchQuery: String, + searchHighlightColor: Color, + ttsHighlightInfo: TtsHighlightInfo?, + ttsHighlightColor: Color, + textMeasurer: TextMeasurer, + onLinkClickCallback: (String) -> Unit, + onGeneralTapCallback: (Offset) -> Unit, + userHighlights: List, + activeSelection: PaginatedSelection?, + onSelectionChange: (PaginatedSelection?) -> Unit, + onHighlightClick: (UserHighlight, Rect) -> Unit, + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color, + blockLayoutMap: MutableMap>, + density: Density, + imageLoader: ImageLoader, + modifier: Modifier = Modifier +) { + val styledModifier = modifier + .padding( + start = block.style.margin.left.coerceAtLeast(0.dp), + top = block.style.margin.top.coerceAtLeast(0.dp), + end = block.style.margin.right.coerceAtLeast(0.dp), + bottom = block.style.margin.bottom.coerceAtLeast(0.dp) + ) + .drawCssBorders(block.style, density) + .padding( + start = block.style.padding.left.coerceAtLeast(0.dp), + top = block.style.padding.top.coerceAtLeast(0.dp), + end = block.style.padding.right.coerceAtLeast(0.dp), + bottom = block.style.padding.bottom.coerceAtLeast(0.dp) + ) + + when (block) { + is WrappingContentBlock -> { + WrappingContentLayout( + block = block, + textStyle = textStyle, + imageSizeMultiplier = imageSizeMultiplier, + modifier = styledModifier, + searchQuery = searchQuery, + ttsHighlightInfo = ttsHighlightInfo, + searchHighlightColor = searchHighlightColor, + ttsHighlightColor = ttsHighlightColor, + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback + ) + } + is MathBlock -> { + RenderNativeMathBlock( + block = block, + textStyle = textStyle, + imageLoader = imageLoader, + modifier = styledModifier + ) + } + is FlexContainerBlock -> { + val renderChild: @Composable (ContentBlock) -> Unit = { child -> + NativeVerticalContentBlock( + block = child, + pageIndex = pageIndex, + textStyle = textStyle, + imageSizeMultiplier = imageSizeMultiplier, + searchQuery = searchQuery, + searchHighlightColor = searchHighlightColor, + ttsHighlightInfo = ttsHighlightInfo, + ttsHighlightColor = ttsHighlightColor, + textMeasurer = textMeasurer, + onLinkClickCallback = onLinkClickCallback, + onGeneralTapCallback = onGeneralTapCallback, + userHighlights = userHighlights, + activeSelection = activeSelection, + onSelectionChange = onSelectionChange, + onHighlightClick = onHighlightClick, + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, + blockLayoutMap = blockLayoutMap, + density = density, + imageLoader = imageLoader, + modifier = Modifier.fillMaxWidth() + ) + } + if (block.style.flexDirection == "row") { + Row(modifier = styledModifier.fillMaxWidth()) { + block.children.forEach { child -> + Box(modifier = Modifier.weight(1f, fill = false)) { + renderChild(child) + } + } + } + } else { + Column(modifier = styledModifier.fillMaxWidth()) { + block.children.forEach { child -> renderChild(child) } + } + } + } + else -> { + Box(modifier = styledModifier) { + RenderFlexChildBlock( + childBlock = block, + textStyle = textStyle, + imageSizeMultiplier = imageSizeMultiplier, + searchQuery = searchQuery, + searchHighlightColor = searchHighlightColor, + ttsHighlightInfo = ttsHighlightInfo, + ttsHighlightColor = ttsHighlightColor, + textMeasurer = textMeasurer, + onLinkClickCallback = onLinkClickCallback, + onGeneralTapCallback = onGeneralTapCallback, + userHighlights = userHighlights, + activeSelection = activeSelection, + onSelectionChange = onSelectionChange, + onHighlightClick = onHighlightClick, + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, + blockLayoutMap = blockLayoutMap, + density = density, + imageLoader = imageLoader, + pageIndex = pageIndex, + registerStableLayoutKey = true + ) + } + } + } +} + +@Composable +private fun RenderNativeMathBlock( + block: MathBlock, + textStyle: TextStyle, + imageLoader: ImageLoader, + modifier: Modifier = Modifier +) { + val svgContent = block.svgContent?.takeIf { it.isNotBlank() } + if (svgContent != null) { + val imageRequest = Builder(LocalContext.current) + .data(SvgData(svgContent)) + .listener( + onError = { _, result -> + Timber.e(result.throwable, "Coil failed to load SVG for native vertical MathBlock.") + } + ) + .build() + AsyncImage( + model = imageRequest, + contentDescription = block.altText ?: "Equation", + modifier = modifier + .fillMaxWidth() + .heightIn(min = 24.dp), + contentScale = ContentScale.Fit, + colorFilter = if (block.isFromMathJax) ColorFilter.tint(textStyle.color) else null, + imageLoader = imageLoader + ) + } else { + Text( + text = block.altText ?: "[Equation not available]", + style = textStyle, + modifier = modifier + ) + } +} + private fun parseEmphasisAnnotation(annotation: String, defaultColor: Color): TextEmphasis { Timber.d("Parsing annotation string: '$annotation'") val map = annotation.split(';').filter { it.isNotBlank() }.associate { @@ -1522,14 +4623,6 @@ private fun findFuzzyMatch(source: String, target: String, ignoreCase: Boolean = internal fun getHighlightOffsetsInBlock( block: TextContentBlock, highlight: UserHighlight ): IntRange? { - if (block.cfi == null) return null - - val blockPath = CfiUtils.getPath(block.cfi!!) - val parts = highlight.cfi.split('|') - val startCfi = parts.firstOrNull() ?: highlight.cfi - val endCfi = parts.lastOrNull() - val isMultipartHighlight = endCfi != null && endCfi != startCfi - @Suppress("REDUNDANT_ELSE_IN_WHEN") val blockStartAbs = when (block) { is ParagraphBlock -> block.startCharOffsetInSource is HeaderBlock -> block.startCharOffsetInSource @@ -1540,12 +4633,43 @@ internal fun getHighlightOffsetsInBlock( val blockEndAbs = block.endCharOffsetInSource .takeIf { it > blockStartAbs } ?: (blockStartAbs + block.content.text.length) + val blockText = block.content.text + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_start blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "blockAbs=$blockStartAbs..$blockEndAbs blockLen=${blockText.length} " + + "hasPreciseLocator=${highlight.locator.hasTextRange} " + + highlight.androidHighlightRenderLabel() + ) + + locatorHighlightOffsetsInBlock( + blockText = blockText, + blockStartAbs = blockStartAbs, + blockEndAbs = blockEndAbs, + blockIndex = block.blockIndex, + blockCfi = block.cfi, + highlight = highlight + )?.let { return it } + + if (block.cfi == null) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=missing_block_cfi blockIndex=${block.blockIndex} blockAbs=$blockStartAbs..$blockEndAbs " + + highlight.androidHighlightRenderLabel() + ) + return null + } + + val blockPath = CfiUtils.getPath(block.cfi!!) + val sourceCfi = highlight.locator.cfi?.takeIf { it.isNotBlank() } ?: highlight.cfi + val parts = sourceCfi.split('|') + val startCfi = parts.firstOrNull() ?: highlight.cfi + val endCfi = parts.lastOrNull() + val isMultipartHighlight = endCfi != null && endCfi != startCfi Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_check blockCfi=${block.cfi} blockPath=$blockPath " + "blockAbs=$blockStartAbs..$blockEndAbs blockLen=${block.content.text.length} " + "highlightId=${highlight.id} highlightChapter=${highlight.chapterIndex} " + - "highlightCfi=${highlight.cfi} startCfi=$startCfi endCfi=$endCfi " + + "highlightCfi=$sourceCfi startCfi=$startCfi endCfi=$endCfi " + "highlightTextLen=${highlight.text.length} highlightText='${highlightDiagSnippet(highlight.text)}'" ) @@ -1587,10 +4711,16 @@ internal fun getHighlightOffsetsInBlock( ) } - val blockText = block.content.text val highlightText = highlight.text - if (blockText.isEmpty() || highlightText.isEmpty()) return null + if (blockText.isEmpty() || highlightText.isEmpty()) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=empty_text blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "blockTextLen=${blockText.length} highlightTextLen=${highlightText.length} " + + highlight.androidHighlightRenderLabel() + ) + return null + } val isIntermediateBlock = relevantPart == null && isMultipartHighlight && @@ -1602,9 +4732,20 @@ internal fun getHighlightOffsetsInBlock( ) if (relevantPart == null) { - if (!isIntermediateBlock) return null + if (!isIntermediateBlock) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=no_relevant_cfi_part blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "startCfi=$startCfi endCfi=$endCfi " + + highlight.androidHighlightRenderLabel() + ) + return null + } if (highlightText.contains(blockText, ignoreCase = false)) { val range = 0 until blockText.length + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=intermediate_exact blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=intermediate_exact blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" @@ -1613,6 +4754,10 @@ internal fun getHighlightOffsetsInBlock( } if (highlightText.contains(blockText, ignoreCase = true)) { val range = 0 until blockText.length + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=intermediate_exact_ignore_case blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=intermediate_exact_ignore_case blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" @@ -1623,12 +4768,21 @@ internal fun getHighlightOffsetsInBlock( val normHighlight = highlightText.filter { !it.isWhitespace() } return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) { val range = 0 until blockText.length + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=intermediate_normalized blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=intermediate_normalized blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" ) range } else { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=intermediate_text_miss blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "blockText='${highlightDiagSnippet(blockText)}' " + + highlight.androidHighlightRenderLabel() + ) null } } @@ -1657,31 +4811,72 @@ internal fun getHighlightOffsetsInBlock( if (startMatches || endMatches) { val startAbs = CfiUtils.getOffsetOrNull(startCfi) val endAbs = endCfi?.let { CfiUtils.getOffsetOrNull(it) } + val startLocal = startAbs?.let { + cfiOffsetToBlockLocal( + offset = it, + blockStartAbs = blockStartAbs, + blockEndAbs = blockEndAbs, + textLength = blockText.length + ) + } + val endLocal = endAbs?.let { + cfiOffsetToBlockLocal( + offset = it, + blockStartAbs = blockStartAbs, + blockEndAbs = blockEndAbs, + textLength = blockText.length + ) + } Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_offset_inputs blockCfi=${block.cfi} highlightId=${highlight.id} " + - "blockAbs=$blockStartAbs..$blockEndAbs cfiOffsets=$startAbs..$endAbs" + "blockAbs=$blockStartAbs..$blockEndAbs cfiOffsets=$startAbs..$endAbs " + + "localOffsets=$startLocal..$endLocal" ) - if (startMatches && endMatches && startAbs != null && endAbs != null) { - val rangeStartAbs = minOf(startAbs, endAbs) - val rangeEndAbs = maxOf(startAbs, endAbs) - if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) { + if (startMatches && endMatches && startLocal != null && endLocal != null) { + val rangeStartLocal = minOf(startLocal, endLocal) + val rangeEndLocal = maxOf(startLocal, endLocal) + if (rangeEndLocal <= 0 || rangeStartLocal >= blockText.length) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=same_path_split_outside_offsets blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "highlightLocal=$rangeStartLocal..$rangeEndLocal blockLen=${blockText.length} " + + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_skip reason=same_path_split_outside_offsets blockCfi=${block.cfi} " + - "highlightId=${highlight.id} highlightAbs=$rangeStartAbs..$rangeEndAbs " + + "highlightId=${highlight.id} highlightLocal=$rangeStartLocal..$rangeEndLocal " + "blockAbs=$blockStartAbs..$blockEndAbs" ) return null } } else { - if (startMatches && startAbs != null && startAbs >= blockEndAbs) return null - if (endMatches && endAbs != null && endAbs <= blockStartAbs) return null + if (startMatches && startLocal != null && startLocal >= blockText.length) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=start_offset_after_block blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "startLocal=$startLocal blockLen=${blockText.length} " + + highlight.androidHighlightRenderLabel() + ) + return null + } + if (endMatches && endLocal != null && endLocal <= 0) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=end_offset_before_block blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "endLocal=$endLocal blockLen=${blockText.length} " + + highlight.androidHighlightRenderLabel() + ) + return null + } } var s = 0 var e = blockText.length if (startMatches) { - val absOffset = startAbs ?: CfiUtils.getOffset(startCfi) - val relOffset = absOffset - blockStartAbs + val rawOffset = startAbs ?: CfiUtils.getOffset(startCfi) + val relOffset = cfiOffsetToBlockLocal( + offset = rawOffset, + blockStartAbs = blockStartAbs, + blockEndAbs = blockEndAbs, + textLength = blockText.length + ) if (relOffset < 0) { s = 0 @@ -1725,12 +4920,17 @@ internal fun getHighlightOffsetsInBlock( } if (endMatches) { - val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!) - val relOffset = absOffset - blockStartAbs + val rawOffset = endAbs ?: CfiUtils.getOffset(endCfi!!) + val relOffset = cfiOffsetToBlockLocal( + offset = rawOffset, + blockStartAbs = blockStartAbs, + blockEndAbs = blockEndAbs, + textLength = blockText.length + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_end_match blockCfi=${block.cfi} highlightId=${highlight.id} " + - "absOffset=$absOffset relOffset=$relOffset blockLen=${blockText.length}" + "rawOffset=$rawOffset relOffset=$relOffset blockLen=${blockText.length}" ) e = if (relOffset > blockText.length) { @@ -1745,12 +4945,23 @@ internal fun getHighlightOffsetsInBlock( if (s < e) { val range = s until e + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=cfi_offsets blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range startMatches=$startMatches endMatches=$endMatches " + + "startAbs=$startAbs endAbs=$endAbs startLocal=$startLocal endLocal=$endLocal " + + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=cfi_offsets blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" ) return range } else { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=invalid_cfi_range blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$s..$e startMatches=$startMatches endMatches=$endMatches " + + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).w( "map_skip reason=invalid_range blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$s..$e" @@ -1762,6 +4973,10 @@ internal fun getHighlightOffsetsInBlock( if (highlightText.contains(blockText, ignoreCase = false)) { val range = 0 until blockText.length + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=block_inside_highlight_text blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=block_inside_highlight_text blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" @@ -1770,6 +4985,10 @@ internal fun getHighlightOffsetsInBlock( } if (highlightText.contains(blockText, ignoreCase = true)) { val range = 0 until blockText.length + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=block_inside_highlight_text_ignore_case blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=block_inside_highlight_text_ignore_case blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" @@ -1784,6 +5003,10 @@ internal fun getHighlightOffsetsInBlock( if (startIndex >= 0) { val range = startIndex until (startIndex + highlightText.length) + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=highlight_text_inside_block blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range startIndex=$startIndex " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=highlight_text_inside_block blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" @@ -1793,6 +5016,10 @@ internal fun getHighlightOffsetsInBlock( val match = findFuzzyMatch(blockText, highlightText) if (match != null) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=fuzzy_text blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$match " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=fuzzy_text blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$match" @@ -1801,15 +5028,157 @@ internal fun getHighlightOffsetsInBlock( } if (relevantPart != null) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=cfi_match_text_miss blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "relevantPart=$relevantPart " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_skip reason=cfi_match_text_miss blockCfi=${block.cfi} " + "highlightId=${highlight.id} highlightCfi=${highlight.cfi}" ) } + if (highlight.locator.hasTextRange) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=precise_locator_and_cfi_miss blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "sourceCfi=$sourceCfi " + highlight.androidHighlightRenderLabel() + ) + return null + } + + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=no_mapping_match blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + highlight.androidHighlightRenderLabel() + ) return null } +private fun androidHighlightSourceCfi(highlight: UserHighlight): String { + return highlight.locator.cfi?.takeIf { it.isNotBlank() } ?: highlight.cfi +} + +private fun androidCfiPathsEquivalent(first: String, second: String): Boolean { + val firstPath = CfiUtils.getPath(first) + val secondPath = CfiUtils.getPath(second) + if (firstPath == secondPath || firstPath.startsWith("$secondPath/") || secondPath.startsWith("$firstPath/")) { + return true + } + val firstParts = firstPath.split('/').filter { it.isNotEmpty() } + val secondParts = secondPath.split('/').filter { it.isNotEmpty() } + if (firstParts == secondParts) return true + return firstParts.size == secondParts.size && + firstParts.isNotEmpty() && + firstParts.drop(1) == secondParts.drop(1) +} + +private fun androidHighlightHasMultipartCfiRange(highlight: UserHighlight): Boolean { + val parts = androidHighlightSourceCfi(highlight) + .split('|') + .filter { it.startsWith("/") } + if (parts.size < 2) return false + val first = parts.first() + return parts.drop(1).any { !androidCfiPathsEquivalent(first, it) } +} + +private fun androidHighlightCfiTouchesBlock(highlight: UserHighlight, blockCfi: String?): Boolean { + val blockPath = blockCfi?.takeIf { it.startsWith("/") } ?: return false + return androidHighlightSourceCfi(highlight) + .split('|') + .filter { it.startsWith("/") } + .any { androidCfiPathsEquivalent(it, blockPath) } +} + +private fun cfiOffsetToBlockLocal( + offset: Int, + blockStartAbs: Int, + blockEndAbs: Int, + textLength: Int +): Int { + return when { + offset in 0..textLength -> offset + offset in blockStartAbs..blockEndAbs -> offset - blockStartAbs + else -> offset + } +} + +private fun locatorHighlightOffsetsInBlock( + blockText: String, + blockStartAbs: Int, + blockEndAbs: Int, + blockIndex: Int, + blockCfi: String?, + highlight: UserHighlight +): IntRange? { + if (blockText.isEmpty()) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_skip reason=empty_block_text blockAbs=$blockStartAbs..$blockEndAbs " + + highlight.androidHighlightRenderLabel() + ) + return null + } + if (androidHighlightHasMultipartCfiRange(highlight)) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_skip reason=multipart_cfi_uses_cfi_mapper blockIndex=$blockIndex blockCfi=$blockCfi " + + highlight.androidHighlightRenderLabel() + ) + return null + } + val locatorBlockIndex = highlight.locator.blockIndex + val blockMatchesLocator = locatorBlockIndex != null && locatorBlockIndex == blockIndex + val cfiMatchesBlock = androidHighlightCfiTouchesBlock(highlight, blockCfi) + val hasStructuralScope = locatorBlockIndex != null || androidHighlightSourceCfi(highlight).startsWith("/") + if (hasStructuralScope && !blockMatchesLocator && !cfiMatchesBlock) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_miss reason=structural_scope_miss blockIndex=$blockIndex blockCfi=$blockCfi " + + "blockMatchesLocator=$blockMatchesLocator cfiMatchesBlock=$cfiMatchesBlock " + + highlight.androidHighlightRenderLabel() + ) + return null + } + val start = highlight.locator.startOffset ?: run { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_skip reason=missing_start blockAbs=$blockStartAbs..$blockEndAbs " + + highlight.androidHighlightRenderLabel() + ) + return null + } + val end = highlight.locator.endOffset ?: run { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_skip reason=missing_end blockAbs=$blockStartAbs..$blockEndAbs " + + highlight.androidHighlightRenderLabel() + ) + return null + } + val rangeStartAbs = minOf(start, end) + val rangeEndAbs = maxOf(start, end) + if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_miss reason=no_intersection blockAbs=$blockStartAbs..$blockEndAbs " + + "highlightAbs=$rangeStartAbs..$rangeEndAbs " + + highlight.androidHighlightRenderLabel() + ) + return null + } + val localStart = (rangeStartAbs - blockStartAbs).coerceIn(0, blockText.length) + val localEnd = (rangeEndAbs - blockStartAbs).coerceIn(localStart, blockText.length) + return if (localStart < localEnd) { + val range = localStart until localEnd + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=locator_offsets blockAbs=$blockStartAbs..$blockEndAbs " + + "range=$range highlightAbs=$rangeStartAbs..$rangeEndAbs " + + highlight.androidHighlightRenderLabel() + ) + range + } else { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_miss reason=invalid_local_range blockAbs=$blockStartAbs..$blockEndAbs " + + "local=$localStart..$localEnd highlightAbs=$rangeStartAbs..$rangeEndAbs " + + highlight.androidHighlightRenderLabel() + ) + null + } +} + private fun List.extractTextBlocks(): List { val result = mutableListOf() for (block in this) { @@ -1830,6 +5199,186 @@ private fun List.extractTextBlocks(): List { return result } +private fun LayoutCoordinates.androidEpubPageContentBounds( + horizontalPaddingPx: Int, + verticalPaddingPx: Int +): AndroidEpubPageContentBounds { + val pageTopPx = positionInWindow().y.roundToInt() + val contentTopPx = pageTopPx + verticalPaddingPx + val contentBottomPx = pageTopPx + size.height - verticalPaddingPx + return AndroidEpubPageContentBounds( + topPx = contentTopPx, + bottomPx = contentBottomPx, + widthPx = (size.width - (horizontalPaddingPx * 2)).coerceAtLeast(0), + heightPx = (contentBottomPx - contentTopPx).coerceAtLeast(0), + pageWidthPx = size.width, + pageHeightPx = size.height, + horizontalPaddingPx = horizontalPaddingPx, + verticalPaddingPx = verticalPaddingPx + ) +} + +private fun logAndroidEpubCutoff(message: String) { + if (!BuildConfig.DEBUG) return + Log.d(AndroidEpubCutoffLogTag, message) +} + +private fun Modifier.androidEpubNaturalHeight(): Modifier = this.then( + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure( + constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity) + ) + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } +) + +private fun TextContentBlock.androidEpubSourceRangeLabel(): String { + val start = startCharOffsetInSource + val end = endCharOffsetInSource.takeIf { it > start } ?: (start + content.text.length) + return "$start..$end" +} + +private fun TextContentBlock.androidEpubKindName(): String { + return when (this) { + is HeaderBlock -> "header" + is ParagraphBlock -> "paragraph" + is QuoteBlock -> "quote" + is ListItemBlock -> "list_item" + else -> "text" + } +} + +private fun ContentBlock.androidEpubKindName(): String { + return when (this) { + is HeaderBlock -> "header" + is ParagraphBlock -> "paragraph" + is QuoteBlock -> "quote" + is ListItemBlock -> "list_item" + is TextContentBlock -> "text" + is ImageBlock -> "image" + is MathBlock -> "math" + is TableBlock -> "table" + is FlexContainerBlock -> "flex" + is WrappingContentBlock -> "wrapping" + is SpacerBlock -> "spacer" + } +} + +private fun logAndroidEpubBlockOverflowIfNeeded( + pageIndex: Int, + block: ContentBlock, + coordinates: LayoutCoordinates, + pageContentBounds: AndroidEpubPageContentBounds?, + diagnosticsContext: String, + signatureAlreadyLogged: (String) -> Boolean, + markSignatureLogged: (String) -> Unit +) { + val bounds = pageContentBounds ?: return + val blockTopPx = coordinates.positionInWindow().y.roundToInt() + val blockBottomPx = blockTopPx + coordinates.size.height + val contentOverflowPx = blockBottomPx - bounds.bottomPx + val pageClipOverflowPx = blockBottomPx - bounds.pageClipBottomPx + if (pageClipOverflowPx <= AndroidEpubCutoffTolerancePx) return + val relativeTopPx = blockTopPx - bounds.topPx + val signature = "block:$pageIndex:${block.blockIndex}:$relativeTopPx:${coordinates.size.height}:$pageClipOverflowPx" + if (signatureAlreadyLogged(signature)) return + markSignatureLogged(signature) + logAndroidEpubCutoff( + "cutoff_probe layer=android_rendered_block_overflow page=${pageIndex + 1} " + + "block=${block.blockIndex} kind=${block.androidEpubKindName()} " + + "blockTopPx=$relativeTopPx blockHeightPx=${coordinates.size.height} " + + "blockBottomPx=${blockBottomPx - bounds.topPx} contentPx=${bounds.widthPx}x${bounds.heightPx} " + + "pagePx=${bounds.pageWidthPx}x${bounds.pageHeightPx} contentOverflowPx=$contentOverflowPx " + + "pageClipOverflowPx=$pageClipOverflowPx " + + "expectedHeightPx=${block.expectedHeight} actualHeightPx=${coordinates.size.height} " + + "paddingPx=${bounds.horizontalPaddingPx}x${bounds.verticalPaddingPx} $diagnosticsContext" + ) +} + +private fun logAndroidEpubTextCutoffIfNeeded( + pageIndex: Int, + block: TextContentBlock, + layout: TextLayoutResult, + coordinates: LayoutCoordinates, + pageContentBounds: AndroidEpubPageContentBounds?, + diagnosticsContext: String, + previousSignature: String? +): String? { + val boxTopPx = coordinates.positionInWindow().y.roundToInt() + val boxHeightPx = coordinates.size.height + val lastLine = layout.lineCount - 1 + val lastLineTopPx = if (lastLine >= 0) layout.getLineTop(lastLine).roundToInt() else 0 + val lastLineBottomPx = if (lastLine >= 0) layout.getLineBottom(lastLine).roundToInt() else layout.size.height + val lastLineStart = if (lastLine >= 0) layout.getLineStart(lastLine) else 0 + val lastLineEnd = if (lastLine >= 0) layout.getLineEnd(lastLine, visibleEnd = true) else 0 + val overflowBottomInBoxPx = maxOf(layout.size.height, lastLineBottomPx) + val boxClipPx = overflowBottomInBoxPx - boxHeightPx + val bounds = pageContentBounds + val lineBottomInPagePx = if (bounds != null) { + boxTopPx + overflowBottomInBoxPx - bounds.topPx + } else { + overflowBottomInBoxPx + } + val contentOverflowPx = bounds?.let { boxTopPx + overflowBottomInBoxPx - it.bottomPx } ?: 0 + val pageClipOverflowPx = bounds?.let { boxTopPx + overflowBottomInBoxPx - it.pageClipBottomPx } ?: 0 + val contentBottomInsetPx = bounds?.let { it.bottomPx - (boxTopPx + overflowBottomInBoxPx) } + val pageClipBottomInsetPx = bounds?.let { it.pageClipBottomPx - (boxTopPx + overflowBottomInBoxPx) } + val bottomEdgeRisk = pageClipBottomInsetPx != null && pageClipBottomInsetPx in 0..AndroidEpubCutoffEdgeProbePx + if ( + boxClipPx <= AndroidEpubCutoffTolerancePx && + pageClipOverflowPx <= AndroidEpubCutoffTolerancePx && + !bottomEdgeRisk + ) { + return previousSignature + } + + val signature = buildString { + append(pageIndex) + append(':') + append(block.blockIndex) + append(':') + append(coordinates.size.width) + append('x') + append(boxHeightPx) + append(':') + append(layout.size.width) + append('x') + append(layout.size.height) + append(':') + append(lastLineBottomPx) + append(':') + append(bounds?.pageClipBottomPx ?: -1) + } + if (signature == previousSignature) return previousSignature + + val layer = if (boxClipPx > AndroidEpubCutoffTolerancePx) { + "android_text_clip" + } else if (pageClipOverflowPx > AndroidEpubCutoffTolerancePx) { + "android_text_page_overflow" + } else if (bottomEdgeRisk) { + "android_text_bottom_edge" + } else { + "android_text_page_overflow" + } + logAndroidEpubCutoff( + "cutoff_probe layer=$layer page=${pageIndex + 1} block=${block.blockIndex} " + + "kind=${block.androidEpubKindName()} boxPx=${coordinates.size.width}x$boxHeightPx " + + "layoutPx=${layout.size.width}x${layout.size.height} lines=${layout.lineCount} " + + "lastLine=$lastLine lastLineTopPx=$lastLineTopPx lastLineBottomPx=$lastLineBottomPx " + + "lastLineBottomInPagePx=$lineBottomInPagePx boxClipPx=$boxClipPx " + + "contentOverflowPx=$contentOverflowPx pageClipOverflowPx=$pageClipOverflowPx " + + "contentBottomInsetPx=${contentBottomInsetPx ?: "unknown"} " + + "pageClipBottomInsetPx=${pageClipBottomInsetPx ?: "unknown"} " + + "contentPx=${bounds?.let { "${it.widthPx}x${it.heightPx}" } ?: "unknown"} " + + "pagePx=${bounds?.let { "${it.pageWidthPx}x${it.pageHeightPx}" } ?: "unknown"} " + + "lineOffsets=$lastLineStart..$lastLineEnd sourceRange=${block.androidEpubSourceRangeLabel()} " + + "textChars=${block.content.text.length} expectedHeightPx=${block.expectedHeight} $diagnosticsContext" + ) + return signature +} + @Composable private fun TextWithEmphasis( text: AnnotatedString, @@ -1844,15 +5393,31 @@ private fun TextWithEmphasis( activeSelection: PaginatedSelection?, @Suppress("unused") onSelectionChange: (PaginatedSelection?) -> Unit, onHighlightClick: (UserHighlight, Rect) -> Unit, - @Suppress("unused") isDarkTheme: Boolean, + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color, + pageContentBoundsProvider: (() -> AndroidEpubPageContentBounds?)? = null, + cutoffDiagnosticsEnabled: Boolean = true, + cutoffDiagnosticsContext: String = "", onRegisterLayout: ((TextLayoutResult, LayoutCoordinates) -> Unit)? = null ) { var textLayoutResult by remember { mutableStateOf(null) } + var lastCutoffLogSignature by remember { mutableStateOf(null) } val viewConfiguration = LocalViewConfiguration.current var layoutCoordinates by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() var pressedHighlightCfi by remember { mutableStateOf(null) } val density = LocalDensity.current + val latestTextLayoutResult = rememberUpdatedState(textLayoutResult) + val latestOnLinkClick = rememberUpdatedState(onLinkClick) + val latestOnGeneralTap = rememberUpdatedState(onGeneralTap) + val displayText = remember(text, isDarkTheme, themeBackgroundColor, themeTextColor, style.color) { + text.withReaderLinkDisplayStyle( + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = style.color.takeIf { it.isSpecified } ?: themeTextColor + ) + } data class EmphasisMarkInfo(val center: Offset, val radius: Float, val color: Color) data class UnderlineDrawInfo(val path: Path?, val effect: PathEffect?, val minX: Float, val maxX: Float, val y: Float, val decoStyle: String, val decoColor: Color) @@ -1862,7 +5427,7 @@ private fun TextWithEmphasis( val startTime = System.currentTimeMillis() val paths = mutableListOf>() val layout = textLayoutResult - if (layout != null && block.cfi != null && userHighlights.isNotEmpty()) { + if (layout != null && userHighlights.isNotEmpty()) { userHighlights.forEach { highlight -> val range = getHighlightOffsetsInBlock(block, highlight) if (range != null) { @@ -1878,6 +5443,12 @@ private fun TextWithEmphasis( "highlightCfi=${highlight.cfi} range=$range " + "blockText='${highlightDiagSnippet(block.content.text)}'" ) + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "draw_highlight surface=native_or_paginated page=$pageIndex blockIndex=${block.blockIndex} " + + "blockCfi=${block.cfi} blockAbs=$blockStartAbs..$blockEndAbs range=$range " + + "blockText='${highlightDiagSnippet(block.content.text)}' " + + highlight.androidHighlightRenderLabel() + ) val path = layout.getPathForRange(range.first, range.last + 1) paths.add(path to highlight.color.color.copy(alpha = 0.4f)) if (highlight.cfi == pressedHighlightCfi) { @@ -2176,18 +5747,56 @@ private fun TextWithEmphasis( return null } - Text(text = text, style = style, modifier = modifier + fun logCutoffIfNeeded( + layout: TextLayoutResult?, + coordinates: LayoutCoordinates?, + pageContentBounds: AndroidEpubPageContentBounds? = pageContentBoundsProvider?.invoke() + ) { + if (!cutoffDiagnosticsEnabled) return + if (layout == null || coordinates == null || !coordinates.isAttached) return + lastCutoffLogSignature = logAndroidEpubTextCutoffIfNeeded( + pageIndex = pageIndex, + block = block, + layout = layout, + coordinates = coordinates, + pageContentBounds = pageContentBounds, + diagnosticsContext = cutoffDiagnosticsContext, + previousSignature = lastCutoffLogSignature + ) + } + + val currentPageContentBounds = pageContentBoundsProvider?.invoke() + LaunchedEffect(textLayoutResult, layoutCoordinates, currentPageContentBounds) { + logCutoffIfNeeded(textLayoutResult, layoutCoordinates, currentPageContentBounds) + } + + Text(text = displayText, style = style, modifier = modifier .onGloballyPositioned { layoutCoordinates = it + logCutoffIfNeeded(textLayoutResult, it) if (textLayoutResult != null && block.cfi != null) { onRegisterLayout?.invoke(textLayoutResult!!, it) } } .then(customDrawer) - .pointerInput(userHighlights, text) { + .pointerInput(displayText, viewConfiguration.touchSlop) { + awaitEachGesture { + awaitReaderLinkTap( + source = "TextWithEmphasis:block=${block.blockIndex}", + urlAtPosition = { offset -> + latestTextLayoutResult.value?.let { layout -> + displayText.readerUrlAnnotationAtPosition(layout, offset) + } + }, + touchSlop = viewConfiguration.touchSlop, + onLinkClick = { latestOnLinkClick.value(it) } + ) + } + } + .pointerInput(userHighlights, displayText) { detectTapGestures( onLongPress = { offset -> - textLayoutResult?.let { layout -> + latestTextLayoutResult.value?.let { layout -> val charOffset = layout.getOffsetForPosition(offset) val wordBoundary = layout.getWordBoundary(charOffset) @@ -2246,7 +5855,7 @@ private fun TextWithEmphasis( } }, onTap = { offset -> - textLayoutResult?.let { layout -> + latestTextLayoutResult.value?.let { layout -> val hit = getHighlightAt(offset, layout) if (hit != null) { val (highlight, localRect) = hit @@ -2262,17 +5871,32 @@ private fun TextWithEmphasis( } val charOffset = layout.getOffsetForPosition(offset) - val urlAnnotation = text.getStringAnnotations("URL", charOffset, charOffset).firstOrNull() - if (urlAnnotation != null) onLinkClick(urlAnnotation.item) - else onGeneralTap(offset) + val url = displayText.readerUrlAnnotationAtPosition(layout, offset) + if (url != null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "detect_tap_link source=TextWithEmphasis:block=${block.blockIndex} " + + "page=$pageIndex charOffset=$charOffset href=${url.readerLinkDiagPreview()}" + ) + latestOnLinkClick.value(url) + } else { + latestOnGeneralTap.value(offset) + } } } ) }, onTextLayout = { textLayoutResult = it + if (displayText.getStringAnnotations("URL", 0, displayText.length).isNotEmpty()) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "layout_text source=TextWithEmphasis page=$pageIndex block=${block.blockIndex} " + + "size=${it.size.width}x${it.size.height} lines=${it.lineCount} " + + displayText.readerAnnotatedLinkDiagSummary() + ) + } if (layoutCoordinates != null && block.cfi != null) { onRegisterLayout?.invoke(it, layoutCoordinates!!) } + logCutoffIfNeeded(it, layoutCoordinates) }) } @@ -2308,7 +5932,7 @@ private fun checkLayoutMismatch( } @Suppress("unused") -@SuppressLint("UnusedBoxWithConstraintsScope") +@SuppressLint("UnusedBoxWithConstraintsScope", "BinaryOperationInTimber") @OptIn(ExperimentalFoundationApi::class) @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) @Composable @@ -2329,7 +5953,7 @@ internal fun PaginatedReaderContent( onGetChapterIndex: (Int) -> Int?, onGetChapterPath: (Int) -> String?, onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit, - onInternalLinkNavigated: (Int) -> Unit, + onInternalLinkNavigated: (Int, Locator?) -> Unit, onTap: (Offset?) -> Unit, isProUser: Boolean, isOss: Boolean, @@ -2341,7 +5965,7 @@ internal fun PaginatedReaderContent( onNoteRequested: (String?) -> Unit, onGetChapterInfo: (Int) -> Pair?, userHighlights: List, - onHighlightCreated: (String, String, String) -> Unit, + onHighlightCreated: (String, String, String, SharedReaderLocator) -> Unit, onHighlightDeleted: (String) -> Unit, activeHighlightPalette: List, onUpdatePalette: (Int, HighlightColor) -> Unit, @@ -2352,6 +5976,7 @@ internal fun PaginatedReaderContent( ) { val coroutineScope = rememberCoroutineScope() val density = LocalDensity.current + val pageViewConfiguration = LocalViewConfiguration.current var showExternalLinkDialog by remember { mutableStateOf(null) } val context = LocalContext.current val imageLoader = context.imageLoader @@ -2372,10 +5997,14 @@ internal fun PaginatedReaderContent( Row(horizontalArrangement = Arrangement.End) { 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)) } TextButton( @@ -2498,6 +6127,7 @@ internal fun PaginatedReaderContent( var pageContent by remember { mutableStateOf(null) } var currentChapterPath by remember { mutableStateOf(null) } + var pageLayoutCoordinates by remember { mutableStateOf(null) } val pageChapterIndex = onGetChapterIndex(pageIndex) val pageUserHighlights = highlightsForPaginatedPage( pageChapterIndex = pageChapterIndex, @@ -2521,19 +6151,28 @@ internal fun PaginatedReaderContent( } LaunchedEffect(pageIndex, uiState.generation) { - val fetchStartTime = System.currentTimeMillis() - Timber.tag("PageTurnDiag").d("Page $pageIndex: Starting content fetch") + if (DEBUG_PAGE_TURN_DIAG) { + Timber.tag("PageTurnDiag").d("Page $pageIndex: Starting content fetch") + } + val fetchStartTime = if (DEBUG_PAGE_TURN_DIAG) System.currentTimeMillis() else 0L pageContent = onGetPage(pageIndex) - val fetchDuration = System.currentTimeMillis() - fetchStartTime - Timber.tag("PageTurnDiag").d("Page $pageIndex: Content fetched in ${fetchDuration}ms") + if (DEBUG_PAGE_TURN_DIAG) { + val fetchDuration = System.currentTimeMillis() - fetchStartTime + Timber.tag("PageTurnDiag").d("Page $pageIndex: Content fetched in ${fetchDuration}ms") + } onGetChapterPath(pageIndex)?.let { currentChapterPath = it } } - SideEffect { - Timber.tag("PageTurnDiag").v("Page $pageIndex: Re-composing content area") + LaunchedEffect(pageIndex, pageChapterIndex, currentChapterPath, themedPageContent) { + val page = themedPageContent ?: return@LaunchedEffect + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_render page=$pageIndex chapter=$pageChapterIndex " + + "chapterPath=${currentChapterPath.orEmpty().readerLinkDiagPreview()} " + + page.readerPageLinkDiagSummary() + ) } val textBlocksOnPage = @@ -2667,45 +6306,123 @@ internal fun PaginatedReaderContent( pendingCrossPageSelection = null } - Box(modifier = Modifier.fillMaxSize().background(effectiveBg).then(pageTextureModifier).then(pageModifier)) { + val onGeneralTapCallback: (Offset) -> Unit = { offset -> + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_general_tap source=content page=$pageIndex x=${offset.x.roundToInt()} y=${offset.y.roundToInt()}" + ) + activeSelection = null + onTap(offset) + } + val onLinkClickCallback: (String) -> Unit = { href -> + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "link_click_callback page=$pageIndex currentPagerPage=${pagerState.currentPage} " + + "chapterPath=${currentChapterPath.orEmpty().readerLinkDiagPreview()} " + + "href=${href.readerLinkDiagPreview()}" + ) + if (href.isReaderExternalHref()) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "external_link_dialog href=${href.readerLinkDiagPreview()}" + ) + showExternalLinkDialog = href.readerExternalHrefForDisplay() + } else { + val path = currentChapterPath + if (path == null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).w( + "internal_link_dropped reason=missing_current_chapter_path href=${href.readerLinkDiagPreview()}" + ) + } else { + onLinkClick(path, href) { targetPageIndex -> + onInternalLinkNavigated(targetPageIndex, null) + coroutineScope.launch { + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "link_scroll targetPage=$targetPageIndex currentPage=${pagerState.currentPage}" + ) + pagerState.scrollToPage(targetPageIndex) + } + } + } + } + } + val latestPageLayoutCoordinates = rememberUpdatedState(pageLayoutCoordinates) + val latestOnLinkClickCallback = rememberUpdatedState(onLinkClickCallback) + val pageHorizontalPaddingPx = with(density) { horizontalPadding.roundToPx() } + val pageVerticalPaddingPx = with(density) { verticalPadding.roundToPx() } + val pageContentBoundsProvider = { + pageLayoutCoordinates + ?.takeIf { it.isAttached } + ?.androidEpubPageContentBounds( + horizontalPaddingPx = pageHorizontalPaddingPx, + verticalPaddingPx = pageVerticalPaddingPx + ) + } + val cutoffLogSignatures = remember(pageIndex, uiState.generation) { + mutableStateMapOf() + } + val cutoffDiagnosticsEnabled = !uiState.isLoading + val cutoffDiagnosticsContext = + "generation=${uiState.generation} loading=${uiState.isLoading} pageCount=${uiState.totalPageCount}" + + Box( + modifier = Modifier + .fillMaxSize() + .background(effectiveBg) + .then(pageTextureModifier) + .then(pageModifier) + .onGloballyPositioned { pageLayoutCoordinates = it } + .pointerInput(pageIndex, pageViewConfiguration.touchSlop) { + awaitEachGesture { + awaitReaderLinkTap( + source = "PageLinkInterceptor:page=$pageIndex", + urlAtPosition = { offset -> + val hit = latestPageLayoutCoordinates.value + ?.takeIf { it.isAttached } + ?.let { coordinates -> + blockLayoutMap.readerLinkAtPagePosition( + pageCoordinates = coordinates, + pageIndex = pageIndex, + position = offset + ) + } + if (hit != null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_link_interceptor_hit page=$pageIndex block=${hit.blockIndex} " + + "cfi=${hit.cfi.orEmpty().readerLinkDiagPreview()} " + + "href=${hit.href.readerLinkDiagPreview()}" + ) + } + hit?.href + }, + touchSlop = pageViewConfiguration.touchSlop, + onLinkClick = { latestOnLinkClickCallback.value(it) } + ) + } + } + ) { Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) { detectTapGestures( onTap = { offset -> - Timber.d("Tap detected on empty page area.") + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_general_tap source=background page=$pageIndex " + + "x=${offset.x.roundToInt()} y=${offset.y.roundToInt()}" + ) activeSelection = null onTap(offset) }) - }.padding( + }) + Box(modifier = Modifier.fillMaxSize().padding( horizontal = horizontalPadding, vertical = verticalPadding ), contentAlignment = Alignment.TopStart) { if (themedPageContent != null) { val displayPage = themedPageContent - val onGeneralTapCallback: (Offset) -> Unit = { offset -> - activeSelection = null - onTap(offset) - } - val onLinkClickCallback: (String) -> Unit = { href -> - Timber.d("Link clicked: $href") - if (href.startsWith("http://") || href.startsWith("https://")) { - showExternalLinkDialog = href - } else { - currentChapterPath?.let { path -> - onLinkClick(path, href) { targetPageIndex -> - onInternalLinkNavigated(targetPageIndex) - coroutineScope.launch { - Timber.tag(TAG_STABLE_PAGE_NAV).d( - "link_scroll targetPage=$targetPageIndex currentPage=${pagerState.currentPage}" - ) - pagerState.scrollToPage(targetPageIndex) - } - } - } - } - } - Column(modifier = Modifier.fillMaxSize()) { + // Measure page blocks at their natural height; pagination, not Column, owns page breaks. + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(unbounded = true) + ) { val searchHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) val ttsHighlightColor = @@ -2738,7 +6455,12 @@ internal fun PaginatedReaderContent( Modifier.width(block.style.width) } else { Modifier.fillMaxWidth() - } + }.then( + Modifier.widthIn( + min = block.style.minWidth.takeIf { it.isSpecified && it > 0.dp } ?: Dp.Unspecified, + max = block.style.maxWidth.takeIf { it.isSpecified && it > 0.dp } ?: Dp.Unspecified + ) + ) val styleModifier = alignModifier.then(if (block.style.horizontalAlign == "center") widthModifier else Modifier) @@ -2746,11 +6468,27 @@ internal fun PaginatedReaderContent( blockStyle = block.style, density = density ) + .then(if (block.style.visibility == "hidden") Modifier.graphicsLayer(alpha = 0f) else Modifier) val diagnosticModifier = Modifier.onGloballyPositioned { coordinates -> val actualHeight = coordinates.size.height + if (cutoffDiagnosticsEnabled) { + logAndroidEpubBlockOverflowIfNeeded( + pageIndex = pageIndex, + block = block, + coordinates = coordinates, + pageContentBounds = pageContentBoundsProvider(), + diagnosticsContext = cutoffDiagnosticsContext, + signatureAlreadyLogged = { signature -> + cutoffLogSignatures[signature] == true + }, + markSignatureLogged = { signature -> + cutoffLogSignatures[signature] = true + } + ) + } if (block.expectedHeight > 0) { val snippet = when (block) { is ParagraphBlock -> block.content.text.take( @@ -2861,7 +6599,7 @@ internal fun PaginatedReaderContent( } }.then(marginModifier).then(styleModifier) - Box(modifier = diagnosticModifier) { + Box(modifier = diagnosticModifier.androidEpubNaturalHeight()) { val paddingModifier = Modifier.padding( start = block.style.padding.left.coerceAtLeast( 0.dp @@ -2881,6 +6619,19 @@ internal fun PaginatedReaderContent( if (block.style.horizontalAlign != "center") widthModifier else Modifier.fillMaxWidth() ) + block.style.backgroundImage + ?.trim() + ?.takeIf { it.isNotBlank() && !it.contains("gradient(", ignoreCase = true) } + ?.let { backgroundImagePath -> + val backgroundFile = remember(backgroundImagePath) { File(backgroundImagePath) } + AsyncImage( + model = if (backgroundFile.exists()) backgroundFile else backgroundImagePath, + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = imageContentScale(block.style) + ) + } + @Suppress("DEPRECATION") when (block) { is ParagraphBlock -> { val paragraphStyle = textStyle.copy( @@ -2985,6 +6736,11 @@ internal fun PaginatedReaderContent( activeSelection = null }, isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + pageContentBoundsProvider = pageContentBoundsProvider, + cutoffDiagnosticsEnabled = cutoffDiagnosticsEnabled, + cutoffDiagnosticsContext = cutoffDiagnosticsContext, onRegisterLayout = { layout, coords -> if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] = Triple( @@ -3070,6 +6826,11 @@ internal fun PaginatedReaderContent( activeSelection = null }, isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + pageContentBoundsProvider = pageContentBoundsProvider, + cutoffDiagnosticsEnabled = cutoffDiagnosticsEnabled, + cutoffDiagnosticsContext = cutoffDiagnosticsContext, onRegisterLayout = { layout, coords -> if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] = Triple( @@ -3154,6 +6915,11 @@ internal fun PaginatedReaderContent( activeSelection = null }, isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + pageContentBoundsProvider = pageContentBoundsProvider, + cutoffDiagnosticsEnabled = cutoffDiagnosticsEnabled, + cutoffDiagnosticsContext = cutoffDiagnosticsContext, onRegisterLayout = { layout, coords -> if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] = Triple( @@ -3271,6 +7037,11 @@ internal fun PaginatedReaderContent( activeSelection = null }, isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + pageContentBoundsProvider = pageContentBoundsProvider, + cutoffDiagnosticsEnabled = cutoffDiagnosticsEnabled, + cutoffDiagnosticsContext = cutoffDiagnosticsContext, onRegisterLayout = { layout, coords -> if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] = Triple( @@ -3291,7 +7062,12 @@ internal fun PaginatedReaderContent( searchQuery = searchQuery, ttsHighlightInfo = ttsHighlightInfo, searchHighlightColor = searchHighlightColor, - ttsHighlightColor = ttsHighlightColor + ttsHighlightColor = ttsHighlightColor, + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback ) } @@ -3343,6 +7119,8 @@ internal fun PaginatedReaderContent( null }, isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, blockLayoutMap = blockLayoutMap, density = density, imageLoader = imageLoader, @@ -3396,6 +7174,8 @@ internal fun PaginatedReaderContent( null }, isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, blockLayoutMap = blockLayoutMap, density = density, imageLoader = imageLoader, @@ -3619,16 +7399,13 @@ internal fun PaginatedReaderContent( Modifier } ) - .onGloballyPositioned { coords -> - Timber.tag("IMAGE_DIAG").v("Actual Rendered Height for [#${block.blockIndex}]: ${coords.size.height}px") - } AsyncImage( model = imageRequest, contentDescription = block.altText ?: "Image from EPUB", modifier = finalImageModifier, - contentScale = ContentScale.Fit, + contentScale = imageContentScale(style), colorFilter = colorFilter ) } @@ -3731,20 +7508,30 @@ internal fun PaginatedReaderContent( cell.content.forEach { blockInCell -> when (blockInCell) { is ParagraphBlock -> { - Text( + LinkAwareText( text = blockInCell.content, style = cellTextStyle, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback ) } is HeaderBlock -> { - Text( + LinkAwareText( text = blockInCell.content, style = cellTextStyle.copy( fontWeight = FontWeight.Bold ), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback ) } @@ -3762,12 +7549,17 @@ internal fun PaginatedReaderContent( ) ) } - Text( + LinkAwareText( text = blockInCell.content, style = cellTextStyle, modifier = Modifier.weight( 1f - ) + ), + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback ) } } @@ -3796,7 +7588,7 @@ internal fun PaginatedReaderContent( ) .build(), contentDescription = blockInCell.altText, - contentScale = ContentScale.Fit, + contentScale = imageContentScale(blockInCell.style), modifier = tableCellImageModifier( block = blockInCell, density = density, @@ -3806,10 +7598,15 @@ internal fun PaginatedReaderContent( } is TextContentBlock -> { - Text( + LinkAwareText( text = blockInCell.content, style = cellTextStyle, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback ) } @@ -3933,10 +7730,14 @@ internal fun PaginatedReaderContent( ) { PaginatedTextSelectionMenu( onCopy = { - val clipboardManager = - context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), sel.text) - clipboardManager.setPrimaryClip(clip) + val copied = copyPlainTextToClipboard( + context = context, + label = context.getString(R.string.clip_label_copied_text), + text = sel.text + ) + if (!copied) { + Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show() + } activeSelection = null }, onSelectAll = null, @@ -3963,6 +7764,10 @@ internal fun PaginatedReaderContent( "${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}" val absoluteCandidateCfi = "${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset" + val locator = sel.toSharedHighlightLocator( + chapterIndex = onGetChapterIndex(sel.startPageIndex), + cfi = finalCfi + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "create_request source=highlight_menu color=${color.id} " + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + @@ -3974,7 +7779,18 @@ internal fun PaginatedReaderContent( "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + "textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" ) - onHighlightCreated(finalCfi, sel.text, color.id) + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "create_request surface=paginated action=highlight color=${color.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "startBaseCfi=${sel.startBaseCfi} endBaseCfi=${sel.endBaseCfi} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "locator=${locator} textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) + onHighlightCreated(finalCfi, sel.text, color.id, locator) activeSelection = null }, onNote = { @@ -3985,6 +7801,10 @@ internal fun PaginatedReaderContent( "${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}" val absoluteCandidateCfi = "${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset" + val locator = sel.toSharedHighlightLocator( + chapterIndex = onGetChapterIndex(sel.startPageIndex), + cfi = finalCfi + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "create_request source=note_menu color=${HighlightColor.YELLOW.id} " + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + @@ -3996,7 +7816,18 @@ internal fun PaginatedReaderContent( "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + "textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" ) - onHighlightCreated(finalCfi, sel.text, HighlightColor.YELLOW.id) + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "create_request surface=paginated action=note color=${HighlightColor.YELLOW.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "startBaseCfi=${sel.startBaseCfi} endBaseCfi=${sel.endBaseCfi} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "locator=${locator} textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) + onHighlightCreated(finalCfi, sel.text, HighlightColor.YELLOW.id, locator) activeSelection = null }, onTts = { @@ -4438,10 +8269,13 @@ private fun RenderFlexChildBlock( onSelectionChange: (PaginatedSelection?) -> Unit, onHighlightClick: (UserHighlight, Rect) -> Unit, isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color, blockLayoutMap: MutableMap>, density: Density, imageLoader: ImageLoader, - pageIndex: Int + pageIndex: Int, + registerStableLayoutKey: Boolean = false ) { @Composable fun renderTextBlock(block: TextContentBlock) { @@ -4472,15 +8306,15 @@ private fun RenderFlexChildBlock( searchHighlighted } - // Apply block specific styles (like header font weight) - val finalStyle = if (block is HeaderBlock) { - createHeaderTextStyle( + val finalStyle = when (block) { + is HeaderBlock -> createHeaderTextStyle( baseStyle = textStyle, level = block.level, textAlign = block.textAlign ) - } else { - textStyle + is ParagraphBlock -> textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign) + is QuoteBlock -> textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign) + is ListItemBlock -> textStyle } TextWithEmphasis( @@ -4497,9 +8331,16 @@ private fun RenderFlexChildBlock( onSelectionChange = onSelectionChange, onHighlightClick = onHighlightClick, isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, onRegisterLayout = { layout, coords -> block.cfi?.let { cfi -> - blockLayoutMap["${cfi}_$pageIndex"] = Triple(layout, coords, block) + val key = if (registerStableLayoutKey) { + textBlockLayoutKey(cfi, pageIndex, block) + } else { + legacyTextBlockLayoutKey(cfi, pageIndex) + } + blockLayoutMap[key] = Triple(layout, coords, block) } }) } @@ -4515,7 +8356,7 @@ private fun RenderFlexChildBlock( if (itemMarkerImage != null) { val imageRequest = - Builder(LocalContext.current).data(File(itemMarkerImage)) + Builder(LocalContext.current).data(nativeVerticalImageModelData(itemMarkerImage)) .crossfade(true).build() val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() } @@ -4585,7 +8426,7 @@ private fun RenderFlexChildBlock( } else if (style.width != Dp.Unspecified && style.width > 0.dp) { Modifier.width(style.width) } else { - Modifier + Modifier.fillMaxWidth() } ) .then( @@ -4608,11 +8449,11 @@ private fun RenderFlexChildBlock( ) AsyncImage( - model = Builder(LocalContext.current).data(File(childBlock.path)).crossfade(true) + model = Builder(LocalContext.current).data(nativeVerticalImageModelData(childBlock.path)).crossfade(true) .build(), contentDescription = childBlock.altText, modifier = imageModifier, - contentScale = ContentScale.Fit, + contentScale = imageContentScale(childBlock.style), colorFilter = colorFilter, imageLoader = imageLoader ) @@ -4683,20 +8524,23 @@ private fun RenderFlexChildBlock( else textStyle cell.content.forEach { blockInCell -> if (blockInCell is TextContentBlock) { - Text( + LinkAwareText( text = blockInCell.content, style = cellTextStyle, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback ) } else if (blockInCell is ImageBlock) { AsyncImage( model = Builder(LocalContext.current).data( - File( - blockInCell.path - ) + nativeVerticalImageModelData(blockInCell.path) ).build(), contentDescription = blockInCell.altText, - contentScale = ContentScale.Fit, + contentScale = imageContentScale(blockInCell.style), modifier = tableCellImageModifier( block = blockInCell, density = density, diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderViewModel.kt similarity index 95% rename from app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderViewModel.kt index 7e9c340..e38ff3b 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderViewModel.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import android.content.Context import android.os.Build @@ -31,8 +31,8 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.aryan.reader.epub.EpubBook -import com.aryan.reader.paginatedreader.data.BookCacheDatabase +import org.dueattendant149.bookreader.epub.EpubBook +import org.dueattendant149.bookreader.paginatedreader.data.BookCacheDatabase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -58,6 +58,7 @@ class PaginatedReaderViewModel : ViewModel() { @VisibleForTesting internal fun setPaginatorForTest(testPaginator: IPaginator) { + paginator?.dispose() paginator = testPaginator observePaginatorState() } @@ -177,4 +178,9 @@ class PaginatedReaderViewModel : ViewModel() { fun onLinkClick(currentChapterPath: String, href: String, onNavigationComplete: (Int) -> Unit) { paginator?.navigateToHref(currentChapterPath, href, onNavigationComplete) } + + override fun onCleared() { + paginator?.dispose() + super.onCleared() + } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReconfiguration.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReconfiguration.kt similarity index 75% rename from app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReconfiguration.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReconfiguration.kt index fe7fa7e..9a4d550 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReconfiguration.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReconfiguration.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader internal fun resolvePaginatedReconfigurationAnchor( currentPageLocator: Locator?, diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/Paginator.kt similarity index 68% rename from app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/Paginator.kt index 078c90b..aa8302a 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/Paginator.kt @@ -17,15 +17,20 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import android.os.Build +import android.util.Log +import org.dueattendant149.bookreader.BuildConfig import timber.log.Timber import androidx.annotation.RequiresApi +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density @@ -34,11 +39,66 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap +import kotlin.math.ceil import kotlin.math.roundToInt +import kotlin.coroutines.coroutineContext private const val DEBUG_PAGINATION_LOGS = false +private const val AndroidEpubCutoffLogTag = "EpistemeEpubCutoff" +private const val JustifiedSplitGapProbeMinFraction = 0.18f + +internal fun measuredTextHeightForPagination( + layoutHeightPx: Int, + lastLineBottomPx: Float +): Int { + return maxOf(layoutHeightPx, ceil(lastLineBottomPx.toDouble()).toInt()) +} + +private fun TextLayoutResult.paginationMeasuredHeightPx(): Int { + val lastLineBottomPx = if (lineCount > 0) getLineBottom(lineCount - 1) else 0f + return measuredTextHeightForPagination(size.height, lastLineBottomPx) +} + +private fun logAndroidEpubCutoff(message: String) { + if (!BuildConfig.DEBUG) return + Log.d(AndroidEpubCutoffLogTag, message) +} + +private fun CharSequence.firstWordOrEmpty(): String { + var start = 0 + while (start < length && this[start].isWhitespace()) start++ + if (start >= length) return "" + var end = start + while (end < length && !this[end].isWhitespace()) end++ + return subSequence(start, end).toString() +} + +private fun CharSequence.skipWhitespaceFrom(index: Int): Int { + var current = index.coerceIn(0, length) + while (current < length && this[current].isWhitespace()) current++ + return current +} + +private fun CharSequence.trimTrailingWhitespaceBefore(index: Int): Int { + var current = index.coerceIn(0, length) + while (current > 0 && this[current - 1].isWhitespace()) current-- + return current +} + +private fun CharSequence.nextWordEndAfter(index: Int): Int { + var current = skipWhitespaceFrom(index) + while (current < length && !this[current].isWhitespace()) current++ + return current +} + +private fun CharSequence.previousWordEndBefore(index: Int): Int { + var current = trimTrailingWhitespaceBefore(index) + while (current > 0 && !this[current - 1].isWhitespace()) current-- + return trimTrailingWhitespaceBefore(current) +} interface BlockMeasurementProvider { suspend fun measure(block: ContentBlock): Int @@ -59,6 +119,7 @@ class SuspendingAndroidBlockMeasurementProvider( private val measurementCache = ConcurrentHashMap() override suspend fun measure(block: ContentBlock): Int { + coroutineContext.ensureActive() val cacheKey = blockMeasurementCacheKey(block) measurementCache[cacheKey]?.let { return it } @@ -85,6 +146,7 @@ class SuspendingAndroidBlockMeasurementProvider( } override suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair? { + coroutineContext.ensureActive() return splitParagraphBlock( block = block, textMeasurer = textMeasurer, @@ -96,6 +158,7 @@ class SuspendingAndroidBlockMeasurementProvider( } override suspend fun split(block: WrappingContentBlock, availableHeight: Int): Pair>? { + coroutineContext.ensureActive() val imageBlock = block.floatedImage val (imageWidthPx, imageHeightPx) = run { @@ -145,6 +208,7 @@ class SuspendingAndroidBlockMeasurementProvider( val wrappingContentWidth = (constraints.maxWidth - imageWidthPx).toInt().coerceAtLeast(0) while (textOffset < fullText.length) { + coroutineContext.ensureActive() val isBesideImage = currentY < imageHeightPx val currentMaxWidth = if (isBesideImage) wrappingContentWidth else constraints.maxWidth @@ -212,6 +276,7 @@ class SuspendingAndroidBlockMeasurementProvider( var splitOccurred = false for ((index, paraRange) in paragraphOffsets.withIndex()) { + coroutineContext.ensureActive() val originalPara = block.paragraphsToWrap[index] if (splitOccurred) { @@ -287,6 +352,7 @@ class SuspendingAndroidBlockMeasurementProvider( } override suspend fun split(block: TableBlock, availableHeight: Int): Pair? { + coroutineContext.ensureActive() var currentHeight = 0 var splitRowIndex = -1 @@ -304,17 +370,28 @@ class SuspendingAndroidBlockMeasurementProvider( currentHeight += decorationTop for (i in block.rows.indices) { + coroutineContext.ensureActive() val row = block.rows[i] var maxRowHeight = 0 val totalColspan = row.sumOf { it.colspan }.toFloat().coerceAtLeast(1f) - row.forEach { cell -> + for (cell in row) { + coroutineContext.ensureActive() val cellMaxWidth = ((constraints.maxWidth) * (cell.colspan.toFloat() / totalColspan)).roundToInt() - @Suppress("UnusedVariable", "Unused") val cellConstraints = constraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0)) + val cellConstraints = constraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0)) var cellHeight = 0 - cell.content.forEach { b -> - cellHeight += measure(b) + for (b in cell.content) { + coroutineContext.ensureActive() + cellHeight += measureBlockHeight( + block = b, + textMeasurer = textMeasurer, + constraints = cellConstraints, + defaultStyle = textStyle, + headerStyle = textStyle.copy(fontWeight = FontWeight.Bold), + density = density, + imageSizeMultiplier = imageSizeMultiplier + ) } val cellDecoration = with(density) { cell.style.blockStyle.padding.top.toPx() + cell.style.blockStyle.padding.bottom.toPx() + @@ -346,6 +423,7 @@ class SuspendingAndroidBlockMeasurementProvider( } override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair? { + coroutineContext.ensureActive() if (block.style.flexDirection == "row") return null var currentHeight = 0 @@ -362,6 +440,7 @@ class SuspendingAndroidBlockMeasurementProvider( currentHeight += decorationTop for (i in block.children.indices) { + coroutineContext.ensureActive() val child = block.children[i] val childHeight = measure(child) val margin = with(density) { @@ -422,6 +501,15 @@ private fun setBlockExpectedHeight(block: T, height: Int): T } as T } +private fun BlockStyle.avoidsBreakInside(): Boolean = + pageBreakInsideAvoid || breakInside in setOf("avoid", "avoid-page", "avoid-column") + +private fun BlockStyle.forcesBreakBefore(): Boolean = + breakBefore in setOf("page", "always", "left", "right", "recto", "verso") + +private fun BlockStyle.forcesBreakAfter(): Boolean = + breakAfter in setOf("page", "always", "left", "right", "recto", "verso") + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) suspend fun paginate( blocks: List, @@ -444,8 +532,19 @@ suspend fun paginate( val safetyMarginPerBlock = 0 while (remainingBlocks.isNotEmpty()) { + coroutineContext.ensureActive() val block = remainingBlocks.removeAt(0) + if (currentPageContent.isNotEmpty() && block.style.forcesBreakBefore()) { + zeroOutBottomMargin(currentPageContent) + pages.add(Page(content = currentPageContent.toList())) + pageIndex++ + currentPageContent = mutableListOf() + remainingHeight = pageHeight + remainingBlocks.add(0, block) + continue + } + val blockHeight = measurementProvider.measure(block) val blockHeightWithSafetyMargin = blockHeight + safetyMarginPerBlock @@ -488,6 +587,14 @@ suspend fun paginate( currentPageContent.add(blockToAdd) remainingHeight -= spaceRequired + + if (block.style.forcesBreakAfter() && remainingBlocks.isNotEmpty()) { + zeroOutBottomMargin(currentPageContent) + pages.add(Page(content = currentPageContent.toList())) + pageIndex++ + currentPageContent = mutableListOf() + remainingHeight = pageHeight + } } else { var wasSplit = false val heightForSplitting = remainingHeight - spaceBetweenBlocks @@ -495,7 +602,7 @@ suspend fun paginate( if (heightForSplitting > 50) { when (block) { is ParagraphBlock -> { - if (!block.style.pageBreakInsideAvoid) { + if (!block.style.avoidsBreakInside()) { measurementProvider.split(block, heightForSplitting) ?.let { (part1, part2) -> if (part1.content.isNotEmpty()) { @@ -534,7 +641,7 @@ suspend fun paginate( } is WrappingContentBlock -> { - measurementProvider.split(block, heightForSplitting) + if (!block.style.avoidsBreakInside()) measurementProvider.split(block, heightForSplitting) ?.let { (part1, part2) -> if (part1.paragraphsToWrap.any { it.content.isNotBlank() }) { val collapsedMarginDp = @@ -570,7 +677,7 @@ suspend fun paginate( } is TableBlock -> { - measurementProvider.split(block, heightForSplitting) + if (!block.style.avoidsBreakInside()) measurementProvider.split(block, heightForSplitting) ?.let { (part1, part2) -> val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() } if (currentPageContent.isNotEmpty()) { @@ -602,7 +709,7 @@ suspend fun paginate( } is FlexContainerBlock -> { - measurementProvider.split(block, heightForSplitting) + if (!block.style.avoidsBreakInside()) measurementProvider.split(block, heightForSplitting) ?.let { (part1, part2) -> val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() } if (currentPageContent.isNotEmpty()) { @@ -692,6 +799,7 @@ private suspend fun measureBlockHeight( density: Density, imageSizeMultiplier: Float = 1.0f ): Int { + coroutineContext.ensureActive() val boxMetrics = computeBlockBoxMetrics(block, constraints, density) val verticalPaddingPx = boxMetrics.verticalPaddingPx val verticalBorderPx = boxMetrics.verticalBorderPx @@ -705,7 +813,7 @@ private suspend fun measureBlockHeight( text = block.content, style = paragraphStyle, constraints = adjustedConstraints - ).size.height + ).paginationMeasuredHeightPx() } height + centeredTextSafetyPaddingPx(paragraphStyle, density) } @@ -718,7 +826,7 @@ private suspend fun measureBlockHeight( text = block.content, style = style, constraints = adjustedConstraints - ).size.height + ).paginationMeasuredHeightPx() } height + centeredTextSafetyPaddingPx(style, density) } @@ -731,7 +839,9 @@ private suspend fun measureBlockHeight( ) ?: with(density) { 250.dp.toPx() } val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt() - Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})") + if (DEBUG_PAGINATION_LOGS) { + Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})") + } finalHeight } is SpacerBlock -> { @@ -745,7 +855,7 @@ private suspend fun measureBlockHeight( text = block.content, style = quoteStyle, constraints = adjustedConstraints - ).size.height + ).paginationMeasuredHeightPx() } height + centeredTextSafetyPaddingPx(quoteStyle, density) } @@ -759,7 +869,7 @@ private suspend fun measureBlockHeight( text = block.content, style = defaultStyle, constraints = textConstraints - ).size.height + ).paginationMeasuredHeightPx() } val markerImageHeight = if (block.itemMarkerImage != null) { with(density) { (defaultStyle.fontSize.value * 0.8f).sp.toPx().roundToInt() } @@ -771,11 +881,13 @@ private suspend fun measureBlockHeight( } is TableBlock -> { var totalHeight = 0 - block.rows.forEach { row -> + for (row in block.rows) { + coroutineContext.ensureActive() var maxRowHeight = 0 val totalColspan = row.sumOf { it.colspan }.toFloat().coerceAtLeast(1f) - row.forEach { cell -> + for (cell in row) { + coroutineContext.ensureActive() val cellBlockStyle = cell.style.blockStyle val cellMaxWidth = when { cellBlockStyle.width.isSpecified -> with(density) { cellBlockStyle.width.toPx().roundToInt() } @@ -844,6 +956,7 @@ private suspend fun measureBlockHeight( // Loop until all text is measured. while (textOffset < fullText.length) { + coroutineContext.ensureActive() val isBesideImage = currentY < imageHeightPx val currentMaxWidth = if (isBesideImage) { wrappingContentWidth @@ -936,11 +1049,19 @@ private suspend fun measureBlockHeight( } } val specifiedHeightDp = block.style.height - val finalHeight = if (block.style.boxSizing == "border-box" && specifiedHeightDp != Dp.Unspecified) { + var finalHeight = if (block.style.boxSizing == "border-box" && specifiedHeightDp != Dp.Unspecified) { with(density) { specifiedHeightDp.toPx().roundToInt() } } else { (contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt() } + with(density) { + if (block.style.minHeight.isSpecified) { + finalHeight = finalHeight.coerceAtLeast(block.style.minHeight.toPx().roundToInt()) + } + if (block.style.maxHeight.isSpecified && block.style.overflow in setOf("hidden", "clip", "scroll", "auto")) { + finalHeight = finalHeight.coerceAtMost(block.style.maxHeight.toPx().roundToInt()) + } + } if (DEBUG_PAGINATION_LOGS) { Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight") @@ -948,6 +1069,380 @@ private suspend fun measureBlockHeight( return finalHeight } +private suspend fun logJustifiedSplitGapIfSuspicious( + block: ParagraphBlock, + text: AnnotatedString, + textMeasurer: TextMeasurer, + paragraphStyle: TextStyle, + paragraphConstraints: Constraints, + layoutResult: TextLayoutResult, + lastVisibleLine: Int, + splitOffset: Int, + availableTextHeight: Int +) { + coroutineContext.ensureActive() + val isJustified = block.textAlign == TextAlign.Justify || + paragraphStyle.textAlign == TextAlign.Justify || + text.paragraphStyles.any { it.item.textAlign == TextAlign.Justify } + if (!isJustified || lastVisibleLine !in 0 until layoutResult.lineCount) return + + val lineStart = layoutResult.getLineStart(lastVisibleLine) + val lineEnd = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true) + if (lineStart >= lineEnd || lineEnd > text.length) return + + val visibleRightPx = (lineStart until lineEnd) + .asSequence() + .filter { !text[it].isWhitespace() } + .mapNotNull { index -> + runCatching { layoutResult.getBoundingBox(index).right }.getOrNull() + } + .maxOrNull() ?: return + + val contentWidthPx = paragraphConstraints.maxWidth.takeIf { it > 0 } ?: return + val visualGapPx = contentWidthPx - visibleRightPx + if (visualGapPx < contentWidthPx * JustifiedSplitGapProbeMinFraction) return + + val nextWord = text.text.subSequence(splitOffset.coerceIn(0, text.length), text.length) + .firstWordOrEmpty() + .take(48) + if (nextWord.isBlank()) return + + val lineText = text.text.substring(lineStart, lineEnd).trimEnd() + val candidateLineCount = withContext(Dispatchers.Main) { + textMeasurer.measure( + text = "$lineText $nextWord", + style = paragraphStyle, + constraints = paragraphConstraints + ).lineCount + } + + coroutineContext.ensureActive() + logAndroidEpubCutoff( + "cutoff_probe layer=android_justified_split_gap block=${block.blockIndex} " + + "line=$lastVisibleLine lineOffsets=$lineStart..$lineEnd splitOffset=$splitOffset " + + "sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " + + "contentWidthPx=$contentWidthPx visibleRightPx=${visibleRightPx.roundToInt()} " + + "visualGapPx=${visualGapPx.roundToInt()} availableTextHeightPx=$availableTextHeight " + + "nextWordChars=${nextWord.length} candidateLineCount=$candidateLineCount " + + "note=justify_expands_spaces_so_visual_gap_may_not_be_fit_capacity" + ) +} + +private suspend fun logRenderedJustifiedSplitGapIfSuspicious( + block: ParagraphBlock, + part1Text: AnnotatedString, + part2Text: AnnotatedString, + textMeasurer: TextMeasurer, + paragraphStyle: TextStyle, + paragraphConstraints: Constraints, + originalLayoutResult: TextLayoutResult, + originalLastVisibleLine: Int, + splitOffset: Int, + availableTextHeight: Int +) { + coroutineContext.ensureActive() + val isJustified = block.textAlign == TextAlign.Justify || + paragraphStyle.textAlign == TextAlign.Justify || + part1Text.paragraphStyles.any { it.item.textAlign == TextAlign.Justify } + if (!isJustified || part1Text.isEmpty()) return + + val renderedPart1Layout = withContext(Dispatchers.Main) { + textMeasurer.measure( + text = part1Text, + style = paragraphStyle, + constraints = paragraphConstraints + ) + } + coroutineContext.ensureActive() + + val renderedLastLine = renderedPart1Layout.lineCount - 1 + if (renderedLastLine < 0) return + + val renderedLineStart = renderedPart1Layout.getLineStart(renderedLastLine) + val renderedLineEnd = renderedPart1Layout.getLineEnd(renderedLastLine, visibleEnd = true) + if (renderedLineStart >= renderedLineEnd || renderedLineEnd > part1Text.length) return + + val visibleRightPx = (renderedLineStart until renderedLineEnd) + .asSequence() + .filter { !part1Text[it].isWhitespace() } + .mapNotNull { index -> + runCatching { renderedPart1Layout.getBoundingBox(index).right }.getOrNull() + } + .maxOrNull() ?: return + + val contentWidthPx = paragraphConstraints.maxWidth.takeIf { it > 0 } ?: return + val visualGapPx = contentWidthPx - visibleRightPx + val renderedLineText = part1Text.text.substring(renderedLineStart, renderedLineEnd).trim() + val renderedLineWordCount = renderedLineText.split(Regex("\\s+")).count { it.isNotBlank() } + val sparseByGap = visualGapPx >= contentWidthPx * 0.10f + val sparseByWords = renderedLineWordCount <= 4 && visualGapPx >= contentWidthPx * 0.06f + if (!sparseByGap && !sparseByWords) return + + val nextWord = part2Text.text.firstWordOrEmpty().take(48) + if (nextWord.isBlank()) return + + val visualCandidateLineCount = withContext(Dispatchers.Main) { + textMeasurer.measure( + text = "$renderedLineText $nextWord", + style = paragraphStyle, + constraints = paragraphConstraints + ).lineCount + } + + val part2LineCount = withContext(Dispatchers.Main) { + textMeasurer.measure( + text = part2Text, + style = paragraphStyle, + constraints = paragraphConstraints + ).lineCount + } + val nextWordEnd = part2Text.text.nextWordEndAfter(0) + val remainingAfterNextWordStart = part2Text.text.skipWhitespaceFrom(nextWordEnd) + val remainingAfterNextWordLineCount = if (remainingAfterNextWordStart < part2Text.length) { + withContext(Dispatchers.Main) { + textMeasurer.measure( + text = part2Text.subSequence(remainingAfterNextWordStart, part2Text.length), + style = paragraphStyle, + constraints = paragraphConstraints + ).lineCount + } + } else { + 0 + } + + val originalLineStart = if (originalLastVisibleLine in 0 until originalLayoutResult.lineCount) { + originalLayoutResult.getLineStart(originalLastVisibleLine) + } else { + -1 + } + val originalLineEnd = if (originalLastVisibleLine in 0 until originalLayoutResult.lineCount) { + originalLayoutResult.getLineEnd(originalLastVisibleLine, visibleEnd = true) + } else { + -1 + } + + coroutineContext.ensureActive() + logAndroidEpubCutoff( + "cutoff_probe layer=android_justified_split_gap block=${block.blockIndex} " + + "sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " + + "splitOffset=$splitOffset availableTextHeightPx=$availableTextHeight " + + "renderedLines=${renderedPart1Layout.lineCount} renderedLastLine=$renderedLastLine " + + "renderedLineOffsets=$renderedLineStart..$renderedLineEnd " + + "renderedLineChars=${renderedLineText.length} renderedLineWords=$renderedLineWordCount " + + "contentWidthPx=$contentWidthPx renderedVisibleRightPx=${visibleRightPx.roundToInt()} " + + "renderedVisualGapPx=${visualGapPx.roundToInt()} nextWordChars=${nextWord.length} " + + "visualCandidateLineCount=$visualCandidateLineCount part2Lines=$part2LineCount " + + "remainingAfterNextWordLines=$remainingAfterNextWordLineCount " + + "originalLastLine=$originalLastVisibleLine originalLineOffsets=$originalLineStart..$originalLineEnd " + + "note=rendered_split_final_line_is_unjustified_so_gap_can_appear_after_pagination" + ) +} + +private data class RenderedSplitCandidate( + val splitOffset: Int, + val prefixHeightPx: Int, + val prefixLineCount: Int, + val remainingLineCount: Int, + val lastLineChars: Int, + val lastLineWords: Int, + val lastLineVisualGapPx: Int, + val contentWidthPx: Int +) { + val sparseLastLine: Boolean + get() = lastLineVisualGapPx >= contentWidthPx * 0.20f || + (lastLineWords <= 4 && lastLineVisualGapPx >= contentWidthPx * 0.08f) +} + +private fun isBetterRenderedJustifySplitCandidate( + candidate: RenderedSplitCandidate, + current: RenderedSplitCandidate +): Boolean { + if (candidate.sparseLastLine != current.sparseLastLine) { + return !candidate.sparseLastLine + } + if (candidate.sparseLastLine) { + if (candidate.lastLineVisualGapPx != current.lastLineVisualGapPx) { + return candidate.lastLineVisualGapPx < current.lastLineVisualGapPx + } + return candidate.splitOffset > current.splitOffset + } + if (candidate.prefixLineCount != current.prefixLineCount) { + return candidate.prefixLineCount > current.prefixLineCount + } + if (candidate.lastLineVisualGapPx != current.lastLineVisualGapPx) { + return candidate.lastLineVisualGapPx < current.lastLineVisualGapPx + } + return candidate.splitOffset > current.splitOffset +} + +private suspend fun measureRenderedSplitCandidate( + text: AnnotatedString, + textMeasurer: TextMeasurer, + paragraphStyle: TextStyle, + paragraphConstraints: Constraints, + splitOffset: Int +): RenderedSplitCandidate? { + val prefixEnd = text.text.trimTrailingWhitespaceBefore(splitOffset) + if (prefixEnd <= 0 || prefixEnd >= text.length) return null + + val remainingStart = text.text.skipWhitespaceFrom(prefixEnd) + if (remainingStart >= text.length) return null + + val prefixLayout = withContext(Dispatchers.Main) { + textMeasurer.measure( + text = text.subSequence(0, prefixEnd), + style = paragraphStyle, + constraints = paragraphConstraints + ) + } + coroutineContext.ensureActive() + + val remainingLayout = withContext(Dispatchers.Main) { + textMeasurer.measure( + text = text.subSequence(remainingStart, text.length), + style = paragraphStyle, + constraints = paragraphConstraints + ) + } + coroutineContext.ensureActive() + + val lastLine = prefixLayout.lineCount - 1 + if (lastLine < 0) return null + val lineStart = prefixLayout.getLineStart(lastLine) + val lineEnd = prefixLayout.getLineEnd(lastLine, visibleEnd = true) + if (lineStart >= lineEnd || lineEnd > prefixEnd) return null + val prefixText = text.text.substring(0, prefixEnd) + val lastLineText = prefixText.substring(lineStart, lineEnd).trim() + val lastLineWords = lastLineText.split(Regex("\\s+")).count { it.isNotBlank() } + val contentWidthPx = paragraphConstraints.maxWidth.takeIf { it > 0 } ?: return null + val visibleRightPx = (lineStart until lineEnd) + .asSequence() + .filter { !prefixText[it].isWhitespace() } + .mapNotNull { index -> + runCatching { prefixLayout.getBoundingBox(index).right }.getOrNull() + } + .maxOrNull() ?: return null + val lastLineVisualGapPx = (contentWidthPx - visibleRightPx).roundToInt() + + return RenderedSplitCandidate( + splitOffset = prefixEnd, + prefixHeightPx = prefixLayout.paginationMeasuredHeightPx(), + prefixLineCount = prefixLayout.lineCount, + remainingLineCount = remainingLayout.lineCount, + lastLineChars = lastLineText.length, + lastLineWords = lastLineWords, + lastLineVisualGapPx = lastLineVisualGapPx, + contentWidthPx = contentWidthPx + ) +} + +private suspend fun adjustJustifiedSplitOffsetForRenderedPrefix( + block: ParagraphBlock, + text: AnnotatedString, + textMeasurer: TextMeasurer, + paragraphStyle: TextStyle, + paragraphConstraints: Constraints, + initialSplitOffset: Int, + availableTextHeight: Int, + orphanLines: Int, + widowLines: Int +): Int? { + coroutineContext.ensureActive() + val isJustified = block.textAlign == TextAlign.Justify || + paragraphStyle.textAlign == TextAlign.Justify || + text.paragraphStyles.any { it.item.textAlign == TextAlign.Justify } + if (!isJustified) return initialSplitOffset + + val normalizedInitialOffset = text.text.trimTrailingWhitespaceBefore(initialSplitOffset) + var candidateOffset = normalizedInitialOffset + var bestCandidate: RenderedSplitCandidate? = null + + while (candidateOffset > 0) { + coroutineContext.ensureActive() + val candidate = measureRenderedSplitCandidate( + text = text, + textMeasurer = textMeasurer, + paragraphStyle = paragraphStyle, + paragraphConstraints = paragraphConstraints, + splitOffset = candidateOffset + ) + if (candidate != null && + candidate.prefixHeightPx <= availableTextHeight && + candidate.prefixLineCount >= orphanLines && + candidate.remainingLineCount >= widowLines + ) { + bestCandidate = candidate + break + } + candidateOffset = text.text.previousWordEndBefore(candidateOffset) + } + + if (bestCandidate == null) { + logAndroidEpubCutoff( + "cutoff_probe layer=android_justified_split_adjust block=${block.blockIndex} " + + "sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " + + "initialSplitOffset=$initialSplitOffset adjustedSplitOffset=null " + + "availableTextHeightPx=$availableTextHeight reason=no_rendered_prefix_fit" + ) + return null + } + + var acceptedCandidate: RenderedSplitCandidate = bestCandidate ?: return null + var furthestFittingCandidate: RenderedSplitCandidate = acceptedCandidate + while (true) { + coroutineContext.ensureActive() + val nextOffset = text.text.nextWordEndAfter(furthestFittingCandidate.splitOffset) + if (nextOffset <= furthestFittingCandidate.splitOffset || nextOffset >= text.length) break + + val nextCandidate = measureRenderedSplitCandidate( + text = text, + textMeasurer = textMeasurer, + paragraphStyle = paragraphStyle, + paragraphConstraints = paragraphConstraints, + splitOffset = nextOffset + ) ?: break + + if (nextCandidate.prefixHeightPx > availableTextHeight || + nextCandidate.prefixLineCount < orphanLines || + nextCandidate.remainingLineCount < widowLines + ) { + break + } + furthestFittingCandidate = nextCandidate + if (isBetterRenderedJustifySplitCandidate(nextCandidate, acceptedCandidate)) { + acceptedCandidate = nextCandidate + } + } + + if (acceptedCandidate.splitOffset != normalizedInitialOffset || + acceptedCandidate.splitOffset != furthestFittingCandidate.splitOffset + ) { + val reason = if (acceptedCandidate.splitOffset != furthestFittingCandidate.splitOffset) { + "best_rendered_last_line" + } else { + "rendered_prefix_fit" + } + logAndroidEpubCutoff( + "cutoff_probe layer=android_justified_split_adjust block=${block.blockIndex} " + + "sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " + + "initialSplitOffset=$initialSplitOffset normalizedInitialOffset=$normalizedInitialOffset " + + "adjustedSplitOffset=${acceptedCandidate.splitOffset} availableTextHeightPx=$availableTextHeight " + + "adjustedPrefixHeightPx=${acceptedCandidate.prefixHeightPx} " + + "adjustedPrefixLines=${acceptedCandidate.prefixLineCount} " + + "adjustedRemainingLines=${acceptedCandidate.remainingLineCount} " + + "adjustedLineChars=${acceptedCandidate.lastLineChars} " + + "adjustedLineWords=${acceptedCandidate.lastLineWords} " + + "adjustedLineGapPx=${acceptedCandidate.lastLineVisualGapPx} " + + "furthestFitSplitOffset=${furthestFittingCandidate.splitOffset} " + + "furthestFitLineWords=${furthestFittingCandidate.lastLineWords} " + + "furthestFitLineGapPx=${furthestFittingCandidate.lastLineVisualGapPx} " + + "reason=$reason" + ) + } + + return acceptedCandidate.splitOffset +} + private suspend fun splitParagraphBlock( block: ParagraphBlock, textMeasurer: TextMeasurer, @@ -956,6 +1451,7 @@ private suspend fun splitParagraphBlock( availableHeight: Int, density: Density ): Pair? { + coroutineContext.ensureActive() val text = block.content if (text.isEmpty()) return null val boxMetrics = computeBlockBoxMetrics(block, constraints, density) @@ -992,7 +1488,8 @@ private suspend fun splitParagraphBlock( ) } - if (layoutResult.size.height <= availableTextHeight) { + coroutineContext.ensureActive() + if (layoutResult.paginationMeasuredHeightPx() <= availableTextHeight) { return null } @@ -1010,9 +1507,12 @@ private suspend fun splitParagraphBlock( return null } - if (lastVisibleLine == 0) { + val orphanLines = block.style.orphans.coerceAtLeast(1) + val widowLines = block.style.widows.coerceAtLeast(1) + val visibleLineCount = lastVisibleLine + 1 + if (visibleLineCount < orphanLines) { if (DEBUG_PAGINATION_LOGS) { - Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.") + Timber.d("Orphan control: Preventing split that would leave $visibleLineCount line(s) at the bottom of the page.") } return null } @@ -1028,15 +1528,42 @@ private suspend fun splitParagraphBlock( constraints = paragraphConstraints ) } - if (part2Layout.lineCount == 1) { + coroutineContext.ensureActive() + if (part2Layout.lineCount < widowLines) { if (DEBUG_PAGINATION_LOGS) { - Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.") + Timber.d("Widow control: Adjusting split to keep at least $widowLines line(s) at the top of the next page.") } - lastVisibleLine-- + val linesToMove = widowLines - part2Layout.lineCount + lastVisibleLine -= linesToMove.coerceAtLeast(1) + if (lastVisibleLine + 1 < orphanLines) return null splitOffset = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true) } } + splitOffset = adjustJustifiedSplitOffsetForRenderedPrefix( + block = block, + text = text, + textMeasurer = textMeasurer, + paragraphStyle = paragraphStyle, + paragraphConstraints = paragraphConstraints, + initialSplitOffset = splitOffset, + availableTextHeight = availableTextHeight, + orphanLines = orphanLines, + widowLines = widowLines + ) ?: return null + + logJustifiedSplitGapIfSuspicious( + block = block, + text = text, + textMeasurer = textMeasurer, + paragraphStyle = paragraphStyle, + paragraphConstraints = paragraphConstraints, + layoutResult = layoutResult, + lastVisibleLine = lastVisibleLine, + splitOffset = splitOffset, + availableTextHeight = availableTextHeight + ) + if (splitOffset <= 0 || splitOffset >= text.length) { return null } @@ -1058,6 +1585,19 @@ private suspend fun splitParagraphBlock( return null } + logRenderedJustifiedSplitGapIfSuspicious( + block = block, + part1Text = part1Text, + part2Text = part2Text, + textMeasurer = textMeasurer, + paragraphStyle = paragraphStyle, + paragraphConstraints = paragraphConstraints, + originalLayoutResult = layoutResult, + originalLastVisibleLine = lastVisibleLine, + splitOffset = splitOffset, + availableTextHeight = availableTextHeight + ) + val part2TextWithoutIndent = buildAnnotatedString { append(part2Text) part2Text.paragraphStyles.firstOrNull { it.start == 0 && it.item.textIndent != null }?.let { styleRange -> @@ -1126,7 +1666,8 @@ private suspend fun calculateContentHeightWithMargins( imageSizeMultiplier: Float = 1.0f ): Int { var totalHeight = 0 - children.forEachIndexed { index, child -> + for ((index, child) in children.withIndex()) { + coroutineContext.ensureActive() val childHeight = measureBlockHeight(child, textMeasurer, constraints, defaultStyle, headerStyle, density, imageSizeMultiplier) val margin = with(density) { if (index > 0) { @@ -1174,6 +1715,7 @@ private fun computeBlockBoxMetrics( val isBorderBox = block.style.boxSizing == "border-box" val specifiedWidthDp = block.style.width val specifiedMaxWidthDp = block.style.maxWidth + val specifiedMinWidthDp = block.style.minWidth val blockOuterWidthPx = with(density) { var effectiveWidthPx = constraints.maxWidth.toFloat() @@ -1186,6 +1728,9 @@ private fun computeBlockBoxMetrics( effectiveWidthPx = maxWidthPx } } + if (specifiedMinWidthDp != Dp.Unspecified) { + effectiveWidthPx = effectiveWidthPx.coerceAtLeast(specifiedMinWidthDp.toPx()) + } effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat()) } @@ -1209,7 +1754,7 @@ private fun centeredTextSafetyPaddingPx( style: TextStyle, density: Density ): Int { - if (style.textAlign != androidx.compose.ui.text.style.TextAlign.Center) return 0 + if (style.textAlign != TextAlign.Center) return 0 val fallbackLineHeight = if (style.fontSize.isSpecified) { style.fontSize * 1.2f diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderLinkDiagnostics.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderLinkDiagnostics.kt new file mode 100644 index 0000000..fe48f24 --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderLinkDiagnostics.kt @@ -0,0 +1,247 @@ +package org.dueattendant149.bookreader.paginatedreader + +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element + +internal const val TAG_PAGINATED_LINK_DIAG = "PaginatedLinkDiag" + +private const val LINK_DIAG_MAX_SAMPLES = 4 +private val linkDiagWhitespaceRegex = Regex("\\s+") + +internal fun Document.readerHtmlLinkDiagSummary(): String { + val linkElements = getElementsByTag("a").mapNotNull { element -> + element.readerHrefForDiagnostics()?.let { href -> element to href } + } + val samples = linkElements.take(LINK_DIAG_MAX_SAMPLES).joinToString( + prefix = "[", + postfix = "]" + ) { (element, href) -> + "href=${href.readerLinkDiagPreview()} text=\"${element.text().readerLinkDiagPreview()}\"" + } + return "htmlAnchors=${linkElements.size} htmlSamples=$samples" +} + +internal fun List.readerSemanticLinkDiagSummary(): String { + val collector = ReaderLinkDiagCollector() + forEach { it.collectSemanticLinks(collector) } + return collector.semanticSummary() +} + +internal fun List.readerContentLinkDiagSummary(): String { + val collector = ReaderLinkDiagCollector() + forEach { it.collectContentLinks(collector, pageInChapter = null) } + return collector.contentSummary() +} + +internal fun Page.readerPageLinkDiagSummary(): String { + val collector = ReaderLinkDiagCollector() + content.forEach { it.collectContentLinks(collector, pageInChapter = null) } + return collector.contentSummary() +} + +internal fun List.readerPagesLinkDiagSummary(): String { + val collector = ReaderLinkDiagCollector() + forEachIndexed { pageInChapter, page -> + page.content.forEach { it.collectContentLinks(collector, pageInChapter) } + } + return "pages=$size ${collector.contentSummary()}" +} + +internal fun AnnotatedString.readerAnnotatedLinkDiagSummary(): String { + val collector = ReaderLinkDiagCollector() + collector.addAnnotatedLinks( + blockIndex = null, + blockType = "AnnotatedString", + cfi = null, + text = this, + pageInChapter = null + ) + return collector.contentSummary() +} + +internal fun String.readerLinkDiagPreview(maxLength: Int = 96): String { + val cleaned = replace(linkDiagWhitespaceRegex, " ").trim() + return if (cleaned.length <= maxLength) cleaned else cleaned.take(maxLength - 3) + "..." +} + +private fun Element.readerHrefForDiagnostics(): String? { + return attr("href") + .ifBlank { attr("xlink:href") } + .ifBlank { attr("l:href") } + .ifBlank { attr("epub:href") } + .ifBlank { null } +} + +private class ReaderLinkDiagCollector { + private var semanticTextBlocks = 0 + private var semanticLinkSpans = 0 + private val semanticSamples = mutableListOf() + + private var contentTextBlocks = 0 + private var contentUrlAnnotations = 0 + private var contentLinksWithColor = 0 + private var contentLinksWithBackground = 0 + private var contentLinksWithUnderline = 0 + private var contentLinksWithCoveringStyle = 0 + private val contentSamples = mutableListOf() + + fun addSemanticTextBlock(block: SemanticTextBlock) { + semanticTextBlocks++ + block.spans.forEach { span -> + val href = span.linkHref?.takeIf { it.isNotBlank() } ?: return@forEach + semanticLinkSpans++ + if (semanticSamples.size < LINK_DIAG_MAX_SAMPLES) { + val start = span.start.coerceIn(0, block.text.length) + val end = span.end.coerceIn(start, block.text.length) + semanticSamples += buildString { + append("block=") + append(block.blockIndex) + append(" type=") + append(block::class.simpleName ?: "Text") + append(" tag=") + append(span.tag) + append(" range=") + append(start) + append("..") + append(end) + append(" href=") + append(href.readerLinkDiagPreview()) + append(" text=\"") + append(block.text.substring(start, end).readerLinkDiagPreview()) + append("\"") + } + } + } + } + + fun addAnnotatedLinks( + blockIndex: Int?, + blockType: String, + cfi: String?, + text: AnnotatedString, + pageInChapter: Int? + ) { + contentTextBlocks++ + val annotations = text.getStringAnnotations("URL", 0, text.length) + .filter { it.item.isNotBlank() } + annotations.forEach { annotation -> + contentUrlAnnotations++ + val coverage = text.readerLinkStyleCoverage(annotation) + if (coverage.hasColor) contentLinksWithColor++ + if (coverage.hasBackground) contentLinksWithBackground++ + if (coverage.hasUnderline) contentLinksWithUnderline++ + if (coverage.hasCoveringStyle) contentLinksWithCoveringStyle++ + if (contentSamples.size < LINK_DIAG_MAX_SAMPLES) { + contentSamples += buildString { + if (pageInChapter != null) { + append("pageInChapter=") + append(pageInChapter) + append(" ") + } + append("block=") + append(blockIndex ?: -1) + append(" type=") + append(blockType) + if (!cfi.isNullOrBlank()) { + append(" cfi=") + append(cfi) + } + append(" range=") + append(annotation.start) + append("..") + append(annotation.end) + append(" href=") + append(annotation.item.readerLinkDiagPreview()) + append(" style={color=") + append(coverage.hasColor) + append(",bg=") + append(coverage.hasBackground) + append(",underline=") + append(coverage.hasUnderline) + append(",covering=") + append(coverage.hasCoveringStyle) + append("} text=\"") + append( + text.text.substring( + annotation.start.coerceIn(0, text.length), + annotation.end.coerceIn(annotation.start.coerceIn(0, text.length), text.length) + ).readerLinkDiagPreview() + ) + append("\"") + } + } + } + } + + fun semanticSummary(): String { + return "semanticTextBlocks=$semanticTextBlocks semanticLinkSpans=$semanticLinkSpans semanticSamples=${semanticSamples.joinToString(prefix = "[", postfix = "]")}" + } + + fun contentSummary(): String { + return "contentTextBlocks=$contentTextBlocks urlAnnotations=$contentUrlAnnotations styled={color=$contentLinksWithColor,bg=$contentLinksWithBackground,underline=$contentLinksWithUnderline,covering=$contentLinksWithCoveringStyle} contentSamples=${contentSamples.joinToString(prefix = "[", postfix = "]")}" + } +} + +private data class ReaderLinkStyleCoverage( + val hasColor: Boolean, + val hasBackground: Boolean, + val hasUnderline: Boolean, + val hasCoveringStyle: Boolean +) + +private fun AnnotatedString.readerLinkStyleCoverage( + link: AnnotatedString.Range +): ReaderLinkStyleCoverage { + val overlappingStyles = spanStyles.filter { styleRange -> + styleRange.start < link.end && styleRange.end > link.start + } + return ReaderLinkStyleCoverage( + hasColor = overlappingStyles.any { it.item.color.isSpecified }, + hasBackground = overlappingStyles.any { it.item.background.isSpecified }, + hasUnderline = overlappingStyles.any { + it.item.textDecoration?.contains(TextDecoration.Underline) == true + }, + hasCoveringStyle = overlappingStyles.any { + it.start <= link.start && it.end >= link.end + } + ) +} + +private fun SemanticBlock.collectSemanticLinks(collector: ReaderLinkDiagCollector) { + when (this) { + is SemanticList -> items.forEach { it.collectSemanticLinks(collector) } + is SemanticTable -> rows.flatten().forEach { cell -> + cell.content.forEach { it.collectSemanticLinks(collector) } + } + is SemanticFlexContainer -> children.forEach { it.collectSemanticLinks(collector) } + is SemanticWrappingBlock -> paragraphsToWrap.forEach { it.collectSemanticLinks(collector) } + is SemanticTextBlock -> collector.addSemanticTextBlock(this) + else -> Unit + } +} + +private fun ContentBlock.collectContentLinks( + collector: ReaderLinkDiagCollector, + pageInChapter: Int? +) { + when (this) { + is WrappingContentBlock -> paragraphsToWrap.forEach { + it.collectContentLinks(collector, pageInChapter) + } + is TableBlock -> rows.flatten().forEach { cell -> + cell.content.forEach { it.collectContentLinks(collector, pageInChapter) } + } + is FlexContainerBlock -> children.forEach { it.collectContentLinks(collector, pageInChapter) } + is TextContentBlock -> collector.addAnnotatedLinks( + blockIndex = blockIndex, + blockType = this::class.simpleName ?: "Text", + cfi = cfi, + text = content, + pageInChapter = pageInChapter + ) + else -> Unit + } +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkStyle.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderLinkStyle.kt similarity index 98% rename from app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkStyle.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderLinkStyle.kt index eef2832..861ce32 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkStyle.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderLinkStyle.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderNavigationTargets.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderNavigationTargets.kt new file mode 100644 index 0000000..715b125 --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderNavigationTargets.kt @@ -0,0 +1,112 @@ +package org.dueattendant149.bookreader.paginatedreader + +import org.dueattendant149.bookreader.SearchResult + +internal fun flattenTextContentBlocksForNavigation(blocks: List): List { + return blocks.flatMap { block -> + when (block) { + is WrappingContentBlock -> flattenTextContentBlocksForNavigation( + listOf(block.floatedImage) + block.paragraphsToWrap + ) + is FlexContainerBlock -> flattenTextContentBlocksForNavigation(block.children) + is TableBlock -> block.rows.flatten().flatMap { flattenTextContentBlocksForNavigation(it.content) } + is TextContentBlock -> listOf(block) + else -> emptyList() + } + } +} + +internal fun findLocatorForSearchResultInBlocks( + result: SearchResult, + blocks: List +): Locator? { + val query = result.query.takeIf { it.isNotBlank() } ?: return null + var occurrenceCount = 0 + + flattenTextContentBlocksForNavigation(blocks).forEach { block -> + val text = block.content.text + var lastIndex = -1 + while (true) { + lastIndex = text.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true) + if (lastIndex == -1) break + + val isWordStart = lastIndex == 0 || !text[lastIndex - 1].isLetterOrDigit() + if (isWordStart) { + if (occurrenceCount == result.occurrenceIndexInLocation) { + return Locator( + chapterIndex = result.locationInSource, + blockIndex = block.blockIndex, + charOffset = block.startCharOffsetInSource + lastIndex + ) + } + occurrenceCount++ + } + } + } + + return null +} + +internal fun findLocatorForAnchorInBlocks( + chapterIndex: Int, + anchor: String?, + blocks: List +): Locator? { + if (anchor.isNullOrBlank()) return Locator(chapterIndex, 0, 0) + return blocks.asSequence() + .mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) } + .firstOrNull() +} + +private fun findLocatorForAnchorInBlock( + chapterIndex: Int, + anchor: String, + block: ContentBlock +): Locator? { + if (block.elementId == anchor) return locatorForBlockStart(chapterIndex, block) + + if (block is TextContentBlock) { + block.content.getStringAnnotations("ID", 0, block.content.length) + .firstOrNull { it.item == anchor } + ?.let { annotation -> + return Locator( + chapterIndex = chapterIndex, + blockIndex = block.blockIndex, + charOffset = block.startCharOffsetInSource + annotation.start + ) + } + } + + return when (block) { + is FlexContainerBlock -> block.children.asSequence() + .mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) } + .firstOrNull() + is TableBlock -> block.rows.asSequence() + .flatMap { row -> row.asSequence() } + .flatMap { cell -> cell.content.asSequence() } + .mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) } + .firstOrNull() + is WrappingContentBlock -> sequenceOf(block.floatedImage) + .plus(block.paragraphsToWrap.asSequence().map { it as ContentBlock }) + .mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) } + .firstOrNull() + else -> null + } +} + +private fun locatorForBlockStart(chapterIndex: Int, block: ContentBlock): Locator { + val firstText = flattenTextContentBlocksForNavigation(listOf(block)).firstOrNull() + return if (firstText != null) { + Locator( + chapterIndex = chapterIndex, + blockIndex = firstText.blockIndex, + charOffset = firstText.startCharOffsetInSource + ) + } else { + Locator( + chapterIndex = chapterIndex, + blockIndex = block.blockIndex, + charOffset = 0 + ) + } +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/RenderThemeApplier.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/RenderThemeApplier.kt index 6b830ea..401bbda 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/RenderThemeApplier.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/StablePaginatedNavigation.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/StablePaginatedNavigation.kt similarity index 92% rename from app/src/main/java/com/aryan/reader/paginatedreader/StablePaginatedNavigation.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/StablePaginatedNavigation.kt index ae7de84..e83dbd2 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/StablePaginatedNavigation.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/StablePaginatedNavigation.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader internal suspend fun resolveStableChapterStartPage( chapterIndex: Int, diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/SvgStringFetcher.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/SvgStringFetcher.kt similarity index 97% rename from app/src/main/java/com/aryan/reader/paginatedreader/SvgStringFetcher.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/SvgStringFetcher.kt index 32a9e2d..91de476 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/SvgStringFetcher.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/SvgStringFetcher.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import timber.log.Timber import coil.decode.DataSource diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Woff2Converter.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/Woff2Converter.kt similarity index 95% rename from app/src/main/java/com/aryan/reader/paginatedreader/Woff2Converter.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/Woff2Converter.kt index 992ab7e..e24fdd2 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Woff2Converter.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/Woff2Converter.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader object Woff2Converter { diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/data/BookCacheDatabase.kt similarity index 75% rename from app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/data/BookCacheDatabase.kt index 821fd86..d9d2655 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/data/BookCacheDatabase.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader.data +package org.dueattendant149.bookreader.paginatedreader.data import android.content.Context import androidx.room.Dao @@ -50,11 +50,14 @@ abstract class BookCacheDao { // --- Chapter Operations (Internal Raw Access) --- - @Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex") - protected abstract suspend fun getChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? + @Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex AND style_config_hash = :styleConfigHash") + protected abstract suspend fun getChapterMetadata(bookId: String, chapterIndex: Int, styleConfigHash: Int): ProcessedChapterMetadata? - @Query("SELECT chunk_data FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex ORDER BY chunk_index ASC") - protected abstract suspend fun getChapterChunks(bookId: String, chapterIndex: Int): List + @Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex ORDER BY rowid DESC LIMIT 1") + protected abstract suspend fun getAnyChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? + + @Query("SELECT chunk_data FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex AND style_config_hash = :styleConfigHash ORDER BY chunk_index ASC") + protected abstract suspend fun getChapterChunks(bookId: String, chapterIndex: Int, styleConfigHash: Int): List @Insert(onConflict = OnConflictStrategy.REPLACE) protected abstract suspend fun insertChapterMetadata(metadata: ProcessedChapterMetadata) @@ -65,6 +68,9 @@ abstract class BookCacheDao { @Query("DELETE FROM processed_chapter_metadata WHERE book_id = :bookId") protected abstract suspend fun deleteChapterMetadataForBook(bookId: String) + @Query("DELETE FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex AND style_config_hash = :styleConfigHash") + protected abstract suspend fun deleteChapterChunksForChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int) + @Insert(onConflict = OnConflictStrategy.REPLACE) abstract suspend fun insertAnchorIndices(anchors: List) @@ -75,12 +81,16 @@ abstract class BookCacheDao { abstract suspend fun deleteAnchorsForBook(bookId: String) @Transaction - open suspend fun getProcessedChapter(bookId: String, chapterIndex: Int): ProcessedChapter? { - val metadata = getChapterMetadata(bookId, chapterIndex) ?: return null - val chunks = getChapterChunks(bookId, chapterIndex) + open suspend fun getProcessedChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int? = null): ProcessedChapter? { + val metadata = if (styleConfigHash == null) { + getAnyChapterMetadata(bookId, chapterIndex) + } else { + getChapterMetadata(bookId, chapterIndex, styleConfigHash) + } ?: return null + val chunks = getChapterChunks(bookId, chapterIndex, metadata.styleConfigHash) if (chunks.isEmpty()) { - return ProcessedChapter(bookId, chapterIndex, ByteArray(0), metadata.estimatedPageCount) + return ProcessedChapter(bookId, chapterIndex, ByteArray(0), metadata.estimatedPageCount, metadata.styleConfigHash) } val totalSize = chunks.sumOf { it.size } @@ -95,7 +105,8 @@ abstract class BookCacheDao { bookId = bookId, chapterIndex = chapterIndex, contentBlocksProto = mergedData, - estimatedPageCount = metadata.estimatedPageCount + estimatedPageCount = metadata.estimatedPageCount, + styleConfigHash = metadata.styleConfigHash ) } @@ -107,8 +118,10 @@ abstract class BookCacheDao { val metadata = ProcessedChapterMetadata( bookId = chapter.bookId, chapterIndex = chapter.chapterIndex, - estimatedPageCount = chapter.estimatedPageCount + estimatedPageCount = chapter.estimatedPageCount, + styleConfigHash = chapter.styleConfigHash ) + deleteChapterChunksForChapter(chapter.bookId, chapter.chapterIndex, chapter.styleConfigHash) insertChapterMetadata(metadata) val fullData = chapter.contentBlocksProto @@ -126,6 +139,7 @@ abstract class BookCacheDao { ProcessedChapterChunk( bookId = chapter.bookId, chapterIndex = chapter.chapterIndex, + styleConfigHash = chapter.styleConfigHash, chunkIndex = chunkIndex, chunkData = chunkBytes ) @@ -309,7 +323,7 @@ abstract class BookCacheDao { PageCacheChunk::class, PageIndexEntry::class ], - version = 11, + version = 12, exportSchema = false ) abstract class BookCacheDatabase : RoomDatabase() { @@ -326,7 +340,7 @@ abstract class BookCacheDatabase : RoomDatabase() { BookCacheDatabase::class.java, "book_cache_database" ) - .addMigrations(MIGRATION_10_11) + .addMigrations(MIGRATION_10_11, MIGRATION_11_12) .fallbackToDestructiveMigration(true) .build() INSTANCE = instance @@ -394,5 +408,65 @@ abstract class BookCacheDatabase : RoomDatabase() { ) } } + + private val MIGRATION_11_12 = object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `processed_chapter_chunks` RENAME TO `processed_chapter_chunks_old`" + ) + db.execSQL( + "ALTER TABLE `processed_chapter_metadata` RENAME TO `processed_chapter_metadata_old`" + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `processed_chapter_metadata` ( + `book_id` TEXT NOT NULL, + `chapter_index` INTEGER NOT NULL, + `estimated_page_count` INTEGER NOT NULL, + `style_config_hash` INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(`book_id`, `chapter_index`, `style_config_hash`) + ) + """.trimIndent() + ) + db.execSQL( + """ + INSERT INTO `processed_chapter_metadata` (`book_id`, `chapter_index`, `estimated_page_count`, `style_config_hash`) + SELECT `book_id`, `chapter_index`, `estimated_page_count`, 0 + FROM `processed_chapter_metadata_old` + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `processed_chapter_chunks` ( + `book_id` TEXT NOT NULL, + `chapter_index` INTEGER NOT NULL, + `style_config_hash` INTEGER NOT NULL DEFAULT 0, + `chunk_index` INTEGER NOT NULL, + `chunk_data` BLOB NOT NULL, + PRIMARY KEY(`book_id`, `chapter_index`, `style_config_hash`, `chunk_index`), + FOREIGN KEY(`book_id`, `chapter_index`, `style_config_hash`) + REFERENCES `processed_chapter_metadata`(`book_id`, `chapter_index`, `style_config_hash`) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + """ + INSERT INTO `processed_chapter_chunks` (`book_id`, `chapter_index`, `style_config_hash`, `chunk_index`, `chunk_data`) + SELECT `book_id`, `chapter_index`, 0, `chunk_index`, `chunk_data` + FROM `processed_chapter_chunks_old` + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_processed_chapter_chunks_book_id_chapter_index_style_config_hash` ON `processed_chapter_chunks` (`book_id`, `chapter_index`, `style_config_hash`)" + ) + db.execSQL( + "DROP TABLE `processed_chapter_chunks_old`" + ) + db.execSQL( + "DROP TABLE `processed_chapter_metadata_old`" + ) + } + } } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/data/BookCacheEntities.kt similarity index 90% rename from app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/data/BookCacheEntities.kt index 3afc197..4732dcc 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/data/BookCacheEntities.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader.data +package org.dueattendant149.bookreader.paginatedreader.data import androidx.room.ColumnInfo import androidx.room.Entity @@ -25,8 +25,8 @@ import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey -const val LATEST_PROCESSING_VERSION = 11 -const val LATEST_PAGE_CACHE_VERSION = 3 +const val LATEST_PROCESSING_VERSION = 15 +const val LATEST_PAGE_CACHE_VERSION = 4 @Entity(tableName = "processed_books") data class ProcessedBook( @@ -52,7 +52,8 @@ data class ProcessedChapter( val bookId: String, val chapterIndex: Int, val contentBlocksProto: ByteArray, - val estimatedPageCount: Int + val estimatedPageCount: Int, + val styleConfigHash: Int = 0 ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -62,6 +63,7 @@ data class ProcessedChapter( if (chapterIndex != other.chapterIndex) return false if (!contentBlocksProto.contentEquals(other.contentBlocksProto)) return false if (estimatedPageCount != other.estimatedPageCount) return false + if (styleConfigHash != other.styleConfigHash) return false return true } @@ -70,6 +72,7 @@ data class ProcessedChapter( result = 31 * result + chapterIndex result = 31 * result + contentBlocksProto.contentHashCode() result = 31 * result + estimatedPageCount + result = 31 * result + styleConfigHash return result } } @@ -77,11 +80,12 @@ data class ProcessedChapter( /** * Database Entity: Stores metadata only (small size). */ -@Entity(tableName = "processed_chapter_metadata", primaryKeys = ["book_id", "chapter_index"]) +@Entity(tableName = "processed_chapter_metadata", primaryKeys = ["book_id", "chapter_index", "style_config_hash"]) data class ProcessedChapterMetadata( @ColumnInfo(name = "book_id") val bookId: String, @ColumnInfo(name = "chapter_index") val chapterIndex: Int, - @ColumnInfo(name = "estimated_page_count") val estimatedPageCount: Int + @ColumnInfo(name = "estimated_page_count") val estimatedPageCount: Int, + @ColumnInfo(name = "style_config_hash") val styleConfigHash: Int = 0 ) /** @@ -89,20 +93,21 @@ data class ProcessedChapterMetadata( */ @Entity( tableName = "processed_chapter_chunks", - primaryKeys = ["book_id", "chapter_index", "chunk_index"], + primaryKeys = ["book_id", "chapter_index", "style_config_hash", "chunk_index"], foreignKeys = [ ForeignKey( entity = ProcessedChapterMetadata::class, - parentColumns = ["book_id", "chapter_index"], - childColumns = ["book_id", "chapter_index"], + parentColumns = ["book_id", "chapter_index", "style_config_hash"], + childColumns = ["book_id", "chapter_index", "style_config_hash"], onDelete = ForeignKey.CASCADE ) ], - indices = [Index(value = ["book_id", "chapter_index"])] + indices = [Index(value = ["book_id", "chapter_index", "style_config_hash"])] ) data class ProcessedChapterChunk( @ColumnInfo(name = "book_id") val bookId: String, @ColumnInfo(name = "chapter_index") val chapterIndex: Int, + @ColumnInfo(name = "style_config_hash") val styleConfigHash: Int = 0, @ColumnInfo(name = "chunk_index") val chunkIndex: Int, @ColumnInfo(name = "chunk_data", typeAffinity = ColumnInfo.BLOB) val chunkData: ByteArray ) { @@ -112,6 +117,7 @@ data class ProcessedChapterChunk( other as ProcessedChapterChunk if (bookId != other.bookId) return false if (chapterIndex != other.chapterIndex) return false + if (styleConfigHash != other.styleConfigHash) return false if (chunkIndex != other.chunkIndex) return false if (!chunkData.contentEquals(other.chunkData)) return false return true @@ -120,6 +126,7 @@ data class ProcessedChapterChunk( override fun hashCode(): Int { var result = bookId.hashCode() result = 31 * result + chapterIndex + result = 31 * result + styleConfigHash result = 31 * result + chunkIndex result = 31 * result + chunkData.contentHashCode() return result diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/data/BookProcessingWorker.kt similarity index 79% rename from app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/data/BookProcessingWorker.kt index 11165da..8d8845a 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/paginatedreader/data/BookProcessingWorker.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader.data +package org.dueattendant149.bookreader.paginatedreader.data import android.content.Context import android.graphics.BitmapFactory @@ -31,21 +31,27 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.sp import androidx.work.CoroutineWorker import androidx.work.Data +import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters -import com.aryan.reader.epub.epubContentFilePath -import com.aryan.reader.paginatedreader.CssParser -import com.aryan.reader.paginatedreader.FontFaceInfo -import com.aryan.reader.paginatedreader.MathMLRenderer -import com.aryan.reader.paginatedreader.OptimizedCssRules -import com.aryan.reader.paginatedreader.RenderResult -import com.aryan.reader.paginatedreader.androidHtmlToSemanticBlocks -import com.aryan.reader.paginatedreader.loadFontFamilies -import com.aryan.reader.paginatedreader.semanticBlockModule +import org.dueattendant149.bookreader.applyBookReplacementsToHtmlDocument +import org.dueattendant149.bookreader.epub.epubContentFilePath +import org.dueattendant149.bookreader.paginatedreader.CssParser +import org.dueattendant149.bookreader.paginatedreader.AndroidHtmlResourceResolver +import org.dueattendant149.bookreader.paginatedreader.FontFaceInfo +import org.dueattendant149.bookreader.paginatedreader.MathMLRenderer +import org.dueattendant149.bookreader.paginatedreader.OptimizedCssRules +import org.dueattendant149.bookreader.paginatedreader.RenderResult +import org.dueattendant149.bookreader.paginatedreader.androidHtmlToSemanticBlocks +import org.dueattendant149.bookreader.paginatedreader.loadFontFamilies +import org.dueattendant149.bookreader.paginatedreader.semanticBlockModule +import org.dueattendant149.bookreader.shared.ReaderBookReplacementPreferencesJson +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @@ -56,8 +62,8 @@ import kotlinx.serialization.protobuf.ProtoNumber import org.jsoup.Jsoup import org.jsoup.nodes.Element import java.io.File -import java.net.URLDecoder import kotlin.math.abs +import kotlin.coroutines.coroutineContext @OptIn(ExperimentalSerializationApi::class) @Serializable @@ -78,7 +84,10 @@ data class BookProcessingInput( @ProtoNumber(5) val density: Float, @ProtoNumber(6) val constraintsMaxWidth: Int, @ProtoNumber(7) val constraintsMaxHeight: Int, - @ProtoNumber(8) val fontFaces: List = emptyList() + @ProtoNumber(8) val fontFaces: List = emptyList(), + @ProtoNumber(9) val styleConfigHash: Int = 0, + @ProtoNumber(10) val bookReplacementPreferencesJson: String = "", + @ProtoNumber(11) val bookReplacementFileId: String = "" ) @OptIn(ExperimentalSerializationApi::class) @@ -95,6 +104,13 @@ class BookProcessingWorker( private const val KEY_ESTIMATED_TOTAL_PAGES = "estimatedTotalPages" private const val KEY_START_CHAPTER_INDEX = "startChapterIndex" + private fun uniqueWorkName(bookId: String): String = "process_$bookId" + + fun cancelForBook(context: Context, bookId: String) { + WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkName(bookId)) + Timber.i("Cancelled stale background processing for book: $bookId") + } + fun enqueue( context: Context, bookId: String, @@ -121,11 +137,11 @@ class BookProcessingWorker( .build() WorkManager.getInstance(context).enqueueUniqueWork( - "process_$bookId", - androidx.work.ExistingWorkPolicy.KEEP, + uniqueWorkName(bookId), + ExistingWorkPolicy.REPLACE, workRequest ) - Timber.i("Enqueued background processing for book: $bookId") + Timber.i("Enqueued latest background processing for book: $bookId config=${processingInput.styleConfigHash}") } } @@ -137,7 +153,6 @@ class BookProcessingWorker( Timber.i("Starting pre-scan to calculate image dimensions...") for (chapter in chapters) { val document = Jsoup.parse(chapter.htmlContent) - val chapterParentPath = File(chapter.absPath).parent ?: "" // Find all image tags (both and ) document.select("img, image").forEach { element -> @@ -145,14 +160,10 @@ class BookProcessingWorker( val src = element.attr(srcAttr).ifBlank { element.attr("xlink:href") } if (src.isNotBlank()) { - val decodedSrc = try { - URLDecoder.decode(src, "UTF-8") - } catch (_: Exception) { - src - } - - val imageFile = File(File(extractionBasePath, chapterParentPath), decodedSrc).canonicalFile - val imagePath = imageFile.absolutePath + val imagePath = AndroidHtmlResourceResolver + .resolvePath(chapter.absPath, extractionBasePath, src) + ?: return@forEach + val imageFile = File(imagePath) // If not already cached, read dimensions from disk if (imageFile.exists() && !dimensionsCache.containsKey(imagePath)) { @@ -196,6 +207,9 @@ class BookProcessingWorker( return@withContext Result.failure() } val input = proto.decodeFromByteArray(inputFile.readBytes()) + val bookReplacementPreferences = ReaderBookReplacementPreferencesJson.decodeOrEmpty( + input.bookReplacementPreferencesJson, + ) Timber.i("Worker decoded input. Number of chapters received: ${input.chapters.size}") // Worker now reconstructs everything it needs for a pure light-theme processing run. @@ -240,11 +254,13 @@ class BookProcessingWorker( Timber.i("Worker processing with up to $numCores threads, prioritizing around chapter $startChapterIndex.") chaptersToProcess.chunked(numCores).forEach { chunk -> + coroutineContext.ensureActive() Timber.d("Processing a chunk of ${chunk.size} chapters.") val deferreds = chunk.map { (index, chapter) -> async { + coroutineContext.ensureActive() Timber.d("Async task started for chapter index $index.") - if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) { + if (db.bookCacheDao().getProcessedChapter(bookId, index, input.styleConfigHash) == null) { Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}") val htmlToParse = chapter.htmlContent.ifBlank { val backingFile = File(extractionBasePath, epubContentFilePath(chapter.htmlFilePath)) @@ -290,6 +306,12 @@ class BookProcessingWorker( } Timber.d("Chapter $index (Background Worker): Finished processing MathML. SVG cache has ${svgResults.size} items. Keys: ${svgResults.keys.joinToString()}") } + applyBookReplacementsToHtmlDocument( + document = document, + preferences = bookReplacementPreferences, + fileId = input.bookReplacementFileId, + ) + coroutineContext.ensureActive() val processedHtml = document.outerHtml() Timber.d("Chapter $index (Background Worker): Processed HTML contains : ${processedHtml.contains("math-placeholder")}") @@ -306,12 +328,14 @@ class BookProcessingWorker( imageDimensionsCache = imageDimensionsCache, mathSvgCache = svgResults ) + coroutineContext.ensureActive() val protoBytes = proto.encodeToByteArray(semanticBlocks) ProcessedChapter( bookId = bookId, chapterIndex = index, contentBlocksProto = protoBytes, - estimatedPageCount = estimateSemanticPageCount(semanticBlocks) + estimatedPageCount = estimateSemanticPageCount(semanticBlocks), + styleConfigHash = input.styleConfigHash ) } else { Timber.d("Chapter $index was already in the database. Skipping.") @@ -325,7 +349,7 @@ class BookProcessingWorker( val allAnchors = mutableListOf() processedChapters.forEach { chapter -> - val blocks = proto.decodeFromByteArray>(chapter.contentBlocksProto) + val blocks = proto.decodeFromByteArray>(chapter.contentBlocksProto) allAnchors.addAll(extractAnchorsFromBlocks(bookId, chapter.chapterIndex, blocks)) } if (allAnchors.isNotEmpty()) { @@ -341,6 +365,9 @@ class BookProcessingWorker( Timber.i("[BG_PROC] Finished processing all chapters for book $bookId.") return@withContext Result.success() + } catch (e: CancellationException) { + Timber.i("Background processing cancelled for book $bookId") + throw e } catch (e: Exception) { Timber.e(e, "Error in pagination worker for book $bookId") return@withContext Result.failure() @@ -353,18 +380,18 @@ class BookProcessingWorker( private fun extractAnchorsFromBlocks( bookId: String, chapterIndex: Int, - blocks: List + blocks: List ): List { val anchors = mutableListOf() - fun walk(block: com.aryan.reader.paginatedreader.SemanticBlock) { + fun walk(block: org.dueattendant149.bookreader.paginatedreader.SemanticBlock) { // 1. Check block ID block.elementId?.let { anchors.add(AnchorIndexEntry(bookId, it, chapterIndex, block.blockIndex)) } // 2. Check Span IDs (Inline anchors) - if (block is com.aryan.reader.paginatedreader.SemanticTextBlock) { + if (block is org.dueattendant149.bookreader.paginatedreader.SemanticTextBlock) { block.spans.forEach { span -> span.elementId?.let { anchors.add(AnchorIndexEntry(bookId, it, chapterIndex, block.blockIndex)) @@ -374,10 +401,10 @@ class BookProcessingWorker( // 3. Recurse when (block) { - is com.aryan.reader.paginatedreader.SemanticFlexContainer -> block.children.forEach { walk(it) } - is com.aryan.reader.paginatedreader.SemanticTable -> block.rows.flatten().forEach { cell -> cell.content.forEach { walk(it) } } - is com.aryan.reader.paginatedreader.SemanticList -> block.items.forEach { walk(it) } - is com.aryan.reader.paginatedreader.SemanticWrappingBlock -> { + is org.dueattendant149.bookreader.paginatedreader.SemanticFlexContainer -> block.children.forEach { walk(it) } + is org.dueattendant149.bookreader.paginatedreader.SemanticTable -> block.rows.flatten().forEach { cell -> cell.content.forEach { walk(it) } } + is org.dueattendant149.bookreader.paginatedreader.SemanticList -> block.items.forEach { walk(it) } + is org.dueattendant149.bookreader.paginatedreader.SemanticWrappingBlock -> { walk(block.floatedImage) block.paragraphsToWrap.forEach { walk(it) } } @@ -390,21 +417,21 @@ class BookProcessingWorker( } private fun estimateSemanticPageCount( - blocks: List + blocks: List ): Int { var charCount = 0 - fun walk(block: com.aryan.reader.paginatedreader.SemanticBlock) { + fun walk(block: org.dueattendant149.bookreader.paginatedreader.SemanticBlock) { when (block) { - is com.aryan.reader.paginatedreader.SemanticTextBlock -> { + is org.dueattendant149.bookreader.paginatedreader.SemanticTextBlock -> { charCount += block.text.length } - is com.aryan.reader.paginatedreader.SemanticFlexContainer -> block.children.forEach(::walk) - is com.aryan.reader.paginatedreader.SemanticTable -> { + is org.dueattendant149.bookreader.paginatedreader.SemanticFlexContainer -> block.children.forEach(::walk) + is org.dueattendant149.bookreader.paginatedreader.SemanticTable -> { block.rows.forEach { row -> row.forEach { cell -> cell.content.forEach(::walk) } } } - is com.aryan.reader.paginatedreader.SemanticList -> block.items.forEach(::walk) - is com.aryan.reader.paginatedreader.SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::walk) + is org.dueattendant149.bookreader.paginatedreader.SemanticList -> block.items.forEach(::walk) + is org.dueattendant149.bookreader.paginatedreader.SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::walk) else -> Unit } } diff --git a/app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/AnnotationDock.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/AnnotationDock.kt index 2d83401..5ed1400 100644 --- a/app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/AnnotationDock.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState @@ -75,7 +75,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import com.aryan.reader.R +import org.dueattendant149.bookreader.R @Composable fun AnnotationDock( diff --git a/app/src/main/java/com/aryan/reader/pdf/DemoAnnotationGenerator.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/DemoAnnotationGenerator.kt similarity index 98% rename from app/src/main/java/com/aryan/reader/pdf/DemoAnnotationGenerator.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/DemoAnnotationGenerator.kt index 852ee9d..76a7a20 100644 --- a/app/src/main/java/com/aryan/reader/pdf/DemoAnnotationGenerator.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/DemoAnnotationGenerator.kt @@ -17,12 +17,12 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.graphics.Path import androidx.compose.ui.graphics.Color import androidx.core.graphics.PathParser -import com.aryan.reader.pdf.data.PdfAnnotation +import org.dueattendant149.bookreader.pdf.data.PdfAnnotation object DemoAnnotationGenerator { diff --git a/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/MagnifierComposable.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/MagnifierComposable.kt index e2612c9..30abf7e 100644 --- a/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/MagnifierComposable.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.graphics.Rect import android.graphics.RectF diff --git a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/NativePdfiumBridge.kt similarity index 96% rename from app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/NativePdfiumBridge.kt index b5a6ac0..6c73ce2 100644 --- a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/NativePdfiumBridge.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf -import com.aryan.reader.shared.pdf.PdfiumAnnotationSubtype +import org.dueattendant149.bookreader.shared.pdf.PdfiumAnnotationSubtype object NativePdfiumBridge { init { diff --git a/app/src/main/java/com/aryan/reader/pdf/OcrModels.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/OcrModels.kt similarity index 96% rename from app/src/main/java/com/aryan/reader/pdf/OcrModels.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/OcrModels.kt index b7e4dc5..af5e1f7 100644 --- a/app/src/main/java/com/aryan/reader/pdf/OcrModels.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/OcrModels.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf.ocr +package org.dueattendant149.bookreader.pdf.ocr import android.graphics.Rect diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfAnnotationModels.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfAnnotationModels.kt similarity index 83% rename from app/src/main/java/com/aryan/reader/pdf/PdfAnnotationModels.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfAnnotationModels.kt index af6df30..c18ce01 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfAnnotationModels.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfAnnotationModels.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf enum class AnnotationType { INK, TEXT diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfBubblePrefetch.kt similarity index 94% rename from app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfBubblePrefetch.kt index 6c19bcd..36319d8 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfBubblePrefetch.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf internal const val PDF_BUBBLE_PREFETCH_RADIUS = 1 diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfBubbleZoom.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfBubbleZoom.kt similarity index 97% rename from app/src/main/java/com/aryan/reader/pdf/PdfBubbleZoom.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfBubbleZoom.kt index ce292e3..1515351 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfBubbleZoom.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfBubbleZoom.kt @@ -1,9 +1,9 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.graphics.Bitmap import android.graphics.RectF import androidx.core.graphics.createBitmap -import com.aryan.reader.ml.SpeechBubble +import org.dueattendant149.bookreader.ml.SpeechBubble import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfCoverGenerator.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfCoverGenerator.kt similarity index 98% rename from app/src/main/java/com/aryan/reader/pdf/PdfCoverGenerator.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfCoverGenerator.kt index 2611dd5..7b72a67 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfCoverGenerator.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfCoverGenerator.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import android.graphics.Bitmap diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDialogs.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfDialogs.kt similarity index 98% rename from app/src/main/java/com/aryan/reader/pdf/PdfDialogs.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfDialogs.kt index 10f6fd9..28226db 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfDialogs.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfDialogs.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -38,7 +38,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import com.aryan.reader.R +import org.dueattendant149.bookreader.R @Composable internal fun PasswordDialog(isError: Boolean, onDismiss: () -> Unit, onConfirm: (String) -> Unit) { diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfDocumentUtils.kt similarity index 77% rename from app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfDocumentUtils.kt index e1a789d..a2c4b14 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfDocumentUtils.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import android.graphics.Bitmap @@ -13,14 +13,15 @@ import android.print.PrintDocumentInfo import android.provider.OpenableColumns import android.util.LruCache import androidx.core.graphics.createBitmap -import com.aryan.reader.pdf.data.PdfAnnotation -import com.aryan.reader.pdf.data.PdfTextBox +import org.dueattendant149.bookreader.pdf.data.PdfAnnotation +import org.dueattendant149.bookreader.pdf.data.PdfTextBox import io.legere.pdfiumandroid.suspend.PdfiumCoreKt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber +import java.io.ByteArrayOutputStream import java.io.FileInputStream import java.io.FileOutputStream import kotlin.math.roundToInt @@ -30,6 +31,8 @@ import kotlin.random.Random private const val PDF_PREVIEW_MAX_WIDTH_PX = 1080 private const val PDF_PREVIEW_MAX_HEIGHT_PX = 2048 private const val PDF_PREVIEW_MAX_BYTES = 16L * 1024L * 1024L +private const val PDF_ENCRYPT_MARKER_TAIL_BYTES = 512 * 1024 +private val PDF_ENCRYPT_MARKER = "/Encrypt".toByteArray(Charsets.US_ASCII) object PdfiumCoreProvider { val core: PdfiumCoreKt by lazy { @@ -42,7 +45,8 @@ internal data class DocumentCacheItem( val pfd: ParcelFileDescriptor?, val totalPages: Int, val pageAspectRatios: List, - val flatTableOfContents: List + val flatTableOfContents: List, + val isPasswordProtectedPdf: Boolean = false ) internal class DocumentCache(val maxSize: Int = 3) { @@ -123,6 +127,68 @@ class PdfPrintDocumentAdapter( } } +internal fun pdfBytesContainEncryptMarker(bytes: ByteArray): Boolean { + for (index in 0..bytes.size - PDF_ENCRYPT_MARKER.size) { + var matches = true + for (offset in PDF_ENCRYPT_MARKER.indices) { + if (bytes[index + offset] != PDF_ENCRYPT_MARKER[offset]) { + matches = false + break + } + } + if (matches && bytes.getOrNull(index + PDF_ENCRYPT_MARKER.size)?.isPdfNameDelimiter() != false) { + return true + } + } + return false +} + +internal fun isPdfLikelyEncryptedForPrint(context: Context, uri: Uri): Boolean { + return try { + context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> + FileInputStream(pfd.fileDescriptor).use { input -> + val knownSize = pfd.statSize.takeIf { it >= 0L } + ?: runCatching { input.channel.size() }.getOrNull()?.takeIf { it >= 0L } + val tailBytes = if (knownSize != null && knownSize > PDF_ENCRYPT_MARKER_TAIL_BYTES) { + input.channel.position(knownSize - PDF_ENCRYPT_MARKER_TAIL_BYTES) + input.readBytes() + } else if (knownSize != null) { + input.readBytes() + } else { + input.readLastBytes(PDF_ENCRYPT_MARKER_TAIL_BYTES) + } + pdfBytesContainEncryptMarker(tailBytes) + } + } ?: false + } catch (e: Exception) { + Timber.tag("PdfPrint").w(e, "Could not inspect PDF encryption marker before print") + false + } +} + +private fun Byte.isPdfNameDelimiter(): Boolean { + return when (toInt().toChar()) { + '\u0000', '\t', '\n', '\u000C', '\r', ' ', '(', ')', '<', '>', '[', ']', '{', '}', '/', '%' -> true + else -> false + } +} + +private fun FileInputStream.readLastBytes(maxBytes: Int): ByteArray { + val output = ByteArrayOutputStream(maxBytes) + val buffer = ByteArray(8192) + var bytesRead: Int + while (read(buffer).also { bytesRead = it } > 0) { + if (output.size() + bytesRead <= maxBytes) { + output.write(buffer, 0, bytesRead) + } else { + val combined = output.toByteArray() + buffer.copyOf(bytesRead) + output.reset() + output.write(combined, combined.size - maxBytes, maxBytes) + } + } + return output.toByteArray() +} + internal fun generateShortId(): String { return Random.nextInt(1000, 9999).toString() } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfDrawer.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfDrawer.kt index ebfe680..88ecfa1 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfDrawer.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.Image @@ -73,7 +73,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.aryan.reader.R +import org.dueattendant149.bookreader.R import io.legere.pdfiumandroid.api.Bookmark import io.legere.pdfiumandroid.suspend.PdfDocumentKt import kotlinx.coroutines.delay @@ -82,9 +82,9 @@ import kotlinx.coroutines.withContext import org.json.JSONArray import timber.log.Timber import androidx.core.graphics.createBitmap -import com.aryan.reader.cardTitle -import com.aryan.reader.data.RecentFileItem -import com.aryan.reader.pdf.data.VirtualPage +import org.dueattendant149.bookreader.cardTitle +import org.dueattendant149.bookreader.data.RecentFileItem +import org.dueattendant149.bookreader.pdf.data.VirtualPage private const val MAX_FIXED_RECURSION = 128 diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfEmbeddedAnnotations.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfEmbeddedAnnotations.kt similarity index 97% rename from app/src/main/java/com/aryan/reader/pdf/PdfEmbeddedAnnotations.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfEmbeddedAnnotations.kt index bf62806..08d8a38 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfEmbeddedAnnotations.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfEmbeddedAnnotations.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.graphics.RectF import timber.log.Timber diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfExporter.kt similarity index 100% rename from app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfExporter.kt diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfHelper.kt similarity index 96% rename from app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfHelper.kt index e41b138..08d7f07 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfHelper.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.graphics.Bitmap import android.graphics.Rect @@ -90,13 +90,17 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties -import com.aryan.reader.OcrEngine -import com.aryan.reader.R -import com.aryan.reader.pdf.ocr.OcrElement -import com.aryan.reader.pdf.ocr.OcrLine -import com.aryan.reader.pdf.ocr.OcrResult -import com.aryan.reader.pdf.ocr.OcrSymbol -import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment +import org.dueattendant149.bookreader.OcrEngine +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.pdf.ocr.OcrElement +import org.dueattendant149.bookreader.pdf.ocr.OcrLine +import org.dueattendant149.bookreader.pdf.ocr.OcrResult +import org.dueattendant149.bookreader.pdf.ocr.OcrSymbol +import org.dueattendant149.bookreader.shared.pdf.DEFAULT_SHARED_PDF_COMMENT_AUTHOR +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationComment +import org.dueattendant149.bookreader.shared.pdf.pdfCommentChildren +import org.dueattendant149.bookreader.shared.pdf.visiblePdfAnnotationComments +import org.dueattendant149.bookreader.shared.pdf.withoutPdfCommentThread import timber.log.Timber import java.text.DateFormat import java.util.Date @@ -708,8 +712,6 @@ private enum class PdfAnnotationSheetSection { COMMENTS } -private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader" - @OptIn(ExperimentalMaterial3Api::class) @Composable fun PdfAnnotationBottomSheet( @@ -740,7 +742,7 @@ fun PdfAnnotationBottomSheet( highlight.comments .lastOrNull { it.author.isNotBlank() } ?.author - ?: DEFAULT_PDF_COMMENT_AUTHOR + ?: DEFAULT_SHARED_PDF_COMMENT_AUTHOR ) } @@ -844,14 +846,14 @@ fun PdfAnnotationBottomSheet( editingCommentId = comment.id replyTargetId = null commentText = comment.contents - commentAuthor = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR } + commentAuthor = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } }, onCancelEdit = { editingCommentId = null commentText = "" }, onDelete = { comment -> - val nextComments = comments.withoutCommentThread(comment.id) + val nextComments = comments.withoutPdfCommentThread(comment.id) persistComments(nextComments) if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) { replyTargetId = null @@ -865,7 +867,7 @@ fun PdfAnnotationBottomSheet( val contents = commentText.trim() if (contents.isNotBlank()) { val now = System.currentTimeMillis() - val author = commentAuthor.trim().ifBlank { DEFAULT_PDF_COMMENT_AUTHOR } + val author = commentAuthor.trim().ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } val nextComments = if (editingCommentId != null) { comments.map { comment -> if (comment.id == editingCommentId) { @@ -1005,16 +1007,7 @@ private fun PdfHighlightCommentsEditor( onDelete: (SharedPdfAnnotationComment) -> Unit, onAddComment: () -> Unit ) { - val commentIds = comments.filter { it.contents.isNotBlank() }.map { it.id }.toSet() - val visibleComments = comments - .filter { it.contents.isNotBlank() } - .map { comment -> - if (comment.parentId != null && comment.parentId !in commentIds) { - comment.copy(parentId = null) - } else { - comment - } - } + val visibleComments = comments.visiblePdfAnnotationComments() val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId } val editingComment = visibleComments.firstOrNull { it.id == editingCommentId } @@ -1048,7 +1041,7 @@ private fun PdfHighlightCommentsEditor( } else { stringResource( R.string.label_replying_to, - replyTarget?.author?.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }.orEmpty() + replyTarget?.author?.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }.orEmpty() ) }, style = MaterialTheme.typography.labelMedium, @@ -1115,8 +1108,7 @@ private fun PdfHighlightCommentThread( onDelete: (SharedPdfAnnotationComment) -> Unit ) { comments - .filter { it.parentId == parentId } - .sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id })) + .pdfCommentChildren(parentId) .forEach { comment -> if (comment.id in visitedIds) return@forEach PdfHighlightCommentItem( @@ -1167,7 +1159,7 @@ private fun PdfHighlightCommentItem( Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }, + text = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, @@ -1216,19 +1208,6 @@ private fun pdfAnnotationTextFieldColors(effectiveText: Color) = unfocusedTextColor = effectiveText ) -private fun List.withoutCommentThread(commentId: String): List { - val childrenByParentId = groupBy { it.parentId } - val idsToRemove = mutableSetOf() - - fun collect(id: String) { - if (!idsToRemove.add(id)) return - childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) } - } - - collect(commentId) - return filterNot { it.id in idsToRemove } -} - private fun Long.formatPdfCommentTimestamp(): String { if (this <= 0L) return "" return runCatching { diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfModels.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfModels.kt similarity index 55% rename from app/src/main/java/com/aryan/reader/pdf/PdfModels.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfModels.kt index 0831a0b..bde90c7 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfModels.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfModels.kt @@ -1,24 +1,24 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import androidx.annotation.OptIn import androidx.core.content.edit import androidx.media3.common.util.UnstableApi -import com.aryan.reader.pdf.data.PdfAnnotation -import com.aryan.reader.tts.TtsPlaybackManager +import org.dueattendant149.bookreader.pdf.data.PdfAnnotation +import org.dueattendant149.bookreader.tts.TtsPlaybackManager -internal typealias SaveMode = com.aryan.reader.shared.SaveMode +internal typealias SaveMode = org.dueattendant149.bookreader.shared.SaveMode -typealias SearchHighlightMode = com.aryan.reader.shared.SearchHighlightMode +typealias SearchHighlightMode = org.dueattendant149.bookreader.shared.SearchHighlightMode internal sealed interface HistoryAction { data class Add(val pageIndex: Int, val annotation: PdfAnnotation) : HistoryAction data class Remove(val items: Map>) : HistoryAction } -internal typealias DockLocation = com.aryan.reader.shared.DockLocation +internal typealias DockLocation = org.dueattendant149.bookreader.shared.DockLocation -internal typealias DisplayMode = com.aryan.reader.shared.PdfDisplayMode +internal typealias DisplayMode = org.dueattendant149.bookreader.shared.PdfDisplayMode @OptIn(UnstableApi::class) @Suppress("unused") diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfNavigationUI.kt similarity index 86% rename from app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfNavigationUI.kt index a42e8af..f731406 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfNavigationUI.kt @@ -1,13 +1,10 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf -import android.graphics.Bitmap import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -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.Orientation @@ -21,7 +18,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -38,10 +34,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -49,7 +42,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp -import com.aryan.reader.R +import org.dueattendant149.bookreader.R private data class ScrollbarCalculations( val thumbHeight: Float, @@ -195,42 +188,6 @@ internal fun PageScrubbingAnimation( } } -@Composable -internal fun ThumbnailWithIndicator( - thumbnail: Bitmap, - modifier: Modifier = Modifier, - borderColor: Color = Color.Unspecified, - onClick: () -> Unit -) { - val effectiveBorderColor = if (borderColor == Color.Unspecified) { - MaterialTheme.colorScheme.primary - } else { - borderColor - } - Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { - Surface( - modifier = Modifier - .width(45.dp) - .height(64.dp) - .clickable(onClick = onClick), - shape = RoundedCornerShape(4.dp), - border = BorderStroke(2.dp, effectiveBorderColor) - ) { - Image( - bitmap = thumbnail.asImageBitmap(), - contentDescription = stringResource(R.string.content_desc_start_page_thumbnail), - contentScale = ContentScale.FillBounds, - modifier = Modifier.fillMaxSize() - ) - } - Box(modifier = Modifier - .offset(y = (-4).dp) - .size(8.dp) - .rotate(45f) - .background(effectiveBorderColor)) - } -} - @Composable internal fun BookmarkButton( isBookmarked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfOneHandZoom.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfOneHandZoom.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/PdfOneHandZoom.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfOneHandZoom.kt index ee5afc4..a1f3a32 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfOneHandZoom.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfOneHandZoom.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageAnnotationRemapping.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageAnnotationRemapping.kt similarity index 96% rename from app/src/main/java/com/aryan/reader/pdf/PdfPageAnnotationRemapping.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageAnnotationRemapping.kt index 50ae6e1..d64bfd4 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageAnnotationRemapping.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageAnnotationRemapping.kt @@ -1,8 +1,8 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf -import com.aryan.reader.pdf.data.PdfAnnotation -import com.aryan.reader.pdf.data.PdfTextBox -import com.aryan.reader.pdf.data.VirtualPage +import org.dueattendant149.bookreader.pdf.data.PdfAnnotation +import org.dueattendant149.bookreader.pdf.data.PdfTextBox +import org.dueattendant149.bookreader.pdf.data.VirtualPage import org.json.JSONArray import org.json.JSONObject diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageComposable.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageComposable.kt index de8d13e..3399fdc 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageComposable.kt @@ -2,7 +2,7 @@ @file:Suppress( "RemoveRedundantQualifierName", "COMPOSE_APPLIER_CALL_MISMATCH", "UnusedVariable", "unused" ) -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.graphics.Bitmap import android.graphics.BitmapShader @@ -117,20 +117,20 @@ import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.zIndex import androidx.core.graphics.createBitmap import androidx.core.graphics.scale -import com.aryan.reader.R -import com.aryan.reader.SearchResult -import com.aryan.reader.isCanvasSafeBitmap -import com.aryan.reader.loadReaderTextureBitmap -import com.aryan.reader.ml.SpeechBubble -import com.aryan.reader.pdf.data.PdfAnnotation -import com.aryan.reader.pdf.data.PdfTextBox -import com.aryan.reader.pdf.data.VirtualPage -import com.aryan.reader.pdf.ocr.OcrElement -import com.aryan.reader.pdf.ocr.OcrResult -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.SearchResult +import org.dueattendant149.bookreader.isCanvasSafeBitmap +import org.dueattendant149.bookreader.loadReaderTextureBitmap +import org.dueattendant149.bookreader.ml.SpeechBubble +import org.dueattendant149.bookreader.pdf.data.PdfAnnotation +import org.dueattendant149.bookreader.pdf.data.PdfTextBox +import org.dueattendant149.bookreader.pdf.data.VirtualPage +import org.dueattendant149.bookreader.pdf.ocr.OcrElement +import org.dueattendant149.bookreader.pdf.ocr.OcrResult +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.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -166,7 +166,7 @@ private fun Throwable.readablePdfErrorDetail(): String { private const val PDF_TILE_SIZE_DP = 256 private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072 -private const val PDF_TILE_SCALE_TOLERANCE = 0.06f +private const val PDF_TILE_SCALE_TOLERANCE = 0.03f private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 60L private const val PDF_TILE_RENDER_IDLE_COOLDOWN_MS = 220L private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f @@ -266,7 +266,7 @@ internal fun PdfPageComposable( resetZoomTrigger: Long = 0L, onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null, onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null, - activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false), + activeTheme: org.dueattendant149.bookreader.ReaderTheme = org.dueattendant149.bookreader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false), activeTextureAlpha: Float = 0.55f, excludeImages: Boolean = false, onDoubleTap: ((Offset) -> Unit)? = null, @@ -462,7 +462,13 @@ internal fun PdfPageComposable( var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) } var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) } - val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage) + val needsTilingNow = shouldRenderPdfHighResTiles( + effectiveScale = effectiveScale, + targetWidthPx = actualBitmapWidthPx, + targetHeightPx = actualBitmapHeightPx, + isVerticalScroll = isVerticalScroll, + isActivePage = isActivePage + ) val canvasWidthPx = remember { mutableFloatStateOf(0f) } val canvasHeightPx = remember { mutableFloatStateOf(0f) } @@ -1241,7 +1247,7 @@ internal fun PdfPageComposable( } } - if (latestShouldPauseHighResTileRendering && renderScale > 1f) { + if (latestShouldPauseHighResTileRendering) { if (shouldLogTileSample) { PdfVerticalPerfLog.d( "tile-render-paused mode=$tileLogMode page=$pageIndex reason=motion scale=${PdfVerticalPerfLog.f(renderScale)} " + @@ -1260,7 +1266,7 @@ internal fun PdfPageComposable( } delay(PDF_TILE_IDLE_RENDER_DELAY_MS) if (!isActive) return@collectLatest - if (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) { + if (latestShouldPauseHighResTileRendering) { if (shouldLogHighResTile) { PdfVerticalPerfLog.d( "tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-resumed missing=${tilesToRenderIds.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" @@ -1323,7 +1329,7 @@ internal fun PdfPageComposable( ) } if (!isActive) return@withLock - if (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) { + if (latestShouldPauseHighResTileRendering) { if (shouldLogHighResTile) { PdfVerticalPerfLog.d( "tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-started-before-native tile=$tileId scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" @@ -1380,7 +1386,7 @@ internal fun PdfPageComposable( } return@collectLatest } - if (renderedTiles.isNotEmpty() && latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) { + if (renderedTiles.isNotEmpty() && latestShouldPauseHighResTileRendering) { if (shouldLogHighResTile) { PdfVerticalPerfLog.d( "tile-render-discarded mode=$tileLogMode page=$pageIndex reason=motion-before-commit rendered=${renderedTiles.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" @@ -4040,9 +4046,9 @@ internal fun PdfPageComposable( val stableTiles = remember(tiles) { StableHolder(tiles) } val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) } val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) } - val shouldDrawHighResTiles = !shouldPauseHighResTileRendering + val shouldDrawHighResTiles = !shouldPauseHighResTileRendering && needsTilingNow LaunchedEffect(shouldDrawHighResTiles, stableTiles.item.size, effectiveScale) { - if (stableTiles.item.isNotEmpty() && effectiveScale > 1f) { + if (stableTiles.item.isNotEmpty() && shouldDrawHighResTiles) { PdfVerticalPerfLog.d( "tile-display mode=${if (isVerticalScroll) "vertical" else "pagination"} page=$pageIndex " + "visible=$shouldDrawHighResTiles tiles=${stableTiles.item.size} pause=$shouldPauseHighResTileRendering " + @@ -4513,8 +4519,7 @@ private fun PdfBitmapLayer( } } - val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000 - if (needsTiling && shouldDrawHighResTiles) { + if (shouldDrawHighResTiles) { tiles.forEach { tile -> if ( tile.bitmap.isCanvasSafeBitmap( @@ -5353,7 +5358,7 @@ private fun PdfPageRenderer( ) { MagnifierComposable( sourceBitmap = staticData.bitmap.item.asImageBitmap(), - tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(), + tiles = if (staticData.shouldDrawHighResTiles) staticData.tiles.item else emptyList(), currentScale = effectiveScale, magnifierCenterOnBitmap = magnifierCenterTarget, contentWidthPx = staticData.targetWidth, @@ -5713,10 +5718,11 @@ fun PdfRichTextLayer( val selection = tfv.selection @Suppress("ControlFlowWithEmptyBody") if (controller.activePageIndex == pageIndex) { - val localStart = selection.start.coerceIn(0, textToRender.length) - val localEnd = selection.end.coerceIn(0, textToRender.length) - - if (localStart != localEnd) { + androidPdfRichTextSelectionBounds( + selectionStart = selection.start, + selectionEnd = selection.end, + textLength = textToRender.length + )?.let { (localStart, localEnd) -> val selectionPath = measureResult.getPathForRange(localStart, localEnd) Canvas(modifier = Modifier.fillMaxSize()) { drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f)) @@ -5724,6 +5730,7 @@ fun PdfRichTextLayer( } if (selection.collapsed && controller.isCursorVisible) { + val localStart = selection.start.coerceIn(0, textToRender.length) val alpha = if (isScrolling) { 1f } else { diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageLayoutDebug.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageLayoutDebug.kt similarity index 89% rename from app/src/main/java/com/aryan/reader/pdf/PdfPageLayoutDebug.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageLayoutDebug.kt index fd22157..e376c40 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageLayoutDebug.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageLayoutDebug.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf -import com.aryan.reader.pdf.data.VirtualPage +import org.dueattendant149.bookreader.pdf.data.VirtualPage internal const val PDF_BLANK_PAGE_PERSISTENCE_TAG = "PdfBlankPagePersist" diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageLinks.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageLinks.kt similarity index 85% rename from app/src/main/java/com/aryan/reader/pdf/PdfPageLinks.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageLinks.kt index 72e0f04..3bc074d 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageLinks.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageLinks.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.graphics.Rect -import com.aryan.reader.pdf.data.VirtualPage +import org.dueattendant149.bookreader.pdf.data.VirtualPage enum class LinkSource { ANNOTATION, TEXT_CONTENT diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageRenderResources.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageRenderResources.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/PdfPageRenderResources.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageRenderResources.kt index 4b406db..eabb202 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageRenderResources.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPageRenderResources.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.graphics.Bitmap import android.graphics.Rect @@ -14,7 +14,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.core.graphics.createBitmap import androidx.core.graphics.set -import com.aryan.reader.pdf.data.PdfAnnotation +import org.dueattendant149.bookreader.pdf.data.PdfAnnotation import timber.log.Timber import java.util.concurrent.ConcurrentLinkedQueue import kotlin.math.PI diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPreferences.kt similarity index 90% rename from app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPreferences.kt index 30952c6..101a527 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfPreferences.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import androidx.annotation.StringRes @@ -6,11 +6,11 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.core.content.edit -import com.aryan.reader.BuildConfig -import com.aryan.reader.R -import com.aryan.reader.epubreader.SystemUiMode -import com.aryan.reader.shared.BuiltInPdfReaderThemes -import com.aryan.reader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.BuildConfig +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.epubreader.SystemUiMode +import org.dueattendant149.bookreader.shared.BuiltInPdfReaderThemes +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll" internal const val SETTINGS_PREFS_NAME = "epub_reader_settings" @@ -115,12 +115,48 @@ private fun sanitizePdfToolNameSet( }.toSet() } +internal fun sanitizePdfHiddenToolNames(toolNames: Collection): Set { + return sanitizePdfToolNameSet(toolNames.toSet()) +} + +internal fun sanitizePdfBottomToolNames(toolNames: Collection): Set { + return sanitizePdfToolNameSet( + toolNames = toolNames.toSet(), + includeTool = ::isPdfToolbarPlacementTool + ) +} + +internal fun restorePdfToolOrderNames(toolNames: Collection): List { + val savedTools = toolNames + .mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } } + .filter(::isPdfReaderToolAvailable) + return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct() +} + +internal fun isPdfToolbarPlacementTool(tool: PdfReaderTool): Boolean { + return when (tool) { + PdfReaderTool.DICTIONARY, + PdfReaderTool.THEME, + PdfReaderTool.BRIGHTNESS, + PdfReaderTool.LOCK_PANNING, + PdfReaderTool.SLIDER, + PdfReaderTool.TOC, + PdfReaderTool.SEARCH, + PdfReaderTool.HIGHLIGHT_ALL, + PdfReaderTool.AI_FEATURES, + PdfReaderTool.EDIT_MODE, + PdfReaderTool.TTS_CONTROLS, + PdfReaderTool.SCREEN_ORIENTATION -> true + else -> false + } +} + internal fun loadPdfHiddenTools(context: Context): Set { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - val savedHiddenTools = sanitizePdfToolNameSet(prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty()) + val savedHiddenTools = sanitizePdfHiddenToolNames(prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty()) val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0) if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) { - val migratedHiddenTools = sanitizePdfToolNameSet(savedHiddenTools + pdfHiddenToolsIntroducedAfter(defaultsVersion)) + val migratedHiddenTools = sanitizePdfHiddenToolNames(savedHiddenTools + pdfHiddenToolsIntroducedAfter(defaultsVersion)) prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools) putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) @@ -143,28 +179,27 @@ private fun pdfHiddenToolsIntroducedAfter(defaultsVersion: Int): Set { internal fun savePdfHiddenTools(context: Context, hiddenTools: Set) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { - putStringSet(PDF_HIDDEN_TOOLS_KEY, sanitizePdfToolNameSet(hiddenTools)) + putStringSet(PDF_HIDDEN_TOOLS_KEY, sanitizePdfHiddenToolNames(hiddenTools)) putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) } } internal fun loadPdfToolOrder(context: Context): List { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - val savedTools = prefs.getString(PDF_TOOL_ORDER_KEY, null) + val savedToolNames = prefs.getString(PDF_TOOL_ORDER_KEY, null) ?.split(',') ?.filter { it.isNotBlank() } - ?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } } - ?.filter(::isPdfReaderToolAvailable) .orEmpty() - return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct() + return restorePdfToolOrderNames(savedToolNames) } internal fun savePdfToolOrder(context: Context, toolOrder: List) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + val sanitizedOrder = restorePdfToolOrderNames(toolOrder.map { it.name }) prefs.edit { putString( PDF_TOOL_ORDER_KEY, - toolOrder.filter(::isPdfReaderToolAvailable).joinToString(",") { it.name } + sanitizedOrder.joinToString(",") { it.name } ) } } @@ -172,22 +207,15 @@ internal fun savePdfToolOrder(context: Context, toolOrder: List) internal fun loadPdfBottomTools(context: Context): Set { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val defaultBottomTools = defaultPdfBottomTools() - return sanitizePdfToolNameSet( - toolNames = prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools, - includeTool = { it.category == "Bottom Bar" } - ) + val savedBottomTools = prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, null) ?: return defaultBottomTools + val sanitizedBottomTools = sanitizePdfBottomToolNames(savedBottomTools) + return if (savedBottomTools.isNotEmpty() && sanitizedBottomTools.isEmpty()) defaultBottomTools else sanitizedBottomTools } internal fun savePdfBottomTools(context: Context, bottomTools: Set) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { - putStringSet( - PDF_BOTTOM_TOOLS_KEY, - sanitizePdfToolNameSet( - toolNames = bottomTools, - includeTool = { it.category == "Bottom Bar" } - ) - ) + putStringSet(PDF_BOTTOM_TOOLS_KEY, sanitizePdfBottomToolNames(bottomTools)) } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfSearchUI.kt similarity index 98% rename from app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfSearchUI.kt index f0bbb43..fdd6bd2 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfSearchUI.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -41,8 +41,8 @@ import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemContentType -import com.aryan.reader.R -import com.aryan.reader.SearchResult +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.SearchResult @Composable internal fun SearchNavigationPill( diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfSettingsSheets.kt similarity index 96% rename from app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfSettingsSheets.kt index e0fa480..b3b5952 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfSettingsSheets.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalMaterial3Api::class) -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.foundation.clickable import androidx.annotation.StringRes @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures @@ -28,8 +29,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +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.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Edit @@ -64,17 +67,19 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.painterResource 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.compose.ui.window.DialogProperties -import com.aryan.reader.R -import com.aryan.reader.epubreader.OptionSegmentedControl -import com.aryan.reader.epubreader.SystemUiMode -import com.aryan.reader.epubreader.titleRes -import com.aryan.reader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.epubreader.OptionSegmentedControl +import org.dueattendant149.bookreader.epubreader.SystemUiMode +import org.dueattendant149.bookreader.epubreader.titleRes +import org.dueattendant149.bookreader.readerModalMaxHeightDp +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode enum class PdfFlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL } @@ -116,25 +121,17 @@ fun sanitizePdfPlaceholders(list: List): List return result } -private val pdfReorderableToolbarTools = setOf( - PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.BRIGHTNESS, PdfReaderTool.LOCK_PANNING, - PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH, - PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES, - PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS, - PdfReaderTool.SCREEN_ORIENTATION -) - internal fun buildPdfToolbarItems( hiddenTools: Set, toolOrder: List, bottomTools: Set ): List { val availableToolOrder = toolOrder.filter(::isPdfReaderToolAvailable) - val toolbarTools = availableToolOrder.filter { it in pdfReorderableToolbarTools } + val toolbarTools = availableToolOrder.filter(::isPdfToolbarPlacementTool) val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) } - val moreTools = availableToolOrder.filter { it !in pdfReorderableToolbarTools } + val moreTools = availableToolOrder.filterNot(::isPdfToolbarPlacementTool) val list = mutableListOf() @@ -209,7 +206,7 @@ fun PdfCustomizeToolsSheet( val commitDragDrop = { val newHidden = localHiddenTools.filter { toolName -> - toolOrder.find { it.name == toolName } !in pdfReorderableToolbarTools + toolOrder.find { it.name == toolName }?.let(::isPdfToolbarPlacementTool) != true }.toMutableSet() val newBottom = mutableSetOf() @@ -576,6 +573,8 @@ fun PdfVisualOptionsSheet( onDismiss: () -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val configuration = LocalConfiguration.current + val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, @@ -585,6 +584,8 @@ fun PdfVisualOptionsSheet( Column( modifier = Modifier .fillMaxWidth() + .heightIn(max = maxSheetHeight) + .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 8.dp) .padding(bottom = 32.dp) ) { diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfTextBox.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfTextBox.kt index 4686c32..017e411 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfTextBox.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -47,7 +47,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.aryan.reader.R +import org.dueattendant149.bookreader.R import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset @@ -71,7 +71,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import com.aryan.reader.pdf.data.PdfTextBox +import org.dueattendant149.bookreader.pdf.data.PdfTextBox import timber.log.Timber import kotlin.math.roundToInt diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfToHtmlGenerator.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfToHtmlGenerator.kt index f613fd5..e1e27a4 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfToHtmlGenerator.kt @@ -1,5 +1,5 @@ // PdfToHtmlGenerator.kt -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import android.graphics.Bitmap diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfToolbars.kt similarity index 82% rename from app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfToolbars.kt index fc6ad15..baa4855 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfToolbars.kt @@ -1,5 +1,5 @@ // PdfToolbars.kt -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -13,7 +13,7 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import com.aryan.reader.data.RecentFileItem +import org.dueattendant149.bookreader.data.RecentFileItem import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -37,34 +37,19 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.sp -import com.aryan.reader.BuildConfig -import com.aryan.reader.FileType -import com.aryan.reader.R -import com.aryan.reader.SearchState -import com.aryan.reader.SearchTopBar -import com.aryan.reader.TooltipIconButton -import com.aryan.reader.areReaderAiFeaturesEnabled -import com.aryan.reader.cardTitle -import com.aryan.reader.epubreader.SystemUiMode +import org.dueattendant149.bookreader.BuildConfig +import org.dueattendant149.bookreader.FileType +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.SearchState +import org.dueattendant149.bookreader.SearchTopBar +import org.dueattendant149.bookreader.TooltipIconButton +import org.dueattendant149.bookreader.areReaderAiFeaturesEnabled +import org.dueattendant149.bookreader.cardTitle +import org.dueattendant149.bookreader.epubreader.SystemUiMode import kotlin.collections.isNotEmpty internal val PdfTabStripHeight = 44.dp -private val pdfToolbarTools = setOf( - PdfReaderTool.DICTIONARY, - PdfReaderTool.THEME, - PdfReaderTool.BRIGHTNESS, - PdfReaderTool.LOCK_PANNING, - PdfReaderTool.SLIDER, - PdfReaderTool.TOC, - PdfReaderTool.SEARCH, - PdfReaderTool.HIGHLIGHT_ALL, - PdfReaderTool.AI_FEATURES, - PdfReaderTool.EDIT_MODE, - PdfReaderTool.TTS_CONTROLS, - PdfReaderTool.SCREEN_ORIENTATION -) - internal enum class PdfOverflowMenuSection { CUSTOMIZE_TOOLBAR, HIDDEN_TOOLS, @@ -87,7 +72,8 @@ internal fun pdfOverflowMenuSections( hasHiddenToolbarTools: Boolean, isPro: Boolean, effectiveFileType: FileType, - hasFileInfo: Boolean = true + hasFileInfo: Boolean = true, + canPrintDocument: Boolean = true ): List = buildList { add(PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR) if (hasHiddenToolbarTools) add(PdfOverflowMenuSection.HIDDEN_TOOLS) @@ -111,7 +97,7 @@ internal fun pdfOverflowMenuSections( if ( !hiddenTools.contains(PdfReaderTool.SHARE.name) || (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) || - (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) + (effectiveFileType == FileType.PDF && canPrintDocument && !hiddenTools.contains(PdfReaderTool.PRINT.name)) ) { add(PdfOverflowMenuSection.FILE_ACTIONS) } @@ -151,6 +137,7 @@ internal fun PdfTopBar( isReflowingThisBook: Boolean, hasReflowFile: Boolean, isPdfDocumentLoaded: Boolean, + canPrintDocument: Boolean = true, isTabsEnabled: Boolean, openTabs: List, activeTabBookId: String?, @@ -243,6 +230,8 @@ internal fun PdfTopBar( totalPages > 0 && pagerStatePageCount == 0 -> stringResource(R.string.loading_page) else -> stringResource(R.string.pdf_viewer) } + val topToolbarTools = toolOrder + .filter { isPdfToolbarPlacementTool(it) && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } Text( text = titleText, style = MaterialTheme.typography.titleMedium, @@ -251,117 +240,126 @@ internal fun PdfTopBar( modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator") ) - toolOrder - .filter { it in pdfToolbarTools && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } - .forEach { tool -> - when (tool) { - PdfReaderTool.THEME -> TooltipIconButton( - text = stringResource(R.string.tooltip_theme), - description = stringResource(R.string.tooltip_theme_desc), - onClick = onShowThemePanel - ) { - Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - PdfReaderTool.BRIGHTNESS -> TooltipIconButton( - text = stringResource(R.string.reader_brightness_title), - description = stringResource(R.string.reader_brightness_system_desc), - onClick = onShowBrightnessControl - ) { - Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - PdfReaderTool.LOCK_PANNING -> TooltipIconButton( - text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), - description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc), - onClick = onToggleScrollLock - ) { - Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - PdfReaderTool.DICTIONARY -> TooltipIconButton( - text = stringResource(R.string.tooltip_dictionary), - description = stringResource(R.string.tooltip_dictionary_desc), - onClick = onShowDictionarySettings - ) { - Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - PdfReaderTool.SLIDER -> TooltipIconButton( - text = stringResource(R.string.tooltip_slider), - description = stringResource(R.string.tooltip_slider_desc), - onClick = onShowSlider, - enabled = !isTtsPlayingOrLoading - ) { - Icon( - painterResource(id = R.drawable.slider), - contentDescription = stringResource(R.string.content_desc_navigate_slider), - tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - PdfReaderTool.TOC -> TooltipIconButton( - text = stringResource(R.string.tooltip_toc), - description = stringResource(R.string.tooltip_toc_desc), - onClick = onShowToc, - enabled = !isTtsPlayingOrLoading - ) { - Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents)) - } - PdfReaderTool.SEARCH -> TooltipIconButton( - text = stringResource(R.string.tooltip_search), - description = stringResource(R.string.tooltip_search_desc), - onClick = onSearchClick, - enabled = !isTtsPlayingOrLoading - ) { - Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search)) - } - PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton( - text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights), - description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc), - onClick = onToggleHighlights - ) { - if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp)) - else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) - } - PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) { - TooltipIconButton( - text = stringResource(R.string.tooltip_ai), - description = stringResource(R.string.tooltip_ai_desc), - onClick = onShowAiHub + if (topToolbarTools.isNotEmpty() || BuildConfig.DEBUG) { + val topToolbarScrollState = rememberScrollState() + Row( + modifier = Modifier + .weight(1f, fill = false) + .horizontalScroll(topToolbarScrollState), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + topToolbarTools.forEach { tool -> + when (tool) { + PdfReaderTool.THEME -> TooltipIconButton( + text = stringResource(R.string.tooltip_theme), + description = stringResource(R.string.tooltip_theme_desc), + onClick = onShowThemePanel ) { - Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai)) + Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant) } + PdfReaderTool.BRIGHTNESS -> TooltipIconButton( + text = stringResource(R.string.reader_brightness_title), + description = stringResource(R.string.reader_brightness_system_desc), + onClick = onShowBrightnessControl + ) { + Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + PdfReaderTool.LOCK_PANNING -> TooltipIconButton( + text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), + description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc), + onClick = onToggleScrollLock + ) { + Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + PdfReaderTool.DICTIONARY -> TooltipIconButton( + text = stringResource(R.string.tooltip_dictionary), + description = stringResource(R.string.tooltip_dictionary_desc), + onClick = onShowDictionarySettings + ) { + Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + PdfReaderTool.SLIDER -> TooltipIconButton( + text = stringResource(R.string.tooltip_slider), + description = stringResource(R.string.tooltip_slider_desc), + onClick = onShowSlider, + enabled = !isTtsPlayingOrLoading + ) { + Icon( + painterResource(id = R.drawable.slider), + contentDescription = stringResource(R.string.content_desc_navigate_slider), + tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + PdfReaderTool.TOC -> TooltipIconButton( + text = stringResource(R.string.tooltip_toc), + description = stringResource(R.string.tooltip_toc_desc), + onClick = onShowToc, + enabled = !isTtsPlayingOrLoading + ) { + Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents)) + } + PdfReaderTool.SEARCH -> TooltipIconButton( + text = stringResource(R.string.tooltip_search), + description = stringResource(R.string.tooltip_search_desc), + onClick = onSearchClick, + enabled = !isTtsPlayingOrLoading + ) { + Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search)) + } + PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton( + text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights), + description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc), + onClick = onToggleHighlights + ) { + if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp)) + else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + } + PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) { + TooltipIconButton( + text = stringResource(R.string.tooltip_ai), + description = stringResource(R.string.tooltip_ai_desc), + onClick = onShowAiHub + ) { + Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai)) + } + } + PdfReaderTool.EDIT_MODE -> TooltipIconButton( + text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode), + description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc), + onClick = onToggleEditMode + ) { + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + } + PdfReaderTool.TTS_CONTROLS -> TooltipIconButton( + text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start), + description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc), + onClick = onToggleTts + ) { + Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + } + PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton( + text = stringResource(R.string.menu_screen_orientation), + description = stringResource(R.string.visual_options_screen_orientation_desc), + onClick = onShowScreenOrientation + ) { + Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + else -> Unit } - PdfReaderTool.EDIT_MODE -> TooltipIconButton( - text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode), - description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc), - onClick = onToggleEditMode - ) { - Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) - } - PdfReaderTool.TTS_CONTROLS -> TooltipIconButton( - text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start), - description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc), - onClick = onToggleTts - ) { - Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) - } - PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton( - text = stringResource(R.string.menu_screen_orientation), - description = stringResource(R.string.visual_options_screen_orientation_desc), - onClick = onShowScreenOrientation - ) { - Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - else -> Unit } - } - if (BuildConfig.DEBUG) { - TooltipIconButton(text = stringResource(R.string.tooltip_demo_annotations), onClick = onGenerateDemoAnnotations) { - Icon(Icons.Default.BugReport, contentDescription = stringResource(R.string.content_desc_generate_demo_annotations), tint = MaterialTheme.colorScheme.secondary) - } - TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) { - Icon(Icons.Default.Star, contentDescription = stringResource(R.string.content_desc_open_pen_playground), tint = MaterialTheme.colorScheme.primary) - } - TooltipIconButton(text = stringResource(R.string.import_svg), onClick = onImportSvg) { - Icon(Icons.Default.Brush, contentDescription = stringResource(R.string.import_svg), tint = Color(0xFFE91E63)) + if (BuildConfig.DEBUG) { + TooltipIconButton(text = stringResource(R.string.tooltip_demo_annotations), onClick = onGenerateDemoAnnotations) { + Icon(Icons.Default.BugReport, contentDescription = stringResource(R.string.content_desc_generate_demo_annotations), tint = MaterialTheme.colorScheme.secondary) + } + TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) { + Icon(Icons.Default.Star, contentDescription = stringResource(R.string.content_desc_open_pen_playground), tint = MaterialTheme.colorScheme.primary) + } + TooltipIconButton(text = stringResource(R.string.import_svg), onClick = onImportSvg) { + Icon(Icons.Default.Brush, contentDescription = stringResource(R.string.import_svg), tint = Color(0xFFE91E63)) + } + } } } @@ -394,17 +392,18 @@ internal fun PdfTopBar( showMoreMenu = false } ) { - val hiddenToolbarTools = toolOrder.filter { it in pdfToolbarTools && hiddenTools.contains(it.name) } + val hiddenToolbarTools = toolOrder.filter { isPdfToolbarPlacementTool(it) && hiddenTools.contains(it.name) } val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name) val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name) val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name) val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name) - val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name) + val showPrintAction = effectiveFileType == FileType.PDF && canPrintDocument && !hiddenTools.contains(PdfReaderTool.PRINT.name) pdfOverflowMenuSections( hiddenTools = hiddenTools, hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(), isPro = BuildConfig.IS_PRO, - effectiveFileType = effectiveFileType + effectiveFileType = effectiveFileType, + canPrintDocument = canPrintDocument ).forEachIndexed { index, section -> if (index > 0) HorizontalDivider() when (section) { @@ -970,7 +969,7 @@ fun PdfBottomBar( horizontalArrangement = Arrangement.SpaceEvenly ) { toolOrder - .filter { it in pdfToolbarTools && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } + .filter { isPdfToolbarPlacementTool(it) && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } .forEach { tool -> when (tool) { PdfReaderTool.THEME -> TooltipIconButton( diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalPerfLog.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfVerticalPerfLog.kt similarity index 95% rename from app/src/main/java/com/aryan/reader/pdf/PdfVerticalPerfLog.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfVerticalPerfLog.kt index 7eee3f1..17d8b60 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalPerfLog.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfVerticalPerfLog.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import timber.log.Timber import kotlin.math.roundToInt diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfVerticalReader.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfVerticalReader.kt index 63cd25f..d6683a2 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfVerticalReader.kt @@ -20,7 +20,7 @@ // PdfVerticalReader.kt @file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "VariableNeverRead") -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.annotation.SuppressLint import android.graphics.Bitmap @@ -108,13 +108,13 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import com.aryan.reader.SearchResult -import com.aryan.reader.ml.SpeechBubble -import com.aryan.reader.pdf.data.PdfAnnotation -import com.aryan.reader.pdf.data.PdfTextBox -import com.aryan.reader.pdf.data.VirtualPage -import com.aryan.reader.shared.pdf.calculatePdfVerticalPageLayoutPx -import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp +import org.dueattendant149.bookreader.SearchResult +import org.dueattendant149.bookreader.ml.SpeechBubble +import org.dueattendant149.bookreader.pdf.data.PdfAnnotation +import org.dueattendant149.bookreader.pdf.data.PdfTextBox +import org.dueattendant149.bookreader.pdf.data.VirtualPage +import org.dueattendant149.bookreader.shared.pdf.calculatePdfVerticalPageLayoutPx +import org.dueattendant149.bookreader.shared.pdf.pdfVerticalPageGapDp import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope @@ -132,7 +132,7 @@ private const val SCROLL_BOUNDS_TAG = "PdfScrollBounds" private const val VERTICAL_TILE_RENDER_IDLE_COOLDOWN_MS = 220L internal fun resolvePdfVerticalPageBackgroundColor( - activeTheme: com.aryan.reader.ReaderTheme + activeTheme: org.dueattendant149.bookreader.ReaderTheme ): Color { val resolved = when (activeTheme.id) { "no_theme", "system" -> Color.White @@ -261,7 +261,7 @@ internal fun PdfVerticalReader( state: VerticalPdfReaderState, pdfDocument: StableHolder, documentKey: String, - activeTheme: com.aryan.reader.ReaderTheme, + activeTheme: org.dueattendant149.bookreader.ReaderTheme, activeTextureAlpha: Float = 0.55f, excludeImages: Boolean = false, totalPages: Int, diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfViewerScreen.kt similarity index 94% rename from app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfViewerScreen.kt index 66e9a60..130f591 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfViewerScreen.kt @@ -22,7 +22,7 @@ "SimplifyBooleanWithConstants" ) @file:kotlin.OptIn(ExperimentalMaterial3Api::class) -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.Manifest import android.annotation.SuppressLint @@ -95,6 +95,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.NavigateBefore +import androidx.compose.material.icons.automirrored.filled.NavigateNext import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Close @@ -114,7 +116,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -208,78 +209,86 @@ import androidx.lifecycle.viewModelScope import androidx.media3.common.util.UnstableApi import androidx.paging.compose.collectAsLazyPagingItems import androidx.work.WorkInfo -import com.aryan.reader.AiDefinitionPopup -import com.aryan.reader.AiDefinitionResult -import com.aryan.reader.AiFeature -import com.aryan.reader.AiHubBottomSheet -import com.aryan.reader.BuildConfig -import com.aryan.reader.FileType -import com.aryan.reader.HighlightColorPickerDialog -import com.aryan.reader.MainViewModel -import com.aryan.reader.R -import com.aryan.reader.ReaderBrightnessEffect -import com.aryan.reader.ReaderBrightnessSheet -import com.aryan.reader.ReaderFileInfoDialogs -import com.aryan.reader.ReaderScreenOrientationEffect -import com.aryan.reader.ReaderScreenOrientationSheet -import com.aryan.reader.ReaderThemePanel -import com.aryan.reader.SearchResult -import com.aryan.reader.SummarizationResult -import com.aryan.reader.SummaryCacheManager -import com.aryan.reader.TtsSettingsSheet -import com.aryan.reader.TtsWordReplacementsSheet -import com.aryan.reader.areReaderAiFeaturesEnabled -import com.aryan.reader.callByokGeminiInlineAi -import com.aryan.reader.epubreader.AutoScrollControls -import com.aryan.reader.epubreader.DictionarySettingsDialog -import com.aryan.reader.epubreader.ExternalDictionaryHelper -import com.aryan.reader.epubreader.SystemUiMode -import com.aryan.reader.epubreader.TtsOverlayControls -import com.aryan.reader.epubreader.loadTapToNavigateSetting -import com.aryan.reader.epubreader.saveTapToNavigateSetting -import com.aryan.reader.fetchAiDefinition -import com.aryan.reader.isByokCloudTtsAvailable -import com.aryan.reader.loadCustomThemes -import com.aryan.reader.loadGlobalTextureTransparency -import com.aryan.reader.loadPdfRightToLeftPagination -import com.aryan.reader.loadReaderBrightnessSettings -import com.aryan.reader.loadReaderScreenOrientationMode -import com.aryan.reader.loadReaderSliderToggled -import com.aryan.reader.loadTtsReplacementPreferences -import com.aryan.reader.ml.SpeechBubble -import com.aryan.reader.paginatedreader.TtsChunk -import com.aryan.reader.pdf.data.AnnotationSettingsRepository -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.SmartSearchResult -import com.aryan.reader.pdf.data.TextStyleConfig -import com.aryan.reader.pdf.data.VirtualPage -import com.aryan.reader.readerSliderBookmarkPosition -import com.aryan.reader.readerSliderChromeColors -import com.aryan.reader.readerSliderToggleState -import com.aryan.reader.rememberSearchState -import com.aryan.reader.saveCustomThemes -import com.aryan.reader.saveGlobalTextureTransparency -import com.aryan.reader.savePdfRightToLeftPagination -import com.aryan.reader.saveReaderBrightnessSettings -import com.aryan.reader.saveReaderScreenOrientationMode -import com.aryan.reader.saveReaderSliderToggled -import com.aryan.reader.saveTtsReplacementPreferences -import com.aryan.reader.scaledToCanvasLimit -import com.aryan.reader.shared.ReaderTtsReplacementPreferences -import com.aryan.reader.shared.pdf.PdfSpreadLayout -import com.aryan.reader.shared.reader.ReaderSettings -import com.aryan.reader.shouldRenderReaderSlider -import com.aryan.reader.summarizationUrl -import com.aryan.reader.tts.SpeakerSamplePlayer -import com.aryan.reader.tts.TtsPlaybackManager -import com.aryan.reader.tts.rememberTtsController -import com.aryan.reader.tts.splitTextIntoChunks -import com.aryan.reader.withTtsReplacements +import org.dueattendant149.bookreader.AiDefinitionPopup +import org.dueattendant149.bookreader.AiDefinitionResult +import org.dueattendant149.bookreader.AiFeature +import org.dueattendant149.bookreader.AiHubBottomSheet +import org.dueattendant149.bookreader.BuildConfig +import org.dueattendant149.bookreader.COMIC_ARCHIVE_FILE_TYPES +import org.dueattendant149.bookreader.FileType +import org.dueattendant149.bookreader.HighlightColorPickerDialog +import org.dueattendant149.bookreader.MainViewModel +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.ReaderBrightnessEffect +import org.dueattendant149.bookreader.ReaderBrightnessSheet +import org.dueattendant149.bookreader.ReaderFileInfoDialogs +import org.dueattendant149.bookreader.ReaderScreenOrientationEffect +import org.dueattendant149.bookreader.ReaderScreenOrientationSheet +import org.dueattendant149.bookreader.ReaderThemePanel +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.callByokGeminiInlineAi +import org.dueattendant149.bookreader.epubreader.AutoScrollControls +import org.dueattendant149.bookreader.epubreader.DictionarySettingsDialog +import org.dueattendant149.bookreader.epubreader.ExternalDictionaryHelper +import org.dueattendant149.bookreader.epubreader.SystemUiMode +import org.dueattendant149.bookreader.epubreader.TtsOverlayControls +import org.dueattendant149.bookreader.epubreader.loadTapToNavigateSetting +import org.dueattendant149.bookreader.epubreader.saveTapToNavigateSetting +import org.dueattendant149.bookreader.fetchAiDefinition +import org.dueattendant149.bookreader.isByokCloudTtsAvailable +import org.dueattendant149.bookreader.loadCustomThemes +import org.dueattendant149.bookreader.loadGlobalTextureTransparency +import org.dueattendant149.bookreader.loadPdfRightToLeftPagination +import org.dueattendant149.bookreader.loadReaderBrightnessSettings +import org.dueattendant149.bookreader.loadReaderScreenOrientationMode +import org.dueattendant149.bookreader.loadReaderSliderToggled +import org.dueattendant149.bookreader.loadTtsReplacementPreferences +import org.dueattendant149.bookreader.logCloudAnnotationSyncTrace +import org.dueattendant149.bookreader.ml.SpeechBubble +import org.dueattendant149.bookreader.paginatedreader.TtsChunk +import org.dueattendant149.bookreader.pdf.data.AnnotationSettingsRepository +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.SmartSearchResult +import org.dueattendant149.bookreader.pdf.data.TextStyleConfig +import org.dueattendant149.bookreader.pdf.data.VirtualPage +import org.dueattendant149.bookreader.readerSliderBookmarkPosition +import org.dueattendant149.bookreader.readerSliderChromeColors +import org.dueattendant149.bookreader.readerSliderStepPage +import org.dueattendant149.bookreader.readerSliderToggleState +import org.dueattendant149.bookreader.rememberSearchState +import org.dueattendant149.bookreader.saveCustomThemes +import org.dueattendant149.bookreader.saveGlobalTextureTransparency +import org.dueattendant149.bookreader.savePdfRightToLeftPagination +import org.dueattendant149.bookreader.saveReaderBrightnessSettings +import org.dueattendant149.bookreader.saveReaderScreenOrientationMode +import org.dueattendant149.bookreader.saveReaderSliderToggled +import org.dueattendant149.bookreader.saveTtsReplacementPreferences +import org.dueattendant149.bookreader.scaledToCanvasLimit +import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences +import org.dueattendant149.bookreader.shared.pdf.PdfSpreadLayout +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.ui.ReaderMinimalSlider +import org.dueattendant149.bookreader.shouldRenderReaderSlider +import org.dueattendant149.bookreader.summarizationUrl +import org.dueattendant149.bookreader.tts.ReaderTtsOverlaySize +import org.dueattendant149.bookreader.tts.SpeakerSamplePlayer +import org.dueattendant149.bookreader.tts.TtsPlaybackManager +import org.dueattendant149.bookreader.tts.loadReaderTtsOverlaySize +import org.dueattendant149.bookreader.tts.readerTtsOverlayAlignmentBias +import org.dueattendant149.bookreader.tts.rememberTtsController +import org.dueattendant149.bookreader.tts.saveReaderTtsOverlaySize +import org.dueattendant149.bookreader.tts.splitTextIntoChunks +import org.dueattendant149.bookreader.withTtsReplacements import io.legere.pdfiumandroid.suspend.PdfDocumentKt import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -341,7 +350,7 @@ fun PdfViewerScreen( var tapToNavigateEnabled by remember { mutableStateOf(loadTapToNavigateSetting(context)) } var showThemePanel by remember { mutableStateOf(false) } var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) } - var excludeImages by remember { mutableStateOf(com.aryan.reader.loadExcludeImages(context)) } + var excludeImages by remember { mutableStateOf(org.dueattendant149.bookreader.loadExcludeImages(context)) } var customThemes by remember { mutableStateOf(loadCustomThemes(context)) } var globalTextureTransparency by remember { mutableFloatStateOf(loadGlobalTextureTransparency(context)) } val documentCache = remember { DocumentCache(3) } @@ -369,7 +378,6 @@ fun PdfViewerScreen( var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) } var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) } var showScreenOrientationSheet by remember { mutableStateOf(false) } - var documentPassword by rememberSaveable { mutableStateOf(null) } var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) } var isScrollLocked by remember { mutableStateOf(false) } var lockedState by remember { mutableStateOf?>(null) } @@ -387,23 +395,41 @@ fun PdfViewerScreen( var pendingActionAfterOcrSelection by remember { mutableStateOf<(() -> Unit)?>(null) } var showCustomizeToolsSheet by remember { mutableStateOf(false) } - var hiddenTools by remember { mutableStateOf(loadPdfHiddenTools(context)) } - var toolOrder by remember { mutableStateOf(loadPdfToolOrder(context)) } - var bottomTools by remember { mutableStateOf(loadPdfBottomTools(context)) } + var hiddenToolNames by rememberSaveable { + mutableStateOf(loadPdfHiddenTools(context).toList()) + } + var toolOrderNames by rememberSaveable { + mutableStateOf(loadPdfToolOrder(context).map { it.name }) + } + var bottomToolNames by rememberSaveable { + mutableStateOf(loadPdfBottomTools(context).toList()) + } + val hiddenTools = remember(hiddenToolNames) { + sanitizePdfHiddenToolNames(hiddenToolNames) + } + val toolOrder = remember(toolOrderNames) { + restorePdfToolOrderNames(toolOrderNames) + } + val bottomTools = remember(bottomToolNames) { + sanitizePdfBottomToolNames(bottomToolNames) + } val onUpdateHiddenTools = { newSet: Set -> - hiddenTools = newSet - savePdfHiddenTools(context, newSet) + val sanitized = sanitizePdfHiddenToolNames(newSet) + hiddenToolNames = sanitized.toList() + savePdfHiddenTools(context, sanitized) } val onUpdateToolOrder = { newOrder: List -> - toolOrder = newOrder - savePdfToolOrder(context, newOrder) + val sanitized = restorePdfToolOrderNames(newOrder.map { it.name }) + toolOrderNames = sanitized.map { it.name } + savePdfToolOrder(context, sanitized) } val onUpdateBottomTools = { newBottomTools: Set -> - bottomTools = newBottomTools - savePdfBottomTools(context, newBottomTools) + val sanitized = sanitizePdfBottomToolNames(newBottomTools) + bottomToolNames = sanitized.toList() + savePdfBottomTools(context, sanitized) } val isOss = BuildConfig.FLAVOR == "oss" @@ -427,7 +453,9 @@ fun PdfViewerScreen( val uiState by viewModel.uiState.collectAsState() val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri val effectiveFileType = uiState.selectedFileType ?: FileType.PDF - val isComicFile = effectiveFileType == FileType.CBZ || effectiveFileType == FileType.CBR || effectiveFileType == FileType.CB7 + var documentPassword by rememberSaveable(effectivePdfUri.toString()) { mutableStateOf(null) } + var isPrintBlockedForPasswordProtectedPdf by rememberSaveable(effectivePdfUri.toString()) { mutableStateOf(false) } + val isComicFile = effectiveFileType in COMIC_ARCHIVE_FILE_TYPES var showNewTabSheet by remember { mutableStateOf(false) } var showFileInfoDialog by remember { mutableStateOf(false) } @@ -477,7 +505,7 @@ fun PdfViewerScreen( var isAutoScrollTempPaused by remember { mutableStateOf(false) } val autoScrollResumeJob = remember { mutableStateOf(null) } var isAutoScrollCollapsed by remember { mutableStateOf(false) } - var isTtsCollapsed by remember { mutableStateOf(false) } + var ttsOverlaySize by remember(context) { mutableStateOf(loadReaderTtsOverlaySize(context)) } var isMusicianMode by remember { mutableStateOf(loadPdfMusicianMode(context)) } var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(context)) } @@ -489,7 +517,7 @@ fun PdfViewerScreen( ttsState.currentText var currentTtsMode by remember { mutableStateOf( - com.aryan.reader.tts.loadTtsMode(context).let { + org.dueattendant149.bookreader.tts.loadTtsMode(context).let { if (BuildConfig.FLAVOR == "oss" && !isByokCloudTtsAvailable(context)) TtsPlaybackManager.TtsMode.BASE else it } ) @@ -571,7 +599,11 @@ fun PdfViewerScreen( isAutoScrollLocal = loadPdfAutoScrollLocalMode(context, bookId) } - val onPrintDocument: () -> Unit = { + val onPrintDocument: () -> Unit = onPrintDocument@{ + if (isPrintBlockedForPasswordProtectedPdf) { + showBanner(context.getString(R.string.error_print_password_protected), isError = true) + return@onPrintDocument + } val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager val jobName = "${context.getString(R.string.app_name)} - $originalFileName" @@ -707,7 +739,7 @@ fun PdfViewerScreen( 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) } @@ -1339,22 +1371,50 @@ fun PdfViewerScreen( saveMutex.withLock { withContext(Dispatchers.IO) { @Suppress("VariableNeverRead") var didSave = false + var sidecarsSaved = false if (canSaveSidecarsSnapshot) { - if (force || annotsHash != lastSavedHashes[0]) { + if (annotsHash != lastSavedHashes[0]) { + logCloudAnnotationSyncTrace { + "android.reader.save_ink book=$bookId force=$force oldHash=${lastSavedHashes[0]} " + + "newHash=$annotsHash pages=${annots.keys.sorted()} count=${annots.values.sumOf { it.size }}" + } annotationRepository.saveAnnotations(bookId, annots) lastSavedHashes[0] = annotsHash didSave = true + sidecarsSaved = true + } else if (force) { + logCloudAnnotationSyncTrace { + "android.reader.save_ink_noop book=$bookId force=true hash=$annotsHash" + } } - if (force || boxesHash != lastSavedHashes[1]) { + if (boxesHash != lastSavedHashes[1]) { + logCloudAnnotationSyncTrace { + "android.reader.save_textboxes book=$bookId force=$force oldHash=${lastSavedHashes[1]} " + + "newHash=$boxesHash count=${boxes.size}" + } textBoxRepository.saveTextBoxes(bookId, boxes) lastSavedHashes[1] = boxesHash didSave = true + sidecarsSaved = true + } else if (force) { + logCloudAnnotationSyncTrace { + "android.reader.save_textboxes_noop book=$bookId force=true hash=$boxesHash" + } } - if (force || highlightsHash != lastSavedHashes[2]) { + if (highlightsHash != lastSavedHashes[2]) { + logCloudAnnotationSyncTrace { + "android.reader.save_highlights book=$bookId force=$force oldHash=${lastSavedHashes[2]} " + + "newHash=$highlightsHash count=${highlights.size}" + } highlightRepository.saveHighlights(bookId, highlights) lastSavedHashes[2] = highlightsHash didSave = true + sidecarsSaved = true + } else if (force) { + logCloudAnnotationSyncTrace { + "android.reader.save_highlights_noop book=$bookId force=true hash=$highlightsHash" + } } } else { Timber.tag("PdfTabSync").d( @@ -1384,6 +1444,12 @@ fun PdfViewerScreen( } lastSavedHashes[4] = page } + if (sidecarsSaved) { + logCloudAnnotationSyncTrace { + "android.reader.sidecar_upload_queue book=$bookId force=$force" + } + viewModel.queuePdfSidecarCloudUpload(bookId) + } } } } @@ -1391,6 +1457,44 @@ fun PdfViewerScreen( } } + val persistInkAnnotationsNow = remember(currentBookId, annotationRepository) { + { annotationsSnapshot: Map>, deletedAnnotations: Collection, reason: String -> + val bookIdSnapshot = currentBookId + val loadedSidecarBookIdSnapshot = currentLoadedSidecarBookId + val canSaveSidecarsSnapshot = canUsePdfSidecarsForBook( + bookIdSnapshot, + loadedSidecarBookIdSnapshot, + currentAreAnnotationsLoaded + ) + viewModel.viewModelScope.launch { + val bookId = bookIdSnapshot ?: return@launch + if (!canSaveSidecarsSnapshot) { + logCloudAnnotationSyncTrace { + "android.reader.persist_ink_skip book=$bookId reason=$reason loadedSidecarBook=$loadedSidecarBookIdSnapshot" + } + return@launch + } + val deletedIds = deletedAnnotations.mapNotNull { it.id.takeIf(String::isNotBlank) }.toSet() + withContext(NonCancellable) { + saveMutex.withLock { + withContext(Dispatchers.IO) { + if (deletedIds.isNotEmpty()) { + annotationRepository.markAnnotationsDeleted(bookId, deletedIds) + } + annotationRepository.saveAnnotations(bookId, annotationsSnapshot) + lastSavedHashes[0] = annotationsSnapshot.hashCode() + } + } + } + logCloudAnnotationSyncTrace { + "android.reader.persist_ink book=$bookId reason=$reason count=${annotationsSnapshot.values.sumOf { it.size }} " + + "deletedIds=${deletedIds.sorted()}" + } + viewModel.queuePdfSidecarCloudUpload(bookId) + } + } + } + DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { @@ -2324,8 +2428,9 @@ fun PdfViewerScreen( } } - LaunchedEffect(currentPageScale) { - if (currentPageScale != 1f) { + val zoomIndicatorPercentage = pdfZoomIndicatorPercent(currentPageScale) + LaunchedEffect(zoomIndicatorPercentage) { + if (shouldShowPdfZoomIndicator(zoomIndicatorPercentage)) { showZoomIndicator = true delay(1500) showZoomIndicator = false @@ -2477,8 +2582,16 @@ fun PdfViewerScreen( allAnnotations = loaded textBoxes.addAll(loadedBoxes) userHighlights.addAll(loadedHighlights) + lastSavedHashes[0] = loaded.hashCode() + lastSavedHashes[1] = loadedBoxes.hashCode() + lastSavedHashes[2] = loadedHighlights.hashCode() loadedSidecarBookId = loadingBookId areAnnotationsLoaded = true + logCloudAnnotationSyncTrace { + "android.reader.sidecar_load book=$loadingBookId inkPages=${loaded.keys.sorted()} " + + "inkCount=${loaded.values.sumOf { it.size }} textBoxes=${loadedBoxes.size} " + + "highlights=${loadedHighlights.size} hashes=${lastSavedHashes.copyOfRange(0, 3).joinToString()}" + } Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( "ui.sidecarLoad.done bookId=$loadingBookId annotationPages=${loaded.keys.sorted()} " + "textBoxes=${loadedBoxes.size} highlights=${loadedHighlights.size}" @@ -2646,7 +2759,6 @@ fun PdfViewerScreen( var sliderCurrentPage by remember { mutableFloatStateOf(0f) } var isFastScrubbing by remember { mutableStateOf(false) } val scrubDebounceJob = remember { mutableStateOf(null) } - var startPageThumbnail by remember { mutableStateOf(null) } val pdfSliderChromeVisible = shouldRenderReaderSlider( isToggledOn = isPageSliderVisible, isBottomChromeVisible = showStandardBars, @@ -2733,7 +2845,7 @@ fun PdfViewerScreen( val effectiveUseOnline = areReaderAiFeaturesEnabled(context) && useOnlineDictionary if (effectiveUseOnline) { - val wordCount = com.aryan.reader.countWords(text) + val wordCount = org.dueattendant149.bookreader.countWords(text) if (BuildConfig.FLAVOR != "oss" && wordCount > 1 && !isProUser) { showDictionaryUpsellDialog = true } else { @@ -3342,23 +3454,6 @@ fun PdfViewerScreen( } } - LaunchedEffect(pdfSliderChromeVisible, sliderStartPage, pdfDocument, totalPages) { - startPageThumbnail?.recycle() - startPageThumbnail = null - if (pdfSliderChromeVisible) { - val doc = pdfDocument - if (doc != null && totalPages > 0) { - Timber.d("Slider visible. Rendering thumbnail for page $sliderStartPage") - startPageThumbnail = renderPageToBitmap(doc, sliderStartPage) - Timber.d( - "Thumbnail rendering complete. Is bitmap null: ${startPageThumbnail == null}" - ) - } - } else { - Timber.d("Slider hidden. Clearing thumbnail.") - } - } - LaunchedEffect(ttsState.currentText, ttsPageData, ttsState.startOffsetInSource) { val currentText = ttsState.currentText val currentTtsData = ttsPageData @@ -3429,6 +3524,7 @@ fun PdfViewerScreen( isDocumentReady = false errorMessage = null documentMetadataTitle = null + isPrintBlockedForPasswordProtectedPdf = false currentBookId = null areAnnotationsLoaded = false loadedSidecarBookId = null @@ -3502,6 +3598,7 @@ fun PdfViewerScreen( totalPages = cachedItem.totalPages pageAspectRatios = cachedItem.pageAspectRatios flatTableOfContents = cachedItem.flatTableOfContents + isPrintBlockedForPasswordProtectedPdf = cachedItem.isPasswordProtectedPdf val mapPage = tabStateMap[currentBookId!!] val uiPage = uiState.initialPageInBook @@ -3545,6 +3642,8 @@ fun PdfViewerScreen( val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore) + val loadedPasswordProtectedPdf = selectedDocumentType == FileType.PDF && + (documentPassword != null || isPdfLikelyEncryptedForPrint(context, effectivePdfUri)) if (!isActive) { doc.close() @@ -3552,6 +3651,7 @@ fun PdfViewerScreen( } pdfDocument = doc + isPrintBlockedForPasswordProtectedPdf = loadedPasswordProtectedPdf documentMetadataTitle = (doc as? PdfDocumentWrapper)?.let { wrapper -> PdfiumEngineProvider.withPdfium { wrapper.pdfDocument.getDocumentMeta().title?.takeIf { it.isNotBlank() } @@ -3648,7 +3748,8 @@ fun PdfViewerScreen( pfd = null, totalPages = pagesCount, pageAspectRatios = ratios, - flatTableOfContents = flatTableOfContents + flatTableOfContents = flatTableOfContents, + isPasswordProtectedPdf = loadedPasswordProtectedPdf ) ) @@ -4469,6 +4570,8 @@ fun PdfViewerScreen( val latestSpreadScale = rememberUpdatedState(currentActiveScale) val latestSpreadOffset = rememberUpdatedState(currentActiveOffset) val spreadPageGap = if (showVerticalPageGap) 8.dp else 0.dp + val spreadPageGapPx = with(density) { spreadPageGap.toPx() } + val spreadPageCount = spreadPageIndices.size var spreadPanFlingJob by remember { mutableStateOf(null) } Row( modifier = Modifier @@ -4841,6 +4944,20 @@ fun PdfViewerScreen( ) { spreadPageIndices.forEach { pageIndex -> key(pageIndex) { + val spreadPageWidth = if (spreadPageCount > 1) { + val pageAspectRatio = displayPageRatios.getOrElse(pageIndex) { 1f } + with(density) { + pdfSpreadPageSlotWidth( + containerWidth = boxMaxWidthFloat, + containerHeight = boxMaxHeightFloat, + pageGap = spreadPageGapPx, + spreadPageCount = spreadPageCount, + pageAspectRatio = pageAspectRatio + ).toDp() + } + } else { + with(density) { boxMaxWidthFloat.toDp() } + } val isPageBookmarked by remember(bookmarks, pageIndex) { derivedStateOf { bookmarks.any { it.pageIndex == pageIndex } @@ -5041,7 +5158,7 @@ fun PdfViewerScreen( ocrHoverHighlights = stableOcrRects, modifier = if (spreadPageIndices.size > 1) { Modifier - .weight(1f) + .width(spreadPageWidth) .fillMaxHeight() } else { Modifier.fillMaxSize() @@ -5084,8 +5201,14 @@ fun PdfViewerScreen( val pageIdx = finalAnnotation.pageIndex val existing = allAnnotations[pageIdx] ?: emptyList() - allAnnotations = + val nextAnnotations = allAnnotations + (pageIdx to (existing + finalAnnotation)) + allAnnotations = nextAnnotations + persistInkAnnotationsNow( + nextAnnotations, + emptyList(), + "draw_end" + ) undoStack.add( HistoryAction.Add( pageIdx, finalAnnotation @@ -5099,6 +5222,11 @@ fun PdfViewerScreen( erasedAnnotationsFromStroke.mapValues { it.value.toList() } + persistInkAnnotationsNow( + allAnnotations, + removalMap.values.flatten(), + "erase_end" + ) undoStack.add( HistoryAction.Remove(removalMap) ) @@ -5559,8 +5687,14 @@ fun PdfViewerScreen( val pageIdx = finalAnnotation.pageIndex val existing = allAnnotations[pageIdx] ?: emptyList() - allAnnotations = + val nextAnnotations = allAnnotations + (pageIdx to (existing + finalAnnotation)) + allAnnotations = nextAnnotations + persistInkAnnotationsNow( + nextAnnotations, + emptyList(), + "draw_end" + ) undoStack.add( HistoryAction.Add( pageIdx, finalAnnotation @@ -5574,6 +5708,11 @@ fun PdfViewerScreen( erasedAnnotationsFromStroke.mapValues { it.value.toList() } + persistInkAnnotationsNow( + allAnnotations, + removalMap.values.flatten(), + "erase_end" + ) undoStack.add( HistoryAction.Remove(removalMap) ) @@ -5916,6 +6055,42 @@ fun PdfViewerScreen( pageText = pdfSliderPageText, themePrimary = MaterialTheme.colorScheme.primary ) + val pdfSliderMaxPage = (totalDisplayPages - 1).coerceAtLeast(0) + val pdfSliderCurrentPage = sliderCurrentPage.roundToInt().coerceIn(0, pdfSliderMaxPage) + + suspend fun scrollPdfSliderToPage(pageIndex: Int) { + val targetPage = pageIndex.coerceIn(0, pdfSliderMaxPage) + if (displayMode == DisplayMode.PAGINATION) { + scrollPaginationToDisplayPage(targetPage) + } else { + verticalReaderState.scrollToPage(targetPage) + } + } + + fun jumpPdfSliderToPage(pageIndex: Int) { + val targetPage = pageIndex.coerceIn(0, pdfSliderMaxPage) + scrubDebounceJob.value?.cancel() + sliderCurrentPage = targetPage.toFloat() + isFastScrubbing = false + coroutineScope.launch { + scrollPdfSliderToPage(targetPage) + } + } + + fun scrubPdfSliderToPage(newValue: Float) { + sliderCurrentPage = newValue.coerceIn(0f, pdfSliderMaxPage.toFloat()) + isFastScrubbing = true + scrubDebounceJob.value?.cancel() + scrubDebounceJob.value = coroutineScope.launch { + delay(200) + if (isActive) { + val targetPage = newValue.roundToInt().coerceIn(0, pdfSliderMaxPage) + scrollPdfSliderToPage(targetPage) + sliderCurrentPage = targetPage.toFloat() + isFastScrubbing = false + } + } + } // --- Slider UI attached to the bottom chrome --- AnimatedVisibility( @@ -5926,144 +6101,79 @@ fun PdfViewerScreen( .align(Alignment.BottomCenter) .padding(bottom = pdfSliderBottomPadding) ) { - 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() } - ) {} + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .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 = { newValue -> - sliderCurrentPage = newValue - isFastScrubbing = true - scrubDebounceJob.value?.cancel() - scrubDebounceJob.value = coroutineScope.launch { - delay(200) - if (isActive) { - val targetPage = newValue.roundToInt() - if (displayMode == DisplayMode.PAGINATION) { - scrollPaginationToDisplayPage(targetPage) - } else { - verticalReaderState.scrollToPage(targetPage) - } - isFastScrubbing = false - } - } - }, - valueRange = 0f..(totalDisplayPages - 1).toFloat().coerceAtLeast(0f), - steps = if (totalDisplayPages > 2) totalDisplayPages - 2 else 0, - modifier = Modifier.fillMaxWidth(), - thumb = { - Surface( - modifier = Modifier.size(20.dp), - shape = CircleShape, - color = pdfReaderSliderColors.thumbColor, - tonalElevation = 0.dp, - shadowElevation = 0.dp - ) {} - }, - track = { sliderState -> - val trackHeight = 2.dp - val trackShape = RoundedCornerShape(trackHeight) - val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start - val fraction = if (range == 0f) 0f else { - ((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .height(trackHeight) - .background( - color = pdfReaderSliderColors.inactiveTrackColor, - shape = trackShape - ) - ) { - Box( - modifier = Modifier - .fillMaxWidth(fraction) - .fillMaxHeight() - .background( - color = pdfReaderSliderColors.activeTrackColor, - shape = trackShape - ) - ) - } - } - ) - - val startPageOffsetFraction = if (totalDisplayPages > 1) { - sliderStartPage.toFloat() / (totalDisplayPages - 1) - } else { - 0f - } - val thumbWidth = 20.dp - val trackWidth = maxWidth - thumbWidth - val startPagePixelPosition = - (trackWidth * startPageOffsetFraction) + (thumbWidth / 2) - - val indicatorSize = 8.dp - val indicatorOffset = startPagePixelPosition - (indicatorSize / 2) - Surface( - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = indicatorOffset) - .size(indicatorSize), - shape = CircleShape, - color = pdfReaderSliderColors.bookmarkColor - ) {} - - startPageThumbnail?.let { thumbnail -> - ThumbnailWithIndicator( - thumbnail = thumbnail, - borderColor = pdfReaderSliderColors.bookmarkColor, - modifier = Modifier - .graphicsLayer { clip = false } - .align(Alignment.TopStart) - .offset( - x = startPagePixelPosition - (45.dp / 2), - y = (-72).dp - ), - onClick = { - sliderCurrentPage = sliderStartPage.toFloat() - coroutineScope.launch { - if (displayMode == DisplayMode.PAGINATION) { - scrollPaginationToDisplayPage(sliderStartPage) - } else { - verticalReaderState.scrollToPage(sliderStartPage) - } - } - } + IconButton( + onClick = { + jumpPdfSliderToPage( + readerSliderStepPage( + currentPage = pdfSliderCurrentPage, + delta = -1, + minPage = 0, + maxPage = pdfSliderMaxPage ) - } - } + ) + }, + enabled = pdfSliderCurrentPage > 0, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = stringResource(R.string.desktop_previous_page), + tint = pdfReaderSliderColors.contentColor.copy( + alpha = if (pdfSliderCurrentPage > 0) 0.9f else 0.32f + ) + ) + } - Text( - text = pdfPageRangeText( - pageIndex = sliderCurrentPage.roundToInt(), - pageCount = totalDisplayPages, - displayMode = displayMode, - settings = pdfSpreadSettings - ), - style = MaterialTheme.typography.bodyLarge, - color = pdfReaderSliderColors.contentColor, - fontSize = 18.sp + ReaderMinimalSlider( + value = sliderCurrentPage.coerceIn(0f, pdfSliderMaxPage.toFloat()), + onValueChange = ::scrubPdfSliderToPage, + valueRange = 0f..pdfSliderMaxPage.toFloat(), + enabled = pdfSliderMaxPage > 0, + activeColor = pdfReaderSliderColors.activeTrackColor, + inactiveColor = pdfReaderSliderColors.inactiveTrackColor, + thumbColor = pdfReaderSliderColors.thumbColor, + markerValue = sliderStartPage.toFloat(), + markerColor = pdfReaderSliderColors.bookmarkColor, + modifier = Modifier + .weight(1f) + .height(32.dp) + ) + + IconButton( + onClick = { + jumpPdfSliderToPage( + readerSliderStepPage( + currentPage = pdfSliderCurrentPage, + delta = 1, + minPage = 0, + maxPage = pdfSliderMaxPage + ) + ) + }, + enabled = pdfSliderCurrentPage < pdfSliderMaxPage, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = stringResource(R.string.desktop_next_page), + tint = pdfReaderSliderColors.contentColor.copy( + alpha = if (pdfSliderCurrentPage < pdfSliderMaxPage) 0.9f else 0.32f + ) ) } } @@ -6196,6 +6306,7 @@ fun PdfViewerScreen( isReflowingThisBook = isReflowingThisBook, hasReflowFile = hasReflowFile, isPdfDocumentLoaded = pdfDocument != null, + canPrintDocument = !isPrintBlockedForPasswordProtectedPdf, isTabsEnabled = isPdfTabStripVisible, openTabs = openTabs, activeTabBookId = activeTabBookId, @@ -7018,9 +7129,8 @@ fun PdfViewerScreen( enter = fadeIn(), exit = fadeOut() ) { - val percentage = (currentPageScale * 100).roundToInt() ZoomPercentageIndicator( - percentage = percentage, + percentage = zoomIndicatorPercentage, onResetZoomClick = { resetZoomTrigger = System.currentTimeMillis() } @@ -7199,7 +7309,7 @@ fun PdfViewerScreen( ) val ttsAlignmentBias by animateFloatAsState( - targetValue = if (isTtsCollapsed) 1f else 0f, + targetValue = readerTtsOverlayAlignmentBias(ttsOverlaySize), label = "TtsAlignAnimation" ) @@ -7216,8 +7326,11 @@ fun PdfViewerScreen( ttsController = ttsController, ttsState = ttsState, currentTtsMode = currentTtsMode, - isCollapsed = isTtsCollapsed, - onCollapseChange = { isTtsCollapsed = it }, + overlaySize = ttsOverlaySize, + onOverlaySizeChange = { newSize -> + ttsOverlaySize = newSize + saveReaderTtsOverlaySize(context, newSize) + }, onLocateCurrentChunk = { ttsDisplayPageIndex?.let { targetPage -> coroutineScope.launch { @@ -7926,7 +8039,7 @@ fun PdfViewerScreen( excludeImages = excludeImages, onExcludeImagesChange = { excludeImages = it - com.aryan.reader.saveExcludeImages(context, it) + org.dueattendant149.bookreader.saveExcludeImages(context, it) }, showExcludeImagesOption = true, builtInThemes = PdfBuiltInThemes, diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfViewerStateLogic.kt similarity index 68% rename from app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfViewerStateLogic.kt index 7fa37b4..a988985 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfViewerStateLogic.kt @@ -1,8 +1,9 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.ui.geometry.Offset -import com.aryan.reader.shared.pdf.PdfSpreadLayout -import com.aryan.reader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.pdf.PdfSpreadLayout +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import kotlin.math.roundToInt internal fun resolveEraserStrokeWidth( isEraserOverride: Boolean, @@ -88,6 +89,22 @@ internal fun clampPdfSpreadCameraOffset( ) } +internal fun pdfSpreadPageSlotWidth( + containerWidth: Float, + containerHeight: Float, + pageGap: Float, + spreadPageCount: Int, + pageAspectRatio: Float +): Float { + if (containerWidth <= 0f || containerHeight <= 0f || spreadPageCount <= 0) return 0f + val safeGap = pageGap.coerceAtLeast(0f) + val safeAspectRatio = pageAspectRatio.takeIf { it.isFinite() && it > 0f } ?: 1f + val availableWidth = (containerWidth - (safeGap * (spreadPageCount - 1))).coerceAtLeast(0f) + val maxPageWidth = availableWidth / spreadPageCount + val heightFittedPageWidth = containerHeight * safeAspectRatio + return heightFittedPageWidth.coerceAtMost(maxPageWidth).coerceAtLeast(0f) +} + internal fun activePdfCameraAfterLockPreferenceLoad( isScrollLocked: Boolean, lockedState: Triple? @@ -139,3 +156,32 @@ internal fun shouldResetPdfZoomAfterBubbleZoomCleanup( isZoomEnabled && !isScrollLocked } + +internal fun shouldRenderPdfHighResTiles( + effectiveScale: Float, + targetWidthPx: Int, + targetHeightPx: Int, + isVerticalScroll: Boolean, + isActivePage: Boolean, + largePageThresholdPx: Int = 3000, + verticalScaleTolerance: Float = 0.01f +): Boolean { + val hasLargePage = targetWidthPx > largePageThresholdPx || targetHeightPx > largePageThresholdPx + val isPageEligible = isVerticalScroll || isActivePage + if (!isPageEligible) return false + if (hasLargePage) return true + + val safeScale = effectiveScale.takeIf { it.isFinite() && it > 0f } ?: 1f + return if (isVerticalScroll) { + kotlin.math.abs(safeScale - 1f) > verticalScaleTolerance + } else { + safeScale > 1f + } +} + +internal fun pdfZoomIndicatorPercent(scale: Float): Int { + val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f + return (safeScale * 100f).roundToInt() +} + +internal fun shouldShowPdfZoomIndicator(percentage: Int): Boolean = percentage != 100 diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfiumAnnotationExporter.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfiumAnnotationExporter.kt similarity index 98% rename from app/src/main/java/com/aryan/reader/pdf/PdfiumAnnotationExporter.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfiumAnnotationExporter.kt index c22d71c..cbebfcc 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfiumAnnotationExporter.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfiumAnnotationExporter.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import android.graphics.Bitmap @@ -31,16 +31,16 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.isSpecified -import com.aryan.reader.pdf.data.PdfAnnotation -import com.aryan.reader.pdf.data.PdfTextBox -import com.aryan.reader.pdf.data.VirtualPage -import com.aryan.reader.shared.pdf.PdfAnnotationKind -import com.aryan.reader.shared.pdf.PdfInkTool -import com.aryan.reader.shared.pdf.PdfPageBounds -import com.aryan.reader.shared.pdf.PdfPagePoint -import com.aryan.reader.shared.pdf.SharedPdfAnnotation -import com.aryan.reader.shared.pdf.SharedPdfAnnotationExportMapper -import com.aryan.reader.shared.pdf.pdfInkAppearancePoints +import org.dueattendant149.bookreader.pdf.data.PdfAnnotation +import org.dueattendant149.bookreader.pdf.data.PdfTextBox +import org.dueattendant149.bookreader.pdf.data.VirtualPage +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationExportMapper +import org.dueattendant149.bookreader.shared.pdf.pdfInkAppearancePoints import java.io.File import java.io.FileInputStream import java.io.FileOutputStream diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfiumEngineProvider.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfiumEngineProvider.kt similarity index 97% rename from app/src/main/java/com/aryan/reader/pdf/PdfiumEngineProvider.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfiumEngineProvider.kt index a9662ed..1274392 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfiumEngineProvider.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PdfiumEngineProvider.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf -import com.aryan.reader.shared.pdf.PdfiumBridge +import org.dueattendant149.bookreader.shared.pdf.PdfiumBridge import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock diff --git a/app/src/main/java/com/aryan/reader/pdf/PenIcons.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PenIcons.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/PenIcons.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PenIcons.kt index 149df40..1aaaff1 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PenIcons.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/PenIcons.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.graphics.BitmapShader import android.graphics.PorterDuff @@ -46,7 +46,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke as ComposeStroke import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb -import com.aryan.reader.pdf.data.PdfAnnotation +import org.dueattendant149.bookreader.pdf.data.PdfAnnotation import android.graphics.Paint as NativePaint private val BODY_COLOR = Color(0xFF454545) diff --git a/app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/ReflowWorker.kt similarity index 94% rename from app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/ReflowWorker.kt index 08f316b..51704c9 100644 --- a/app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/ReflowWorker.kt @@ -1,15 +1,15 @@ // ReflowWorker.kt -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import androidx.core.net.toUri import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import androidx.work.workDataOf -import com.aryan.reader.FileType -import com.aryan.reader.R -import com.aryan.reader.data.RecentFileItem -import com.aryan.reader.data.RecentFilesRepository +import org.dueattendant149.bookreader.FileType +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.data.RecentFileItem +import org.dueattendant149.bookreader.data.RecentFilesRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber diff --git a/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/RichTextSystem.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/RichTextSystem.kt index 9205052..5064d94 100644 --- a/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/RichTextSystem.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import androidx.compose.runtime.Stable @@ -44,7 +44,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.sp -import com.aryan.reader.pdf.data.VirtualPage +import org.dueattendant149.bookreader.pdf.data.VirtualPage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -68,6 +68,17 @@ private const val ZWSP = "\u200B" internal fun String.hasRenderableRichText(): Boolean = any { it != PAGE_BREAK_CHAR && !it.isWhitespace() } +internal fun androidPdfRichTextSelectionBounds( + selectionStart: Int, + selectionEnd: Int, + textLength: Int +): Pair? { + val safeLength = textLength.coerceAtLeast(0) + val localStart = minOf(selectionStart, selectionEnd).coerceIn(0, safeLength) + val localEnd = maxOf(selectionStart, selectionEnd).coerceIn(0, safeLength) + return if (localStart < localEnd) localStart to localEnd else null +} + object PdfFontCache { private val cache = ConcurrentHashMap() private var assetManager: android.content.res.AssetManager? = null diff --git a/app/src/main/java/com/aryan/reader/pdf/SvgToAnnotationConverter.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/SvgToAnnotationConverter.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/SvgToAnnotationConverter.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/SvgToAnnotationConverter.kt index 6641c16..876728b 100644 --- a/app/src/main/java/com/aryan/reader/pdf/SvgToAnnotationConverter.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/SvgToAnnotationConverter.kt @@ -19,14 +19,14 @@ */ @file:Suppress("SameParameterValue") -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import android.graphics.Path import android.util.Xml import androidx.compose.ui.graphics.Color import androidx.core.graphics.PathParser -import com.aryan.reader.pdf.data.PdfAnnotation +import org.dueattendant149.bookreader.pdf.data.PdfAnnotation import org.xmlpull.v1.XmlPullParser import timber.log.Timber import java.util.Stack diff --git a/app/src/main/java/com/aryan/reader/pdf/TextAnnotationDock.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/TextAnnotationDock.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/TextAnnotationDock.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/TextAnnotationDock.kt index f66d333..ae270c0 100644 --- a/app/src/main/java/com/aryan/reader/pdf/TextAnnotationDock.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/TextAnnotationDock.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -111,8 +111,8 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.core.graphics.toColorInt -import com.aryan.reader.R -import com.aryan.reader.data.CustomFontEntity +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.data.CustomFontEntity import timber.log.Timber import java.io.File import kotlin.math.roundToInt @@ -238,13 +238,13 @@ fun TextAnnotationDock( ) } - items(com.aryan.reader.epubreader.ReaderFont.entries.toTypedArray()) { font -> - if (font == com.aryan.reader.epubreader.ReaderFont.ORIGINAL) return@items + items(org.dueattendant149.bookreader.epubreader.ReaderFont.entries.toTypedArray()) { font -> + if (font == org.dueattendant149.bookreader.epubreader.ReaderFont.ORIGINAL) return@items val isSelected = currentFontName == font.displayName FontItem( name = font.displayName, isSelected = isSelected, - fontFamily = com.aryan.reader.epubreader.getComposeFontFamily(font, null, LocalContext.current.assets), + fontFamily = org.dueattendant149.bookreader.epubreader.getComposeFontFamily(font, null, LocalContext.current.assets), onClick = { val assetPath = getAssetPathForReaderFont(font.displayName) onFontSelected(font.displayName, assetPath) diff --git a/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/ToolSettingsPopup.kt similarity index 95% rename from app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/ToolSettingsPopup.kt index b88ee12..50a7e4a 100644 --- a/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/ToolSettingsPopup.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Canvas @@ -32,11 +32,14 @@ import androidx.compose.foundation.layout.Spacer 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.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.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -69,6 +72,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.selected @@ -78,12 +82,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import com.aryan.reader.BrightnessSlider -import com.aryan.reader.ColorComparePill -import com.aryan.reader.HexInput -import com.aryan.reader.R -import com.aryan.reader.RgbInputColumn -import com.aryan.reader.SpectrumBox +import org.dueattendant149.bookreader.BrightnessSlider +import org.dueattendant149.bookreader.ColorComparePill +import org.dueattendant149.bookreader.HexInput +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.RgbInputColumn +import org.dueattendant149.bookreader.SpectrumBox +import org.dueattendant149.bookreader.readerModalMaxHeightDp import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @@ -151,18 +156,28 @@ fun ToolSettingsPopup( } val circleSize = 28.dp + val configuration = LocalConfiguration.current + val maxPopupHeight = readerModalMaxHeightDp( + screenHeightDp = configuration.screenHeightDp, + fraction = 0.8f, + verticalMarginDp = 64, + preferredMinHeightDp = 240 + ).dp Surface( modifier = modifier .width(360.dp) - .padding(12.dp), + .padding(12.dp) + .heightIn(max = maxPopupHeight), shape = RoundedCornerShape(28.dp), color = Color(0xFF1E1E1E), shadowElevation = 12.dp, tonalElevation = 0.dp ) { Column( - modifier = Modifier.padding(20.dp), + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { if (isEraser) { @@ -450,15 +465,21 @@ private fun ColorPickerDialog( 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.85f) .padding(8.dp) + .heightIn(max = maxDialogHeight) ) { Column( - modifier = Modifier.padding(20.dp), + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { Box( @@ -788,4 +809,4 @@ private fun StyledPropertySlider( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt similarity index 97% rename from app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt index 1a7e6b7..94c0e7d 100644 --- a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt @@ -1,5 +1,5 @@ // UniversalDocument.kt -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import android.graphics.Bitmap @@ -12,9 +12,10 @@ import android.graphics.Rect import android.graphics.RectF import android.net.Uri import android.os.Build -import com.aryan.reader.FileType -import com.aryan.reader.R -import com.aryan.reader.pptx.PptxDocumentWrapper +import org.dueattendant149.bookreader.COMIC_ARCHIVE_FILE_TYPES +import org.dueattendant149.bookreader.FileType +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.pptx.PptxDocumentWrapper import io.legere.pdfiumandroid.api.Bookmark import io.legere.pdfiumandroid.suspend.PdfDocumentKt import io.legere.pdfiumandroid.suspend.PdfPageKt @@ -106,7 +107,7 @@ object DocumentFactory { throw e } PptxDocumentWrapper(cacheFile, deleteOnClose = true) - } else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { + } else if (type in COMIC_ARCHIVE_FILE_TYPES) { val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}") withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri)?.use { input -> @@ -534,7 +535,7 @@ class PdfTextPageWrapper( } } -// ================= CBZ, CBR, CB7 IMPLEMENTATION ================= +// ================= CBZ, CBR, CB7, CBT IMPLEMENTATION ================= class DummyTextPage : ReaderTextPage { override suspend fun textPageCountChars() = 0 @@ -783,16 +784,16 @@ class OpdsStreamDocumentWrapper( private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() } private val catalog = catalogId?.let { - com.aryan.reader.opds.OpdsRepository(context).getCatalogs().find { c -> c.id == it } + org.dueattendant149.bookreader.opds.OpdsRepository(context).getCatalogs().find { c -> c.id == it } } - private val client = com.aryan.reader.opds.OpdsRepository.sharedHttpClient.newBuilder() + private val client = org.dueattendant149.bookreader.opds.OpdsRepository.sharedHttpClient.newBuilder() .apply { val streamCatalog = catalog val username = streamCatalog?.username val password = streamCatalog?.password if (!username.isNullOrBlank() && !password.isNullOrBlank()) { - authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(username, password)) + authenticator(org.dueattendant149.bookreader.opds.OpdsRepository.OpdsAuthenticator(username, password)) } } .build() diff --git a/app/src/main/java/com/aryan/reader/pdf/data/AnnotationSettingsRepository.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/AnnotationSettingsRepository.kt similarity index 98% rename from app/src/main/java/com/aryan/reader/pdf/data/AnnotationSettingsRepository.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/AnnotationSettingsRepository.kt index 032c5b8..8a8c706 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/AnnotationSettingsRepository.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/AnnotationSettingsRepository.kt @@ -17,12 +17,12 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf.data +package org.dueattendant149.bookreader.pdf.data import android.content.Context import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import com.aryan.reader.pdf.InkType +import org.dueattendant149.bookreader.pdf.InkType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PageLayoutRepository.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PageLayoutRepository.kt similarity index 97% rename from app/src/main/java/com/aryan/reader/pdf/data/PageLayoutRepository.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PageLayoutRepository.kt index 8535d78..b1f23c1 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PageLayoutRepository.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PageLayoutRepository.kt @@ -17,11 +17,11 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf.data +package org.dueattendant149.bookreader.pdf.data import android.content.Context -import com.aryan.reader.pdf.PDF_BLANK_PAGE_PERSISTENCE_TAG -import com.aryan.reader.pdf.pdfLayoutDebugSummary +import org.dueattendant149.bookreader.pdf.PDF_BLANK_PAGE_PERSISTENCE_TAG +import org.dueattendant149.bookreader.pdf.pdfLayoutDebugSummary import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfAnnotationData.kt similarity index 95% rename from app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfAnnotationData.kt index e1bc860..4bc7891 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfAnnotationData.kt @@ -17,20 +17,21 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf.data +package org.dueattendant149.bookreader.pdf.data import android.graphics.RectF import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import com.aryan.reader.pdf.AnnotationType -import com.aryan.reader.pdf.InkType -import com.aryan.reader.pdf.PdfHighlightColor -import com.aryan.reader.pdf.PdfPoint -import com.aryan.reader.pdf.PdfUserHighlight -import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment +import org.dueattendant149.bookreader.pdf.AnnotationType +import org.dueattendant149.bookreader.pdf.InkType +import org.dueattendant149.bookreader.pdf.PdfHighlightColor +import org.dueattendant149.bookreader.pdf.PdfPoint +import org.dueattendant149.bookreader.pdf.PdfUserHighlight +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationComment import org.json.JSONArray import org.json.JSONObject +import timber.log.Timber import java.util.Locale import java.util.UUID @@ -143,7 +144,7 @@ object AnnotationSerializer { resultMap[pageIndex]?.add(annotation) } } catch (e: Exception) { - e.printStackTrace() + Timber.e(e, "Failed to parse PDF ink annotations") } return resultMap } @@ -217,7 +218,7 @@ object TextBoxSerializer { ) } } catch (e: Exception) { - e.printStackTrace() + Timber.e(e, "Failed to parse PDF text boxes") } return result } @@ -290,7 +291,7 @@ object HighlightSerializer { ) } } catch (e: Exception) { - e.printStackTrace() + Timber.e(e, "Failed to parse PDF highlights") } return result } diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfAnnotationRepository.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfAnnotationRepository.kt new file mode 100644 index 0000000..b5c84f1 --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfAnnotationRepository.kt @@ -0,0 +1,166 @@ +/* + * 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.pdf.data + +import android.content.Context +import org.dueattendant149.bookreader.logCloudAnnotationSyncTrace +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec +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") + } + + private fun getDeletedFile(bookId: String): File { + val safeBookId = bookId.replace("/", "_") + val dir = File(context.filesDir, "annotations") + if (!dir.exists()) dir.mkdirs() + return File(dir, "deleted_annotation_$safeBookId.json") + } + + suspend fun saveAnnotations(bookId: String, annotations: Map>) { + 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) + if (file.exists() && file.readText() == json) { + logCloudAnnotationSyncTrace { + "android.repository.save_ink_noop book=$bookId count=${annotations.values.sumOf { it.size }} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + Timber.tag("AnnotationSync").d("Skipping unchanged annotation JSON for $bookId.") + return@withContext + } + file.writeText(json) + logCloudAnnotationSyncTrace { + "android.repository.save_ink book=$bookId count=${annotations.values.sumOf { it.size }} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + + Timber.tag("AnnotationSync").d("Finished saving local JSON for $bookId. Path: ${file.absolutePath}, Size: ${file.length()}") + } 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 + } + + suspend fun markAnnotationsDeleted( + bookId: String, + annotationIds: Collection, + deletedAt: Long = System.currentTimeMillis() + ) { + val ids = annotationIds.mapNotNull { it.takeIf(String::isNotBlank) }.toSet() + if (ids.isEmpty()) return + withContext(Dispatchers.IO) { + val file = getDeletedFile(bookId) + val existing = if (file.isFile) { + SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText()) + } else { + emptyMap() + } + val next = existing.toMutableMap() + ids.forEach { id -> next[id] = maxOf(next[id] ?: 0L, deletedAt) } + val json = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(next) + if (file.isFile && file.readText() == json) return@withContext + file.writeText(json) + logCloudAnnotationSyncTrace { + "android.repository.mark_deleted_ink book=$bookId ids=${ids.sorted()} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + } + } + + suspend fun replaceDeletedAnnotations( + bookId: String, + deletions: Map, + timestamp: Long? = null + ) { + withContext(Dispatchers.IO) { + val file = getDeletedFile(bookId) + if (deletions.isEmpty()) { + if (file.exists()) file.delete() + return@withContext + } + val json = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(deletions) + if (!file.isFile || file.readText() != json) { + file.writeText(json) + } + timestamp?.takeIf { it > 0L }?.let(file::setLastModified) + logCloudAnnotationSyncTrace { + "android.repository.replace_deleted_ink book=$bookId count=${deletions.size} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + } + } + + fun getDeletedAnnotationsFileForSync(bookId: String): File? { + val file = getDeletedFile(bookId) + val deletions = if (file.isFile) { + SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText()) + } else { + emptyMap() + } + return if (deletions.isNotEmpty()) file else null + } +} diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfHighlightRepository.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfHighlightRepository.kt similarity index 65% rename from app/src/main/java/com/aryan/reader/pdf/data/PdfHighlightRepository.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfHighlightRepository.kt index 07cfe77..8a9352d 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfHighlightRepository.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfHighlightRepository.kt @@ -1,8 +1,9 @@ // PdfHighlightRepository.kt -package com.aryan.reader.pdf.data +package org.dueattendant149.bookreader.pdf.data import android.content.Context -import com.aryan.reader.pdf.PdfUserHighlight +import org.dueattendant149.bookreader.logCloudAnnotationSyncTrace +import org.dueattendant149.bookreader.pdf.PdfUserHighlight import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber @@ -25,7 +26,19 @@ class PdfHighlightRepository(private val context: Context) { if (file.exists()) file.delete() return@withContext } - file.writeText(HighlightSerializer.toJson(highlights)) + val json = HighlightSerializer.toJson(highlights) + if (file.exists() && file.readText() == json) { + logCloudAnnotationSyncTrace { + "android.repository.save_highlights_noop book=$bookId count=${highlights.size} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + return@withContext + } + file.writeText(json) + logCloudAnnotationSyncTrace { + "android.repository.save_highlights book=$bookId count=${highlights.size} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } } catch (e: Exception) { Timber.e(e, "Failed to save local highlights") } @@ -52,4 +65,4 @@ class PdfHighlightRepository(private val context: Context) { val dir = File(context.filesDir, "pdf_highlights") if (dir.exists()) dir.deleteRecursively() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextBoxRepository.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfTextBoxRepository.kt similarity index 75% rename from app/src/main/java/com/aryan/reader/pdf/data/PdfTextBoxRepository.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfTextBoxRepository.kt index 10dea67..d62f89b 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextBoxRepository.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfTextBoxRepository.kt @@ -17,9 +17,10 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf.data +package org.dueattendant149.bookreader.pdf.data import android.content.Context +import org.dueattendant149.bookreader.logCloudAnnotationSyncTrace import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -35,13 +36,24 @@ class PdfTextBoxRepository(private val context: Context) { suspend fun saveTextBoxes(bookId: String, textBoxes: List) { withContext(Dispatchers.IO) { + val file = getFile(bookId) if (textBoxes.isEmpty()) { - val file = getFile(bookId) if (file.exists()) file.delete() return@withContext } val json = TextBoxSerializer.toJson(textBoxes) - getFile(bookId).writeText(json) + if (file.exists() && file.readText() == json) { + logCloudAnnotationSyncTrace { + "android.repository.save_textboxes_noop book=$bookId count=${textBoxes.size} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + return@withContext + } + file.writeText(json) + logCloudAnnotationSyncTrace { + "android.repository.save_textboxes book=$bookId count=${textBoxes.size} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } } } @@ -71,4 +83,4 @@ class PdfTextBoxRepository(private val context: Context) { val file = getFile(bookId) if(file.exists()) file.delete() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextDatabase.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfTextDatabase.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/data/PdfTextDatabase.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfTextDatabase.kt index f26b065..5e1bbdf 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextDatabase.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfTextDatabase.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf.data +package org.dueattendant149.bookreader.pdf.data import android.content.Context import androidx.paging.PagingSource diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfTextRepository.kt similarity index 99% rename from app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfTextRepository.kt index 00fcb2a..acd603b 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/data/PdfTextRepository.kt @@ -17,13 +17,13 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.pdf.data +package org.dueattendant149.bookreader.pdf.data import android.content.Context import android.graphics.RectF import timber.log.Timber import androidx.core.graphics.createBitmap -import com.aryan.reader.pdf.OcrHelper +import org.dueattendant149.bookreader.pdf.OcrHelper import io.legere.pdfiumandroid.suspend.PdfDocumentKt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -33,8 +33,8 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.flatMap -import com.aryan.reader.SearchResult -import com.aryan.reader.pdf.PdfiumEngineProvider +import org.dueattendant149.bookreader.SearchResult +import org.dueattendant149.bookreader.pdf.PdfiumEngineProvider import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -42,7 +42,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.graphics.Color import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import com.aryan.reader.pdf.ReaderDocument +import org.dueattendant149.bookreader.pdf.ReaderDocument private const val TAG = "PdfSearchDiag" diff --git a/app/src/main/java/com/aryan/reader/pptx/PptxDocument.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pptx/PptxDocument.kt similarity index 95% rename from app/src/main/java/com/aryan/reader/pptx/PptxDocument.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/pptx/PptxDocument.kt index d3cc2f1..2742c05 100644 --- a/app/src/main/java/com/aryan/reader/pptx/PptxDocument.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pptx/PptxDocument.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pptx +package org.dueattendant149.bookreader.pptx import android.content.Context import android.graphics.Bitmap @@ -28,33 +28,33 @@ import android.text.style.SubscriptSpan import android.text.style.SuperscriptSpan import android.text.style.TypefaceSpan import androidx.core.graphics.createBitmap -import com.aryan.reader.pdf.DummyTextPage -import com.aryan.reader.pdf.ReaderDocument -import com.aryan.reader.pdf.ReaderLink -import com.aryan.reader.pdf.ReaderPage -import com.aryan.reader.pdf.ReaderTextPage -import com.aryan.reader.pdf.ReaderTextRect -import com.aryan.reader.shared.pptx.SharedPptxAutoFitMode -import com.aryan.reader.shared.pptx.SharedPptxCharBox -import com.aryan.reader.shared.pptx.SharedPptxCustomGeometry -import com.aryan.reader.shared.pptx.SharedPptxDeck -import com.aryan.reader.shared.pptx.SharedPptxDeckCache -import com.aryan.reader.shared.pptx.SharedPptxElement -import com.aryan.reader.shared.pptx.SharedPptxGradientFill -import com.aryan.reader.shared.pptx.SharedPptxImageCrop -import com.aryan.reader.shared.pptx.SharedPptxImageElement -import com.aryan.reader.shared.pptx.SharedPptxParagraph -import com.aryan.reader.shared.pptx.SharedPptxPathCommand -import com.aryan.reader.shared.pptx.SharedPptxRect -import com.aryan.reader.shared.pptx.SharedPptxShapeElement -import com.aryan.reader.shared.pptx.SharedPptxSlide -import com.aryan.reader.shared.pptx.SharedPptxTableCell -import com.aryan.reader.shared.pptx.SharedPptxTableElement -import com.aryan.reader.shared.pptx.SharedPptxTableRow -import com.aryan.reader.shared.pptx.SharedPptxTextAlign -import com.aryan.reader.shared.pptx.SharedPptxTextInsets -import com.aryan.reader.shared.pptx.SharedPptxTextRun -import com.aryan.reader.shared.pptx.SharedPptxVerticalAnchor +import org.dueattendant149.bookreader.pdf.DummyTextPage +import org.dueattendant149.bookreader.pdf.ReaderDocument +import org.dueattendant149.bookreader.pdf.ReaderLink +import org.dueattendant149.bookreader.pdf.ReaderPage +import org.dueattendant149.bookreader.pdf.ReaderTextPage +import org.dueattendant149.bookreader.pdf.ReaderTextRect +import org.dueattendant149.bookreader.shared.pptx.SharedPptxAutoFitMode +import org.dueattendant149.bookreader.shared.pptx.SharedPptxCharBox +import org.dueattendant149.bookreader.shared.pptx.SharedPptxCustomGeometry +import org.dueattendant149.bookreader.shared.pptx.SharedPptxDeck +import org.dueattendant149.bookreader.shared.pptx.SharedPptxDeckCache +import org.dueattendant149.bookreader.shared.pptx.SharedPptxElement +import org.dueattendant149.bookreader.shared.pptx.SharedPptxGradientFill +import org.dueattendant149.bookreader.shared.pptx.SharedPptxImageCrop +import org.dueattendant149.bookreader.shared.pptx.SharedPptxImageElement +import org.dueattendant149.bookreader.shared.pptx.SharedPptxParagraph +import org.dueattendant149.bookreader.shared.pptx.SharedPptxPathCommand +import org.dueattendant149.bookreader.shared.pptx.SharedPptxRect +import org.dueattendant149.bookreader.shared.pptx.SharedPptxShapeElement +import org.dueattendant149.bookreader.shared.pptx.SharedPptxSlide +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTableCell +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTableElement +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTableRow +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTextAlign +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTextInsets +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTextRun +import org.dueattendant149.bookreader.shared.pptx.SharedPptxVerticalAnchor import io.legere.pdfiumandroid.api.Bookmark import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/BaseTtsSynthesizer.kt similarity index 90% rename from app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/tts/BaseTtsSynthesizer.kt index d1f0136..c23a422 100644 --- a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/BaseTtsSynthesizer.kt @@ -17,17 +17,17 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.tts +package org.dueattendant149.bookreader.tts import android.content.Context import android.os.Bundle import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener import android.speech.tts.Voice -import com.aryan.reader.BuildConfig -import com.aryan.reader.epubreader.loadTtsPitch -import com.aryan.reader.epubreader.loadTtsSpeechRate -import com.aryan.reader.loadNativeVoice +import org.dueattendant149.bookreader.BuildConfig +import org.dueattendant149.bookreader.epubreader.loadTtsPitch +import org.dueattendant149.bookreader.epubreader.loadTtsSpeechRate +import org.dueattendant149.bookreader.loadNativeVoice import timber.log.Timber import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.TimeoutCancellationException @@ -81,6 +81,13 @@ internal fun resolveNativeTtsVoiceForBuild( } } +internal fun shouldResolveNativeTtsVoice( + preferredVoiceName: String?, + isOfflineBuild: Boolean +): Boolean { + return isOfflineBuild || !preferredVoiceName.isNullOrBlank() +} + class BaseTtsSynthesizer(private val context: Context) { private var tts: TextToSpeech? = null @@ -144,15 +151,6 @@ class BaseTtsSynthesizer(private val context: Context) { if (status == TextToSpeech.SUCCESS) { isInitialized = true Timber.d("TextToSpeech engine initialized successfully.") - try { - val result = tts?.setLanguage(Locale.getDefault()) - if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { - Timber.e("Default language not supported/missing data") - } - } catch (e: Exception) { - Timber.e(e, "Error setting language") - } - tts?.setOnUtteranceProgressListener(sharedListener) if (continuation.isActive) continuation.resume(Unit) } else { @@ -178,11 +176,25 @@ class BaseTtsSynthesizer(private val context: Context) { } } + private suspend fun stopEngineForRetryLocked() { + Timber.w("BaseTts: Stopping current TTS utterance before retry.") + try { + tts?.stop() + } catch (e: Exception) { + Timber.e(e, "BaseTts: Failed to stop TTS during retry recovery") + } finally { + delay(350) + } + } + private fun applyPreferredVoice() { if (tts == null) return try { val preferredVoiceName = loadNativeVoice(context) + if (!shouldResolveNativeTtsVoice(preferredVoiceName, BuildConfig.IS_OFFLINE)) { + return + } val defaultLocale = Locale.getDefault() val defaultVoice = tts?.defaultVoice val availableVoices = tts?.voices @@ -195,7 +207,6 @@ class BaseTtsSynthesizer(private val context: Context) { ) if (targetVoice == null) { - tts?.language = defaultLocale Timber.w("BaseTts: No suitable local voice found for locale $defaultLocale.") return } @@ -208,15 +219,10 @@ class BaseTtsSynthesizer(private val context: Context) { Timber.w("BaseTts: Saved voice '$preferredVoiceName' requires network or is unavailable in offline build. Using ${targetVoice.name}.") } - if (tts?.voice?.name != targetVoice.name) { - Timber.d("BaseTts: Setting native voice to ${targetVoice.name} (${targetVoice.locale})") - try { - tts?.language = targetVoice.locale - } catch (e: Exception) { - Timber.e(e, "BaseTts: Failed to set language for voice") - } - tts?.voice = targetVoice - } + Timber.d("BaseTts: Setting native voice to ${targetVoice.name} (${targetVoice.locale})") + tts?.voice = targetVoice + } catch (e: OutOfMemoryError) { + Timber.e(e, "BaseTts: Skipping optional voice selection due to low memory") } catch (e: Exception) { Timber.e(e, "BaseTts: Failed to apply preferred voice") } @@ -307,7 +313,7 @@ class BaseTtsSynthesizer(private val context: Context) { requests.remove(utteranceId) if (attempt < MAX_RETRY_ATTEMPTS) { - shutdownEngineLocked() + stopEngineForRetryLocked() } } } diff --git a/app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/ReaderTtsMiniBar.kt similarity index 94% rename from app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/tts/ReaderTtsMiniBar.kt index 4d522bf..ddfc02a 100644 --- a/app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/ReaderTtsMiniBar.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.tts +package org.dueattendant149.bookreader.tts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable @@ -34,8 +34,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.media3.common.util.UnstableApi -import com.aryan.reader.R -import com.aryan.reader.tts.TtsPlaybackManager.TtsState +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsState private const val TTS_MINI_BAR_EDGE_PADDING_DP = 16 private const val TTS_MINI_BAR_MAIN_BOTTOM_PADDING_DP = 96 @@ -74,20 +74,16 @@ fun ReaderTtsMiniBar( ttsState.currentChunkIndex >= 0 && ttsState.currentChunkIndex < ttsState.totalChunks - 1 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 title = ttsState.bookTitle ?.takeIf { it.isNotBlank() } ?: stringResource(R.string.action_read_aloud) val subtitle = remember(title, ttsState.chapterTitle, chunkLabel) { listOfNotNull( + chunkLabel, ttsState.chapterTitle - ?.takeIf { it.isNotBlank() && it != title }, - chunkLabel + ?.takeIf { it.isNotBlank() && it != title } ).joinToString(" - ") } diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/tts/ReaderTtsOverlaySize.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/ReaderTtsOverlaySize.kt new file mode 100644 index 0000000..648fe54 --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/ReaderTtsOverlaySize.kt @@ -0,0 +1,42 @@ +package org.dueattendant149.bookreader.tts + +import android.content.Context +import androidx.core.content.edit + +enum class ReaderTtsOverlaySize { + LARGE, + MEDIUM, + SMALL +} + +private const val READER_PREFS_NAME = "reader_prefs" +private const val READER_TTS_OVERLAY_SIZE_KEY = "reader_tts_overlay_size" + +internal fun readerTtsOverlayAlignmentBias(size: ReaderTtsOverlaySize): Float { + return if (size == ReaderTtsOverlaySize.SMALL) 1f else 0f +} + +internal fun readerTtsOverlayAlternativeSizes(size: ReaderTtsOverlaySize): List { + return ReaderTtsOverlaySize.entries.filter { it != size } +} + +internal fun resolveReaderTtsOverlaySize(savedName: String?): ReaderTtsOverlaySize { + return ReaderTtsOverlaySize.entries.firstOrNull { it.name == savedName } + ?: ReaderTtsOverlaySize.LARGE +} + +internal fun loadReaderTtsOverlaySize(context: Context): ReaderTtsOverlaySize { + val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE) + return resolveReaderTtsOverlaySize(prefs.getString(READER_TTS_OVERLAY_SIZE_KEY, null)) +} + +internal fun saveReaderTtsOverlaySize(context: Context, size: ReaderTtsOverlaySize) { + val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putString(READER_TTS_OVERLAY_SIZE_KEY, size.name) } +} + +internal fun formatReaderTtsChunkLabel(currentChunkIndex: Int, totalChunks: Int): String? { + if (totalChunks <= 0) return null + if (currentChunkIndex !in 0 until totalChunks) return null + return "Chunk ${currentChunkIndex + 1}/$totalChunks" +} diff --git a/app/src/main/java/com/aryan/reader/tts/TtsController.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsController.kt similarity index 97% rename from app/src/main/java/com/aryan/reader/tts/TtsController.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsController.kt index dcf3f2a..a13a3db 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsController.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsController.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.tts +package org.dueattendant149.bookreader.tts import android.content.ComponentName import android.content.Context @@ -34,11 +34,11 @@ import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.SessionToken -import com.aryan.reader.BuildConfig -import com.aryan.reader.epubreader.loadTtsPitch -import com.aryan.reader.epubreader.loadTtsSpeechRate -import com.aryan.reader.isByokCloudTtsAvailable -import com.aryan.reader.tts.TtsPlaybackManager.TtsState +import org.dueattendant149.bookreader.BuildConfig +import org.dueattendant149.bookreader.epubreader.loadTtsPitch +import org.dueattendant149.bookreader.epubreader.loadTtsSpeechRate +import org.dueattendant149.bookreader.isByokCloudTtsAvailable +import org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsState import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.MoreExecutors import kotlinx.coroutines.CoroutineScope @@ -196,7 +196,7 @@ class TtsController(context: Context) : Player.Listener { } fun start( - chunks: List, + chunks: List, bookTitle: String, chapterTitle: String?, coverImageUri: String?, diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsNotificationIntent.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsNotificationIntent.kt new file mode 100644 index 0000000..32973da --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsNotificationIntent.kt @@ -0,0 +1,8 @@ +package org.dueattendant149.bookreader.tts + +const val ACTION_OPEN_TTS_SESSION = "org.dueattendant149.bookreader.tts.OPEN_SESSION" +const val EXTRA_TTS_BOOK_ID = "org.dueattendant149.bookreader.tts.extra.BOOK_ID" +const val EXTRA_TTS_CHAPTER_INDEX = "org.dueattendant149.bookreader.tts.extra.CHAPTER_INDEX" +const val EXTRA_TTS_SOURCE_CFI = "org.dueattendant149.bookreader.tts.extra.SOURCE_CFI" +const val EXTRA_TTS_START_OFFSET = "org.dueattendant149.bookreader.tts.extra.START_OFFSET" +const val EXTRA_TTS_PAGE_INDEX = "org.dueattendant149.bookreader.tts.extra.PAGE_INDEX" diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsPlaybackManager.kt similarity index 91% rename from app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsPlaybackManager.kt index 52231ae..011fcb0 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsPlaybackManager.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.tts +package org.dueattendant149.bookreader.tts import android.app.PendingIntent import android.content.Context @@ -34,8 +34,8 @@ import androidx.media3.session.CommandButton import androidx.media3.session.MediaSession import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult -import com.aryan.reader.MainActivity -import com.aryan.reader.R +import org.dueattendant149.bookreader.MainActivity +import org.dueattendant149.bookreader.R import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.CoroutineScope @@ -50,31 +50,40 @@ import kotlinx.coroutines.withContext import java.io.File import java.util.concurrent.atomic.AtomicInteger import androidx.core.net.toUri -import com.aryan.reader.paginatedreader.TimedWord -import com.aryan.reader.paginatedreader.TtsChunk +import org.dueattendant149.bookreader.paginatedreader.TimedWord +import org.dueattendant149.bookreader.paginatedreader.TtsChunk import kotlinx.coroutines.delay import kotlin.math.roundToInt +internal fun stableSortedIntSnapshot(values: Collection): List { + return try { + values.toTypedArray().sorted() + } catch (e: RuntimeException) { + Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).w(e, "Failed to snapshot TTS cache keys") + emptyList() + } +} + val START_TTS_COMMAND: SessionCommand - get() = ttsSessionCommand("com.aryan.reader.tts.START") + get() = ttsSessionCommand("org.dueattendant149.bookreader.tts.START") val STOP_TTS_COMMAND: SessionCommand - get() = ttsSessionCommand("com.aryan.reader.tts.STOP") + get() = ttsSessionCommand("org.dueattendant149.bookreader.tts.STOP") val CHANGE_SPEAKER_COMMAND: SessionCommand - get() = ttsSessionCommand("com.aryan.reader.tts.CHANGE_SPEAKER") + get() = ttsSessionCommand("org.dueattendant149.bookreader.tts.CHANGE_SPEAKER") val FLUSH_PREFETCH_COMMAND: SessionCommand - get() = ttsSessionCommand("com.aryan.reader.tts.FLUSH_PREFETCH") + get() = ttsSessionCommand("org.dueattendant149.bookreader.tts.FLUSH_PREFETCH") private val STATE_UPDATE_COMMAND: SessionCommand - get() = ttsSessionCommand("com.aryan.reader.tts.STATE_UPDATE") + get() = ttsSessionCommand("org.dueattendant149.bookreader.tts.STATE_UPDATE") val CHANGE_TTS_MODE_COMMAND: SessionCommand - get() = ttsSessionCommand("com.aryan.reader.tts.CHANGE_MODE") + get() = ttsSessionCommand("org.dueattendant149.bookreader.tts.CHANGE_MODE") val SLICE_CURRENT_AND_RELOAD_COMMAND: SessionCommand - get() = ttsSessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD") + get() = ttsSessionCommand("org.dueattendant149.bookreader.tts.SLICE_AND_RELOAD") val SET_PLAYBACK_PARAMS_COMMAND: SessionCommand - get() = ttsSessionCommand("com.aryan.reader.tts.SET_PLAYBACK_PARAMS") + get() = ttsSessionCommand("org.dueattendant149.bookreader.tts.SET_PLAYBACK_PARAMS") val SKIP_TO_PREVIOUS_TTS_CHUNK_COMMAND: SessionCommand - get() = ttsSessionCommand("com.aryan.reader.tts.SKIP_TO_PREVIOUS_CHUNK") + get() = ttsSessionCommand("org.dueattendant149.bookreader.tts.SKIP_TO_PREVIOUS_CHUNK") val SKIP_TO_NEXT_TTS_CHUNK_COMMAND: SessionCommand - get() = ttsSessionCommand("com.aryan.reader.tts.SKIP_TO_NEXT_CHUNK") + get() = ttsSessionCommand("org.dueattendant149.bookreader.tts.SKIP_TO_NEXT_CHUNK") const val TTS_NOTIFICATION_DIAG_TAG = "TTS_NOTIFICATION_DIAG" const val TTS_CHUNK_NAV_DIAG_TAG = "TTS_CHUNK_NAV_DIAG" @@ -107,6 +116,7 @@ private const val TTS_NOTIFICATION_TRAILING_BUFFER_MS = 2_000L private const val TTS_NOTIFICATION_AVERAGE_WORD_MS = 550L private const val TTS_NOTIFICATION_PUNCTUATION_PAUSE_MS = 120L private const val NO_DEFERRED_TRANSITION_PREFETCH_GENERATION = -1 +internal const val MAX_CHUNK_GENERATION_FAILURES = 2 private val TTS_NOTIFICATION_WORD_PATTERN = Regex("""\S+""") private fun ttsSessionCommand(action: String): SessionCommand { @@ -143,9 +153,14 @@ internal fun resolveReusableTtsPlaylistIndex( internal fun shouldAdvanceToTtsPlaylistChunk( currentChunkIndex: Int, - playlistChunkIndex: Int? + playlistChunkIndex: Int?, + skippedChunkIndices: Set = emptySet() ): Boolean { - return playlistChunkIndex == currentChunkIndex + 1 + return playlistChunkIndex == resolveNextPlayableTtsChunkIndex( + currentChunkIndex = currentChunkIndex, + totalChunks = maxOf(playlistChunkIndex?.plus(1) ?: 0, currentChunkIndex + 2), + skippedChunkIndices = skippedChunkIndices + ) } internal fun shouldStartTtsTransitionPrefetch( @@ -155,6 +170,29 @@ internal fun shouldStartTtsTransitionPrefetch( return currentGeneration != deferredGeneration } +internal fun shouldStopTtsPrefetchAfterMissingChunk( + isLoaded: Boolean, + playlistIndex: Int? +): Boolean { + return !isLoaded && playlistIndex == null +} + +internal fun resolveNextPlayableTtsChunkIndex( + currentChunkIndex: Int, + totalChunks: Int, + skippedChunkIndices: Set +): Int? { + if (totalChunks <= 0 || currentChunkIndex !in -1 until totalChunks) return null + return ((currentChunkIndex + 1) until totalChunks).firstOrNull { it !in skippedChunkIndices } +} + +internal fun shouldGiveUpTtsChunkGeneration( + failureCount: Int, + maxFailures: Int = MAX_CHUNK_GENERATION_FAILURES +): Boolean { + return failureCount >= maxFailures +} + internal fun resolveTtsStreamPcmDurationMs(totalBytes: Long): Long? { if (totalBytes <= TTS_STREAM_WAV_HEADER_BYTES) return null return ((totalBytes - TTS_STREAM_WAV_HEADER_BYTES) / TTS_STREAM_PCM_BYTES_PER_MS) @@ -234,6 +272,8 @@ class TtsPlaybackManager( private var currentAuthToken: String? = null private val loadedChunks: MutableSet = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) private val chunkStreamIds = java.util.concurrent.ConcurrentHashMap() + private val skippedChunks: MutableSet = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) + private val chunkGenerationFailures = java.util.concurrent.ConcurrentHashMap() enum class TtsMode { CLOUD, BASE @@ -339,7 +379,7 @@ class TtsPlaybackManager( } private fun cancelPrefetchWork() { - logChunkNav("prefetch-cancel", "activePrefetching=${prefetchingJobs.keys.sorted()} lastPrefetch=$lastPrefetchIndex") + logChunkNav("prefetch-cancel", "activePrefetching=${stableSortedIntSnapshot(prefetchingJobs.keys)} lastPrefetch=$lastPrefetchIndex") prefetchLoopJob?.cancel() prefetchingJobs.values.forEach { it.cancel() } prefetchingJobs.clear() @@ -379,7 +419,7 @@ class TtsPlaybackManager( } private fun cacheSnapshot(): String { - return "generation=${currentPlaybackGeneration()} deferredTransitionPrefetch=${deferredTransitionPrefetchGeneration.get()} lastPrefetch=$lastPrefetchIndex loaded=${loadedChunks.sorted()} audio=${audioFiles.keys.sorted()} streams=${chunkStreamIds.keys.sorted()} prefetching=${prefetchingJobs.keys.sorted()}" + return "generation=${currentPlaybackGeneration()} deferredTransitionPrefetch=${deferredTransitionPrefetchGeneration.get()} lastPrefetch=$lastPrefetchIndex loaded=${stableSortedIntSnapshot(loadedChunks)} skipped=${stableSortedIntSnapshot(skippedChunks)} audio=${stableSortedIntSnapshot(audioFiles.keys)} streams=${stableSortedIntSnapshot(chunkStreamIds.keys)} prefetching=${stableSortedIntSnapshot(prefetchingJobs.keys)}" } override fun onConnect( @@ -819,6 +859,8 @@ class TtsPlaybackManager( this.pageIndex = pageIndex loadedChunks.clear() + skippedChunks.clear() + chunkGenerationFailures.clear() lastPrefetchIndex = -1 _ttsState.value = TtsState( @@ -907,7 +949,11 @@ class TtsPlaybackManager( } private fun advanceToNextChunkMediaItem(currentChunkIndex: Int): Boolean { - val nextChunkIndex = resolveTtsChunkSkipTarget(currentChunkIndex, textChunks.size, direction = 1) + val nextPlayableChunkIndex = resolveNextPlayableTtsChunkIndex( + currentChunkIndex = currentChunkIndex, + totalChunks = textChunks.size, + skippedChunkIndices = skippedChunks + ) ?: run { logChunkNavMain( "advance-next-no-target", @@ -915,16 +961,16 @@ class TtsPlaybackManager( ) return false } - val nextPlaylistIndex = findPlaylistIndexForChunk(nextChunkIndex) + val nextPlaylistIndex = findPlaylistIndexForChunk(nextPlayableChunkIndex) ?: run { logChunkNavMain( "advance-next-missing-playlist-item", - "currentChunk=$currentChunkIndex expectedNextChunk=$nextChunkIndex" + "currentChunk=$currentChunkIndex expectedNextChunk=$nextPlayableChunkIndex skipped=${stableSortedIntSnapshot(skippedChunks)}" ) return false } val nextPlaylistChunkIndex = player.getMediaItemAt(nextPlaylistIndex).mediaId.toIntOrNull() - if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex)) { + if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex, skippedChunks)) { logChunkNavWarnMain( "advance-next-refused-non-contiguous", "Refusing non-contiguous TTS advance. current=$currentChunkIndex, nextPlaylistChunk=$nextPlaylistChunkIndex" @@ -933,7 +979,7 @@ class TtsPlaybackManager( } logChunkNavMain( "advance-next-seek", - "currentChunk=$currentChunkIndex nextChunk=$nextChunkIndex nextPlaylistIndex=$nextPlaylistIndex" + "currentChunk=$currentChunkIndex nextChunk=$nextPlayableChunkIndex nextPlaylistIndex=$nextPlaylistIndex" ) player.seekTo(nextPlaylistIndex, 0L) return true @@ -1021,6 +1067,8 @@ class TtsPlaybackManager( audioFiles.clear() chunkStreamIds.clear() loadedChunks.clear() + skippedChunks.clear() + chunkGenerationFailures.clear() lastPrefetchIndex = -1 Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( @@ -1090,6 +1138,8 @@ class TtsPlaybackManager( val serverText = ttsAudioData.serverText if ((audioFile != null || streamUri != null) && serverText != null) { + chunkGenerationFailures.remove(startAtIndex) + skippedChunks.remove(startAtIndex) if (audioFile != null) { audioFiles[startAtIndex] = audioFile } @@ -1160,10 +1210,30 @@ class TtsPlaybackManager( prefetchNextChunkAudio(startAtIndex) } } else { + val failureCount = recordChunkGenerationFailure(startAtIndex) logChunkNav( "prepare-first-failed", - "chunk=$startAtIndex error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}" + "chunk=$startAtIndex failureCount=$failureCount error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}" ) + val nextPlayableChunk = resolveNextPlayableTtsChunkIndex( + currentChunkIndex = startAtIndex, + totalChunks = textChunks.size, + skippedChunkIndices = skippedChunks + startAtIndex + ) + if (shouldGiveUpTtsChunkGeneration(failureCount) && nextPlayableChunk != null) { + skippedChunks.add(startAtIndex) + logChunkNav( + "prepare-first-skip-failed-chunk", + "chunk=$startAtIndex nextChunk=$nextPlayableChunk failureCount=$failureCount" + ) + prepareAndPlayFirstChunk( + startAtIndex = nextPlayableChunk, + playWhenReady = playWhenReady, + startAtPosition = 0L, + prefetchAfterPrepare = prefetchAfterPrepare + ) + return + } _ttsState.value = _ttsState.value.copy( isLoading = false, errorMessage = ttsAudioData.error ?: appContext.getString(R.string.tts_error_load_audio) @@ -1236,6 +1306,8 @@ class TtsPlaybackManager( pageIndex = null cancelPrefetchWork() loadedChunks.clear() + skippedChunks.clear() + chunkGenerationFailures.clear() scope.launch { clearAudioFiles() @@ -1427,6 +1499,10 @@ class TtsPlaybackManager( } val targetIndex = currentIndex + i if (targetIndex < textChunks.size) { + if (skippedChunks.contains(targetIndex)) { + logChunkNav("prefetch-target-skip-marked", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation") + continue + } if (prefetchingJobs.containsKey(targetIndex)) { logChunkNav("prefetch-target-skip-inflight", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation") continue @@ -1489,6 +1565,8 @@ class TtsPlaybackManager( val serverText = ttsAudioData.serverText if ((audioFile != null || streamUri != null) && serverText != null) { + chunkGenerationFailures.remove(targetIndex) + skippedChunks.remove(targetIndex) val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings) val pathToUse = streamUri ?: audioFile!!.absolutePath val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk) @@ -1553,7 +1631,11 @@ class TtsPlaybackManager( } val currentChunkIndex = currentChunkIndexFromPlayer() - val isImmediateNextChunk = targetIndex == currentChunkIndex + 1 + val isImmediateNextChunk = targetIndex == resolveNextPlayableTtsChunkIndex( + currentChunkIndex = currentChunkIndex, + totalChunks = textChunks.size, + skippedChunkIndices = skippedChunks + ) if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) { logChunkNavMain( @@ -1572,11 +1654,19 @@ class TtsPlaybackManager( } } } else { + val failureCount = recordChunkGenerationFailure(targetIndex) Timber.e("Prefetch: Failed to download chunk $targetIndex") logChunkNav( "prefetch-generate-failed", - "targetChunk=$targetIndex generation=$generation error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}" + "targetChunk=$targetIndex generation=$generation failureCount=$failureCount error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}" ) + if (shouldGiveUpTtsChunkGeneration(failureCount)) { + skippedChunks.add(targetIndex) + logChunkNav( + "prefetch-skip-failed-chunk", + "targetChunk=$targetIndex generation=$generation failureCount=$failureCount" + ) + } } } prefetchingJobs[targetIndex] = job @@ -1592,11 +1682,28 @@ class TtsPlaybackManager( ) return@launch } - if (!loadedChunks.contains(targetIndex) && findPlaylistIndexForChunk(targetIndex) == null) { - logChunkNavWarnMain( - "prefetch-stop-after-missing-chunk", - "Stopping TTS prefetch after missing chunk $targetIndex to keep playlist contiguous." + if (skippedChunks.contains(targetIndex)) { + logChunkNav( + "prefetch-after-join-skipped", + "targetChunk=$targetIndex generation=$generation" ) + continue + } + val shouldStopAfterMissingChunk = withContext(Dispatchers.Main) { + val playlistIndex = findPlaylistIndexForChunk(targetIndex) + shouldStopTtsPrefetchAfterMissingChunk( + isLoaded = loadedChunks.contains(targetIndex), + playlistIndex = playlistIndex + ).also { shouldStop -> + if (shouldStop) { + logChunkNavWarnMain( + "prefetch-stop-after-missing-chunk", + "Stopping TTS prefetch after missing chunk $targetIndex to keep playlist contiguous." + ) + } + } + } + if (shouldStopAfterMissingChunk) { return@launch } } @@ -1608,6 +1715,12 @@ class TtsPlaybackManager( } } + private fun recordChunkGenerationFailure(chunkIndex: Int): Int { + return chunkGenerationFailures + .getOrPut(chunkIndex) { AtomicInteger(0) } + .incrementAndGet() + } + private suspend fun trackWordByWord() { var loopCount = 0 while (true) { @@ -1804,6 +1917,8 @@ class TtsPlaybackManager( chunkStreamIds.values.forEach { StreamRegistry.remove(it) } // ADDED chunkStreamIds.clear() // ADDED loadedChunks.clear() + skippedChunks.clear() + chunkGenerationFailures.clear() } } diff --git a/app/src/main/java/com/aryan/reader/tts/TtsService.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsService.kt similarity index 98% rename from app/src/main/java/com/aryan/reader/tts/TtsService.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsService.kt index 3531ed0..17c9ad9 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsService.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsService.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.tts +package org.dueattendant149.bookreader.tts import android.Manifest import android.app.Notification @@ -42,11 +42,11 @@ import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaNotification import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService -import com.aryan.reader.R -import com.aryan.reader.GEMINI_CLOUD_TTS_MODEL -import com.aryan.reader.isByokCloudTtsAvailable -import com.aryan.reader.loadAiByokSettings -import com.aryan.reader.tts.TtsPlaybackManager.TtsMode +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.GEMINI_CLOUD_TTS_MODEL +import org.dueattendant149.bookreader.isByokCloudTtsAvailable +import org.dueattendant149.bookreader.loadAiByokSettings +import org.dueattendant149.bookreader.tts.TtsPlaybackManager.TtsMode import kotlinx.coroutines.Job import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -249,8 +249,8 @@ private const val TTS_FOREGROUND_CHANNEL_ID = "tts_playback" // Keep this aligned with Media3's default notification ID so playback updates replace the fallback. private const val TTS_FOREGROUND_NOTIFICATION_ID = 1001 private const val TTS_FOREGROUND_IDLE_GRACE_MS = 15_000L -private const val ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK = "com.aryan.reader.tts.NOTIFICATION_PREVIOUS_CHUNK" -private const val ACTION_TTS_NOTIFICATION_NEXT_CHUNK = "com.aryan.reader.tts.NOTIFICATION_NEXT_CHUNK" +private const val ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK = "org.dueattendant149.bookreader.tts.NOTIFICATION_PREVIOUS_CHUNK" +private const val ACTION_TTS_NOTIFICATION_NEXT_CHUNK = "org.dueattendant149.bookreader.tts.NOTIFICATION_NEXT_CHUNK" private const val TTS_NOTIFICATION_PREVIOUS_REQUEST_CODE = 4208 private const val TTS_NOTIFICATION_NEXT_REQUEST_CODE = 4209 diff --git a/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsUtils.kt similarity index 87% rename from app/src/main/java/com/aryan/reader/tts/TtsUtils.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsUtils.kt index cb403f3..594b823 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/tts/TtsUtils.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.tts +package org.dueattendant149.bookreader.tts import android.content.Context import android.media.MediaPlayer @@ -27,7 +27,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.content.edit import androidx.media3.common.util.UnstableApi -import com.aryan.reader.BuildConfig +import org.dueattendant149.bookreader.BuildConfig import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -113,7 +113,31 @@ fun formatBytes(bytes: Long): String { } class TtsCacheManager(private val context: Context) { - private fun sanitize(name: String): String = name.replace(Regex("[^a-zA-Z0-9.-]"), "_") + private val baseDir: File + get() = File(context.filesDir, "TTS_Cache") + + private fun safeCacheSegment(name: String, fallback: String): String { + val normalized = name.trim().takeIf { it.isNotBlank() } ?: fallback + val slug = normalized + .replace(Regex("[^a-zA-Z0-9_-]+"), "_") + .trim('_', '-') + .ifBlank { fallback } + .take(48) + return "${slug}_${hash(normalized).take(16)}" + } + + private fun sanitizeFileToken(name: String): String { + return name + .replace(Regex("[^a-zA-Z0-9._-]+"), "_") + .trim('.', '_', '-') + .ifBlank { "default" } + } + + private fun bookDirName(bookTitle: String): String = safeCacheSegment(bookTitle, "book") + + private fun chapterDirName(chapterTitle: String?): String { + return safeCacheSegment(chapterTitle ?: "Unknown_Chapter", "chapter") + } private fun hash(input: String): String { val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) @@ -121,9 +145,8 @@ class TtsCacheManager(private val context: Context) { } fun saveTotalChunks(bookTitle: String, chapterTitle: String?, totalChunks: Int) { - val baseDir = File(context.filesDir, "TTS_Cache") - val bookDir = File(baseDir, sanitize(bookTitle.take(50))) - val chapterDir = File(bookDir, sanitize((chapterTitle ?: "Unknown_Chapter").take(50))) + val bookDir = getBookCacheDir(bookTitle) + val chapterDir = File(bookDir, chapterDirName(chapterTitle)) if (!chapterDir.exists()) chapterDir.mkdirs() val metaFile = File(chapterDir, "total_chunks.txt") metaFile.writeText(totalChunks.toString()) @@ -137,22 +160,20 @@ class TtsCacheManager(private val context: Context) { speakerId: String, mode: TtsPlaybackManager.TtsMode ): File { - val baseDir = File(context.filesDir, "TTS_Cache") - val bookDir = File(baseDir, sanitize(bookTitle.take(50))) - val chapterDir = File(bookDir, sanitize((chapterTitle ?: "Unknown_Chapter").take(50))) + val bookDir = getBookCacheDir(bookTitle) + val chapterDir = File(bookDir, chapterDirName(chapterTitle)) if (!chapterDir.exists()) { chapterDir.mkdirs() } val hashParams = hash(text + speakerId + mode.name) - val safeSpeaker = sanitize(speakerId) + val safeSpeaker = sanitizeFileToken(speakerId) return File(chapterDir, "cached_chunk_${safeSpeaker}_$hashParams.wav") } fun getBookCacheDir(bookTitle: String): File { - val baseDir = File(context.filesDir, "TTS_Cache") - return File(baseDir, sanitize(bookTitle.take(50))) + return File(baseDir, bookDirName(bookTitle)) } fun getChapterCaches(bookTitle: String, speakerFilter: String? = null): List { @@ -194,18 +215,35 @@ class TtsCacheManager(private val context: Context) { } fun deleteChapterCache(chapterDir: File) { - chapterDir.deleteRecursively() + if (chapterDir.isInsideBaseDir()) { + chapterDir.deleteRecursively() + } } fun deleteSpecificFiles(files: List, chapterDir: File) { - files.forEach { it.delete() } - if (chapterDir.listFiles()?.isEmpty() == true) { + if (!chapterDir.isInsideBaseDir()) return + files.forEach { file -> + if (file.isInside(chapterDir)) { + file.delete() + } + } + if (chapterDir.listFiles()?.isEmpty() == true && chapterDir.isInsideBaseDir()) { chapterDir.deleteRecursively() } } fun clearBookCache(bookTitle: String) { - getBookCacheDir(bookTitle).deleteRecursively() + getBookCacheDir(bookTitle).takeIf { it.isInsideBaseDir() }?.deleteRecursively() + } + + private fun File.isInsideBaseDir(): Boolean { + return isInside(baseDir) + } + + private fun File.isInside(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) } } diff --git a/app/src/main/java/com/aryan/reader/ui/theme/Color.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Color.kt similarity index 98% rename from app/src/main/java/com/aryan/reader/ui/theme/Color.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Color.kt index c690bdc..e69b93a 100644 --- a/app/src/main/java/com/aryan/reader/ui/theme/Color.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Color.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.ui.theme +package org.dueattendant149.bookreader.ui.theme import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt similarity index 98% rename from app/src/main/java/com/aryan/reader/ui/theme/Theme.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt index 4ed63a3..080c31c 100644 --- a/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.ui.theme +package org.dueattendant149.bookreader.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme @@ -32,7 +32,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import com.materialkolor.PaletteStyle import androidx.compose.ui.platform.LocalContext -import com.aryan.reader.shared.ui.withAppFontFamily +import org.dueattendant149.bookreader.shared.ui.withAppFontFamily import com.materialkolor.dynamicColorScheme private val lightScheme = lightColorScheme( diff --git a/app/src/main/java/com/aryan/reader/ui/theme/Type.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Type.kt similarity index 94% rename from app/src/main/java/com/aryan/reader/ui/theme/Type.kt rename to app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Type.kt index f4c3191..996ef13 100644 --- a/app/src/main/java/com/aryan/reader/ui/theme/Type.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Type.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.ui.theme +package org.dueattendant149.bookreader.ui.theme import androidx.compose.material3.Typography diff --git a/app/src/main/res/drawable-nodpi/account_circle.xml b/app/src/main/res/drawable-nodpi/account_circle.xml new file mode 100644 index 0000000..9cca17a --- /dev/null +++ b/app/src/main/res/drawable-nodpi/account_circle.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/add.xml b/app/src/main/res/drawable-nodpi/add.xml new file mode 100644 index 0000000..d6fd3d3 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/add.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/arrow_back.xml b/app/src/main/res/drawable-nodpi/arrow_back.xml new file mode 100644 index 0000000..0e2e863 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/arrow_back.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/arrow_downward.xml b/app/src/main/res/drawable-nodpi/arrow_downward.xml new file mode 100644 index 0000000..e383ebd --- /dev/null +++ b/app/src/main/res/drawable-nodpi/arrow_downward.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/arrow_drop_down.xml b/app/src/main/res/drawable-nodpi/arrow_drop_down.xml new file mode 100644 index 0000000..dfea22c --- /dev/null +++ b/app/src/main/res/drawable-nodpi/arrow_drop_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/arrow_drop_up.xml b/app/src/main/res/drawable-nodpi/arrow_drop_up.xml new file mode 100644 index 0000000..05735c6 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/arrow_drop_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/arrow_forward.xml b/app/src/main/res/drawable-nodpi/arrow_forward.xml new file mode 100644 index 0000000..81139b1 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/arrow_forward.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/arrow_upward.xml b/app/src/main/res/drawable-nodpi/arrow_upward.xml new file mode 100644 index 0000000..8e4a2bc --- /dev/null +++ b/app/src/main/res/drawable-nodpi/arrow_upward.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/book.xml b/app/src/main/res/drawable-nodpi/book.xml new file mode 100644 index 0000000..ec1fb1d --- /dev/null +++ b/app/src/main/res/drawable-nodpi/book.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/bookmark_border.xml b/app/src/main/res/drawable-nodpi/bookmark_border.xml new file mode 100644 index 0000000..cc78582 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/bookmark_border.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/brush.xml b/app/src/main/res/drawable-nodpi/brush.xml new file mode 100644 index 0000000..9f7fdcb --- /dev/null +++ b/app/src/main/res/drawable-nodpi/brush.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/bug_report.xml b/app/src/main/res/drawable-nodpi/bug_report.xml new file mode 100644 index 0000000..108a234 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/bug_report.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/check.xml b/app/src/main/res/drawable-nodpi/check.xml new file mode 100644 index 0000000..280f0bd --- /dev/null +++ b/app/src/main/res/drawable-nodpi/check.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/chevron_left.xml b/app/src/main/res/drawable-nodpi/chevron_left.xml new file mode 100644 index 0000000..7c486f8 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/chevron_left.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/chevron_right.xml b/app/src/main/res/drawable-nodpi/chevron_right.xml new file mode 100644 index 0000000..3d036ec --- /dev/null +++ b/app/src/main/res/drawable-nodpi/chevron_right.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/cloud.xml b/app/src/main/res/drawable-nodpi/cloud.xml new file mode 100644 index 0000000..b665132 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/cloud.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/cloud_download.xml b/app/src/main/res/drawable-nodpi/cloud_download.xml new file mode 100644 index 0000000..41ebcc0 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/cloud_download.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/code.xml b/app/src/main/res/drawable-nodpi/code.xml new file mode 100644 index 0000000..8e7fc92 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/code.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/collapse_all.xml b/app/src/main/res/drawable-nodpi/collapse_all.xml new file mode 100644 index 0000000..6d30f6b --- /dev/null +++ b/app/src/main/res/drawable-nodpi/collapse_all.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/content_copy.xml b/app/src/main/res/drawable-nodpi/content_copy.xml new file mode 100644 index 0000000..c744e1a --- /dev/null +++ b/app/src/main/res/drawable-nodpi/content_copy.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/copy_all.xml b/app/src/main/res/drawable-nodpi/copy_all.xml new file mode 100644 index 0000000..0cf3a90 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/copy_all.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/create_new_folder.xml b/app/src/main/res/drawable-nodpi/create_new_folder.xml new file mode 100644 index 0000000..8767320 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/create_new_folder.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/delete.xml b/app/src/main/res/drawable-nodpi/delete.xml new file mode 100644 index 0000000..d724c2e --- /dev/null +++ b/app/src/main/res/drawable-nodpi/delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/description.xml b/app/src/main/res/drawable-nodpi/description.xml new file mode 100644 index 0000000..9e37e87 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/description.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/devices.xml b/app/src/main/res/drawable-nodpi/devices.xml new file mode 100644 index 0000000..7d69f1a --- /dev/null +++ b/app/src/main/res/drawable-nodpi/devices.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/do_not_touch.xml b/app/src/main/res/drawable-nodpi/do_not_touch.xml new file mode 100644 index 0000000..40e3521 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/do_not_touch.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/download.xml b/app/src/main/res/drawable-nodpi/download.xml new file mode 100644 index 0000000..dba4601 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/download.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/edit.xml b/app/src/main/res/drawable-nodpi/edit.xml new file mode 100644 index 0000000..b253108 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/edit.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/expand_all.xml b/app/src/main/res/drawable-nodpi/expand_all.xml new file mode 100644 index 0000000..f9ab87a --- /dev/null +++ b/app/src/main/res/drawable-nodpi/expand_all.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/expand_less.xml b/app/src/main/res/drawable-nodpi/expand_less.xml new file mode 100644 index 0000000..c194db7 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/expand_less.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/expand_more.xml b/app/src/main/res/drawable-nodpi/expand_more.xml new file mode 100644 index 0000000..0c79f6e --- /dev/null +++ b/app/src/main/res/drawable-nodpi/expand_more.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/favorite.xml b/app/src/main/res/drawable-nodpi/favorite.xml new file mode 100644 index 0000000..2e40a45 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/favorite.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/favorite_border.xml b/app/src/main/res/drawable-nodpi/favorite_border.xml new file mode 100644 index 0000000..2e40a45 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/favorite_border.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/file_open.xml b/app/src/main/res/drawable-nodpi/file_open.xml new file mode 100644 index 0000000..c94ebed --- /dev/null +++ b/app/src/main/res/drawable-nodpi/file_open.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/filter_list.xml b/app/src/main/res/drawable-nodpi/filter_list.xml new file mode 100644 index 0000000..3a6f319 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/filter_list.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/folder.xml b/app/src/main/res/drawable-nodpi/folder.xml new file mode 100644 index 0000000..fc4e96c --- /dev/null +++ b/app/src/main/res/drawable-nodpi/folder.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/folder_special.xml b/app/src/main/res/drawable-nodpi/folder_special.xml new file mode 100644 index 0000000..273a817 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/folder_special.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/format_list_numbered.xml b/app/src/main/res/drawable-nodpi/format_list_numbered.xml new file mode 100644 index 0000000..03106f4 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/format_list_numbered.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/fullscreen.xml b/app/src/main/res/drawable-nodpi/fullscreen.xml new file mode 100644 index 0000000..16f704f --- /dev/null +++ b/app/src/main/res/drawable-nodpi/fullscreen.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/fullscreen_exit.xml b/app/src/main/res/drawable-nodpi/fullscreen_exit.xml new file mode 100644 index 0000000..54177a1 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/fullscreen_exit.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/gavel.xml b/app/src/main/res/drawable-nodpi/gavel.xml new file mode 100644 index 0000000..9c3d180 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/gavel.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/graphic_eq.xml b/app/src/main/res/drawable-nodpi/graphic_eq.xml new file mode 100644 index 0000000..ee498e8 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/graphic_eq.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/import_export.xml b/app/src/main/res/drawable-nodpi/import_export.xml new file mode 100644 index 0000000..9f16b52 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/import_export.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/info.xml b/app/src/main/res/drawable-nodpi/info.xml new file mode 100644 index 0000000..7eda45e --- /dev/null +++ b/app/src/main/res/drawable-nodpi/info.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/keep.xml b/app/src/main/res/drawable-nodpi/keep.xml new file mode 100644 index 0000000..b7be67f --- /dev/null +++ b/app/src/main/res/drawable-nodpi/keep.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/keyboard_arrow_down.xml b/app/src/main/res/drawable-nodpi/keyboard_arrow_down.xml new file mode 100644 index 0000000..3f4697d --- /dev/null +++ b/app/src/main/res/drawable-nodpi/keyboard_arrow_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/keyboard_arrow_left.xml b/app/src/main/res/drawable-nodpi/keyboard_arrow_left.xml new file mode 100644 index 0000000..7c486f8 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/keyboard_arrow_left.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/keyboard_arrow_right.xml b/app/src/main/res/drawable-nodpi/keyboard_arrow_right.xml new file mode 100644 index 0000000..3d036ec --- /dev/null +++ b/app/src/main/res/drawable-nodpi/keyboard_arrow_right.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/keyboard_arrow_up.xml b/app/src/main/res/drawable-nodpi/keyboard_arrow_up.xml new file mode 100644 index 0000000..62e0593 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/keyboard_arrow_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/list.xml b/app/src/main/res/drawable-nodpi/list.xml new file mode 100644 index 0000000..a16c937 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/list.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/lock.xml b/app/src/main/res/drawable-nodpi/lock.xml new file mode 100644 index 0000000..67e9183 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/lock.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/lock_open.xml b/app/src/main/res/drawable-nodpi/lock_open.xml new file mode 100644 index 0000000..111e0fb --- /dev/null +++ b/app/src/main/res/drawable-nodpi/lock_open.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/mail.xml b/app/src/main/res/drawable-nodpi/mail.xml new file mode 100644 index 0000000..a50fe01 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/mail.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/menu.xml b/app/src/main/res/drawable-nodpi/menu.xml new file mode 100644 index 0000000..538d1cf --- /dev/null +++ b/app/src/main/res/drawable-nodpi/menu.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/menu_book.xml b/app/src/main/res/drawable-nodpi/menu_book.xml new file mode 100644 index 0000000..bca9124 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/menu_book.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/more_vert.xml b/app/src/main/res/drawable-nodpi/more_vert.xml new file mode 100644 index 0000000..e4aa85d --- /dev/null +++ b/app/src/main/res/drawable-nodpi/more_vert.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/my_location.xml b/app/src/main/res/drawable-nodpi/my_location.xml new file mode 100644 index 0000000..d023089 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/my_location.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/navigate_before.xml b/app/src/main/res/drawable-nodpi/navigate_before.xml new file mode 100644 index 0000000..dd65a77 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/navigate_before.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/navigate_next.xml b/app/src/main/res/drawable-nodpi/navigate_next.xml new file mode 100644 index 0000000..7a1867c --- /dev/null +++ b/app/src/main/res/drawable-nodpi/navigate_next.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/open_in_new.xml b/app/src/main/res/drawable-nodpi/open_in_new.xml new file mode 100644 index 0000000..d7dabf4 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/open_in_new.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/phone_android.xml b/app/src/main/res/drawable-nodpi/phone_android.xml new file mode 100644 index 0000000..cdfa780 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/phone_android.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/play_arrow.xml b/app/src/main/res/drawable-nodpi/play_arrow.xml new file mode 100644 index 0000000..9bc6b5d --- /dev/null +++ b/app/src/main/res/drawable-nodpi/play_arrow.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/policy.xml b/app/src/main/res/drawable-nodpi/policy.xml new file mode 100644 index 0000000..646b819 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/policy.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/psychology.xml b/app/src/main/res/drawable-nodpi/psychology.xml new file mode 100644 index 0000000..e7835ad --- /dev/null +++ b/app/src/main/res/drawable-nodpi/psychology.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/push_pin.xml b/app/src/main/res/drawable-nodpi/push_pin.xml new file mode 100644 index 0000000..b7be67f --- /dev/null +++ b/app/src/main/res/drawable-nodpi/push_pin.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/redo.xml b/app/src/main/res/drawable-nodpi/redo.xml new file mode 100644 index 0000000..d47efe3 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/redo.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/refresh.xml b/app/src/main/res/drawable-nodpi/refresh.xml new file mode 100644 index 0000000..f4302a5 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/refresh.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/remove.xml b/app/src/main/res/drawable-nodpi/remove.xml new file mode 100644 index 0000000..46c12d3 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/remove.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/restore.xml b/app/src/main/res/drawable-nodpi/restore.xml new file mode 100644 index 0000000..93ca20d --- /dev/null +++ b/app/src/main/res/drawable-nodpi/restore.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/save.xml b/app/src/main/res/drawable-nodpi/save.xml new file mode 100644 index 0000000..2da0ac2 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/save.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/screen_rotation.xml b/app/src/main/res/drawable-nodpi/screen_rotation.xml new file mode 100644 index 0000000..708aed3 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/screen_rotation.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/settings.xml b/app/src/main/res/drawable-nodpi/settings.xml new file mode 100644 index 0000000..4bcd4aa --- /dev/null +++ b/app/src/main/res/drawable-nodpi/settings.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/settings_backup_restore.xml b/app/src/main/res/drawable-nodpi/settings_backup_restore.xml new file mode 100644 index 0000000..2c551ca --- /dev/null +++ b/app/src/main/res/drawable-nodpi/settings_backup_restore.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/share.xml b/app/src/main/res/drawable-nodpi/share.xml new file mode 100644 index 0000000..9224dbe --- /dev/null +++ b/app/src/main/res/drawable-nodpi/share.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/smartphone.xml b/app/src/main/res/drawable-nodpi/smartphone.xml new file mode 100644 index 0000000..03fb0c2 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/smartphone.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/star.xml b/app/src/main/res/drawable-nodpi/star.xml new file mode 100644 index 0000000..0a592a4 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/star.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/stop.xml b/app/src/main/res/drawable-nodpi/stop.xml new file mode 100644 index 0000000..cfc9094 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/stop.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/swap_horiz.xml b/app/src/main/res/drawable-nodpi/swap_horiz.xml new file mode 100644 index 0000000..bd8b94b --- /dev/null +++ b/app/src/main/res/drawable-nodpi/swap_horiz.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/text_fields.xml b/app/src/main/res/drawable-nodpi/text_fields.xml new file mode 100644 index 0000000..672a80a --- /dev/null +++ b/app/src/main/res/drawable-nodpi/text_fields.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/text_select_start.xml b/app/src/main/res/drawable-nodpi/text_select_start.xml new file mode 100644 index 0000000..2d59fa3 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/text_select_start.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/touch_app.xml b/app/src/main/res/drawable-nodpi/touch_app.xml new file mode 100644 index 0000000..6df7b49 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/touch_app.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/tune.xml b/app/src/main/res/drawable-nodpi/tune.xml new file mode 100644 index 0000000..c37eb33 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/tune.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/upload_file.xml b/app/src/main/res/drawable-nodpi/upload_file.xml new file mode 100644 index 0000000..7f7df0f --- /dev/null +++ b/app/src/main/res/drawable-nodpi/upload_file.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/verified.xml b/app/src/main/res/drawable-nodpi/verified.xml new file mode 100644 index 0000000..5b8996e --- /dev/null +++ b/app/src/main/res/drawable-nodpi/verified.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/verified_user.xml b/app/src/main/res/drawable-nodpi/verified_user.xml new file mode 100644 index 0000000..e0bddb4 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/verified_user.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/visibility.xml b/app/src/main/res/drawable-nodpi/visibility.xml new file mode 100644 index 0000000..0fded3f --- /dev/null +++ b/app/src/main/res/drawable-nodpi/visibility.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/visibility_off.xml b/app/src/main/res/drawable-nodpi/visibility_off.xml new file mode 100644 index 0000000..6fa698a --- /dev/null +++ b/app/src/main/res/drawable-nodpi/visibility_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/volume_up.xml b/app/src/main/res/drawable-nodpi/volume_up.xml new file mode 100644 index 0000000..bc9c5c8 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/volume_up.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values-ar/plurals.xml b/app/src/main/res/values-ar/plurals.xml index a73d4c4..4732792 100644 --- a/app/src/main/res/values-ar/plurals.xml +++ b/app/src/main/res/values-ar/plurals.xml @@ -2,23 +2,23 @@ لا كتب - كتاب واحد + %1$d كتاب كتابان %1$d كتب %1$d كتابًا %1$d كتاب - لا كتب - كتاب واحد + كتب + كتاب كتابان - %1$d كتب - %1$d كتابًا - %1$d كتاب + كتب + كتاب + كتب لا توجد رفوف - رف واحد + %1$d رف رفان %1$d رفوف %1$d رفاً @@ -26,23 +26,23 @@ لم يتم العثور على نتائج - تم العثور على نتيجة واحدة + تم العثور على %1$d نتيجة تم العثور على نتيجتين تم العثور على %1$d نتائج تم العثور على %1$d نتيجة تم العثور على %1$d نتيجة - لا توجد ملفات لحذفها نهائياً - حذف الملف نهائياً - حذف الملفين نهائياً - حذف الـ %1$d ملفات نهائياً - حذف الـ %1$d ملفاً نهائياً - حذف الـ %1$d ملف نهائياً + حذف الملفات نهائيًا + حذف الملف نهائيًا + حذف الملفين نهائيًا + حذف الملفات نهائيًا + حذف الملفات نهائيًا + حذف الملفات نهائيًا لا توجد ملفات مختارة. - هل تريد حذف الملف المختار نهائياً من جهازك؟ لا يمكن التراجع عن هذا الإجراء. + هل تريد حذف %1$d ملف محدد نهائيًا من جهازك؟ لا يمكن التراجع عن هذا الإجراء. هل تريد حذف الملفين المختارين نهائياً من جهازك؟ لا يمكن التراجع عن هذا الإجراء. هل تريد حذف الـ %1$d ملفات المختارة نهائياً من جهازك؟ لا يمكن التراجع عن هذا الإجراء. هل تريد حذف الـ %1$d ملفاً مختاراً نهائياً من جهازك؟ لا يمكن التراجع عن هذا الإجراء. @@ -50,7 +50,7 @@ لا توجد ملفات مختارة لإزالتها من القائمة. - هل تريد إزالة الملف المختار من قائمة الملفات الأخيرة؟ سيظهر مجدداً إذا فتحته من المكتبة. + هل تريد إزالة %1$d ملف محدد من قائمة الملفات الأخيرة؟ سيظهر مرة أخرى إذا فتحته من المكتبة. هل تريد إزالة الملفين المختارين من قائمة الملفات الأخيرة؟ سيظهران مجدداً إذا فتحتهما من المكتبة. هل تريد إزالة الـ %1$d ملفات المختارة من قائمة الملفات الأخيرة؟ ستظهر مجدداً إذا فتحتها من المكتبة. هل تريد إزالة الـ %1$d ملفاً مختاراً من قائمة الملفات الأخيرة؟ ستظهر مجدداً إذا فتحتها من المكتبة. @@ -58,7 +58,7 @@ لا توجد كتب مختارة لإزالتها من الرف. - هل أنت متأكد من أنك تريد إزالة كتاب واحد من رف \'%2$s\'؟ سيبقى الكتاب في مكتبتك وسيظهر ضمن القسم \"غير مصنف\". + هل أنت متأكد من أنك تريد إزالة %1$d كتاب من رف \'%2$s\'؟ سيبقى الكتاب في مكتبتك وسيظهر ضمن القسم "غير مصنف". هل أنت متأكد من أنك تريد إزالة كتابين من رف \'%2$s\'؟ سيبقى الكتابان في مكتبتك وسيظهران ضمن القسم \"غير مصنف\". هل أنت متأكد من أنك تريد إزالة %1$d كتب من رف \'%2$s\'؟ ستبقى الكتب في مكتبتك وستظهر ضمن القسم \"غير مصنف\". هل أنت متأكد من أنك تريد إزالة %1$d كتاباً من رف \'%2$s\'؟ ستبقى الكتب في مكتبتك وستظهر ضمن القسم \"غير مصنف\". @@ -66,10 +66,210 @@ لم يتم إزالة أي كتب من المكتبة. - تم إزالة كتاب واحد من المكتبة. + تمت إزالة %1$d كتاب من المكتبة. تم إزالة كتابين من المكتبة. تم إزالة %1$d كتب من المكتبة. تم إزالة %1$d كتاباً من المكتبة. تم إزالة %1$d كتاب من المكتبة. + + %1$d تم العثور على التطابقات + %1$d تم العثور على تطابق + %1$d تم العثور على التطابقات + %1$d تم العثور على التطابقات + %1$d تم العثور على التطابقات + %1$d تم العثور على التطابقات + + + استيراد %1$d الكتب... ستظهر في مكتبتك قريبًا. + استيراد %1$d كتاب... سيظهر في مكتبتك قريبا. + استيراد %1$d الكتب... ستظهر في مكتبتك قريبًا. + استيراد %1$d الكتب... ستظهر في مكتبتك قريبًا. + استيراد %1$d الكتب... ستظهر في مكتبتك قريبًا. + استيراد %1$d الكتب... ستظهر في مكتبتك قريبًا. + + + مستورد %1$d كتب. يمكنك العثور عليها في علامة التبويب "المكتبة". + مستورد %1$d كتاب. يمكنك العثور عليه في علامة التبويب "المكتبة". + مستورد %1$d كتب. يمكنك العثور عليها في علامة التبويب "المكتبة". + مستورد %1$d كتب. يمكنك العثور عليها في علامة التبويب "المكتبة". + مستورد %1$d كتب. يمكنك العثور عليها في علامة التبويب "المكتبة". + مستورد %1$d كتب. يمكنك العثور عليها في علامة التبويب "المكتبة". + + + %1$d الكتب المضافة إلى الرف. + %1$d تمت إضافة الكتاب إلى الرف. + %1$d الكتب المضافة إلى الرف. + %1$d الكتب المضافة إلى الرف. + %1$d الكتب المضافة إلى الرف. + %1$d الكتب المضافة إلى الرف. + + + %1$d الكتب الموسومة ب "%2$s". + %1$d كتاب ذو علامة "%2$s". + %1$d الكتب الموسومة ب "%2$s". + %1$d الكتب الموسومة ب "%2$s". + %1$d الكتب الموسومة ب "%2$s". + %1$d الكتب الموسومة ب "%2$s". + + + تمت إزالة المجلد "%1$s" و %2$d الكتب من التطبيق. + تمت إزالة المجلد "%1$s" و %2$d كتاب من التطبيق. + تمت إزالة المجلد "%1$s" و %2$d الكتب من التطبيق. + تمت إزالة المجلد "%1$s" و %2$d الكتب من التطبيق. + تمت إزالة المجلد "%1$s" و %2$d الكتب من التطبيق. + تمت إزالة المجلد "%1$s" و %2$d الكتب من التطبيق. + + + %1$d المجلدات + %1$d المجلد + %1$d المجلدات + %1$d المجلدات + %1$d المجلدات + %1$d المجلدات + + + %1$d ملفات + %1$d ملف + %1$d ملفات + %1$d ملفات + %1$d ملفات + %1$d ملفات + + + أفلت للاستيراد %1$d ملفات + أفلت للاستيراد %1$d file + أفلت للاستيراد %1$d ملفات + أفلت للاستيراد %1$d ملفات + أفلت للاستيراد %1$d ملفات + أفلت للاستيراد %1$d ملفات + + + %1$d سيتم تخطي الملفات غير المدعومة. + %1$d سيتم تخطي الملف غير المدعوم. + %1$d سيتم تخطي الملفات غير المدعومة. + %1$d سيتم تخطي الملفات غير المدعومة. + %1$d سيتم تخطي الملفات غير المدعومة. + %1$d سيتم تخطي الملفات غير المدعومة. + + + استيراد %1$d ملفات… + استيراد %1$d ملف… + استيراد %1$d ملفات… + استيراد %1$d ملفات… + استيراد %1$d ملفات… + استيراد %1$d ملفات… + + + مستورد %1$d ملفات. + مستورد %1$d ملف. + مستورد %1$d ملفات. + مستورد %1$d ملفات. + مستورد %1$d ملفات. + مستورد %1$d ملفات. + + + مستورد %1$d ملفات. يأتي دعم القارئ لاحقًا. + مستورد %1$d ملف. يأتي دعم القارئ لاحقًا. + مستورد %1$d ملفات. يأتي دعم القارئ لاحقًا. + مستورد %1$d ملفات. يأتي دعم القارئ لاحقًا. + مستورد %1$d ملفات. يأتي دعم القارئ لاحقًا. + مستورد %1$d ملفات. يأتي دعم القارئ لاحقًا. + + + تعذر استيراد %1$d ملفات. + تعذر استيراد %1$d ملف. + تعذر استيراد %1$d ملفات. + تعذر استيراد %1$d ملفات. + تعذر استيراد %1$d ملفات. + تعذر استيراد %1$d ملفات. + + + تم تخطي %1$d ملفات. + تم تخطي %1$d ملف. + تم تخطي %1$d ملفات. + تم تخطي %1$d ملفات. + تم تخطي %1$d ملفات. + تم تخطي %1$d ملفات. + + + إزالة "%1$s" و %2$d الكتب من التطبيق؟ لن يتم حذف الملفات الموجودة على القرص. + إزالة "%1$s" و %2$d كتاب من التطبيق؟ لن يتم حذف الملفات الموجودة على القرص. + إزالة "%1$s" و %2$d الكتب من التطبيق؟ لن يتم حذف الملفات الموجودة على القرص. + إزالة "%1$s" و %2$d الكتب من التطبيق؟ لن يتم حذف الملفات الموجودة على القرص. + إزالة "%1$s" و %2$d الكتب من التطبيق؟ لن يتم حذف الملفات الموجودة على القرص. + إزالة "%1$s" و %2$d الكتب من التطبيق؟ لن يتم حذف الملفات الموجودة على القرص. + + + فشلت مزامنة المجلد لـ %1$d المجلدات. + فشلت مزامنة المجلد لـ %1$d المجلد. + فشلت مزامنة المجلد لـ %1$d المجلدات. + فشلت مزامنة المجلد لـ %1$d المجلدات. + فشلت مزامنة المجلد لـ %1$d المجلدات. + فشلت مزامنة المجلد لـ %1$d المجلدات. + + + انتهت مزامنة المجلد مع %1$d تم تخطي المجلدات. + انتهت مزامنة المجلد مع %1$d تم تخطي المجلد. + انتهت مزامنة المجلد مع %1$d تم تخطي المجلدات. + انتهت مزامنة المجلد مع %1$d تم تخطي المجلدات. + انتهت مزامنة المجلد مع %1$d تم تخطي المجلدات. + انتهت مزامنة المجلد مع %1$d تم تخطي المجلدات. + + + تمت الإزالة %1$d متدفقة OPDS الكتب من هذا الكتالوج. + تمت الإزالة %1$d متدفقة OPDS كتاب من هذا الكتالوج. + تمت الإزالة %1$d متدفقة OPDS الكتب من هذا الكتالوج. + تمت الإزالة %1$d متدفقة OPDS الكتب من هذا الكتالوج. + تمت الإزالة %1$d متدفقة OPDS الكتب من هذا الكتالوج. + تمت الإزالة %1$d متدفقة OPDS الكتب من هذا الكتالوج. + + + %1$d العلامات + %1$d علامة + %1$d العلامات + %1$d العلامات + %1$d العلامات + %1$d العلامات + + + جميع الكتب %1$d + جميع الكتب %1$d + جميع الكتب %1$d + جميع الكتب %1$d + جميع الكتب %1$d + جميع الكتب %1$d + + + رفوف %1$d + رفوف %1$d + رفوف %1$d + رفوف %1$d + رفوف %1$d + رفوف %1$d + + + العلامات %1$d + العلامات %1$d + العلامات %1$d + العلامات %1$d + العلامات %1$d + العلامات %1$d + + + المجلدات %1$d + المجلدات %1$d + المجلدات %1$d + المجلدات %1$d + المجلدات %1$d + المجلدات %1$d + + + (%1$d قطع) + (%1$d قطعة) + (%1$d قطع) + (%1$d قطع) + (%1$d قطع) + (%1$d قطع) + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 03b05ae..f393f2f 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -657,4 +657,859 @@ Nederlands (الهولندية) Українська (الأوكرانية) Bahasa Indonesia (الإندونيسية) + تمكين + خطأ: %1$s + العودة + إظهار علامات التبويب في شريط التطبيقات العلوي + دعم المشروع + تعطيل المزامنة المحلية + تمكين المزامنة المحلية + تم تعطيل المزامنة المحلية + هل تريد تعطيل مزامنة المجلد المحلي؟ + Episteme سيتوقف عن فحص هذا المجلد ويتوقف عن الكتابة JSON مزامنة الملفات. قم بإزالة %1$s مجلد من هذا المجلد أيضا؟ + احتفظ ببيانات المزامنة + إزالة بيانات المزامنة + Google الخطوط + تصفح Google الخطوط + ابحث عن أكثر من 1900 خط... + الاختيارات الشعبية + لم يتم العثور على خطوط مطابقة \'%1$s\' + تم تنزيلها بالفعل + هل تريد حذف الخطوط؟ + هل أنت متأكد أنك تريد حذف %1$d الخطوط المختارة؟ سيؤدي هذا إلى إزالتها من جميع أجهزتك إذا كانت المزامنة قيد التشغيل. + دعم المشروع + ساعد في الحفاظ على Episteme تتحرك + دعمكم يساعدني في الحفاظ على Episteme للجميع!!! + الراعي على جيثب + دعم التطوير مباشرة من خلال رعاة GitHub. كتعبير عن شكرك، ستحصل على صيحة README في مستودع المشروع. + انضم على باتريون + كشكر لك على دعم التطبيق، يحصل مؤيدو Patreon على محتوى وفوائد إضافية: نظرة خاطفة على ما أعمل عليه، ولقطات شاشة وتحديثات مبكرة، وأصوات تساعد في تشكيل الشكل الذي يجب أن تبدو عليه الميزات الجديدة وتعمل، وصيحة README في مستودع المشروع. + لم يتم تمكين مزامنة أي مجلدات محلية. + تم تعطيل مزامنة المجلد المحلي. + تم تعطيل مزامنة المجلد المحلي. تمت إزالة مجلد بيانات المزامنة. + تم تعطيل مزامنة المجلد المحلي، ولكن لا يمكن إزالة مجلد بيانات المزامنة. + تم تمكين مزامنة المجلد المحلي. + نوع الملف هذا غير مدعوم. + جارٍ إنشاء الملخص... + نسخ الموضوع + لا يمكن إنشاء أي ملخص. + التفكير… + AI لا يمكن تقديم تعريف. + السؤال AI حول \'%1$s\'… + السحابة (المقر الرئيسي) + الإعدادات المسبقة محكم + بلدي المواضيع محكم + لا توجد سمات مزخرفة مخصصة حتى الآن. + موضوع محكم جديد + الملمس + لا شيء + تحميل + شفافية الملمس + النص فارغ. + AI عاد تعريف فارغ. + لا يمكن الحصول على التعريف. + حدث خطأ غير معروف في الخادم. + خطأ في الشبكة. تحقق من الاتصال. + لا يوجد سياق كافٍ للتلخيص. + فشل تحليل الملخّص. + خطأ في الشبكة أثناء إنشاء الملخّص. + محتوى الكتاب فارغ. + فشل تحليل الملخص من استجابة الخادم. + تعذر جلب الملخص. + خطأ: %1$d. %2$s + خطأ في الشبكة. يرجى التحقق من الاتصال وحالة الخادم. + تحليل الفصل %1$d... + قراءة الوضع الحالي... + جارٍ إنشاء الملخّص... + ملخص الفصل + ملخص القصة (تجريبي) + فتح تلخيص الفصل + احصل على ملخصات موجزة لأي فصل باستخدام Episteme Pro. قم بالترقية لبدء استخدام هذه الميزة. + فتح القاموس الذكي + يعد تحديد العبارات والفقرات بأكملها حتى 2000 حرفًا ميزة احترافية. قم بالترقية للحصول على تعريفات فورية لأي نص محدد. + عمودي (عرض ويب) + عمودي (إصدار تجريبي أصلي) + اتجاه الشاشة + تغيير وضع القراءة + مرقّم (من اليمين إلى اليسار) + TTS الإعدادات + TTS استبدال الكلمات + كتاب استبدال الكلمات + شارك أو احفظ أو اطبع + خلاصة (تجريبية) + السابق TTS قطعة + التالي TTS قطعة + إنقاص + زيادة + علامات التبويب + الصفحات + الصور + قم بتوسيع الكل + طي الكل + تحديد موقع + لم يتم العثور على صور. + تحميل الصورة + عنوان جديد + تم الحفظ %1$s + لا يمكن حفظ الصورة. + تخطيط الصفحة + PDF انتشار الصفحة + صفحة واحدة + صفحتين + الصفحة الأولى وحدها + يبدأ الحيزات المواجهة للصفحة بعد صفحة الغلاف. + إزالة الفجوة بين الصفحات + ينطبق على القراءة الرأسية والفروق المكونة من صفحتين. + إخفاء تراكب رقم الصفحة + إزالة تسمية عدد الصفحات الصغيرة من كل صفحة. + اتجاه الشاشة + اختر ما إذا كان القارئ يتبع اتجاه النظام أو يفضل الوضع الرأسي أو الأفقي عند Android يسمح بذلك. + الموقف + السطوع + استخدام سطوع النظام + يتبع إعداد سطوع الجهاز. + سطوع مخصص + ينطبق عندما تكون شاشة القارئ مفتوحة. + %1$d%% + تم إنشاء الرف "%1$s". + تم إنشاء رف ذكي "%1$s". + تمت إعادة تسمية الرف إلى "%1$s". + الرف المحذوف "%1$s". + تم التحديث "%1$s". + هذه الملفات موجودة بالفعل في المكتبة. + %1$s - %2$s + حفظ + حفظ التعليق + أضف تعليق + رد + إضافة تعليق… + التعليقات + تحرير التعليق + الرد على %1$s + حجم الصورة + الهامش الأفقي + الهامش العمودي + لا شيء + حدد عائلة الخطوط + حدد حجم الخط + خلفية الخط + جريئة + مائل + تسطير + يتوسطه خط + إدراج مربع نص + تمكين القراءة متعددة علامات التبويب + استخدم عامل تصفية الملفات الصارمة + استخدم PDF أسماء الملفات + اللغة + كشف ML في لوحة الاختبار + اختبار اكتشاف فقاعة الكلام ML + سجلات التصدير (آخر %1$d الأسطر) + تمكين عامل تصفية الملفات الصارمة + If you enable this, some supported file types like AZW3, CB7, and FB2 might not show up depending on your file manager.\n\nAre you sure you want to enable this filter? + الإنجليزية (افتراضي) + العربية (العربية) + الألمانية (الألمانية) + التركية (التركية) + الفرنسية (الفرنسية) + Русский (الروسية) + موضوع التطبيق + المظهر + التباين + سطوع النص + نظام الألوان + ديناميكي + إنشاء سمة التطبيق + موضوع مخصص + المحيط + نعناع + روز + بني داكن + الجمشت + العنبر + الياقوت + إضافة موضوع مخصص + موضوع التطبيق + أيقونة التطبيق + الجهاز + درج مفتوح + صورة الملف الشخصي + الملف الشخصي + ميزة برو + تصفية + فرز + إغلاق البحث + مسح الاستعلام + رف البحث + %1$s غطاء الرف + الحفاظ على ألوان الصورة + احتفظ بألوان الصورة الأصلية عندما يتغير المظهر + غير على الرف + جميع الكتب + نظام + ضوء + مظلم + معيار + واسطة + عالي + مؤخرًا + العنوان من الألف إلى الياء + المؤلف من الألف إلى الياء + النسبة المئوية الكاملة 0-100 + النسبة المئوية كاملة 100-0 + الحجم (الأصغر) + الحجم (الأكبر) + الجميع + غير مقروءة + في تَقَدم + مكتمل + العلامات: %1$s + تصفح حسب العلامة + العلامات + المجلدات + ملفات + تحويل النص إلى كلام + ضوابط التشغيل لتحويل النص إلى كلام. + إعداد النص إلى الكلام + تحضير: %1$s + نشط TTS محرك + سحابة AI + الجهاز أصلي + أصوات السحابة + أصوات الجهاز + ذاكرة التخزين المؤقت السحابية + حدد صوت سحابي عالي الجودة + عينات واضحة + الصوت الافتراضي للنظام + يستخدم إعدادات الجهاز + مرشح اللغة + متصل + غير متصل + مرشح الصوت + لا يوجد صوت مخزن مؤقتًا لهذا الصوت. + مسح ذاكرة التخزين المؤقت لـ %1$s + هذه عينة صوتية + AI سمات + ملخص + خلاصة + مخبأ + لا يوجد ملخص لـ %1$s حتى الآن. + إنشاء ملخص لـ %1$s + احصل على ملخص للقصة حتى موقعك الحالي. + إنشاء خلاصة القصة + خلاصة القصة + ضرب ذاكرة التخزين المؤقت • مجانا + تم الإنشاء • مجاني (%1$d/10 متبقي) + تم الإنشاء • التكلفة: %1$s الاعتمادات + التوليد... • التكلفة: الحساب + AI الإخراج + تجديد + لا توجد ملخصات مخبأة لهذا الكتاب. + الاعتمادات + AI والائتمانات السحابية + الاعتمادات المتاحة + %1$d الاعتمادات + توزيع التكلفة المقدرة + سحابة TTS + Cost: ~3–4 credits per minute of audio generated.\nTo enable: Reader Screen > More > TTS Voice Settings. + AI ملخصات وخلاصة + Cost: ~1–4 credits per request based on chapter length.\nPro Users get 10 free summaries daily. + عن طريق الشراء، + نفاد الاعتمادات + ليس لديك\'ليس لديك أرصدة كافية. احصل على Episteme Pro للحصول على 10 ملخصات مجانية يوميًا، أو أضف المزيد من الاعتمادات لاستخدام الملخصات، السحابة TTS وملخص القصة. + احصل على Pro / أضف الاعتمادات + فتح تلخيص الصفحة + احصل على ملخصات موجزة لأي صفحة باستخدام Episteme Pro. قم بالترقية لبدء استخدام هذه الميزة. + قم بتنزيل نموذج Bubble Zoom + لاستخدام ميزة Bubble Zoom، استخدم AI يجب تنزيل النموذج (حوالي 134 ميجابايت). هل تريد تحميله الآن؟ + ترجمة + العودة إلى صفحة %1$d + الصفحة %1$d + النتيجة %1$d / %2$d + فشل التحميل PDF. + جارٍ تنزيل نموذج Bubble Zoom… %1$d%% + الخروج من شريط التمرير + القفز للخلف + القفز إلى الأمام + قم بالتمرير إلى صفحة القراءة + صفحة مشروحة + إغلاق الصورة + تبديل أبرز البحث + اسحب لتحريك مربع النص + لا يوجد أيقونة الملفات + نسخ %1$s + علامة + علامة عنصر القائمة + إعادة ضبط التكبير + إنشاء التعليقات التوضيحية التجريبية + التعليقات التوضيحية + افتح ملعب القلم + علامة تبويب جديدة + تسليط الضوء على كل النص + تبديل وضع التحرير + التكبير الهزلي الذكي + تخصيص النقاط البارزة + الإنجليزية، الإسبانية، الفرنسية، الخ + الهندية، الماراثية، السنسكريتية + الإنجليزية + الصينية + الإنجليزية + اليابانية + الإنجليزية + الكورية + الإنجليزية + وثيقة + تم إنشاؤها + %1$s (عرض النص) + %1$s (إنحسر) + إضافة / تحرير + لم يتم تعيين أي علامات. + تطبيق العلامات + بحث أو إنشاء علامة... + إنشاء \"%1$s\" + سحب المسافة لتغيير الفصل + قصير + طويل + السرعة: %1$sx + الملعب: %1$sx + تشغيل/إيقاف مؤقت + إعادة ضبط السرعة + إعادة تعيين الملعب + حدد اللون + هذا الكتاب ليس لديه محتوى لعرضه. + الرابط المنسوخ + النص المنسوخ + جدول المحتويات + إشارة مرجعية + انتقل إلى الصفحة %1$d + العودة إلى الصفحة السابقة + الخروج من التكبير الذكي + التكبير الهزلي الذكي + تبديل التكبير الهزلي الذكي + حماية التقاط الشاشة + حماية التقاط الشاشة قيد التشغيل + تم إيقاف حماية التقاط الشاشة + الإعدادات + تحرير + استعادة + %1$s مختارة + افتراضيات القارئ + PDF-الخاصة بـ OCR والتعليقات التوضيحية وإعدادات الأداة تظل في PDF قارئ. + AI التعريف + الفصل %1$d + الموقع + الخط المخصص + حدث خطأ: %1$s + حدث خطأ أثناء تحميل المستند: %1$s + OCR لم يتم العثور على نص في هذه الصفحة. + تبدو الصفحة فارغة أو النص غير قابل للاستخراج. + لا يمكن تلخيص صفحة فارغة. + لم يتم تحميل المستند. + AI الميزات غير متوفرة في وضع عدم الاتصال OSS يبني. + تم حظره لأسباب تتعلق بالسلامة. + اختر نموذجًا لـ %1$s في AI إعدادات المفتاح والنموذج. + أضف %1$s API أدخل AI إعدادات المفتاح والنموذج. + AI أعاد الموفر استجابة فارغة. + AI خطأ في الموفر: %1$d. %2$s + يحتاج هذا الملخص إلى Gemini لأن نماذج Groq المحددة لا تدعم PDF/إدخال الصورة. + يجب عليك تسجيل الدخول لاستخدام التعليقات. + يجب عليك تسجيل الدخول لإرسال الملاحظات. + الحد الأقصى المسموح به هو 3 صور للتذاكر. + الحد الأقصى المسموح به هو 5 صور لكل رسالة. + تتجاوز صورة واحدة أو أكثر الحد المسموح به وهو 5 ميغابايت. + فشل إنشاء تذكرة: %1$s + فشل الإرسال: %1$s + فشل تحميل الخلاصة: %1$s + جسد فارغ + فشل التنزيل: %1$s + خطأ في التحميل: %1$s + فشل الشراء: %1$s + تعذر الاتصال بخدمة الفوترة. + لم يتم العثور على المنتجات. + فشل الاستعلام عن المنتجات + غير متوفر في النسخة مفتوحة المصدر + لا يوجد نص للقراءة. + حدث خطأ أثناء بدء التشغيل. + فشل تحميل الصوت. + خطأ في التشغيل: %1$s + سحابة TTS لم يتم تكوينه. + كتاب غير معروف + AI المفاتيح والنماذج + المفاتيح المحفوظة + إضافة أو استبدال المفتاح + مزود + API مفتاح + حفظ المفتاح + استخدم نموذجًا واحدًا لجميع الميزات + عند إيقاف التشغيل، كل قارئ AI تستخدم الميزة النموذج المحدد الخاص بها. + الكل AI سمات + يستخدم القاموس الذكي والملخصات والملخصات هذا النموذج. + القاموس الذكي + يستخدم عند تحديد الكلمات أو العبارات المحددة. + ملخصات + يستخدم لـ EPUB ملخصات و PDF ملخصات الصفحة. PDF/تحتاج ملخصات الصور إلى Gemini. + ملخصات + تستخدم لتوليد ملخص القصة. + يستخدم Gemini المحفوظة مفتاح. فقط %1$s مدعوم في الوقت الحالي. + حفظ %1$s مفتاح؟ + بعد الحفظ، ستكون الأحرف الثلاثة الأولى والأخيرة مرئية فقط. لتغييره لاحقًا، قم باستبداله أو حذفه. + حذف %1$s مفتاح؟ + ستتوقف الميزات التي تستخدم هذا الموفر عن العمل حتى يتم حفظ مفتاح جديد. + لم يتم حفظ أي مفتاح + حذف %1$s مفتاح + نموذج + لم يتم تحديد أي نموذج + عرض AI في القارئ + إخفاء AI في القارئ + الألوان الصلبة + محكم + مخصص الصلبة + مخصص محكم + حدد نسيج مخصص + سطوع النص (الضوء) + سطوع النص (داكن) + تقصير + غادر + يمين + يبرر + اعرض دائمًا + المزامنة مع القوائم + إخفاء دائما + قمة + قاع + هل تريد استعادة البيانات الوصفية الأصلية؟ + سيؤدي هذا إلى كتابة العنوان الأصلي والمؤلف والسلسلة والملخص مرة أخرى في EPUB ملف. لن يتغير تقدم القراءة والعلامات والملاحظات. + EPUB تم تحرير البيانات الوصفية + بيانات التعريف من EPUB ملف + تم تغيير اسم العرض في التطبيق + البيانات الوصفية من الملف + البيانات الوصفية + ملف + عنوان + مسلسل + قراءة + اسم الملف + معدل + ملخص + علامات المكتبة + البيانات الوصفية القابلة للتحرير + اسم العرض + الاسم الموضح في القارئ + الملف الأصلي: %1$s + الشريط العلوي + شريط أسفل + الأدوات المخفية + المزيد من القائمة + الأدوات المخفية + قم بإسقاط الأدوات هنا + اسحب لإعادة الترتيب + التطبيقات الخارجية + شريط تمرير التنقل + السطوع + الشريط الجانبي + قم بتمييز النص القابل للتحديد + وضع التحرير + TTS الضوابط + وضع القراءة + إدارة الصفحة + عرض النص (إعادة التدفق) + الكتاب الحالي + عالمي + هذا الكتاب + تمكين البدائل + تنطبق القواعد هنا على كل كتاب ما لم يتم تعطيله لعنوان محدد. + أضف القاعدة + إضافة قواعد الكتاب + لا توجد قواعد استبدال عالمية حتى الآن. + لا توجد قواعد خاصة بالكتاب حتى الآن. + استخدم القواعد العالمية هنا + قم بإيقاف تشغيل هذا عندما يحتاج الكتاب إلى خيارات النطق الخاصة به. + تمكين قواعد الكتاب + القواعد المحلية تعمل بعد القواعد العالمية. + القواعد العالمية الموروثة + لا توجد قواعد عالمية للميراث. + المسموح به في هذا الكتاب + معطل لهذا الكتاب + اقتراحات + بديل جديد + تحرير الاستبدال + استبدال + تحدث كما + ممكّن + كلمة كاملة + حالة المباراة + معاينة الإدخال + القواعد + الصمت + نص عادي + حساس لحالة الأحرف + الكتاب الحالي + أضف القاعدة + لا توجد قواعد بديلة لهذا الكتاب حتى الآن. + بديل جديد + تحرير الاستبدال + مع + نص فارغ + حول + قارئ سطح المكتب + الوصول إلى سطح المكتب + الحساب + الحساب والائتمانات + نظرة عامة على الحساب + AI hub + يستخدم لـ EPUB ملخصات و PDF ملخصات الصفحة. + Episteme oss + نص المؤلف + ذاكرة التخزين المؤقت: %1$s + مخبأة + ملخص مخبأة + اختر Gemini الصوت المستخدم للقراءة السحابية بصوت عالٍ. + احذف كتاب سطح المكتب الذي تم إنشاؤه وEPUB ملفات ذاكرة التخزين المؤقت لترقيم الصفحات؟ سيتم إعادة إنشائها في المرة القادمة التي يتم فيها فتح الكتب. + مسح ذاكرة التخزين المؤقت الصوتية + إغلاق الأدوات + المزامنة السحابية + سحابة TTS الاحتياجات Gemini + سحابة TTS يحتاج إلى اعتمادات مسجلة + سحابة TTS مستعد + سحابة TTS إعدادات + سحابة TTS غير متاح + سحابة TTS صوت + يتضمن + حساب التكلفة + قم بإنشاء خلاصة تصل إلى موقعك الحالي. + إنشاء الرف الذكي + %1$d الاعتمادات المتاحة + %1$s الاعتمادات + الخطوط المستوردة للقارئ + حذف الخط + حذف %1$s؟ ستعود الكتب التي تستخدمه إلى الخط الافتراضي. + حذف \"%1$s\"؟ الكتب تبقى في مكتبتك. + حذف الملخص + عاجز + قم بإسقاط الملفات لاستيرادها + قم بإسقاط الملفات المدعومة لاستيرادها + اتصل بنا مباشرة عن طريق البريد الإلكتروني لأي شيء آخر. + يساوي + إضافات + تعليق + مجال + مسار المجلد + من هنا + مسح كامل + مجاني، %1$d غادر + إنشاء خلاصة + إنشاء ملخص + قم بالإبلاغ عن الأخطاء أو طلب الميزات أو الاتصال بالدعم مباشرة. + رعاة جيثب + دعم التطوير من خلال رعاة GitHub. + Google لم يتم تكوين تسجيل الدخول لإصدار سطح المكتب هذا. + أعظم من + يساعد + تقارير الأخطاء وطلبات الميزات والدعم + يخفي + استيراد الملفات + مشاكل + افتح أداة تعقب المشكلات لمعرفة الأخطاء وطلبات الميزات. + أقل من + المكتبة والقارئ + أي + إجراءات المكتبة + أكثر + لا توجد ملخصات مخبأة لهذا الكتاب حتى الآن. + قم باستيراد ملفات TTF أو OTF أو WOFF2 لاستخدامها في الكتب. + لم يتم العثور على خطوط مطابقة \"%1$s\" + لا Google الحساب متصل. + لا يوجد ملخص مخبأ لهذا القسم. + قارئ سطح المكتب غير متصل + فتح القراء + الافتتاح %1$s + فتح مكتبتك + المشغل + صفحة + محمية بكلمة مرور PDF + باتريون + ادعم المشروع على باتريون. + متوقف مؤقتًا + %1$s يتطلب كلمة مرور قبل أن يتم فتحه. + كلمة المرور مطلوبة أو غير صحيحة. + كلمة المرور هذه لم تفتح %1$s. أدخل PDF كلمة المرور وحاول مرة أخرى. + نسبة مئوية + يخطط + تحضير الصوت + التفضيلات + الحساب والائتمانات + الحساب والائتمانات + لم يتم فتح Pro لهذا الحساب. + لا يمكن شراء Pro والأرصدة إلا من Android برنامج. يتحقق سطح المكتب من نفس الحساب الذي تم تسجيل الدخول إليه ويستخدم تلك الاعتمادات للسحابة TTS والملخصات والملخصات وغيرها من المبالغ المدفوعة AI سمات. + قم بتسجيل الدخول للتحقق من حالة حسابك على سطح المكتب. + تم فتح Pro لهذا الحساب. + تقدم + مشروع + قارئ + علامات تبويب القارئ معطلة + علامات تبويب القارئ على + ينعش + الافراج عن إضافة إلى مكتبتك. + تخزين المفاتيح الآمنة غير متوفر في نظام التشغيل هذا. سيتم استخدام المفاتيح التي تم إدخالها هنا لهذه الجلسة ولكن لن تستمر. + مركز الإعدادات + يطابق Android إخفاء التبديل للقاموس الذكي والملخصات والملخصات. + مزامنة الحساب، Pro، والائتمانات + تم تسجيل الدخول + كود المصدر + تصفح مصدر المشروع على GitHub. + التوقف عن القراءة لتغيير الأصوات. + يدعم + الدعم Episteme + تساعد المساهمات في الحفاظ على تحسن القارئ عبر Android وسطح المكتب. + طرق دعم Episteme تطوير + مزامنة المجلدات + مزامنة البيانات التعريفية + اسم العلامة + وضع علامة على الكتب المختارة + نص العنوان + أدوات + إعدادات الاستيراد والمزامنة والتطبيق + اكتب، على سبيل المثال. PDF + منظر + ذاكرة التخزين المؤقت الصوتية + جارٍ تحضير عرض الويب المضمن… + إعداد عرض الويب المضمن المجمع %1$d%% + تم تثبيت عرض الويب المضمن. إعادة تشغيل Episteme لإنهاء الإعداد. + تعذر بدء عرض الويب المضمن: %1$s + عمل… + مساحة العمل + أضف إلى الرف + أنشئ رفًا أولاً، ثم أضف الكتب المحددة إليه. + إنشاء موضوع + موجود: %1$s + لقد قمت بالنقر فوق رابط خارجي. + تحرير EPUB البيانات الوصفية + أقل + …المزيد + لا توجد مواضيع مخصصة حتى الآن + إعادة تسمية في التطبيق + العلامات، مفصولة بفواصل + غير معروف + تحديد + تعليق توضيحي + خيارات التعليقات التوضيحية + أدوات التعليقات التوضيحية + مساعدة + اختر أي PDF للحفظ. + مسح تاريخ القفز + سحابة TTS فشل. + أضف Gemini مفتاح وحدد Gemini سحابة TTS في AI المفاتيح والنماذج. + سحابة TTS لم يتم تكوينه لبناء سطح المكتب هذا. + قم بتسجيل الدخول باستخدام Google لاستخدام السحابة TTS. + سحابة TTS يحتاج إلى حساب مسجل الدخول مع الاعتمادات. لا يمكن شراء Pro والأرصدة إلا من Android برنامج. + اللون + خيارات التعليق + مخصص + يؤدي هذا إلى إزالة التعليق التوضيحي من PDF. + هل تريد حذف التعليق التوضيحي؟ + نص الوثيقة + مضمن PDF تعليق + فشل عرض الصفحة. + الميزة غير متاحة + انتهى + قلم حبر + إخفاء نتائج البحث + لون التمييز %1$d + لوحة تمييز + التفاعل + الفهرسة %1$d/%2$d الصفحات + العلامات + %1$d مباريات + %1$d المباريات حتى الآن + الصفحة التالية + نتيجة البحث التالية + لا يوجد تعليقات توضيحية حتى الآن + لا توجد إشارات مرجعية حتى الآن + لا تعليق + لا توجد مباريات + لا توجد تطابقات في الصفحات المفهرسة حتى الآن + لا يوجد جدول محتويات + لا يوجد نص هنا للقراءة. + لا يوجد نص في هذه الصفحة للقراءة. + لا يوجد نص لتلخيصه. + فتح التعليق + نفاد الاعتمادات. لا يمكن شراء Pro والأرصدة إلا من Android برنامج. + باستخدام السحابة TTS يحتاج إلى اعتمادات على سطح المكتب. لا يمكن شراء Pro والأرصدة إلا من Android برنامج. + يحتاج استخدام هذه الميزة إلى أرصدة على سطح المكتب. لا يمكن شراء Pro والأرصدة إلا من Android برنامج. + يحتاج استخدام الملخّصات إلى أرصدة على سطح المكتب. لا يمكن شراء Pro والأرصدة إلا من Android برنامج. + استخدام الملخصات يحتاج إلى اعتمادات على سطح المكتب. لا يمكن شراء Pro والأرصدة إلا من Android برنامج. + مِقلاة + PDF فشل الإجراء + PDF لا يمكن إكمال الإجراء. + PDF تعليق + ص. %1$d + PDF الصفحة %1$d + الصفحة %1$d - %2$s + الصفحة %1$s من %2$d + الصفحات %1$s من %2$d + PDF أنقذ + PDF أدوات + قلم رصاص + تحضير الاختيار + تحضير %1$s + الصفحة السابقة + نتيجة البحث السابقة + انتهى مربع حوار الطباعة. + مطلوب برو + هذه الميزة تتطلب برو. يمكن شراء Pro فقط من Android التطبيق، فسيستخدم سطح المكتب الحساب الذي تمت ترقيته بعد تسجيل الدخول. + يتطلب القاموس الذكي متعدد الكلمات Pro. يمكن شراء Pro فقط من Android التطبيق، فسيستخدم سطح المكتب الحساب الذي تمت ترقيته بعد تسجيل الدخول. + القارئ AI الميزات مخفية. + سطح المكتب AI لم يتم تكوينه لهذا البناء. + ينطبق على القراءة الرأسية والفروق المكونة من صفحتين. + هايلايتر دائري + تم الحفظ في %1$s + قم بالتمرير + البحث في PDF + حدد النص + تم التحديد %1$s + عرض نتائج البحث + قم بتسجيل الدخول باستخدام Google لاستخدام هذه الميزة على سطح المكتب. + قم بتسجيل الدخول باستخدام Google لاستخدام القاموس الذكي متعدد الكلمات على سطح المكتب. + قم بتسجيل الدخول باستخدام Google لاستخدام الملخّصات على سطح المكتب. + قم بتسجيل الدخول باستخدام Google لاستخدام الملخصات على سطح المكتب. + توقف + ملاحظة نصية + ملاحظة نصية + نمط النص + سمك %1$s + جدول المحتويات + اكتب للبحث في هذا PDF + بدون عنوان + عرض الحساب والائتمانات + تم مسح ذاكرة التخزين المؤقت الصوتية + تكبير + تكبير + تصغير + يختار + مواصلة القراءة + رفض + تحت + أعلى + AI + مركز + المؤلفون + العودة إلى المكتبة + كتاب الإجراءات + مجلد + تصفح + فئات + الفصل. %1$d + يتحول الفصل + اختر الخط + اختر نسيج القارئ + مسح أنواع الملفات + مسح التعليقات التوضيحية للصفحة + مصادر واضحة + حالة واضحة + مسح العلامات + إغلاق القارئ + مستمر + يغطي + ألوان مخصصة + معاينة الموضوع المخصص + إنقاص %1$s + تحديد الصفحة + يؤدي هذا إلى إزالة التمييز وملاحظته. + أدخل ملء الشاشة + الخروج من وضع ملء الشاشة + بحث خارجي + كتب + كاريكاتير + المستندات + أخرى + النص والويب + ملء + مظهر تخطيط ثابت + المجلد فارغ + لا تتوفر هنا أي ملفات أو مجلدات فرعية مدعومة. + %1$s, %2$s + %1$s - %2$s + إخفاء المرشحات + إخفاء أدوات القارئ + اضغط على إحدى الفتحات، ثم اختر لونًا. + مواصلة القراءة والكتب الأخيرة + استيراد الكتب + مجلد الاستيراد + الخطوط المستوردة + %1$s %2$s + زيادة %1$s + القفز على التاريخ + التخطيط والتباعد + قم باستيراد الملفات إلى مساحة تخزين التطبيق أو قم بإضافة مجلد لقراءة الملفات الموجودة في مكانها. + تصفح مجموعتك + AI مفاتيح + ذكي %1$d + غير مقروء %1$d + قيد التقدم %1$d + أكمل %1$d + قائمة + الملاحة + لا يوجد كتاب مفتوح + أضف مجلدًا لقراءة الملفات من هذا المجلد في مكانه. + لا توجد مجلدات حتى الآن + لا توجد عناصر التنقل + لا يوجد محتوى الصفحة + لم يتم العثور على إعدادات + ستظهر هنا الرفوف اليدوية ومجموعات السلسلة. + لا يوجد رفوف بعد + إنشاء رفوف ذكية لجمع الكتب حسب القواعد. + لا توجد أرفف ذكية حتى الآن + ستظهر هنا العلامات المضافة إلى الكتب. + لا توجد علامات حتى الآن + لم يتم استيراد أي ملفات مدعومة. + كتالوج + هل تريد حذف "%1$s"؟ قد يتوقف فتح الكتب المتدفقة من هذا الكتالوج إذا تغيرت بيانات الاعتماد لاحقًا. + لا توجد كتالوجات + أضف OPDS كتالوج لتصفح الكتب عن بعد. + تصفح الكتالوجات، والتدفقات، والتنزيلات + كتاب مفتوح + افتح المجلد + فتح PDF + ألوان الصفحة والنص + معلومات الصفحة + عرض الصفحة + تنطبق هذه الإعدادات الافتراضية عندما يدعم النظام الأساسي PDF المشترك مظهر. لكل كتاب PDF تبقى التجاوزات في PDF قارئ. + PDF إجراءات الملف + PDF هيغليغتر + المحفوظة مع القارئ تسليط الضوء على الشفافية. + دبوس + إدارة القارئ PDF أدوات + التمرير التلقائي، OCR، الإعدادات الافتراضية للتعليقات التوضيحية، وPDF- تتم إدارة رؤية الأداة فقط داخل PDF قارئ. + %1$s %2$s من %3$d (%4$d%%) + تتم إدارة الإعدادات الافتراضية لشريط أدوات القارئ من القارئ على هذا النظام الأساسي. + أدوات القارئ + حفظ الصورة + البحث: %1$s + البحث في القارئ + إعدادات البحث + اختيار + مقبض نهاية التحديد + مقبض بداية التحديد + أضف رفوفًا أو علامات أو بيانات تعريف المجلدات لتنظيم مكتبتك. + المجموعات والسلاسل والعلامات والمجلدات + إظهار أدوات القارئ + مجلد + ذكي + صلب + سرعة + بدء التمرير التلقائي + إيقاف التمرير التلقائي + توقف عن القراءة بصوت عالٍ + قوة الملمس + اكتب للبحث في هذا الكتاب + الطباعة + التراجع عن التعليق التوضيحي + إزالة التثبيت + استخدم المظهر الداكن + استخدام موضوع الضوء + كثرة الوحيدات + بلا + شريف + ابحث في الكتب أو المؤلفين أو العلامات + لا توجد أدوات + مرئي + استبدل فقط ما يتم التحدث به + يظل نص القارئ والإبرازات والمواقع دون تغيير. + %1$s -> %2$s diff --git a/app/src/main/res/values-be/plurals.xml b/app/src/main/res/values-be/plurals.xml index d44076d..cabb6d3 100644 --- a/app/src/main/res/values-be/plurals.xml +++ b/app/src/main/res/values-be/plurals.xml @@ -78,4 +78,130 @@ (%1$d фрагментаў) (%1$d фрагмента) + + Імпарт %1$d кніга... Хутка яна з\'явіцца ў вашай бібліятэцы. + Імпарт %1$d кнігі... Хутка яны з\'явяцца ў вашай бібліятэцы. + Імпарт %1$d кнігі... Хутка яны з\'явяцца ў вашай бібліятэцы. + Імпарт %1$d кнігі... Хутка яны з\'явяцца ў вашай бібліятэцы. + + + Імпартавана %1$d кніга. Вы можаце знайсці яго на ўкладцы «Бібліятэка». + Імпартавана %1$d кнігі. Вы можаце знайсці іх на ўкладцы «Бібліятэка». + Імпартавана %1$d кнігі. Вы можаце знайсці іх на ўкладцы «Бібліятэка». + Імпартавана %1$d кнігі. Вы можаце знайсці іх на ўкладцы «Бібліятэка». + + + %1$d кніга дададзена на паліцу. + %1$d кніг дададзена на паліцу. + %1$d кніг дададзена на паліцу. + %1$d кніг дададзена на паліцу. + + + %1$d кніга з тэгам «%2$s». + %1$d кнігі з тэгам «%2$s». + %1$d кнігі з тэгам «%2$s». + %1$d кнігі з тэгам «%2$s». + + + Выдалена папка "%1$s" і %2$d кніга з дадатку. + Выдалена папка "%1$s" і %2$d кнігі з прыкладання. + Выдалена папка "%1$s" і %2$d кнігі з прыкладання. + Выдалена папка "%1$s" і %2$d кнігі з прыкладання. + + + %1$d файл + %1$d файлы + %1$d файлы + %1$d файлы + + + Адпусціце, каб імпартаваць %1$d файл + Адпусціце, каб імпартаваць %1$d файлы + Адпусціце, каб імпартаваць %1$d файлы + Адпусціце, каб імпартаваць %1$d файлы + + + %1$d файл, які не падтрымліваецца, будзе прапушчаны. + %1$d файлы, якія не падтрымліваюцца, будуць прапушчаны. + %1$d файлы, якія не падтрымліваюцца, будуць прапушчаны. + %1$d файлы, якія не падтрымліваюцца, будуць прапушчаны. + + + Імпарт %1$d файл… + Імпарт %1$d файлы… + Імпарт %1$d файлы… + Імпарт %1$d файлы… + + + Імпартавана %1$d файл. + Імпартавана %1$d файлы. + Імпартавана %1$d файлы. + Імпартавана %1$d файлы. + + + Імпартавана %1$d файл. Падтрымка Reader з\'явіцца пазней. + Імпартавана %1$d файлы. Падтрымка Reader з\'явіцца пазней. + Імпартавана %1$d файлы. Падтрымка Reader з\'явіцца пазней. + Імпартавана %1$d файлы. Падтрымка Reader з\'явіцца пазней. + + + Не атрымалася імпартаваць %1$d файл. + Не атрымалася імпартаваць %1$d файлы. + Не атрымалася імпартаваць %1$d файлы. + Не атрымалася імпартаваць %1$d файлы. + + + Прапушчана %1$d файл. + Прапушчана %1$d файлы. + Прапушчана %1$d файлы. + Прапушчана %1$d файлы. + + + Выдаліць "%1$s" і яго %2$d кніга з прыкладання? Файлы на дыску не будуць выдалены. + Выдаліць "%1$s" і яго %2$d кнігі з прыкладання? Файлы на дыску не будуць выдалены. + Выдаліць "%1$s" і яго %2$d кнігі з прыкладання? Файлы на дыску не будуць выдалены. + Выдаліць "%1$s" і яго %2$d кнігі з прыкладання? Файлы на дыску не будуць выдалены. + + + Збой сінхранізацыі папкі для %1$d папка. + Збой сінхранізацыі папкі для %1$d папкі. + Збой сінхранізацыі папкі для %1$d папкі. + Збой сінхранізацыі папкі для %1$d папкі. + + + Сінхранізацыя папкі завершана з %1$d папка прапушчана. + Сінхранізацыя папкі завершана з %1$d тэчкі прапушчаны. + Сінхранізацыя папкі завершана з %1$d тэчкі прапушчаны. + Сінхранізацыя папкі завершана з %1$d тэчкі прапушчаны. + + + Выдалена %1$d струменевае OPDS кнігу з гэтага каталога. + Выдалена %1$d струменевае OPDS кнігі з гэтага каталога. + Выдалена %1$d струменевае OPDS кнігі з гэтага каталога. + Выдалена %1$d струменевае OPDS кнігі з гэтага каталога. + + + Усе кнігі %1$d + Усе кнігі %1$d + Усе кнігі %1$d + Усе кнігі %1$d + + + Паліцы %1$d + Паліцы %1$d + Паліцы %1$d + Паліцы %1$d + + + Тэгі %1$d + Тэгі %1$d + Тэгі %1$d + Тэгі %1$d + + + Тэчкі %1$d + Тэчкі %1$d + Тэчкі %1$d + Тэчкі %1$d + diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index b961995..c40352c 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -11,11 +11,11 @@ Назад Пошук Ачысціць - Ужыць + Прымяніць Уключыць Памылка: %1$s Вярнуцца - Вольная + Свабодная Актыўныя ўкладкі Закрыць укладку Закрыць усе ўкладкі @@ -26,7 +26,7 @@ Палітыка прыватнасці Ліцэнзіі Выбрана %1$d - Ачысціць выбар + Зняць вылучанае Замацаваць/Адмацаваць Звесткі Выбраць усё @@ -67,7 +67,7 @@ Недаступна лакальна Увайсці праз Google Уваходзячы, - Episteme Pro + Episteme Про Перайсці на Episteme Pro Сінхранізаваць бібліятэку Воблачная сінхранізацыя лакальных папак @@ -200,10 +200,10 @@ Вы атрымліваеце Episteme Pro па спецыяльнай зніжанай цане ў перыяд ранняга доступу! Гэта прапанова абмежавана па часе. Увайдзіце ў свой уліковы запіс Google, каб купіць Episteme Pro і разблакіраваць усе прэміум-функцыі. Не цяпер - Зразумела + Зразумела! Карыстальніцкія шрыфты Імпартаваць шрыфт - Google Fonts + Google Шрыфты Праглядаць Google Fonts Шукаць сярод 1900+ шрыфтоў… Папулярныя варыянты @@ -577,7 +577,7 @@ Візуальныя параметры Макет старонкі Прыбраць прамежак паміж старонкамі - Ужываецца да вертыкальнага рэжыму чытання. + Ужываецца да вертыкальнага рэжыму чытання і двух старонак. Схаваць накладку нумара старонкі Прыбірае малую пазнаку колькасці старонак з кожнай старонкі. Сістэмны UI (панэлі стану і навігацыі) @@ -902,7 +902,7 @@ Дакумент Згенеравана %1$s (тэкставы выгляд) - %1$s (Reflow) + %1$s (Адаптыўны выгляд) Дадаць / Рэдагаваць Тэгі не прызначаны. Ужыць тэгі @@ -1086,4 +1086,430 @@ Nederlands (нідэрландская) Українська (украінская) Bahasa Indonesia (інданезійская) + %1$d%% + Створана паліца %1$s. + Створана разумная паліца %1$s. + Паліца перайменавана ў %1$s. + Выдалена паліца %1$s. + %1$s абноўлена. + Гэтыя файлы ўжо ў бібліятэцы. + %1$s - %2$s + Захаваць + Захаваць каментарый + Дадаць каментарый + Адказаць + Дадайце каментарый… + Каментарыі + Рэдагаванне каментарыя + У адказ %1$s + Выкарыстоўваць назвы файлаў PDF + Яркасць + Паказваць укладкі на верхняй панэлі + Адключыць лакальную сінхранізацыю + Уключыць лакальную сінхранізацыю + Лакальная сінхранізацыя адключана + Адключыць лакальную сінхранізацыю папак? + Episteme спыніць сканаванне гэтай папкі і спыніць запіс JSON сінхранізаваць файлы. Выдаліце ​​%1$s тэчку з гэтай папкі таксама? + Захоўваць сінхранізацыю дадзеных + Выдаліць дадзеныя сінхранізацыі + Выдаліць шрыфты? + Вы ўпэўнены, што хочаце выдаліць %1$d выбраныя шрыфты? Гэта выдаліць іх з усіх вашых прылад, калі сінхранізацыя ўключана. + Ні ў адной лакальнай папцы не ўключана сінхранізацыя. + Сінхранізацыя лакальнай папкі адключана. + Сінхранізацыя лакальнай папкі адключана. Папка дадзеных сінхранізацыі выдалена. + Сінхранізацыя лакальнай папкі адключана, але папку дадзеных сінхранізацыі нельга выдаліць. + Сінхранізацыя лакальных папак уключана. + Вертыкальны (WebView) + Вертыкальны (родная бэта-версія) + Кніга Замены слоў + Папярэдняя TTS кавалак + Далей TTS кавалак + Малюнкі + Выяў не знойдзена. + Спампаваць малюнак + Захавана %1$s + Не ўдалося захаваць малюнак. + PDF разварот старонкі + Адна старонка + Дзве старонкі + Першая старонка адна + Пачынае развароты пасля тытульнай старонкі. + Яркасць + Выкарыстоўвайце яркасць сістэмы + Адпавядае наладзе яркасці прылады. + Карыстальніцкая яркасць + Прымяняецца, калі экран чытача адкрыты. + Актуальная кніга + Дадаць правіла + Для гэтай кнігі пакуль няма правілаў замены. + Новая замена + Рэдагаваць замену + з + пусты тэкст + Аб + Чыталка для працоўнага стала + Доступ да працоўнага стала + Рахунак + Рахунак і крэдыты + Агляд акаўнта + AI хаб + Выкарыстоўваецца для EPUB зводкі і PDF зводкі старонак. + Episteme ас + Аўтарскі тэкст + Кэш: %1$s + У кэшы + Рэзюмэ ў кэшы + Выберыце Gemini голас, які выкарыстоўваецца для чытання ўслых у воблаку. + Выдаліць згенераваную настольную кнігу і EPUB файлы кэша пагінацыі? Яны будуць адноўлены пры наступным адкрыцці кніг. + Ачысціць галасавы кэш + Закрыць інструменты + Воблачная сінхранізацыя + Воблака TTS патрэбы Gemini + Воблака TTS патрабуе ўваходу ў крэдыты + Воблака TTS гатовы + Воблака TTS налады + Воблака TTS недаступны + Воблака TTS голас + Змяшчае + Разлік кошту + Стварыце рэзюмэ да вашай бягучай пазіцыі. + Ствары разумную паліцу + %1$d даступныя крэдыты + %1$s крэдыты + Імпартаваныя шрыфты для чыталкі + Выдаліць шрыфт + Выдаліць %1$s? Кнігі, якія выкарыстоўваюць яго, вернуцца да шрыфта па змаўчанні. + Выдаліць \"%1$s\"? Кнігі застаюцца ў вашай бібліятэцы. + Выдаліць зводку + Інваліды + Перацягніце файлы для імпарту + Адпусціце падтрымоўваныя файлы для імпарту + Звязвайцеся з намі непасрэдна па электроннай пошце для чаго-небудзь яшчэ. + Роўнае + Дадаткова + Зваротная сувязь + Палявы + Шлях да папкі + Адсюль + Поўнае сканаванне + Бясплатна, %1$d злева + Стварыце рэзюмэ + Сфармаваць рэзюмэ + Паведамляйце пра памылкі, запытвайце функцыі або звяртайцеся ў службу падтрымкі. + Спонсары GitHub + Падтрымка распрацоўкі праз спонсараў GitHub. + Google уваход не наладжаны для гэтай зборкі працоўнага стала. + Больш, чым + Даведка + Справаздачы пра памылкі, запыты функцый і падтрымка + Схаваць + Імпарт файлаў + Праблемы + Адкрыйце праграму адсочвання памылак і запытаў функцый. + Менш чым + Бібліятэка і чытач + Любая + Бібліятэчныя акцыі + больш + Кэшаваных зводак для гэтай кнігі пакуль няма. + Імпартуйце файлы TTF, OTF або WOFF2, каб выкарыстоўваць іх у кнігах. + Шрыфты, якія адпавядаюць \"%1$s\" + Няма Google уліковы запіс падключаны. + Для гэтага раздзела няма зводкі ў кэшы. + Аўтаномная праграма для чытання на працоўным стале + Адкрытыя чытачы + Адкрыццё %1$s + Адкрыццё вашай бібліятэкі + Аператар + старонка + Абаронена паролем PDF + патрэон + Падтрымайце праект на Patreon. + Прыпынена + %1$s перад адкрыццём патрабуецца пароль. + Пароль патрабуецца або няправільны. + Гэты пароль не адкрыў %1$s. Увядзіце PDF пароль і паспрабуйце яшчэ раз. + Працэнт + План + Падрыхтоўка аўдыё + Прэферэнцыі + Рахунак і крэдыты + Рахунак і крэдыты + Pro не разблакіраваны для гэтага ўліковага запісу. + Pro і крэдыты можна набыць толькі ў Android дадатак. Desktop правярае той жа ўліковы запіс, у які ўвайшоў, і выкарыстоўвае гэтыя крэдыты для воблака TTS, зводак, рэзюмэ і іншых платных AI асаблівасці. + Увайдзіце, каб праверыць стан свайго ўліковага запісу на працоўным стале. + Pro разблакіравана для гэтага ўліковага запісу. + Прагрэс + Праект + Чытач + Укладкі Reader выключаны + Укладкі для чытання + Абнавіць + Адпусціце, каб дадаць у сваю бібліятэку. + Бяспечнае сховішча ключоў недаступнае ў гэтай аперацыйнай сістэме. Ключы, уведзеныя тут, будуць выкарыстоўвацца для гэтага сеансу, але не будуць захоўвацца. + Цэнтр налад + Супадае з Android схаваць пераключальнік для разумнага слоўніка, рэзюмэ і рэзюмэ. + Сінхранізацыя ўліковага запісу, Pro і крэдытаў + Выкананы ўваход + Зыходны код + Праглядзіце зыходны код праекта на GitHub. + Каб змяніць галасы, спыніце чытанне. + Падтрымка + Падтрымка Episteme + Уклад дапамагае чытачу паляпшацца ў Android і працоўны стол. + Спосабы падтрымкі Episteme развіццё + Сінхранізацыя тэчак + Сінхранізацыя метададзеных + Імя тэга + Пазначыць выбраныя кнігі + Тэкст загалоўка + інструменты + Імпарт, сінхранізацыя і налады праграм + Тып, напр. PDF + Выгляд + Галасавы кэш + Падрыхтоўка ўбудаванага вэб-прагляду… + Падрыхтоўка ўбудаванага вэб-прагляду %1$d%% + Убудаваны вэб-прагляд усталяваны. Перазапусціць Episteme каб завяршыць наладку. + Не ўдалося запусціць убудаваны вэб-прагляд: %1$s + Працуе... + Працоўная прастора + Дадаць на паліцу + Спачатку стварыце паліцу, а потым дадайце на яе выбраныя кнігі. + Стварыць тэму + Існуючы: %1$s + Вы націснулі на знешнюю спасылку. + Рэдагаваць EPUB метададзеныя + Менш + …больш + Карыстальніцкіх тэм пакуль няма + Перайменаваць у дадатку + Тэгі, падзеленыя коскамі + Невядомы + Вызначце + Анатацыя + Параметры анатацыі + Інструменты анатавання + Дапамога + Выберыце, які PDF каб захаваць. + Ачысціць гісторыю скачкоў + Воблака TTS не атрымалася. + Дадайце Gemini і выберыце Gemini воблака TTS у AI ключы і мадэлі. + Воблака TTS не наладжаны для гэтай зборкі працоўнага стала. + Увайдзіце з Google выкарыстоўваць воблака TTS. + Воблака TTS патрабуе ўваходу ва ўліковы запіс з крэдытамі. Pro і крэдыты можна набыць толькі ў Android дадатак. + Колер + Параметры каментарыяў + Прыстасаваныя + Гэта выдаляе анатацыю з гэтага PDF. + Выдаліць анатацыю? + Тэкст дакумента + Убудаваны PDF каментар + Не ўдалося адлюстраваць старонку. + Функцыя недаступная + Скончана + Аўтаручка + Схаваць вынікі пошуку + Колер выдзялення %1$d + Палітра хайлайтер + Узаемадзеянне + Індэксацыя %1$d/%2$d старонкі + Разметка + %1$d запалкі + %1$d матчаў да гэтага часу + Наступная старонка + Наступны вынік пошуку + Пакуль няма анатацый + Закладак пакуль няма + Без каментароў + Супадзенняў няма + Супадзенняў на праіндэксаваных старонках пакуль няма + Няма зместу + Тут няма тэксту для чытання. + На гэтай старонцы няма тэксту для чытання. + Няма тэксту для абагульнення. + Адкрыты каментар + Скончыліся крэдыты. Pro і крэдыты можна набыць толькі ў Android дадатак. + Выкарыстанне воблака TTS патрэбны крэдыты на працоўны стол. Pro і крэдыты можна набыць толькі ў Android дадатак. + Для выкарыстання гэтай функцыі патрэбны крэдыты на працоўным стале. Pro і крэдыты можна набыць толькі ў Android дадатак. + Для выкарыстання рэзюмэ патрэбны крэдыты на працоўным стале. Pro і крэдыты можна набыць толькі ў Android дадатак. + Для выкарыстання рэзюмэ патрэбны крэдыты на працоўным стале. Pro і крэдыты можна набыць толькі ў Android дадатак. + Пан + PDF дзеянне не атрымалася + PDF дзеянне не можа быць завершана. + PDF каментар + стар. %1$d + PDF старонка %1$d + Старонка %1$d - %2$s + Старонка %1$s з %2$d + Старонкі %1$s з %2$d + PDF захаваны + PDF інструменты + Аловак + Рыхтуем выбар + Падрыхтоўка %1$s + Папярэдняя старонка + Папярэдні вынік пошуку + Дыялогавае акно друку завершана. + Патрабуецца прафесіянал + Для гэтай функцыі патрабуецца Pro. Pro можна набыць толькі ў Android пасля ўваходу ў сістэму працоўны стол будзе выкарыстоўваць абноўлены ўліковы запіс. + Шматслоўны разумны слоўнік патрабуе Pro. Pro можна набыць толькі ў Android пасля ўваходу ў сістэму працоўны стол будзе выкарыстоўваць абноўлены ўліковы запіс. + Чытач AI функцыі схаваныя. + Працоўны стол AI не настроены для гэтай зборкі. + Ужываецца для вертыкальнага чытання і двухстаронкавых разваротаў. + Круглы хайлайтер + Захавана ў %1$s + Скрутак + Шукаць у PDF + Вылучыце тэкст + Выбраны %1$s + Паказаць вынікі пошуку + Увайдзіце з Google каб выкарыстоўваць гэтую функцыю на працоўным стале. + Увайдзіце з Google выкарыстоўваць шматслоўны разумны слоўнік на працоўным стале. + Увайдзіце з Google выкарыстоўваць recaps на працоўным стале. + Увайдзіце з Google выкарыстоўваць зводкі на працоўным стале. + Спыніліся + Тэкставая нататка + тэкставая нататка + Стыль тэксту + Таўшчыня %1$s + TOC + Увядзіце для пошуку PDF + Без назвы + Прагляд рахунку і крэдытаў + Галасавы кэш ачышчаны + маштабаванне + Павялічыць + Паменшыць маштаб + Выбірай + Працягвайце чытаць + Звольніць + Уніз + Уверх + AI + Цэнтр + Аўтары + Вярнуцца да бібліятэкі + Кніжныя акцыі + Папка + Праглядзіце + Катэгорыі + гл. %1$d + Раздзел паваротаў + Выберыце шрыфт + Выберыце тэкстуру чытача + Выдаліць тыпы файлаў + Ачысціць анатацыі старонкі + Чыстыя крыніцы + Выразны статус + Ачысціць тэгі + Закрыць чытач + Бесперапынны + Вокладкі + Індывідуальныя колеры + Папярэдні прагляд карыстальніцкай тэмы + Паменшыць %1$s + Вызначыць старонку + Гэта выдаляе вылучэнне і яго ноту. + Перайсці на ўвесь экран + Выйсці з поўнаэкраннага рэжыму + Знешні пошук + Кнігі + Коміксы + Дакументы + Іншае + Тэкст і вэб + Запоўніць + Знешні выгляд фіксаванага макета + Тэчка пустая + Тут няма падтрымоўваных файлаў і падтэчак. + %1$s, %2$s + %1$s - %2$s + Схаваць фільтры + Схаваць інструменты для чытання + Дакраніцеся да слота і выберыце колер. + Працягвайце чытаць і апошнія кнігі + Імпартаваць кнігі + Тэчка імпарту + Імпартаваныя шрыфты + %1$s %2$s + Павелічэнне %1$s + Гісторыя скачкоў + Макет і інтэрвал + Імпартуйце файлы ў сховішча праграмы або дадайце папку для чытання файлаў на месцы. + Праглядзіце сваю калекцыю + AI ключы + Разумны %1$d + Непрачытанае %1$d + Выконваецца %1$d + Завяршыць %1$d + Спіс + Навігацыя + Няма адкрытай кнігі + Дадайце папку для чытання файлаў з гэтай папкі на месцы. + Папак пакуль няма + Няма элементаў навігацыі + Няма зместу старонкі + Налады не знойдзены + Тут з\'явяцца ручныя паліцы і калекцыі серый. + Паліц пакуль няма + Стварыце разумныя паліцы, каб збіраць кнігі па правілах. + Разумных паліц пакуль няма + Тэгі, дададзеныя да кніг, з\'явяцца тут. + Тэгаў пакуль няма + Файлы, якія падтрымліваюцца, не былі імпартаваныя. + Каталог + Выдаліць "%1$s"? Патокавыя кнігі з гэтага каталога могуць перастаць адкрывацца, калі ўліковыя даныя зменяцца пазней. + Ніякіх каталогаў + Дадайце OPDS каталог для прагляду выдаленых кніг. + Праглядайце каталогі, патокі і загрузкі + Адкрытая кніга + Адкрыць тэчку + Адкрыць PDF + Колеры старонкі і тэксту + Інфармацыя пра старонку + Шырыня старонкі + Гэтыя значэнні па змаўчанні прымяняюцца, калі платформа падтрымлівае агульны PDF знешні выгляд. За кнігу PDF перавызначае знаходжанне ў PDF чытач. + PDF дзеянні з файламі + PDF хайлайтер + Захавана з празрыстасцю вылучэння чытача. + Pin + Кіраваны чытачом PDF інструменты + Аўтаматычная пракрутка, OCR, анатацыі па змаўчанні і PDF-толькі бачнасць інструмента кіруюцца ўнутры актыўнага PDF чытач. + %1$s %2$s з %3$d (%4$d%%) + Налады панэлі інструментаў Reader па змаўчанні кіруюцца з праграмы Reader на гэтай платформе. + Інструменты чытача + Захаваць малюнак + Пошук: %1$s + Пошук у чыталцы + Налады пошуку + Выбар + Маркёр канца выбару + Маркер пачатку выбару + Дадайце паліцы, тэгі або метаданыя тэчак, каб упарадкаваць сваю бібліятэку. + Калекцыі, серыі, тэгі і папкі + Паказаць інструменты для чытання + Папка + Разумны + Цвёрдая + хуткасць + Запусціць аўтаматычную пракрутку + Спыніць аўтаматычную пракрутку + Перастаньце чытаць услых + Трываласць тэкстуры + Увядзіце для пошуку ў гэтай кнізе + Тыпаграфіка + Адмяніць анатацыю + Адмацаваць + Выкарыстоўвайце цёмную тэму + Выкарыстоўвайце светлую тэму + Мона + Без + Засечкі + Пошук кніг, аўтараў або тэгаў + Без інструментаў + Бачны + Замяняйце толькі сказанае + Тэкст Reader, вылучэнні і месцы застаюцца нязменнымі. + %1$s -> %2$s diff --git a/app/src/main/res/values-de/plurals.xml b/app/src/main/res/values-de/plurals.xml index ca3511a..76bd90b 100644 --- a/app/src/main/res/values-de/plurals.xml +++ b/app/src/main/res/values-de/plurals.xml @@ -52,4 +52,88 @@ (%1$d Stück) (%1$d Stücke) + + Import %1$d Buch… Es wird in Kürze in Ihrer Bibliothek erscheinen. + Import %1$d Bücher… Sie werden in Kürze in Ihrer Bibliothek erscheinen. + + + Importiert %1$d Buch. Sie finden es auf der Registerkarte „Bibliothek“. + Importiert %1$d Bücher. Sie finden sie auf der Registerkarte „Bibliothek“. + + + %1$d Buch zum Regal hinzugefügt. + %1$d Bücher zum Regal hinzugefügt. + + + %1$d Buch mit dem Tag „%2$s“. + %1$d Bücher mit dem Tag „%2$s“. + + + Ordner „%1$s“ entfernt und %2$d Buchen Sie über die App. + Ordner „%1$s“ entfernt und %2$d Bücher aus der App. + + + %1$d Datei + %1$d Dateien + + + Zum Importieren %1$d ablegen Datei + Zum Importieren %1$d ablegen Dateien + + + %1$d Nicht unterstützte Dateien werden übersprungen. + %1$d Nicht unterstützte Dateien werden übersprungen. + + + Import %1$d Datei… + Import %1$d Dateien… + + + Importiert %1$d Datei. + Importiert %1$d Dateien. + + + Importiert %1$d Datei. Die Reader-Unterstützung kommt später. + Importiert %1$d Dateien. Die Reader-Unterstützung kommt später. + + + %1$d konnte nicht importiert werden Datei. + %1$d konnte nicht importiert werden Dateien. + + + Übersprungen %1$d Datei. + Übersprungen %1$d Dateien. + + + Entfernen Sie „%1$s“ und sein %2$d über die App buchen? Dateien auf der Festplatte werden nicht gelöscht. + Entfernen Sie „%1$s“ und sein %2$d Bücher aus der App? Dateien auf der Festplatte werden nicht gelöscht. + + + Ordnersynchronisierung für %1$d fehlgeschlagen Ordner. + Ordnersynchronisierung für %1$d fehlgeschlagen Ordner. + + + Ordnersynchronisierung mit %1$d abgeschlossen Ordner übersprungen. + Ordnersynchronisierung mit %1$d abgeschlossen Ordner übersprungen. + + + Entfernt %1$d gestreamt OPDS Buch aus diesem Katalog. + Entfernt %1$d gestreamt OPDS Bücher aus diesem Katalog. + + + Alle Bücher %1$d + Alle Bücher %1$d + + + Regale %1$d + Regale %1$d + + + Schlagworte %1$d + Schlagworte %1$d + + + Ordner %1$d + Ordner %1$d + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index bd19efc..53b1a00 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -415,8 +415,8 @@ Weitere Optionen Originales PDF anzeigen Textansicht löschen - Lese-Modus: Vertikal - Lese-Modus: Seitenbasiert + Vertikal + Seitenbasiert (links nach rechts) Aktiviert Lesezeichen entfernen Lesezeichen auf dieser Seite hinzufügen @@ -785,8 +785,8 @@ Zuletzt Titel A-Z Autor A-Z - Prozent fertig 0-100 - Prozent fertig 100-0 + Prozent fertig 0–100 + Prozent fertig 100–0 Größe (Aufsteigend) Größe (Absteigend) Alle @@ -839,7 +839,7 @@ Cloud TTS Kosten: ca. 3–4 Credits pro Minute erzeugter Audioaufnahme.\nZum Aktivieren: Lesebildschirm > Mehr > TTS-Stimmeinstellungen. KI-Zusammenfassungen & Wiederholung - Kosten: ~1-4 Credits pro Anfrage abhängig von Kapitel-Länge.\nPro-Nutzer bekommen täglich 10 kostenlose Zusammenfassungen. + Kosten: ~1–4 Credits pro Anfrage abhängig von Kapitel-Länge.\nPro-Nutzer bekommen täglich 10 kostenlose Zusammenfassungen. Mit dem Kauf, Credits verbraucht Sie haben nicht genügend Credits. Holen Sie sich Episteme Pro für 10 kostenlose Zusammenfassungen pro Tag oder laden Sie weitere Credits auf, um Zusammenfassungen, Cloud-TTS und Wiederholungen zu nutzen. @@ -848,4 +848,668 @@ Nederlands (Niederländisch) Українська (Ukrainisch) Bahasa Indonesia (Indonesisch) + Tabs in der oberen App-Leiste anzeigen + Dieser Dateityp wird nicht unterstützt. + Bildschirmausrichtung + Lese-Modus ändern + Seitenbasiert (rechts nach links) + TTS Einstellungen + TTS Wordersetzungen + Teilen, Speichern oder Drucken + Vorheriger TTS-Abschnitt + Nächster TTS-Abschnitt + Tabs + Bilder + Keine Bilder gefunden. + Bild herunterladen + %1$s gespeichert + Bild konnte nicht gespeichert werden. + Seitenlayout + PDF-Seitenanordnung + Einzeln + Doppelseitig + Erste Seite einzeln + Beginnt mit Doppelseiten nach dem Titelblatt. + Lücke zwischen Seiten entfernen + Gilt für vertikales Lesen und Doppelseiten. + Seitenzahl-Überlagerung verbergen + Entfernt die kleine Seitenzahlangabe von jeder Seite. + Bildschirmausrichtung + Wählen Sie aus, ob die Leseansicht der Systemausrichtung folgt oder ob sie Hoch- oder Querformat bevorzugt, sofern Android dies zulässt. + Helligkeit + System-Helligkeit verwenden + Der Helligkeits-Einstellung des Systems folgen. + Angepasste Helligkeit + Gilt, wenn die Leseansicht geöffnet ist. + %1$d%% + Regal %1$s erstellt. + Schlaues Regal %1$s erstellt. + Regal in %1$s umbenannt. + Regal %1$s gelöscht. + %1$s aktualisiert. + Diese Dateien befinden sich bereits in der Bibliothek. + %1$s - %2$s + Speichern + Kommentar speichern + Kommentar hinzufügen + Antworten + Kommentar hinzufügen… + Kommentare + Kommentar bearbeiten + %1$s antworten + PDF-Dateinamen benutzen + Text-zu-Sprache + Wiedergabesteuerungen für Text-zu-Sprache. + Text-zu-Sprache vorbereiten + Wird vorbereitet: %1$s + Mit Episteme Pro erhalten Sie prägnante Zusammenfassungen jeder beliebigen Seite. Führen Sie ein Upgrade durch, um diese Funktion zu nutzen. + Übersetzen + Zurück zu Seite %1$d + Seite %1$d + Ergebnis %1$d / %2$d + Laden der PDF-Datei fehlgeschlagen. + Schieberegler-Navigation beenden + Zurückspringen + Vorspringen + Zu aktuell gelesener Seite scrollen + Seite mit Anmerkungen + Bild schließen + Suchhervorhebungen umschalten + Ziehen, um Textfeld zu bewegen + %1$s kopiert + Schlagwort + Zoom zurücksetzen + Neuer Tab + Ganzen Text hervorheben + Bearbeitungsmodus umschalten + Englisch, Spanisch, Französisch, etc. + Hindi, Marathi, Sanskrit + Englisch + Chinesisch + Englisch + Japanisch + Englisch + Koreanisch + Englisch + Dokument + Generiert + Hinzufügen / Bearbeiten + Keine Schlagwörter zugewiesen. + Schlagwörter festlegen + Tags suchen oder erstellen… + Kurz + Lang + Geschwindigkeit: %1$sx + Tonhöhe: %1$sx + Abspielen/Pause + Geschwindigkeit zurücksetzen + Tonhöhe zurücksetzen + Farbe auswählen + Dieses Buch hat keinen Inhalt zum Anzeigen. + Link kopiert + Text kopiert + Inhaltsverzeichnis + Lesezeichen + Zu Seite %1$d zurückspringen + Zurück zu vorheriger Seite + Smart Zoom beenden + Smart Comic Zoom + Smart Comic Zoom umschalten + Schutz vor Bildschirmaufnahmen + Einstellungen + Bearbeiten + Wiederherstellen + %1$s ausgewählt + Standards der Leseansicht + KI-Definition + Kapitel %1$d + Standort + Benutzerdefinierte Schrift + Ein Fehler ist aufgetreten: %1$s + Fehler beim Laden des Dokuments: %1$s + OCR hat keinen Text auf dieser Seite gefunden. + Seite scheint leer zu sein oder Text nicht extrahierbar. + Leere Seite kann nicht zusammengefasst werden. + Dokument nicht geladen. + Aus Sicherheitsgründen blockiert. + Leerer Textkörper + Herunterladen fehlgeschlagen: %1$s + Produkte nicht gefunden. + In der Open Source Version nicht verfügbar + Kein Text zum Vorlesen. + Fehler beim Starten der Wiedergabe. + Cloud-TTS ist nicht konfiguriert. + Unbekanntes Buch + KI-Schlüssel und Modelle + Gespeicherte Schlüssel + Schlüssel hinzufügen oder ersetzen + Betreiber + API-Schlüssel + Schlüssel speichern + Alle KI-Funktionen + %1$s-Schlüssel löschen? + Kein Schlüssel gespeichert + %1$s-Schlüssel löschen + Modell + Kein Modell ausgewählt + KI in der Leseansicht anzeigen + KI in der Leseansicht verbergen + Einheitliche Farbe + Textur + Benutzerdefinierte Farbe + Benutzerdefinierte Textur + Benutzerdefinierte Textur auswählen + Text-Helligkeit (Hell) + Text-Helligkeit (Dunkel) + Standard + Links + Rechts + Blocksatz + Immer anzeigen + Mit Menüs synchronisieren + Immer verbergen + Oben + Unten + Originale Metadaten wiederherstellen? + Dadurch werden der ursprüngliche Titel, der Autor, die Reihe und die Zusammenfassung wieder in die EPUB-Datei geschrieben. Lesefortschritt, Tags und Notizen bleiben unverändert. + EPUB Metadaten bearbeitet + Metadaten der EPUB-Datei + Anzeigename in der App geändert + Metadaten der Datei + Metadaten + Datei + Titel + Buchreihe + Dateiname + Modifiziert + Zusammenfassung + Bibliotheks-Tags + Bearbeitbare Metadaten + Anzeigename + In der Leseansicht angezeigter Name + Originale Datei: %1$s + Obere Leiste + Untere Leiste + Versteckte Werkzeuge + Versteckte Werkzeuge + Zum Umordnen ziehen + Externe Apps + Navigationsleiste + Helligkeit + Seitenleiste + Auswählbaren Text hervorheben + Bearbeitungsmodus + TTS-Steuerung + Lesemodus + Seitenverwaltung + Aktuelles buch + Global + Dieses Buch + Ersetzungen aktivieren + Regel hinzufügen + Buch-Regel hinzufügen + Bubble Zoom Modell herunterladen + Um die Bubble Zoom Funktion zu nutzen, muss ein KI Modell heruntergeladen werden (~134 MB). Jetzt herunterladen? + Bubble Zoom Modell herunterladen... %1$d%% + Deaktivieren Sie die lokale Synchronisierung + Aktivieren Sie die lokale Synchronisierung + Lokale Synchronisierung deaktiviert + Lokale Ordnersynchronisierung deaktivieren? + Episteme stoppt das Scannen dieses Ordners und stoppt das Schreiben von JSON Dateien synchronisieren. Entfernen Sie den %1$s Ordner aus diesem Ordner auch? + Synchronisieren Sie die Daten + Synchronisierungsdaten entfernen + Schriftarten löschen? + Sind Sie sicher, dass Sie %1$d löschen möchten? ausgewählte Schriftarten? Dadurch werden sie von allen Ihren Geräten entfernt, wenn die Synchronisierung aktiviert ist. + Für keine lokalen Ordner ist die Synchronisierung aktiviert. + Synchronisierung lokaler Ordner deaktiviert. + Synchronisierung lokaler Ordner deaktiviert. Synchronisierungsdatenordner entfernt. + Die Synchronisierung lokaler Ordner wurde deaktiviert, der Synchronisierungsdatenordner konnte jedoch nicht entfernt werden. + Lokale Ordnersynchronisierung aktiviert. + Vertikal (WebView) + Vertikal (Native Beta) + Wortersetzungen in Büchern + Symbol „Keine Dateien“. + Listenelementmarkierung + Demo-Anmerkungen generieren + Demo-Anmerkungen + Öffnen Sie den Pen Playground + Intelligenter Comic-Zoom + Passen Sie Highlights an + %1$s (Textansicht) + %1$s (Reflow) + Erstellen Sie \"%1$s\" + Ziehen Sie die Distanz, um das Kapitel zu wechseln + Der Bildschirmaufnahmeschutz ist aktiviert + Der Bildschirmaufnahmeschutz ist deaktiviert + PDF-spezifische OCR-, Anmerkungs- und Werkzeugeinstellungen bleiben im PDF Leser. + AI Funktionen sind im Offline-Modus OSS nicht verfügbar bauen. + Wählen Sie ein Modell für %1$s in AI Schlüssel- und Modelleinstellungen. + Fügen Sie einen %1$s hinzu API Geben Sie AI ein Schlüssel- und Modelleinstellungen. + Der AI Der Anbieter hat eine leere Antwort zurückgegeben. + AI Anbieterfehler: %1$d. %2$s + Diese Zusammenfassung benötigt einen Gemini Modell, da die ausgewählten Groq-Modelle die Eingabe PDF/image nicht unterstützen. + Sie müssen angemeldet sein, um Feedback nutzen zu können. + Sie müssen angemeldet sein, um Feedback abzugeben. + Für Tickets sind maximal 3 Bilder zulässig. + Pro Nachricht sind maximal 5 Bilder zulässig. + Ein oder mehrere Bilder überschreiten die 5-MB-Grenze. + Ticket konnte nicht erstellt werden: %1$s + Senden fehlgeschlagen: %1$s + Feed konnte nicht geladen werden: %1$s + Download-Fehler: %1$s + Der Kauf ist fehlgeschlagen: %1$s + Es konnte keine Verbindung zum Abrechnungsdienst hergestellt werden. + Die Produktabfrage ist fehlgeschlagen + Audio konnte nicht geladen werden. + Wiedergabefehler: %1$s + Verwenden Sie ein Modell für alle Funktionen + Im ausgeschalteten Zustand ist jeder Leser AI Die Funktion verwendet ihr eigenes ausgewähltes Modell. + Intelligente Wörterbücher, Zusammenfassungen und Rückblicke nutzen alle dieses Modell. + Intelligentes Wörterbuch + Wird beim Definieren ausgewählter Wörter oder Phrasen verwendet. + Zusammenfassungen + Wird für EPUB verwendet Zusammenfassungen und PDF Seitenzusammenfassungen. PDF/Bildzusammenfassungen benötigen Gemini. + Zusammenfassungen + Wird für die Generierung von Story-Zusammenfassungen verwendet. + Verwendet den gespeicherten Gemini Schlüssel. Nur %1$s wird vorerst unterstützt. + Sparen Sie %1$s Schlüssel? + Nach dem Speichern sind nur die ersten 3 und die letzten 3 Zeichen sichtbar. Um es später zu ändern, ersetzen oder löschen Sie es. + Funktionen, die diesen Anbieter nutzen, funktionieren erst, wenn ein neuer Schlüssel gespeichert wird. + Lesen + Mehr Menü + Legen Sie hier Werkzeuge ab + Textansicht (Reflow) + Die hier aufgeführten Regeln gelten für jedes Buch, sofern sie nicht für einen bestimmten Titel deaktiviert sind. + Noch keine globalen Ersatzregeln. + Noch keine buchspezifischen Regeln. + Nutzen Sie hier globale Regeln + Deaktivieren Sie diese Option, wenn ein Buch eigene Ausspracheoptionen benötigt. + Buchregeln aktivieren + Lokale Regeln werden nach globalen Regeln ausgeführt. + Geerbte globale Regeln + Keine globalen Regeln zum Vererben. + In diesem Buch erlaubt + Für dieses Buch deaktiviert + Vorschläge + Neuer Ersatz + Ersetzung bearbeiten + Ersetzen + Sprechen Sie als + Aktiviert + Ganzes Wort + Streichholzetui + Vorschau der Eingabe + Regeln + Stille + Klartext + Groß- und Kleinschreibung beachten + Aktuelles Buch + Regel hinzufügen + Für dieses Buch gibt es noch keine Ersatzregeln. + Neuer Ersatz + Ersetzung bearbeiten + Mit + leerer Text + Über + Desktop-Reader + Desktop-Zugriff + Konto + Konto & Guthaben + Kontoübersicht + AI Nabe + Wird für EPUB verwendet Zusammenfassungen und PDF Seitenzusammenfassungen. + Episteme oss + Autorentext + Cache: %1$s + Zwischengespeichert + Zwischengespeicherte Zusammenfassung + Wählen Sie Gemini Stimme, die zum Vorlesen in der Cloud verwendet wird. + Löschen Sie das generierte Desktop-Buch und EPUB Paginierungs-Cache-Dateien? Sie werden beim nächsten Öffnen von Büchern neu erstellt. + Sprachcache löschen + Werkzeuge schließen + Cloud-Synchronisierung + Wolke TTS braucht Gemini + Wolke TTS benötigt angemeldete Credits + Wolke TTS fertig + Wolke TTS Einstellungen + Wolke TTS nicht verfügbar + Wolke TTS Stimme + Enthält + Kostenberechnung + Erstellen Sie eine Zusammenfassung bis zu Ihrer aktuellen Position. + Erstellen Sie ein intelligentes Regal + %1$d Credits verfügbar + %1$s Credits + Importierte Schriftarten für den Leser + Schriftart löschen + %1$s löschen? Bücher, die diese Schriftart verwenden, greifen auf die Standardschriftart zurück. + \"%1$s\" löschen? Bücher bleiben in Ihrer Bibliothek. + Zusammenfassung löschen + Deaktiviert + Legen Sie die zu importierenden Dateien ab + Löschen Sie unterstützte Dateien zum Importieren + Kontaktieren Sie uns für alles Weitere direkt per E-Mail. + Gleich + Extras + Rückmeldung + Feld + Ordnerpfad + Von hier + Vollständiger Scan + Kostenlos, %1$d links + Zusammenfassung erstellen + Zusammenfassung erstellen + Melden Sie Fehler, fordern Sie Funktionen an oder wenden Sie sich direkt an den Support. + GitHub-Sponsoren + Unterstützen Sie die Entwicklung durch GitHub-Sponsoren. + Google Die Anmeldung ist für diesen Desktop-Build nicht konfiguriert. + Größer als + Hilfe + Fehlerberichte, Funktionsanfragen und Support + Verstecken + Dateien importieren + Probleme + Öffnen Sie den Issue-Tracker für Fehler und Funktionsanfragen. + Weniger als + Bibliothek und Leser + Irgendein + Bibliotheksaktionen + Mehr + Für dieses Buch wurden noch keine Zusammenfassungen zwischengespeichert. + Importieren Sie TTF-, OTF- oder WOFF2-Dateien, um sie in Büchern zu verwenden. + Keine passenden Schriftarten für \"%1$s\" gefunden + Nein Google Konto ist verbunden. + Für diesen Abschnitt wurde keine Zusammenfassung zwischengespeichert. + Offline-Desktop-Reader + Offene Leser + Eröffnung %1$s + Öffnen Sie Ihre Bibliothek + Betreiber + Seite + Passwortgeschützt PDF + Patreon + Unterstützen Sie das Projekt auf Patreon. + Angehalten + %1$s erfordert ein Passwort, bevor es geöffnet werden kann. + Das Passwort ist erforderlich oder falsch. + Mit diesem Passwort konnte %1$s nicht geöffnet werden. Geben Sie PDF ein Geben Sie Ihr Passwort ein und versuchen Sie es erneut. + Prozent + Planen + Audio vorbereiten + Präferenzen + Konto & Guthaben + Konto & Guthaben + Pro ist für dieses Konto nicht freigeschaltet. + Pro- und Credits können nur über Android erworben werden App. Desktop überprüft dasselbe angemeldete Konto und verwendet diese Credits für Cloud TTS, Zusammenfassungen, Rückblicke und andere kostenpflichtige AI Merkmale. + Melden Sie sich an, um Ihren Kontostatus auf dem Desktop zu überprüfen. + Pro ist für dieses Konto freigeschaltet. + Fortschritt + Projekt + Leser + Reader-Tabs aus + Reader-Tabs an + Aktualisieren + Zum Hinzufügen zu Ihrer Bibliothek freigeben. + Eine sichere Schlüsselspeicherung ist auf diesem Betriebssystem nicht verfügbar. Die hier eingegebenen Schlüssel werden für diese Sitzung verwendet, aber nicht gespeichert. + Einstellungs-Hub + Entspricht Android Blenden Sie den Schalter für intelligentes Wörterbuch, Zusammenfassungen und Rückblicke aus. + Konto, Pro und Credits synchronisieren + Angemeldet + Quellcode + Durchsuchen Sie die Projektquelle auf GitHub. + Hören Sie auf zu lesen, um die Stimmen zu ändern. + Unterstützung + Unterstützung Episteme + Beiträge tragen dazu bei, dass sich der Leser im gesamten Android weiter verbessert und Desktop. + Möglichkeiten zur Unterstützung von Episteme Entwicklung + Ordner synchronisieren + Metadaten synchronisieren + Tag-Name + Markieren Sie ausgewählte Bücher + Titeltext + Werkzeuge + Import-, Synchronisierungs- und App-Einstellungen + Typ, z.B. PDF + Ansicht + Sprachcache + Eingebettete Webansicht wird vorbereitet… + Vorbereiten der gebündelten eingebetteten Webansicht %1$d%% + Eingebettete Webansicht installiert. Starten Sie Episteme neu um die Einrichtung abzuschließen. + Eingebettete Webansicht konnte nicht gestartet werden: %1$s + Arbeiten… + Arbeitsbereich + Zum Regal hinzufügen + Erstellen Sie zunächst ein Regal und fügen Sie dann ausgewählte Bücher hinzu. + Thema erstellen + Vorhanden: %1$s + Sie haben auf einen externen Link geklickt. + Bearbeiten EPUB Metadaten + Weniger + …mehr + Noch keine benutzerdefinierten Designs + In App umbenennen + Tags, durch Kommas getrennt + Unbekannt + Definieren + Anmerkung + Anmerkungsoptionen + Anmerkungswerkzeuge + Assist + Wählen Sie aus, welches PDF zu retten. + Sprungverlauf löschen + Wolke TTS fehlgeschlagen. + Fügen Sie einen Gemini hinzu Taste und wählen Sie Gemini Wolke TTS in AI Schlüssel und Modelle. + Wolke TTS ist für diesen Desktop-Build nicht konfiguriert. + Melden Sie sich mit Google an um die Cloud TTS zu verwenden. + Wolke TTS benötigt ein angemeldetes Konto mit Guthaben. Pro- und Credits können nur über Android erworben werden App. + Farbe + Kommentarmöglichkeiten + Benutzerdefiniert + Dadurch wird die Anmerkung von diesem PDF entfernt. + Anmerkung löschen? + Dokumenttext + Eingebettet PDF Kommentar + Seite konnte nicht gerendert werden. + Funktion nicht verfügbar + Fertig + Füllfederhalter + Suchergebnisse ausblenden + Hervorhebungsfarbe %1$d + Highlighter-Palette + Interaktion + Indizierung %1$d/%2$d Seiten + Markup + %1$d Streichhölzer + %1$d Spiele bisher + Nächste Seite + Nächstes Suchergebnis + Noch keine Anmerkungen + Noch keine Lesezeichen + Kein Kommentar + Keine Übereinstimmungen + Noch keine Übereinstimmungen auf indizierten Seiten + Kein Inhaltsverzeichnis + Hier gibt es keinen Text zum Lesen. + Auf dieser Seite gibt es keinen Text zum Lesen. + Es gibt keinen zusammenfassenden Text. + Kommentar öffnen + Keine Credits mehr. Pro- und Credits können nur über Android erworben werden App. + Verwenden der Cloud TTS benötigt Credits auf dem Desktop. Pro- und Credits können nur über Android erworben werden App. + Für die Nutzung dieser Funktion sind Credits auf dem Desktop erforderlich. Pro- und Credits können nur über Android erworben werden App. + Für die Verwendung von Zusammenfassungen sind Credits auf dem Desktop erforderlich. Pro- und Credits können nur über Android erworben werden App. + Für die Verwendung von Zusammenfassungen sind Credits auf dem Desktop erforderlich. Pro- und Credits können nur über Android erworben werden App. + Pfanne + PDF Aktion fehlgeschlagen + Der PDF Aktion konnte nicht abgeschlossen werden. + PDF Kommentar + P. %1$d + PDF Seite %1$d + Seite %1$d - %2$s + Seite %1$s von %2$d + Seiten %1$s von %2$d + PDF gespeichert + PDF Werkzeuge + Bleistift + Auswahl vorbereiten + Vorbereiten %1$s + Vorherige Seite + Vorheriges Suchergebnis + Der Druckdialog ist beendet. + Profi erforderlich + Für diese Funktion ist Pro erforderlich. Pro kann nur bei Android erworben werden App, dann verwendet der Desktop nach der Anmeldung das aktualisierte Konto. + Für das intelligente Wörterbuch mit mehreren Wörtern ist Pro erforderlich. Pro kann nur bei Android erworben werden App, dann verwendet der Desktop nach der Anmeldung das aktualisierte Konto. + Leser AI Funktionen sind ausgeblendet. + Desktop AI ist für diesen Build nicht konfiguriert. + Gilt für vertikales Lesen und Doppelseiten. + Runder Textmarker + Gespeichert unter %1$s + Scrollen + Suche in PDF + Text auswählen + Ausgewählt %1$s + Suchergebnisse anzeigen + Melden Sie sich mit Google an um diese Funktion auf dem Desktop zu verwenden. + Melden Sie sich mit Google an um das intelligente Wörterbuch mit mehreren Wörtern auf dem Desktop zu verwenden. + Melden Sie sich mit Google an um Zusammenfassungen auf dem Desktop zu verwenden. + Melden Sie sich mit Google an um Zusammenfassungen auf dem Desktop zu verwenden. + Angehalten + Textnotiz + Textnotiz + Textstil + Dicke %1$s + Inhaltsverzeichnis + Geben Sie Folgendes ein, um nach diesem PDF zu suchen + Ohne Titel + Konto und Guthaben anzeigen + Sprachcache geleert + Zoomen + Vergrößern + Herauszoomen + Wählen Sie + Lesen Sie weiter + Entlassen + Runter + Auf + AI + Mitte + Autoren + Zurück zur Bibliothek + Aktionen buchen + Ordner + Durchsuchen + Kategorien + Kap. %1$d + Kapitelwechsel + Wählen Sie eine Schriftart + Wählen Sie die Textur des Lesers + Dateitypen löschen + Seitenanmerkungen löschen + Klare Quellen + Klarer Status + Tags löschen + Leser schließen + Kontinuierlich + Abdeckungen + Benutzerdefinierte Farben + Vorschau des benutzerdefinierten Themes + %1$s verringern + Seite definieren + Dadurch werden die Hervorhebung und ihre Notiz entfernt. + Geben Sie den Vollbildmodus ein + Verlassen Sie den Vollbildmodus + Externe Suche + Bücher + Comics + Dokumente + Andere + Text und Web + Füllen + Erscheinungsbild mit festem Layout + Der Ordner ist leer + Hier sind keine unterstützten Dateien oder Unterordner verfügbar. + %1$s, %2$s + %1$s - %2$s + Filter ausblenden + Lesetools ausblenden + Tippen Sie auf einen Steckplatz und wählen Sie dann eine Farbe aus. + Lesen Sie weiter und aktuelle Bücher + Bücher importieren + Ordner importieren + Importierte Schriftarten + %1$s %2$s + %1$s erhöhen + Sprunggeschichte + Layout und Abstand + Importieren Sie Dateien in den App-Speicher oder fügen Sie einen Ordner hinzu, um Dateien direkt zu lesen. + Durchsuchen Sie Ihre Sammlung + AI Schlüssel + Smart %1$d + Ungelesen %1$d + In Bearbeitung %1$d + Vervollständigen Sie %1$d + Liste + Navigation + Kein Buch geöffnet + Fügen Sie einen Ordner hinzu, um Dateien aus diesem Ordner direkt zu lesen. + Noch keine Ordner + Keine Navigationselemente + Kein Seiteninhalt + Keine Einstellungen gefunden + Hier erscheinen manuelle Regale und Seriensammlungen. + Noch keine Regale + Erstellen Sie intelligente Regale, um Bücher nach Regeln zu sammeln. + Noch keine smarten Regale + Den Büchern hinzugefügte Tags werden hier angezeigt. + Noch keine Tags + Es wurden keine unterstützten Dateien importiert. + Katalog + „%1$s“ löschen? Gestreamte Bücher aus diesem Katalog werden möglicherweise nicht mehr geöffnet, wenn sich die Anmeldeinformationen später ändern. + Keine Kataloge + Fügen Sie einen OPDS hinzu Katalog zum Durchsuchen entfernter Bücher. + Durchsuchen Sie Kataloge, Streams und Downloads + Offenes Buch + Ordner öffnen + Öffnen Sie PDF + Seiten- und Textfarben + Seiteninformationen + Seitenbreite + Diese Standardeinstellungen gelten, wenn die Plattform gemeinsam genutzte PDF unterstützt Aussehen. Pro Buch PDF Überschreibungen bleiben im PDF Leser. + PDF Dateiaktionen + PDF Textmarker + Gespeichert mit Leser-Highlight-Transparenz. + Pin + Vom Leser verwaltet PDF Werkzeuge + Automatischer Bildlauf, OCR, Anmerkungsstandardwerte und nur PDF-Werkzeugsichtbarkeit werden innerhalb des aktiven PDF verwaltet Leser. + %1$s %2$s von %3$d (%4$d%%) + Die Standardeinstellungen der Reader-Symbolleiste werden vom Reader auf dieser Plattform verwaltet. + Reader-Tools + Bild speichern + Suche: %1$s + Im Reader suchen + Sucheinstellungen + Auswahl + Auswahl-Endgriff + Starthandle für die Auswahl + Fügen Sie Regale, Tags oder Ordnermetadaten hinzu, um Ihre Bibliothek zu organisieren. + Sammlungen, Serien, Tags und Ordner + Lesetools anzeigen + Ordner + Clever + Solide + Geschwindigkeit + Automatisches Scrollen starten + Automatisches Scrollen stoppen + Hören Sie auf, laut vorzulesen + Texturstärke + Geben Sie ein, um dieses Buch zu durchsuchen + Typografie + Anmerkung rückgängig machen + Lösen + Verwenden Sie ein dunkles Thema + Verwenden Sie ein helles Thema + Mono + Ohne + Serife + Suchen Sie nach Büchern, Autoren oder Tags + Keine Werkzeuge + Sichtbar + Ersetzen Sie nur das Gesprochene + Lesertext, Hervorhebungen und Standorte bleiben unverändert. + %1$s -> %2$s diff --git a/app/src/main/res/values-es/plurals.xml b/app/src/main/res/values-es/plurals.xml index fbd9e9a..f9d77c7 100644 --- a/app/src/main/res/values-es/plurals.xml +++ b/app/src/main/res/values-es/plurals.xml @@ -52,4 +52,88 @@ (%1$d fragmento) (%1$d fragmentos) + + Importando %1$d libro... Aparecerá en tu biblioteca en breve. + Importando %1$d libros... Aparecerán en tu biblioteca en breve. + + + Importado %1$d libro. Puede encontrarlo en la pestaña Biblioteca. + Importado %1$d libros. Puede encontrarlos en la pestaña Biblioteca. + + + %1$d libro agregado al estante. + %1$d libros agregados al estante. + + + %1$d libro etiquetado con "%2$s". + %1$d libros etiquetados con "%2$s". + + + Carpeta eliminada "%1$s" y %2$d reservar desde la aplicación. + Carpeta eliminada "%1$s" y %2$d libros desde la aplicación. + + + %1$d archivo + %1$d archivos + + + Soltar para importar %1$d archivo + Soltar para importar %1$d archivos + + + %1$d Se omitirá el archivo no compatible. + %1$d Se omitirán los archivos no compatibles. + + + Importando %1$d archivo… + Importando %1$d archivos… + + + Importado %1$d archivo. + Importado %1$d archivos. + + + Importado %1$d archivo. El soporte para lectores llegará más tarde. + Importado %1$d archivos. El soporte para lectores llegará más tarde. + + + No se pudo importar %1$d archivo. + No se pudo importar %1$d archivos. + + + Saltado %1$d archivo. + Saltado %1$d archivos. + + + Eliminar "%1$s" y su %2$d reservar desde la aplicación? Los archivos en el disco no se eliminarán. + Eliminar "%1$s" y su %2$d libros de la aplicación? Los archivos en el disco no se eliminarán. + + + Error de sincronización de carpetas para %1$d carpeta. + Error de sincronización de carpetas para %1$d carpetas. + + + Sincronización de carpetas finalizada con %1$d carpeta omitida. + Sincronización de carpetas finalizada con %1$d carpetas omitidas. + + + Eliminado %1$d transmitido OPDS libro de ese catálogo. + Eliminado %1$d transmitido OPDS libros de ese catálogo. + + + Todos los libros %1$d + Todos los libros %1$d + + + Estantes %1$d + Estantes %1$d + + + Etiquetas %1$d + Etiquetas %1$d + + + Carpetas %1$d + Carpetas %1$d + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0fac6be..a443664 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1086,4 +1086,430 @@ Nederlands (neerlandés) Українська (ucraniano) Bahasa Indonesia (indonesio) + Mostrar pestañas en la barra superior de aplicaciones + Deshabilitar la sincronización local + Habilitar sincronización local + Sincronización local deshabilitada + ¿Desactivar la sincronización de carpetas locales? + Episteme dejará de escanear esta carpeta y dejará de escribir JSON sincronizar archivos. Retire el %1$s carpeta de esta carpeta también? + Mantener datos sincronizados + Eliminar datos de sincronización + ¿Eliminar fuentes? + ¿Estás seguro de que deseas eliminar %1$d fuentes seleccionadas? Esto los eliminará de todos sus dispositivos si la sincronización está activada. + Ninguna carpeta local tiene la sincronización habilitada. + Sincronización de carpeta local deshabilitada. + Sincronización de carpeta local deshabilitada. Se eliminó la carpeta de datos de sincronización. + La sincronización de la carpeta local está deshabilitada, pero no se pudo eliminar la carpeta de datos de sincronización. + Sincronización de carpeta local habilitada. + Vertical (vista web) + Vertical (Beta nativa) + Reemplazos de palabras de libros + Anterior TTS pedazo + Siguiente TTS pedazo + Imágenes + No se encontraron imágenes. + Descargar imagen + Guardado %1$s + No se pudo guardar la imagen. + PDF extensión de página + Una sola página + dos paginas + Primera pagina sola + Comienza a desplegar páginas opuestas después de la portada. + Brillo + Usar el brillo del sistema + Sigue la configuración de brillo del dispositivo. + Brillo personalizado + Se aplica mientras una pantalla de lector está abierta. + %1$d%% + Estante creado "%1$s". + Estante inteligente creado "%1$s". + Estante renombrado a "%1$s". + Estante eliminado "%1$s". + Actualizado "%1$s". + Esos archivos ya están en la biblioteca. + %1$s - %2$s + Ahorrar + Guardar comentario + Agregar comentario + Responder + Añade un comentario… + Comentarios + Editando comentario + Respondiendo a %1$s + Uso PDF Nombres de archivos + Brillo + libro actual + Agregar regla + Aún no hay reglas de reemplazo para este libro. + Nuevo reemplazo + Editar reemplazo + Con + texto vacío + Acerca de + Lector de escritorio + Acceso al escritorio + Cuenta + Cuenta y créditos + Descripción general de la cuenta + AI centro + Utilizado para EPUB resúmenes y PDF resúmenes de páginas. + Episteme oss + Texto del autor + Caché: %1$s + En caché + Resumen en caché + Elija el Gemini voz utilizada para la lectura en la nube en voz alta. + Eliminar el libro de escritorio generado y EPUB archivos de caché de paginación? Se recrearán la próxima vez que se abran los libros. + Borrar caché de voz + Cerrar herramientas + Sincronización en la nube + Nube TTS necesidades Gemini + Nube TTS necesita créditos registrados + Nube TTS listo + Nube TTS ajustes + Nube TTS indisponible + Nube TTS voz + Contiene + cálculo de costos + Cree un resumen hasta su posición actual. + Crear estante inteligente + %1$d créditos disponibles + %1$s créditos + Fuentes importadas para el lector. + Eliminar fuente + Eliminar %1$s? Los libros que lo utilicen volverán a la fuente predeterminada. + ¿Eliminar \"%1$s\"? Los libros permanecen en tu biblioteca. + Eliminar resumen + Desactivado + Soltar archivos para importar + Eliminar archivos compatibles para importar + Contáctenos directamente por correo electrónico para cualquier otra cosa. + igual + Extras + Comentario + Campo + Ruta de la carpeta + Desde aquí + escaneo completo + Gratis, %1$d izquierda + Generar resumen + Generar resumen + Informe errores, solicite funciones o comuníquese con el soporte directamente. + Patrocinadores de GitHub + Apoyar el desarrollo a través de patrocinadores de GitHub. + Google El inicio de sesión no está configurado para esta compilación de escritorio. + Más que + Ayuda + Informes de errores, solicitudes de funciones y soporte + Esconder + Importar archivos + Asuntos + Abra el rastreador de problemas para detectar errores y solicitudes de funciones. + Menos que + Biblioteca y lector + Cualquier + Acciones de biblioteca + Más + Aún no hay resúmenes almacenados en caché para este libro. + Importe archivos TTF, OTF o WOFF2 para usarlos en libros. + No se encontraron fuentes que coincidan con \"%1$s\" + No Google La cuenta está conectada. + No hay ningún resumen almacenado en caché para esta sección. + Lector de escritorio sin conexión + Lectores abiertos + Apertura %1$s + Abriendo tu biblioteca + Operador + Página + Protegido con contraseña PDF + Patreón + Apoya el proyecto en Patreon. + En pausa + %1$s requiere una contraseña antes de poder abrirse. + La contraseña es obligatoria o incorrecta. + Esa contraseña no se abrió %1$s. Ingrese el PDF contraseña y vuelva a intentarlo. + Por ciento + Plan + Preparando audio + Preferencias + Cuenta y créditos + Cuenta y créditos + Pro no está desbloqueado para esta cuenta. + Pro y créditos solo se pueden comprar en Android aplicación. Desktop verifica la misma cuenta iniciada y usa esos créditos para la nube TTS, resúmenes, resúmenes y otros pagos AI características. + Inicie sesión para verificar el estado de su cuenta en el escritorio. + Pro está desbloqueado para esta cuenta. + Progreso + Proyecto + Lector + Pestañas del lector desactivadas + Pestañas del lector en + Refrescar + Suelte para agregarlo a su biblioteca. + El almacenamiento seguro de claves no está disponible en este sistema operativo. Las claves ingresadas aquí se usarán para esta sesión pero no se conservarán. + Centro de configuración + Coincide con el Android Ocultar alternar para diccionario inteligente, resúmenes y resúmenes. + Sincronizar cuenta, Pro y créditos + Iniciado sesión + código fuente + Explore el código fuente del proyecto en GitHub. + Deja de leer para cambiar de voz. + Apoyo + Soporte Episteme + Las contribuciones ayudan a que el lector siga mejorando en Android y escritorio. + Formas de apoyar Episteme desarrollo + Sincronizar carpetas + Sincronizar metadatos + Nombre de etiqueta + Etiquetar libros seleccionados + Texto del título + Herramientas + Importación, sincronización y configuración de aplicaciones + Tipo, p.e. PDF + Vista + Caché de voz + Preparando vista web integrada... + Preparando la vista web integrada incluida %1$d%% + Vista web integrada instalada. Reiniciar Episteme para finalizar la configuración. + La vista web integrada no pudo iniciarse: %1$s + Laboral… + Espacio de trabajo + Añadir al estante + Primero cree un estante y luego agréguele los libros seleccionados. + Crear tema + Existente: %1$s + Hiciste clic en un enlace externo. + Editar EPUB metadatos + Menos + …más + Aún no hay temas personalizados + Cambiar nombre en la aplicación + Etiquetas, separadas por comas + Desconocido + Definir + Anotación + Opciones de anotación + Herramientas de anotación + Asistir + Elige cuál PDF para ahorrar. + Borrar historial de saltos + Nube TTS fallido. + Agregar un Gemini y seleccione Gemini nube TTS en AI claves y modelos. + Nube TTS no está configurado para esta versión de escritorio. + Inicia sesión con Google utilizar la nube TTS. + Nube TTS necesita una cuenta registrada con créditos. Pro y créditos solo se pueden comprar en Android aplicación. + Color + Opciones de comentarios + Costumbre + Esto elimina la anotación de este PDF. + ¿Eliminar anotación? + Texto del documento + Integrado PDF comentario + No se pudo renderizar la página. + Característica no disponible + Finalizado + Pluma estilográfica + Ocultar resultados de búsqueda + Color de resaltado %1$d + paleta de resaltadores + Interacción + Indexación %1$d/%2$d paginas + Margen + %1$d partidos + %1$d partidos hasta ahora + Página siguiente + Siguiente resultado de búsqueda + Aún no hay anotaciones + Aún no hay marcadores + Sin comentarios + No hay coincidencias + Aún no hay coincidencias en las páginas indexadas + Sin tabla de contenidos + No hay texto aquí para leer. + No hay texto en esta página para leer. + No hay texto para resumir. + Abrir comentario + Sin créditos. Pro y créditos solo se pueden comprar en Android aplicación. + Usando la nube TTS necesita créditos en el escritorio. Pro y créditos solo se pueden comprar en Android aplicación. + Para utilizar esta función se necesitan créditos en el escritorio. Pro y créditos solo se pueden comprar en Android aplicación. + El uso de resúmenes necesita créditos en el escritorio. Pro y créditos solo se pueden comprar en Android aplicación. + El uso de resúmenes necesita créditos en el escritorio. Pro y créditos solo se pueden comprar en Android aplicación. + Cacerola + PDF la acción falló + El PDF la acción no se pudo completar. + PDF comentario + pag. %1$d + PDF página %1$d + Página %1$d - %2$s + Página %1$s de %2$d + Páginas %1$s de %2$d + PDF salvado + PDF herramientas + Lápiz + Preparando la selección + Preparando %1$s + Pagina anterior + Resultado de búsqueda anterior + El cuadro de diálogo de impresión ha finalizado. + Se requiere profesional + Esta característica requiere Pro. Pro solo se puede comprar en Android aplicación, luego el escritorio usará la cuenta actualizada después de iniciar sesión. + El diccionario inteligente de varias palabras requiere Pro. Pro solo se puede comprar en Android aplicación, luego el escritorio usará la cuenta actualizada después de iniciar sesión. + Lector AI Las características están ocultas. + Escritorio AI no está configurado para esta compilación. + Se aplica a lectura vertical y pliegos de dos páginas. + resaltador redondo + Guardado en %1$s + Voluta + Buscar en PDF + Seleccionar texto + Seleccionado %1$s + Mostrar resultados de búsqueda + Inicia sesión con Google para utilizar esta función en el escritorio. + Inicia sesión con Google para utilizar el diccionario inteligente de varias palabras en el escritorio. + Inicia sesión con Google para usar resúmenes en el escritorio. + Inicia sesión con Google para usar resúmenes en el escritorio. + Interrumpido + Nota de texto + nota de texto + Estilo de texto + Espesor %1$s + TOC + Escribe para buscar esto PDF + Intitulado + Ver cuenta y créditos + Caché de voz borrado + Zoom + Dar un golpe de zoom + alejar + Elegir + Continuar leyendo + Despedir + Abajo + Arriba + AI + Centro + Autores + volver a la biblioteca + Acciones del libro + Carpeta + Navegar + Categorías + Cap. %1$d + Turnos de capítulo + Elige fuente + Elige la textura del lector + Borrar tipos de archivos + Borrar anotaciones de página + Fuentes claras + Borrar estado + Borrar etiquetas + Lector cercano + Continuo + Cubiertas + Colores personalizados + Vista previa del tema personalizado + Disminución %1$s + Definir página + Esto elimina el resaltado y su nota. + Entrar en pantalla completa + Salir de pantalla completa + búsqueda externa + Libros + Historietas + Documentos + Otro + Texto y web + Llenar + Apariencia de diseño fijo + La carpeta está vacía + No hay archivos ni subcarpetas compatibles disponibles aquí. + %1$s, %2$s + %1$s - %2$s + Ocultar filtros + Ocultar herramientas de lectura + Toca una ranura y luego elige un color. + Continuar leyendo y libros recientes + importar libros + Carpeta de importación + Fuentes importadas + %1$s %2$s + Incrementar %1$s + Historial de saltos + Diseño y espaciado + Importe archivos al almacenamiento de aplicaciones o agregue una carpeta para leer archivos en su lugar. + Explora tu colección + AI llaves + Inteligente %1$d + No leído %1$d + En curso %1$d + Completo %1$d + Lista + Navegación + ningún libro abierto + Agregue una carpeta para leer archivos de esa carpeta en su lugar. + Aún no hay carpetas + Sin elementos de navegación + Sin contenido de página + No se encontraron configuraciones + Aquí aparecerán estanterías manuales y colecciones de series. + Aún no hay estantes + Crea estanterías inteligentes para recoger libros según reglas. + Aún no hay estantes inteligentes + Las etiquetas agregadas a los libros aparecerán aquí. + Aún no hay etiquetas + No se importaron archivos compatibles. + Catalogar + ¿Eliminar "%1$s"? Los libros transmitidos desde este catálogo pueden dejar de abrirse si las credenciales cambian más adelante. + Sin catálogos + Agregar un OPDS catálogo para buscar libros remotos. + Explorar catálogos, transmisiones y descargas + Libro abierto + Abrir carpeta + Abierto PDF + Colores de página y texto + Información de la página + Ancho de página + Estos valores predeterminados se aplican cuando la plataforma admite compartido PDF apariencia. Por libro PDF anula la permanencia en el PDF lector. + PDF acciones de archivo + PDF resaltador + Guardado con transparencia resaltada del lector. + Alfiler + Gestionado por lector PDF herramientas + El desplazamiento automático, OCR, los valores predeterminados de anotación y la visibilidad de la herramienta PDF-solo se administran dentro del PDF activo. lector. + %1$s %2$s de %3$d (%4$d%%) + Los valores predeterminados de la barra de herramientas del lector se administran desde el lector en esta plataforma. + Herramientas de lectura + Salvar el imagen + Buscar: %1$s + Buscar en lector + Configuración de búsqueda + Selección + Mango final de selección + Controlador de inicio de selección + Agregue estantes, etiquetas o metadatos de carpetas para organizar su biblioteca. + Colecciones, series, etiquetas y carpetas. + Mostrar herramientas de lectura + Carpeta + Elegante + Sólido + Velocidad + Iniciar desplazamiento automático + Detener el desplazamiento automático + dejar de leer en voz alta + Fuerza de la textura + Escribe para buscar este libro + Tipografía + Deshacer anotación + Desprender + Usar tema oscuro + Usar tema claro + Mononucleosis infecciosa + sin + Serifa + Buscar libros, autores o etiquetas + Sin herramientas + Visible + Reemplazar solo lo que se habla + El texto del lector, los aspectos destacados y las ubicaciones permanecen sin cambios. + %1$s -> %2$s diff --git a/app/src/main/res/values-et/plurals.xml b/app/src/main/res/values-et/plurals.xml index d3af106..e02f040 100644 --- a/app/src/main/res/values-et/plurals.xml +++ b/app/src/main/res/values-et/plurals.xml @@ -20,4 +20,120 @@ Kustuta fail jäädavalt Kustuta failid jäädavalt + + %1$d riiul + %1$d riiulit + + + Kas kustutada %1$d valitud fail seadmest jäädavalt? Seda toimingut ei saa tagasi võtta. + Kas kustutada %1$d valitud faili seadmest jäädavalt? Seda toimingut ei saa tagasi võtta. + + + Kas eemaldada %1$d valitud fail viimaste failide loendist? See ilmub uuesti, kui avad selle raamatukogust. + Kas eemaldada %1$d valitud faili viimaste failide loendist? Need ilmuvad uuesti, kui avad need raamatukogust. + + + Kas eemaldada %1$d raamat riiulilt "%2$s"? Raamat jääb raamatukokku ja kuvatakse riiulita raamatute all. + Kas eemaldada %1$d raamatut riiulilt "%2$s"? Raamatud jäävad raamatukokku ja kuvatakse riiulita raamatute all. + + + %1$d raamat raamatukogust eemaldatud. + %1$d raamatut eemaldati raamatukogust. + + + Impordin %1$d raamatut… See ilmub peagi raamatukogusse. + Impordin %1$d raamatut… Need ilmuvad peagi raamatukogusse. + + + Imporditi %1$d raamat. Leiad selle vahekaardilt Raamatukogu. + Imporditi %1$d raamatut. Leiad need vahekaardilt Raamatukogu. + + + %1$d raamat lisatud riiulile. + %1$d raamatut lisati riiulile. + + + %1$d raamat sildiga "%2$s". + %1$d raamatut märgiti sildiga "%2$s". + + + Eemaldati kaust "%1$s" ja %2$d raamat rakendusest. + Eemaldati kaust "%1$s" ja %2$d raamatut rakendusest. + + + %1$d kaust + %1$d kausta + + + %1$d fail + %1$d faili + + + Lohista importimiseks %1$d fail + Lohista importimiseks %1$d faili + + + %1$d toetamata fail jäetakse vahele. + %1$d toetamata faili jäetakse vahele. + + + Impordin %1$d faili… + Impordin %1$d faili… + + + Imporditi %1$d fail. + Imporditi %1$d faili. + + + Imporditi %1$d fail. Lugeja tugi tuleb hiljem. + Imporditi %1$d faili. Lugeja tugi tuleb hiljem. + + + %1$d faili importimine nurjus. + %1$d faili importimine nurjus. + + + %1$d fail jäeti vahele. + %1$d faili jäeti vahele. + + + Kas eemaldada "%1$s" ja selle %2$d raamat rakendusest? Kettal olevaid faile ei kustutata. + Kas eemaldada "%1$s" ja selle %2$d raamatut rakendusest? Kettal olevaid faile ei kustutata. + + + %1$d kausta sünkroonimine nurjus. + %1$d kausta sünkroonimine nurjus. + + + Kausta sünkroonimine lõppes, %1$d kaust jäeti vahele. + Kausta sünkroonimine lõppes, %1$d kausta jäeti vahele. + + + Sellest kataloogist eemaldati %1$d voogedastatud OPDS-raamat. + Sellest kataloogist eemaldati %1$d voogedastatud OPDS-raamatut. + + + %1$d silt + %1$d silti + + + Kõik raamatud %1$d + Kõik raamatud %1$d + + + Riiulid %1$d + Riiulid %1$d + + + Sildid %1$d + Sildid %1$d + + + Kaustad %1$d + Kaustad %1$d + + + (%1$d osa) + (%1$d osa) + diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..0522653 --- /dev/null +++ b/app/src/main/res/values-et/strings.xml @@ -0,0 +1,1522 @@ + + + Katkesta + Salvesta + Kustuta + Eemalda + OK + Sulge + Lisa + Muuda nime + Tagasi + Otsi + Tühjenda + Rakenda + Luba + Viga: %1$s + Mine tagasi + Vaba + Aktiivsed vahelehed + Kuva vahekaardid ülemisel rakenduseribal + Sule vahekaart + Sulge kõik vahelehed + Kas sulgeda kõik vahelehed? + Kas sulgeda kõik aktiivsed vahelehed? + %1$s nõustud meie %2$s ja kinnitad, et oled lugenud meie %3$s. + Kasutustingimused + Privaatsuspoliitika + Litsentsid + %1$d valitud + Tühjenda valik + Kinnita/vabasta + Info + Vali kõik + Eemalda hiljutiste hulgast + Hoiatus: osa valitud üksusi on sünkroonitud kohalikust kaustast. Jätkamisel kustutatakse tegelikud failid seadme mälust.\n\nSeda toimingut ei saa tagasi võtta. + Faili teave + Raamatu nimi + Kopeeri nimi + Algne nimi: %1$s + Taasta algne nimi + Faili nimi: %1$s + Autor + Vorming + Suurus + Lisatud + Asukoht + Allikas: OPDS-voog + Rakendusesisene salvestusruum + Sisemälu + Teave Episteme kohta + Versioon: %1$s (järk: %2$d) + Vali fail + Kas kustutada kõik sünkroonitud andmed? + Kas kustutada kõik raamatuandmed pilvest jäädavalt? See tühjendab uuesti sünkroonimise vältimiseks ka kohaliku raamatukogu. Seda toimingut ei saa tagasi võtta. + KUSTUTA KÕIK ANDMED + Kodu + Raamatukogu + Viimased failid + Raamatukogu on tühi + Vali lugemiseks fail või sünkrooni kohalik kaust, et raamatud automaatselt importida. + Viimaseid faile pole + Ava fail raamatukogust, et seda siin näha. + Kausta sünkroonimise seadistamine + Sünkrooni kaust + Kohalik kaust + Kinnitatud + %1$d%% täielik + Pole kohapeal saadaval + Logi Google’iga sisse + Sisse logides + Episteme Pro + Uuenda versioonile Episteme Pro + Sünkrooni raamatukogu + Pilvesünkroonimine kohalike kaustade jaoks + Laadi sünkroonitud kaustade raamatud Google Drive’i üles. + Kohandatud fondid + Toeta projekti + Abi ja tagasiside + Logi välja + Viimaste failide limiit + Piiramata + %1$d failid + Tühjenda raamatu vahemälu + Tühjenda reflow-vahemälu + Raamatukogu + Otsi pealkirja või autorit… + Tüübid: %1$s + Kaustad: %1$d + Olek: %1$s + Kõik raamatud + Riiulid + Kaustad + Kataloogid + Päringule \"%1$s\" ei leitud tulemusi + Alustamiseks vali seadmest PDF-, EPUB-, MOBI- või AZW3-fail. + Lisa fail + Uus riiul + Loo uus riiul + Riiuli nimi + Loo + Nimeta riiul ümber + Kustuta riiul + Lisa raamatuid + See riiul on tühi + Lisa %1$s + LISA (%1$d) + Pole ühtegi riiulita raamatut, mida lisada + Kõik raamatud on juba sellel riiulil + Nimeta riiul ümber + Kas kustutada riiul? + Kas kustutada riiul "%1$s"? Kõik raamatud teisaldatakse riiulita raamatute alla. + Kas eemaldada riiulist? + Kustuta %1$s? + Kas kustutada %1$d valitud %2$s? Kõik sees olevad raamatud teisaldatakse riiulita raamatute alla. + Sünkrooni kohalikud kaustad + Ühenda kohalikud kaustad, et luua reaalajas raamatukogu. Episteme jälgib faile ja sünkroonib lugemisjärge. + Lisa kaust + Skanni kõik + Skannimine… + Sünkrooni meta + VIIMANE SÜNKROON + RAAMATUD + Redigeeri filtreid + Eemalda kaust + Keela kohalik sünkroonimine + Luba kohalik sünkroonimine + Kohalik sünkroonimine on keelatud + Kas keelata kohaliku kausta sünkroonimine? + Episteme lõpetab selle kausta skannimise ja sünkroonimise JSON-failidesse kirjutamise. Kas eemaldada kaustast ka %1$s kaust? + Hoia sünkroonimisandmed + Eemalda sünkroonimisandmed + Filtreeri failitüübid + Vali failitüübid, mida sellest kaustast sünkroonida: + Filtreeri raamatukogu + Faili tüüp + Allikakaust + Lugemise olek + Tühjenda kõik + Rakendusesisene salvestusruum + Kas salvestada fail? + Kas salvestada see väline fail rakenduse raamatukokku? Kui mitte, eemaldatakse see.\n\n(Seda vaikekäitumist saad igal ajal muuta: avakuva > Rohkem valikuid > Välise faili käitumine.) + Ära küsi uuesti + Hoia raamatukogus + Eemalda + Küsi iga kord + Hoia alati + Eemalda alati + Välise faili käitumine + Lisa kataloog + Otsi kataloogist… + See voog on tühi. + Allalaadimine… + Laadimine… + Voog + Pole saadaval + Laadi alla + Laadi alla vorming + Voogedasta kohe + Loe + Toetatud vorminguid pole saadaval. + VÄLJAANDJA + AVALDATUD + KEEL + Sisukokkuvõte + Redigeeri kataloogi + Lisa OPDS-kataloog + Kataloogi nimi + URL + Autentimine (valikuline) + Kasutajanimi + Parool + Kustuta kataloog + Kas oled kindel, et soovid kustutada \'%1$s\'? + Selle kataloogi kustutamisel eemaldatakse raamatukogust jäädavalt ka %1$d sellega seotud voogedastatud raamatut. + Eelseadistatud + Tasuta plaan + Igavesti tasuta + Mitu vormingut + Toetab vorminguid PDF, EPUB, MOBI ja AZW3 + Androidi tekst kõneks + Kuulake oma raamatuid sisseehitatud TTS + Põhisõnastik + Otsige kiiresti üles üksikud sõnad + Praegune plaan + Hinna laadimine… + Ühekordne makse + Eluaegne juurdepääs + Varajase juurdepääsu müük + Omadused: + Pilvesünkroonimine seadmete vahel + Hoia kogu oma kogu, sealhulgas raamatufailid ja lugemised, sünkroonituna kuni neljas seadmes. + Kokkuvõte + Saate päevas 10 tasuta kokkuvõtet peatükkide või lehtede kohta + Nutikas sõnastik + Otsige fraase ja isegi lõike, mitte ainult üksikuid sõnu + Prioriteetsete funktsioonide taotlused + Sinu ettepanekud seatakse prioriteediks + Pro funktsioonid on lukustamata! + Sisselogimine Nõutav + Ostu kinnitamine… + Olemasolev ost leitud + Hangi eluaegne juurdepääs + Uuendamine pole praegu saadaval. Kontrollige oma Internetti ja proovige uuesti. + Logi sisse oma Google konto ostmiseks Episteme Pro. + Logi sisse oma Google konto krediidi ostmiseks. + See võib võtta mõne hetke. Pro-olekut värskendatakse automaatselt. + Selles seadmes on juba Pro-ost, kuid see on seotud teise kontoga. Pro-funktsioonide taastamiseks logi sisse kontoga, millega algne ost tehti. + Te\'toodate Episteme Pro meie varase juurdepääsu perioodil erisoodushinnaga! See on piiratud aja pakkumine. + Logi Google’i kontoga sisse, et osta Episteme Pro ja avada kõik premium-funktsioonid. + Mitte praegu + Selge! + Kohandatud fondid + Importi font + Google Fondid + Sirvi Google Fondid + Otsige üle 1900 fondi… + Populaarsed valikud + No fonts found matching \'%1$s\' + Juba alla laaditud + Kohandatud fonte pole + Impordi TTF- või OTF-faile, et neid oma raamatutes kasutada. + Eelvaade pole saadaval (kehtetu fondifail) + Kas kustutada font? + Kas kustutada "%1$s"? Kui sünkroonimine on sisse lülitatud, eemaldatakse see kõigist seadmetest. + Kas kustutada fondid? + Kas kustutada %1$d valitud fonti? Kui sünkroonimine on sisse lülitatud, eemaldatakse need kõigist seadmetest. + Võtke ühendust + Leidsid vea, sul on funktsioonisoov või tahad lihtsalt tere öelda? Anna GitHubis teada või saada meile e-kiri. + GitHubi probleemid + Teatage vigadest, taotlege funktsioone ja jälgige arenduse edenemist. + Meili tugi + Muude päringute korral võtke meiega otse e-posti teel ühendust. + Toeta projekti + Aidake hoida Episteme liigub + Sinu tugi aitab Epistemet kõigi jaoks hoida ja täiustada. + Sponsor GitHubis + Toeta arendust otse GitHubi sponsorite kaudu. Tänutäheks saate projekti repos README hüüdlause. + Liituge Patreoniga + Tänutäheks rakenduse toetamise eest saavad Patreoni toetajad lisasisu ja -hüvesid: pilkupüüre sellest, millega ma töötan, varasemaid ekraanipilte ja värskendusi, hääli, mis aitavad kujundada, kuidas uued funktsioonid peaksid välja nägema ja töötama, ning README-hüüde projekti repos. + Ava Episteme Pro + Seadmetevaheline sünkroonimine on Pro-funktsioon. Ava kõik Pro-funktsioonid ühe ühekordse ostuga. + Uuendage + Kinnitage väljalogimine + Kas logida välja? + Seadme limiit on saavutatud + Episteme Pro kasutamiseks selles seadmes eemalda üks olemasolevatest registreeritud seadmetest. + Viimati nähtud: %1$s + Kinnitage hävitav tegevus + See kustutab jäädavalt kõik raamatud ja lugemisjärje sellest seadmest ning Google Drive’i kontolt. Seda toimingut ei saa tagasi võtta. Kas jätkata? + Tühjenda raamatu vahemälu + See kustutab kõik töödeldud lehed lehekülgede muutmise režiimis. See aitab lahendada paigutusprobleeme, kuid järgmisel korral tuleb raamatute avamisel uuesti töödelda. + Kinnita ja kustuta + Tühjenda reflow-vahemälu + See kustutab kõik loodud PDF-ide tekstivaate versioonid ja tühjendab nendega seotud piltide/HTML-i vahemälu. Algsed PDF-id jäävad puutumata. + Tagasi + Sõnastik + Rohkem valikuid + Lehekülje liugur + Sisukord + Tekstivorming + Otsi + AI Omadused + Käivitage kõneks muutmine + Peatage kõneks muutmine + Paus + Jätka + Luba tume režiim + Keela tume režiim + Lukusta panoraam + Ava panoraamimine + Täisekraan + Kuva esiletõstmised + Peida esiletõstmised + Märkuste režiim + Välju annotatsioonirežiimist + Sule otsing + Tühjenda otsing + Näita tulemusi + Peida tulemused + Eelmine tulemus + Järgmine tulemus + Väljuge lugejast ja naaske avakuvale + Vali sõnade otsimiseks eelistatud rakendus + Juurdepääs lugemisrežiimile, järjehoidjatele ja täpsematele seadetele + Lohistage, et hüpata kiiresti dokumendi mis tahes lehele + Sirvige peatükke ja navigeerige mis tahes jaotisesse + Reguleerige fonti, suurust, rea kõrgust, joondamist ja kohandatud fonte + Otsige sellest raamatust üles mis tahes sõna või fraas + Tehke praegusest peatükist või leheküljest kokkuvõte, kasutades AI + Loe raamatut ette oma seadme\'s häälemootori abil + Peatage praegune ettelugemise seanss + Peatage praegune etteloetud taasesitus + Jätka peatatud ettelugemist + Inverteerida PDF värvid tumeda režiimi jaoks + Keela tume režiim ja taasta originaal PDF värvid + Lukusta lehel horisontaalne panoraamimine + Ava panoraamimine, et lubada uuesti suumimis- ja lohistusliigutused + Peitke kõik kasutajaliidese juhtnupud, et näha kaasahaaravat ja häireteta lugemisvaadet + Märkige visuaalselt valitud tekstipiirkonnad praegusel lehel + Eemalda lehelt valitav tekstiülekate + Lisage tinti või tekstimärkusi + Lõpetage redigeerimine ja naaske tavalisse lugemisvaatesse + Väljuge otsingust ja minge tagasi lugeja juurde + Kustutage praegune otsingupäring ja alustage otsast peale + Laienda paneeli, et näha kõiki otsinguvastuseid + Ahenda otsingutulemuste paneel + Hüppa dokumendis eelmisele otsingu vastele + Hüppa dokumendis järgmise otsingu vaste juurde + Logi sisse + Vali kaust + Vali + Privaatsuspoliitika • Kasutustingimused • Litsentsid + Sinu seade ei toeta kaustade valimist. Faile saab endiselt ükshaaval importida. + Failihaldurit ei leitud. Installige failihalduri rakendus. + Allalaaditud %1$s + %1$s: %2$s + Mitte kunagi + %1$s: %2$d + Eemaldatud %1$d voogesituse raamatud. + Fondi importimine ebaõnnestus: %1$s + Tekstivaade kustutatud. + Sünkroonimiseks on vaja Google Drive luba. + Ostmisel ilmnes viga. + Uuendamine õnnestus! Tere tulemast Pro-sse. + Ostu kinnitamine ebaõnnestus. Kui teilt võeti tasu, võtke ühendust klienditoega. + See seade eemaldati sinu kontolt. + Seda seadet ei saanud kinnitada. Palun kontrollige oma ühendust. + Seadmete värskendamine ebaõnnestus. Palun proovi uuesti. + Säästmine PDF… + PDF edukalt salvestatud. + Faili avamine salvestamiseks ebaõnnestus. + Viga salvestamisel PDF: %1$s + Originaali salvestamine PDF… + Originaal PDF edukalt salvestatud. + Algse faili salvestamine… + Algne fail salvestati. + Faili salvestamisel tekkis viga: %1$s + Jagamine: %1$s + Jaga PDF + Jaga faili + Jagamine ebaõnnestus: %1$s + Lõikelauale kopeerimine nurjus + Parooliga kaitstud PDF-faile ei saa printida + Limiit saavutatud: maksimaalne %1$d kaustad lubatud. + See kaust on juba sünkroonitud. + Lisatud kaust: %1$s + Juurdepääs kausta lubadele ebaõnnestus. + Kaust eemaldatud. + Kaustade sünkroonimine: metaandmete värskendamine… + Kausta skannimine uute raamatute jaoks… + Kausta sünkroonimine: skannimine on lõpetatud. + Sünkroonimine ebaõnnestus. + Üheski kohalikus kaustas pole sünkroonimine lubatud. + Kohaliku kausta sünkroonimine on keelatud. + Kohaliku kausta sünkroonimine on keelatud. Sünkroonimisandmete kaust on eemaldatud. + Kohaliku kausta sünkroonimine on keelatud, kuid sünkroonimisandmete kausta ei saanud eemaldada. + Kohaliku kausta sünkroonimine on lubatud. + Failide allalaadimiseks lubage sünkroonimine. + Allalaadimine ebaõnnestus %1$s. + Pilveandmete kustutamiseks lubage sünkroonimine. + Pole sisse logitud, ei saa pilvandmeid kustutada. + Kõigi pilve- ja kohalike andmete kustutamine… + Kõik pilve- ja kohalikud andmed kustutati edukalt. + Viga: kõigi andmete kustutamine ebaõnnestus. + Kõik kohalikud andmed kustutati. + Sisselogimine ebaõnnestus. Palun proovi uuesti. + Ei leitud Google konto. See võib juhtuda värske installi korral, proovige mõne aja pärast uuesti. + Sisselogimisel ilmnes viga. Kontrollige oma Interneti-ühendust. + Seadmehalduse testimiseks logi sisse. + Sünkroonimine on Episteme Pro funktsiooni. + Pole sisse logitud, ei saa sünkroonida. + Pilvesünkroonimine: värskenduste otsimine… + Pilvesünkroonimine: lõpetatud. + Teegi sünkroonimine ebaõnnestus. + Viimast üksust ei leitud. + Faili importimine ebaõnnestus. + Faili asukohta ei leitud. + Seda failitüüpi ei toetata. + Loodud tekstivaate laadimine ebaõnnestus. + Tekstivaate genereerimine ebaõnnestus. + FB2 laadimine ebaõnnestus: %1$s + Faili laadimine ebaõnnestus: %1$s + Laadimine ebaõnnestus MOBI: %1$s + Laadimine ebaõnnestus EPUB: %1$s + Fail kustutati kaustast. Teegist eemaldatud. + Selle nimega riiul on juba olemas. + Kustutamine kõigist seadmetest… + Kustutamine lõpetatud. + Pilvesünkroonimine ebaõnnestus, kustutati kohapeal. + Vahemälu ja loodud tekstivaated on kustutatud. + Otsi raamatust… + Tulemusi ei leitud. + Kokkuvõtte genereerimine… + Peatus + Loe ette + Kopeeri + Kopeeri lõim + Kokkuvõtet ei saanud luua. + Mõeldes… + Ava sõnastikurakenduses + AI ei osanud definitsiooni anda. + Küsib AI umbes \'%1$s\'… + Kõnesünteesi seaded + Seadete muutmiseks peatage taasesitus. + Sünteesirežiim + Seadmes + Pilv (HQ) + Hääle valik + Esita näidis + Seadme hääleseaded + Sulgege seaded + Süsteemi vaikeseade + Vastab sinu Android süsteemi seaded + Valitud + Häälte laadimine… + Selles seadmes pole hääli saadaval. + Konkreetsed hääled + Saadaolevad hääled (%1$d) + Selle keele jaoks ei leitud ühtegi häält. + Variant: %1$s + See on näidis %1$s. + Lugemise teemad + Eelseaded + Tekstuuriga eelseaded + Minu teemad + Minu tekstureeritud teemad + Kohandatud teemasid veel pole. Puudutage \'+\' ühe loomiseks. + Kohandatud tekstuuriga teemasid pole veel. + Uus teema + Uus tekstureeritud teema + Redigeeri teemat + Teema nimi + Nii palju raamatuid, nii vähe aega. + ⚠️ Madal kontrast! See võib põhjustada silmade väsimust. + Lehekülje värv + Teksti värv + Tekstuur + Mitte ühtegi + Laadi üles + Tekstuuri läbipaistvus + Reaalajas eelvaade + Lugemine on unistamine. + Tekst on tühi. + AI tagastas tühja definitsiooni. + Ei saanud definitsiooni. + Ilmnes tundmatu serveriviga. + Võrgu viga. Kontrollige ühendust. + Kokkuvõtteks ei piisa kontekstist. + Kokkuvõtte sõelumine ebaõnnestus. + Võrguviga kokkuvõtte loomisel. + Otsi seaded + Sõnaraamat Mootor + Nutikas (AI) + Väline rakendus + Kasutab AI määratluste jaoks. Võrguühenduseta või kui valitud fraas on liiga pikk, naaseb allolevasse välisrakendusse. + Kasutab valitud rakendust sõnastikust otsimiseks. + Varurakendus + Sõnastiku rakendus + Vali rakendus + Tõlgi + Rakendus, mida kasutatakse valitud teksti tõlkimiseks. + Otsi rakendust + Rakendus, mida kasutatakse veebiotsinguteks. + Mitte ühtegi + Raamatu sisu on tühi. + Serveri vastuse kokkuvõtte sõelumine ebaõnnestus. + Kokkuvõtet ei õnnestunud tuua. + Viga: %1$d. %2$s + Võrgu viga. Kontrollige ühendust ja serveri olekut. + Peatüki analüüsimine %1$d… + Praeguse asukoha lugemine… + Kokkuvõtte genereerimine… + Peatüki kokkuvõte + Loo kokkuvõte (beeta) + Ava peatüki kokkuvõte + Saate mis tahes peatüki lühikokkuvõtteid kasutades Episteme Pro. Selle funktsiooni kasutamise alustamiseks uuendage. + Lisateave + Ava nutikas sõnastik + Tervete fraaside ja lõikude määratlemine kuni 2000 tähemärgini on Pro funktsioon. Täiendage, et saada mis tahes valitud teksti jaoks kohesed määratlused. + Järjehoidja + Valitud pesa + Kohandage palett + Puudutage muutmiseks pesa: + Vali pesa värv: + See peatükk on tühi. + Peatükki ei leitud + Viga peatüki laadimisel + Sõnastiku sätted + Teema + Teema seaded + Rohkem valikuid + Vaata originaali PDF + Kustuta tekstivaade + Vertikaalne + Vertikaalne (veebivaade) + Vertikaalne (native beeta) + Leheküljed (vasakult paremale) + Lubatud + Eemalda järjehoidja + Lisa see leht järjehoidjatesse + Lehekülgede pööramiseks puudutage + Helitugevuse nupu kerimine + Helitugevuse nupp Lehekülje pööramine + Realistlikud leheküljepöörded + Hoia ekraan sees + Visuaalsed valikud + Ekraani suund + Muutke lugemisrežiimi + Leheküljed (paremalt vasakule) + Automaatne kerimine + TTS Seaded + TTS Hääleseaded + TTS Sõnade asendused + Sõnaasenduste raamat + Jagage, salvestage või printige + TTS Seaded (silumine) + Liikuge liuguriga + Peatükkide menüü + Teksti vormindamine + Peatükk Kokkuvõte + Kokkuvõte (beeta) + Peatus TTS + Alusta TTS + Paus TTS + Jätka TTS + Eelmine TTS tükk + Järgmine TTS tükk + Välju liuguriga navigeerimisest + Avalehe pisipilt + Laienda + Ahenda + Mängi + Kohalik kiirus + Globaalne kiirus + Vali režiim + Kehtib kõikidele failidele + Salvestatud ainult selle faili jaoks + Keela muusiku režiim + Lubage muusiku režiim + Vahetage juhtnuppe + Min + Max + Aeglasem + Kiiremini + Vähendada + Suurendada + Lehekülg %1$d of %2$d + Peatükid + Vahekaardid + Järjehoidjad + Esiletõstmised + Leheküljed + Pildid + Laienda kõik + Ahenda kõik + Otsige üles + You haven\'t added any bookmarks yet. + Pilte ei leitud. + Laadi pilt alla + Rohkem valikuid järjehoidja jaoks + Nimeta järjehoidja ümber + Uus nimi + Uus pealkiri + Kas kustutada järjehoidja? + Kas oled kindel, et soovid selle järjehoidja jäädavalt kustutada? + Esiletõsteid veel pole. + Tundmatu peatükk + Valikud + Kas kustutada esiletõst? + Kas kustutada see esiletõst jäädavalt? + Salvestatud %1$s + Pilti ei saanud salvestada. + Originaal PDF ei leitud. + Viga: raamatu sisu ei leitud. Tee: %1$s + Vali esmalt sõnastikurakendus. + Vali esmalt tõlkerakendus. + Vali esmalt otsingurakendus. + Selle raamatu jaoks pole peatükke saadaval. + Navigeerimine asukohta… + Nõutav luba + Taasesituse juhtnuppude kuvamiseks, kui rakendus töötab taustal, andke teatise luba. + Jätka + Põhjendatud teksti piirang + Põhjendatud joonduse kasutamine leheküljelises režiimis võib küljenduse piirangute tõttu muuta teksti valiku ja esiletõstmised ebatäpseks. + ma saan aru + Peatükki navigeerimine… + Vali esmalt võrguühenduseta sõnastik. + Raamat pole veel lehekülgedega varustatud. + Oodake, kuni raamat on täielikult laaditud. + Eelmise peatüki väljalase + Väljalase järgmise peatüki jaoks + Tõmmake edasi… (%1$d%%) + Peatükk + Peatüki sisu ei õnnestunud hankida. + Praegust peatükki ei õnnestunud määrata. + WebView pole saadaval. + Lehekülg %1$d/%2$d + Kohalik formaat + Globaalne formaat + Lähtesta + Suurus + Vahekaugus + Vali Font + Eelseaded + Imporditud + Import failidest + Pole veel imporditud fonte. + Visuaalsed valikud + Lehekülje paigutus + PDF leht laiali + Üks leht + Kaks lehte + Esimene leht üksi + Alustab esikülje laialivalgumist pärast kaanelehte. + Eemalda lehtede vahe + Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel. + Peida lehenumbri ülekate + Eemaldab igalt lehelt väikese lehekülgede arvu sildi. + Süsteemi kasutajaliides (oleku- ja navigeerimisribad) + Kontrollige seadme\'-süsteemi ribade nähtavust. + Ekraani suund + Vali, kas lugeja järgib süsteemi orientatsiooni või eelistab vertikaalset või horisontaalset, kui Android lubab seda. + Edenemisriba + Lugemise edenemise ja peatüki indikaator lugemisekraanil. + positsioon + Peatükkide sujuv üleminek + Laadi lõpust mööda kerides kohe järgmine/eelmine peatükk, ilma tõmmake värskendamiseks animatsioonita. + Eemalda servapolsterdus + Eemaldab horisontaalse vahe vasakust ja paremast servast. + Heledus + Kasutage süsteemi heledust + Järgib seadme heleduse seadistust. + Kohandatud heledus + Kehtib, kui lugejaekraan on avatud. + %1$d%% + Otsi + Viga sõnastiku avamisel + Tõlkerakenduse avamisel tekkis viga + Viga otsingurakenduse avamisel + Avatud lähtekoodiga versioon + Playstore\'i versioon + Versioon %1$s + Ehitamine %1$s + Sirvige lähtekoodi, tärniga, kahvliga ja teatage probleemidest. + Kuidas me sinu andmeid käsitleme. + Kasutustingimused. + Kasutatud avatud lähtekoodiga teegid. + Importimine %1$d raamatud… Need ilmuvad peagi sinu kogusse. + Loodi riiul "%1$s". + Loodi nutikas riiul "%1$s". + Riiul nimetati ümber "%1$s". + Kustutatud riiul "%1$s". + Uuendatud "%1$s". + Need failid on juba raamatukogus. + %1$s - %2$s + Väline link + You clicked on an external link:\n\n%1$s\n\nWhat would you like to do? + Avatud + Lingi avamiseks ei leitud ühtegi brauserit. + Salvesta märkus + Salvesta + Salvesta kommentaar + Lisa kommentaar + Vasta + Lisa märge… + Lisa kommentaar… + Dikt + Rääkige + Märkus + Kommentaarid + Kommentaari redigeerimine + Vastamine %1$s + Muuda + FONT JA JOONDAMINE + PAIGUTUS JA VAHUMUS + Fondi suurus + Joone kõrgus + Lõigu lünk + Pildi suurus + Horisontaalne marginaal + Vertikaalne veeris + Mitte ühtegi + Orig + Hääle reguleerimine + Kiirus (%1$sx) + Kõrgus (%1$sx) + Nii kõlavad sinu praegused hääleseaded. + Peata raamat + Jätkamise raamat + Süsteemi hääle/mootori sätted + Globaalne kiirus + Kohalik kiirus + Kehtib kõikidele failidele + Salvestatud ainult selle faili jaoks + Kerige üles + Kohandage tööriistariba + Vali tööriistad, mida nähtavana hoida. Tööriista märke eemaldamine peidab selle kasutajaliidesest, et lugemisruum oleks häirimatu. + Märkused + Kõik + Märkmetega + Lisa märkus + Redigeeri märkust + %1$d / %2$d + Lehekülg %1$d of %2$d + Sulgege redigeerimisrežiim + Lülitage nähtavus sisse + Ainult pliiatsi režiim + Pliiats + Esiletõstja + Tekst + Kustutuskumm + Võta tagasi + Tee uuesti + Näita dokki + Vali fondipere + Vali Fondi suurus + Fondi taust + Paks + Kursiiv + Allajoonimine + Läbikriipsutatud + Sisesta tekstikast + OCR valiku viga: %1$s + Valiku viga: %1$s + Viga lehe töötlemisel: %1$s + Lehte %1$d ei saa kuvada. + Prindisätteid ei saanud avada + Laadimine PDF… + Viga laadimisel PDF + Lehe laadimine… + PDF Vaataja + Pliiatsi mänguväljak + Impordi SVG + Imporditud %1$d SVG lööki! + SVG importimine ebaõnnestus või tühi. + OCR Keel + Sisesta tühi leht + Kustuta leht + Tekib… %1$d%% + Ava tekstivaade + Loo tekstivaade + Jaga + Salvesta koopia seadmesse + Prindi + Tekstivaate genereerimine… + Lehtede indekseerimine… %1$d%% tehtud. Otsingutulemused värskendatakse automaatselt. + Tulemused leitud %1$d+ lehekülgedel + Tulemus %1$d / %2$d + %1$d+ Lehekülgi + Lehe kokkuvõte (lehekülg %1$d) + Allalaadimine %1$s keelepakett… + Vali OCR Keel + Paremate tekstituvastustulemuste saamiseks vali selle dokumendi peamine keel/kiri. + Saate seda hiljem muuta jaotises Rohkem valikuid > OCR Keel. + Kas dokument uuesti indekseerida? + Muudad OCR-i kirja väärtuseks %1$s.\n\nOtsingu täpsuse tagamiseks peame olemasoleva indeksi tühjendama ja OCR-i vajavad lehed selle uue keelega uuesti skannima.\n\nSee toimub taustal. + Indekseeri uuesti + Parooliga kaitstud + See dokument on krüpteeritud. Selle vaatamiseks sisestage parool. + Parool + Vale parool + Peida parool + Näita parooli + You are about to navigate to:\n%1$s + Külastage + Salvesta seadmesse + Vali salvestamiseks vorming: + Koos märkustega + Originaal + Vali jagamiseks vorming: + Ettevalmistus PDF… + Lisa PDF vahekaardile + Teisi PDF-e sinu teegist ei leitud. + PDF on tühi või seda ei saa kuvada. + Leht lisatud aadressil %1$d + Leht kustutatud + Lisaleht eemaldatud + Peatükid pole selle raamatu jaoks saadaval. + Esiletõstetud jaotis + Sirge joon + Spekter + Valmis + Punane + Roheline + Sinine + Süsteemi vaikefont + Import + Ühtegi fonti ei imporditud + Fondi värv + Tõstke esile + Mitme vahekaardi lugemise lubamine + Kasutage ranget failifiltrit + Kasutage PDF Failinimed + Keel + Testpaneeli ML tuvastamine + Testige kõnemulli ML tuvastamist + Ekspordi logid (viimased %1$d read) + Luba range failifilter + If you enable this, some supported file types like AZW3, CB7, and FB2 might not show up depending on your file manager.\n\nAre you sure you want to enable this filter? + Süsteemi vaikeseade + English (inglise) + English (vaikimisi) + العربية (araabia) + Deutsch (saksa) + türkçe (türgi) + Français (prantsuse) + Русский (vene) + Беларуская (valgevene keel) + español (hispaania) + Português (Brasiilia) + Italiano (itaalia) + polski (poola) + Tiếng Việt (vietnami) + 日本語 (jaapani keel) + 한국어 (korea) + हिन्दी (hindi) + 简体中文 (hiina, lihtsustatud) + Eesti + Rakenduse teema + Välimus + Kontrast + Teksti heledus + Värviskeem + Dünaamiline + Loo rakenduse teema + Kohandatud teema + Ookean + Mint + Roos + Seepia + Ametüst + Merevaik + Safiir + Lisa kohandatud teema + Rakenduse teema + Rakenduse ikoon + Seade + Ava sahtel + Profiilipilt + Profiil + Pro funktsioon + Filter + Sorteeri + Sule otsing + Kustuta päring + Otsi riiul + %1$s riiuli kate + Säilitage pildi värvid + Teema muutumisel säilitage pildi algsed värvid + Riiulita + Kõik raamatud + Süsteem + Valgus + Tume + Standardne + Keskmine + Kõrge + Viimased + Pealkiri A-Z + Autor A-Z + Valmisoleku protsent 0–100 + Valmisoleku protsent 100–0 + Suurus (väikseim) + Suurus (suurim) + Kõik + Lugemata + Käimas + Lõpetatud + Sildid: %1$s + Sirvige sildi järgi + Sildid + Kaustad + Failid + Tekst kõneks + Taasesituse juhtnupud teksti kõneks muutmiseks. + Teksti kõneks ettevalmistamine + Ettevalmistus: %1$s + Aktiivne TTS Mootor + Pilv AI + Seadme algseade + Pilve hääled + Seadme hääled + Pilve vahemälu + Vali Kvaliteetne pilvehääl + Puhasta proovid + Süsteemi vaikehääl + Kasutab seadme sätteid + Keelefilter + Internetis + Võrguühenduseta + Häälefilter + Selle hääle jaoks pole vahemällu salvestatud heli. + Tühjenda vahemälu %1$s + See on häälenäidis. + AI Omadused + Kokkuvõte + Kokkuvõte + Vahemälu + Puudub kokkuvõte %1$s veel. + Loo kokkuvõte: %1$s + Tehke loo kokkuvõte kuni oma praeguse positsioonini. + Loo kokkuvõte + Loo kokkuvõte + Vahemälu tabamus • Tasuta + Loodud • Tasuta (%1$d/10 jäänud) + Tekitatud • Kulud: %1$s krediiti + Loomine… • Maksumus: arvutamine + AI Väljund + Regenereerida + Selle raamatu kohta pole vahemällu salvestatud kokkuvõtteid. + Krediidid + AI & Cloud Credits + Krediidid on saadaval + %1$d Krediidid + Hinnanguline kulude jaotus + Pilv TTS + Cost: ~3–4 credits per minute of audio generated.\nTo enable: Reader Screen > More > TTS Voice Settings. + AI Kokkuvõtted ja kokkuvõte + Cost: ~1–4 credits per request based on chapter length.\nPro Users get 10 free summaries daily. + Ostes, + Krediidid otsas + Teil ei ole piisavalt krediiti\' Hangi Episteme Pro 10 tasuta kokkuvõtet päevas või lisage krediiti, et kasutada kokkuvõtteid, Cloud TTS ja loo kokkuvõte. + Hangi Pro / lisa krediiti + Ava lehe kokkuvõte + Hangi Episteme Proga täpseid kokkuvõtteid mis tahes lehekülje kohta. Uuenda, et seda funktsiooni kasutada. + Laadi alla Bubble Zoom mudel + Bubble Zoomi kasutamiseks tuleb alla laadida AI-mudel (~134 MB). Kas laadida see kohe alla? + Tõlgi + Tagasi lk %1$d + Lehekülg %1$d + Tulemus %1$d / %2$d + Laadimine ebaõnnestus PDF. + Bubble Zoom mudeli allalaadimine… %1$d%% + Välju liuguriga navigeerimisest + Hüppa tagasi + Hüppa edasi + Kerige lugemislehele + Märkustega leht + Sule pilt + Otsingu esiletõstude sisse- ja väljalülitamine + Tekstikasti teisaldamiseks lohistage + Failide ikoon puudub + Kopeeri %1$s + Tag + Loendi üksuse marker + Lähtestage suum + Loo demomärkusi + Demo annotatsioonid + Ava pliiatsi mänguväljak + Uus vaheleht + Tõstke esile kogu tekst + Lülitage redigeerimisrežiim sisse + Nutikas koomiksisuum + Esiletõstete kohandamine + inglise, hispaania, prantsuse jne. + hindi, marati, sanskriti + inglise keel + hiina + inglise keel + jaapani + inglise keel + korea + inglise keel + Leht pole saadaval + Dokument + Loodud + %1$s (Tekstivaade) + %1$s (Taasvoolamine) + Lisa / Muuda + Silte pole määratud. + Rakenda sildid + Otsige või looge silt… + Loo \"%1$s\" + Peatüki muutmiseks tõmmake kaugust + Lühike + Pikk + Kiirus: %1$sx + Kõrgus: %1$sx + Esita/Paus + Lähtestage kiirus + Lähtesta helikõrgus + Vali Värv + Sellel raamatul pole kuvatavat sisu. + Kopeeritud link + Kopeeritud tekst + Sisukord + Järjehoidja + Hüppa tagasi lehele %1$d + Tagasi eelmisele lehele + Välju Smart Zoom + Nutikas koomiksisuum + Lülitage nutikas koomiksisuum sisse + Ekraani jäädvustamise kaitse + Ekraani jäädvustamise kaitse on sisse lülitatud + Ekraani jäädvustamise kaitse on välja lülitatud + Seaded + Muuda + Taasta + %1$s valitud + Lugeja vaikeseaded + PDF-spetsiifiline OCR, annotatsioon ja tööriista seaded jäävad PDF lugeja. + AI Definitsioon + Peatükk %1$d + Asukoht + Kohandatud font + Ilmnes viga: %1$s + Viga dokumendi laadimisel: %1$s + OCR sellelt lehelt teksti ei leitud. + Leht näib olevat tühi või teksti ei saa välja tõmmata. + Tühja lehe kokkuvõtet ei saa teha. + Dokumenti ei laaditud. + AI funktsioonid pole võrguühenduseta saadaval OSS ehitada. + Turvakaalutlustel blokeeritud. + Vali mudel %1$s AI võtmete ja mudelite sätetes. + Lisa %1$s API-võti AI võtmete ja mudelite sätetes. + AI pakkuja andis tühja vastuse. + AI pakkuja viga: %1$d. %2$s + See kokkuvõte vajab Gemini mudelit, kuna valitud Groqi mudelid ei toeta PDF/pildisisendit. + Tagasiside kasutamiseks peate olema sisse logitud. + Tagasiside saatmiseks peate olema sisse logitud. + Piletite jaoks on lubatud kuni 3 pilti. + Ühe sõnumi kohta on lubatud kuni 5 pilti. + Üks või mitu pilti ületavad 5 MB piirangut. + Pileti loomine ebaõnnestus: %1$s + Saatmine ebaõnnestus: %1$s + Voo laadimine ebaõnnestus: %1$s + Tühi keha + Allalaadimine ebaõnnestus: %1$s + Allalaadimisviga: %1$s + Ost ebaõnnestus: %1$s + Arveldusteenusega ei õnnestunud ühendust luua. + Tooteid ei leitud. + Toodete päringu esitamine ebaõnnestus + Pole saadaval avatud lähtekoodiga versioonis + Teksti pole lugeda. + Viga taasesituse alustamisel. + Heli laadimine ebaõnnestus. + Taasesituse viga: %1$s + Pilv TTS ei ole konfigureeritud. + Tundmatu raamat + AI võtmed ja mudelid + Salvestatud võtmed + Lisa või asenda võti + Pakkuja + API võti + Salvesta võti + Kasutage kõigi funktsioonide jaoks ühte mudelit + Kui see on välja lülitatud, siis iga lugeja AI funktsioon kasutab oma valitud mudelit. + Kõik AI funktsioonid + Seda mudelit kasutavad nutikas sõnastik, kokkuvõtted ja kokkuvõtted. + Nutikas sõnastik + Kasutatakse valitud sõnade või fraaside määratlemisel. + Kokkuvõtted + Kasutatud EPUB kokkuvõtted ja PDF lehe kokkuvõtted. PDF/vajavad pildikokkuvõtted Gemini. + Kokkuvõtted + Kasutatakse lugude kokkuvõtete genereerimiseks. + Kasutab salvestatud Gemini võti. Ainult %1$s on praegu toetatud. + Salvesta %1$s võti? + Pärast salvestamist on nähtavad ainult esimesed 3 ja viimased 3 märki. Hiljem muutmiseks asenda või kustuta võti. + Kustuta %1$s võti? + Seda teenusepakkujat kasutavad funktsioonid lakkavad töötamast kuni uue võtme salvestamiseni. + Võti pole salvestatud + Kustuta %1$s võti + Mudel + Ühtegi mudelit pole valitud + Näita AI lugejas + Peida AI lugejas + Ühevärvilised + Tekstuuriga + Kohandatud tahke + Kohandatud tekstuuriga + Vali Kohandatud tekstuur + Teksti heledus (hele) + Teksti heledus (tume) + Vaikimisi + Vasakule + Õige + Põhjenda + Näita alati + Sünkrooni menüüdega + Peida alati + Üles + Altpoolt + Kas taastada algsed metaandmed? + See kirjutab algse pealkirja, autori, sarja ja kokkuvõtte tagasi EPUB faili. Lugemise edenemine, sildid ja märkmed ei muutu. + EPUB metaandmeid muudetud + Metaandmed saidilt EPUB faili + Rakenduses kuvatav nimi muudetud + Metaandmed failist + Metaandmed + Fail + Pealkiri + seeria + Lugemine + Faili nimi + Muudetud + Kokkuvõte + Raamatukogu sildid + Redigeeritavad metaandmed + Kuvatav nimi + Lugejas kuvatud nimi + Algne fail: %1$s + Ülemine riba + Alumine riba + Peidetud tööriistad + Rohkem menüüd + Varjatud tööriistad + Pane tööriistad siia + Lohistage ümberjärjestamiseks + Välised rakendused + Navigeerimisliugur + Heledus + Külgriba + Tõstke esile valitav tekst + Redigeerimisrežiim + TTS Juhtnupud + Lugemisrežiim + Lehekülje haldamine + Tekstivaade (ümbervoolamine) + Praegune raamat + Globaalne + See raamat + Luba asendused + Siinsed reeglid kehtivad iga raamatu kohta, välja arvatud juhul, kui need on teatud pealkirja puhul keelatud. + Lisa reegel + Lisa raamatu reegel + Globaalseid asendusreegleid pole veel. + Raamatupõhiseid reegleid veel pole. + Kasutage siin globaalseid reegleid + Lülitage see välja, kui raamat vajab oma hääldusvalikuid. + Luba raamatureeglid + Kohalikud reeglid järgivad globaalseid reegleid. + Päritud globaalsed reeglid + Pärimiseks pole globaalseid reegleid. + Selles raamatus lubatud + Selle raamatu jaoks keelatud + Ettepanekud + Uus asendus + Redigeeri asendust + Asenda + Räägi nagu + Lubatud + Terve sõna + Matši juhtum + Sisestuse eelvaade + Reeglid + vaikus + Lihttekst + tõstutundlik + Praegune raamat + Lisa reegel + Selle raamatu jaoks pole veel asendusreegleid. + Uus asendus + Redigeeri asendust + Koos + tühi tekst + Holland (hollandi) + Українська (ukraina) + Bahasa Indonesia (indoneesia) + Umbes + Lauaarvuti lugeja + Juurdepääs töölauale + konto + Konto ja krediidid + Konto ülevaade + AI jaotur + Kasutatud EPUB kokkuvõtted ja PDF lehe kokkuvõtted. + Episteme oss + Autortekst + Vahemälu: %1$s + Vahemällu salvestatud + Vahemällu salvestatud kokkuvõte + Vali Gemini hääl, mida kasutatakse pilve ettelugemiseks. + Kustutage loodud töölauaraamat ja EPUB lehekülgede vahemälu failid? Järgmisel raamatute avamisel luuakse need uuesti. + Tühjenda hääle vahemälu + Sulge tööriistad + Pilvesünkroonimine + Pilv TTS vajab Gemini + Pilv TTS vajab sisselogitud krediiti + Pilv TTS valmis + Pilv TTS seaded + Pilv TTS kättesaamatu + Pilv TTS hääl + Sisaldab + Kulude arvutamine + Tehke kokkuvõte kuni oma praeguse positsioonini. + Loo nutikas riiul + %1$d krediiti saadaval + %1$s krediiti + Imporditud fondid lugeja jaoks + Kustuta font + Kustuta %1$s? Seda kasutavad raamatud naasevad vaikefondile. + Kas kustutada \"%1$s\"? Raamatud jäävad sinu raamatukogusse. + Kustuta kokkuvõte + Keelatud + Pukseeri failid importimiseks + Eemalda importimiseks toetatud failid + Kui vajad midagi muud, võta meiega otse e-posti teel ühendust. + Võrdub + Lisad + Tagasiside + Väli + Kausta tee + Siit + Täielik skannimine + Tasuta, %1$d vasakule + Loo kokkuvõte + Loo kokkuvõte + Teata vigadest, taotle funktsioone või võta toega otse ühendust. + GitHubi sponsorid + Toeta arendust GitHub Sponsorsi kaudu. + Google sisselogimine pole selle töölauajärgu jaoks konfigureeritud. + Suurem kui + Abi + Veaaruanded, funktsioonitaotlused ja tugi + Peida + Impordi faile + Probleemid + Ava probleemide jälgija vigade ja funktsioonisoovide jaoks. + Vähem kui + Raamatukogu ja lugeja + Ükskõik milline + Raamatukogu tegevused + Rohkem + Selle raamatu kohta pole veel vahemällu salvestatud kokkuvõtteid. + Impordi TTF-, OTF- või WOFF2-faile, et neid raamatutes kasutada. + Ei leitud fonte, mis vastavad \"%1$s\" + Nr Google konto on ühendatud. + Selle jaotise kohta pole vahemällu salvestatud kokkuvõtet. + Võrguühenduseta lauaarvuti lugeja + Avatud lugejad + Avamine %1$s + Raamatukogu avamine + Operaator + Lehekülg + Parooliga kaitstud PDF + Patreon + Toeta projekti Patreonis. + Peatatud + %1$s nõuab enne avamist parooli. + Parool on nõutav või vale. + See parool ei avanenud %1$s. Sisestage PDF parool ja proovige uuesti. + protsenti + Plaan + Heli ettevalmistamine + Eelistused + Konto ja krediidid + Konto ja krediidid + Pro ei ole selle konto jaoks avatud. + Pro and credits can only be purchased from the Android rakendus. Desktop checks the same signed-in account and uses those credits for cloud TTS, summaries, recaps, and other paid AI funktsioonid. + Sign in to check your account status on desktop. + Pro on selle konto jaoks lukustamata. + Edusammud + Projekt + Lugeja + Lugeja vahelehed välja lülitatud + Lugeja vahelehed sisse lülitatud + Värskenda + Teeki lisamiseks vabastage. + Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted. + Seadete keskus + Vastab Android hide toggle for smart dictionary, summaries, and recaps. + Sünkrooni konto, Pro ja krediit + Sisse logitud + Lähtekood + Sirvige projekti allikat GitHubis. + Stop reading to change voices. + Toetus + Tugi Episteme + Contributions help keep the reader improving across Android ja töölaud. + Toetamise viisid Episteme arengut + Sünkrooni kaustad + Sünkrooni metaandmed + Sildi nimi + Märgistage valitud raamatud + Pealkirja tekst + Tööriistad + Import, sync, and app settings + Tüüp, nt. PDF + Vaade + Hääle vahemälu + Manustatud veebivaate ettevalmistamine… + Pakitud manustatud veebivaate ettevalmistamine %1$d%% + Manustatud veebivaade installitud. Taaskäivitage Episteme seadistamise lõpetamiseks. + Manustatud veebivaadet ei saanud käivitada: %1$s + Töötab… + Tööruum + Lisa riiulile + Loo esmalt riiul ja lisa siis valitud raamatud sinna. + Loo teema + Olemasolev: %1$s + Klõpsasite välisel lingil. + Redigeeri EPUB metaandmed + Vähem + …veel + Kohandatud teemasid pole veel + Nimeta rakenduses ümber + Sildid, komadega eraldatud + Tundmatu + Defineeri + Annotatsioon + Märkuste valikud + Märkuste tegemise tööriistad + Abi + Vali, milline PDF päästa. + Tühjenda hüppeajalugu + Pilv TTS ebaõnnestunud. + Lisa Gemini võti ja vali Gemini pilve-TTS jaotises AI võtmed ja mudelid. + Pilv TTS pole selle töölaua järgu jaoks konfigureeritud. + Pilve-TTS-i kasutamiseks logi Google’iga sisse. + Pilve-TTS vajab sisselogitud krediitidega kontot. Pro ja krediite saab osta ainult Androidi rakendusest. + Värv + Kommentaaride valikud + Kohandatud + See eemaldab märkuse sellelt PDF. + Kas kustutada märkus? + Dokumendi tekst + Manustatud PDF-i kommentaar + Lehe renderdamine ebaõnnestus. + Funktsioon pole saadaval + Valmis + Täitesulepea + Peida otsingutulemused + Tõstke esile värv %1$d + Highlighteri palett + Interaktsioon + Indekseerimine %1$d/%2$d lehekülgi + Märgistus + %1$d tikud + %1$d seniseid vasteid + Järgmine leht + Järgmine otsingutulemus + Märkusi veel pole + Järjehoidjaid pole veel + Ei mingit kommentaari + Vasteid pole + Indekseeritud lehtedel pole veel vasteid + Sisukorda pole + There is no text here to read. + Sellel lehel pole lugemiseks teksti. + There is no text to summarize. + Ava kommentaar + Krediidid otsas. Pro ja krediite saab osta ainult Android rakendus. + Pilve-TTS vajab töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest. + See funktsioon vajab töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest. + Kokkuvõtted vajavad töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest. + Kokkuvõtted vajavad töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest. + Panoraami + PDF-i toiming ebaõnnestus + PDF-i toimingut ei saanud lõpule viia. + PDF-i kommentaar + lk. %1$d + PDF leht %1$d + Lehekülg %1$d - %2$s + Lehekülg %1$s of %2$d + Lehekülgi %1$s of %2$d + PDF salvestatud + PDF tööriistad + Pliiats + Valiku ettevalmistamine + Ettevalmistus %1$s + Eelmine leht + Eelmine otsingutulemus + Printimisdialoog on lõppenud. + Nõutav pro + See funktsioon nõuab Pro. Pro saab osta ainult ettevõttelt Android rakendust, siis kasutab töölaud pärast sisselogimist täiendatud kontot. + Mitmesõnaline nutikas sõnastik nõuab Pro-d. Pro saab osta ainult ettevõttelt Android rakendust, siis kasutab töölaud pärast sisselogimist täiendatud kontot. + Lugeja AI funktsioonid on peidetud. + Lauaarvuti AI pole selle järgu jaoks konfigureeritud. + Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel. + Ümmargune highlighter + Salvestatud asukohta %1$s + Keri + Otsi: PDF + Vali tekst + Valitud %1$s + Kuva otsingutulemused + Logi sisse rakendusega Google selle funktsiooni kasutamiseks töölaual. + Logi sisse rakendusega Google mitmesõnalise nutika sõnastiku kasutamiseks töölaual. + Logi sisse rakendusega Google töölaual kokkuvõtete kasutamiseks. + Logi sisse rakendusega Google töölaual kokkuvõtete kasutamiseks. + Peatatud + Tekstimärkus + tekstimärkus + Teksti stiil + Paksus %1$s + TOC + Sisestage selle otsimiseks PDF + Pealkirjata + Vaadake kontot ja krediite + Hääle vahemälu tühjendatud + Suumi + Suumi sisse + Suumi välja + Vali + Jätka lugemist + Loobu + Alla + Üles + AI + Keskus + Autorid + Tagasi raamatukogusse + Raamatutoimingud + Kaust + Sirvige + Kategooriad + Ptk %1$d + Peatüki pöörded + Vali font + Vali lugeja tekstuur + Kustuta failitüübid + Lehekülje märkuste kustutamine + Tühjenda allikad + Tühjenda olek + Tühjenda sildid + Sule lugeja + Pidev + Kaaned + Kohandatud värvid + Kohandatud teema eelvaade + Vähendamine %1$s + Määrake leht + See eemaldab esiletõstmise ja selle märkuse. + Sisenege täisekraanil + Välju täisekraanilt + Väline otsing + Raamatud + Koomiksid + Dokumendid + muud + Tekst ja veeb + Täida + Fikseeritud paigutusega välimus + Kaust on tühi + Siin pole toetatud faile ega alamkaustu saadaval. + %1$s, %2$s + %1$s - %2$s + Peida filtrid + Peida lugeja tööriistad + Puuduta pesa ja vali värv. + Jätka lugemist ja vaata hiljutisi raamatuid + Impordi raamatuid + Impordi kaust + Imporditud fondid + %1$s %2$s + Suurendada %1$s + Hüppe ajalugu + Paigutus ja vahekaugus + Impordi failid rakenduse salvestusruumi või lisa failide lugemiseks kaust. + Sirvige oma kollektsiooni + AI võtmed + Nutikas %1$d + Lugemata %1$d + Pooleli %1$d + Täitke %1$d + Nimekiri + Navigeerimine + Avatud pole ühtegi raamatut + Lisage kaust selle kausta failide lugemiseks. + Pole veel kaustu + Navigeerimisüksusi pole + Lehe sisu puudub + Seadeid ei leitud + Siin ilmuvad käsitsi riiulid ja seeriakollektsioonid. + Riiuleid veel pole + Looge nutikad riiulid raamatute kogumiseks reeglite järgi. + Nutikaid riiuleid veel pole + Siin kuvatakse raamatutele lisatud sildid. + Silte pole veel + Toetatud faile ei imporditud. + Kataloog + Kas kustutada "%1$s"? Sellest kataloogist voogesitatud raamatute avamine võib peatada, kui volikirjad hiljem muutuvad. + Kataloogid puuduvad + Lisage OPDS kataloog kaugraamatute sirvimiseks. + Sirvige katalooge, vooge ja allalaaditavaid faile + Ava raamat + Ava kaust + Avatud PDF + Lehekülje ja teksti värvid + Lehekülje teave + Lehekülje laius + Need vaikeseaded kehtivad, kui platvorm toetab jagatud PDF välimus. Raamatu kohta PDF alistab jääda PDF lugeja. + PDF failitoimingud + PDF marker + Salvestatud lugeja esiletõstmise läbipaistvusega. + Pin + Lugeja hallatav PDF tööriistad + Automaatne kerimine, OCR, märkuste vaikeseaded ja PDF-ainult tööriista nähtavust hallatakse aktiivses PDF lugeja. + %1$s %2$s of %3$d (%4$d%%) + Lugeja tööriistariba vaikeseadeid hallatakse sellel platvormil olevast lugejast. + Lugeja tööriistad + Salvesta pilt + Otsing: %1$s + Otsi lugejast + Otsinguseaded + Valik + Valiku otsa käepide + Valiku käivituskäepide + Teegi korrastamiseks lisage riiuleid, silte või kausta metaandmeid. + Kogud, sarjad, sildid ja kaustad + Näita lugeja tööriistu + Kaust + Nutikas + Tahke + Kiirus + Käivitage automaatne kerimine + Peatage automaatne kerimine + Lõpetage ette lugemine + Tekstuuri tugevus + Selle raamatu otsimiseks tippige + Tüpograafia + Võta märkus tagasi + Vabastage + Kasutage tumedat teemat + Kasutage heledat teemat + Mono + Sans + Serif + Otsige raamatuid, autoreid või silte + Tööriistad puuduvad + Nähtav + Asendage ainult see, mida räägitakse + Lugeja tekst, esiletõstmised ja asukohad jäävad muutumatuks. + %1$s -> %2$s + diff --git a/app/src/main/res/values-fr/plurals.xml b/app/src/main/res/values-fr/plurals.xml index b9367a0..f997d91 100644 --- a/app/src/main/res/values-fr/plurals.xml +++ b/app/src/main/res/values-fr/plurals.xml @@ -60,4 +60,114 @@ (%1$d morceaux) (%1$d morceaux) + + %1$d correspondance trouvée + %1$d correspondances trouvées + %1$d correspondances trouvées + + + Importation %1$d livre… Il apparaîtra bientôt dans votre bibliothèque. + Importation %1$d livres… Ils apparaîtront bientôt dans votre bibliothèque. + Importation %1$d livres… Ils apparaîtront bientôt dans votre bibliothèque. + + + Importé %1$d livre. Vous pouvez le trouver dans l\'onglet Bibliothèque. + Importé %1$d livres. Vous pouvez les trouver dans l\'onglet Bibliothèque. + Importé %1$d livres. Vous pouvez les trouver dans l\'onglet Bibliothèque. + + + %1$d livre ajouté à l\'étagère. + %1$d livres ajoutés à l\'étagère. + %1$d livres ajoutés à l\'étagère. + + + %1$d livre étiqueté "%2$s". + %1$d livres étiquetés avec "%2$s". + %1$d livres étiquetés avec "%2$s". + + + Dossier supprimé "%1$s" et %2$d réserver depuis l\'application. + Dossier supprimé "%1$s" et %2$d livres depuis l\'application. + Dossier supprimé "%1$s" et %2$d livres depuis l\'application. + + + %1$d fichier + %1$d fichiers + %1$d fichiers + + + Déposer pour importer %1$d fichier + Déposer pour importer %1$d fichiers + Déposer pour importer %1$d fichiers + + + %1$d les fichiers non pris en charge seront ignorés. + %1$d les fichiers non pris en charge seront ignorés. + %1$d les fichiers non pris en charge seront ignorés. + + + Importation %1$d fichier… + Importation %1$d fichiers… + Importation %1$d fichiers… + + + Importé %1$d déposer. + Importé %1$d fichiers. + Importé %1$d fichiers. + + + Importé %1$d déposer. Le support des lecteurs vient plus tard. + Importé %1$d fichiers. Le support des lecteurs vient plus tard. + Importé %1$d fichiers. Le support des lecteurs vient plus tard. + + + Impossible d\'importer %1$d déposer. + Impossible d\'importer %1$d fichiers. + Impossible d\'importer %1$d fichiers. + + + Ignoré %1$d déposer. + Ignoré %1$d fichiers. + Ignoré %1$d fichiers. + + + Supprimer "%1$s" et son %2$d réserver depuis l\'application ? Les fichiers sur le disque ne seront pas supprimés. + Supprimer "%1$s" et son %2$d des livres depuis l\'application ? Les fichiers sur le disque ne seront pas supprimés. + Supprimer "%1$s" et son %2$d des livres depuis l\'application ? Les fichiers sur le disque ne seront pas supprimés. + + + La synchronisation du dossier a échoué pour %1$d dossier. + La synchronisation du dossier a échoué pour %1$d dossiers. + La synchronisation du dossier a échoué pour %1$d dossiers. + + + Synchronisation des dossiers terminée avec %1$d dossier ignoré. + Synchronisation des dossiers terminée avec %1$d dossiers ignorés. + Synchronisation des dossiers terminée avec %1$d dossiers ignorés. + + + Supprimé %1$d diffusé OPDS livre de ce catalogue. + Supprimé %1$d diffusé OPDS livres de ce catalogue. + Supprimé %1$d diffusé OPDS livres de ce catalogue. + + + Tous les livres %1$d + Tous les livres %1$d + Tous les livres %1$d + + + Étagères %1$d + Étagères %1$d + Étagères %1$d + + + Balises %1$d + Balises %1$d + Balises %1$d + + + Dossiers %1$d + Dossiers %1$d + Dossiers %1$d + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 550209f..f21da19 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -740,4 +740,776 @@ Nederlands (Néerlandais) Українська (Ukrainien) Bahasa Indonesia (Indonésien) + Voir les onglets dans la barre d\'application du haut + Garder Episteme en mouvement + Pour remercier le soutien de l\'application, les contributeurs Patreon recevront du contenu supplémentaire et des bénéfices : Un aperçu du travail actuel, des captures d\'écran et mises à jour en avant-première, des votes qui aideront à déterminer à quoi ressembleront et comment fonctionneront les nouveaux ajouts, et une mention spéciale dans le LISEZMOI du projet. + Ce type de fichier n\'est pas pris en charge. + En pleine réflexion… + Préselections texturés + Mes thèmes texturés + Pas encore de thème texturé personnalisé. + Nouveau thème texturé + Texture + Transparence de la texture + Récapitulation de l\'histoire (Bêta) + Débloquer la récapitulation des chapitres + Désactiver la synchronisation locale + Activer la synchronisation locale + Synchronisation locale désactivée + Désactiver la synchronisation des dossiers locaux ? + Episteme arrêtera d\'analyser ce dossier et arrêtera d\'écrire JSON synchroniser les fichiers. Supprimez le %1$s dossier de ce dossier également ? + Conserver les données de synchronisation + Supprimer les données de synchronisation + Supprimer les polices ? + Etes-vous sûr de vouloir supprimer %1$d polices sélectionnées ? Cela les supprimera de tous vos appareils si la synchronisation est activée. + Aucun dossier local n\'a la synchronisation activée. + Synchronisation des dossiers locaux désactivée. + Synchronisation des dossiers locaux désactivée. Dossier de données de synchronisation supprimé. + La synchronisation du dossier local est désactivée, mais le dossier de données de synchronisation n\'a pas pu être supprimé. + Synchronisation des dossiers locaux activée. + Obtenez des résumés concis de n’importe quel chapitre avec Episteme Pro. Effectuez la mise à niveau pour commencer à utiliser cette fonctionnalité. + Débloquez le dictionnaire intelligent + Définir des phrases entières et des paragraphes jusqu\'à 2 000 caractères est une fonctionnalité Pro. Mettez à niveau pour obtenir des définitions instantanées pour tout texte sélectionné. + Verticale (WebView) + Vertical (bêta native) + Orientation de l\'écran + Changer le mode de lecture + Paginé (de droite à gauche) + TTS Paramètres + TTS Remplacements de mots + Remplacements de mots de livres + Partager, enregistrer ou imprimer + Récapitulatif (bêta) + Précédent TTS morceau + Suivant TTS morceau + Onglets + Images + Aucune image trouvée. + Télécharger l\'image + Enregistré %1$s + Impossible d\'enregistrer l\'image. + Mise en page + PDF page étendue + Page unique + Deux pages + Première page seule + Commence les pages en regard après la page de garde. + Supprimer l\'espace entre les pages + S\'applique à la lecture verticale et aux doubles pages. + Masquer la superposition des numéros de page + Supprime la petite étiquette de nombre de pages de chaque page. + Orientation de l\'écran + Choisissez si le lecteur suit l\'orientation du système ou préfère le portrait ou le paysage lorsque Android le permet. + Luminosité + Utiliser la luminosité du système + Suit le réglage de la luminosité de l\'appareil. + Luminosité personnalisée + S\'applique lorsqu\'un écran de lecteur est ouvert. + %1$d%% + Étagère créée "%1$s". + Création d\'une étagère intelligente "%1$s". + Étagère renommée "%1$s". + Étagère supprimée "%1$s". + Mise à jour de "%1$s". + Ces fichiers sont déjà dans la bibliothèque. + %1$s - %2$s + Enregistrer + Enregistrer le commentaire + Ajouter un commentaire + Répondre + Ajouter un commentaire… + Commentaires + Modification du commentaire + Répondre à %1$s + Fond de police + Utiliser un filtre de fichiers strict + Utiliser PDF Noms de fichiers + Détection ML du panneau de test + Tester la détection ML des bulles vocales + Activer le filtre de fichiers strict + Thème de l\'application + Apparence + Schéma de couleurs + Dynamique + Créer un thème d\'application + Thème personnalisé + Ajouter un thème personnalisé + Thème de l\'application + Tiroir ouvert + Fonctionnalité Pro + Trier + Fermer la recherche + Effacer la requête + Étagère de recherche + %1$s couverture d\'étagère + Préserver les couleurs de l\'image + Conserver les couleurs d\'origine de l\'image lorsque le thème change + Non mis en rayon + Tous les livres + Système + Lumière + Sombre + Norme + Moyen + Élevé + Récent + Titre A à Z + Auteur A-Z + Pourcentage terminé 0 à 100 + Pourcentage terminé 100-0 + Taille (la plus petite) + Taille (la plus grande) + Tout + Non lu + En cours + Terminé + Étiquettes : %1$s + Rechercher par balise + Balises + Dossiers + Fichiers + Synthèse vocale + Commandes de lecture pour la synthèse vocale. + Préparation de la synthèse vocale + Préparation : %1$s + Actif TTS Moteur + Nuage AI + Appareil natif + Voix dans le cloud + Voix de l\'appareil + Cache cloud + Sélectionnez une voix cloud de haute qualité + Effacer les échantillons + Voix par défaut du système + Utilise les paramètres de l\'appareil + Filtre de langue + En ligne + Hors ligne + Filtre vocal + Aucun audio mis en cache pour cette voix. + Vider le cache pour %1$s + Ceci est un échantillon de voix. + AI Caractéristiques + Résumé + Récapitulatif + Cache + Pas de résumé pour %1$s encore. + Générer un résumé pour %1$s + Obtenez un récapitulatif de l\'histoire jusqu\'à votre position actuelle. + Générer un récapitulatif de l\'histoire + Récapitulatif de l\'histoire + Accès au cache • Gratuit + Généré • Gratuit (%1$d/10 restant) + Généré • Coût : %1$s crédits + Générer… • Coût : Calculer + AI Sortie + Régénérer + Aucun résumé en cache pour ce livre. + Crédits + AI & Crédits Cloud + Crédits disponibles + %1$d Crédits + Répartition des coûts estimés + Nuage TTS + Cost: ~3–4 credits per minute of audio generated.\nTo enable: Reader Screen > More > TTS Voice Settings. + AI Résumés et récapitulation + Cost: ~1–4 credits per request based on chapter length.\nPro Users get 10 free summaries daily. + En achetant, + Plus de crédits + Vous n\'avez pas\'pas assez de crédits. Obtenez Episteme Pro pour 10 résumés gratuits par jour, ou ajoutez plus de crédits pour utiliser les résumés, Cloud TTS et Récapitulatif de l\'histoire. + Devenir Pro / Ajouter des crédits + Déverrouiller le résumé de la page + Obtenez des résumés concis de n’importe quelle page avec Episteme Pro. Effectuez la mise à niveau pour commencer à utiliser cette fonctionnalité. + Télécharger le modèle de zoom à bulles + Pour utiliser la fonction Bubble Zoom, un AI le modèle doit être téléchargé (~ 134 Mo). Voulez-vous le télécharger maintenant ? + Traduire + Retour à la page %1$d + Page %1$d + Résultat %1$d / %2$d + Échec du chargement de PDF. + Téléchargement du modèle Bubble Zoom… %1$d%% + Quitter la navigation par curseur + Revenir en arrière + Aller en avant + Faites défiler jusqu\'à la page de lecture + Page annotée + Fermer l\'image + Basculer les points forts de la recherche + Faites glisser pour déplacer la zone de texte + Aucune icône de fichiers + Copier %1$s + Étiquette + Marqueur d\'élément de liste + Réinitialiser le zoom + Générer des annotations de démonstration + Annotations de démonstration + Aire de jeux à stylo ouvert + Nouvel onglet + Surligner tout le texte + Basculer le mode d\'édition + Zoom intelligent sur la bande dessinée + Personnaliser les faits saillants + Anglais, espagnol, français, etc. + Hindi, Marathi, Sanskrit + Anglais + Chinois + Anglais + Japonais + Anglais + Coréen + Anglais + Documenter + Généré + %1$s (Vue texte) + %1$s (Refusion) + Ajouter/Modifier + Aucune balise attribuée. + Appliquer les balises + Rechercher ou créer une balise… + Créer \"%1$s\" + Tirez la distance pour changer de chapitre + Court + Longue + Vitesse : %1$sx + Pas : %1$sx + Lecture/Pause + Réinitialiser la vitesse + Réinitialiser le pas + Sélectionnez la couleur + Ce livre n\'a aucun contenu à afficher. + Lien copié + Texte copié + Table des matières + Marque-page + Revenir à la page %1$d + Retour à la page précédente + Quitter le zoom intelligent + Zoom intelligent sur la bande dessinée + Activer le zoom intelligent de la bande dessinée + Protection contre les captures d\'écran + La protection contre les captures d\'écran est activée + La protection contre les captures d\'écran est désactivée + Paramètres + Modifier + Restaurer + %1$s sélectionné + Paramètres par défaut du lecteur + PDF-spécifiques OCR, les annotations et les paramètres d\'outils restent dans PDF lecteur. + AI Définition + Chapitre %1$d + Emplacement + Police personnalisée + Une erreur s\'est produite : %1$s + Erreur lors du chargement du document : %1$s + OCR n\'a trouvé aucun texte sur cette page. + La page semble vide ou le texte ne peut pas être extrait. + Impossible de résumer une page blanche. + Document non chargé. + AI les fonctionnalités ne sont pas disponibles dans le mode hors ligne OSS construire. + Bloqué pour des raisons de sécurité. + Choisissez un modèle pour %1$s dans AI paramètres de clé et de modèle. + Ajouter un %1$s API saisir AI paramètres de clé et de modèle. + Le AI Le fournisseur a renvoyé une réponse vide. + AI erreur du fournisseur : %1$d. %2$s + Ce résumé nécessite un Gemini modèle car les modèles Groq sélectionnés ne prennent pas en charge l\'entrée PDF/image. + Vous devez être connecté pour utiliser les commentaires. + Vous devez être connecté pour soumettre des commentaires. + Max 3 images autorisées pour les billets. + Max 5 images autorisées par message. + Une ou plusieurs images dépassent la limite de 5 Mo. + Échec de la création du ticket : %1$s + Échec de l\'envoi : %1$s + Échec du chargement du flux : %1$s + Corps vide + Échec du téléchargement : %1$s + Erreur de téléchargement : %1$s + Échec de l\'achat : %1$s + Impossible de se connecter au service de facturation. + Produits introuvables. + Échec de l\'interrogation des produits + Non disponible en version Open Source + Aucun texte à lire. + Erreur lors du démarrage de la lecture. + Échec du chargement de l\'audio. + Erreur de lecture : %1$s + Nuage TTS n’est pas configuré. + Livre inconnu + AI clés et modèles + Clés enregistrées + Ajouter ou remplacer une clé + Fournisseur + API clé + Enregistrer la clé + Utiliser un seul modèle pour toutes les fonctionnalités + Lorsqu\'il est éteint, chaque lecteur AI La fonctionnalité utilise son propre modèle sélectionné. + Tous AI fonctionnalités + Le dictionnaire intelligent, les résumés et les récapitulatifs utilisent tous ce modèle. + Dictionnaire intelligent + Utilisé lors de la définition de mots ou d\'expressions sélectionnés. + Résumés + Utilisé pour EPUB résumés et PDF résumés de pages. PDF/les résumés d\'images nécessitent Gemini. + Récapitulatifs + Utilisé pour la génération de récapitulatifs d’histoire. + Utilise le Gemini enregistré clé. Uniquement %1$s est pris en charge pour l\'instant. + Enregistrer %1$s clé? + Après la sauvegarde, seuls les 3 premiers et 3 derniers caractères seront visibles. Pour le modifier ultérieurement, remplacez-le ou supprimez-le. + Supprimer %1$s clé? + Les fonctionnalités utilisant ce fournisseur cesseront de fonctionner jusqu\'à ce qu\'une nouvelle clé soit enregistrée. + Aucune clé enregistrée + Supprimer %1$s clé + Modèle + Aucun modèle sélectionné + Afficher AI dans le lecteur + Masquer AI dans le lecteur + Couleurs unies + Texturé + Solide personnalisé + Texturé personnalisé + Sélectionnez une texture personnalisée + Luminosité du texte (Lumière) + Luminosité du texte (foncé) + Par défaut + Gauche + À droite + Justifier + Toujours afficher + Synchroniser avec les menus + Toujours se cacher + Haut + En bas + Restaurer les métadonnées d\'origine ? + Cela réécrira le titre original, l\'auteur, la série et le résumé dans le EPUB déposer. La progression de la lecture, les balises et les notes ne changeront pas. + EPUB métadonnées modifiées + Métadonnées de EPUB fichier + Nom d\'affichage modifié dans l\'application + Métadonnées du fichier + Métadonnées + Fichier + Titre + Série + Lecture + Nom du fichier + Modifié + Résumé + Balises de la bibliothèque + Métadonnées modifiables + Nom d\'affichage + Nom affiché dans Reader + Fichier d\'origine : %1$s + Barre supérieure + Barre inférieure + Outils cachés + Plus de menu + Outils cachés + Déposez les outils ici + Faites glisser pour réorganiser + Applications externes + Curseur de navigation + Luminosité + Barre latérale + Mettre en surbrillance le texte sélectionnable + Mode édition + TTS Contrôles + Mode de lecture + Gestion des pages + Vue texte (redistribution) + Livre actuel + Mondial + Ce livre + Activer les remplacements + Les règles ici s\'appliquent à chaque livre, sauf si elles sont désactivées pour un titre spécifique. + Ajouter une règle + Ajouter une règle du livre + Aucune règle de remplacement globale pour l\'instant. + Pas encore de règles spécifiques au livre. + Utilisez les règles globales ici + Désactivez cette option lorsqu\'un livre a besoin de ses propres choix de prononciation. + Activer les règles du livre + Les règles locales s\'exécutent après les règles globales. + Règles globales héritées + Aucune règle globale à hériter. + Autorisé dans ce livre + Désactivé pour ce livre + Suggestions + Nouveau remplacement + Modifier le remplacement + Remplacer + Parlez comme + Activé + Mot entier + Étui de correspondance + Aperçu de la saisie + Règles + silence + Texte brut + sensible à la casse + Livre actuel + Ajouter une règle + Il n\'y a pas encore de règles de remplacement pour ce livre. + Nouveau remplacement + Modifier le remplacement + Avec + texte vide + À propos + Lecteur de bureau + Accès au bureau + Compte + Compte et crédits + Aperçu du compte + AI moyeu + Utilisé pour EPUB résumés et PDF résumés de pages. + Episteme OSS + Texte de l\'auteur + Cache : %1$s + En cache + Résumé en cache + Choisissez le Gemini voix utilisée pour la lecture à haute voix dans le cloud. + Supprimez le livre de bureau généré et EPUB fichiers de cache de pagination ? Ils seront recréés lors de la prochaine ouverture des livres. + Effacer le cache vocal + Fermer les outils + Synchronisation dans le cloud + Nuage TTS besoins Gemini + Nuage TTS a besoin de crédits de connexion + Nuage TTS prêt + Nuage TTS paramètres + Nuage TTS indisponible + Nuage TTS voix + Contient + Calcul des coûts + Créez un récapitulatif jusqu\'à votre position actuelle. + Créer une étagère intelligente + %1$d crédits disponibles + %1$s crédits + Polices importées pour le lecteur + Supprimer la police + Supprimer %1$s? Les livres qui l\'utilisent reviendront à la police par défaut. + Supprimer \"%1$s\" ? Les livres restent dans votre bibliothèque. + Supprimer le résumé + Désactivé + Déposez les fichiers à importer + Supprimez les fichiers pris en charge à importer + Contactez-nous directement par email pour toute autre chose. + Égal + Suppléments + Commentaires + Champ + Chemin du dossier + D\'ici + Analyse complète + Gratuit, %1$d à gauche + Générer un récapitulatif + Générer un résumé + Signalez des bogues, demandez des fonctionnalités ou contactez directement l’assistance. + Commanditaires GitHub + Soutenir le développement via les sponsors GitHub. + Google la connexion n’est pas configurée pour cette version de bureau. + Plus grand que + Aide + Rapports de bogues, demandes de fonctionnalités et assistance + Masquer + Importer des fichiers + Problèmes + Ouvrez le suivi des problèmes pour les bogues et les demandes de fonctionnalités. + Moins de + Bibliothèque et lecteur + N\'importe lequel + Actions de la bibliothèque + Plus + Aucun résumé en cache pour ce livre pour l\'instant. + Importez des fichiers TTF, OTF ou WOFF2 pour les utiliser dans des livres. + Aucune police trouvée correspondant à \"%1$s\" + Non Google le compte est connecté. + Aucun résumé mis en cache pour cette section. + Lecteur de bureau hors ligne + Lecteurs ouverts + Ouverture %1$s + Ouvrir votre bibliothèque + Opérateur + Pages + Protégé par mot de passe PDF + Patréon + Soutenez le projet sur Patreon. + En pause + %1$s nécessite un mot de passe avant de pouvoir être ouvert. + Le mot de passe est requis ou incorrect. + Ce mot de passe ne s\'est pas ouvert %1$s. Entrez le PDF mot de passe et réessayez. + Pourcentage + Planifier + Préparation du son + Préférences + Compte et crédits + Compte et crédits + Pro n\'est pas débloqué pour ce compte. + Les pro et les crédits ne peuvent être achetés qu\'à partir du Android application. Desktop vérifie le même compte connecté et utilise ces crédits pour le cloud TTS, les résumés, les récapitulatifs et autres AI payants. caractéristiques. + Connectez-vous pour vérifier l\'état de votre compte sur le bureau. + Pro est débloqué pour ce compte. + Progrès + Projet + Lecteur + Le lecteur onglets désactivés + Onglets du lecteur sur + Actualiser + Relâchez pour ajouter à votre bibliothèque. + Le stockage sécurisé des clés n\'est pas disponible sur ce système d\'exploitation. Les clés saisies ici seront utilisées pour cette session mais ne seront pas conservées. + Centre de paramètres + Correspond au Android masquer la bascule pour un dictionnaire intelligent, des résumés et des récapitulations. + Synchroniser le compte, Pro et les crédits + Connecté + Code source + Parcourez la source du projet sur GitHub. + Arrêtez de lire pour changer de voix. + Assistance + Prise en charge Episteme + Les contributions aident le lecteur à s\'améliorer sur Android et ordinateur de bureau. + Façons de soutenir Episteme développement + Synchroniser les dossiers + Synchroniser les métadonnées + Nom de la balise + Marquer les livres sélectionnés + Texte du titre + Outils + Paramètres d\'importation, de synchronisation et d\'application + Tapez, par ex. PDF + Voir + Cache vocal + Préparation de la vue Web intégrée… + Préparation de la vue Web intégrée groupée %1$d%% + Vue Web intégrée installée. Redémarrer Episteme pour terminer la configuration. + La vue Web intégrée n\'a pas pu démarrer : %1$s + Travailler… + Espace de travail + Ajouter à l\'étagère + Créez d’abord une étagère, puis ajoutez-y les livres sélectionnés. + Créer un thème + Existant : %1$s + Vous avez cliqué sur un lien externe. + Modifier EPUB métadonnées + Moins + …plus + Pas encore de thèmes personnalisés + Renommer dans l\'application + Balises, séparées par des virgules + Inconnu + Définir + Annotations + Options d\'annotation + Outils d\'annotation + Aider + Choisissez lequel PDF pour sauvegarder. + Effacer l\'historique des sauts + Nuage TTS échoué. + Ajouter un Gemini et sélectionnez Gemini nuage TTS dans AI clés et modèles. + Nuage TTS n\'est pas configuré pour cette version de bureau. + Connectez-vous avec Google pour utiliser le cloud TTS. + Nuage TTS a besoin d\'un compte connecté avec des crédits. Les pro et les crédits ne peuvent être achetés qu\'à partir du Android application. + Couleur + Options de commentaires + Personnalisé + Cela supprime l\'annotation de ce PDF. + Supprimer l\'annotation ? + Texte du document + Intégré PDF commentaire + Échec du rendu de la page. + Fonctionnalité indisponible + Fini + Stylo plume + Masquer les résultats de recherche + Couleur de surbrillance %1$d + Palette de surligneurs + Interactions + Indexation %1$d/%2$d pages + Balisage + %1$d matchs + %1$d matchs jusqu\'à présent + Page suivante + Résultat de recherche suivant + Aucune annotation pour l\'instant + Pas encore de favoris + Pas de commentaire + Aucune correspondance + Aucune correspondance dans les pages indexées pour le moment + Pas de table des matières + Il n\'y a pas de texte ici à lire. + Il n\'y a pas de texte à lire sur cette page. + Il n\'y a pas de texte pour résumer. + Ouvrir le commentaire + À court de crédits. Les pro et les crédits ne peuvent être achetés qu\'à partir du Android application. + Utilisation du cloud TTS a besoin de crédits sur le bureau. Les pro et les crédits ne peuvent être achetés qu\'à partir du Android application. + L\'utilisation de cette fonctionnalité nécessite des crédits sur le bureau. Les pro et les crédits ne peuvent être achetés qu\'à partir du Android application. + L\'utilisation des récapitulatifs nécessite des crédits sur le bureau. Les pro et les crédits ne peuvent être achetés qu\'à partir du Android application. + L\'utilisation des résumés nécessite des crédits sur le bureau. Les pro et les crédits ne peuvent être achetés qu\'à partir du Android application. + Poêle + PDF l\'action a échoué + Le PDF l\'action n\'a pas pu être complétée. + PDF commentaire + p. %1$d + PDF %1$d + Page %1$d - %2$s + Page %1$s de %2$d + Pages %1$s de %2$d + PDF enregistré + PDF outils + Crayon + Préparation de la sélection + Préparation %1$s + Page précédente + Résultat de la recherche précédente + La boîte de dialogue d\'impression est terminée. + Professionnel requis + Cette fonctionnalité nécessite Pro. Pro ne peut être acheté qu\'à partir du Android application, le bureau utilisera le compte mis à niveau après la connexion. + Le dictionnaire intelligent multi-mots nécessite Pro. Pro ne peut être acheté qu\'à partir du Android application, le bureau utilisera le compte mis à niveau après la connexion. + Lecteur AI les fonctionnalités sont masquées. + Bureau AI n\'est pas configuré pour cette version. + S\'applique à la lecture verticale et aux doubles pages. + Surligneur rond + Enregistré dans %1$s + Faire défiler + Rechercher dans PDF + Sélectionnez le texte + Sélectionné %1$s + Afficher les résultats de la recherche + Connectez-vous avec Google pour utiliser cette fonctionnalité sur le bureau. + Connectez-vous avec Google pour utiliser un dictionnaire intelligent multi-mots sur le bureau. + Connectez-vous avec Google pour utiliser les récapitulatifs sur le bureau. + Connectez-vous avec Google pour utiliser les résumés sur le bureau. + Arrêté + Note textuelle + note de texte + Style de texte + Épaisseur %1$s + Table des matières + Tapez pour rechercher ceci PDF + Sans titre + Afficher le compte et les crédits + Cache vocal vidé + Zoomer + Zoomer + Zoom arrière + Choisissez + Continuer la lecture + Rejeter + Vers le bas + Vers le haut + AI + Centre + Auteurs + Retour à la bibliothèque + Actions du livre + Dossier + Parcourir + Catégories + Ch. %1$d + Tour de chapitre + Choisir la police + Choisir la texture du lecteur + Effacer les types de fichiers + Effacer les annotations de page + Sources claires + Effacer le statut + Effacer les balises + Fermer le lecteur + Continu + Couvertures + Couleurs personnalisées + Aperçu du thème personnalisé + Diminuer %1$s + Définir la page + Cela supprime la surbrillance et sa note. + Entrer en plein écran + Quitter le plein écran + Recherche externe + Livres + Bandes dessinées + Documents + Autre + Texte et web + Remplir + Apparence de mise en page fixe + Le dossier est vide + Aucun fichier ou sous-dossier pris en charge n\'est disponible ici. + %1$s, %2$s + %1$s - %2$s + Masquer les filtres + Masquer les outils de lecture + Appuyez sur un emplacement, puis choisissez une couleur. + Continuer la lecture et les livres récents + Importer des livres + Dossier d\'importation + Polices importées + %1$s %2$s + Augmenter %1$s + Historique des sauts + Disposition et espacement + Importez des fichiers dans le stockage de l\'application ou ajoutez un dossier pour lire les fichiers sur place. + Parcourez votre collection + AI clés + Intelligent %1$d + Non lu %1$d + En cours %1$d + Terminé %1$d + Liste + Navigation + Aucun livre ouvert + Ajoutez un dossier pour lire les fichiers de ce dossier en place. + Aucun dossier pour l\'instant + Aucun élément de navigation + Aucun contenu de page + Aucun paramètre trouvé + Les étagères manuelles et les collections de séries apparaîtront ici. + Pas encore d\'étagères + Créez des étagères intelligentes pour collecter des livres selon des règles. + Pas encore d\'étagères intelligentes + Les balises ajoutées aux livres apparaîtront ici. + Pas encore de balises + Aucun fichier pris en charge n\'a été importé. + Catalogue + Supprimer "%1$s" ? Les livres diffusés en streaming à partir de ce catalogue peuvent cesser de s\'ouvrir si les informations d\'identification changent ultérieurement. + Aucun catalogue + Ajoutez un OPDS catalogue pour parcourir les livres distants. + Parcourez les catalogues, les flux et les téléchargements + Livre ouvert + Ouvrir le dossier + Ouvrir PDF + Couleurs des pages et du texte + Informations sur la page + Largeur de page + Ces valeurs par défaut s\'appliquent lorsque la plate-forme prend en charge le partage PDF apparence. Par livre PDF les remplacements restent dans le PDF lecteur. + PDF actions sur les fichiers + PDF surligneur + Enregistré avec la transparence du lecteur. + Épingle + Géré par les lecteurs PDF outils + Le défilement automatique, OCR, les valeurs par défaut des annotations et PDF-la visibilité des outils uniquement sont gérés à l\'intérieur du PDF actif. lecteur. + %1$s %2$s de %3$d (%4$d%%) + Les paramètres par défaut de la barre d\'outils du lecteur sont gérés à partir du lecteur sur cette plateforme. + Outils de lecture + Enregistrer l\'image + Rechercher : %1$s + Rechercher dans le lecteur + Paramètres de recherche + Sélection + Poignée de fin de sélection + Poignée de début de sélection + Ajoutez des étagères, des balises ou des métadonnées de dossier pour organiser votre bibliothèque. + Collections, séries, balises et dossiers + Afficher les outils de lecture + Dossier + Intelligent + Solide + Vitesse + Démarrer le défilement automatique + Arrêter le défilement automatique + Arrêtez de lire à haute voix + Résistance de la texture + Tapez pour rechercher ce livre + Typographie + Annuler l\'annotation + Désépingler + Utiliser un thème sombre + Utiliser le thème clair + Mono + Sans + Serif + Rechercher des livres, des auteurs ou des tags + Aucun outil + Visible + Remplacez uniquement ce qui est dit + Le texte du lecteur, les surlignages et les emplacements restent inchangés. + %1$s -> %2$s diff --git a/app/src/main/res/values-hi/plurals.xml b/app/src/main/res/values-hi/plurals.xml index bd76dac..da92a55 100644 --- a/app/src/main/res/values-hi/plurals.xml +++ b/app/src/main/res/values-hi/plurals.xml @@ -52,4 +52,88 @@ (%1$d खंड) (%1$d खंड) + + आयात हो रहा है %1$d पुस्तक... यह शीघ्र ही आपकी लाइब्रेरी में दिखाई देगी। + आयात हो रहा है %1$d पुस्तकें... वे शीघ्र ही आपकी लाइब्रेरी में दिखाई देंगी। + + + आयातित %1$d किताब। आप इसे लाइब्रेरी टैब में पा सकते हैं। + आयातित %1$d किताबें. आप उन्हें लाइब्रेरी टैब में पा सकते हैं। + + + %1$d पुस्तक शेल्फ में जोड़ी गई. + %1$d पुस्तकें शेल्फ में जोड़ी गईं। + + + %1$d पुस्तक को "%2$s" के साथ टैग किया गया है। + %1$d "%2$s" टैग वाली पुस्तकें। + + + हटाया गया फ़ोल्डर "%1$s" और %2$d ऐप से बुक करें. + हटाया गया फ़ोल्डर "%1$s" और %2$d ऐप से किताबें। + + + %1$d फ़ाइल + %1$d फ़ाइलें + + + आयात करने के लिए छोड़ें %1$d फ़ाइल + आयात करने के लिए छोड़ें %1$d फ़ाइलें + + + %1$d असमर्थित फ़ाइल छोड़ दी जाएगी. + %1$d असमर्थित फ़ाइलें छोड़ दी जाएंगी. + + + आयात हो रहा है %1$d फ़ाइल... + आयात हो रहा है %1$d फ़ाइलें... + + + आयातित %1$d फ़ाइल। + आयातित %1$d फ़ाइलें. + + + आयातित %1$d फ़ाइल। पाठक का समर्थन बाद में आता है। + आयातित %1$d फ़ाइलें. पाठक का समर्थन बाद में आता है। + + + आयात नहीं किया जा सका %1$d फ़ाइल। + आयात नहीं किया जा सका %1$d फ़ाइलें. + + + छोड़ दिया गया %1$d फ़ाइल। + छोड़ दिया गया %1$d फ़ाइलें. + + + "%1$s" हटाएं और इसका %2$d ऐप से बुक करें? डिस्क पर फ़ाइलें हटाई नहीं जाएंगी. + "%1$s" हटाएं और इसका %2$d ऐप से किताबें? डिस्क पर फ़ाइलें हटाई नहीं जाएंगी. + + + %1$d के लिए फ़ोल्डर सिंक विफल रहा फ़ोल्डर. + %1$d के लिए फ़ोल्डर सिंक विफल रहा फ़ोल्डर्स. + + + फ़ोल्डर सिंक %1$d के साथ समाप्त हुआ फ़ोल्डर छोड़ दिया गया. + फ़ोल्डर सिंक %1$d के साथ समाप्त हुआ फ़ोल्डर्स छोड़ दिए गए. + + + हटा दिया गया %1$d स्ट्रीम किया गया OPDS उस कैटलॉग से पुस्तक. + हटा दिया गया %1$d स्ट्रीम किया गया OPDS उस कैटलॉग से पुस्तकें. + + + सभी पुस्तकें %1$d + सभी पुस्तकें %1$d + + + अलमारियां %1$d + अलमारियां %1$d + + + टैग %1$d + टैग %1$d + + + फ़ोल्डर्स %1$d + फ़ोल्डर्स %1$d + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 2f1d080..d704c1b 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1086,4 +1086,430 @@ Nederlands (डच) Українська (यूक्रेनी) Bahasa Indonesia (इंडोनेशियाई) + शीर्ष ऐप बार में टैब दिखाएं + स्थानीय सिंक अक्षम करें + स्थानीय सिंक सक्षम करें + स्थानीय समन्वयन अक्षम + स्थानीय फ़ोल्डर सिंक अक्षम करें? + Episteme इस फ़ोल्डर को स्कैन करना बंद कर देगा और लिखना बंद कर देगा JSON फ़ाइलें सिंक करें. %1$s हटाएं इस फ़ोल्डर से भी फ़ोल्डर? + डेटा सिंक रखें + सिंक डेटा हटाएँ + फ़ॉन्ट हटाएँ? + क्या आप वाकई हटाना चाहते हैं %1$d चयनित फ़ॉन्ट? यदि सिंक चालू है तो यह उन्हें आपके सभी डिवाइस से हटा देगा। + किसी भी स्थानीय फ़ोल्डर में सिंक सक्षम नहीं है. + स्थानीय फ़ोल्डर समन्वयन अक्षम. + स्थानीय फ़ोल्डर समन्वयन अक्षम. सिंक डेटा फ़ोल्डर हटा दिया गया. + स्थानीय फ़ोल्डर सिंक अक्षम है, लेकिन सिंक डेटा फ़ोल्डर हटाया नहीं जा सका। + स्थानीय फ़ोल्डर समन्वयन सक्षम. + लंबवत (वेबव्यू) + कार्यक्षेत्र (मूल बीटा) + शब्द प्रतिस्थापन बुक करें + पिछला TTS टुकड़ा + अगला TTS टुकड़ा + छवियाँ + कोई चित्र नहीं मिला. + छवि डाउनलोड करें + सहेजा गया %1$s + छवि सहेजी नहीं जा सकी. + PDF पृष्ठ प्रसार + एकल पृष्ठ + दो पन्ने + पहला पेज अकेला + कवर पेज के बाद फेसिंग पेज स्प्रेड शुरू होता है। + चमक + सिस्टम चमक का प्रयोग करें + डिवाइस की चमक सेटिंग का अनुसरण करता है। + कस्टम चमक + रीडर स्क्रीन खुली होने पर लागू होता है। + %1$d%% + शेल्फ़ बनाया गया "%1$s"। + स्मार्ट शेल्फ "%1$s" बनाया गया। + शेल्फ़ का नाम बदलकर "%1$s" कर दिया गया। + हटाई गई शेल्फ़ "%1$s"। + अपडेट किया गया "%1$s"। + वे फ़ाइलें पहले से ही लाइब्रेरी में हैं. + %1$s - %2$s + सहेजें + टिप्पणी सहेजें + टिप्पणी जोड़ें + उत्तर + एक टिप्पणी जोड़ें... + टिप्पणियाँ + टिप्पणी संपादित करना + %1$s को उत्तर दिया जा रहा है + PDF का प्रयोग करें फ़ाइलनाम + चमक + वर्तमान पुस्तक + नियम जोड़ें + इस पुस्तक के लिए अभी तक कोई प्रतिस्थापन नियम नहीं हैं। + नया प्रतिस्थापन + प्रतिस्थापन संपादित करें + साथ + खाली पाठ + के बारे में + डेस्कटॉप रीडर + डेस्कटॉप पहुंच + खाता + खाता एवं क्रेडिट + खाता अवलोकन + AI हब + EPUB के लिए उपयोग किया जाता है सारांश और PDF पृष्ठ सारांश. + Episteme ओस्स + लेखक पाठ + कैश: %1$s + कैश्ड + कैश्ड सारांश + Gemini चुनें क्लाउड को जोर से पढ़ने के लिए इस्तेमाल की जाने वाली आवाज। + जेनरेट की गई डेस्कटॉप बुक और EPUB को हटाएं पेजिनेशन कैश फ़ाइलें? अगली बार किताबें खोले जाने पर उन्हें फिर से बनाया जाएगा। + वॉइस कैश साफ़ करें + उपकरण बंद करें + क्लाउड सिंक + बादल TTS आवश्यकताएँ Gemini + बादल TTS साइन-इन क्रेडिट की आवश्यकता है + बादल TTS तैयार + बादल TTS सेटिंग्स + बादल TTS अनुपलब्ध + बादल TTS आवाज + शामिल है + लागत की गणना + अपनी वर्तमान स्थिति का पुनर्कथन करें। + स्मार्ट शेल्फ बनाएं + %1$d क्रेडिट उपलब्ध है + %1$s श्रेय + पाठक के लिए आयातित फ़ॉन्ट + फ़ॉन्ट हटाएँ + हटाएं %1$s? इसका उपयोग करने वाली पुस्तकें डिफ़ॉल्ट फ़ॉन्ट पर वापस आ जाएंगी। + \"%1$s\" हटाएं? किताबें आपकी लाइब्रेरी में रहती हैं. + सारांश हटाएँ + विकलांग + आयात करने के लिए फ़ाइलें छोड़ें + आयात करने के लिए समर्थित फ़ाइलें छोड़ें + किसी भी अन्य चीज़ के लिए सीधे ईमेल द्वारा हमसे संपर्क करें। + बराबर + अतिरिक्त + प्रतिक्रिया + मैदान + फ़ोल्डर पथ + यहाँ से + पूर्ण स्कैन + मुफ़्त, %1$d बायां + पुनर्कथन उत्पन्न करें + सारांश उत्पन्न करें + बग की रिपोर्ट करें, सुविधाओं का अनुरोध करें, या सीधे समर्थन से संपर्क करें। + गिटहब प्रायोजक + GitHub प्रायोजकों के माध्यम से विकास का समर्थन करें। + Google इस डेस्कटॉप बिल्ड के लिए साइन-इन कॉन्फ़िगर नहीं किया गया है। + से भी बड़ा + मदद + बग रिपोर्ट, सुविधा अनुरोध और समर्थन + छिपाओ + फ़ाइलें आयात करें + मुद्दे + बग और सुविधा अनुरोधों के लिए समस्या ट्रैकर खोलें। + से भी कम + पुस्तकालय और पाठक + कोई भी + पुस्तकालय क्रियाएँ + अधिक + इस पुस्तक के लिए अभी तक कोई संचित सारांश नहीं है। + पुस्तकों में उपयोग करने के लिए TTF, OTF, या WOFF2 फ़ाइलें आयात करें। + \"%1$s\" से मेल खाता कोई फ़ॉन्ट नहीं मिला + नहीं Google खाता जुड़ा हुआ है. + इस अनुभाग के लिए कोई सारांश कैश नहीं किया गया. + ऑफ़लाइन डेस्कटॉप रीडर + पाठकों को खोलें + खुल रहा है %1$s + अपनी लाइब्रेरी खोल रहा हूँ + संचालिका + पेज + पासवर्ड सुरक्षित PDF + पैट्रियन + पैट्रियन पर परियोजना का समर्थन करें। + रुका हुआ + %1$s इसे खोलने से पहले एक पासवर्ड की आवश्यकता होती है। + पासवर्ड आवश्यक है या ग़लत है. + वह पासवर्ड नहीं खुला %1$s PDF दर्ज करें पासवर्ड और पुनः प्रयास करें. + प्रतिशत + योजना + ऑडियो तैयार हो रहा है + प्राथमिकताएँ + खाता एवं क्रेडिट + खाता एवं क्रेडिट + इस खाते के लिए प्रो अनलॉक नहीं है. + प्रो और क्रेडिट केवल Android से खरीदे जा सकते हैं अनुप्रयोग। डेस्कटॉप उसी साइन-इन खाते की जाँच करता है और उन क्रेडिट का उपयोग क्लाउड TTS, सारांश, पुनर्कथन और अन्य भुगतान AI के लिए करता है। विशेषताएँ। + डेस्कटॉप पर अपने खाते की स्थिति जांचने के लिए साइन इन करें। + इस खाते के लिए प्रो अनलॉक है. + प्रगति + प्रोजेक्ट + पाठक + रीडर टैब बंद + पाठक टैब चालू करता है + ताज़ा करें + अपनी लाइब्रेरी में जोड़ने के लिए रिलीज़ करें। + इस ऑपरेटिंग सिस्टम पर सुरक्षित कुंजी भंडारण उपलब्ध नहीं है। यहां दर्ज की गई कुंजियाँ इस सत्र के लिए उपयोग की जाएंगी लेकिन कायम नहीं रहेंगी। + सेटिंग्स हब + Android से मेल खाता है स्मार्ट शब्दकोश, सारांश और पुनर्कथन के लिए टॉगल छिपाएँ। + खाता, प्रो और क्रेडिट सिंक करें + साइन इन किया गया + स्रोत कोड + GitHub पर प्रोजेक्ट स्रोत ब्राउज़ करें। + आवाज़ें बदलने के लिए पढ़ना बंद करें। + समर्थन + समर्थन Episteme + योगदान पाठक को Android में सुधार लाने में मदद करता है और डेस्कटॉप. + समर्थन करने के तरीके Episteme विकास + फ़ोल्डर सिंक करें + मेटाडेटा सिंक करें + टैग नाम + चयनित पुस्तकों को टैग करें + शीर्षक पाठ + उपकरण + आयात, समन्वयन और ऐप सेटिंग + प्रकार, उदा. PDF + देखें + वॉयस कैश + एम्बेडेड वेबव्यू तैयार किया जा रहा है... + बंडल एम्बेडेड वेबव्यू तैयार किया जा रहा है %1$d%% + एंबेडेड वेबव्यू स्थापित किया गया. पुनरारंभ करें Episteme सेटअप समाप्त करने के लिए. + एंबेडेड वेबव्यू प्रारंभ नहीं हो सका: %1$s + काम कर रहा हूँ... + कार्यक्षेत्र + शेल्फ़ में जोड़ें + पहले एक शेल्फ बनाएं, फिर उसमें चयनित पुस्तकें जोड़ें। + थीम बनाएं + मौजूदा: %1$s + आपने एक बाहरी लिंक पर क्लिक किया. + संपादित करें EPUB मेटाडेटा + कम + …और अधिक + अभी तक कोई कस्टम थीम नहीं है + ऐप में नाम बदलें + टैग, अल्पविराम से अलग + अज्ञात + परिभाषित करें + एनोटेशन + एनोटेशन विकल्प + एनोटेशन उपकरण + सहायता करें + कौन सा चुनें PDF बचाने के लिए। + स्पष्ट छलांग इतिहास + बादल TTS असफल। + एक Gemini जोड़ें कुंजी और चयन करें Gemini बादल TTS में AI चाबियाँ और मॉडल. + बादल TTS इस डेस्कटॉप बिल्ड के लिए कॉन्फ़िगर नहीं किया गया है. + Google के साथ साइन इन करें क्लाउड का उपयोग करने के लिए TTS + बादल TTS क्रेडिट के साथ एक साइन-इन खाते की आवश्यकता है। प्रो और क्रेडिट केवल Android से खरीदे जा सकते हैं अनुप्रयोग। + रंग + टिप्पणी विकल्प + कस्टम + यह इस PDF से एनोटेशन हटा देता है। + एनोटेशन हटाएँ? + दस्तावेज़ पाठ + एंबेडेड PDF टिप्पणी + पेज प्रस्तुत करने में विफल. + सुविधा अनुपलब्ध + समाप्त + फाउंटेन पेन + खोज परिणाम छिपाएँ + रंग हाइलाइट करें %1$d + हाइलाइटर पैलेट + इंटरेक्शन + अनुक्रमण %1$d/%2$d पन्ने + मार्कअप + %1$d मेल खाता है + %1$d अब तक के मैच + अगला पेज + अगला खोज परिणाम + अभी तक कोई एनोटेशन नहीं + अभी तक कोई बुकमार्क नहीं + कोई टिप्पणी नहीं + कोई मेल नहीं + अनुक्रमित पृष्ठों में अभी तक कोई मिलान नहीं है + सामग्री की कोई तालिका नहीं + यहां पढ़ने के लिए कोई पाठ नहीं है. + इस पृष्ठ पर पढ़ने के लिए कोई पाठ नहीं है। + संक्षेप में बताने के लिए कोई पाठ नहीं है। + टिप्पणी खोलें + क्रेडिट से बाहर. प्रो और क्रेडिट केवल Android से खरीदे जा सकते हैं अनुप्रयोग। + क्लाउड का उपयोग करना TTS डेस्कटॉप पर क्रेडिट की आवश्यकता है. प्रो और क्रेडिट केवल Android से खरीदे जा सकते हैं अनुप्रयोग। + इस सुविधा का उपयोग करने के लिए डेस्कटॉप पर क्रेडिट की आवश्यकता होती है। प्रो और क्रेडिट केवल Android से खरीदे जा सकते हैं अनुप्रयोग। + रीकैप्स का उपयोग करने के लिए डेस्कटॉप पर क्रेडिट की आवश्यकता होती है। प्रो और क्रेडिट केवल Android से खरीदे जा सकते हैं अनुप्रयोग। + सारांशों का उपयोग करने के लिए डेस्कटॉप पर क्रेडिट की आवश्यकता होती है। प्रो और क्रेडिट केवल Android से खरीदे जा सकते हैं अनुप्रयोग। + पैन + PDF कार्रवाई विफल रही + PDF कार्रवाई पूरी नहीं हो सकी. + PDF टिप्पणी + पी। %1$d + PDF पेज %1$d + पेज %1$d - %2$s + पेज %1$s का %2$d + पेज %1$s का %2$d + PDF बचाया + PDF उपकरण + पेंसिल + चयन की तैयारी + तैयारी %1$s + पिछला पृष्ठ + पिछला खोज परिणाम + मुद्रण संवाद समाप्त हो गया है. + प्रो की आवश्यकता है + इस सुविधा के लिए प्रो की आवश्यकता है. प्रो को केवल Android से खरीदा जा सकता है ऐप, फिर डेस्कटॉप साइन-इन के बाद अपग्रेड किए गए खाते का उपयोग करेगा। + मल्टी-वर्ड स्मार्ट डिक्शनरी के लिए प्रो की आवश्यकता है। प्रो को केवल Android से खरीदा जा सकता है ऐप, फिर डेस्कटॉप साइन-इन के बाद अपग्रेड किए गए खाते का उपयोग करेगा। + पाठक AI विशेषताएं छुपी हुई हैं. + डेस्कटॉप AI इस निर्माण के लिए कॉन्फ़िगर नहीं किया गया है. + वर्टिकल रीडिंग और दो-पेज स्प्रेड पर लागू होता है। + गोल हाइलाइटर + %1$s में सहेजा गया + स्क्रॉल करें + PDF में खोजें + पाठ का चयन करें + चयनित %1$s + खोज परिणाम दिखाएँ + Google के साथ साइन इन करें डेस्कटॉप पर इस सुविधा का उपयोग करने के लिए. + Google के साथ साइन इन करें डेस्कटॉप पर मल्टी-वर्ड स्मार्ट डिक्शनरी का उपयोग करने के लिए। + Google के साथ साइन इन करें डेस्कटॉप पर रीकैप्स का उपयोग करने के लिए. + Google के साथ साइन इन करें डेस्कटॉप पर सारांशों का उपयोग करने के लिए. + रुक गया + टेक्स्ट नोट + पाठ नोट + पाठ शैली + मोटाई %1$s + टीओसी + इसे खोजने के लिए टाइप करें PDF + शीर्षकहीन + खाता और क्रेडिट देखें + वॉइस कैश साफ़ किया गया + ज़ूम करें + ज़ूम इन करें + ज़ूम आउट + चुनें + पढ़ना जारी रखें + ख़ारिज करें + नीचे + ऊपर + AI + केंद्र + लेखक + लाइब्रेरी में वापस जाएँ + पुस्तक क्रियाएँ + फ़ोल्डर + ब्राउज़ करें + श्रेणियाँ + चौ. %1$d + अध्याय बदल जाता है + फ़ॉन्ट चुनें + पाठक बनावट चुनें + फ़ाइल प्रकार साफ़ करें + पृष्ठ एनोटेशन साफ़ करें + स्पष्ट स्रोत + स्पष्ट स्थिति + टैग साफ़ करें + निकट पाठक + निरंतर + कवर + कस्टम रंग + कस्टम थीम पूर्वावलोकन + कमी %1$s + पेज को परिभाषित करें + इससे हाइलाइट और उसका नोट हट जाता है. + पूर्ण स्क्रीन दर्ज करें + पूर्ण स्क्रीन से बाहर निकलें + बाहरी खोज + किताबें + कॉमिक्स + दस्तावेज़ + अन्य + पाठ और वेब + भरें + निश्चित-लेआउट उपस्थिति + फ़ोल्डर खाली है + यहां कोई समर्थित फ़ाइल या सबफ़ोल्डर उपलब्ध नहीं है। + %1$s, %2$s + %1$s - %2$s + फ़िल्टर छिपाएँ + पाठक उपकरण छिपाएँ + एक स्लॉट टैप करें, फिर एक रंग चुनें। + पढ़ना जारी रखें और हाल की किताबें + पुस्तकें आयात करें + फ़ोल्डर आयात करें + आयातित फ़ॉन्ट + %1$s %2$s + बढ़ोतरी %1$s + छलांग का इतिहास + लेआउट और रिक्ति + ऐप स्टोरेज में फ़ाइलें आयात करें या फ़ाइलों को पढ़ने के लिए एक फ़ोल्डर जोड़ें। + अपना संग्रह ब्राउज़ करें + AI चाबियाँ + स्मार्ट %1$d + अपठित %1$d + प्रगति पर %1$d + पूर्ण %1$d + सूची + नेविगेशन + कोई किताब खुली नहीं + उस फ़ोल्डर से फ़ाइलों को पढ़ने के लिए एक फ़ोल्डर जोड़ें। + अभी तक कोई फ़ोल्डर नहीं + कोई नेविगेशन आइटम नहीं + कोई पृष्ठ सामग्री नहीं + कोई सेटिंग नहीं मिली + मैनुअल अलमारियाँ और श्रृंखला संग्रह यहां दिखाई देंगे। + अभी तक कोई शेल्फ़ नहीं + नियमों के अनुसार किताबें एकत्र करने के लिए स्मार्ट अलमारियां बनाएं। + अभी तक कोई स्मार्ट शेल्फ़ नहीं + पुस्तकों में जोड़े गए टैग यहां दिखाई देंगे. + अभी तक कोई टैग नहीं + कोई समर्थित फ़ाइल आयात नहीं की गई. + कैटलॉग + "%1$s" हटाएं? यदि क्रेडेंशियल बाद में बदलते हैं तो इस कैटलॉग से स्ट्रीम की गई पुस्तकें खुलना बंद हो सकती हैं। + कोई कैटलॉग नहीं + एक OPDS जोड़ें दूरस्थ पुस्तकें ब्राउज़ करने के लिए कैटलॉग। + कैटलॉग, स्ट्रीम और डाउनलोड ब्राउज़ करें + किताब खोलें + फ़ोल्डर खोलें + खोलें PDF + पृष्ठ और पाठ रंग + पृष्ठ जानकारी + पृष्ठ की चौड़ाई + ये डिफ़ॉल्ट वहां लागू होते हैं जहां प्लेटफ़ॉर्म साझा PDF का समर्थन करता है उपस्थिति। प्रति-पुस्तक PDF ओवरराइड्स PDF में रहते हैं पाठक. + PDF फ़ाइल कार्रवाई + PDF हाइलाइटर + रीडर हाइलाइट पारदर्शिता के साथ सहेजा गया। + पिन + पाठक-प्रबंधित PDF उपकरण + ऑटो-स्क्रॉल, OCR, एनोटेशन डिफ़ॉल्ट, और PDF-केवल टूल दृश्यता को सक्रिय PDF के अंदर प्रबंधित किया जाता है। पाठक. + %1$s %2$s का %3$d (%4$d%%) + इस प्लेटफ़ॉर्म पर रीडर टूलबार डिफॉल्ट को रीडर से प्रबंधित किया जाता है। + पाठक उपकरण + छवि सहेजें + खोजें: %1$s + पाठक में खोजें + सेटिंग खोजें + चयन + चयन अंत हैंडल + चयन प्रारंभ हैंडल + अपनी लाइब्रेरी को व्यवस्थित करने के लिए शेल्फ़, टैग या फ़ोल्डर मेटाडेटा जोड़ें। + संग्रह, श्रृंखला, टैग और फ़ोल्डर्स + पाठक उपकरण दिखाएँ + फ़ोल्डर + होशियार + ठोस + गति + ऑटो स्क्रॉल प्रारंभ करें + ऑटो स्क्रॉल बंद करो + ज़ोर से पढ़ना बंद करो + बनावट की ताकत + इस पुस्तक को खोजने के लिए टाइप करें + टाइपोग्राफी + एनोटेशन पूर्ववत करें + अनपिन करें + डार्क थीम का प्रयोग करें + लाइट थीम का प्रयोग करें + मोनो + सं + सेरिफ़ + पुस्तकें, लेखक या टैग खोजें + कोई उपकरण नहीं + दर्शनीय + जो बोला गया है उसे ही बदलें + पाठक पाठ, हाइलाइट्स और स्थान अपरिवर्तित रहते हैं। + %1$s -> %2$s diff --git a/app/src/main/res/values-in/plurals.xml b/app/src/main/res/values-in/plurals.xml index 3406b88..9a36cfc 100644 --- a/app/src/main/res/values-in/plurals.xml +++ b/app/src/main/res/values-in/plurals.xml @@ -2,41 +2,138 @@ %1$d buku + %1$d buku buku + buku %1$d rak + %1$d rak %1$d ditemukan + %1$d hasil ditemukan %1$d cocok + %1$d kecocokan ditemukan Berkas dihapus permanen + Hapus File Secara Permanen Anda ingin menghapus secara permanen %1$d file yang dipilih dari perangkat Anda? Tindakan ini tidak dapat dibatalkan. + Apakah Anda ingin menghapus %1$d secara permanen file yang dipilih dari perangkat Anda? Tindakan ini tidak dapat dibatalkan. Apakah Anda ingin menghapus %1$d file yang dipilih dari daftar file terbaru? File tersebut akan muncul kembali jika Anda membukanya lagi dari perpustakaan. + Apakah Anda ingin menghapus %1$d file yang dipilih dari daftar file terbaru? Ini akan muncul kembali jika Anda membukanya lagi dari perpustakaan. Apakah Anda yakin ingin mengeluarkan %1$d buku dari rak \'%2$s\'? Buku-buku tersebut akan tetap berada di perpustakaan Anda dan muncul di bawah Tanpa rak. + Apakah Anda yakin ingin menghapus %1$d buku dari \'%2$s\' rak? Buku tersebut akan tetap ada di perpustakaan Anda dan muncul di bawah Unshelved. %1$d buku dikeluarkan dari perpustakaan. + %1$d buku dihapus dari perpustakaan. %1$d folder + %1$d map %1$d tagar + %1$d menandai (%1$d Potongan) + (%1$d potongan) + + + Mengimpor %1$d buku… Buku itu akan segera muncul di Perpustakaan Anda. + Mengimpor %1$d buku… Ini akan segera muncul di Perpustakaan Anda. + + + Impor %1$d buku. Anda dapat menemukannya di tab Perpustakaan. + Impor %1$d buku. Anda dapat menemukannya di tab Perpustakaan. + + + %1$d buku ditambahkan ke rak. + %1$d buku ditambahkan ke rak. + + + %1$d buku yang diberi tag "%2$s". + %1$d buku yang diberi tag "%2$s". + + + Folder "%1$s" dihapus dan %2$d buku dari aplikasi. + Folder "%1$s" dihapus dan %2$d buku dari aplikasi. + + + %1$d file + %1$d mengajukan + + + Jatuhkan untuk mengimpor %1$d file + Jatuhkan untuk mengimpor %1$d mengajukan + + + %1$d file yang tidak didukung akan dilewati. + %1$d file yang tidak didukung akan dilewati. + + + Mengimpor %1$d file… + Mengimpor %1$d mengajukan… + + + Impor %1$d file. + Impor %1$d mengajukan. + + + Impor %1$d file. Dukungan pembaca datang kemudian. + Impor %1$d mengajukan. Dukungan pembaca datang kemudian. + + + Tidak dapat mengimpor %1$d file. + Tidak dapat mengimpor %1$d mengajukan. + + + Dilewati %1$d file. + Dilewati %1$d mengajukan. + + + Hapus "%1$s" dan %2$dnya buku dari aplikasi? File di disk tidak akan dihapus. + Hapus "%1$s" dan %2$dnya pesan dari aplikasi? File di disk tidak akan dihapus. + + + Sinkronisasi folder gagal untuk %1$d folder. + Sinkronisasi folder gagal untuk %1$d map. + + + Sinkronisasi folder selesai dengan %1$d folder dilewati. + Sinkronisasi folder selesai dengan %1$d folder dilewati. + + + Dihapus %1$d streaming OPDS buku dari katalog itu. + Dihapus %1$d streaming OPDS buku dari katalog itu. + + + Semua Buku %1$d + Semua Buku %1$d + + + Rak %1$d + Rak %1$d + + + Tag %1$d + Tag %1$d + + + Folder %1$d + Folder %1$d diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index f1dc16c..1b98e63 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -577,7 +577,7 @@ Opsi Visual Tata letak halaman Hilangkan jarak antar halaman - Berlaku untuk mode membaca vertikal. + Berlaku untuk pembacaan vertikal dan bentangan dua halaman. Sembunyikan hamparan nomor halaman Menghapus label jumlah halaman kecil dari setiap halaman. UI Sistem (Bilah Status & Navigasi) @@ -799,8 +799,8 @@ Terkini Judul A-Z Penulis A-Z - Persen selesai 0-100 - Persen selesai 100-0 + Persentase selesai 0–100 + Persentase selesai 100–0 Ukuran (Terkecil) Ukuran (Terbesar) Semua @@ -855,9 +855,9 @@ Kredit %1$d Perkiraan Rincian Biaya Awan TTS - Biaya: ~3-4 kredit per menit audio yang dihasilkan.\nUntuk mengaktifkan: Layar Pembaca > Lainnya > Pengaturan Suara TTS. + Biaya: sekitar 3–4 kredit per menit audio yang dihasilkan.\nUntuk mengaktifkan: Layar Pembaca > Lainnya > Pengaturan Suara TTS. Ringkasan & Rekap AI - Biaya: ~1-4 kredit per permintaan berdasarkan panjang bab.\nPro Pengguna mendapatkan 10 ringkasan gratis setiap hari. + Biaya: sekitar 1–4 kredit per permintaan berdasarkan panjang bab.\nPengguna Pro mendapatkan 10 ringkasan gratis setiap hari. Dengan membeli, Kredit Kehabisan Anda tidak memiliki kredit yang cukup. Dapatkan Episteme Pro untuk 10 Ringkasan gratis per hari, atau tambahkan kredit lebih banyak untuk menggunakan Ringkasan, Cloud TTS, dan Rekap Cerita. @@ -1086,4 +1086,430 @@ Nederlands (Belanda) Українська (Ukraina) Bahasa Indonesia + Tampilkan tab di bilah aplikasi atas + Potongan TTS sebelumnya + Potongan TTS berikutnya + Gambar + Tidak ada gambar ditemukan. + Unduh gambar + %1$s disimpan + Tidak dapat menyimpan gambar. + Bentangan halaman PDF + Satu halaman + Dua halaman + Halaman pertama sendiri + Memulai bentangan halaman berhadapan setelah halaman sampul. + Kecerahan + Gunakan kecerahan sistem + Mengikuti pengaturan kecerahan perangkat. + Kecerahan kustom + Berlaku saat layar pembaca terbuka. + %1$d%% + Rak %1$s dibuat. + Rak pintar %1$s dibuat. + Rak diubah namanya menjadi %1$s. + Rak %1$s dihapus. + %1$s diperbarui. + Berkas-berkas itu sudah ada di perpustakaan. + %1$s - %2$s + Simpan + Simpan Komentar + Tambahkan Komentar + Balas + Tambahkan komentar… + Komentar + Mengedit komentar + Membalas kepada %1$s + Gunakan Nama Berkas PDF + Kecerahan + Tentang + Pembaca desktop + Akses desktop + Akun + Pusat AI + Digunakan untuk ringkasan EPUB dan ringkasan halaman PDF. + Teks penulis + Tembolok: %1$s + Di-tembolokkan + Ringkasan yang di-tembolokkan + Pilih suara Gemini yang digunakan untuk pembacaan nyaring cloud. + Hapus berkas buku desktop yang dihasilkan dan berkas tembolok paginasi EPUB? Berkas tersebut akan dibuat kembali saat buku dibuka berikutnya. + Hapus tembolok suara + Tutup alat + Cloud TTS memerlukan Gemini + Cloud TTS memerlukan kredit saat masuk + Cloud TTS siap + Pengaturan Cloud TTS + Cloud TTS tidak tersedia + Suara Cloud TTS + Berisi + Biaya sedang dihitung + Buat rekap hingga posisi Anda saat ini. + Buat rak pintar + %1$d kredit tersedia + %1$s kredit + Font yang diimpor untuk pembaca + Hapus font + Hapus %1$s? Buku yang menggunakannya akan kembali ke font bawaan. + Hapus \"%1$s\"? Buku tetap ada di perpustakaan Anda. + Hapus ringkasan + Dinonaktifkan + Jatuhkan berkas untuk diimpor + Jatuhkan berkas yang didukung untuk diimpor + Hubungi kami langsung melalui surel untuk hal lainnya. + Sama dengan + Ekstra + Umpan balik + Bidang + Jalur folder + Dari sini + Pemindaian penuh + Gratis, tersisa %1$d + Buat rekap + Buat ringkasan + Laporkan bug, minta fitur, atau hubungi dukungan secara langsung. + Sponsor GitHub + Dukung pengembangan melalui Sponsor GitHub. + Masuk dengan Google tidak dikonfigurasi untuk build desktop ini. + Lebih besar dari + Laporan bug, permintaan fitur, dan dukungan + Sembunyikan + Impor berkas + Masalah + Buka pelacak masalah untuk bug dan permintaan fitur. + Kurang dari + Perpustakaan dan pembaca + Apa pun + Belum ada ringkasan tersimpan untuk buku ini. + Impor berkas TTF, OTF, atau WOFF2 untuk digunakan dalam buku. + Tidak ada font yang cocok dengan \"%1$s\" + Tidak ada akun Google yang terhubung. + Tidak ada ringkasan yang tersimpan untuk bagian ini. + Buka pembaca + Membuka %1$s + Membuka perpustakaan Anda + Operator + Halaman + PDF yang dilindungi kata sandi + Patreon + Dukung proyek ini di Patreon. + Dijeda + %1$s memerlukan kata sandi sebelum dapat dibuka. + Kata sandi diperlukan atau salah. + Kata sandi itu tidak membuka %1$s. Masukkan kata sandi PDF dan coba lagi. + Persen + Sedang menyiapkan audio + Pro + Pro dan kredit + Pro tidak diaktifkan untuk akun ini. + Pro dan kredit hanya dapat dibeli dari aplikasi Android. Desktop memeriksa akun masuk yang sama dan menggunakan kredit tersebut untuk cloud TTS, ringkasan, rekap, dan fitur AI berbayar lainnya. + Masuk untuk memeriksa status akun Anda di desktop. + Pro diaktifkan untuk akun ini. + Kemajuan + Proyek + Pembaca + Segarkan + Lepaskan untuk menambahkan ke perpustakaan Anda. + Penyimpanan kunci aman tidak tersedia pada sistem operasi ini. Kunci yang dimasukkan di sini akan digunakan untuk sesi ini tetapi tidak akan disimpan. + Pusat pengaturan + Cocok dengan tombol sembunyikan Android untuk kamus pintar, ringkasan, dan rekap. + Sudah masuk + Kode sumber + Telusuri sumber proyek di GitHub. + Hentikan pembacaan untuk mengganti suara. + Dukungan + Dukung Episteme + Kontribusi membantu menjaga pembaca terus berkembang di Android dan desktop. + Cara mendukung pengembangan Episteme + Sinkronkan folder + Sinkronkan metadata + Nama tag + Beri tag pada buku yang dipilih + Teks judul + Alat + Impor, sinkronisasi, dan pengaturan aplikasi + Jenis, mis. PDF + Tampilan + Tembolok suara + Sedang menyiapkan webview tersemat… + Sedang menyiapkan webview tersemat bawaan %1$d%% + Webview tersemat terpasang. Mulai ulang Episteme untuk menyelesaikan pengaturan. + Webview tersemat tidak dapat dimulai: %1$s + Sedang bekerja… + Ruang kerja + Tambahkan ke rak + Buat rak terlebih dahulu, lalu tambahkan buku yang dipilih ke dalamnya. + Buat tema + Yang ada: %1$s + Anda mengeklik tautan eksternal. + Edit metadata EPUB + Lebih sedikit + …selengkapnya + Belum ada tema kustom + Ubah nama di aplikasi + Tag, dipisahkan koma + Tidak diketahui + Definisikan + Anotasi + Opsi anotasi + Alat anotasi + Bantuan + Pilih PDF yang akan disimpan. + Hapus riwayat lompatan + Cloud TTS gagal. + Tambahkan kunci Gemini dan pilih Gemini cloud TTS di kunci dan model AI. + Cloud TTS tidak dikonfigurasi untuk build desktop ini. + Masuk dengan Google untuk menggunakan cloud TTS. + Cloud TTS memerlukan akun masuk dengan kredit. Pro dan kredit hanya dapat dibeli dari aplikasi Android. + Warna + Opsi komentar + Kustom + Ini akan menghapus anotasi dari PDF ini. + Hapus anotasi? + Teks dokumen + Komentar PDF tersemat + Gagal merender halaman. + Fitur tidak tersedia + Selesai + Pena tinta + Sembunyikan hasil pencarian + Warna sorotan %1$d + Palet penyorot + Interaksi + Mengindeks %1$d/%2$d halaman + Markup + %1$d kecocokan + %1$d kecocokan sejauh ini + Halaman berikutnya + Hasil pencarian berikutnya + Belum ada anotasi + Belum ada penanda + Tidak ada komentar + Tidak ada kecocokan + Belum ada kecocokan dalam halaman yang telah diindeks + Tidak ada daftar isi + Tidak ada teks di sini untuk dibacakan. + Tidak ada teks pada halaman ini untuk dibacakan. + Tidak ada teks untuk diringkas. + Buka komentar + Kredit habis. Pro dan kredit hanya dapat dibeli dari aplikasi Android. + Menggunakan cloud TTS memerlukan kredit di desktop. Pro dan kredit hanya dapat dibeli dari aplikasi Android. + Menggunakan fitur ini memerlukan kredit di desktop. Pro dan kredit hanya dapat dibeli dari aplikasi Android. + Menggunakan rekap memerlukan kredit di desktop. Pro dan kredit hanya dapat dibeli dari aplikasi Android. + Menggunakan ringkasan memerlukan kredit di desktop. Pro dan kredit hanya dapat dibeli dari aplikasi Android. + Geser + Aksi PDF gagal + Aksi PDF tidak dapat diselesaikan. + Komentar PDF + hlm. %1$d + Halaman PDF %1$d + Halaman %1$d - %2$s + Halaman %1$s dari %2$d + Halaman %1$s dari %2$d + PDF disimpan + Alat PDF + Pensil + Menyiapkan pilihan + Menyiapkan %1$s + Halaman sebelumnya + Hasil pencarian sebelumnya + Dialog cetak telah selesai. + Memerlukan Pro + Fitur ini memerlukan Pro. Pro hanya dapat dibeli dari aplikasi Android, lalu desktop akan menggunakan akun yang telah ditingkatkan setelah masuk. + Kamus pintar multikata memerlukan Pro. Pro hanya dapat dibeli dari aplikasi Android, lalu desktop akan menggunakan akun yang telah ditingkatkan setelah masuk. + Fitur AI pembaca disembunyikan. + AI desktop tidak dikonfigurasi untuk build ini. + Berlaku untuk mode baca vertikal. + Penyorot bulat + Disimpan ke %1$s + Gulir + Cari di PDF + Pilih teks + %1$s dipilih + Tampilkan hasil pencarian + Masuk dengan Google untuk menggunakan fitur ini di desktop. + Masuk dengan Google untuk menggunakan kamus pintar multikata di desktop. + Masuk dengan Google untuk menggunakan rekap di desktop. + Masuk dengan Google untuk menggunakan ringkasan di desktop. + Dihentikan + Catatan teks + catatan teks + Gaya teks + Ketebalan %1$s + Daftar isi + Ketik untuk mencari PDF ini + Tanpa judul + Lihat Pro dan kredit + Tembolok suara dibersihkan + Zoom + Perbesar + Perkecil + Pilih + Lanjutkan membaca + Tutup + Bawah + Atas + AI + Tengah + Penulis + Kembali ke perpustakaan + Aksi buku + Folder + Telusuri + Kategori + Bab %1$d + Pergantian Bab + Pilih font + Pilih tekstur pembaca + Hapus jenis berkas + Hapus anotasi halaman + Hapus sumber + Hapus status + Hapus tag + Tutup pembaca + Kontinu + Sampul + Warna kustom + Pratinjau tema kustom + Kurangi %1$s + Definisikan halaman + Ini akan menghapus sorotan dan catatannya. + Masuk layar penuh + Keluar dari layar penuh + Pencarian eksternal + Buku + Komik + Dokumen + Lainnya + Teks dan web + Isi + Tampilan tata letak tetap + Folder kosong + Tidak ada berkas atau subfolder yang didukung tersedia di sini. + %1$s, %2$s + %1$s - %2$s + Sembunyikan filter + Sembunyikan alat pembaca + Ketuk slot, lalu pilih warna. + Lanjutkan membaca dan buku terbaru + Impor buku + Impor folder + Font yang diimpor + %1$s %2$s + Tingkatkan %1$s + Riwayat lompatan + Tata Letak dan Jarak + Impor berkas ke penyimpanan aplikasi atau tambahkan folder untuk membaca berkas langsung di tempatnya. + Telusuri koleksi Anda + Pintar %1$d + Belum dibaca %1$d + Sedang dibaca %1$d + Selesai %1$d + Daftar + Navigasi + Tidak ada buku yang terbuka + Tambahkan folder untuk membaca berkas dari folder itu langsung di tempatnya. + Belum ada folder + Tidak ada item navigasi + Tidak ada konten halaman + Tidak ada pengaturan ditemukan + Rak manual dan koleksi seri akan muncul di sini. + Belum ada rak + Buat rak pintar untuk mengumpulkan buku berdasarkan aturan. + Belum ada rak pintar + Tag yang ditambahkan ke buku akan muncul di sini. + Belum ada tag + Tidak ada berkas yang didukung yang diimpor. + Katalog + Hapus %1$s? Buku yang di-streaming dari katalog ini mungkin berhenti terbuka jika kredensial berubah nanti. + Tidak ada katalog + Tambahkan katalog OPDS untuk menelusuri buku jarak jauh. + Telusuri katalog, stream, dan unduhan + Buka Buku + Buka folder + Buka PDF + Warna halaman dan teks + Info Halaman + Lebar halaman + Setelan bawaan ini berlaku jika platform mendukung tampilan PDF bersama. Override PDF per buku tetap berada di pembaca PDF. + Aksi berkas PDF + Penyorot PDF + Disimpan dengan transparansi sorotan pembaca. + Sematkan + Alat PDF yang dikelola pembaca + Gulir otomatis, OCR, bawaan anotasi, dan visibilitas alat khusus PDF dikelola di dalam pembaca PDF yang aktif. + %1$s %2$s dari %3$d (%4$d%%) + Setelan bawaan bilah alat pembaca dikelola dari pembaca di platform ini. + Alat pembaca + Simpan gambar + Cari: %1$s + Cari di pembaca + Pengaturan pencarian + Seleksi + Handle akhir seleksi + Handle awal seleksi + Tambahkan rak, tag, atau metadata folder untuk mengatur perpustakaan Anda. + Koleksi, seri, tag, dan folder + Tampilkan alat pembaca + Folder + Pintar + Solid + Kecepatan + Mulai gulir otomatis + Hentikan gulir otomatis + Hentikan pembacaan nyaring + Kekuatan tekstur + Ketik untuk mencari buku ini + Tipografi + Batalkan anotasi + Lepas sematan + Gunakan tema gelap + Gunakan tema terang + Mono + Sans + Serif + Cari buku, penulis, atau tag + Tidak ada alat + Terlihat + Ganti hanya yang dibacakan + Teks pembaca, sorotan, dan posisi tetap tidak berubah. + %1$s -> %2$s + Nonaktifkan sinkronisasi lokal + Aktifkan sinkronisasi lokal + Sinkronisasi lokal dinonaktifkan + Nonaktifkan sinkronisasi folder lokal? + Episteme akan berhenti memindai folder ini dan berhenti menulis JSON menyinkronkan file. Hapus %1$s folder dari folder ini juga? + Simpan data sinkronisasi + Hapus data sinkronisasi + Hapus Font? + Apakah Anda yakin ingin menghapus %1$d font yang dipilih? Ini akan menghapusnya dari semua perangkat Anda jika sinkronisasi aktif. + Tidak ada folder lokal yang sinkronisasinya diaktifkan. + Sinkronisasi folder lokal dinonaktifkan. + Sinkronisasi folder lokal dinonaktifkan. Folder data sinkronisasi dihapus. + Sinkronisasi folder lokal dinonaktifkan, namun folder data sinkronisasi tidak dapat dihapus. + Sinkronisasi folder lokal diaktifkan. + Vertikal (Tampilan Web) + Vertikal (Beta Asli) + Penggantian Kata Buku + Buku saat ini + Tambahkan aturan + Belum ada aturan pengganti untuk buku ini. + Pengganti baru + Edit penggantinya + Dengan + teks kosong + Akun & kredit + Ikhtisar akun + Episteme oss + Sinkronisasi awan + Bantuan + Tindakan perpustakaan + Lebih lanjut + Pembaca desktop offline + Rencana + Preferensi + Tab pembaca mati + Tab pembaca aktif + Sinkronkan akun, Pro, dan kredit + AI kunci diff --git a/app/src/main/res/values-it/plurals.xml b/app/src/main/res/values-it/plurals.xml index b3a8206..5c523bc 100644 --- a/app/src/main/res/values-it/plurals.xml +++ b/app/src/main/res/values-it/plurals.xml @@ -52,4 +52,88 @@ (%1$d segmento) (%1$d segmenti) + + Importazione %1$d libro… Apparirà a breve nella tua Libreria. + Importazione %1$d libri… Appariranno a breve nella tua Libreria. + + + Importato %1$d libro. Puoi trovarlo nella scheda Libreria. + Importato %1$d libri. Li puoi trovare nella scheda Libreria. + + + %1$d libro aggiunto allo scaffale. + %1$d libri aggiunti allo scaffale. + + + %1$d libro contrassegnato con "%2$s". + %1$d libri contrassegnati con "%2$s". + + + Cartella rimossa "%1$s" e %2$d prenotare dall\'app. + Cartella rimossa "%1$s" e %2$d libri dall\'app. + + + %1$d file + %1$d file + + + Rilascia per importare %1$d file + Rilascia per importare %1$d file + + + %1$d il file non supportato verrà ignorato. + %1$d i file non supportati verranno ignorati. + + + Importazione %1$d cartella… + Importazione %1$d file... + + + Importato %1$d file. + Importato %1$d file. + + + Importato %1$d file. Il supporto del lettore arriva più tardi. + Importato %1$d file. Il supporto del lettore arriva più tardi. + + + Impossibile importare %1$d file. + Impossibile importare %1$d file. + + + Saltato %1$d file. + Saltato %1$d file. + + + Rimuovi "%1$s" e il relativo %2$d prenotare dall\'app? I file sul disco non verranno eliminati. + Rimuovi "%1$s" e il relativo %2$d libri dall\'app? I file sul disco non verranno eliminati. + + + Sincronizzazione della cartella non riuscita per %1$d cartella. + Sincronizzazione della cartella non riuscita per %1$d cartelle. + + + Sincronizzazione della cartella terminata con %1$d cartella saltata. + Sincronizzazione della cartella terminata con %1$d cartelle saltate. + + + Rimosso %1$d trasmesso in streaming OPDS libro da quel catalogo. + Rimosso %1$d trasmesso in streaming OPDS libri da quel catalogo. + + + Tutti i libri %1$d + Tutti i libri %1$d + + + Ripiani %1$d + Ripiani %1$d + + + Tag %1$d + Tag %1$d + + + Cartelle %1$d + Cartelle %1$d + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 22ebf05..7de301b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1086,4 +1086,430 @@ Nederlands (olandese) Українська (ucraino) Bahasa Indonesia (indonesiano) + Mostra le schede nella barra dell\'app in alto + Disabilita la sincronizzazione locale + Abilita la sincronizzazione locale + Sincronizzazione locale disabilitata + Disabilitare la sincronizzazione delle cartelle locali? + Episteme interromperà la scansione di questa cartella e smetterà di scrivere JSON sincronizzare i file. Rimuovere %1$s cartella anche da questa cartella? + Conserva i dati sincronizzati + Rimuovi i dati di sincronizzazione + Eliminare i caratteri? + Sei sicuro di voler eliminare %1$d caratteri selezionati? Ciò li rimuoverà da tutti i tuoi dispositivi se la sincronizzazione è attiva. + Nessuna cartella locale ha la sincronizzazione abilitata. + Sincronizzazione delle cartelle locali disabilitata. + Sincronizzazione delle cartelle locali disabilitata. Cartella dati di sincronizzazione rimossa. + Sincronizzazione della cartella locale disabilitata, ma non è stato possibile rimuovere la cartella dei dati di sincronizzazione. + Sincronizzazione delle cartelle locali abilitata. + Verticale (Visualizzazione Web) + Verticale (Beta nativa) + Sostituzioni di parole del libro + Precedente TTS pezzo + Successivo TTS pezzo + Immagini + Nessuna immagine trovata. + Scarica l\'immagine + Salvato %1$s + Impossibile salvare l\'immagine. + PDF pagina diffusa + Pagina singola + Due pagine + Solo la prima pagina + Inizia le pagine affiancate dopo la copertina. + Luminosità + Utilizza la luminosità del sistema + Segue l\'impostazione della luminosità del dispositivo. + Luminosità personalizzata + Si applica mentre la schermata di un lettore è aperta. + %1$d%% + Scaffale creato "%1$s". + Creato scaffale intelligente "%1$s". + Scaffale rinominato in "%1$s". + Scaffale "%1$s" eliminato. + Aggiornato "%1$s". + Quei file sono già nella libreria. + %1$s - %2$s + Salva + Salva commento + Aggiungi commento + Rispondi + Aggiungi un commento... + Commenti + Modifica del commento + Rispondendo a %1$s + Utilizzare PDF Nomi dei file + Luminosità + Libro attuale + Aggiungi regola + Nessuna regola sostitutiva per questo libro ancora. + Nuova sostituzione + Modifica sostituzione + Con + testo vuoto + Circa + Lettore da tavolo + Accesso al desktop + Conto + Conto e crediti + Panoramica del conto + AI mozzo + Utilizzato per EPUB riepiloghi e PDF riepiloghi delle pagine. + Episteme oss + Testo dell\'autore + Cache: %1$s + Memorizzato nella cache + Riepilogo memorizzato nella cache + Scegliere Gemini voce utilizzata per la lettura ad alta voce nel cloud. + Elimina il libro desktop generato e EPUB file della cache di impaginazione? Verranno ricreati alla successiva apertura dei libri. + Cancella cache vocale + Chiudi strumenti + Sincronizzazione sul cloud + Nuvola TTS esigenze Gemini + Nuvola TTS necessita di crediti registrati + Nuvola TTS pronto + Nuvola TTS impostazioni + Nuvola TTS non disponibile + Nuvola TTS voce + Contiene + Calcolo dei costi + Crea un riepilogo fino alla tua posizione attuale. + Crea uno scaffale intelligente + %1$d crediti disponibili + %1$s crediti + Caratteri importati per il lettore + Elimina carattere + Elimina %1$s? I libri che lo utilizzano torneranno al carattere predefinito. + Eliminare \"%1$s\"? I libri rimangono nella tua libreria. + Elimina riepilogo + Disabilitato + Rilascia i file da importare + Rilascia i file supportati da importare + Contattaci direttamente via email per qualsiasi altra cosa. + Uguali + Extra + Feedback + Campo + Percorso della cartella + Da qui + Scansione completa + Gratuito, %1$d sinistra + Genera riepilogo + Genera riepilogo + Segnala bug, richiedi funzionalità o contatta direttamente l\'assistenza. + Sponsor di GitHub + Supportare lo sviluppo tramite gli sponsor GitHub. + Google l\'accesso non è configurato per questa build desktop. + Maggiore di + Aiuto + Segnalazioni di bug, richieste di funzionalità e supporto + Nasconditi + Importa file + Problemi + Apri il tracker dei problemi per bug e richieste di funzionalità. + Meno di + Biblioteca e lettore + Qualunque + Azioni della biblioteca + Di più + Nessun riepilogo ancora memorizzato nella cache per questo libro. + Importa file TTF, OTF o WOFF2 per usarli nei libri. + Nessun carattere trovato corrispondente a \"%1$s\" + No Google l\'account è collegato. + Nessun riepilogo memorizzato nella cache per questa sezione. + Lettore desktop offline + Lettori aperti + Apertura %1$s + Apertura della tua libreria + Operatore + Pagina + Protetto da password PDF + Patreon + Sostieni il progetto su Patreon. + In pausa + %1$s richiede una password prima di poter essere aperto. + La password è richiesta o errata. + Quella password non ha aperto %1$s. Immettere PDF password e riprovare. + Percentuale + Piano + Preparazione dell\'audio + Preferenze + Conto e crediti + Conto e crediti + Pro non è sbloccato per questo account. + Pro e crediti possono essere acquistati solo da Android app. Desktop controlla lo stesso account a cui è stato effettuato l\'accesso e utilizza tali crediti per il cloud TTS, riepiloghi, riepiloghi e altri pagamenti AI caratteristiche. + Accedi per verificare lo stato del tuo account sul desktop. + La versione Pro è sbloccata per questo account. + Progresso + Progetto + Lettore + Il lettore si spegne + Schede del lettore attive + Aggiorna + Rilascia per aggiungere alla tua libreria. + L\'archiviazione sicura delle chiavi non è disponibile su questo sistema operativo. Le chiavi immesse qui verranno utilizzate per questa sessione ma non verranno mantenute. + Hub delle impostazioni + Corrisponde a Android nascondi l\'interruttore per dizionario intelligente, riepiloghi e riepiloghi. + Sincronizza account, Pro e crediti + Effettuato l\'accesso + Codice sorgente + Sfoglia l\'origine del progetto su GitHub. + Smetti di leggere per cambiare voce. + Supporto + Supporto Episteme + I contributi aiutano il lettore a migliorare attraverso Android e desktop. + Modi per sostenere Episteme sviluppo + Sincronizza cartelle + Sincronizza i metadati + Nome dell\'etichetta + Tagga i libri selezionati + Testo del titolo + Strumenti + Importa, sincronizza e imposta le app + Digitare, ad es. PDF + Visualizza + Cache vocale + Preparazione della visualizzazione Web incorporata… + Preparazione della visualizzazione Web incorporata in bundle %1$d%% + Visualizzazione Web incorporata installata. Riavvia Episteme per completare la configurazione. + Impossibile avviare la visualizzazione Web incorporata: %1$s + Lavorando… + Spazio di lavoro + Aggiungi allo scaffale + Crea prima uno scaffale, quindi aggiungivi i libri selezionati. + Crea tema + Esistente: %1$s + Hai fatto clic su un collegamento esterno. + Modifica EPUB metadati + Meno + …di più + Nessun tema personalizzato ancora + Rinomina nell\'app + Tag separati da virgole + Sconosciuto + Definire + Annotazione + Opzioni di annotazione + Strumenti di annotazione + Assistere + Scegli quale PDF salvare. + Cancella la cronologia dei salti + Nuvola TTS fallito. + Aggiungi un Gemini e selezionare Gemini nuvola TTS in AI chiavi e modelli. + Nuvola TTS non è configurato per questa build desktop. + Accedi con Google utilizzare il cloud TTS. + Nuvola TTS necessita di un account registrato con crediti. Pro e crediti possono essere acquistati solo da Android app. + Colore + Opzioni di commento + Personalizzato + Ciò rimuove l\'annotazione da questo PDF. + Eliminare l\'annotazione? + Testo del documento + Incorporato PDF commento + Impossibile eseguire il rendering della pagina. + Funzionalità non disponibile + Finito + Penna stilografica + Nascondi i risultati della ricerca + Colore evidenziazione %1$d + Tavolozza di evidenziatori + Interazione + Indicizzazione %1$d/%2$d pagine + Markup + %1$d partite + %1$d partite finora + Pagina successiva + Risultato della ricerca successivo + Nessuna annotazione ancora + Nessun segnalibro ancora + Nessun commento + Nessuna corrispondenza + Ancora nessuna corrispondenza nelle pagine indicizzate + Nessun sommario + Non c\'è testo qui da leggere. + Non c\'è testo da leggere in questa pagina. + Non c\'è testo da riassumere. + Apri commento + Crediti esauriti. Pro e crediti possono essere acquistati solo da Android app. + Utilizzo del cloud TTS necessita di crediti sul desktop. Pro e crediti possono essere acquistati solo da Android app. + L\'utilizzo di questa funzionalità richiede crediti sul desktop. Pro e crediti possono essere acquistati solo da Android app. + L\'utilizzo dei riepiloghi richiede crediti sul desktop. Pro e crediti possono essere acquistati solo da Android app. + L\'utilizzo dei riepiloghi richiede crediti sul desktop. Pro e crediti possono essere acquistati solo da Android app. + Pan + PDF azione fallita + Il PDF Impossibile completare l\'azione. + PDF commento + P. %1$d + PDF pagina %1$d + Pagina %1$d - %2$s + Pagina %1$s di %2$d + Pagine %1$s di %2$d + PDF salvato + PDF strumenti + Matita + Preparazione della selezione + Preparazione %1$s + Pagina precedente + Risultato della ricerca precedente + La finestra di dialogo di stampa è terminata. + Pro richiesto + Questa funzionalità richiede Pro. Pro può essere acquistato solo da Android app, il desktop utilizzerà l\'account aggiornato dopo l\'accesso. + Il dizionario intelligente di più parole richiede Pro. Pro può essere acquistato solo da Android app, il desktop utilizzerà l\'account aggiornato dopo l\'accesso. + Lettore AI le funzionalità sono nascoste. + Desktop AI non è configurato per questa build. + Si applica alla lettura verticale e alle pagine doppie. + Evidenziatore rotondo + Salvato in %1$s + Scorri + Cerca in PDF + Seleziona il testo + Selezionato %1$s + Mostra i risultati della ricerca + Accedi con Google per utilizzare questa funzionalità sul desktop. + Accedi con Google per utilizzare il dizionario intelligente di più parole sul desktop. + Accedi con Google per utilizzare i riepiloghi sul desktop. + Accedi con Google per utilizzare i riepiloghi sul desktop. + Fermato + Nota di testo + nota di testo + Stile del testo + Spessore %1$s + SOMMARIO + Digita per cercare questo PDF + Senza titolo + Visualizza account e crediti + Cache vocale cancellata + Zoom + Ingrandisci + Rimpicciolisci + Scegli + Continua a leggere + Ignora + Giù + Su + AI + Centro + Autori + Ritorno in biblioteca + Azioni del libro + Cartella + Sfoglia + Categorie + cap. %1$d + Il capitolo gira + Scegli il carattere + Scegli la trama del lettore + Cancella tipi di file + Cancellare le annotazioni della pagina + Fonti chiare + Stato chiaro + Cancella tag + Lettore vicino + Continuo + Copertine + Colori personalizzati + Anteprima del tema personalizzato + Diminuisci %1$s + Definire la pagina + Ciò rimuove l\'evidenziazione e la relativa nota. + Entra a schermo intero + Esci dallo schermo intero + Ricerca esterna + Libri + Fumetti + Documenti + Altro + Testo e web + Riempi + Aspetto con layout fisso + La cartella è vuota + Qui non sono disponibili file o sottocartelle supportati. + %1$s, %2$s + %1$s - %2$s + Nascondi filtri + Nascondi gli strumenti di lettura + Tocca uno slot, quindi scegli un colore. + Continua a leggere e libri recenti + Importa libri + Cartella di importazione + Caratteri importati + %1$s %2$s + Aumenta %1$s + Salta la cronologia + Disposizione e spaziatura + Importa file nell\'archivio app o aggiungi una cartella per leggere i file sul posto. + Sfoglia la tua collezione + AI chiavi + Intelligente %1$d + Non letto %1$d + In corso %1$d + Completa %1$d + Elenco + Navigazione + Nessun libro aperto + Aggiungi una cartella per leggere i file da quella cartella sul posto. + Nessuna cartella ancora + Nessun elemento di navigazione + Nessun contenuto della pagina + Nessuna impostazione trovata + Gli scaffali manuali e le raccolte di serie appariranno qui. + Ancora nessuno scaffale + Crea scaffali intelligenti per raccogliere libri secondo regole. + Non ci sono ancora scaffali intelligenti + I tag aggiunti ai libri verranno visualizzati qui. + Nessun tag ancora + Non è stato importato alcun file supportato. + Catalogo + Eliminare "%1$s"? I libri trasmessi in streaming da questo catalogo potrebbero interrompersi se le credenziali cambiano in seguito. + Nessun cataloghi + Aggiungi un OPDS catalogo per sfogliare libri a distanza. + Sfoglia cataloghi, stream e download + Libro aperto + Apri cartella + Apri PDF + Colori della pagina e del testo + Informazioni sulla pagina + Larghezza della pagina + Queste impostazioni predefinite si applicano laddove la piattaforma supporta PDF condiviso aspetto. Per libro PDF sostituisce il soggiorno in PDF lettore. + PDF azioni sui file + PDF evidenziatore + Salvato con la trasparenza dell\'evidenziazione del lettore. + Perno + Gestito dal lettore PDF strumenti + Lo scorrimento automatico, OCR, le impostazioni predefinite delle annotazioni e la visibilità dello strumento solo PDF sono gestiti all\'interno del PDF attivo. lettore. + %1$s %2$s di %3$d (%4$d%%) + Le impostazioni predefinite della barra degli strumenti del lettore sono gestite dal lettore su questa piattaforma. + Strumenti di lettura + Salva immagine + Cerca: %1$s + Cerca nel lettore + Impostazioni di ricerca + Selezione + Maniglia fine selezione + Maniglia di inizio selezione + Aggiungi scaffali, tag o metadati di cartelle per organizzare la tua libreria. + Raccolte, serie, tag e cartelle + Mostra gli strumenti di lettura + Cartella + Intelligente + Solido + Velocità + Avvia lo scorrimento automatico + Arresta lo scorrimento automatico + Smetti di leggere ad alta voce + Forza della trama + Digita per cercare in questo libro + Tipografia + Annulla annotazione + Sblocca + Utilizza il tema scuro + Usa il tema chiaro + Mono + Sans + Serif + Cerca libri, autori o tag + Nessuno strumento + Visibile + Sostituisci solo ciò che viene detto + Il testo, le evidenziazioni e le posizioni del lettore rimangono invariati. + %1$s -> %2$s diff --git a/app/src/main/res/values-ja/plurals.xml b/app/src/main/res/values-ja/plurals.xml index be50ff1..87f3ae2 100644 --- a/app/src/main/res/values-ja/plurals.xml +++ b/app/src/main/res/values-ja/plurals.xml @@ -2,41 +2,138 @@ %1$d冊の本 + %1$d本 + %1$d個の本棚 + %1$d棚 %1$d件の結果が見つかりました + %1$d結果が見つかりました %1$d件の一致が見つかりました + %1$d一致が見つかりました ファイルを完全に削除 + ファイルを完全に削除 選択した%1$d個のファイルをデバイスから完全に削除しますか?この操作は元に戻せません。 + %1$d を完全に削除しますか?デバイスからファイルを選択しましたか?この操作は元に戻すことができません。 選択した%1$d個のファイルを最近使ったファイルから削除しますか?ライブラリからもう一度開くと再表示されます。 + %1$d を削除しますか?最近使用したファイルのリストからファイルを選択しましたか?ライブラリから再度開くと再び表示されます。 %1$d冊の本を本棚「%2$s」から削除しますか?本はライブラリに残り、本棚未設定に表示されます。 + %1$d 削除してもよろしいですか? \'%2$s\' の本棚?この本はライブラリに残り、「未棚」の下に表示されます。 %1$d冊の本をライブラリから削除しました。 + %1$d図書館から削除された本。 %1$d個のフォルダー + %1$dフォルダ %1$d個のタグ + %1$dタグ (%1$dチャンク) + (%1$d チャンク) + + + インポート中 %1$d本… すぐにライブラリに表示されます。 + インポート中 %1$d本…まもなくライブラリに表示されます。 + + + 輸入品 %1$d本。これらは「ライブラリ」タブで見つけることができます。 + 輸入品 %1$d本。 [ライブラリ]タブで見つけることができます。 + + + %1$d本が棚に追加されました。 + %1$d本が棚に追加されました。 + + + %1$d 「%2$s」のタグが付けられた本。 + %1$d 「%2$s」のタグが付けられた本。 + + + フォルダー「%1$s」を削除しましたそして %2$dアプリから本を。 + フォルダー「%1$s」を削除しましたそして %2$dアプリから予約します。 + + + %1$dファイル + %1$dファイル + + + ドロップしてインポート %1$dファイル + ドロップしてインポート %1$dファイル + + + %1$dサポートされていないファイルはスキップされます。 + %1$dサポートされていないファイルはスキップされます。 + + + インポート中 %1$dファイル… + インポート中 %1$dファイル… + + + 輸入品 %1$dファイル。 + 輸入品 %1$dファイル。 + + + 輸入品 %1$dファイル。読者サポートは後で提供されます。 + 輸入品 %1$dファイル。読者サポートは後で提供されます。 + + + %1$d をインポートできませんでしたファイル。 + %1$d をインポートできませんでしたファイル。 + + + スキップされました %1$dファイル。 + スキップされました %1$dファイル。 + + + 「%1$s」を削除しますそしてその %2$dアプリから本を?ディスク上のファイルは削除されません。 + 「%1$s」を削除しますそしてその %2$dアプリから予約しますか?ディスク上のファイルは削除されません。 + + + %1$d のフォルダー同期が失敗しましたフォルダー。 + %1$d のフォルダー同期が失敗しましたフォルダ。 + + + フォルダーの同期は %1$d で終了しましたフォルダーはスキップされました。 + フォルダーの同期は %1$d で終了しましたフォルダがスキップされました。 + + + 削除されました %1$dストリーミング OPDSそのカタログからの本。 + 削除されました %1$dストリーミング OPDSそのカタログからの本。 + + + すべての書籍 %1$d + すべての書籍 %1$d + + + 棚 %1$d + 棚 %1$d + + + タグ %1$d + タグ %1$d + + + フォルダー %1$d + フォルダー %1$d diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 08a442f..51631a6 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1086,4 +1086,430 @@ Nederlands (オランダ語) Українська (ウクライナ語) Bahasa Indonesia (インドネシア語) + トップアプリバーにタブを表示 + ローカル同期を無効にする + ローカル同期を有効にする + ローカル同期が無効になっています + ローカルフォルダーの同期を無効にしますか? + Epistemeこのフォルダのスキャンを停止し、書き込みを停止します JSONファイルを同期します。 %1$s を取り外します。このフォルダからもフォルダを作成しますか? + 同期データを維持する + 同期データを削除する + フォントを削除しますか? + %1$d 削除してもよろしいですか?選択されたフォント?同期がオンになっている場合、これによりすべてのデバイスからそれらが削除されます。 + 同期が有効になっているローカル フォルダーはありません。 + ローカルフォルダーの同期が無効になっています。 + ローカルフォルダーの同期が無効になっています。同期データフォルダーが削除されました。 + ローカル フォルダーの同期は無効になっていますが、同期データ フォルダーを削除できませんでした。 + ローカルフォルダーの同期が有効になりました。 + 縦型 (WebView) + 垂直 (ネイティブ ベータ版) + 書籍の単語の置換 + 前 TTSかたまり + 次へ TTSかたまり + 画像 + 画像が見つかりませんでした。 + 画像をダウンロード + 保存しました %1$s + 画像を保存できませんでした。 + PDF見開きページ + 単一ページ + 2ページ + 最初のページだけ + 表紙の後に見開きページが始まります。 + 輝度 + システムの明るさを使用する + デバイスの明るさ設定に従います。 + カスタムの明るさ + リーダー画面が開いているときに適用されます。 + %1$d%% + シェルフ「%1$s」を作成しました。 + スマートシェルフ「%1$s」を作成しました。 + シェルフの名前を「%1$s」に変更しました。 + シェルフ「%1$s」を削除しました。 + 「%1$s」を更新しました。 + これらのファイルはすでにライブラリにあります。 + %1$s - %2$s + 保存 + コメントの保存 + コメントの追加 + 返事 + コメントを追加… + コメント + コメントの編集 + %1$s に返信します + PDF を使用しますファイル名 + 輝度 + 現在の本 + ルールの追加 + この本の代替ルールはまだありません。 + 新しい交換品 + 置換の編集 + + 空のテキスト + について + デスクトップリーダー + デスクトップアクセス + アカウント + アカウントとクレジット + アカウントの概要 + AIハブ + EPUB に使用概要と PDFページの概要。 + Epistemeオス + 著者のテキスト + キャッシュ: %1$s + キャッシュされた + キャッシュされた概要 + Gemini を選択してくださいクラウドの読み上げに使用される音声。 + 生成されたデスクトップ ブックと EPUB を削除します。ページネーションキャッシュファイル?次回本を開いたときに再作成されます。 + 音声キャッシュをクリアする + ツールを閉じる + クラウド同期 + クラウド TTS Gemini が必要です + クラウド TTSサインインしたクレジットが必要です + クラウド TTS準備ができて + クラウド TTS設定 + クラウド TTS利用不可 + クラウド TTS声 + 含まれています + コスト計算 + 現在の立場までの要約を作成します。 + スマートシェルフを作成する + %1$d利用可能なクレジット + %1$sクレジット + リーダー用にインポートされたフォント + フォントの削除 + %1$s を削除しますか?これを使用する書籍はデフォルトのフォントに戻ります。 + 「%1$s\」を削除しますか?本はあなたの図書館に残ります。 + 概要の削除 + 無効 + インポートするファイルをドロップします + サポートされているファイルをドロップしてインポートする + それ以外のことについては、メールで直接お問い合わせください。 + 等しい + エクストラ + フィードバック + 分野 + フォルダーパス + ここから + フルスキャン + 無料、%1$d左 + 要約を生成する + 概要の生成 + バグを報告したり、機能をリクエストしたり、サポートに直接連絡したりできます。 + GitHub スポンサー + GitHub スポンサーを通じて開発をサポートします。 + Googleこのデスクトップ ビルドにはサインインが構成されていません。 + より大きい + ヘルプ + バグレポート、機能リクエスト、サポート + 隠れる + ファイルをインポートする + 問題 + バグや機能リクエストについては、問題トラッカーを開いてください。 + 未満 + ライブラリとリーダー + どれでも + ライブラリのアクション + もっと + この本のキャッシュされた要約はまだありません。 + TTF、OTF、または WOFF2 ファイルをインポートして書籍で使用します。 + 「%1$s\」に一致するフォントが見つかりませんでした + いいえ Googleアカウントが接続されています。 + このセクションの要約はキャッシュされていません。 + オフラインデスクトップリーダー + オープンリーダー + オープニング %1$s + ライブラリを開く + オペレーター + ページ + パスワードで保護されています PDF + パトレオン + Patreon でプロジェクトをサポートしてください。 + 一時停止中 + %1$s開く前にパスワードが必要です。 + パスワードが必要か、間違っています。 + そのパスワードは %1$s を開けませんでした。 PDF を入力します。パスワードを入力して再試行してください。 + パーセント + プラン + 音声の準備中 + 設定 + アカウントとクレジット + アカウントとクレジット + このアカウントでは Pro のロックが解除されていません。 + Pro とクレジットは Android からのみ購入できます。アプリ。デスクトップは同じサインイン アカウントをチェックし、それらのクレジットをクラウド TTS、概要、要約、その他の有料 AI に使用します。特徴。 + サインインしてデスクトップでアカウントのステータスを確認します。 + このアカウントでは Pro のロックが解除されています。 + 進捗 + プロジェクト + リーダー + リーダーのタブがオフになっています + リーダータブ + リフレッシュ + リリースしてライブラリに追加します。 + このオペレーティング システムでは安全なキー ストレージを利用できません。ここに入力されたキーはこのセッションで使用されますが、永続化されません。 + 設定ハブ + Android と一致しますスマート辞書、要約、要約のトグルを非表示にします。 + アカウント、Pro、クレジットを同期する + サインインしました + ソースコード + GitHub でプロジェクトのソースを参照します。 + 声を変えるには読むのをやめてください。 + サポート + サポート Episteme + 貢献は Android 全体で読者の向上を維持するのに役立ちます。そしてデスクトップ。 + Episteme をサポートする方法発達 + フォルダーを同期する + メタデータを同期する + タグ名 + 選択した書籍にタグを付ける + タイトルテキスト + ツール + インポート、同期、アプリ設定 + タイプ: 例: PDF + ビュー + 音声キャッシュ + 埋め込み Web ビューを準備しています… + バンドルされた埋め込み Web ビューを準備しています %1$d%% + 埋め込みWebViewがインストールされています。再起動 Epistemeセットアップを終了します。 + 埋め込み Web ビューを開始できませんでした: %1$s + 働く… + ワークスペース + 棚に追加 + まず棚を作成し、選択した本をそこに追加します。 + テーマの作成 + 既存: %1$s + 外部リンクをクリックしました。 + 編集 EPUBメタデータ + 少ない + …もっと + カスタムテーマはまだありません + アプリ内で名前を変更する + タグ、カンマ区切り + 未知 + 定義する + 注釈 + 注釈オプション + 注釈ツール + アシスト + PDF どちらを選択してください保存するために。 + ジャンプ履歴をクリアする + クラウド TTS失敗した。 + Gemini を追加しますキーを押して Gemini を選択します。クラウド TTS AI でキーとモデル。 + クラウド TTSはこのデスクトップ ビルド用に構成されていません。 + Google でサインインしますクラウド TTS を使用します。 + クラウド TTSクレジットのあるサインインしたアカウントが必要です。 Pro とクレジットは Android からのみ購入できます。アプリ。 + + コメントオプション + カスタム + これにより、この PDF から注釈が削除されます。 + 注釈を削除しますか? + 文書テキスト + 埋め込み型 PDFコメント + ページのレンダリングに失敗しました。 + 利用できない機能 + 終了した + 万年筆 + 検索結果を非表示にする + ハイライトカラー %1$d + ハイライトパレット + 交流 + インデックス作成 %1$d/%2$dページ + マークアップ + %1$dマッチ + %1$dこれまでの試合 + 次のページ + 次の検索結果 + まだ注釈はありません + まだブックマークはありません + ノーコメント + 一致しません + インデックスされたページに一致するものはまだありません + 目次なし + ここには読むべきテキストはありません。 + このページには読むべきテキストはありません。 + 要約するテキストはありません。 + コメントを開く + クレジットが不足しています。 Pro とクレジットは Android からのみ購入できます。アプリ。 + クラウドの使用 TTSデスクトップにクレジットが必要です。 Pro とクレジットは Android からのみ購入できます。アプリ。 + この機能を使用するには、デスクトップにクレジットが必要です。 Pro とクレジットは Android からのみ購入できます。アプリ。 + Recaps を使用するには、デスクトップにクレジットが必要です。 Pro とクレジットは Android からのみ購入できます。アプリ。 + 概要を使用するには、デスクトップにクレジットが必要です。 Pro とクレジットは Android からのみ購入できます。アプリ。 + パン + PDFアクションが失敗しました + PDFアクションを完了できませんでした。 + PDFコメント + p. %1$d + PDFページ %1$d + ページ %1$d - %2$s + ページ %1$s %2$d + ページ %1$s %2$d + PDF保存されました + PDFツール + 鉛筆 + 選択範囲を準備しています + 準備中 %1$s + 前のページへ + 前回の検索結果 + 印刷ダイアログが終了しました。 + プロが必要 + この機能には Pro が必要です。 Pro は Android からのみ購入できます。アプリの場合、デスクトップではサインイン後にアップグレードされたアカウントが使用されます。 + 複数の単語のスマート辞書には Pro が必要です。 Pro は Android からのみ購入できます。アプリの場合、デスクトップではサインイン後にアップグレードされたアカウントが使用されます。 + リーダー AI特徴が隠されています。 + デスクトップ AIはこのビルド用に構成されていません。 + 縦読みおよび見開きに適用されます。 + ラウンドハイライター + %1$s に保存されました + スクロール + PDF で検索します + テキストを選択 + 選択済み %1$s + 検索結果を表示する + Google でサインインしますこの機能をデスクトップで使用するには、 + Google でサインインしますデスクトップで複数単語のスマート辞書を使用するには。 + Google でサインインしますデスクトップで要約を使用するには。 + Google でサインインしますデスクトップで概要を使用します。 + 停止しました + テキストメモ + テキストメモ + テキストスタイル + 厚さ %1$s + 目次 + PDF と入力して検索します + 無題 + アカウントとクレジットを表示する + 音声キャッシュがクリアされました + ズーム + ズームイン + ズームアウト + 選ぶ + 続きを読む + 却下する + + + AI + 中心 + 著者 + ライブラリに戻る + 予約アクション + フォルダ + ブラウズ + カテゴリー + Ch. %1$d + 章の変わり目 + フォントを選択してください + リーダーのテクスチャを選択する + クリアファイルの種類 + ページの注釈をクリアする + 出典を明確にする + クリアステータス + タグをクリアする + 読者を閉じる + 継続的 + カバー + カスタムカラー + カスタムテーマのプレビュー + 減少 %1$s + ページの定義 + これにより、ハイライトとそのメモが削除されます。 + 全画面表示に入る + 全画面表示を終了する + 外部ルックアップ + + 漫画 + 書類 + 他の + テキストとウェブ + 埋める + 固定レイアウトの外観 + フォルダーが空です + ここでは、サポートされているファイルやサブフォルダーは利用できません。 + %1$s、%2$s + %1$s - %2$s + フィルターを非表示にする + リーダーツールを非表示にする + スロットをタップして、色を選択します。 + 続きを読むと最近の本 + 本を輸入する + インポートフォルダー + インポートされたフォント + %1$s %2$s + 増加 %1$s + ジャンプ履歴 + レイアウトと間隔 + ファイルをアプリのストレージにインポートするか、フォルダーを追加してファイルを所定の場所に読み込みます。 + コレクションを閲覧する + AIキー + スマート %1$d + 未読 %1$d + 進行中 %1$d + 完了 %1$d + リスト + ナビゲーション + 本が開いていない + 適切な場所にフォルダーを追加して、そのフォルダーからファイルを読み取ります。 + まだフォルダがありません + ナビゲーション項目はありません + ページコンテンツがありません + 設定が見つかりません + 手動棚やシリーズコレクションがここに表示されます。 + まだ棚がありません + ルールに従って本を収集するためのスマートな棚を作成します。 + スマートシェルフはまだありません + 書籍に追加されたタグがここに表示されます。 + まだタグがありません + サポートされているファイルはインポートされませんでした。 + カタログ + 「%1$s」を削除しますか?資格情報が後で変更されると、このカタログからストリーミングされた書籍が開かなくなる可能性があります。 + カタログなし + OPDS を追加しますリモートの書籍を閲覧するためのカタログ。 + カタログ、ストリーム、ダウンロードを閲覧する + 開いた本 + フォルダーを開く + 開く PDF + ページとテキストの色 + ページ情報 + ページ幅 + これらのデフォルトは、プラットフォームが共有 PDF をサポートする場合に適用されます。外観。本ごと PDFオーバーライドは PDF に残ります。リーダー。 + PDFファイルアクション + PDFハイライター + リーダーのハイライト透明度を使用して保存されます。 + ピン + リーダー管理 PDFツール + 自動スクロール、OCR、注釈のデフォルト、および PDF のみのツールの表示設定は、アクティブな PDF 内で管理されます。リーダー。 + %1$s %2$s %3$d (%4$d%%) + リーダー ツールバーのデフォルトは、このプラットフォームのリーダーから管理されます。 + リーダーツール + 画像の保存 + 検索: %1$s + リーダー内で検索 + 検索設定 + 選択 + 選択終了ハンドル + 選択開始ハンドル + シェルフ、タグ、またはフォルダーのメタデータを追加して、ライブラリを整理します。 + コレクション、シリーズ、タグ、フォルダー + リーダーツールを表示 + フォルダ + 頭いい + 固体 + スピード + 自動スクロールを開始します + 自動スクロールを停止する + 読み上げをやめる + 質感の強さ + この本を検索するには入力してください + タイポグラフィ + 注釈を元に戻す + 固定を解除する + ダークテーマを使用する + ライトテーマを使用する + 単核症 + サンズ + セリフ + 書籍、著者、またはタグを検索する + 工具なし + 見える + 話されている内容だけを置き換える + リーダーのテキスト、ハイライト、位置は変更されません。 + %1$s -> %2$s diff --git a/app/src/main/res/values-ko/plurals.xml b/app/src/main/res/values-ko/plurals.xml index 23ccbcf..77df96c 100644 --- a/app/src/main/res/values-ko/plurals.xml +++ b/app/src/main/res/values-ko/plurals.xml @@ -2,41 +2,138 @@ 책 %1$d권 + %1$d 책 + 책장 %1$d개 + %1$d 선반 결과 %1$d개 발견 + %1$d 결과를 찾았습니다 일치 항목 %1$d개 발견 + %1$d 일치하는 항목을 찾았습니다. 파일 영구 삭제 + 파일을 영구적으로 삭제 선택한 파일 %1$d개를 기기에서 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + %1$d을(를) 영구적으로 삭제하시겠습니까? 장치에서 파일을 선택하셨나요? 이 작업은 취소할 수 없습니다. 선택한 파일 %1$d개를 최근 파일 목록에서 제거하시겠습니까? 라이브러리에서 다시 열면 다시 표시됩니다. + %1$d을(를) 제거하시겠습니까? 최근 파일 목록에서 선택한 파일은 무엇입니까? 라이브러리에서 다시 열면 다시 나타납니다. 책 %1$d권을 “%2$s” 책장에서 제거하시겠습니까? 책은 라이브러리에 남아 미분류로 표시됩니다. + %1$d을(를) 제거하시겠습니까? \'%2$s\' 선반? 책은 라이브러리에 남아 있으며 Unshelved 아래에 표시됩니다. 책 %1$d권을 라이브러리에서 제거했습니다. + %1$d 도서관에서 책이 제거되었습니다. 폴더 %1$d개 + %1$d 폴더 태그 %1$d개 + %1$d 태그 (%1$d개 청크) + (%1$d 청크) + + + 가져오는 중 %1$d 책… 곧 라이브러리에 표시됩니다. + 가져오는 중 %1$d 책… 곧 라이브러리에 표시됩니다. + + + 가져옴 %1$d 서적. 라이브러리 탭에서 찾을 수 있습니다. + 가져옴 %1$d 책. 라이브러리 탭에서 찾을 수 있습니다. + + + %1$d 책장에 책이 추가되었습니다. + %1$d 책이 서가에 추가되었습니다. + + + %1$d "%2$s" 태그가 붙은 책. + %1$d "%2$s" 태그가 붙은 책입니다. + + + "%1$s" 폴더가 제거되었습니다. 그리고 %2$d 앱에서 책을 읽습니다. + "%1$s" 폴더가 제거되었습니다. 그리고 %2$d 앱에서 예약하세요. + + + %1$d 파일 + %1$d 파일 + + + 가져오기로 드롭 %1$d 파일 + 가져오기로 드롭 %1$d 파일 + + + %1$d 지원되지 않는 파일은 건너뜁니다. + %1$d 지원되지 않는 파일은 건너뜁니다. + + + 가져오는 중 %1$d 파일… + 가져오는 중 %1$d 파일… + + + 가져옴 %1$d 파일. + 가져옴 %1$d 파일. + + + 가져옴 %1$d 파일. 독자 지원은 나중에 제공됩니다. + 가져옴 %1$d 파일. 독자 지원은 나중에 제공됩니다. + + + %1$d가져올 수 없습니다. 파일. + %1$d가져올 수 없습니다. 파일. + + + 건너뛰었습니다 %1$d 파일. + 건너뛰었습니다 %1$d 파일. + + + "%1$s" 제거 그리고 %2$d 앱에서 책을? 디스크에 있는 파일은 삭제되지 않습니다. + "%1$s" 제거 그리고 %2$d 앱에서 예약하시겠어요? 디스크에 있는 파일은 삭제되지 않습니다. + + + %1$d에 대한 폴더 동기화가 실패했습니다. 폴더. + %1$d에 대한 폴더 동기화가 실패했습니다. 접는 사람. + + + %1$d로 폴더 동기화가 완료되었습니다. 폴더를 건너뛰었습니다. + %1$d로 폴더 동기화가 완료되었습니다. 폴더를 건너뛰었습니다. + + + 제거됨 %1$d 스트리밍됨 OPDS 그 카탈로그의 책들. + 제거됨 %1$d 스트리밍됨 OPDS 그 카탈로그에서 책을 읽으세요. + + + 모든 책 %1$d + 모든 책 %1$d + + + 선반 %1$d + 선반 %1$d + + + 태그 %1$d + 태그 %1$d + + + 폴더 %1$d + 폴더 %1$d diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 4445a1c..4691082 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1086,4 +1086,430 @@ Nederlands (네덜란드어) Українська (우크라이나어) Bahasa Indonesia (인도네시아어) + 상단 앱 바에 탭 표시 + 로컬 동기화 비활성화 + 로컬 동기화 활성화 + 로컬 동기화가 비활성화되었습니다. + 로컬 폴더 동기화를 비활성화하시겠습니까? + Episteme 이 폴더 스캔을 중지하고 쓰기를 중지합니다 JSON 동기화 파일. %1$s를 제거하세요. 이 폴더에도 있나요? + 동기화 데이터 유지 + 동기화 데이터 삭제 + 글꼴을 삭제하시겠습니까? + 정말로 %1$d을(를) 삭제하시겠습니까? 선택한 글꼴? 동기화가 켜져 있으면 모든 기기에서 해당 항목이 제거됩니다. + 동기화가 활성화된 로컬 폴더가 없습니다. + 로컬 폴더 동기화가 비활성화되었습니다. + 로컬 폴더 동기화가 비활성화되었습니다. 동기화 데이터 폴더가 제거되었습니다. + 로컬 폴더 동기화가 비활성화되었지만 동기화 데이터 폴더를 제거할 수 없습니다. + 로컬 폴더 동기화가 활성화되었습니다. + 수직(WebView) + 수직(네이티브 베타) + 책 단어 교체 + 이전 TTS 덩어리 + 다음 TTS 덩어리 + 이미지 + 이미지를 찾을 수 없습니다. + 이미지 다운로드 + 저장됨 %1$s + 이미지를 저장할 수 없습니다. + PDF 페이지 펼치기 + 단일 페이지 + 두 페이지 + 첫 페이지만 + 표지 이후 양면 페이지 펼치기를 시작합니다. + 밝기 + 시스템 밝기 사용 + 장치 밝기 설정을 따릅니다. + 맞춤 밝기 + 리더 화면이 열려 있는 동안 적용됩니다. + %1$d%% + "%1$s" 서가를 생성했습니다. + 스마트 선반 \'%1$s\'을(를) 생성했습니다. + 선반 이름을 "%1$s"로 변경했습니다. + "%1$s" 서가를 삭제했습니다. + "%1$s"을 업데이트했습니다. + 해당 파일은 이미 라이브러리에 있습니다. + %1$s - %2$s + 저장 + 댓글 저장 + 댓글 추가 + 답장하다 + 댓글 추가… + 댓글 + 댓글 편집 중 + %1$s에 응답합니다. + PDF 사용 파일 이름 + 밝기 + 현재 도서 + 규칙 추가 + 이 책에는 아직 대체 규칙이 없습니다. + 새로운 교체 + 교체 수정 + + 빈 텍스트 + 소개 + 데스크탑 리더 + 데스크톱 액세스 + 계정 + 계정 및 크레딧 + 계정 개요 + AI 허브 + EPUB에 사용됩니다. 요약 및 PDF 페이지 요약. + Episteme 오스 + 작성자 텍스트 + 캐시: %1$s + 캐시됨 + 캐시된 요약 + Gemini을 선택하세요. 클라우드 읽기에 사용되는 음성입니다. + 생성된 데스크탑 북을 삭제하고 EPUB 페이지 매김 캐시 파일? 다음에 책을 열 때 다시 만들어집니다. + 음성 캐시 지우기 + 도구 닫기 + 클라우드 동기화 + 클라우드 TTS 필요 Gemini + 클라우드 TTS 로그인 크레딧이 필요합니다 + 클라우드 TTS 준비 + 클라우드 TTS 설정 + 클라우드 TTS 이용할 수 없음 + 클라우드 TTS 목소리 + 포함 + 비용 계산 + 현재 위치까지 요약을 작성하십시오. + 스마트 선반 만들기 + %1$d 사용 가능한 크레딧 + %1$s 크레딧 + 독자를 위해 가져온 글꼴 + 글꼴 삭제 + %1$s을(를) 삭제하시겠습니까? 이를 사용하는 책은 기본 글꼴로 돌아갑니다. + \"%1$s\"을(를) 삭제하시겠습니까? 책은 도서관에 보관됩니다. + 요약 삭제 + 장애인 + 가져올 파일을 삭제하세요. + 가져올 지원되는 파일을 삭제하세요. + 그 밖의 사항은 이메일로 직접 문의해 주세요. + 같음 + 엑스트라 + 피드백 + 필드 + 폴더 경로 + 여기에서 + 전체 스캔 + 무료, %1$d 왼쪽 + 요약 생성 + 요약 생성 + 버그를 신고하고 기능을 요청하거나 지원팀에 직접 문의하세요. + GitHub 후원자 + GitHub 후원자를 통해 개발을 지원합니다. + Google 이 데스크톱 빌드에는 로그인이 구성되어 있지 않습니다. + 보다 큼 + 도움말 + 버그 보고서, 기능 요청 및 지원 + 숨기기 + 파일 가져오기 + 문제 + 버그 및 기능 요청에 대한 문제 추적기를 엽니다. + 미만 + 도서관과 독자 + 모두 + 도서관 활동 + + 아직 이 책에 대해 캐시된 요약이 없습니다. + TTF, OTF, WOFF2 파일을 가져와서 책에 사용하세요. + \"%1$s\"와 일치하는 글꼴을 찾을 수 없습니다. + 아니요 Google 계정이 연결되었습니다. + 이 섹션에 대해 캐시된 요약이 없습니다. + 오프라인 데스크톱 리더 + 열린 독자 + 여는 중 %1$s + 라이브러리 열기 + 운영자 + 페이지 + 비밀번호로 보호됨 PDF + 패트리온 + Patreon에서 프로젝트를 지원하세요. + 일시중지됨 + %1$s 열려면 비밀번호가 필요합니다. + 비밀번호가 필요하거나 올바르지 않습니다. + 해당 비밀번호는 %1$s을 열지 못했습니다. PDF 비밀번호를 입력하고 다시 시도하세요. + 퍼센트 + 계획 + 오디오 준비 중 + 환경설정 + 계정 및 크레딧 + 계정 및 크레딧 + 이 계정에서는 Pro가 잠금 해제되지 않았습니다. + Pro 및 크레딧은 Android에서만 구매할 수 있습니다. 앱. 데스크톱은 동일한 로그인 계정을 확인하고 해당 크레딧을 클라우드 TTS, 요약, 요약 및 기타 유료 AI에 사용합니다. 특징. + 데스크톱에서 계정 상태를 확인하려면 로그인하세요. + 이 계정에 대해 Pro가 잠금 해제되었습니다. + 진행상황 + 프로젝트 + 리더 + 리더 탭 꺼짐 + 리더 탭 켜짐 + 새로고침 + 라이브러리에 추가하려면 손을 떼세요. + 이 운영 체제에서는 보안 키 저장소를 사용할 수 없습니다. 여기에 입력한 키는 이 세션에 사용되지만 유지되지는 않습니다. + 설정 허브 + Android와 일치합니다. 스마트 사전, 요약, 요약 토글을 숨깁니다. + 계정, Pro 및 크레딧 동기화 + 로그인됨 + 소스 코드 + GitHub에서 프로젝트 소스를 찾아보세요. + 목소리를 바꾸려면 읽기를 중단하세요. + 지원 + 지원 Episteme + 기여는 Android 전반에 걸쳐 독자의 발전을 유지하는 데 도움이 됩니다. 그리고 데스크탑. + 지원 방법 Episteme 개발 + 폴더 동기화 + 메타데이터 동기화 + 태그 이름 + 선택한 책에 태그 지정 + 제목 텍스트 + 도구 + 가져오기, 동기화 및 앱 설정 + 유형을 입력하세요. PDF + 보기 + 음성 캐시 + 삽입된 웹뷰 준비 중… + 번들로 포함된 웹뷰 준비 중 %1$d%% + 임베디드 웹뷰가 설치되었습니다. 다시 시작 Episteme 설정을 마치려면 + 내장된 WebView를 시작할 수 없습니다: %1$s + 일하는 중… + 작업공간 + 선반에 추가 + 먼저 서가를 만든 다음 선택한 책을 서가에 추가하세요. + 테마 만들기 + 기존: %1$s + 외부 링크를 클릭하셨습니다. + 편집 EPUB 메타데이터 + + …더 보기 + 아직 맞춤 테마가 없습니다. + 앱에서 이름 바꾸기 + 태그, 쉼표로 구분 + 알 수 없음 + 정의 + 주석 + 주석 옵션 + 주석 도구 + 어시스트 + PDF 저장합니다. + 점프 기록 지우기 + 클라우드 TTS 실패한. + Gemini 추가 키를 누르고 Gemini 클라우드 TTS AI 열쇠와 모델. + 클라우드 TTS 이 데스크탑 빌드에 대해 구성되지 않았습니다. + Google로 로그인 클라우드를 사용하려면 TTS. + 클라우드 TTS 크레딧이 있는 로그인된 계정이 필요합니다. Pro 및 크레딧은 Android에서만 구매할 수 있습니다. 앱. + 색상 + 댓글 옵션 + 맞춤 + 그러면 PDF에서 주석이 제거됩니다. + 주석을 삭제하시겠습니까? + 문서 텍스트 + 임베디드 PDF 코멘트 + 페이지를 렌더링하지 못했습니다. + 사용할 수 없는 기능 + 완료 + 만년필 + 검색결과 숨기기 + 강조 색상 %1$d + 하이라이터 팔레트 + 상호작용 + 인덱싱 %1$d/%2$d 페이지 + 마크업 + %1$d 성냥 + %1$d 지금까지의 경기 + 다음 페이지 + 다음 검색결과 + 아직 주석이 없습니다. + 아직 북마크가 없습니다. + 댓글 없음 + 일치하는 항목 없음 + 아직 색인이 생성된 페이지에 일치하는 항목이 없습니다. + 목차 없음 + 여기에 읽을 텍스트가 없습니다. + 이 페이지에는 읽을 텍스트가 없습니다. + 요약할 텍스트가 없습니다. + 댓글 열기 + 크레딧이 부족합니다. Pro 및 크레딧은 Android에서만 구매할 수 있습니다. 앱. + 클라우드 사용 TTS 데스크톱에는 크레딧이 필요합니다. Pro 및 크레딧은 Android에서만 구매할 수 있습니다. 앱. + 이 기능을 사용하려면 데스크톱에서 크레딧이 필요합니다. Pro 및 크레딧은 Android에서만 구매할 수 있습니다. 앱. + 요약을 사용하려면 데스크톱에서 크레딧이 필요합니다. Pro 및 크레딧은 Android에서만 구매할 수 있습니다. 앱. + 요약을 사용하려면 데스크톱에서 크레딧이 필요합니다. Pro 및 크레딧은 Android에서만 구매할 수 있습니다. 앱. + + PDF 작업 실패 + PDF 작업을 완료할 수 없습니다. + PDF 코멘트 + 피. %1$d + PDF 페이지 %1$d + 페이지 %1$d - %2$s + 페이지 %1$s %2$d + 페이지 %1$s %2$d + PDF 저장됨 + PDF 도구 + 연필 + 선택 준비 중 + 준비 중 %1$s + 이전 페이지 + 이전 검색결과 + 인쇄 대화 상자가 완료되었습니다. + 프로 필수 + 이 기능을 사용하려면 Pro가 필요합니다. Pro는 Android에서만 구매할 수 있습니다. 앱을 사용하면 로그인 후 데스크톱에서 업그레이드된 계정을 사용합니다. + 다중 단어 스마트 사전에는 Pro가 필요합니다. Pro는 Android에서만 구매할 수 있습니다. 앱을 사용하면 로그인 후 데스크톱에서 업그레이드된 계정을 사용합니다. + 리더 AI 기능이 숨겨져 있습니다. + 데스크탑 AI 이 빌드에 대해 구성되지 않았습니다. + 세로 읽기 및 두 페이지 보기에 적용됩니다. + 원형 하이라이터 + %1$s에 저장되었습니다. + 스크롤 + PDF에서 검색 + 텍스트 선택 + 선택됨 %1$s + 검색결과 표시 + Google로 로그인 데스크톱에서 이 기능을 사용하려면 + Google로 로그인 데스크탑에서 다중 단어 스마트 사전을 사용하려면 + Google로 로그인 데스크톱에서 요약을 사용합니다. + Google로 로그인 데스크톱에서 요약을 사용하려면 + 중지됨 + 텍스트 메모 + 텍스트 메모 + 텍스트 스타일 + 두께 %1$s + 목차 + 검색하려면 입력하세요 PDF + 제목 없음 + 계정 및 크레딧 보기 + 음성 캐시가 삭제되었습니다. + + 확대 + 축소 + 선택 + 계속 읽기 + 닫기 + 아래로 + 위로 + AI + 센터 + 저자 + 도서관으로 돌아가기 + 도서 작업 + 폴더 + 찾아보기 + 카테고리 + Ch. %1$d + 챕터 턴 + 글꼴 선택 + 리더 텍스처 선택 + 파일 형식 지우기 + 페이지 주석 지우기 + 소스 지우기 + 상태 지우기 + 태그 지우기 + 독자 닫기 + 연속 + 커버 + 맞춤 색상 + 맞춤 테마 미리보기 + 감소 %1$s + 페이지 정의 + 그러면 강조 표시와 해당 메모가 제거됩니다. + 전체 화면으로 전환 + 전체 화면 종료 + 외부 조회 + + 만화 + 문서 + 기타 + 텍스트와 웹 + 채우기 + 고정 레이아웃 모양 + 폴더가 비어 있습니다. + 여기서는 지원되는 파일이나 하위 폴더를 사용할 수 없습니다. + %1$s, %2$s + %1$s - %2$s + 필터 숨기기 + 리더 도구 숨기기 + 슬롯을 탭한 후 색상을 선택하세요. + 계속 읽기 및 최근 도서 + 도서 가져오기 + 폴더 가져오기 + 가져온 글꼴 + %1$s %2$s + 증가 %1$s + 점프 이력 + 레이아웃 및 간격 + 파일을 앱 저장소로 가져오거나 폴더를 추가하여 파일을 읽을 수 있습니다. + 컬렉션 찾아보기 + AI 열쇠 + 스마트 %1$d + 읽지 않음 %1$d + 진행 중 %1$d + 완료 %1$d + 목록 + 네비게이션 + 열린 책 없음 + 해당 폴더의 파일을 읽을 수 있도록 폴더를 추가하세요. + 아직 폴더가 없습니다. + 탐색 항목 없음 + 페이지 콘텐츠 없음 + 설정을 찾을 수 없습니다. + 수동 서가와 시리즈 컬렉션이 여기에 표시됩니다. + 아직 선반이 없습니다. + 스마트 선반을 만들어 규칙에 따라 책을 모아보세요. + 아직 스마트 선반이 없습니다. + 책에 추가된 태그가 여기에 표시됩니다. + 아직 태그가 없습니다. + 지원되는 파일을 가져오지 않았습니다. + 카탈로그 + "%1$s"을 삭제하시겠습니까? 나중에 자격 증명이 변경되면 이 카탈로그의 스트리밍된 책이 열리지 않을 수 있습니다. + 카탈로그 없음 + OPDS 추가 원격 도서를 검색할 수 있는 카탈로그입니다. + 카탈로그, 스트림, 다운로드 찾아보기 + 오픈북 + 폴더 열기 + 열기 PDF + 페이지 및 텍스트 색상 + 페이지 정보 + 페이지 너비 + 이러한 기본값은 플랫폼이 공유 PDF를 지원하는 경우 적용됩니다. 모습. 도서별 PDF 재정의는 PDF 리더. + PDF 파일 작업 + PDF 형광펜 + 리더 하이라이트 투명도와 함께 저장되었습니다. + + 리더 관리 PDF 도구 + 자동 스크롤, OCR, 주석 기본값 및 PDF 전용 도구 가시성은 활성 PDF 리더. + %1$s %2$s %3$d (%4$d%%) + 리더 도구 모음 기본값은 이 플랫폼의 리더에서 관리됩니다. + 리더 도구 + 이미지 저장 + 검색: %1$s + 리더에서 검색 + 검색 설정 + 선택 + 선택 끝 핸들 + 선택 시작 핸들 + 서가, 태그 또는 폴더 메타데이터를 추가하여 라이브러리를 정리하세요. + 컬렉션, 시리즈, 태그 및 폴더 + 리더 도구 표시 + 폴더 + 스마트 + 솔리드 + 속도 + 자동 스크롤 시작 + 자동 스크롤 중지 + 소리내어 읽기 중지 + 질감 강도 + 이 책을 검색하려면 입력하세요. + 타이포그래피 + 주석 실행 취소 + 고정 해제 + 어두운 테마 사용 + 밝은 테마 사용 + 모노 + 샌즈 + 세리프 + 도서, 저자 또는 태그 검색 + 도구 없음 + 보이는 + 말한 내용만 바꾸기 + 리더 텍스트, 하이라이트, 위치는 변경되지 않습니다. + %1$s -> %2$s diff --git a/app/src/main/res/values-nl/plurals.xml b/app/src/main/res/values-nl/plurals.xml index d1d6423..1c39ab0 100644 --- a/app/src/main/res/values-nl/plurals.xml +++ b/app/src/main/res/values-nl/plurals.xml @@ -52,4 +52,88 @@ (%1$d segment) (%1$d segmenten) + + %1$d importeren boek... Het zal binnenkort in uw bibliotheek verschijnen. + %1$d importeren boeken... Ze verschijnen binnenkort in uw bibliotheek. + + + Geïmporteerd %1$d boek. Je vindt het onder het tabblad Bibliotheek. + Geïmporteerd %1$d boeken. Je vindt ze onder het tabblad Bibliotheek. + + + %1$d boek toegevoegd aan de plank. + %1$d boeken toegevoegd aan de plank. + + + %1$d boek getagd met "%2$s". + %1$d boeken getagd met "%2$s". + + + Map "%1$s" verwijderd en %2$d boek vanuit de app. + Map "%1$s" verwijderd en %2$d boeken uit de app. + + + %1$d bestand + %1$d bestanden + + + Ga naar import %1$d bestand + Ga naar import %1$d bestanden + + + %1$d niet-ondersteund bestand wordt overgeslagen. + %1$d niet-ondersteunde bestanden worden overgeslagen. + + + %1$d importeren bestand… + %1$d importeren bestanden… + + + Geïmporteerd %1$d bestand. + Geïmporteerd %1$d bestanden. + + + Geïmporteerd %1$d bestand. Ondersteuning voor lezers komt later. + Geïmporteerd %1$d bestanden. Ondersteuning voor lezers komt later. + + + Kan %1$d niet importeren bestand. + Kan %1$d niet importeren bestanden. + + + %1$d overgeslagen bestand. + %1$d overgeslagen bestanden. + + + Verwijder "%1$s" en zijn %2$d boeken vanuit de app? Bestanden op schijf worden niet verwijderd. + Verwijder "%1$s" en zijn %2$d boeken uit de app? Bestanden op schijf worden niet verwijderd. + + + Mapsynchronisatie mislukt voor %1$d map. + Mapsynchronisatie mislukt voor %1$d mappen. + + + Mapsynchronisatie voltooid met %1$d map overgeslagen. + Mapsynchronisatie voltooid met %1$d mappen overgeslagen. + + + %1$d verwijderd gestreamd OPDS boek uit die catalogus. + %1$d verwijderd gestreamd OPDS boeken uit die catalogus. + + + Alle boeken %1$d + Alle boeken %1$d + + + Planken %1$d + Planken %1$d + + + Labels %1$d + Labels %1$d + + + Mappen %1$d + Mappen %1$d + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 4640cd3..2075356 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1086,4 +1086,430 @@ Nederlands Українська (Oekraïens) Bahasa Indonesia (Indonesisch) + Toon tabbladen in de bovenste app-balk + Schakel lokale synchronisatie uit + Schakel lokale synchronisatie in + Lokale synchronisatie uitgeschakeld + Synchronisatie van lokale mappen uitschakelen? + Episteme stopt met het scannen van deze map en stopt met schrijven JSON bestanden synchroniseren. Verwijder de %1$s map ook uit deze map? + Bewaar synchronisatiegegevens + Synchronisatiegegevens verwijderen + Lettertypen verwijderen? + Weet u zeker dat u %1$d wilt verwijderen geselecteerde lettertypen? Hierdoor worden ze van al uw apparaten verwijderd als de synchronisatie is ingeschakeld. + Er zijn geen lokale mappen waarvoor synchronisatie is ingeschakeld. + Synchronisatie van lokale mappen uitgeschakeld. + Synchronisatie van lokale mappen uitgeschakeld. Map voor synchronisatiegegevens verwijderd. + Synchronisatie van lokale mappen uitgeschakeld, maar de map met synchronisatiegegevens kan niet worden verwijderd. + Synchronisatie van lokale mappen ingeschakeld. + Verticaal (WebView) + Verticaal (native bèta) + Boekwoordvervangingen + Vorige TTS stuk + Volgende TTS stuk + Afbeeldingen + Geen afbeeldingen gevonden. + Afbeelding downloaden + Opgeslagen %1$s + Kan afbeelding niet opslaan. + PDF pagina verspreid + Enkele pagina + Twee pagina\'s + Alleen al de eerste pagina + Begint met tegenover elkaar liggende pagina\'s na het voorblad. + Helderheid + Gebruik systeemhelderheid + Volgt de helderheidsinstelling van het apparaat. + Aangepaste helderheid + Van toepassing terwijl een lezerscherm geopend is. + %1$d%% + Plank "%1$s" gemaakt. + Slimme plank "%1$s" gemaakt. + Hernoemde plank naar "%1$s". + Plank "%1$s" verwijderd. + Bijgewerkt "%1$s". + Deze bestanden staan ​​al in de bibliotheek. + %1$s - %2$s + Opslaan + Opmerking opslaan + Commentaar toevoegen + Antwoord + Voeg een reactie toe… + Opmerkingen + Commentaar bewerken + Reageert op %1$s + Gebruik PDF Bestandsnamen + Helderheid + Huidig boek + Regel toevoegen + Er zijn nog geen vervangingsregels voor dit boek. + Nieuwe vervanging + Vervanging bewerken + Met + lege tekst + Over + Desktoplezer + Desktoptoegang + Rekening + Account & tegoeden + Rekeningoverzicht + AI naaf + Gebruikt voor EPUB samenvattingen en PDF pagina samenvattingen. + Episteme oss + Auteur tekst + Cache: %1$s + In cache opgeslagen + Samenvatting in cache + Kies de Gemini stem gebruikt voor voorlezen in de cloud. + Verwijder het gegenereerde bureaubladboek en EPUB paginering cachebestanden? De volgende keer dat er boeken worden geopend, worden ze opnieuw gemaakt. + Spraakcache wissen + Gereedschap sluiten + Cloudsynchronisatie + Wolk TTS heeft Gemini nodig + Wolk TTS heeft ingelogde credits nodig + Wolk TTS klaar + Wolk TTS instellingen + Wolk TTS niet beschikbaar + Wolk TTS stem + Bevat + Kosten berekenen + Maak een samenvatting van uw huidige positie. + Creëer een slimme plank + %1$d beschikbare kredieten + %1$s tegoeden + Geïmporteerde lettertypen voor de lezer + Lettertype verwijderen + %1$s verwijderen? Boeken die dit gebruiken, vallen terug op het standaardlettertype. + \"%1$s\" verwijderen? Boeken blijven in uw bibliotheek. + Samenvatting verwijderen + Uitgeschakeld + Zet bestanden neer om te importeren + Zet ondersteunde bestanden neer om te importeren + Neem voor al het andere rechtstreeks contact met ons op via e-mail. + Gelijk aan + Extra\'s + Feedback + Veld + Mappad + Vanaf hier + Volledige scan + Gratis, %1$d links + Samenvatting genereren + Samenvatting genereren + Rapporteer bugs, vraag functies aan of neem rechtstreeks contact op met de ondersteuning. + GitHub-sponsors + Ondersteun de ontwikkeling via GitHub-sponsors. + Google aanmelden is niet geconfigureerd voor deze desktopbuild. + Groter dan + Hulp + Bugrapporten, functieverzoeken en ondersteuning + Verbergen + Importeer bestanden + Problemen + Open de issuetracker voor bugs en functieverzoeken. + Minder dan + Bibliotheek en lezer + Elke + Bibliotheekacties + Meer + Er zijn nog geen samenvattingen in de cache voor dit boek. + Importeer TTF-, OTF- of WOFF2-bestanden om ze in boeken te gebruiken. + Geen lettertypen gevonden die overeenkomen met \"%1$s\" + Nee Google rekening is verbonden. + Er is geen samenvatting in de cache opgeslagen voor deze sectie. + Offline desktoplezer + Open lezers + Openen %1$s + Uw bibliotheek openen + Exploitant + Pagina + Wachtwoord beveiligd PDF + Patreon + Steun het project op Patreon. + Gepauzeerd + %1$s vereist een wachtwoord voordat het kan worden geopend. + Wachtwoord is vereist of onjuist. + Dat wachtwoord kan %1$s niet openen. Voer de PDF in wachtwoord en probeer het opnieuw. + Procent + Plannen + Audio voorbereiden + Voorkeuren + Account & tegoeden + Account & tegoeden + Pro is niet ontgrendeld voor dit account. + Pro en credits kunnen alleen worden gekocht bij Android app. Desktop controleert hetzelfde ingelogde account en gebruikt deze credits voor cloud TTS, samenvattingen, samenvattingen en andere betaalde AI functies. + Meld u aan om uw accountstatus op uw desktop te controleren. + Pro is ontgrendeld voor dit account. + Vooruitgang + Project + Lezer + De lezer wordt uitgeschakeld + Lezertabbladen ingeschakeld + Vernieuwen + Geef vrij om toe te voegen aan uw bibliotheek. + Veilige sleutelopslag is niet beschikbaar op dit besturingssysteem. Sleutels die hier worden ingevoerd, worden voor deze sessie gebruikt, maar worden niet bewaard. + Instellingen-hub + Komt overeen met Android verberg schakelaar voor slim woordenboek, samenvattingen en samenvattingen. + Synchroniseer account, Pro en tegoeden + Ingelogd + Broncode + Blader door de projectbron op GitHub. + Stop met lezen om van stem te veranderen. + Ondersteuning + Ondersteuning Episteme + Bijdragen zorgen ervoor dat de lezer zich blijft verbeteren in Android en bureaublad. + Manieren om Episteme te ondersteunen ontwikkeling + Synchroniseer mappen + Synchroniseer metagegevens + Tagnaam + Tag geselecteerde boeken + Titel tekst + Gereedschap + Import-, synchronisatie- en app-instellingen + Typ bijv. PDF + Bekijk + Spraakcache + Ingesloten webweergave voorbereiden… + Gebundelde ingebedde webweergave voorbereiden %1$d%% + Ingebouwde webview geïnstalleerd. Herstart Episteme om de installatie te voltooien. + Ingesloten webweergave kan niet starten: %1$s + Werken… + Werkruimte + Toevoegen aan plank + Maak eerst een plank en voeg er vervolgens geselecteerde boeken aan toe. + Maak een thema + Bestaand: %1$s + U heeft op een externe link geklikt. + Bewerken EPUB metagegevens + Minder + …meer + Nog geen aangepaste thema\'s + Naam wijzigen in app + Tags, door komma\'s gescheiden + Onbekend + Definieer + Annotatie + Annotatie-opties + Annotatiehulpmiddelen + Assisteren + Kies welke PDF om op te slaan. + Wis de spronggeschiedenis + Wolk TTS mislukt. + Voeg een Gemini toe en selecteer Gemini wolk TTS in AI sleutels en modellen. + Wolk TTS is niet geconfigureerd voor deze desktopbuild. + Meld u aan met Google om cloud TTS te gebruiken. + Wolk TTS heeft een ingelogd account met credits nodig. Pro en credits kunnen alleen worden gekocht via Android app. + Kleur + Commentaaropties + Aangepast + Hierdoor wordt de annotatie van deze PDF verwijderd. + Annotatie verwijderen? + Documenttekst + Ingesloten PDF commentaar + Kan de pagina niet weergeven. + Functie niet beschikbaar + Klaar + Vulpen + Zoekresultaten verbergen + Markeerkleur %1$d + Markeerstiftpalet + Interactie + Indexering %1$d/%2$d pagina\'s + Opmaak + %1$d wedstrijden + %1$d wedstrijden tot nu toe + Volgende pagina + Volgende zoekresultaat + Nog geen aantekeningen + Nog geen bladwijzers + Geen commentaar + Geen overeenkomsten + Er zijn nog geen overeenkomsten op geïndexeerde pagina\'s + Geen inhoudsopgave + Er is hier geen tekst om te lezen. + Er is geen tekst op deze pagina om te lezen. + Er is geen tekst om samen te vatten. + Opmerking openen + Geen krediet meer. Pro en credits kunnen alleen worden gekocht bij Android app. + Met behulp van de cloud TTS heeft credits nodig op de desktop. Pro en credits kunnen alleen worden gekocht via Android app. + Voor het gebruik van deze functie zijn credits op de desktop nodig. Pro en credits kunnen alleen worden gekocht bij Android app. + Voor het gebruik van samenvattingen zijn credits nodig op de desktop. Pro en credits kunnen alleen worden gekocht bij Android app. + Voor het gebruik van samenvattingen zijn credits op de desktop nodig. Pro en credits kunnen alleen worden gekocht bij Android app. + Pan + PDF actie mislukt + De PDF actie kon niet worden voltooid. + PDF commentaar + P. %1$d + PDF pagina %1$d + Pagina %1$d - %2$s + Pagina %1$s van %2$d + Pagina\'s %1$s van %2$d + PDF opgeslagen + PDF gereedschap + Potlood + Selectie voorbereiden + %1$s voorbereiden + Vorige pagina + Vorig zoekresultaat + Het afdrukdialoogvenster is voltooid. + Pro vereist + Voor deze functie is Pro vereist. Pro kan alleen worden gekocht bij de Android app, waarna de desktop het geüpgradede account gebruikt na het inloggen. + Voor een slim woordenboek met meerdere woorden is Pro vereist. Pro kan alleen worden gekocht bij de Android app, waarna de desktop het geüpgradede account gebruikt na het inloggen. + Lezer AI functies zijn verborgen. + Bureaublad AI is niet geconfigureerd voor deze build. + Geldt voor verticaal lezen en spreads van twee pagina\'s. + Ronde markeerstift + Opgeslagen in %1$s + Blader + Zoek in PDF + Selecteer tekst + Geselecteerd %1$s + Toon zoekresultaten + Meld u aan met Google om deze functie op het bureaublad te gebruiken. + Meld u aan met Google om een ​​slim woordenboek met meerdere woorden op het bureaublad te gebruiken. + Meld u aan met Google om samenvattingen op het bureaublad te gebruiken. + Meld u aan met Google om samenvattingen op het bureaublad te gebruiken. + Gestopt + Tekstnotitie + tekst notitie + Tekststijl + Dikte %1$s + TOC + Typ om dit te zoeken PDF + Zonder titel + Bekijk account en tegoeden + Spraakcache gewist + Zoomen + Zoom in + Uitzoomen + Kies + Lees verder + Negeren + Naar beneden + Op + AI + Centrum + Auteurs + Terug naar bibliotheek + Boek acties + Map + Blader + Categorieën + Ch. %1$d + Hoofdstuk beurten + Kies lettertype + Kies lezertextuur + Bestandstypen wissen + Paginaannotaties wissen + Duidelijke bronnen + Duidelijke status + Duidelijke tags + Dichte lezer + Continu + Hoezen + Aangepaste kleuren + Aangepast themavoorbeeld + Verlaag %1$s + Pagina definiëren + Hierdoor worden de markering en de bijbehorende noot verwijderd. + Ga naar volledig scherm + Sluit het volledige scherm af + Externe zoekopdracht + Boeken + Strips + Documenten + Anders + Tekst en internet + Vullen + Uiterlijk met vaste lay-out + Map is leeg + Er zijn hier geen ondersteunde bestanden of submappen beschikbaar. + %1$s, %2$s + %1$s - %2$s + Verberg filters + Lezerstools verbergen + Tik op een slot en kies vervolgens een kleur. + Lees verder en recente boeken + Boeken importeren + Map importeren + Geïmporteerde lettertypen + %1$s %2$s + Verhoog %1$s + Geschiedenis springen + Indeling en afstand + Importeer bestanden in de app-opslag of voeg een map toe om bestanden ter plekke te lezen. + Blader door uw collectie + AI sleutels + Slim %1$d + Ongelezen %1$d + In uitvoering %1$d + Voltooi %1$d + Lijst + Navigatie + Geen boek geopend + Voeg een map toe om bestanden uit die map te lezen. + Nog geen mappen + Geen navigatie-items + Geen pagina-inhoud + Geen instellingen gevonden + Hier verschijnen handmatige planken en seriecollecties. + Nog geen planken + Creëer slimme planken om boeken volgens regels te verzamelen. + Nog geen slimme planken + Tags die aan boeken zijn toegevoegd, worden hier weergegeven. + Nog geen tags + Er zijn geen ondersteunde bestanden geïmporteerd. + Catalogus + "%1$s" verwijderen? Gestreamde boeken uit deze catalogus worden mogelijk niet meer geopend als de inloggegevens later veranderen. + Geen catalogi + Voeg een OPDS toe catalogus om door externe boeken te bladeren. + Blader door catalogi, streams en downloads + Boek openen + Map openen + Open PDF + Pagina- en tekstkleuren + Pagina-info + Paginabreedte + Deze standaardwaarden zijn van toepassing als het platform gedeelde PDF ondersteunt verschijning. Per boek PDF overschrijvingen blijven in PDF lezer. + PDF bestandsacties + PDF markeerstift + Opgeslagen met transparantie van de lezermarkering. + Vastzetten + Door lezer beheerd PDF gereedschap + Automatisch scrollen, OCR, standaardinstellingen voor annotaties en PDF-only toolzichtbaarheid worden beheerd binnen de actieve PDF lezer. + %1$s %2$s van %3$d (%4$d%%) + De standaardinstellingen van de werkbalk van de lezer worden beheerd vanuit de lezer op dit platform. + Lezershulpmiddelen + Afbeelding opslaan + Zoeken: %1$s + Zoek in lezer + Zoekinstellingen + Selectie + Selectie eindgreep + Startgreep selectie + Voeg planken, tags of mapmetagegevens toe om uw bibliotheek te ordenen. + Collecties, series, tags en mappen + Toon leeshulpmiddelen + Map + Slim + Solide + Snelheid + Start automatisch scrollen + Stop automatisch scrollen + Houd op met voorlezen + Textuursterkte + Typ om dit boek te doorzoeken + Typografie + Annotatie ongedaan maken + Losmaken + Gebruik een donker thema + Gebruik een licht thema + Mono + San + Serif + Zoek naar boeken, auteurs of tags + Geen gereedschap + Zichtbaar + Vervang alleen wat er wordt gesproken + Lezertekst, hoogtepunten en locaties blijven ongewijzigd. + %1$s -> %2$s diff --git a/app/src/main/res/values-pl/plurals.xml b/app/src/main/res/values-pl/plurals.xml index 1f7a4d7..cf142fc 100644 --- a/app/src/main/res/values-pl/plurals.xml +++ b/app/src/main/res/values-pl/plurals.xml @@ -78,4 +78,130 @@ (%1$d fragmentów) (%1$d fragmentu) + + Importowanie %1$d książka… Wkrótce pojawi się w Twojej bibliotece. + Importowanie %1$d książki… Wkrótce pojawią się w Twojej bibliotece. + Importowanie %1$d książki… Wkrótce pojawią się w Twojej bibliotece. + Importowanie %1$d książki… Wkrótce pojawią się w Twojej bibliotece. + + + Zaimportowano %1$d książka. Znajdziesz ją w zakładce Biblioteka. + Zaimportowano %1$d książki. Znajdziesz je w zakładce Biblioteka. + Zaimportowano %1$d książki. Znajdziesz je w zakładce Biblioteka. + Zaimportowano %1$d książki. Znajdziesz je w zakładce Biblioteka. + + + %1$d książka dodana do półki. + %1$d książki dodane do półki. + %1$d książki dodane do półki. + %1$d książki dodane do półki. + + + %1$d książka oznaczona tagiem „%2$s”. + %1$d książki oznaczone tagiem „%2$s”. + %1$d książki oznaczone tagiem „%2$s”. + %1$d książki oznaczone tagiem „%2$s”. + + + Usunięto folder „%1$s” i %2$d zarezerwuj z aplikacji. + Usunięto folder „%1$s” i %2$d książki z aplikacji. + Usunięto folder „%1$s” i %2$d książki z aplikacji. + Usunięto folder „%1$s” i %2$d książki z aplikacji. + + + %1$d plik + %1$d akta + %1$d akta + %1$d akta + + + Przejdź do importu %1$d plik + Przejdź do importu %1$d akta + Przejdź do importu %1$d akta + Przejdź do importu %1$d akta + + + %1$d nieobsługiwany plik zostanie pominięty. + %1$d nieobsługiwane pliki zostaną pominięte. + %1$d nieobsługiwane pliki zostaną pominięte. + %1$d nieobsługiwane pliki zostaną pominięte. + + + Importowanie %1$d plik… + Importowanie %1$d akta… + Importowanie %1$d akta… + Importowanie %1$d akta… + + + Zaimportowano %1$d plik. + Zaimportowano %1$d akta. + Zaimportowano %1$d akta. + Zaimportowano %1$d akta. + + + Zaimportowano %1$d plik. Wsparcie czytelników pojawi się później. + Zaimportowano %1$d akta. Wsparcie czytelników pojawi się później. + Zaimportowano %1$d akta. Wsparcie czytelników pojawi się później. + Zaimportowano %1$d akta. Wsparcie czytelników pojawi się później. + + + Nie można zaimportować %1$d plik. + Nie można zaimportować %1$d akta. + Nie można zaimportować %1$d akta. + Nie można zaimportować %1$d akta. + + + Pominięte %1$d plik. + Pominięte %1$d akta. + Pominięte %1$d akta. + Pominięte %1$d akta. + + + Usuń „%1$s” i jego %2$d zarezerwować z aplikacji? Pliki na dysku nie zostaną usunięte. + Usuń „%1$s” i jego %2$d książki z aplikacji? Pliki na dysku nie zostaną usunięte. + Usuń „%1$s” i jego %2$d książki z aplikacji? Pliki na dysku nie zostaną usunięte. + Usuń „%1$s” i jego %2$d książki z aplikacji? Pliki na dysku nie zostaną usunięte. + + + Synchronizacja folderu nie powiodła się dla %1$d falcówka. + Synchronizacja folderu nie powiodła się dla %1$d lornetka składana. + Synchronizacja folderu nie powiodła się dla %1$d lornetka składana. + Synchronizacja folderu nie powiodła się dla %1$d lornetka składana. + + + Synchronizacja folderów zakończona %1$d folder pominięty. + Synchronizacja folderów zakończona %1$d foldery pominięte. + Synchronizacja folderów zakończona %1$d foldery pominięte. + Synchronizacja folderów zakończona %1$d foldery pominięte. + + + Usunięto %1$d przesyłane strumieniowo OPDS książka z tego katalogu. + Usunięto %1$d przesyłane strumieniowo OPDS książki z tego katalogu. + Usunięto %1$d przesyłane strumieniowo OPDS książki z tego katalogu. + Usunięto %1$d przesyłane strumieniowo OPDS książki z tego katalogu. + + + Wszystkie książki %1$d + Wszystkie książki %1$d + Wszystkie książki %1$d + Wszystkie książki %1$d + + + Półki %1$d + Półki %1$d + Półki %1$d + Półki %1$d + + + Tagi %1$d + Tagi %1$d + Tagi %1$d + Tagi %1$d + + + Foldery %1$d + Foldery %1$d + Foldery %1$d + Foldery %1$d + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 340b6a5..7c5cd44 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1086,4 +1086,430 @@ Nederlands (niderlandzki) Українська (ukraiński) Bahasa Indonesia (indonezyjski) + Pokaż karty na górnym pasku aplikacji + Wyłącz synchronizację lokalną + Włącz synchronizację lokalną + Synchronizacja lokalna wyłączona + Wyłączyć synchronizację folderów lokalnych? + Episteme przestanie skanować ten folder i przestanie zapisywać JSON synchronizować pliki. Usuń %1$s folder też z tego folderu? + Zachowaj dane synchronizacji + Usuń dane synchronizacji + Usunąć czcionki? + Czy na pewno chcesz usunąć %1$d wybrane czcionki? Spowoduje to usunięcie ich ze wszystkich Twoich urządzeń, jeśli synchronizacja jest włączona. + Żaden folder lokalny nie ma włączonej synchronizacji. + Synchronizacja folderów lokalnych wyłączona. + Synchronizacja folderów lokalnych wyłączona. Usunięto folder danych synchronizacji. + Synchronizacja folderów lokalnych jest wyłączona, ale nie można usunąć folderu danych synchronizacji. + Włączono synchronizację folderów lokalnych. + Pionowo (WebView) + Pion (natywna wersja beta) + Książka Zamienniki słów + Poprzedni TTS kawałek + Dalej TTS kawałek + Obrazy + Nie znaleziono obrazów. + Pobierz obraz + Zapisano %1$s + Nie można zapisać obrazu. + PDF rozkład strony + Pojedyncza strona + Dwie strony + Sama pierwsza strona + Rozpoczyna rozkładówkę po stronie tytułowej. + Jasność + Użyj jasności systemu + Podąża za ustawieniem jasności urządzenia. + Niestandardowa jasność + Obowiązuje, gdy ekran czytnika jest otwarty. + %1$d%% + Utworzono półkę „%1$s”. + Utworzono inteligentną półkę „%1$s”. + Zmieniono nazwę półki na „%1$s”. + Usunięto półkę „%1$s”. + Zaktualizowano „%1$s”. + Te pliki są już w bibliotece. + %1$s - %2$s + Zapisz + Zapisz komentarz + Dodaj komentarz + Odpowiedz + Dodaj komentarz… + Komentarze + Edytowanie komentarza + Odpowiadanie na %1$s + Użyj PDF Nazwy plików + Jasność + Aktualna książka + Dodaj regułę + Nie ma jeszcze zasad wymiany tej książki. + Nowy zamiennik + Edytuj zamiennik + Z + pusty tekst + O + Czytnik stacjonarny + Dostęp do pulpitu + Konto + Konto i kredyty + Przegląd konta + AI centrum + Używany do EPUB podsumowania i PDF podsumowania stron. + Episteme os + Tekst autora + Pamięć podręczna: %1$s + Buforowane + Podsumowanie w pamięci podręcznej + Wybierz Gemini głos używany do głośnego czytania w chmurze. + Usuń wygenerowaną książkę komputerową i EPUB pliki pamięci podręcznej stronicowania? Zostaną odtworzone przy następnym otwarciu książek. + Wyczyść pamięć podręczną głosu + Zamknij narzędzia + Synchronizacja w chmurze + Chmura TTS potrzeby Gemini + Chmura TTS wymaga zalogowanych kredytów + Chmura TTS gotowy + Chmura TTS ustawienia + Chmura TTS nie płynny + Chmura TTS głos + Zawiera + Obliczanie kosztów + Utwórz podsumowanie aż do aktualnej pozycji. + Stwórz inteligentną półkę + %1$d dostępne kredyty + %1$s kredyty + Zaimportowane czcionki dla czytnika + Usuń czcionkę + Usunąć %1$s? Książki korzystające z tej czcionki powrócą do domyślnej czcionki. + Usunąć \"%1$s\"? Książki pozostają w Twojej bibliotece. + Usuń podsumowanie + Wyłączony + Upuść pliki do zaimportowania + Upuść obsługiwane pliki, aby je zaimportować + Skontaktuj się z nami bezpośrednio przez e-mail, aby uzyskać więcej informacji. + Równe + Dodatki + Informacja zwrotna + Pole + Ścieżka folderu + Stąd + Pełne skanowanie + Bezpłatnie, %1$d lewy + Wygeneruj podsumowanie + Wygeneruj podsumowanie + Zgłaszaj błędy, żądaj funkcji lub skontaktuj się bezpośrednio z pomocą techniczną. + Sponsorzy GitHuba + Wspieraj rozwój za pośrednictwem sponsorów GitHub. + Google logowanie nie jest skonfigurowane dla tej kompilacji komputerów stacjonarnych. + Większy niż + Pomoc + Raporty o błędach, prośby o funkcje i wsparcie + Ukrywać + Importuj pliki + Kwestie + Otwórz narzędzie do śledzenia problemów, aby znaleźć błędy i prośby o funkcje. + Mniej niż + Biblioteka i czytelnik + Każdy + Działania biblioteczne + Więcej + Nie ma jeszcze żadnych podsumowań tej książki w pamięci podręcznej. + Importuj pliki TTF, OTF lub WOFF2, aby używać ich w książkach. + Nie znaleziono czcionek pasujących do \"%1$s\" + Nie Google konto jest połączone. + Brak podsumowania w pamięci podręcznej dla tej sekcji. + Czytnik stacjonarny offline + Otwórzcie czytelników + Otwarcie %1$s + Otwieram bibliotekę + Operator + Strona + Chroniony hasłem PDF + Patreona + Wesprzyj projekt na Patreonie. + Wstrzymano + %1$s wymaga hasła, zanim będzie można je otworzyć. + Hasło jest wymagane lub nieprawidłowe. + To hasło nie otworzyło się %1$s. Wprowadź PDF hasło i spróbuj ponownie. + Procent + Plan + Przygotowanie dźwięku + Preferencje + Konto i kredyty + Konto i kredyty + Wersja Pro nie jest odblokowana dla tego konta. + Pro i kredyty można kupić wyłącznie u Android aplikacja. Desktop sprawdza to samo zalogowane konto i wykorzystuje te środki do chmury TTS, podsumowań, podsumowań i innych płatnych AI cechy. + Zaloguj się, aby sprawdzić stan swojego konta na komputerze. + Pro jest odblokowane dla tego konta. + Postęp + Projekt + Czytelnik + Zakładki czytnika wyłączone + Zakładki czytnika włączone + Odświeżać + Zwolnij, aby dodać do swojej biblioteki. + Bezpieczne przechowywanie kluczy jest niedostępne w tym systemie operacyjnym. Wpisane tutaj klucze zostaną użyte w tej sesji, ale nie zostaną utrwalone. + Centrum ustawień + Pasuje do Android ukryj przełącznik inteligentnego słownika, podsumowań i podsumowań. + Zsynchronizuj konto, Pro i kredyty + Zalogowano + Kod źródłowy + Przeglądaj źródło projektu w serwisie GitHub. + Przestań czytać, aby zmienić głosy. + Wsparcie + Wsparcie Episteme + Wkłady pomagają czytelnikowi w ulepszaniu Android i komputer stacjonarny. + Sposoby wsparcia Episteme rozwój + Synchronizuj foldery + Synchronizuj metadane + Nazwa znacznika + Oznacz wybrane książki + Tekst tytułu + Narzędzia + Importuj, synchronizuj i ustawienia aplikacji + Wpisz np. PDF + Pogląd + Pamięć podręczna głosowa + Przygotowuję osadzony widok internetowy… + Przygotowanie wbudowanego widoku internetowego %1$d%% + Zainstalowany wbudowany podgląd sieciowy. Uruchom ponownie Episteme aby zakończyć konfigurację. + Nie można uruchomić wbudowanego widoku internetowego: %1$s + Pracujący… + Obszar roboczy + Dodaj do półki + Najpierw utwórz półkę, a następnie dodaj do niej wybrane książki. + Utwórz motyw + Istniejące: %1$s + Kliknąłeś link zewnętrzny. + Edytuj EPUB metadane + Mniej + …więcej + Nie ma jeszcze niestandardowych motywów + Zmień nazwę w aplikacji + Tagi, oddzielone przecinkami + Nieznany + Określić + Adnotacja + Opcje adnotacji + Narzędzia do adnotacji + Wspierać + Wybierz który PDF zapisać. + Wyczyść historię skoków + Chmura TTS przegrany. + Dodaj Gemini klawisz i wybierz Gemini chmura TTS w AI klucze i modele. + Chmura TTS nie jest skonfigurowany dla tej kompilacji komputera stacjonarnego. + Zaloguj się za pomocą Google korzystać z chmury TTS. + Chmura TTS wymaga zalogowanego konta z kredytami. Pro i kredyty można kupić wyłącznie u Android aplikacja. + Kolor + Opcje komentarzy + Zwyczaj + Spowoduje to usunięcie adnotacji z tego PDF. + Usunąć adnotację? + Tekst dokumentu + Wbudowany PDF komentarz + Nie udało się wyrenderować strony. + Funkcja niedostępna + Gotowy + Wieczne pióro + Ukryj wyniki wyszukiwania + Kolor podświetlenia %1$d + Paleta rozświetlaczy + Wzajemne oddziaływanie + Indeksowanie %1$d/%2$d strony + Oznaczenia + %1$d zapałki + %1$d mecze jak dotąd + Następna strona + Następny wynik wyszukiwania + Nie ma jeszcze żadnych adnotacji + Nie ma jeszcze żadnych zakładek + Bez komentarza + Brak dopasowań + Brak dopasowań na zaindeksowanych stronach + Brak spisu treści + Nie ma tu tekstu do przeczytania. + Na tej stronie nie ma tekstu do przeczytania. + Nie ma tekstu do podsumowania. + Otwórz komentarz + Brak kredytów. Pro i kredyty można kupić wyłącznie u Android aplikacja. + Korzystanie z chmury TTS potrzebuje kredytów na komputerze. Pro i kredyty można kupić wyłącznie u Android aplikacja. + Korzystanie z tej funkcji wymaga kredytów na komputerze. Pro i kredyty można kupić wyłącznie u Android aplikacja. + Korzystanie z podsumowań wymaga kredytów na komputerze. Pro i kredyty można kupić wyłącznie u Android aplikacja. + Korzystanie z podsumowań wymaga kredytów na komputerze. Pro i kredyty można kupić wyłącznie u Android aplikacja. + Patelnia + PDF akcja nie powiodła się + PDF akcja nie mogła zostać ukończona. + PDF komentarz + P. %1$d + PDF strona %1$d + Strona %1$d - %2$s + Strona %1$s z %2$d + Strony %1$s z %2$d + PDF zapisane + PDF narzędzia + Ołówek + Przygotowanie selekcji + Przygotowanie %1$s + Poprzednia strona + Poprzedni wynik wyszukiwania + Okno drukowania zostało zakończone. + Wymagany profesjonalista + Ta funkcja wymaga wersji Pro. Pro można kupić wyłącznie w sklepie Android app, komputer stacjonarny użyje uaktualnionego konta po zalogowaniu. + Inteligentny słownik wielowyrazowy wymaga wersji Pro. Pro można kupić wyłącznie w sklepie Android app, komputer stacjonarny użyje uaktualnionego konta po zalogowaniu. + Czytnik AI funkcje są ukryte. + Pulpit AI nie jest skonfigurowany dla tej kompilacji. + Dotyczy czytania pionowego i rozkładówek dwustronicowych. + Okrągły rozświetlacz + Zapisano w %1$s + Zwój + Szukaj w PDF + Wybierz tekst + Wybrano %1$s + Pokaż wyniki wyszukiwania + Zaloguj się za pomocą Google aby korzystać z tej funkcji na komputerze. + Zaloguj się za pomocą Google aby korzystać z inteligentnego słownika wielowyrazowego na komputerze. + Zaloguj się za pomocą Google aby korzystać z podsumowań na komputerze. + Zaloguj się za pomocą Google aby korzystać z podsumowań na komputerze. + Zatrzymany + Notatka tekstowa + notatka tekstowa + Styl tekstu + Grubość %1$s + Spis treści + Wpisz, aby wyszukać PDF + Nieuprawny + Zobacz konto i środki + Pamięć podręczna głosu została wyczyszczona + Brzęczenie + Powiększ + Pomniejsz + Wybierz + Kontynuuj czytanie + Odrzuć + W dół + W górę + AI + Centrum + Autorzy + Powrót do biblioteki + Akcje książkowe + Folder + Przeglądaj + Kategorie + Ch. %1$d + Rozdział Zakręty + Wybierz czcionkę + Wybierz teksturę czytnika + Wyczyść typy plików + Wyczyść adnotacje na stronie + Wyczyść źródła + Wyczyść stan + Wyczyść tagi + Zamknij czytelnika + Ciągłe + Okładki + Kolory niestandardowe + Niestandardowy podgląd motywu + Zmniejsz %1$s + Zdefiniuj stronę + Spowoduje to usunięcie wyróżnienia i przypisanej mu notatki. + Wejdź na pełny ekran + Wyjdź z pełnego ekranu + Wyszukiwanie zewnętrzne + Książki + Komiksy + Dokumenty + Inne + Tekst i sieć + Wypełnij + Wygląd o stałym układzie + Folder jest pusty + Nie są tu dostępne żadne obsługiwane pliki ani podfoldery. + %1$s, %2$s + %1$s - %2$s + Ukryj filtry + Ukryj narzędzia czytnika + Kliknij miejsce, a następnie wybierz kolor. + Kontynuuj czytanie i najnowsze książki + Importuj książki + Importuj folder + Importowane czcionki + %1$s %2$s + Zwiększ %1$s + Historia skoków + Układ i odstępy + Zaimportuj pliki do magazynu aplikacji lub dodaj folder, aby czytać pliki na miejscu. + Przeglądaj swoją kolekcję + AI klucze + Inteligentne %1$d + Nieprzeczytane %1$d + W toku %1$d + Kompletny %1$d + Lista + Nawigacja + Żadna książka nie jest otwarta + Dodaj folder, aby odczytywać pliki z tego folderu na miejscu. + Nie ma jeszcze folderów + Brak elementów nawigacyjnych + Brak zawartości strony + Nie znaleziono ustawień + Pojawią się tutaj półki ręczne i kolekcje seryjne. + Nie ma jeszcze półek + Twórz inteligentne półki, aby gromadzić książki według zasad. + Nie ma jeszcze inteligentnych półek + Tutaj pojawią się tagi dodane do książek. + Nie ma jeszcze tagów + Nie zaimportowano żadnych obsługiwanych plików. + Katalog + Usunąć „%1$s”? Książki przesyłane strumieniowo z tego katalogu mogą przestać się otwierać, jeśli dane uwierzytelniające zmienią się później. + Żadnych katalogów + Dodaj OPDS katalog do przeglądania zdalnych książek. + Przeglądaj katalogi, strumienie i pliki do pobrania + Otwórz książkę + Otwórz folder + Otwórz PDF + Kolory strony i tekstu + Informacje o stronie + Szerokość strony + Te wartości domyślne mają zastosowanie, gdy platforma obsługuje współdzielone PDF wygląd. Za książkę PDF zastępuje pobyt w PDF czytelnik. + PDF akcje plików + PDF zakreślacz + Zapisano z przezroczystością podświetlenia czytnika. + Szpilka + Zarządzane przez czytnik PDF narzędzia + Automatyczne przewijanie, OCR, domyślne ustawienia adnotacji i PDF-tylko widoczność narzędzi są zarządzane w aktywnym PDF czytelnik. + %1$s %2$s z %3$d (%4$d%%) + Domyślnymi ustawieniami paska narzędzi czytnika zarządza się z poziomu czytnika na tej platformie. + Narzędzia czytnika + Zapisz obraz + Szukaj: %1$s + Szukaj w czytniku + Ustawienia wyszukiwania + Wybór + Wybór uchwytu końcowego + Uchwyt początkowy zaznaczenia + Dodaj półki, znaczniki lub metadane folderów, aby uporządkować swoją bibliotekę. + Kolekcje, serie, tagi i foldery + Pokaż narzędzia czytnika + Falcówka + Mądry + Solidny + Prędkość + Rozpocznij automatyczne przewijanie + Zatrzymaj automatyczne przewijanie + Przestań czytać na głos + Siła tekstury + Wpisz, aby przeszukać tę książkę + Typografia + Cofnij adnotację + Odpiąć + Użyj ciemnego motywu + Użyj jasnego motywu + Mononukleoza + Bez + Szeryf + Wyszukaj książki, autorów lub tagi + Żadnych narzędzi + Widoczny + Zastąp tylko to, co zostało powiedziane + Tekst czytnika, wyróżnienia i lokalizacje pozostają niezmienione. + %1$s -> %2$s diff --git a/app/src/main/res/values-pt-rBR/plurals.xml b/app/src/main/res/values-pt-rBR/plurals.xml index a533b7f..b6f47f9 100644 --- a/app/src/main/res/values-pt-rBR/plurals.xml +++ b/app/src/main/res/values-pt-rBR/plurals.xml @@ -52,4 +52,88 @@ (%1$d trecho) (%1$d trechos) + + Importando %1$d livro… Ele aparecerá em sua Biblioteca em breve. + Importando %1$d livros… Eles aparecerão em sua Biblioteca em breve. + + + Importado %1$d livro. Você pode encontrá-lo na guia Biblioteca. + Importado %1$d livros. Você pode encontrá-los na guia Biblioteca. + + + %1$d livro adicionado à estante. + %1$d livros adicionados à estante. + + + %1$d livro marcado com "%2$s". + %1$d livros marcados com "%2$s". + + + Pasta removida "%1$s" e %2$d reserve no aplicativo. + Pasta removida "%1$s" e %2$d livros do aplicativo. + + + %1$d arquivo + %1$d arquivos + + + Solte para importar %1$d arquivo + Solte para importar %1$d arquivos + + + %1$d arquivo não suportado será ignorado. + %1$d arquivos não suportados serão ignorados. + + + Importando %1$d arquivo… + Importando %1$d arquivos… + + + Importado %1$d arquivo. + Importado %1$d arquivos. + + + Importado %1$d arquivo. O suporte ao leitor vem mais tarde. + Importado %1$d arquivos. O suporte ao leitor vem mais tarde. + + + Não foi possível importar %1$d arquivo. + Não foi possível importar %1$d arquivos. + + + Ignorado %1$d arquivo. + Ignorado %1$d arquivos. + + + Remover "%1$s" e seu %2$d reservar pelo aplicativo? Os arquivos no disco não serão excluídos. + Remover "%1$s" e seu %2$d livros do aplicativo? Os arquivos no disco não serão excluídos. + + + Falha na sincronização da pasta para %1$d pasta. + Falha na sincronização da pasta para %1$d pastas. + + + Sincronização de pasta concluída com %1$d pasta ignorada. + Sincronização de pasta concluída com %1$d pastas ignoradas. + + + Removido %1$d transmitido OPDS livro desse catálogo. + Removido %1$d transmitido OPDS livros desse catálogo. + + + Todos os livros %1$d + Todos os livros %1$d + + + Prateleiras %1$d + Prateleiras %1$d + + + Etiquetas %1$d + Etiquetas %1$d + + + Pastas %1$d + Pastas %1$d + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 2d806ae..c882207 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1086,4 +1086,430 @@ Nederlands (holandês) Українська (ucraniano) Bahasa Indonesia (indonésio) + Exibir abas na barra superior do aplicativo + TTS Anterior + Proximo TTS + Imagens + Nenhuma imagem encontrada. + Baixar imagem + Salvo %1$s + Não foi possivel salvar a imagem. + Página única + Duas páginas + Apenas a primeira página + Desativar sincronização local + Habilitar sincronização local + Sincronização local desativada + Desativar a sincronização de pastas locais? + Episteme irá parar de verificar esta pasta e parar de escrever JSON sincronizar arquivos. Remova o %1$s pasta desta pasta também? + Manter dados sincronizados + Remover dados sincronizados + Excluir fontes? + Tem certeza de que deseja excluir %1$d fontes selecionadas? Isso os removerá de todos os seus dispositivos se a sincronização estiver ativada. + Nenhuma pasta local tem sincronização ativada. + Sincronização de pasta local desativada. + Sincronização de pasta local desativada. Pasta de dados de sincronização removida. + A sincronização da pasta local foi desativada, mas não foi possível remover a pasta de dados de sincronização. + Sincronização de pasta local habilitada. + Verticais (WebView) + Vertical (beta nativo) + Substituições de palavras de livros + PDF propagação da página + Inicia páginas espelhadas após a página de rosto. + Brilho + Use o brilho do sistema + Segue a configuração de brilho do dispositivo. + Brilho personalizado + Aplica-se enquanto uma tela do leitor está aberta. + %1$d%% + Prateleira criada "%1$s". + Criada prateleira inteligente "%1$s". + Prateleira renomeada para "%1$s". + Prateleira excluída "%1$s". + "%1$s" atualizado. + Esses arquivos já estão na biblioteca. + %1$s - %2$s + Salvar + Salvar comentário + Adicionar comentário + Responder + Adicione um comentário… + Comentários + Editando comentário + Respondendo a %1$s + Usar PDF Nomes de arquivos + Brilho + Livro atual + Adicionar regra + Ainda não há regras de substituição para este livro. + Nova substituição + Editar substituição + Com + texto vazio + Sobre + Leitor de mesa + Acesso à área de trabalho + Conta + Conta e créditos + Visão geral da conta + AI centro + Usado para EPUB resumos e PDF resumos de páginas. + Episteme oss + Texto do autor + Cache: %1$s + Em cache + Resumo em cache + Escolha o Gemini voz usada para nuvem lida em voz alta. + Exclua o livro de desktop gerado e EPUB arquivos de cache de paginação? Eles serão recriados na próxima vez que os livros forem abertos. + Limpar cache de voz + Fechar ferramentas + Sincronização na nuvem + Nuvem TTS necessidades Gemini + Nuvem TTS precisa de créditos de login + Nuvem TTS pronto + Nuvem TTS configurações + Nuvem TTS indisponível + Nuvem TTS voz + Contém + Cálculo de custos + Crie uma recapitulação da sua posição atual. + Crie uma prateleira inteligente + %1$d créditos disponíveis + %1$s créditos + Fontes importadas para o leitor + Excluir fonte + Excluir %1$s? Os livros que o utilizam voltarão à fonte padrão. + Excluir \"%1$s\"? Os livros ficam na sua biblioteca. + Excluir resumo + Desativado + Solte arquivos para importar + Solte os arquivos suportados para importar + Contate-nos diretamente por e-mail para qualquer outra coisa. + Igual + Extras + Comentários + Campo + Caminho da pasta + Daqui + Verificação completa + Grátis, %1$d esquerda + Gerar recapitulação + Gerar resumo + Relate bugs, solicite recursos ou entre em contato diretamente com o suporte. + Patrocinadores do GitHub + Apoie o desenvolvimento por meio de patrocinadores do GitHub. + Google o login não está configurado para esta versão de desktop. + Maior que + Ajuda + Relatórios de bugs, solicitações de recursos e suporte + Esconder + Importar arquivos + Problemas + Abra o rastreador de problemas para bugs e solicitações de recursos. + Menos que + Biblioteca e leitor + Qualquer + Ações da biblioteca + Mais + Ainda não há resumos em cache para este livro. + Importe arquivos TTF, OTF ou WOFF2 para usá-los em livros. + Nenhuma fonte encontrada correspondente a \"%1$s\" + Não Google conta está conectada. + Nenhum resumo armazenado em cache para esta seção. + Leitor de desktop off-line + Leitores abertos + Abertura %1$s + Abrindo sua biblioteca + Operador + Página + Protegido por senha PDF + Patreon + Apoie o projeto no Patreon. + Pausado + %1$s requer uma senha antes de poder ser aberto. + A senha é obrigatória ou está incorreta. + Essa senha não abriu %1$s. Digite o PDF senha e tente novamente. + Porcentagem + Plano + Preparando áudio + Preferências + Conta e créditos + Conta e créditos + O Pro não está desbloqueado para esta conta. + Pro e créditos só podem ser adquiridos no Android aplicativo. O Desktop verifica a mesma conta conectada e usa esses créditos para nuvem TTS, resumos, recapitulações e outros AI pagos características. + Faça login para verificar o status da sua conta no desktop. + O Pro está desbloqueado para esta conta. + Progresso + Projeto + Leitor + Guias do leitor desativadas + Guias do leitor ativadas + Atualizar + Solte para adicionar à sua biblioteca. + O armazenamento seguro de chaves não está disponível neste sistema operacional. As chaves inseridas aqui serão usadas para esta sessão, mas não serão persistidas. + Centro de configurações + Corresponde ao Android ocultar a alternância para dicionário inteligente, resumos e recapitulações. + Sincronizar conta, Pro e créditos + Conectado + Código fonte + Navegue pela fonte do projeto no GitHub. + Pare de ler para mudar de voz. + Suporte + Suporte Episteme + As contribuições ajudam a manter o leitor melhorando em Android e área de trabalho. + Maneiras de apoiar Episteme desenvolvimento + Sincronizar pastas + Sincronizar metadados + Nome da etiqueta + Marcar livros selecionados + Texto do título + Ferramentas + Importar, sincronizar e configurações de aplicativos + Digite, por ex. PDF + Ver + Cache de voz + Preparando webview incorporado… + Preparando webview integrado incluído %1$d%% + Webview incorporado instalado. Reinicie Episteme para concluir a configuração. + A visualização da web incorporada não pôde ser iniciada: %1$s + Trabalhando… + Espaço de trabalho + Adicionar à estante + Crie primeiro uma estante e depois adicione os livros selecionados a ela. + Criar tema + Existente: %1$s + Você clicou em um link externo. + Editar EPUB metadados + Menos + …mais + Ainda não há temas personalizados + Renomear no aplicativo + Tags, separadas por vírgula + Desconhecido + Definir + Anotação + Opções de anotação + Ferramentas de anotação + Ajuda + Escolha qual PDF para salvar. + Limpar histórico de saltos + Nuvem TTS fracassado. + Adicione um Gemini e selecione Gemini nuvem TTS em AI chaves e modelos. + Nuvem TTS não está configurado para esta compilação de desktop. + Faça login com Google usar nuvem TTS. + Nuvem TTS precisa de uma conta conectada com créditos. Pro e créditos só podem ser adquiridos no Android aplicativo. + Cor + Opções de comentários + Personalizado + Isso remove a anotação deste PDF. + Excluir anotação? + Texto do documento + Incorporado PDF comentar + Falha ao renderizar a página. + Recurso indisponível + Concluído + Caneta-tinteiro + Ocultar resultados da pesquisa + Cor de destaque %1$d + Paleta de realce + Interação + Indexação %1$d/%2$d páginas + Marcação + %1$d partidas + %1$d jogos até agora + Próxima página + Próximo resultado da pesquisa + Ainda não há anotações + Ainda não há favoritos + Sem comentários + Nenhuma correspondência + Ainda não há correspondências nas páginas indexadas + Sem índice + Não há texto aqui para ler. + Não há texto nesta página para ler. + Não há texto para resumir. + Abrir comentário + Fora dos créditos. Pro e créditos só podem ser adquiridos no Android aplicativo. + Usando nuvem TTS precisa de créditos no desktop. Pro e créditos só podem ser adquiridos no Android aplicativo. + O uso deste recurso requer créditos no desktop. Pro e créditos só podem ser adquiridos no Android aplicativo. + O uso de recapitulações precisa de créditos no desktop. Pro e créditos só podem ser adquiridos no Android aplicativo. + O uso de resumos requer créditos no desktop. Pro e créditos só podem ser adquiridos no Android aplicativo. + Panela + PDF ação falhou + O PDF a ação não pôde ser concluída. + PDF comentar + pág. %1$d + PDF página %1$d + Página %1$d - %2$s + Página %1$s de %2$d + Páginas %1$s de %2$d + PDF salvo + PDF ferramentas + Lápis + Preparando seleção + Preparando %1$s + Página anterior + Resultado da pesquisa anterior + A caixa de diálogo de impressão foi concluída. + Profissional obrigatório + Este recurso requer Pro. O Pro só pode ser adquirido no Android app, o desktop usará a conta atualizada após o login. + O dicionário inteligente de várias palavras requer Pro. O Pro só pode ser adquirido no Android app, o desktop usará a conta atualizada após o login. + Leitor AI recursos estão ocultos. + Computador de mesa AI não está configurado para esta compilação. + Aplica-se à leitura vertical e páginas espelhadas de duas páginas. + Marcador redondo + Salvo em %1$s + Rolar + Pesquise em PDF + Selecione o texto + Selecionado %1$s + Mostrar resultados de pesquisa + Faça login com Google para usar esse recurso na área de trabalho. + Faça login com Google para usar o dicionário inteligente de várias palavras no desktop. + Faça login com Google para usar recapitulações no desktop. + Faça login com Google para usar resumos no desktop. + Parado + Nota de texto + nota de texto + Estilo de texto + Espessura %1$s + Índice + Digite para pesquisar isso PDF + Sem título + Ver conta e créditos + Cache de voz limpo + Zoom + Ampliar + Diminuir zoom + Escolha + Continuar lendo + Dispensar + Para baixo + Acima + AI + Centro + Autores + De volta à biblioteca + Reservar ações + Pasta + Navegar + Categorias + Cap. %1$d + Turnos de Capítulo + Escolha a fonte + Escolha a textura do leitor + Limpar tipos de arquivo + Limpar anotações da página + Fontes claras + Limpar status + Limpar tags + Fechar leitor + Contínuo + Capas + Cores personalizadas + Pré-visualização do tema personalizado + Diminuir %1$s + Definir página + Isso remove o destaque e sua nota. + Entrar em tela cheia + Sair da tela inteira + Pesquisa externa + Livros + Quadrinhos + Documentos + Outro + Texto e web + Preencher + Aparência de layout fixo + A pasta está vazia + Nenhum arquivo ou subpasta compatível está disponível aqui. + %1$s, %2$s + %1$s - %2$s + Ocultar filtros + Ocultar ferramentas de leitura + Toque em um slot e escolha uma cor. + Continue lendo e livros recentes + Importar livros + Pasta de importação + Fontes importadas + %1$s %2$s + Aumentar %1$s + Histórico de saltos + Layout e espaçamento + Importe arquivos para o armazenamento do aplicativo ou adicione uma pasta para ler os arquivos no local. + Navegue pela sua coleção + AI chaves + Inteligente %1$d + Não lido %1$d + Em andamento %1$d + Concluído %1$d + Lista + Navegação + Nenhum livro aberto + Adicione uma pasta para ler os arquivos dessa pasta. + Ainda não há pastas + Nenhum item de navegação + Nenhum conteúdo da página + Nenhuma configuração encontrada + Estantes manuais e coleções de séries aparecerão aqui. + Ainda não há prateleiras + Crie estantes inteligentes para coletar livros de acordo com regras. + Ainda não há prateleiras inteligentes + As tags adicionadas aos livros aparecerão aqui. + Ainda não há tags + Nenhum arquivo compatível foi importado. + Catálogo + Excluir "%1$s"? Os livros transmitidos deste catálogo poderão parar de abrir se as credenciais forem alteradas posteriormente. + Sem catálogos + Adicione um OPDS catálogo para navegar em livros remotos. + Navegue por catálogos, streams e downloads + Livro aberto + Abrir pasta + Abra PDF + Cores da página e do texto + Informações da página + Largura da página + Esses padrões se aplicam onde a plataforma suporta PDF aparência. Por livro PDF as substituições permanecem no PDF leitor. + PDF ações de arquivo + PDF marcador + Salvo com transparência de destaque do leitor. + Fixar + Gerenciado por leitor PDF ferramentas + Rolagem automática, OCR, padrões de anotação e visibilidade da ferramenta somente PDF são gerenciados dentro do PDF ativo leitor. + %1$s %2$s de %3$d (%4$d%%) + Os padrões da barra de ferramentas do leitor são gerenciados pelo leitor nesta plataforma. + Ferramentas de leitura + Salvar imagem + Pesquisar: %1$s + Pesquisar no leitor + Configurações de pesquisa + Seleção + Alça final de seleção + Alça inicial de seleção + Adicione estantes, tags ou metadados de pasta para organizar sua biblioteca. + Coleções, séries, tags e pastas + Mostrar ferramentas do leitor + Pasta + Inteligente + Sólido + Velocidade + Iniciar rolagem automática + Parar a rolagem automática + Pare de ler em voz alta + Força da textura + Digite para pesquisar este livro + Tipografia + Desfazer anotação + Liberar + Usar tema escuro + Use tema claro + Mono + Sem + Serif + Pesquise livros, autores ou tags + Sem ferramentas + Visível + Substitua apenas o que é falado + O texto do leitor, os destaques e os locais permanecem inalterados. + %1$s -> %2$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6da523f..3b9ab7b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -416,8 +416,8 @@ Дополнительные параметры Смотреть оригинал PDF Удалить текстовую версию - Вертикальная прокрутка - Постраничный режим + Вертикальный + Постранично (слева направо) Включено Удалить закладку Добавить закладку @@ -767,8 +767,8 @@ Недавние По названию (А-Я) По автору (А-Я) - По прочтению (возр.) - По прочтению (убыв.) + Процент прочтения 0–100 + Процент прочтения 100–0 По размеру (сначала маленькие) По размеру (сначала большие) Все @@ -809,10 +809,10 @@ Загружено из кэша (Бесплатно) Сгенерировано • Бесплатно (%1$d/10) Сгенерировано • Стоимость: %1$s кредитов - Расчет стоимости... + Генерация... • Расчет стоимости... Результат ИИ Сгенерировать заново - Нет закэшированных кратких содержаний + Нет кэшированных кратких содержаний для этой книги. Кредиты Баланс кредитов Доступно кредитов @@ -915,4 +915,601 @@ Нидерландский Украинский Индонезийский + Этот тип файла не поддерживается. + Ориентация экрана + Изменить режим чтения + По страницам (справа налево) + Настройки TTS + Поделиться, сохранить или распечатать + Вкладки + Макет страницы + Убрать разрыв между страницами + Применяется к вертикальному режиму чтения и развороту на 2 страницы. + Скрыть номер страницы + Убирает маленькую метку с номером на каждой странице. + Ориентация экрана + Выберите, следует ли читалка системной ориентации или предпочитает книжную/альбомную, когда Android это позволяет. + Синтез речи (TTS) + Элементы управления воспроизведением для TTS. + Подготовка TTS + Подготовка: %1$s + Настройки + Редактировать + Восстановить + Выбрано: %1$s + Настройки читалки по умолчанию + Специфичные для PDF настройки OCR, кратких содержаний и инструментов остаются в PDF-читалке. + Определение ИИ + Глава %1$d + Позиция + Пользовательский шрифт + Произошла ошибка: %1$s + Ошибка загрузки документа: %1$s + OCR не нашел текста на этой странице. + Страница пуста или текст невозможно извлечь. + Невозможно создать краткое содержание для пустой страницы. + Документ не загружен. + Функции ИИ недоступны в офлайн-сборке OSS. + Заблокировано из соображений безопасности. + Выберите модель для %1$s в настройках ключей и моделей ИИ. + Добавьте API-ключ %1$s в настройках ключей и моделей ИИ. + Провайдер ИИ вернул пустой ответ. + Ошибка провайдера ИИ: %1$d. %2$s + Для этого краткого содержания нужна модель Gemini, так как выбранные модели Groq не поддерживают ввод PDF/изображений. + Вы должны войти в систему, чтобы использовать обратную связь. + Вы должны войти в систему, чтобы отправить отзыв. + Для тикетов разрешено не более 3 изображений. + Не более 5 изображений на сообщение. + Одно или несколько изображений превышают лимит в 5MB. + Не удалось создать тикет: %1$s + Не удалось отправить: %1$s + Не удалось загрузить каталог: %1$s + Пустой ответ + Ошибка скачивания: %1$s + Ошибка скачивания: %1$s + Ошибка покупки: %1$s + Не удалось подключиться к сервису оплаты. + Продукты не найдены. + Не удалось запросить продукты + Недоступно в версии OSS + Нет текста для чтения. + Ошибка запуска воспроизведения. + Не удалось загрузить аудио. + Ошибка воспроизведения: %1$s + Облачный TTS не настроен. + Неизвестная книга + Ключи и модели ИИ + Сохраненные ключи + Добавить или заменить ключ + Провайдер + API-ключ + Сохранить ключ + Использовать одну модель для всех функций + Если выключено, каждая ИИ-функция читалки использует свою выбранную модель. + Все функции ИИ + Умный словарь, краткие содержания и пересказы используют эту модель. + Умный словарь + Используется при определении выделенных слов или фраз. + Краткие содержания + Используется для краткого содержания EPUB и страниц PDF. Для краткого содержания PDF/изображений нужна Gemini. + Пересказы + Используется для генерации пересказа сюжета. + Использует сохраненный ключ Gemini. Пока поддерживается только %1$s. + Сохранить ключ %1$s? + После сохранения будут видны только первые 3 и последние 3 символа. Чтобы изменить его позже, замените или удалите его. + Удалить ключ %1$s? + Функции, использующие этого провайдера, перестанут работать, пока не будет сохранен новый ключ. + Ключ не сохранен + Удалить ключ %1$s + Модель + Модель не выбрана + Показывать ИИ в читалке + Скрыть ИИ в читалке + Сплошные цвета + Текстурированные + Пользовательский сплошной + Пользовательский текстурированный + Выбрать пользовательскую текстуру + Яркость текста (Светлая тема) + Яркость текста (Темная тема) + По умолчанию + По левому краю + По правому краю + По ширине + Всегда показывать + Синхронизировать с меню + Всегда скрывать + Сверху + Снизу + Восстановить исходные метаданные? + Это запишет исходное название, автора, серию и краткое сорежание обратно в файл EPUB. Прогресс чтения, теги и заметки не изменятся. + Метаданные EPUB отредактированы + Метаданные из файла EPUB + Отображаемое имя изменено в приложении + Метаданные из файла + Метаданные + Файл + Название + Серия + Чтение + Имя файла + Изменено + Краткое содержание + Теги библиотеки + Редактируемые метаданные + Отображаемое имя + Имя, отображаемое в читалке + Исходный файл: %1$s + Верхняя панель + Нижняя панель + Скрытые инструменты + Дополнительное меню + Скрытые инструменты + Перетащите инструменты сюда + Перетащите для изменения порядка + Внешние приложения + Ползунок навигации + Боковая панель + Подсветка выделяемого текста + Режим редактирования + Управление TTS + Режим чтения + Управление страницами + Просмотр текста (Reflow) + Текущая книга + Глобальные + Эта книга + Включить замены + Эти правила применяются к каждой книге, если они не отключены для конкретного издания. + Добавить правило + Добавить правило для книги + Пока нет глобальных правил замены. + Пока нет правил для этой книги. + Использовать глобальные правила здесь + Отключите, если для книги требуются собственные настройки произношения. + Включить правила книги + Локальные правила применяются после глобальных. + Унаследованные глобальные правила + Нет глобальных правил для наследования. + Разрешено в этой книге + Отключено для этой книги + Предложения + Новая замена + Изменить замену + Заменить + Произносить как + Включено + Слово целиком + С учетом регистра + Предпросмотр ввода + Правила + тишина + Обычный текст + с учетом регистра + Показывать вкладки на верхней панели + Предыдущий фрагмент озвучивания + Следующий фрагмент озвучивания + Изображения + Изображения не найдены. + Скачать изображение + Сохранено: %1$s + Не удалось сохранить изображение. + Разворот страниц PDF + Одна страница + Две страницы + Первая страница отдельно + Начинает двухстраничные развороты после обложки. + Яркость + Системная яркость + Использует настройки яркости устройства. + Собственная яркость + Применяется, когда открыт экран чтения. + %1$d%% + Полка «%1$s» создана. + Умная полка «%1$s» создана. + Полка переименована в «%1$s». + Полка «%1$s» удалена. + Обновлено: %1$s. + Эти файлы уже есть в библиотеке. + %1$s — %2$s + Сохранить + Сохранить комментарий + Добавить комментарий + Ответить + Добавить комментарий… + Комментарии + Редактирование комментария + Ответить %1$s + Использовать имена файлов PDF + Яркость + О приложении + Программа для чтения на ПК + Доступ с ПК + Аккаунт + Центр ИИ + Используется для создания кратких содержаний EPUB и страниц PDF. + Текст автора + Кэш: %1$s + В кэше + Краткое содержание из кэша + Выберите голос Gemini для озвучивания текста из облака. + Удалить созданные файлы кэша книг и разбивки EPUB на страницы? Они будут созданы заново при следующем открытии книг. + Очистить кэш голосов + Закрыть инструменты + Для облачного озвучивания нужен Gemini + Для облачного озвучивания требуются кредиты в аккаунте + Облачное озвучивание готово + Настройки облачного озвучивания + Облачное озвучивание недоступно + Голос облачного озвучивания + Содержит + Расчет стоимости… + Создать краткий обзор до текущей позиции. + Создать умную полку + Доступно кредитов: %1$d + Кредиты: %1$s + Импортированные шрифты для чтения + Удалить шрифт + Удалить шрифт %1$s? В книгах, где он используется, будет применен шрифт по умолчанию. + Удалить полку «%1$s»? Книги останутся в вашей библиотеке. + Удалить краткое содержание + Отключено + Перетащите файлы для импорта + Перетащите поддерживаемые файлы для импорта + По любым другим вопросам обращайтесь к нам напрямую по электронной почте. + Равно + Дополнительно + Обратная связь + Поле + Путь к папке + Отсюда + Полное сканирование + Бесплатно, осталось: %1$d + Создать обзор + Создать краткое содержание + Сообщайте об ошибках, предлагайте функции или напрямую обращайтесь в поддержку. + Спонсоры GitHub + Поддержите разработку через GitHub Sponsors. + Вход через Google не настроен для этой сборки приложения. + Больше чем + Отчеты об ошибках, предложения функций и поддержка + Скрыть + Импортировать файлы + Проблемы + Открыть трекер для сообщений об ошибках и предложений функций. + Меньше чем + Библиотека и чтение + Любое + Для этой книги пока нет кратких содержаний в кэше. + Импортируйте файлы TTF, OTF или WOFF2, чтобы использовать их в книгах. + Не найдено шрифтов, соответствующих запросу «%1$s» + Аккаунт Google не подключен. + Для этого раздела нет краткого содержания в кэше. + Открытые книги + Открытие: %1$s + Открытие библиотеки… + Оператор + Страница + Защищенный паролем PDF + Patreon + Поддержите проект на Patreon. + На паузе + Для открытия %1$s требуется ввести пароль. + Требуется пароль или введен неверный пароль. + Этот пароль не подходит для %1$s. Введите верный пароль для PDF и повторите попытку. + Процент + Подготовка аудио… + Pro + Pro-режим и кредиты + Режим Pro не активирован для этого аккаунта. + Версию Pro и кредиты можно приобрести только в приложении для Android. Версия для ПК проверяет тот же аккаунт и использует эти кредиты для облачного чтения вслух, создания сводок, обзоров и других платных функций ИИ. + Войдите, чтобы проверить статус вашего аккаунта на ПК. + Режим Pro разблокирован для этого аккаунта. + Прогресс + Проект + Чтение + Обновить + Отпустите, чтобы добавить в библиотеку. + Безопасное хранилище ключей недоступно в этой операционной системе. Введенные ключи будут использоваться только в течение текущего сеанса и не будут сохранены. + Панель настроек + Соответствует переключателю скрытия на Android для умного словаря, кратких содержаний и обзоров. + Выполнен вход + Исходный код + Просмотр исходного кода проекта на GitHub. + Остановите чтение, чтобы сменить голос. + Поддержка + Поддержать Episteme + Пожертвования помогают развивать приложение на Android и ПК. + Способы поддержать разработку Episteme + Синхронизировать папки + Синхронизировать метаданные + Название тега + Добавить теги к выбранным книгам + Текст заголовка + Инструменты + Импорт, синхронизация и настройки приложения + Тип, например, PDF + Вид + Кэш голосов + Подготовка встроенного веб-просмотра… + Подготовка встроенного веб-просмотра: %1$d%% + Встроенный веб-просмотр установлен. Перезапустите Episteme для завершения настройки. + Не удалось запустить встроенный веб-просмотр: %1$s + Выполнение… + Рабочая область + Добавить на полку + Сначала создайте полку, затем добавьте на нее выбранные книги. + Создать тему + Существующие: %1$s + Вы нажали на внешнюю ссылку. + Редактировать метаданные EPUB + Свернуть + …еще + Пока нет собственных тем + Переименовать в приложении + Теги через запятую + Неизвестно + Определить + Аннотация + Параметры аннотации + Инструменты аннотирования + Ассистент + Выберите файл PDF для сохранения. + Очистить историю переходов + Сбой облачного озвучивания. + Добавьте ключ Gemini и выберите облачное озвучивание Gemini в разделе ключей и моделей ИИ. + Облачное озвучивание не настроено для этой сборки приложения. + Войдите через Google, чтобы использовать облачное озвучивание. + Для облачного озвучивания требуется войти в аккаунт с кредитами. Версию Pro и кредиты можно приобрести только в приложении для Android. + Цвет + Параметры комментариев + Пользовательская + Это действие удалит аннотацию из этого PDF. + Удалить аннотацию? + Текст документа + Встроенный комментарий PDF + Не удалось отобразить страницу. + Функция недоступна + Завершено + Перьевая ручка + Скрыть результаты поиска + Цвет выделения %1$d + Палитра маркеров + Взаимодействие + Индексирование страниц: %1$d/%2$d + Разметка + Совпадений: %1$d + Найдено совпадений: %1$d + Следующая страница + Следующий результат поиска + Пока нет аннотаций + Пока нет закладок + Нет комментариев + Нет совпадений + На проиндексированных страницах пока нет совпадений + Нет оглавления + Здесь нет текста для чтения. + На этой странице нет текста для чтения. + Нет текста для создания краткого содержания. + Открыть комментарий + Закончились кредиты. Режим Pro и кредиты можно приобрести только в приложении для Android. + Для облачного озвучивания на ПК требуются кредиты. Режим Pro и кредиты можно приобрести только в приложении для Android. + Для использования этой функции на ПК требуются кредиты. Режим Pro и кредиты можно приобрести только в приложении для Android. + Для использования кратких обзоров на ПК требуются кредиты. Режим Pro и кредиты можно приобрести только в приложении для Android. + Для использования кратких содержаний на ПК требуются кредиты. Режим Pro и кредиты можно приобрести только в приложении для Android. + Панорамирование + Действие в PDF не выполнено + Не удалось завершить действие в PDF. + Комментарий PDF + стр. %1$d + Страница PDF %1$d + Страница %1$d — %2$s + Страница %1$s из %2$d + Страницы %1$s из %2$d + Файл PDF сохранен + Инструменты PDF + Карандаш + Подготовка выделения + Подготовка: %1$s + Предыдущая страница + Предыдущий результат поиска + Печать завершена. + Требуется версия Pro + Для этой функции требуется версия Pro. Её можно приобрести только в приложении для Android, после чего версия для ПК будет использовать обновленный аккаунт после входа. + Для умного словаря словосочетаний требуется версия Pro. Её можно приобрести только в приложении для Android, после чего версия для ПК будет использовать обновленный аккаунт после входа. + Функции ИИ для чтения скрыты. + ИИ для ПК не настроен для этой сборки. + Применяется к режиму вертикального чтения. + Круглый маркер + Сохранено в %1$s + Прокрутка + Искать в PDF + Выбрать текст + Выбрано: %1$s + Показать результаты поиска + Войдите через Google, чтобы использовать эту функцию на ПК. + Войдите через Google, чтобы использовать умный словарь словосочетаний на ПК. + Войдите через Google, чтобы использовать краткие обзоры на ПК. + Войдите через Google, чтобы использовать краткие содержания на ПК. + Остановлено + Текстовая заметка + текстовая заметка + Стиль текста + Толщина: %1$s + Оглавление + Введите текст для поиска в этом PDF + Без названия + Просмотр Pro-режима и кредитов + Кэш голосов очищен + Масштаб + Увеличить + Уменьшить + Выбрать + Продолжить чтение + Закрыть + Вниз + Вверх + ИИ + По центру + Авторы + Назад в библиотеку + Действия с книгой + Папка + Обзор + Категории + Гл. %1$d + Перелистывание глав + Выбрать шрифт + Выбрать текстуру читалки + Очистить типы файлов + Очистить аннотации на странице + Очистить источники + Очистить статус + Очистить теги + Закрыть читалку + Непрерывно + Обложки + Собственные цвета + Предпросмотр темы + Уменьшить: %1$s + Определить на странице + Это действие удалит выделение и связанную заметку. + Войти в полноэкранный режим + Выйти из полноэкранного режима + Внешний поиск + Книги + Комиксы + Документы + Другое + Текст и веб-страницы + Заполнение + Отображение фиксированного макета + Папка пуста + Поддерживаемые файлы или подпапки не найдены. + %1$s, %2$s + %1$s — %2$s + Скрыть фильтры + Скрыть инструменты чтения + Выберите ячейку, а затем укажите цвет. + Продолжить чтение и недавние книги + Импортировать книги + Импортировать папку + Импортированные шрифты + %1$s %2$s + Увеличить: %1$s + История переходов + Макет и интервалы + Импортируйте файлы в хранилище приложения или добавьте папку для чтения файлов на месте. + Обзор вашей коллекции + Умные: %1$d + Непрочитанные: %1$d + В процессе: %1$d + Завершенные: %1$d + Список + Навигация + Книга не открыта + Добавьте папку, чтобы читать хранящиеся в ней файлы на месте. + Папок пока нет + Нет элементов навигации + Содержимое страницы отсутствует + Настройки не найдены + Созданные вручную полки и серии книг будут отображаться здесь. + Полок пока нет + Создавайте умные полки для сортировки книг по правилам. + Умных полок пока нет + Теги, добавленные к книгам, будут отображаться здесь. + Тегов пока нет + Поддерживаемые файлы не были импортированы. + Каталог + Удалить %1$s? Книги, транслируемые из этого каталога, могут перестать открываться при последующем изменении учетных данных. + Нет каталогов + Добавьте каталог OPDS для просмотра удаленных книг. + Просмотр каталогов, потоков и загрузок + Открыть книгу + Открыть папку + Открыть PDF + Цвета страниц и текста + Информация о странице + Ширина страницы + Эти параметры по умолчанию применяются там, где платформа поддерживает общий вид PDF. Индивидуальные настройки для конкретной книги сохраняются в читалке PDF. + Действия с файлами PDF + Маркер PDF + Сохраняется с прозрачностью выделения из читалки. + Закрепить + Инструменты PDF, управляемые читалкой + Автопрокрутка, распознавание текста (OCR), параметры аннотаций по умолчанию и видимость инструментов только для PDF настраиваются внутри активного модуля чтения PDF. + %1$s %2$s из %3$d (%4$d%%) + Параметры панели инструментов читалки по умолчанию настраиваются непосредственно в читалке на этой платформе. + Инструменты чтения + Сохранить изображение + Поиск: %1$s + Искать в читалке + Настройки поиска + Выделение + Маркер конца выделения + Маркер начала выделения + Добавьте полки, теги или метаданные папок для организации вашей библиотеки. + Коллекции, серии, теги и папки + Показать инструменты чтения + Папка + Умные + Сплошной + Скорость + Запустить автопрокрутку + Остановить автопрокрутку + Остановить чтение вслух + Интенсивность текстуры + Введите текст для поиска в этой книге + Типографика + Отменить аннотацию + Открепить + Темная тема + Светлая тема + Моноширинный + Без засечек + С засечками + Поиск книг, авторов или тегов + Без инструментов + Видимый + Заменять только произносимый текст + Текст в читалке, выделения и позиции остаются без изменений. + %1$s → %2$s + Отключить локальную синхронизацию + Включить локальную синхронизацию + Локальная синхронизация отключена + Отключить синхронизацию локальных папок? + Episteme прекратит сканирование этой папки и перестанет писать JSON синхронизировать файлы. Удалите %1$s папка из этой папки тоже? + Сохранять данные синхронизации + Удалить данные синхронизации + Удалить шрифты? + Вы уверены, что хотите удалить %1$d выбранные шрифты? Они будут удалены со всех ваших устройств, если синхронизация включена. + Ни для одной локальной папки не включена синхронизация. + Синхронизация локальных папок отключена. + Синхронизация локальных папок отключена. Папка с данными синхронизации удалена. + Синхронизация локальной папки отключена, но папку данных синхронизации удалить не удалось. + Синхронизация локальных папок включена. + Вертикальный (WebView) + Вертикальная (собственная бета-версия) + Замены слов в книге + Текущая книга + Добавить правило + Для этой книги пока нет правил замены. + Новая замена + Изменить замену + С + пустой текст + Счет и кредиты + Обзор аккаунта + Episteme ОСС + Облачная синхронизация + Помощь + Действия библиотеки + Подробнее + Автономная настольная читалка + План + Предпочтения + Вкладки Reader отключены + Вкладки Reader включены + Синхронизация учетной записи, Pro и кредитов + AI ключи diff --git a/app/src/main/res/values-tr/plurals.xml b/app/src/main/res/values-tr/plurals.xml index 33f8480..3036ba2 100644 --- a/app/src/main/res/values-tr/plurals.xml +++ b/app/src/main/res/values-tr/plurals.xml @@ -21,15 +21,15 @@ Dosyaları Kalıcı Olarak Sil - Seçilmiş dosyayı cihazınızdan kalıcı olarak silmek istediğinize emin misiniz? Bu işlem geri alınamaz. + %1$d seçilmiş dosyayı cihazınızdan kalıcı olarak silmek istediğinize emin misiniz? Bu işlem geri alınamaz. %1$d seçilmiş dosyayı cihazınızdan kalıcı olarak silmek istediğinize emin misiniz? Bu işlem geri alınamaz. - Seçilmiş dosyayı son dosyalar listesinden silmek istediğinize emin misiniz? Dosyayı kütüphaneden tekrar açarsanız yeniden burada gözükecektir. + %1$d seçilmiş dosyayı son dosyalar listesinden silmek istediğinize emin misiniz? Dosyayı kütüphaneden tekrar açarsanız yeniden burada gözükecektir. %1$d seçilmiş dosyayı son dosyalar listesinden silmek istediğinize emin misiniz? Dosyayı kütüphaneden tekrar açarsanız yeniden burada gözükecektir. - Kitabı \'%2$s\' rafından kaldırmak istediğinize emin misiniz? Kitap, kütüphanenizde kalmaya devam edecek ve Rafta Değil olarak gözükecektir. + %1$d kitabı \'%2$s\' rafından kaldırmak istediğinize emin misiniz? Kitap, kütüphanenizde kalmaya devam edecek ve Rafta Değil olarak gözükecektir. %1$d kitabı \'%2$s\' rafından kaldırmak istediğinize emin misiniz? Kitap, kütüphanenizde kalmaya devam edecek ve Rafta Değil olarak gözükecektir. @@ -52,4 +52,88 @@ (%1$d yığın) (%1$d yığın) + + İçe aktarılıyor %1$d kitap… Kısa süre içinde Kütüphanenizde görünecek. + İçe aktarılıyor %1$d kitaplar… Kısa süre içinde Kitaplığınızda görünecekler. + + + İthal %1$d kitap. Kütüphane sekmesinde bulabilirsiniz. + İthal %1$d kitaplar. Bunları Kitaplık sekmesinde bulabilirsiniz. + + + %1$d kitap rafa eklendi. + %1$d Kitaplar rafa eklendi. + + + %1$d kitap "%2$s" ile etiketlendi. + %1$d "%2$s" ile etiketlenen kitaplar. + + + "%1$s" klasörü kaldırıldı ve %2$d uygulamadan rezervasyon yapın. + "%1$s" klasörü kaldırıldı ve %2$d Uygulamadan kitaplar. + + + %1$d dosya + %1$d dosyalar + + + İçe aktarmak için bırakın %1$d dosya + İçe aktarmak için bırakın %1$d dosyalar + + + %1$d desteklenmeyen dosya atlanacak. + %1$d desteklenmeyen dosyalar atlanacak. + + + İçe aktarılıyor %1$d dosya… + İçe aktarılıyor %1$d dosyalar… + + + İthal %1$d dosya. + İthal %1$d dosyalar. + + + İthal %1$d dosya. Okuyucu desteği daha sonra gelir. + İthal %1$d dosyalar. Okuyucu desteği daha sonra gelir. + + + %1$d içe aktarılamadı dosya. + %1$d içe aktarılamadı dosyalar. + + + Atlandı %1$d dosya. + Atlandı %1$d dosyalar. + + + "%1$s"\'yi kaldır ve %2$d uygulamadan rezervasyon yaptırılsın mı? Diskteki dosyalar silinmeyecektir. + "%1$s"\'yi kaldır ve %2$d uygulamadan kitaplar? Diskteki dosyalar silinmeyecektir. + + + %1$d için klasör senkronizasyonu başarısız oldu dosya. + %1$d için klasör senkronizasyonu başarısız oldu klasörler. + + + Klasör senkronizasyonu %1$d ile tamamlandı klasör atlandı. + Klasör senkronizasyonu %1$d ile tamamlandı klasörler atlandı. + + + Kaldırıldı %1$d akış OPDS o katalogdan kitap. + Kaldırıldı %1$d akış OPDS o katalogdaki kitaplar. + + + Tüm Kitaplar %1$d + Tüm Kitaplar %1$d + + + Raflar %1$d + Raflar %1$d + + + Etiketler %1$d + Etiketler %1$d + + + Klasörler %1$d + Klasörler %1$d + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index a4a032b..78be013 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -106,7 +106,7 @@ \'%1$s\' rafını silmek istediğinize emin misiniz? Tüm kitaplar \'Rafta Değil\'e taşınacaktır. Raftan Kaldırılsın mı? %1$s silinsin mi? - %1$d rafı silmek istediğinize emin misiniz? Tüm kitaplar \'Rafta Değil\'e taşınacaktır. + %1$d seçili %2$s silmek istediğinize emin misiniz? Tüm kitaplar \'Rafta Değil\'e taşınacaktır. Yerel Klasörleri Eşitle Canlı kitaplık oluşturmak için yerel klasörleri bağlayın. Episteme, dosyaları ve eşitleme ilerlemesini gözetecektir. Klasör Ekle @@ -809,4 +809,707 @@ Nederlands (Felemenkçe) Українська (Ukraynaca) Bahasa Indonesia (Endonezce) + Üst uygulama çubuğunda sekmeleri göster + Projeyi Destekleyin + Yerel senkronizasyonu devre dışı bırak + Yerel senkronizasyonu etkinleştir + Yerel senkronizasyon devre dışı bırakıldı + Yerel klasör senkronizasyonu devre dışı bırakılsın mı? + Episteme bu klasörü taramayı ve yazmayı durduracak JSON dosyaları senkronize edin. %1$s öğesini çıkarın Bu klasördeki klasör de mi? + Verileri senkronize et + Senkronizasyon verilerini kaldır + Yazı Tipleri Silinsin mi? + %1$d silmek istediğinizden emin misiniz? seçilen yazı tipleri? Senkronizasyon açıksa bu, onları tüm cihazlarınızdan kaldıracaktır. + Projeyi Destekleyin + Episteme\'yi korumaya yardım edin hareketli + Desteğiniz bakımı sürdürmeme ve Geliştirmeme yardımcı oluyor Episteme herkes için!!! + GitHub\'da sponsor + Geliştirmeyi doğrudan GitHub Sponsorları aracılığıyla destekleyin. Teşekkür olarak proje deposunda README notu alırsınız. + Patreon\'a katılın + Uygulamayı desteklediğiniz için bir teşekkür olarak Patreon destekçileri ekstra içerik ve avantajlar elde ediyor: üzerinde çalıştığım şeye kısa bakışlar, erken ekran görüntüleri ve güncellemeler, yeni özelliklerin nasıl görünmesi ve çalışması gerektiğini şekillendirmeye yardımcı olan oylar ve proje deposunda bir README duyurusu. + Hiçbir yerel klasörde senkronizasyon etkin değil. + Yerel klasör senkronizasyonu devre dışı bırakıldı. + Yerel klasör senkronizasyonu devre dışı bırakıldı. Senkronizasyon veri klasörü kaldırıldı. + Yerel klasör senkronizasyonu devre dışı bırakıldı ancak senkronizasyon verileri klasörü kaldırılamadı. + Yerel klasör senkronizasyonu etkinleştirildi. + Bu dosya türü desteklenmiyor. + Dokulu Hazır Ayarlar + Dokulu Temalarım + Henüz özel dokulu tema yok. + Yeni Dokulu Tema + Doku + Yok + Yükle + Doku Şeffaflığı + Dikey (Web Görünümü) + Dikey (Yerel Beta) + Ekran Yönü + Okuma Modunu Değiştir + Sayfalandırılmış (sağdan sola) + TTS Ayarlar + TTS Kelime Değiştirmeleri + Kitap Kelime Değiştirmeleri + Paylaşın, Kaydedin veya Yazdırın + Önceki TTS yığın + Sonraki TTS yığın + Sekmeler + Görseller + Resim bulunamadı. + Resmi indir + Kaydedildi %1$s + Resim kaydedilemedi. + Sayfa düzeni + PDF sayfa yayılması + Tek sayfa + İki sayfa + Yalnız ilk sayfa + Kapak sayfasından sonra karşılıklı sayfa yayılmalarını başlatır. + Sayfalar arasındaki boşluğu kaldırın + Dikey okuma ve iki sayfaya yayılma için geçerlidir. + Sayfa numarası yer paylaşımını gizle + Her sayfadan küçük sayfa sayısı etiketini kaldırır. + Ekran Yönü + Android olduğunda okuyucunun sistem yönelimini mi izleyeceğini yoksa dikey mi yoksa yatay mı tercih edeceğini seçin. buna izin veriyor. + Pozisyon + Parlaklık + Sistem parlaklığını kullan + Cihazın parlaklık ayarını takip eder. + Özel parlaklık + Okuyucu ekranı açıkken uygulanır. + %1$d%% + "%1$s" rafı oluşturuldu. + Akıllı raf "%1$s" oluşturuldu. + Raf "%1$s" olarak yeniden adlandırıldı. + "%1$s" rafı silindi. + "%1$s" güncellendi. + Bu dosyalar zaten kütüphanede. + %1$s - %2$s + Kaydet + Yorumu Kaydet + Yorum Ekle + Yanıtla + Yorum ekleyin… + Yorumlar + Yorum düzenleniyor + %1$s\'e yanıt veriliyor + Dikey Kenar Boşluğu + PDF kullanın Dosya adları + Metinden konuşmaya + Metinden konuşmaya yönelik oynatma kontrolleri. + Metni konuşmaya hazırlama + Hazırlanıyor: %1$s + Önbellek İsabeti • Ücretsiz + Oluşturuldu • Ücretsiz (%1$d/10 kaldı) + Oluşturulan • Maliyet: %1$s kredi + Üretiliyor... • Maliyet: Hesaplanıyor + AI Çıkış + Yenile + Bu kitap için önbelleğe alınmış özet yok. + Kredi + AI ve Bulut Kredileri + Mevcut Krediler + %1$d Kredi + Tahmini Maliyet Dağılımı + Bulut TTS + Cost: ~3–4 credits per minute of audio generated.\nTo enable: Reader Screen > More > TTS Voice Settings. + AI Özetler ve Özet + Cost: ~1–4 credits per request based on chapter length.\nPro Users get 10 free summaries daily. + Satın alarak, + Kredi Bitti + \'yeterli krediniz yok. Episteme Pro alın Günde 10 ücretsiz Özet karşılığında veya Özetleri kullanmak için daha fazla kredi ekleyin, Bulut TTS ve Hikaye Özeti. + Pro\'yu Alın / Kredi Ekleyin + Sayfa Özetlemenin Kilidini Aç + Episteme Pro ile herhangi bir sayfanın kısa özetlerini alın. Bu özelliği kullanmaya başlamak için yükseltme yapın. + Kabarcık Yakınlaştırma Modelini İndirin + Kabarcık Yakınlaştırma özelliğini kullanmak için AI modelin indirilmesi gerekiyor (~134 MB). Şimdi indirmek istiyor musunuz? + Çevir + Pg\'ye Geri Dön %1$d + Sayfa %1$d + Sonuç %1$d / %2$d + PDF yüklenemedi. + Bubble Zoom modeli indiriliyor… %1$d%% + Kaydırıcı navigasyonundan çık + Geri Atla + İleri Atla + Okuma sayfasına ilerleyin + Açıklamalı Sayfa + Resmi Kapat + Arama vurgularını değiştir + Metin kutusunu taşımak için sürükleyin + Dosya simgesi yok + Kopyala %1$s + Etiket + Liste öğesi işaretçisi + Yakınlaştırmayı Sıfırla + Demo Ek Açıklamaları Oluşturun + Demo Ek Açıklamaları + Açık Kalem Oyun Alanı + Yeni Sekme + Tüm metni vurgula + Düzenleme Modunu Değiştir + Akıllı Komik Yakınlaştırma + Öne Çıkanları Özelleştir + İngilizce, İspanyolca, Fransızca vb. + Hintçe, Marathi, Sanskritçe + İngilizce + Çince + İngilizce + Japonca + İngilizce + Korece + İngilizce + Belge + Oluşturuldu + %1$s (Metin Görünümü) + %1$s (Yeniden akıtma) + Ekle / Düzenle + Hiçbir etiket atanmadı. + Etiketleri Uygula + Etiket arayın veya oluşturun… + \"%1$s\" oluştur + Bölümü Değiştirmek İçin Çekme Mesafesi + Kısa + Uzun + Hız: %1$sx + Aralık: %1$sx + Oynat/Duraklat + Hızı Sıfırla + Perdeyi Sıfırla + Renk Seç + Bu kitabın görüntülenecek içeriği yok. + Kopyalanan Bağlantı + Kopyalanan Metin + İçindekiler + Yer imi + Sayfaya Geri Dön %1$d + Önceki sayfaya dön + Akıllı Yakınlaştırmadan Çık + Akıllı Komik Yakınlaştırma + Akıllı Komik Yakınlaştırmayı Değiştir + Ekran yakalama koruması + Ekran yakalama koruması açık + Ekran yakalama koruması kapalı + Ayarlar + Düzenle + Geri yükle + %1$s seçilmiş + Okuyucu varsayılanları + PDF-özel OCR, ek açıklama ve araç ayarları PDF okuyucu. + AI Tanım + Bölüm %1$d + Konum + Özel Yazı Tipi + Bir hata oluştu: %1$s + Belge yüklenirken hata oluştu: %1$s + OCR bu sayfada metin bulunamadı. + Sayfa boş görünüyor veya metin çıkarılamıyor. + Boş bir sayfa özetlenemez. + Belge yüklenmedi. + AI özellikler çevrimdışı olarak kullanılamaz OSS inşa etmek. + Güvenlik nedeniyle engellendi. + %1$s için bir model seçin AI anahtar ve model ayarları. + %1$s ekleyin API AI girin anahtar ve model ayarları. + AI sağlayıcı boş bir yanıt döndürdü. + AI sağlayıcı hatası: %1$d. %2$s + Bu özetin Gemini olması gerekiyor model çünkü seçilen Groq modelleri PDF/görüntü girişini desteklemiyor. + Geri bildirimi kullanmak için oturum açmalısınız. + Geri bildirim göndermek için oturum açmalısınız. + Biletler için maksimum 3 görsele izin verilir. + Mesaj başına maksimum 5 görsele izin verilir. + Bir veya daha fazla resim 5 MB sınırını aşıyor. + Bilet oluşturulamadı: %1$s + Gönderilemedi: %1$s + Özet akışı yüklenemedi: %1$s + Boş gövde + İndirme başarısız oldu: %1$s + İndirme hatası: %1$s + Satın alma başarısız oldu: %1$s + Faturalandırma hizmetine bağlanılamadı. + Ürünler bulunamadı. + Ürünler sorgulanamadı + Açık Kaynak sürümünde mevcut değil + Okunacak metin yok. + Oynatma başlatılırken hata oluştu. + Ses yüklenemedi. + Oynatma hatası: %1$s + Bulut TTS yapılandırılmamış. + Bilinmeyen Kitap + AI anahtarlar ve modeller + Kayıtlı anahtarlar + Anahtar ekle veya değiştir + sağlayıcı + API anahtar + Anahtarı kaydet + Tüm özellikler için tek bir model kullanın + Kapalıyken, her okuyucu AI özellik kendi seçilmiş modelini kullanır. + Hepsi AI özellikler + Akıllı sözlük, özetler ve özetlerin tümü bu modeli kullanır. + Akıllı sözlük + Seçilen kelimeleri veya cümleleri tanımlarken kullanılır. + Özetler + EPUB için kullanılır özetler ve PDF sayfa özetleri. PDF/resim özetleri için Gemini gerekir. + Özetler + Hikaye özeti oluşturmak için kullanılır. + Kaydedilen Gemini\'yi kullanır anahtar. Yalnızca %1$s şimdilik destekleniyor. + Kaydet %1$s anahtar? + Kaydettikten sonra yalnızca ilk 3 ve son 3 karakter görünecektir. Daha sonra değiştirmek için değiştirin veya silin. + Sil %1$s anahtar? + Bu sağlayıcıyı kullanan özellikler, yeni bir anahtar kaydedilene kadar çalışmayı durduracaktır. + Anahtar kaydedilmedi + Sil %1$s anahtar + Modeli + Hiçbir model seçilmedi + Göster AI okuyucuda + Gizle AI okuyucuda + Katı Renkler + Dokulu + Özel Katı + Özel Dokulu + Özel Dokuyu Seçin + Metin Parlaklığı (Işık) + Metin Parlaklığı (Koyu) + Varsayılan + Sol + Sağ + Gerekçelendir + Her Zaman Göster + Menülerle Senkronizasyon + Her Zaman Gizle + Üst + Alt + Orijinal meta veriler geri yüklensin mi? + Bu, orijinal başlığı, yazarı, seriyi ve özeti EPUB dosyasına geri yazacaktır. dosya. Okuma ilerleme durumu, etiketler ve notlar değişmeyecektir. + EPUB meta veriler düzenlendi + EPUB meta verisi dosya + Uygulamada görünen ad değiştirildi + Dosyadaki meta veriler + Meta veriler + Dosya + Başlık + Serisi + Okuma + Dosya adı + Değiştirildi + Özet + Kitaplık etiketleri + Düzenlenebilir meta veriler + Görünen ad + Reader\'da gösterilen ad + Orijinal dosya: %1$s + Üst Çubuk + Alt Çubuk + Gizli Araçlar + Daha fazla menü + Gizli araçlar + Araçları buraya bırakın + Yeniden sıralamak için sürükleyin + Harici Uygulamalar + Gezinme Kaydırıcısı + Parlaklık + Kenar çubuğu + Seçilebilir metni vurgula + Düzenleme Modu + TTS Kontroller + Okuma Modu + Sayfa Yönetimi + Metin Görünümü (Yeniden Akıtma) + Güncel kitap + Küresel + Bu kitap + Değişiklikleri etkinleştir + Buradaki kurallar, belirli bir başlık için devre dışı bırakılmadığı sürece her kitap için geçerlidir. + Kural ekle + Kitap kuralı ekle + Henüz küresel değiştirme kuralı yok. + Henüz kitaba özel kural yok. + Burada genel kuralları kullanın + Bir kitabın kendi telaffuz seçeneklerine ihtiyacı olduğunda bunu kapatın. + Kitap kurallarını etkinleştir + Yerel kurallar küresel kuralların ardından yürür. + Devralınan küresel kurallar + Devralılacak küresel kural yok. + Bu kitapta izin veriliyor + Bu kitap için devre dışı bırakıldı + Öneriler + Yeni değiştirme + Değiştirmeyi düzenle + Değiştir + Olarak konuş + Etkin + Tüm kelime + Maç durumu + Girişi önizleyin + Kurallar + sessizlik + Düz metin + büyük/küçük harfe duyarlı + Güncel kitap + Kural ekle + Bu kitap için henüz değiştirme kuralı yok. + Yeni değiştirme + Değiştirmeyi düzenle + ile + boş metin + Hakkında + Masaüstü okuyucu + Masaüstü erişimi + Hesap + Hesap ve krediler + Hesaba genel bakış + AI merkez + EPUB için kullanılır özetler ve PDF sayfa özetleri. + Episteme oss + Yazar metni + Önbellek: %1$s + Önbelleğe alındı + Önbelleğe alınmış özet + Gemini\'yi seçin Bulutun yüksek sesle okunması için kullanılan ses. + Oluşturulan masaüstü kitabını ve EPUB sayfalandırma önbellek dosyaları? Kitaplar bir sonraki açıldığında yeniden oluşturulacak. + Ses önbelleğini temizle + Araçları kapat + Bulut senkronizasyonu + Bulut TTS ihtiyaçlar Gemini + Bulut TTS oturum açılmış kredilere ihtiyaç var + Bulut TTS hazır + Bulut TTS ayarlar + Bulut TTS müsait değil + Bulut TTS ses + İçerir + Maliyet hesaplama + Mevcut konumunuza ilişkin bir özet oluşturun. + Akıllı raf oluştur + %1$d kredi mevcut + %1$s kredi + Okuyucu için içe aktarılan yazı tipleri + Yazı tipini sil + %1$s silinsin mi? Bunu kullanan kitaplar varsayılan yazı tipine geri dönecektir. + \"%1$s\" silinsin mi? Kitaplar kütüphanenizde kalır. + Özeti sil + Devre dışı + İçe aktarılacak dosyaları bırakın + Desteklenen dosyaları içe aktarılacak şekilde bırakın + Başka bir şey için doğrudan e-posta yoluyla bizimle iletişime geçin. + Eşittir + Ekstralar + Geribildirim + Alan + Klasör yolu + Buradan + Tam tarama + Ücretsiz, %1$d sol + Özet oluştur + Özet oluştur + Hataları bildirin, özellik isteyin veya doğrudan destek ekibiyle iletişime geçin. + GitHub Sponsorları + GitHub Sponsorları aracılığıyla gelişimi destekleyin. + Google oturum açma bu masaüstü yapısı için yapılandırılmamış. + Şundan büyük: + Yardım + Hata raporları, özellik istekleri ve destek + Gizle + Dosyaları içe aktar + Sorunlar + Hatalar ve özellik istekleri için sorun izleyiciyi açın. + Şundan az: + Kütüphane ve okuyucu + Herhangi biri + Kitaplık eylemleri + Daha Fazla + Bu kitap için henüz önbelleğe alınmış özet yok. + Kitaplarda kullanmak için TTF, OTF veya WOFF2 dosyalarını içe aktarın. + \"%1$s\" ile eşleşen yazı tipi bulunamadı + Hayır Google hesap bağlandı. + Bu bölüm için önbelleğe alınmış özet yok. + Çevrimdışı masaüstü okuyucu + Okuyucuları aç + Açılış %1$s + Kitaplığınızı açma + Operatör + Sayfa + Şifre korumalı PDF + Patreon + Projeyi Patreon\'da destekleyin. + Duraklatıldı + %1$s açılmadan önce bir şifre gerektirir. + Şifre gerekli veya yanlış. + Bu şifre %1$s\'yi açmadı. PDF girin şifreyi girin ve tekrar deneyin. + Yüzde + Planı + Ses hazırlanıyor + Tercihler + Hesap ve krediler + Hesap ve krediler + Bu hesap için Pro\'nun kilidi açık değil. + Pro ve krediler yalnızca Android\'den satın alınabilir. uygulama. Masaüstü aynı oturum açılmış hesabı kontrol eder ve bu kredileri bulut TTS, özetler, özetler ve diğer ücretli AI için kullanır. özellikler. + Hesap durumunuzu masaüstünde kontrol etmek için oturum açın. + Bu hesap için Pro\'nun kilidi açıldı. + İlerleme + Proje + Okuyucu + Okuyucu sekmeleri kapalı + Okuyucu sekmeleri açık + Yenile + Kitaplığınıza eklemek için bırakın. + Güvenli anahtar depolama bu işletim sisteminde kullanılamıyor. Buraya girilen anahtarlar bu oturum için kullanılacak ancak kalıcı olmayacaktır. + Ayarlar merkezi + Android ile eşleşir Akıllı sözlük, özetler ve özetler için geçişi gizleyin. + Hesabı, Pro\'yu ve kredileri senkronize et + Oturum açıldı + Kaynak kodu + GitHub\'daki proje kaynağına göz atın. + Sesleri değiştirmek için okumayı bırakın. + Destek + Destek Episteme + Katkılar okuyucunun Android genelinde gelişmesine yardımcı olur ve masaüstü. + Destekleme yolları Episteme gelişme + Klasörleri senkronize et + Meta verileri senkronize et + Etiket adı + Seçilen kitapları etiketle + Başlık metni + Araçlar + İçe aktarma, senkronize etme ve uygulama ayarları + Tür, ör. PDF + Görüntüle + Ses önbelleği + Gömülü web görünümü hazırlanıyor… + Paketlenmiş gömülü web görünümü hazırlanıyor %1$d%% + Gömülü web görünümü yüklendi. Yeniden başlat Episteme Kurulumu bitirmek için. + Katıştırılmış web görünümü başlatılamadı: %1$s + Çalışıyor… + Çalışma alanı + Rafa ekle + Önce bir raf oluşturun, ardından seçilen kitapları buna ekleyin. + Tema oluştur + Mevcut: %1$s + Harici bir bağlantıya tıkladınız. + Düzenle EPUB meta veri + Daha az + …devamı + Henüz özel tema yok + Uygulamada yeniden adlandır + Etiketler, virgülle ayrılmış + Bilinmiyor + Tanımla + Ek açıklama + Ek açıklama seçenekleri + Ek açıklama araçları + Yardım + Hangisini seçin PDF kurtarmak için. + Atlama geçmişini temizle + Bulut TTS arızalı. + Gemini ekleyin tuşuna basın ve Gemini öğesini seçin bulut TTS AI anahtarlar ve modeller. + Bulut TTS bu masaüstü yapısı için yapılandırılmamış. + Google ile oturum açın bulutu kullanmak için TTS. + Bulut TTS kredili, oturum açmış bir hesaba ihtiyacı var. Pro ve krediler yalnızca Android adresinden satın alınabilir. uygulama. + Renk + Yorum seçenekleri + Özel + Bu, PDF\'deki ek açıklamayı kaldırır. + Ek açıklama silinsin mi? + Belge metni + Gömülü PDF yorum + Sayfa oluşturulamadı. + Özellik kullanılamıyor + Bitti + Dolma kalem + Arama sonuçlarını gizle + Rengi vurgula %1$d + Vurgulayıcı paleti + Etkileşim + İndeksleme %1$d/%2$d sayfalar + İşaretleme + %1$d maçlar + %1$d şu ana kadarki maçlar + Sonraki sayfa + Sonraki arama sonucu + Henüz ek açıklama yok + Henüz yer işareti yok + Yorum yok + Eşleşme yok + Dizine eklenen sayfalarda henüz eşleşme yok + İçindekiler tablosu yok + Burada okunacak bir metin yok. + Bu sayfada okunacak metin yok. + Özetlenecek bir metin yok. + Yorumu aç + Krediler bitti. Pro ve krediler yalnızca Android\'den satın alınabilir. uygulama. + Bulut kullanma TTS masaüstünde krediye ihtiyacı var. Pro ve krediler yalnızca Android adresinden satın alınabilir. uygulama. + Bu özelliğin kullanılması masaüstü bilgisayar için kredi gerektirir. Pro ve krediler yalnızca Android\'den satın alınabilir. uygulama. + Özetleri kullanmak için masaüstünde kredi gerekir. Pro ve krediler yalnızca Android\'den satın alınabilir. uygulama. + Özetleri kullanmak için masaüstünde kredi gerekir. Pro ve krediler yalnızca Android\'den satın alınabilir. uygulama. + Tava + PDF eylem başarısız oldu + PDF eylem tamamlanamadı. + PDF yorum + P. %1$d + PDF sayfa %1$d + Sayfa %1$d - %2$s + Sayfa %1$s %2$d + Sayfalar %1$s %2$d + PDF kaydedildi + PDF aletler + Kalem + Seçim hazırlanıyor + Hazırlanıyor %1$s + Önceki sayfa + Önceki arama sonucu + Yazdırma iletişim kutusu tamamlandı. + Profesyonel gerekli + Bu özellik Pro gerektirir. Pro yalnızca Android adresinden satın alınabilir uygulamasını kullanırsanız, oturum açıldıktan sonra masaüstü yükseltilmiş hesabı kullanacaktır. + Çok kelimeli akıllı sözlük Pro gerektirir. Pro yalnızca Android adresinden satın alınabilir uygulamasını kullanırsanız, oturum açıldıktan sonra masaüstü yükseltilmiş hesabı kullanacaktır. + Okuyucu AI özellikler gizlenmiştir. + Masaüstü AI bu yapı için yapılandırılmamış. + Dikey okuma ve iki sayfaya yayılma için geçerlidir. + Yuvarlak vurgulayıcı + %1$s konumuna kaydedildi + Kaydırma + PDF kategorisinde ara + Metni seçin + Seçildi %1$s + Arama sonuçlarını göster + Google ile oturum açın Bu özelliği masaüstünde kullanmak için. + Google ile oturum açın masaüstünde çok kelimeli akıllı sözlüğü kullanmak için. + Google ile oturum açın Özetleri masaüstünde kullanmak için. + Google ile oturum açın Özetleri masaüstünde kullanmak için. + Durduruldu + Metin notu + metin notu + Metin stili + Kalınlık %1$s + İçindekiler + Bunu aramak için yazın PDF + İsimsiz + Hesabı ve kredileri görüntüle + Ses önbelleği temizlendi + Yakınlaştır + Yakınlaştır + Uzaklaştır + Seç + Okumaya devam et + Reddet + Aşağı + Yukarı + AI + Merkez + Yazarlar + Kütüphaneye geri dön + Rezervasyon işlemleri + Klasör + Göz at + Kategoriler + Ch. %1$d + Bölüm Dönüşleri + Yazı tipini seç + Okuyucu dokusunu seçin + Dosya türlerini temizle + Sayfa açıklamalarını temizle + Kaynakları temizle + Durumu temizle + Etiketleri temizle + Okuyucuyu kapat + Sürekli + Kapaklar + Özel renkler + Özel tema önizlemesi + %1$s\'i azalt + Sayfayı tanımla + Bu, vurguyu ve notunu kaldırır. + Tam ekrana girin + Tam ekrandan çık + Harici arama + Kitaplar + çizgi roman + Belgeler + Diğer + Metin ve web + Doldur + Sabit düzen görünümü + Klasör boş + Burada desteklenen dosya veya alt klasör yok. + %1$s, %2$s + %1$s - %2$s + Filtreleri gizle + Okuyucu araçlarını gizle + Bir yuvaya dokunun, ardından bir renk seçin. + Okumaya devam edin ve yeni kitaplar + Kitapları içe aktar + Klasörü içe aktar + İçe aktarılan yazı tipleri + %1$s %2$s + %1$s\'yi artırın + Atlama geçmişi + Düzen ve Aralık + Dosyaları uygulama depolama alanına aktarın veya dosyaları yerinde okumak için bir klasör ekleyin. + Koleksiyonunuza göz atın + AI anahtarlar + Akıllı %1$d + Okunmamış %1$d + Devam ediyor %1$d + Tamamlandı %1$d + Liste + Navigasyon + Kitap açık değil + Bu klasördeki dosyaları yerinde okumak için bir klasör ekleyin. + Henüz klasör yok + Gezinme öğesi yok + Sayfa içeriği yok + Hiçbir ayar bulunamadı + Manuel raflar ve seri koleksiyonlar burada görünecek. + Henüz raf yok + Kitapları kurallara göre toplamak için akıllı raflar oluşturun. + Henüz akıllı raf yok + Kitaplara eklenen etiketler burada görünecektir. + Henüz etiket yok + Desteklenen hiçbir dosya içe aktarılmadı. + Katalog + "%1$s" silinsin mi? Kimlik bilgileri daha sonra değişirse bu katalogdan yayınlanan kitapların açılması durdurulabilir. + Katalog yok + OPDS ekleyin Uzaktaki kitaplara göz atmak için katalog. + Kataloglara, akışlara ve indirmelere göz atın + Kitabı Aç + Klasörü aç + Aç PDF + Sayfa ve metin renkleri + Sayfa Bilgisi + Sayfa genişliği + Bu varsayılanlar, platformun paylaşımlı PDF\'yi desteklediği durumlarda geçerlidir. dış görünüş. Kitap başına PDF geçersiz kılmalar PDF okuyucu. + PDF dosya eylemleri + PDF vurgulayıcı + Okuyucunun vurguladığı şeffaflıkla kaydedildi. + Sabitle + Okuyucu tarafından yönetilen PDF araçlar + Otomatik kaydırma, OCR, açıklama varsayılanları ve PDFyalnızca araç görünürlüğü, etkin PDF içinde yönetilir. okuyucu. + %1$s %2$s %3$d (%4$d%%) + Okuyucu araç çubuğu varsayılanları bu platformdaki okuyucudan yönetilir. + Okuyucu araçları + Resmi kaydet + Arama: %1$s + Okuyucuda ara + Arama ayarları + Seçim + Seçim bitiş tutamacı + Seçim başlatma tutamacı + Kitaplığınızı düzenlemek için raflar, etiketler veya klasör meta verileri ekleyin. + Koleksiyonlar, seriler, etiketler ve klasörler + Okuyucu araçlarını göster + Klasör + Akıllı + Katı + Hız + Otomatik kaydırmayı başlat + Otomatik kaydırmayı durdur + Yüksek sesle okumayı bırak + Doku gücü + Bu kitabı aramak için yazın + Tipografi + Ek açıklamayı geri al + Sabitlemeyi kaldır + Koyu temayı kullan + Açık temayı kullan + Mono + sans + Serif + Kitapları, yazarları veya etiketleri arayın + Alet yok + Görünür + Yalnızca konuşulanları değiştirin + Okuyucu metni, vurgulamalar ve konumlar değişmeden kalır. + %1$s -> %2$s diff --git a/app/src/main/res/values-uk/plurals.xml b/app/src/main/res/values-uk/plurals.xml index 643772d..5d05bc7 100644 --- a/app/src/main/res/values-uk/plurals.xml +++ b/app/src/main/res/values-uk/plurals.xml @@ -78,4 +78,130 @@ (%1$d фрагментів) (%1$d фрагментів) + + Імпорт %1$d книга… Незабаром вона з’явиться у вашій бібліотеці. + Імпорт %1$d книги… Вони незабаром з’являться у вашій бібліотеці. + Імпорт %1$d книги… Вони незабаром з’являться у вашій бібліотеці. + Імпорт %1$d книги… Вони незабаром з’являться у вашій бібліотеці. + + + Імпортований %1$d книга. Ви можете знайти його на вкладці «Бібліотека». + Імпортований %1$d книги. Ви можете знайти їх у вкладці «Бібліотека». + Імпортований %1$d книги. Ви можете знайти їх у вкладці «Бібліотека». + Імпортований %1$d книги. Ви можете знайти їх у вкладці «Бібліотека». + + + %1$d книгу додано на полицю. + %1$d книг додано на полицю. + %1$d книг додано на полицю. + %1$d книг додано на полицю. + + + %1$d книга з тегом "%2$s". + %1$d книги з тегом «%2$s». + %1$d книги з тегом «%2$s». + %1$d книги з тегом «%2$s». + + + Видалено папку "%1$s" та %2$d книга з додатку. + Видалено папку "%1$s" та %2$d книги з програми. + Видалено папку "%1$s" та %2$d книги з програми. + Видалено папку "%1$s" та %2$d книги з програми. + + + %1$d файл + %1$d файли + %1$d файли + %1$d файли + + + Відпустіть, щоб імпортувати %1$d файл + Відпустіть, щоб імпортувати %1$d файли + Відпустіть, щоб імпортувати %1$d файли + Відпустіть, щоб імпортувати %1$d файли + + + %1$d непідтримуваний файл буде пропущено. + %1$d непідтримувані файли будуть пропущені. + %1$d непідтримувані файли будуть пропущені. + %1$d непідтримувані файли будуть пропущені. + + + Імпорт %1$d файл… + Імпорт %1$d файли… + Імпорт %1$d файли… + Імпорт %1$d файли… + + + Імпортований %1$d файл. + Імпортований %1$d файли. + Імпортований %1$d файли. + Імпортований %1$d файли. + + + Імпортований %1$d файл. Підтримка Reader з’явиться пізніше. + Імпортований %1$d файли. Підтримка Reader з’явиться пізніше. + Імпортований %1$d файли. Підтримка Reader з’явиться пізніше. + Імпортований %1$d файли. Підтримка Reader з’явиться пізніше. + + + Не вдалося імпортувати %1$d файл. + Не вдалося імпортувати %1$d файли. + Не вдалося імпортувати %1$d файли. + Не вдалося імпортувати %1$d файли. + + + Пропущено %1$d файл. + Пропущено %1$d файли. + Пропущено %1$d файли. + Пропущено %1$d файли. + + + Видалити "%1$s" та його %2$d книга з програми? Файли на диску не будуть видалені. + Видалити "%1$s" та його %2$d книги з програми? Файли на диску не будуть видалені. + Видалити "%1$s" та його %2$d книги з програми? Файли на диску не будуть видалені. + Видалити "%1$s" та його %2$d книги з програми? Файли на диску не будуть видалені. + + + Помилка синхронізації папки для %1$d папку. + Помилка синхронізації папки для %1$d папки. + Помилка синхронізації папки для %1$d папки. + Помилка синхронізації папки для %1$d папки. + + + Синхронізацію папки завершено з %1$d папку пропущено. + Синхронізацію папки завершено з %1$d папок пропущено. + Синхронізацію папки завершено з %1$d папок пропущено. + Синхронізацію папки завершено з %1$d папок пропущено. + + + Видалено %1$d потокове OPDS книга з цього каталогу. + Видалено %1$d потокове OPDS книг з цього каталогу. + Видалено %1$d потокове OPDS книг з цього каталогу. + Видалено %1$d потокове OPDS книг з цього каталогу. + + + Усі книги %1$d + Усі книги %1$d + Усі книги %1$d + Усі книги %1$d + + + Полиці %1$d + Полиці %1$d + Полиці %1$d + Полиці %1$d + + + Теги %1$d + Теги %1$d + Теги %1$d + Теги %1$d + + + Папки %1$d + Папки %1$d + Папки %1$d + Папки %1$d + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index daa9aab..35110e0 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1086,4 +1086,430 @@ Nederlands (нідерландська) Українська Bahasa Indonesia (індонезійська) + Показати вкладки на верхній панелі програми + Вимкнути локальну синхронізацію + Увімкнути локальну синхронізацію + Локальну синхронізацію вимкнено + Вимкнути синхронізацію локальних папок? + Episteme припинить сканування цієї папки та припинить запис JSON синхронізувати файли. Видаліть %1$s папку з цієї папки теж? + Тримайте дані синхронізації + Видалити дані синхронізації + Видалити шрифти? + Ви впевнені, що хочете видалити %1$d вибрані шрифти? Це видалить їх з усіх ваших пристроїв, якщо синхронізацію ввімкнено. + Для жодної локальної папки не ввімкнено синхронізацію. + Синхронізацію локальної папки вимкнено. + Синхронізацію локальної папки вимкнено. Папку даних синхронізації видалено. + Синхронізацію локальної папки вимкнено, але папку даних синхронізації не вдалося видалити. + Синхронізацію локальної папки ввімкнено. + Вертикальний (WebView) + Вертикальний (власна бета-версія) + Книга Заміни слів + Попередній TTS шматок + Далі TTS шматок + Зображення + Зображення не знайдено. + Завантажити зображення + Збережено %1$s + Не вдалося зберегти зображення. + PDF розворот сторінки + Одна сторінка + Дві сторінки + Лише перша сторінка + Починає розвороти після титульної сторінки. + Яскравість + Використовуйте системну яскравість + Слідкує за налаштуванням яскравості пристрою. + Власна яскравість + Застосовується, коли відкритий екран читача. + %1$d%% + Створена полиця "%1$s". + Створено розумну полицю "%1$s". + Полицю перейменовано на "%1$s". + Видалена полиця "%1$s". + Оновлено "%1$s". + Ці файли вже є в бібліотеці. + %1$s - %2$s + зберегти + Зберегти коментар + Додати коментар + Відповісти + Додати коментар… + Коментарі + Редагування коментаря + Відповідь на %1$s + Використовуйте PDF Імена файлів + Яскравість + Актуальна книга + Додайте правило + Для цієї книги ще немає правил заміни. + Нова заміна + Редагувати заміну + с + порожній текст + про + Настільний рідер + Доступ до робочого столу + Обліковий запис + Рахунок і кредити + Огляд облікового запису + AI хаб + Використовується для EPUB резюме та PDF резюме сторінок. + Episteme осс + Авторський текст + Кеш: %1$s + Кешовано + Кешований підсумок + Виберіть Gemini голос, який використовується для читання вголос у хмарі. + Видалити згенеровану робочу книгу та EPUB файли кешу сторінки? Їх буде відтворено під час наступного відкриття книг. + Очистити голосовий кеш + Закрити інструменти + Хмарна синхронізація + Хмара TTS потребує Gemini + Хмара TTS потребує авторизації кредитів + Хмара TTS готовий + Хмара TTS налаштування + Хмара TTS недоступний + Хмара TTS голос + Містить + Розрахунок вартості + Створіть підсумок до вашої поточної посади. + Створіть розумну полицю + %1$d доступні кредити + %1$s кредити + Імпортовані шрифти для читалки + Видалити шрифт + Видалити %1$s? Книги, які використовують його, повернуться до шрифту за умовчанням. + Видалити \"%1$s\"? Книги залишаються у вашій бібліотеці. + Видалити резюме + Вимкнено + Перетягніть файли для імпорту + Перетягніть підтримувані файли для імпорту + Зв’яжіться з нами безпосередньо електронною поштою для будь-яких інших питань. + Дорівнює + Додатково + Зворотній зв\'язок + Поле + Шлях до папки + Звідси + Повне сканування + Безкоштовно, %1$d зліва + Згенерувати підсумок + Створити резюме + Повідомляйте про помилки, надсилайте запити на функції або звертайтеся безпосередньо до служби підтримки. + Спонсори GitHub + Підтримуйте розробку через спонсорів GitHub. + Google вхід не налаштовано для цієї збірки робочого столу. + Більше ніж + Довідка + Звіти про помилки, запити на функції та підтримка + Сховати + Імпорт файлів + Питання + Відкрийте програму відстеження помилок і запитів на функції. + Менше ніж + Бібліотека і читач + Будь-який + Бібліотечні акції + більше + Кешованих підсумків для цієї книги ще немає. + Імпортуйте файли TTF, OTF або WOFF2, щоб використовувати їх у книгах. + Не знайдено шрифтів, що відповідають \"%1$s\" + Ні Google обліковий запис підключено. + Для цього розділу не збережено зведення. + Офлайн настільна програма для читання + Відкриті читачі + Відкриття %1$s + Відкриття вашої бібліотеки + Оператор + Сторінка + Захищено паролем PDF + Patreon + Підтримайте проект на Patreon. + Призупинено + %1$s потрібен пароль, перш ніж його можна буде відкрити. + Пароль необхідний або неправильний. + Цей пароль не відкрив %1$s. Введіть PDF пароль і спробуйте ще раз. + Відсоток + План + Підготовка аудіо + Уподобання + Рахунок і кредити + Рахунок і кредити + Pro не розблоковано для цього облікового запису. + Pro та кредити можна придбати лише в Android додаток Desktop перевіряє той самий обліковий запис, у який ви ввійшли, і використовує ці кредити для хмарних TTS, підсумків, підсумків та інших платних AI особливості. + Увійдіть, щоб перевірити стан свого облікового запису на комп’ютері. + Pro для цього облікового запису розблоковано. + Прогрес + Проект + читач + Вкладки Reader вимкнено + Увімкнено вкладки Reader + Оновити + Відпустіть, щоб додати до своєї бібліотеки. + Безпечне зберігання ключів недоступне в цій операційній системі. Введені тут ключі використовуватимуться для цього сеансу, але не зберігатимуться. + Центр налаштувань + Відповідає Android сховати перемикач для інтелектуального словника, підсумків і підсумків. + Синхронізація облікового запису, Pro та кредитів + Ви ввійшли + Вихідний код + Перегляньте джерело проекту на GitHub. + Припиніть читати, щоб змінити голоси. + Підтримка + Підтримка Episteme + Внески допомагають читачеві вдосконалюватись у Android і робочий стіл. + Способи підтримки Episteme розвитку + Синхронізація папок + Синхронізація метаданих + Назва тега + Позначте тегами вибрані книги + Текст заголовка + Інструменти + Імпорт, синхронізація та налаштування програми + Тип, напр. PDF + Переглянути + Голосовий кеш + Підготовка вбудованого веб-перегляду… + Підготовка вбудованого веб-перегляду %1$d%% + Вбудований веб-перегляд встановлено. Перезапустіть Episteme щоб завершити налаштування. + Не вдалося запустити вбудований веб-перегляд: %1$s + Робота… + Робоча область + Додати на полицю + Спочатку створіть полицю, а потім додайте на неї вибрані книги. + Створити тему + Існуючі: %1$s + Ви натиснули зовнішнє посилання. + Редагувати EPUB метадані + менше + … більше + Спеціальних тем ще немає + Перейменувати в додатку + Теги, розділені комами + Невідомий + Визначити + Анотація + Параметри анотації + Інструменти анотування + асист + Виберіть який PDF зберегти. + Очистити історію переходів + Хмара TTS не вдалося. + Додайте Gemini і виберіть Gemini хмара TTS в AI ключі та моделі. + Хмара TTS не налаштовано для цієї збірки робочого столу. + Увійдіть за допомогою Google використовувати хмару TTS. + Хмара TTS потрібен обліковий запис із кредитами. Pro та кредити можна придбати лише в Android додаток + Колір + Варіанти коментарів + Custom + Це видаляє анотацію з цього PDF. + Видалити анотацію? + Текст документа + Вбудований PDF коментар + Не вдалося відобразити сторінку. + Функція недоступна + Готово + Перова ручка + Приховати результати пошуку + Колір виділення %1$d + Палітра хайлайтерів + Взаємодія + Індексація %1$d/%2$d сторінки + Розмітка + %1$d сірники + %1$d матчів на даний момент + Наступна сторінка + Наступний результат пошуку + Анотацій ще немає + Закладок ще немає + Без коментарів + Немає збігів + Збігів на проіндексованих сторінках ще немає + Немає змісту + Тут немає тексту для читання. + На цій сторінці немає тексту для читання. + Немає тексту для підсумовування. + Відкрити коментар + Закінчилися кредити. Pro та кредити можна придбати лише в Android додаток + Використання хмари TTS потрібні кредити на робочому столі. Pro та кредити можна придбати лише в Android додаток + Для використання цієї функції потрібні кредити на комп’ютері. Pro та кредити можна придбати лише в Android додаток + Для використання recaps потрібні кредити на комп’ютері. Pro та кредити можна придбати лише в Android додаток + Для використання резюме потрібні кредити на робочому столі. Pro та кредити можна придбати лише в Android додаток + Пан + PDF дія не вдалася + PDF дію не вдалося завершити. + PDF коментар + стор. %1$d + PDF сторінка %1$d + Сторінка %1$d - %2$s + Сторінка %1$s %2$d + Сторінки %1$s %2$d + PDF збережено + PDF інструменти + Олівець + Готується вибір + Підготовка %1$s + Попередня сторінка + Попередній результат пошуку + Діалог друку завершено. + Потрібен професіонал + Для цієї функції потрібен Pro. Pro можна придбати лише в Android після входу в систему комп’ютер використовуватиме оновлений обліковий запис. + Багатослівний розумний словник вимагає Pro. Pro можна придбати лише в Android після входу в систему комп’ютер використовуватиме оновлений обліковий запис. + Зчитувач AI функції приховані. + Робочий стіл AI не налаштовано для цієї збірки. + Застосовується для вертикального читання та двосторінкових розворотів. + Круглий хайлайтер + Збережено в %1$s + Прокрутка + Пошук у PDF + Виберіть текст + Вибрано %1$s + Показати результати пошуку + Увійдіть за допомогою Google щоб використовувати цю функцію на робочому столі. + Увійдіть за допомогою Google використовувати багатослівний розумний словник на робочому столі. + Увійдіть за допомогою Google використовувати рекапс на робочому столі. + Увійдіть за допомогою Google використовувати резюме на робочому столі. + Зупинився + Текстова примітка + текстова примітка + Стиль тексту + Товщина %1$s + TOC + Введіть для пошуку це PDF + Без назви + Переглянути рахунок і кредити + Голосовий кеш очищено + Збільшити + Збільшити + Зменшити масштаб + Виберіть + Читайте далі + Відхилити + вниз + вгору + AI + центр + Автори + Назад до бібліотеки + Книжкові акції + Папка + переглядати + Категорії + гл. %1$d + Повороти глави + Виберіть шрифт + Виберіть текстуру читача + Очистити типи файлів + Очистити анотації сторінки + Чисті джерела + Очистити статус + Очистити теги + Закрити читання + Безперервний + Обкладинки + Нестандартні кольори + Попередній перегляд спеціальної теми + Зменшити %1$s + Визначити сторінку + Це видалить виділення та його ноту. + Перейти на весь екран + Вийти з повноекранного режиму + Зовнішній пошук + Книги + Комікси + Документи + інше + Текст і Інтернет + Заповнити + Зовнішній вигляд фіксованого макета + Папка порожня + Тут немає підтримуваних файлів або вкладених папок. + %1$s, %2$s + %1$s - %2$s + Сховати фільтри + Приховати інструменти читання + Торкніться слота та виберіть колір. + Продовжуйте читати останні книги + Імпорт книг + Імпорт папки + Імпортовані шрифти + %1$s %2$s + Збільшити %1$s + Історія стрибків + Макет і інтервали + Імпортуйте файли в сховище програми або додайте папку для читання файлів на місці. + Перегляньте свою колекцію + AI ключі + Розумний %1$d + Непрочитаний %1$d + Виконується %1$d + Завершити %1$d + Список + Навігація + Немає відкритої книги + Додайте папку, щоб читати файли з цієї папки на місці. + Папок ще немає + Немає елементів навігації + Немає вмісту сторінки + Налаштувань не знайдено + Тут з’являться ручні полиці та колекції серій. + Ще немає полиць + Створіть розумні полиці, щоб збирати книги за правилами. + Розумних полиць ще немає + Тут з’являться теги, додані до книг. + Тегів ще немає + Не було імпортовано жодного підтримуваного файлу. + Каталог + Видалити "%1$s"? Потокові книги з цього каталогу можуть перестати відкриватися, якщо пізніше облікові дані зміняться. + Без каталогів + Додайте OPDS каталог для перегляду віддалених книг. + Переглядайте каталоги, потоки та завантаження + Відкрита книга + Відкрити папку + Відкрити PDF + Кольори сторінки та тексту + Інформація про сторінку + Ширина сторінки + Ці стандартні параметри застосовуються, якщо платформа підтримує спільний PDF зовнішній вигляд. За книгу PDF перекриває перебування в PDF читач. + PDF дії з файлами + PDF хайлайтер + Збережено з прозорістю підсвічування читача. + Pin + Керований читачем PDF інструменти + Автоматичне прокручування, OCR, анотації за замовчуванням і PDF-лише видимість інструменту керуються всередині активного PDF читач. + %1$s %2$s %3$d (%4$d%%) + Параметри панелі інструментів Reader за замовчуванням керуються з Reader на цій платформі. + Інструменти читача + Зберегти зображення + Пошук: %1$s + Шукати в читалці + Налаштування пошуку + Вибір + Ручка кінця вибору + Початковий маркер вибору + Додайте полиці, теги або метадані папок, щоб упорядкувати свою бібліотеку. + Колекції, серії, теги та папки + Показати засоби читання + Папка + Розумний + Твердий + швидкість + Почати автоматичне прокручування + Зупинити автоматичне прокручування + Перестаньте читати вголос + Міцність текстури + Введіть для пошуку в цій книзі + Типографіка + Скасувати анотацію + Відкріпити + Використовуйте темну тему + Використовуйте світлу тему + Моно + Sans + Засічки + Шукайте книги, авторів або теги + Без інструментів + Видно + Замінювати тільки сказане + Текст Reader, виділення та розташування залишаються незмінними. + %1$s -> %2$s diff --git a/app/src/main/res/values-vi/plurals.xml b/app/src/main/res/values-vi/plurals.xml index 29316d5..93e9d5f 100644 --- a/app/src/main/res/values-vi/plurals.xml +++ b/app/src/main/res/values-vi/plurals.xml @@ -2,41 +2,138 @@ %1$d sách + %1$d cuốn sách sách + cuốn sách %1$d kệ sách + %1$d kệ Tìm thấy %1$d kết quả + %1$d kết quả được tìm thấy Tìm thấy %1$d kết quả khớp + %1$d trận đấu được tìm thấy Xóa vĩnh viễn tệp + Xóa tập tin vĩnh viễn Bạn có muốn xóa vĩnh viễn %1$d tệp đã chọn khỏi thiết bị không? Không thể hoàn tác thao tác này. + Bạn có muốn xóa vĩnh viễn %1$d tập tin đã chọn từ thiết bị của bạn? Không thể hoàn tác hành động này. Bạn có muốn xóa %1$d tệp đã chọn khỏi danh sách tệp gần đây không? Tệp sẽ xuất hiện lại nếu bạn mở lại từ thư viện. + Bạn có muốn xóa %1$d tập tin đã chọn từ danh sách tập tin gần đây? Nó sẽ xuất hiện lại nếu bạn mở lại từ thư viện. Bạn có chắc muốn xóa %1$d sách khỏi kệ \"%2$s\" không? Sách vẫn nằm trong thư viện và xuất hiện trong Chưa xếp kệ. + Bạn có chắc chắn muốn xóa %1$d cuốn sách từ \'%2$s\' cái kệ? Sách sẽ vẫn còn trong thư viện của bạn và xuất hiện dưới mục Chưa được lưu trữ. Đã xóa %1$d sách khỏi thư viện. + %1$d cuốn sách bị xóa khỏi thư viện. %1$d thư mục + %1$d thư mục %1$d thẻ + %1$d gắn thẻ (%1$d đoạn) + (%1$d đoạn) + + + Đang nhập %1$d sách… Chúng sẽ sớm xuất hiện trong Thư viện của bạn. + Đang nhập %1$d sách… Nó sẽ sớm xuất hiện trong Thư viện của bạn. + + + Nhập khẩu %1$d sách. Bạn có thể tìm thấy chúng trong tab Thư viện. + Nhập khẩu %1$d sách. Bạn có thể tìm thấy nó trong tab Thư viện. + + + %1$d sách được thêm vào kệ. + %1$d cuốn sách được thêm vào kệ. + + + %1$d sách được gắn thẻ "%2$s". + %1$d cuốn sách được gắn thẻ "%2$s". + + + Đã xóa thư mục "%1$s" và %2$d sách từ ứng dụng. + Đã xóa thư mục "%1$s" và %2$d cuốn sách từ ứng dụng. + + + %1$d tập tin + %1$d tập tin + + + Thả để nhập %1$d tập tin + Thả để nhập %1$d tập tin + + + %1$d các tập tin không được hỗ trợ sẽ bị bỏ qua. + %1$d tập tin không được hỗ trợ sẽ bị bỏ qua. + + + Đang nhập %1$d tập tin… + Đang nhập %1$d tập tin… + + + Nhập khẩu %1$d tập tin. + Nhập khẩu %1$d tài liệu. + + + Nhập khẩu %1$d tập tin. Hỗ trợ người đọc đến sau. + Nhập khẩu %1$d tài liệu. Hỗ trợ người đọc đến sau. + + + Không thể nhập %1$d tập tin. + Không thể nhập %1$d tài liệu. + + + Đã bỏ qua %1$d tập tin. + Đã bỏ qua %1$d tài liệu. + + + Xóa "%1$s" và %2$d của nó sách từ ứng dụng? Các tập tin trên đĩa sẽ không bị xóa. + Xóa "%1$s" và %2$d của nó cuốn sách từ ứng dụng? Các tập tin trên đĩa sẽ không bị xóa. + + + Đồng bộ hóa thư mục không thành công cho %1$d thư mục. + Đồng bộ hóa thư mục không thành công cho %1$d thư mục. + + + Đồng bộ hóa thư mục đã hoàn tất với %1$d các thư mục bị bỏ qua. + Đồng bộ hóa thư mục đã hoàn tất với %1$d thư mục bị bỏ qua. + + + Đã xóa %1$d đã phát trực tuyến OPDS sách từ danh mục đó. + Đã xóa %1$d đã phát trực tuyến OPDS cuốn sách từ danh mục đó. + + + Tất cả sách %1$d + Tất cả sách %1$d + + + Kệ %1$d + Kệ %1$d + + + Thẻ %1$d + Thẻ %1$d + + + Thư mục %1$d + Thư mục %1$d diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 92b25a0..4e0fcf7 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1086,4 +1086,432 @@ Nederlands (Tiếng Hà Lan) Українська (Tiếng Ukraina) Bahasa Indonesia (Tiếng Indonesia) + Hiển thị thẻ trên thanh ứng dụng trên cùng + Tắt đồng bộ cục bộ + Bật đồng bộ cục bộ + Đã tắt đồng bộ cục bộ + Tắt đồng bộ thư mục cục bộ? + Episteme sẽ ngừng quét thư mục này và ngừng ghi các tệp đồng bộ JSON. Cũng xóa thư mục %1$s khỏi thư mục này chứ? + Giữ dữ liệu đồng bộ + Xóa dữ liệu đồng bộ + Xóa phông chữ? + Bạn có chắc muốn xóa %1$d phông chữ đã chọn không? Phông chữ sẽ bị xóa khỏi tất cả thiết bị nếu đang bật đồng bộ. + Không có thư mục cục bộ nào bật đồng bộ. + Đã tắt đồng bộ thư mục cục bộ. + Đã tắt đồng bộ thư mục cục bộ. Đã xóa thư mục dữ liệu đồng bộ. + Đã tắt đồng bộ thư mục cục bộ, nhưng không thể xóa thư mục dữ liệu đồng bộ. + Đã bật đồng bộ thư mục cục bộ. + Dọc (WebView) + Dọc (Native Beta) + Thay thế từ cho sách + Đoạn TTS trước + Đoạn TTS tiếp theo + Hình ảnh + Không tìm thấy hình ảnh nào. + Tải hình ảnh xuống + Đã lưu %1$s + Không thể lưu hình ảnh. + Dàn trang PDF + Một trang + Hai trang + Trang đầu đứng riêng + Bắt đầu dàn trang đối diện sau trang bìa. + Độ sáng + Dùng độ sáng hệ thống + Theo cài đặt độ sáng của thiết bị. + Độ sáng tùy chỉnh + Áp dụng khi màn hình đọc đang mở. + %1$d%% + Đã tạo kệ \"%1$s\". + Đã tạo kệ thông minh \"%1$s\". + Đã đổi tên kệ thành \"%1$s\". + Đã xóa kệ \"%1$s\". + Đã cập nhật \"%1$s\". + Các tệp đó đã có trong thư viện. + %1$s - %2$s + Lưu + Lưu bình luận + Thêm bình luận + Trả lời + Thêm bình luận… + Bình luận + Đang sửa bình luận + Đang trả lời %1$s + Dùng tên tệp PDF + Độ sáng + Sách hiện tại + Thêm quy tắc + Chưa có quy tắc thay thế cho sách này. + Thay thế mới + Sửa thay thế + Với + văn bản trống + Giới thiệu + Trình đọc trên desktop + Truy cập desktop + Tài khoản + Tài khoản & tín dụng + Tổng quan tài khoản + Trung tâm AI + Dùng cho tóm tắt EPUB và tóm tắt trang PDF. + Episteme OSS + Văn bản tác giả + Bộ nhớ đệm: %1$s + Đã lưu đệm + Tóm tắt đã lưu đệm + Chọn giọng Gemini dùng để đọc thành tiếng trên đám mây. + Xóa các tệp sách desktop đã tạo và bộ nhớ đệm phân trang EPUB? Chúng sẽ được tạo lại vào lần mở sách tiếp theo. + Xóa bộ nhớ đệm giọng nói + Đóng công cụ + Đồng bộ đám mây + Cloud TTS cần Gemini + Cloud TTS cần tín dụng của tài khoản đã đăng nhập + Cloud TTS sẵn sàng + Cài đặt Cloud TTS + Cloud TTS không khả dụng + Giọng Cloud TTS + Chứa + Đang tính chi phí + Tạo tóm tắt diễn biến tới vị trí hiện tại của bạn. + Tạo kệ thông minh + Còn %1$d tín dụng + %1$s tín dụng + Phông chữ đã nhập cho trình đọc + Xóa phông chữ + Xóa %1$s? Sách dùng phông này sẽ quay về phông mặc định. + Xóa \"%1$s\"? Sách vẫn ở trong thư viện của bạn. + Xóa tóm tắt + Đã tắt + Thả tệp để nhập + Thả tệp được hỗ trợ để nhập + Liên hệ trực tiếp với chúng tôi qua email cho mọi yêu cầu khác. + Bằng + Bổ sung + Phản hồi + Trường + Đường dẫn thư mục + Từ đây + Quét toàn bộ + Miễn phí, còn %1$d + Tạo tóm tắt diễn biến + Tạo tóm tắt + Báo lỗi, yêu cầu tính năng hoặc liên hệ hỗ trợ trực tiếp. + GitHub Sponsors + Ủng hộ phát triển qua GitHub Sponsors. + Đăng nhập Google chưa được cấu hình cho bản desktop này. + Lớn hơn + Trợ giúp + Báo lỗi, yêu cầu tính năng và hỗ trợ + Ẩn + Nhập tệp + Vấn đề + Mở trình theo dõi vấn đề để báo lỗi và yêu cầu tính năng. + Nhỏ hơn + Thư viện và trình đọc + Bất kỳ + Tác vụ thư viện + Thêm + Chưa có tóm tắt đã lưu đệm cho sách này. + Nhập tệp TTF, OTF hoặc WOFF2 để dùng trong sách. + Không tìm thấy phông chữ khớp với \"%1$s\" + Chưa kết nối tài khoản Google. + Chưa có tóm tắt đã lưu đệm cho phần này. + Trình đọc desktop ngoại tuyến + Trình đọc đang mở + Đang mở %1$s + Đang mở thư viện của bạn + Toán tử + Trang + PDF được bảo vệ bằng mật khẩu + Patreon + Ủng hộ dự án trên Patreon. + Đã tạm dừng + %1$s cần mật khẩu trước khi có thể mở. + Cần mật khẩu hoặc mật khẩu không đúng. + Mật khẩu đó không mở được %1$s. Nhập mật khẩu PDF rồi thử lại. + Phần trăm + Gói + Đang chuẩn bị âm thanh + Tùy chọn + Tài khoản & tín dụng + Tài khoản & tín dụng + Tài khoản này chưa mở khóa Pro. + Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. Desktop kiểm tra cùng tài khoản đã đăng nhập và dùng các tín dụng đó cho Cloud TTS, tóm tắt, tóm tắt diễn biến và các tính năng AI trả phí khác. + Đăng nhập để kiểm tra trạng thái tài khoản của bạn trên desktop. + Tài khoản này đã mở khóa Pro. + Tiến độ + Dự án + Trình đọc + Đã tắt thẻ trình đọc + Đã bật thẻ trình đọc + Làm mới + Thả để thêm vào thư viện của bạn. + Lưu trữ khóa bảo mật không khả dụng trên hệ điều hành này. Các khóa nhập ở đây sẽ được dùng cho phiên này nhưng sẽ không được lưu lại. + Trung tâm cài đặt + Khớp với công tắc ẩn trên Android cho từ điển thông minh, tóm tắt và tóm tắt diễn biến. + Tài khoản đồng bộ, Pro và tín dụng + Đã đăng nhập + Mã nguồn + Duyệt mã nguồn dự án trên GitHub. + Dừng đọc để đổi giọng. + Ủng hộ + Ủng hộ Episteme + Đóng góp giúp trình đọc tiếp tục cải thiện trên Android và desktop. + Các cách ủng hộ phát triển Episteme + Đồng bộ thư mục + Đồng bộ metadata + Tên thẻ + Gắn thẻ sách đã chọn + Văn bản tiêu đề + Công cụ + Nhập, đồng bộ và cài đặt ứng dụng + Nhập, ví dụ PDF + Xem + Bộ nhớ đệm giọng nói + Đang chuẩn bị webview nhúng… + Đang chuẩn bị webview nhúng đi kèm %1$d%% + Webview nhúng đã được cài đặt. Khởi động lại Episteme để hoàn tất thiết lập. + Không thể khởi động webview nhúng: %1$s + Đang xử lý… + Không gian làm việc + Thêm vào kệ + Tạo kệ trước, rồi thêm sách đã chọn vào đó. + Tạo chủ đề + Hiện có: %1$s + Bạn đã nhấp vào liên kết bên ngoài. + Sửa metadata EPUB + Ít hơn + …thêm + Chưa có chủ đề tùy chỉnh + Đổi tên trong ứng dụng + Thẻ, phân tách bằng dấu phẩy + Không rõ + Định nghĩa + Chú thích + Tùy chọn chú thích + Công cụ chú thích + Hỗ trợ + Chọn PDF cần lưu. + Xóa lịch sử nhảy trang + Cloud TTS thất bại. + Thêm khóa Gemini và chọn Gemini Cloud TTS trong khóa và mô hình AI. + Cloud TTS chưa được cấu hình cho bản desktop này. + Đăng nhập bằng Google để dùng Cloud TTS. + Cloud TTS cần tài khoản đã đăng nhập có tín dụng. Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. + Màu + Tùy chọn bình luận + Tùy chỉnh + Thao tác này sẽ xóa chú thích khỏi PDF này. + Xóa chú thích? + Văn bản tài liệu + Bình luận PDF nhúng + Không thể hiển thị trang. + Tính năng không khả dụng + Đã hoàn tất + Bút máy + Ẩn kết quả tìm kiếm + Màu tô sáng %1$d + Bảng màu bút tô sáng + Tương tác + Đang lập chỉ mục %1$d/%2$d trang + Đánh dấu + %1$d kết quả + %1$d kết quả đến lúc này + Trang tiếp theo + Kết quả tìm kiếm tiếp theo + Chưa có chú thích + Chưa có dấu trang + Không có bình luận + Không có kết quả + Chưa có kết quả trong các trang đã lập chỉ mục + Không có mục lục + Không có văn bản ở đây để đọc. + Không có văn bản trên trang này để đọc. + Không có văn bản để tóm tắt. + Mở bình luận + Hết tín dụng. Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. + Dùng Cloud TTS cần tín dụng trên desktop. Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. + Dùng tính năng này cần tín dụng trên desktop. Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. + Dùng tóm tắt diễn biến cần tín dụng trên desktop. Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. + Dùng tóm tắt cần tín dụng trên desktop. Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. + Kéo trang + Tác vụ PDF thất bại + Không thể hoàn tất tác vụ PDF. + Bình luận PDF + tr. %1$d + Trang PDF %1$d + Trang %1$d - %2$s + Trang %1$s trên %2$d + Trang %1$s trên %2$d + Đã lưu PDF + Công cụ PDF + Bút chì + Đang chuẩn bị vùng chọn + Đang chuẩn bị %1$s + Trang trước + Kết quả tìm kiếm trước + Hộp thoại in đã hoàn tất. + Cần Pro + Tính năng này cần Pro. Chỉ có thể mua Pro từ ứng dụng Android, sau đó desktop sẽ dùng tài khoản đã nâng cấp sau khi đăng nhập. + Từ điển thông minh nhiều từ cần Pro. Chỉ có thể mua Pro từ ứng dụng Android, sau đó desktop sẽ dùng tài khoản đã nâng cấp sau khi đăng nhập. + Các tính năng AI trong trình đọc đang bị ẩn. + AI trên desktop chưa được cấu hình cho bản dựng này. + Áp dụng cho chế độ đọc dọc. + Bút tô sáng bo tròn + Đã lưu vào %1$s + Cuộn + Tìm trong PDF + Chọn văn bản + Đã chọn %1$s + Hiện kết quả tìm kiếm + Đăng nhập bằng Google để dùng tính năng này trên desktop. + Đăng nhập bằng Google để dùng từ điển thông minh nhiều từ trên desktop. + Đăng nhập bằng Google để dùng tóm tắt diễn biến trên desktop. + Đăng nhập bằng Google để dùng tóm tắt trên desktop. + Đã dừng + Ghi chú văn bản + ghi chú văn bản + Kiểu văn bản + Độ dày %1$s + Mục lục + Nhập để tìm trong PDF này + Chưa có tiêu đề + Xem tài khoản & tín dụng + Đã xóa bộ nhớ đệm giọng nói + Thu phóng + Phóng to + Thu nhỏ + Chọn + Tiếp tục đọc + Bỏ qua + Xuống + Lên + AI + Căn giữa + Tác giả + Quay lại thư viện + Tác vụ sách + Thư mục + Duyệt + Danh mục + Ch. %1$d + Chuyển chương + Chọn phông chữ + Chọn họa tiết trình đọc + Xóa loại tệp + Xóa chú thích trang + Xóa nguồn + Xóa trạng thái + Xóa thẻ + Đóng trình đọc + Liên tục + Bìa + Màu tùy chỉnh + Xem trước chủ đề tùy chỉnh + Giảm %1$s + Định nghĩa trang + Thao tác này sẽ xóa phần tô sáng và ghi chú của nó. + Vào toàn màn hình + Thoát toàn màn hình + Tra cứu bên ngoài + Sách + Truyện tranh + Tài liệu + Khác + Văn bản và web + Lấp đầy + Giao diện bố cục cố định + Thư mục trống + Không có tệp hoặc thư mục con được hỗ trợ ở đây. + %1$s, %2$s + %1$s - %2$s + Ẩn bộ lọc + Ẩn công cụ đọc + Nhấn một ô, rồi chọn màu. + Tiếp tục đọc và sách gần đây + Nhập sách + Nhập thư mục + Phông chữ đã nhập + %1$s %2$s + Tăng %1$s + Lịch sử nhảy trang + Bố cục và khoảng cách + Nhập tệp vào bộ nhớ ứng dụng hoặc thêm thư mục để đọc tệp tại chỗ. + Duyệt bộ sưu tập của bạn + Khóa AI + Thông minh %1$d + Chưa đọc %1$d + Đang đọc %1$d + Hoàn tất %1$d + Danh sách + Điều hướng + Chưa mở sách + Thêm thư mục để đọc tệp trực tiếp từ thư mục đó. + Chưa có thư mục + Không có mục điều hướng + Không có nội dung trang + Không tìm thấy cài đặt + Kệ thủ công và bộ sưu tập theo bộ sách sẽ xuất hiện ở đây. + Chưa có kệ + Tạo kệ thông minh để gom sách theo quy tắc. + Chưa có kệ thông minh + Thẻ đã thêm vào sách sẽ xuất hiện ở đây. + Chưa có thẻ + Chưa nhập tệp được hỗ trợ nào. + Danh mục + Xóa \"%1$s\"? Sách đọc trực tuyến từ danh mục này có thể ngừng mở nếu thông tin đăng nhập thay đổi sau này. + Không có danh mục + Thêm danh mục OPDS để duyệt sách từ xa. + Duyệt danh mục, luồng và bản tải xuống + Mở sách + Mở thư mục + Mở PDF + Màu trang và chữ + Thông tin trang + Chiều rộng trang + Các mặc định này áp dụng khi nền tảng hỗ trợ giao diện PDF dùng chung. Ghi đè PDF theo từng sách vẫn nằm trong trình đọc PDF. + Tác vụ tệp PDF + Bút tô sáng PDF + Được lưu cùng độ trong suốt tô sáng của trình đọc. + Ghim + Công cụ PDF do trình đọc quản lý + Tự động cuộn, OCR, mặc định chú thích và hiển thị công cụ chỉ dành cho PDF được quản lý bên trong trình đọc PDF đang hoạt động. + %1$s %2$s trên %3$d (%4$d%%) + Mặc định thanh công cụ trình đọc được quản lý từ trình đọc trên nền tảng này. + Công cụ đọc + Lưu hình ảnh + Tìm kiếm: %1$s + Tìm trong trình đọc + Cài đặt tìm kiếm + Vùng chọn + Tay nắm cuối vùng chọn + Tay nắm đầu vùng chọn + Thêm kệ, thẻ hoặc metadata thư mục để sắp xếp thư viện của bạn. + Bộ sưu tập, bộ sách, thẻ và thư mục + Hiện công cụ đọc + Thư mục + Thông minh + Màu trơn + Tốc độ + Bắt đầu tự động cuộn + Dừng tự động cuộn + Dừng đọc thành tiếng + Độ mạnh họa tiết + Nhập để tìm trong sách này + Kiểu chữ + Hoàn tác chú thích + Bỏ ghim + Dùng chủ đề tối + Dùng chủ đề sáng + Mono + Sans + Serif + Tìm sách, tác giả hoặc thẻ + Không có công cụ + Hiển thị + Chỉ thay thế nội dung được đọc + Văn bản trình đọc, tô sáng và vị trí vẫn không đổi. + %1$s -> %2$s + Không thể sao chép vào khay nhớ tạm + Không thể in tệp PDF được bảo vệ bằng mật khẩu diff --git a/app/src/main/res/values-zh-rCN/plurals.xml b/app/src/main/res/values-zh-rCN/plurals.xml index 06c09ae..e578ab6 100644 --- a/app/src/main/res/values-zh-rCN/plurals.xml +++ b/app/src/main/res/values-zh-rCN/plurals.xml @@ -2,41 +2,138 @@ %1$d 本书 + %1$d书 本书 + %1$d 个书架 + %1$d架子 找到 %1$d 个结果 + %1$d找到结果 找到 %1$d 个匹配项 + %1$d找到匹配项 永久删除文件 + 永久删除文件 确定要从设备中永久删除选中的 %1$d 个文件吗?此操作无法撤销。 + 您想永久删除 %1$d从您的设备中选择文件?此操作无法撤消。 要从最近文件列表中移除选中的 %1$d 个文件吗?如果再次从书库打开,它们会重新显示。 + 您要删除 %1$d从最近的文件列表中选择文件?如果您再次从库中打开它,它会重新出现。 确定要从“%2$s”书架中移除 %1$d 本书吗?这些书仍会保留在你的书库中,并显示在未归架下。 + 您确定要删除 %1$d本书来自\'%2$s\'架子?该书将保留在您的图书馆中并显示在“未上架”下。 已从书库移除 %1$d 本书。 + %1$d书已从图书馆删除。 %1$d 个文件夹 + %1$d文件夹 %1$d 个标签 + %1$d标签 (%1$d 个片段) + (%1$d 块) + + + 导入%1$d书籍……它们很快就会出现在您的图书馆中。 + 导入%1$d书...它很快就会出现在您的图书馆中。 + + + 进口%1$d图书。您可以在“库”选项卡中找到它们。 + 进口%1$d书。您可以在“库”选项卡中找到它。 + + + %1$d书籍已添加至书架。 + %1$d书已添加到书架。 + + + %1$d标有“%2$s”的书籍。 + %1$d标有“%2$s”的书。 + + + 已删除文件夹“%1$s”和%2$d来自应用程序的书籍。 + 已删除文件夹“%1$s”和%2$d从应用程序预订。 + + + %1$d文件 + %1$d文件 + + + 拖拽导入%1$d文件 + 拖拽导入%1$d文件 + + + %1$d将跳过不支持的文件。 + %1$d不支持的文件将被跳过。 + + + 导入%1$d文件… + 导入%1$d文件… + + + 进口%1$d文件。 + 进口%1$d文件。 + + + 进口%1$d文件。读者支持稍后提供。 + 进口%1$d文件。读者支持稍后提供。 + + + 无法导入%1$d文件。 + 无法导入%1$d文件。 + + + 已跳过 %1$d文件。 + 已跳过 %1$d文件。 + + + 删除“%1$s”及其%2$d来自应用程序的书籍?磁盘上的文件不会被删除。 + 删除“%1$s”及其%2$d从应用程序预订?磁盘上的文件不会被删除。 + + + %1$d 文件夹同步失败文件夹。 + %1$d 文件夹同步失败文件夹。 + + + 文件夹同步已完成 %1$d跳过的文件夹。 + 文件夹同步已完成 %1$d文件夹已跳过。 + + + 已删除%1$d流式传输OPDS该目录中的书籍。 + 已删除%1$d流式传输OPDS该目录中的书。 + + + 所有书籍%1$d + 所有书籍%1$d + + + 货架%1$d + 货架%1$d + + + 标签%1$d + 标签%1$d + + + 文件夹%1$d + 文件夹%1$d diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 59528ba..48d1574 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1086,4 +1086,430 @@ Nederlands(荷兰语) Українська(乌克兰语) Bahasa Indonesia(印度尼西亚语) + 在顶部应用栏中显示选项卡 + 禁用本地同步 + 启用本地同步 + 本地同步已禁用 + 禁用本地文件夹同步? + Episteme将停止扫描此文件夹并停止写入 JSON同步文件。删除 %1$s文件夹也来自这个文件夹吗? + 保持同步数据 + 删除同步数据 + 删除字体? + 您确定要删除 %1$d选定的字体?如果同步已打开,这会将它们从您的所有设备中删除。 + 没有本地文件夹启用同步。 + 本地文件夹同步已禁用。 + 本地文件夹同步已禁用。同步数据文件夹已删除。 + 本地文件夹同步已禁用,但无法删除同步数据文件夹。 + 本地文件夹同步已启用。 + 垂直(Web 视图) + 垂直(原生测试版) + 书本单词替换 + 上一页 TTS块 + 下一页 TTS块 + 图片 + 没有找到图像。 + 下载图片 + 已保存%1$s + 无法保存图像。 + PDF页面跨度 + 单页 + 两页 + 单独第一页 + 在封面页之后开始跨页。 + 亮度 + 使用系统亮度 + 遵循设备亮度设置。 + 自定义亮度 + 在阅读器屏幕打开时适用。 + %1$d%% + 创建架子“%1$s”。 + 创建智能货架“%1$s”。 + 将架子重命名为“%1$s”。 + 已删除架子“%1$s”。 + 更新了“%1$s”。 + 这些文件已经在库中。 + %1$s - %2$s + 保存 + 保存评论 + 添加评论 + 回复 + 添加评论... + 评论 + 编辑评论 + 回复%1$s + 使用PDF文件名 + 亮度 + 当前书籍 + 添加规则 + 本书尚无替换规则。 + 新的替代品 + 编辑替换 + + 空文本 + 关于 + 桌面阅读器 + 桌面访问 + 账户 + 账户和积分 + 账户概览 + AI枢纽 + 用于EPUB摘要和 PDF页面摘要。 + Episteme操作系统 + 作者文字 + 缓存:%1$s + 缓存 + 缓存摘要 + 选择Gemini用于云朗读的语音。 + 删除生成的桌面书籍和 EPUB分页缓存文件?它们将在下次打开书籍时重新创建。 + 清除语音缓存 + 关闭工具 + 云同步 + 云TTS需要Gemini + 云TTS需要登录积分 + 云TTS准备好了 + 云TTS设置 + 云TTS不可用 + 云TTS声音 + 包含 + 成本计算 + 对您当前的职位进行回顾。 + 打造智能货架 + %1$d可用学分 + %1$s学分 + 为读者导入的字体 + 删除字体 + 删除%1$s?使用它的书籍将恢复为默认字体。 + 删除\“%1$s\”?书籍留在您的图书馆中。 + 删除摘要 + 残疾人 + 拖放要导入的文件 + 删除要导入的受支持文件 + 如需其他任何信息,请直接通过电子邮件联系我们。 + 等于 + 附加功能 + 反馈意见 + 领域 + 文件夹路径 + 从这里 + 全盘扫描 + 免费,%1$d左 + 生成回顾 + 生成摘要 + 报告错误、请求功能或直接联系支持人员。 + GitHub 赞助商 + 通过 GitHub 赞助商支持开发。 + Google未为此桌面版本配置登录。 + 大于 + 帮助 + 错误报告、功能请求和支持 + 隐藏 + 导入文件 + 问题 + 打开问题跟踪器以查找错误和功能请求。 + 小于 + 图书馆和读者 + 任意 + 图书馆行动 + 更多的 + 还没有这本书的缓存摘要。 + 导入 TTF、OTF 或 WOFF2 文件以在书籍中使用它们。 + 未找到与“%1$s\”匹配的字体 + 否 Google帐户已连接。 + 没有为此部分缓存摘要。 + 离线桌面阅读器 + 开放读者 + 开幕%1$s + 打开你的图书馆 + 操作员 + 页面 + 密码保护PDF + 帕特隆 + 支持 Patreon 上的项目。 + 已暂停 + %1$s需要输入密码才能打开。 + 密码为必填项或不正确。 + 该密码打不开%1$s。输入 PDF密码并重试。 + 百分比 + 计划 + 准备音频 + 偏好设置 + 账户和积分 + 账户和积分 + 此帐户未解锁 Pro。 + 专业版和积分只能从 Android 购买应用程序。 Desktop 检查相同的登录帐户,并将这些积分用于云 TTS、摘要、回顾和其他付费 AI特征。 + 登录并在桌面上检查您的帐户状态。 + 此帐户已解锁 Pro。 + 进展 + 项目 + 读者 + 阅读器关闭标签 + 阅读器选项卡打开 + 刷新 + 发布以添加到您的库中。 + 安全密钥存储在此操作系统上不可用。此处输入的密钥将用于此会话,但不会保留。 + 设置中心 + 匹配Android隐藏智能词典、摘要和回顾的切换。 + 同步帐户、Pro 和积分 + 已登录 + 源代码 + 浏览 GitHub 上的项目源。 + 停止阅读以改变声音。 + 支持 + 支持Episteme + 贡献有助于读者不断提高 Android和桌面。 + 支持方式Episteme发展 + 同步文件夹 + 同步元数据 + 标签名称 + 标记选定的书籍 + 标题文字 + 工具 + 导入、同步和应用程序设置 + 类型,例如PDF + 查看 + 语音缓存 + 正在准备嵌入的网络视图... + 准备捆绑的嵌入式 webview %1$d%% + 安装了嵌入式 webview。重新启动Episteme完成设置。 + 嵌入式 webview 无法启动:%1$s + 工作… + 工作空间 + 添加到货架 + 首先创建一个书架,然后将选定的书籍添加到其中。 + 创建主题 + 现有:%1$s + 您点击了外部链接。 + 编辑EPUB元数据 + + …更多 + 还没有自定义主题 + 在应用程序中重命名 + 标签,逗号分隔 + 未知 + 定义 + 注释 + 注释选项 + 注释工具 + 协助 + 选择哪个PDF保存。 + 清除跳转历史记录 + 云TTS失败的。 + 添加 Gemini键并选择 Gemini云TTS在AI钥匙和型号。 + 云TTS未针对此桌面版本进行配置。 + 使用 Google 登录使用云TTS。 + 云TTS需要一个有积分的登录帐户。专业版和积分只能从 Android 购买应用程序。 + 颜色 + 评论选项 + 定制 + 这将从 PDF 中删除注释。 + 删除注释? + 文档文本 + 嵌入式PDF评论 + 无法呈现页面。 + 功能不可用 + 完成 + 钢笔 + 隐藏搜索结果 + 突出显示颜色 %1$d + 荧光笔调色板 + 互动 + 索引 %1$d/%2$d页面 + 标记 + %1$d比赛 + %1$d到目前为止的比赛 + 下一页 + 下一个搜索结果 + 还没有注释 + 还没有书签 + 暂无评论 + 没有匹配项 + 索引页面中尚无匹配项 + 没有目录 + 这里没有文字可供阅读。 + 此页上没有可供阅读的文字。 + 没有文字可以概括。 + 打开评论 + 积分用完。专业版和积分只能从 Android 购买应用程序。 + 使用云TTS桌面上需要积分。专业版和积分只能从 Android 购买应用程序。 + 使用此功能需要桌面上的积分。专业版和积分只能从 Android 购买应用程序。 + 使用 recaps 需要桌面上的学分。专业版和积分只能从 Android 购买应用程序。 + 使用摘要需要在桌面上使用积分。专业版和积分只能从 Android 购买应用程序。 + + PDF操作失败 + PDF操作无法完成。 + PDF评论 + p。 %1$d + PDF页 %1$d + 页%1$d - %2$s + 页%1$s %2$d + 页数%1$s %2$d + PDF已保存 + PDF工具 + 铅笔 + 准备选择 + 准备%1$s + 上一页 + 上一个搜索结果 + 打印对话框已完成。 + 需要专业版 + 此功能需要专业版。专业版只能从 Android 购买app,则桌面登录后将使用升级后的帐户。 + 多词智能词典需要Pro。专业版只能从 Android 购买app,则桌面登录后将使用升级后的帐户。 + 读者AI功能被隐藏。 + 桌面AI没有为此版本配置。 + 适用于垂直阅读和两页跨页。 + 圆形荧光笔 + 保存到%1$s + 滚动 + 搜索 PDF + 选择文本 + 已选择%1$s + 显示搜索结果 + 使用 Google 登录在桌面上使用此功能。 + 使用 Google 登录在桌面上使用多词智能词典。 + 使用 Google 登录在桌面上使用 recaps。 + 使用 Google 登录在桌面上使用摘要。 + 已停止 + 文字注释 + 文字注释 + 文字样式 + 厚度%1$s + 总有机碳 + 输入搜索此 PDF + 无题 + 查看帐户和积分 + 语音缓存已清除 + 变焦 + 放大 + 缩小 + 选择 + 继续阅读 + 解雇 + 向下 + 向上 + AI + 中心 + 作者 + 返回图书馆 + 预订行动 + 文件夹 + 浏览 + 类别 + 章。 %1$d + 章转 + 选择字体 + 选择读卡器纹理 + 清除文件类型 + 清除页面注释 + 来源清晰 + 状态清晰 + 清除标签 + 关闭读者 + 连续 + 封面 + 定制颜色 + 自定义主题预览 + 降低%1$s + 定义页面 + 这将删除突出显示及其注释。 + 进入全屏 + 退出全屏 + 外部查找 + 书籍 + 漫画 + 文件 + 其他 + 文本和网络 + 填充 + 固定布局外观 + 文件夹为空 + 此处不提供受支持的文件或子文件夹。 + %1$s,%2$s + %1$s - %2$s + 隐藏过滤器 + 隐藏阅读器工具 + 点击一个插槽,然后选择一种颜色。 + 继续阅读和最近的书籍 + 进口书籍 + 导入文件夹 + 导入字体 + %1$s %2$s + 增加%1$s + 跳转历史记录 + 布局和间距 + 将文件导入应用程序存储或添加文件夹以就地读取文件。 + 浏览您的收藏 + AI键 + 智能%1$d + 未读%1$d + 进行中%1$d + 完整%1$d + 列表 + 导航 + 没有书打开 + 添加一个文件夹以从该文件夹中读取文件。 + 还没有文件夹 + 没有导航项目 + 没有页面内容 + 没有找到设置 + 手动货架和系列收藏品都会出现在这里。 + 还没有货架 + 创建智能书架,按规则收集书籍。 + 还没有智能货架 + 添加到书籍的标签将显示在此处。 + 还没有标签 + 未导入受支持的文件。 + 目录 + 删除“%1$s”?如果稍后凭据发生更改,此目录中的流式图书可能会停止打开。 + 没有目录 + 添加OPDS目录以浏览远程书籍。 + 浏览目录、流和下载 + 打开书本 + 打开文件夹 + 打开PDF + 页面和文字颜色 + 页面信息 + 页宽 + 这些默认值适用于平台支持共享 PDF 的地方外貌。每本书PDF覆盖保留在PDF读者。 + PDF文件操作 + PDF荧光笔 + 与阅读器一起保存突出显示透明度。 + + 读者管理 PDF工具 + 自动滚动、OCR、注释默认值和 PDF-仅限工具可见性在活动 PDF 内进行管理读者。 + %1$s %2$s %3$d (%4$d%%) + 阅读器工具栏默认值由该平台上的阅读器管理。 + 读者工具 + 保存图像 + 搜索:%1$s + 在阅读器中搜索 + 搜索设置 + 选择 + 选择结束手柄 + 选择开始手柄 + 添加书架、标签或文件夹元数据来组织您的图书馆。 + 集合、系列、标签和文件夹 + 显示读者工具 + 文件夹 + 智能 + 固体 + 速度 + 开始自动滚动 + 停止自动滚动 + 停止大声朗读 + 质感强度 + 输入搜索这本书 + 版式 + 撤消注释 + 取消固定 + 使用深色主题 + 使用浅色主题 + 单声道 + 桑斯 + 衬线 + 搜索书籍、作者或标签 + 没有工具 + 可见 + 仅替换所说内容 + 阅读器文本、突出显示和位置保持不变。 + %1$s -> %2$s diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml index 81381ea..a2c50fb 100644 --- a/app/src/main/res/values/plurals.xml +++ b/app/src/main/res/values/plurals.xml @@ -1,203 +1,204 @@ - + %1$d book %1$d books - + book books - + %1$d shelf %1$d shelves - + %1$d result found %1$d results found - + %1$d match found %1$d matches found + Delete File Permanently Delete Files Permanently - + Do you want to permanently delete %1$d selected file from your device? This action cannot be undone. Do you want to permanently delete %1$d selected files from your device? This action cannot be undone. - + Do you want to remove %1$d selected file from the recent files list? It will reappear if you open it again from the library. Do you want to remove %1$d selected files from the recent files list? It will reappear if you open it again from the library. - + Are you sure you want to remove %1$d book from the \'%2$s\' shelf? The book will remain in your library and appear under Unshelved. Are you sure you want to remove %1$d books from the \'%2$s\' shelf? The books will remain in your library and appear under Unshelved. - + %1$d book removed from library. %1$d books removed from library. - + Importing %1$d book… It will appear in your Library shortly. Importing %1$d books… They will appear in your Library shortly. - + Imported %1$d book. You can find it in the Library tab. Imported %1$d books. You can find them in the Library tab. - + %1$d book added to shelf. %1$d books added to shelf. - + %1$d book tagged with "%2$s". %1$d books tagged with "%2$s". - + Removed folder "%1$s" and %2$d book from the app. Removed folder "%1$s" and %2$d books from the app. - + %1$d folder %1$d folders - + %1$d file %1$d files - + Drop to import %1$d file Drop to import %1$d files - + %1$d unsupported file will be skipped. %1$d unsupported files will be skipped. - + Importing %1$d file… Importing %1$d files… - + Imported %1$d file. Imported %1$d files. - + Imported %1$d file. Reader support comes later. Imported %1$d files. Reader support comes later. - + Could not import %1$d file. Could not import %1$d files. - + Skipped %1$d file. Skipped %1$d files. - + Remove "%1$s" and its %2$d book from the app? Files on disk will not be deleted. Remove "%1$s" and its %2$d books from the app? Files on disk will not be deleted. - + Folder sync failed for %1$d folder. Folder sync failed for %1$d folders. - + Folder sync finished with %1$d folder skipped. Folder sync finished with %1$d folders skipped. - + Removed %1$d streamed OPDS book from that catalog. Removed %1$d streamed OPDS books from that catalog. - + %1$d tag %1$d tags - + All Books %1$d All Books %1$d - + Shelves %1$d Shelves %1$d - + Tags %1$d Tags %1$d - + Folders %1$d Folders %1$d - + (%1$d chunk) (%1$d chunks) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8e56792..f8c6274 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,7 +16,7 @@ Clear Apply Enable - + Error: %1$s Go Back @@ -29,12 +29,12 @@ Are you sure you want to close all active tabs? - + %1$s you agree to our %2$s and acknowledge you have read our %3$s. Terms of Service Privacy Policy Licenses - + %1$d selected Clear Selection @@ -47,10 +47,10 @@ File Information Book Name Copy Name - + Original Name: %1$s Revert to Original - + File Name: %1$s Author Format @@ -63,7 +63,7 @@ Internal storage About Episteme - + Version: %1$s (Build: %2$d) Select a File Clear All Synced Data? @@ -83,9 +83,9 @@ Sync Folder Local Folder - OPDS Stream + OPDS Pinned - + %1$d%% complete Not available locally @@ -110,7 +110,7 @@ Recent Files Limit No limit - + %1$d files Clear Book Cache Clear Reflow Cache @@ -119,17 +119,17 @@ Library Search title or author… - + Types: %1$s - + Folders: %1$d - + Status: %1$s All Books Shelves Folders Catalogs - + No results found for \"%1$s\" Select a PDF, EPUB, MOBI, or AZW3 file from your device to get started. @@ -144,21 +144,21 @@ Add books This shelf is empty - + Add to %1$s - + ADD (%1$d) No unshelved books to add All books are already in this shelf Rename Shelf Delete Shelf? - + Are you sure you want to delete the \'%1$s\' shelf? All books will be moved to Unshelved. Remove from Shelf? - + Delete %1$s? - + Are you sure you want to delete the %1$d selected %2$s? All books within will be moved to Unshelved. @@ -176,6 +176,14 @@ BOOKS Edit Filters Remove Folder + Disable local sync + Enable local sync + Local sync disabled + Disable local folder sync? + + Episteme will stop scanning this folder and stop writing JSON sync files. Remove the %1$s folder from this folder too? + Keep sync data + Remove sync data Filter File Types Select the file types you want to sync from this folder: Filter Library @@ -193,8 +201,13 @@ Keep in Library Remove Ask Every Time + After closing an externally opened file, ask whether to keep it in the library or remove it. Always Keep + Externally opened files are copied into the library and kept after closing. Always Remove + Externally opened files are copied for reading, then removed after closing. + Open Temporarily + Open directly from the source app in a temporary reader. Back returns to that app without adding the file to the library. External File Behavior @@ -230,9 +243,9 @@ Username Password Delete Catalog - + Are you sure you want to delete \'%1$s\'? - + Deleting this catalog will also permanently remove %1$d streaming books associated with it from your library. Preset @@ -298,7 +311,7 @@ Browse Google Fonts Search 1900+ fonts… Popular Choices - + No fonts found matching \'%1$s\' Already Downloaded No Custom Fonts @@ -308,8 +321,11 @@ Grumpy wizards make toxic brew for the evil queen! 1234567890 ?.,;: Preview unavailable (Invalid font file) Delete Font? - + Are you sure you want to delete \'%1$s\'? This will remove it from all your devices if sync is on. + Delete Fonts? + + Are you sure you want to delete %1$d selected fonts? This will remove them from all your devices if sync is on. Get in Touch @@ -339,7 +355,7 @@ Device Limit Reached To use Episteme Pro on this device, please remove one of your existing registered devices. - + Last seen: %1$s Confirm Destructive Action @@ -427,25 +443,25 @@ [Debug] Show Device Management [Debug] Clear Cloud & Local Data - + FPS: %1$d Your device doesn\'t support folder selection. You can still import files individually. No file manager found. Please install a file manager app. Feedback: Episteme Reader - + Downloaded %1$s - + %1$s: %2$s Never - + %1$s: %2$d - + Removed %1$d streaming books. - + Failed to import font: %1$s Text view deleted. @@ -463,22 +479,26 @@ PDF saved successfully. Failed to open file for saving. - + Error saving PDF: %1$s Saving original PDF… Original PDF saved successfully. - + Saving original file... + Original file saved successfully. + Error saving file: %1$s + Sharing: %1$s Share PDF - + Share file + Share failed: %1$s - + Limit reached: Maximum %1$d folders allowed. This folder is already synced. - + Folder added: %1$s Failed to access folder permissions. Folder removed. @@ -486,8 +506,13 @@ Scanning folder for new books… Folder Sync: Scan complete. Sync failed. + No local folders have sync enabled. + Local folder sync disabled. + Local folder sync disabled. Sync data folder removed. + Local folder sync disabled, but the sync data folder could not be removed. + Local folder sync enabled. Enable sync to download files. - + Failed to download %1$s. Enable sync to clear cloud data. Not signed in, cannot clear cloud data. @@ -514,13 +539,13 @@ Failed to load generated text view. Text view generation failed. - + Failed to load FB2: %1$s - + Failed to load file: %1$s - + Failed to load MOBI: %1$s - + Failed to load EPUB: %1$s File deleted from folder. Removed from library. A shelf with that name already exists. @@ -547,7 +572,7 @@ Thinking… Open in Dictionary App AI could not provide a definition. - + Asking AI about \'%1$s\'… @@ -574,12 +599,12 @@ Loading voices… No voices available on this device. Specific Voices - + Available Voices (%1$d) No voices found for this language. - + Variant: %1$s - + This is a sample of %1$s. @@ -652,10 +677,10 @@ The book content is empty. Failed to parse summary from server response. Could not fetch summary. - + Error: %1$d. %2$s Network error. Please check connection and server status. - + Analyzing Chapter %1$d… Reading current position… Generating Recap… @@ -694,6 +719,8 @@ Delete Text View Vertical + Vertical (WebView) + Vertical (Native Beta) Paginated (left-to-right) @@ -716,6 +743,7 @@ TTS Voice Settings TTS Word Replacements + Book Word Replacements Share, Save or Print TTS Settings (Debug) @@ -765,7 +793,7 @@ Faster Decrease Increase - + Page %1$d of %2$d @@ -794,11 +822,12 @@ Are you sure you want to permanently delete this highlight? + Saved %1$s Could not save image. Original PDF not found. - + Error: Book content not found. Path: %1$s Please select a dictionary app first. Please select a translate app first. @@ -819,13 +848,13 @@ Wait for book to load fully. Release for Previous Chapter Release for Next Chapter - + Pull further… (%1$d%%) Chapter Could not get chapter content. Could not determine current chapter. WebView not available. - + Page %1$d/%2$d @@ -870,6 +899,7 @@ Follows the device brightness setting. Custom brightness Applies while a reader screen is open. + %1$d%% @@ -885,9 +915,9 @@ Open Source Version Playstore Version - + Version %1$s - + Build %1$s GitHub @@ -896,26 +926,26 @@ How we handle your data. Usage terms and conditions. Open source libraries used. - + Importing %1$d books… They will appear in your Library shortly. - + Created shelf "%1$s". - + Created smart shelf "%1$s". - + Renamed shelf to "%1$s". - + Deleted shelf "%1$s". - + Updated "%1$s". Those files are already in the library. - + %1$s - %2$s External Link - + You clicked on an external link:\n\n%1$s\n\nWhat would you like to do? Open No browser found to open the link. @@ -936,6 +966,7 @@ Note Comments Editing comment + Replying to %1$s Edit @@ -960,9 +991,9 @@ Voice Adjustments - + Speed (%1$sx) - + Pitch (%1$sx) This is how your current voice settings sound. Pause Book @@ -993,9 +1024,9 @@ Edit Note - + %1$d / %2$d - + Page %1$d of %2$d @@ -1021,17 +1052,19 @@ Insert Text Box - + OCR selection error: %1$s - + Selection error: %1$s - + Error processing page: %1$s - + Unable to display page %1$d. Could not open print settings + Could not copy to clipboard + Password protected PDF files cannot be printed Loading PDF… @@ -1043,7 +1076,7 @@ Pen Playground Import SVG - + Imported %1$d SVG strokes! Failed to import SVG or empty. @@ -1051,7 +1084,7 @@ OCR Language Insert Blank Page Delete Page - + Generating… %1$d%% Open Text View @@ -1062,17 +1095,17 @@ Print Generating Text View… - + Indexing pages… %1$d%% done. Search results will update automatically. - + Results found on %1$d+ pages - + Result %1$d / %2$d - + %1$d+ Pages - + Summarize Page (Page %1$d) - + Downloading %1$s language pack… Select OCR Language @@ -1082,7 +1115,7 @@ You can change this later in More Options > OCR Language. Re-index Document? - + You are changing the OCR script to %1$s.\n\nTo ensure search accuracy, we need to clear the existing index and re-scan pages that require OCR using this new language.\n\nThis will happen in the background. Re-index @@ -1092,7 +1125,7 @@ Incorrect password Hide password Show password - + You are about to navigate to:\n%1$s Visit Save to Device @@ -1108,7 +1141,7 @@ No other PDFs found in your library. PDF is empty or could not be displayed. - + Page added at %1$d Page deleted Extra page removed @@ -1147,7 +1180,7 @@ Test Panel ML Detection Test Speech Bubble ML Detection - + Export Logs (Last %1$d lines) Enable Strict File Filter @@ -1172,6 +1205,7 @@ 한국어 (Korean) हिन्दी (Hindi) 简体中文 (Chinese, Simplified) + Eesti (Estonian) App Theme @@ -1203,7 +1237,7 @@ Close search Clear query Search shelf - + %1$s shelf cover Preserve Image Colors Keep original image colors when theme changes @@ -1228,7 +1262,7 @@ Unread In Progress Completed - + Tags: %1$s Browse by tag Tags @@ -1239,7 +1273,7 @@ Text to speech Playback controls for text to speech. Preparing text to speech - + Preparing: %1$s Active TTS Engine Cloud AI @@ -1256,7 +1290,7 @@ Offline Voice Filter No audio cached for this voice. - + Clear Cache for %1$s This is a voice sample. @@ -1265,17 +1299,17 @@ Summary Recap Cache - + No summary for %1$s yet. - + Generate Summary for %1$s Get a recap of the story up to your current position. Generate Story Recap Story Recap Cache Hit • Free - + Generated • Free (%1$d/10 left) - + Generated • Cost: %1$s credits Generating… • Cost: Calculating AI Output @@ -1284,7 +1318,7 @@ Credits AI & Cloud Credits Credits Available - + %1$d Credits Estimated Cost Breakdown Cloud TTS @@ -1302,14 +1336,15 @@ Translate + Back to Pg %1$d - + Page %1$d - + Result %1$d / %2$d Failed to load PDF. - + Downloading Bubble Zoom model… %1$d%% Exit slider navigation Jump Back @@ -1320,7 +1355,7 @@ Toggle search highlights Drag to move text box No files icon - + Copy %1$s Tag List item marker @@ -1345,7 +1380,9 @@ Document Generated + %1$s (Text View) + %1$s (Reflow) @@ -1353,16 +1390,16 @@ No tags assigned. Apply Tags Search or create tag… - + Create \"%1$s\" Pull Distance to Change Chapter Short Long - + Speed: %1$sx - + Pitch: %1$sx Play/Pause Reset Speed @@ -1375,7 +1412,7 @@ Table of Contents Bookmark - + Jump Back to Page %1$d Return to previous page Exit Smart Zoom @@ -1389,7 +1426,7 @@ Settings Edit Restore - + %1$s selected Reader defaults @@ -1402,15 +1439,15 @@ Groq AI Definition - + Chapter %1$d Location Custom Font - + %1$d. %2$s - + An error occurred: %1$s - + Error loading document: %1$s OCR found no text on this page. @@ -1422,13 +1459,13 @@ AI features are unavailable in the offline OSS build. Blocked for safety reasons. - + Choose a model for %1$s in AI key and model settings. - + Add a %1$s API key in AI key and model settings. The AI provider returned an empty response. - + AI provider error: %1$d. %2$s This summary needs a Gemini model because the selected Groq models do not support PDF/image input. @@ -1438,18 +1475,18 @@ Max 5 images allowed per message. One or more images exceed the 5MB limit. - + Failed to create ticket: %1$s - + Failed to send: %1$s - + Failed to load feed: %1$s Empty body - + Download failed: %1$s - + Download error: %1$s - + Purchase failed: %1$s Could not connect to billing service. Products not found. @@ -1460,7 +1497,7 @@ No text to read. Error starting playback. Failed to load audio. - + Playback error: %1$s Cloud TTS is not configured. @@ -1487,16 +1524,16 @@ Used for EPUB summaries and PDF page summaries. PDF/image summaries need Gemini. Recaps Used for story recap generation. - + Uses the saved Gemini key. Only %1$s is supported for now. - + Save %1$s key? After saving, only the first 3 and last 3 characters will be visible. To change it later, replace or delete it. - + Delete %1$s key? Features using this provider will stop working until a new key is saved. No key saved - + Delete %1$s key Model No model selected @@ -1543,7 +1580,7 @@ Editable metadata Display name Name shown in Reader - + Original file: %1$s @@ -1604,6 +1641,14 @@ Plain text case-sensitive + Current book + Add rule + No replacement rules for this book yet. + New replacement + Edit replacement + With + empty text + Alice met the White Rabbit. Nederlands (Dutch) Українська (Ukrainian) Bahasa Indonesia (Indonesian) @@ -1612,10 +1657,13 @@ Desktop reader Desktop access Account + Account & credits + Account overview AI hub Used for EPUB summaries and PDF page summaries. + Episteme oss Author text - + Cache: %1$s Cached Cached summary @@ -1623,6 +1671,7 @@ Delete generated desktop book and EPUB pagination cache files? They will be recreated the next time books are opened. Clear voice cache Close tools + Cloud sync Cloud TTS needs Gemini Cloud TTS needs signed-in credits Cloud TTS ready @@ -1633,15 +1682,15 @@ Cost calculating Create a recap up to your current position. Create smart shelf - + %1$d credits available - + %1$s credits Imported fonts for the reader Delete font - + Delete %1$s? Books using it will fall back to the default font. - + Delete \"%1$s\"? Books stay in your library. Delete summary Disabled @@ -1655,7 +1704,7 @@ Folder path From here Full scan - + Free, %1$d left Generate recap Generate summary @@ -1664,6 +1713,7 @@ Support development through GitHub Sponsors. Google sign-in is not configured for this desktop build. Greater than + Help Bug reports, feature requests, and support Hide Import files @@ -1672,14 +1722,17 @@ Less than Library and reader Any + Library actions + More No cached summaries for this book yet. Import TTF, OTF, or WOFF2 files to use them in books. - + No fonts found matching \"%1$s\" No Google account is connected. No summary cached for this section. + Offline desktop reader Open readers - + Opening %1$s Opening your library Operator @@ -1688,15 +1741,17 @@ Patreon Support the project on Patreon. Paused - + %1$s requires a password before it can be opened. Password is required or incorrect. - + That password did not open %1$s. Enter the PDF password and try again. Percent + Plan Preparing audio - Pro - Pro and credits + Preferences + Account & credits + Account & credits Pro is not unlocked for this account. Pro and credits can only be purchased from the Android app. Desktop checks the same signed-in account and uses those credits for cloud TTS, summaries, recaps, and other paid AI features. Sign in to check your account status on desktop. @@ -1704,11 +1759,14 @@ Progress Project Reader + Reader tabs off + Reader tabs on Refresh Release to add to your library. Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted. Settings hub Matches the Android hide toggle for smart dictionary, summaries, and recaps. + Sync account, Pro, and credits Signed in Source code Browse the project source on GitHub. @@ -1728,10 +1786,10 @@ View Voice cache Preparing embedded webview… - + Preparing bundled embedded webview %1$d%% Embedded webview installed. Restart Episteme to finish setup. - + Embedded webview could not start: %1$s Working… Workspace @@ -1739,7 +1797,7 @@ Add to shelf Create a shelf first, then add selected books to it. Create theme - + Existing: %1$s You clicked an external link. Edit EPUB metadata @@ -1773,16 +1831,16 @@ Finished Fountain pen Hide search results - + Highlight color %1$d Highlighter palette Interaction - + Indexing %1$d/%2$d pages Markup - + %1$d matches - + %1$d matches so far Next page Next search result @@ -1805,21 +1863,21 @@ PDF action failed The PDF action could not be completed. PDF comment - + p. %1$d - + PDF page %1$d - + Page %1$d - %2$s - + Page %1$s of %2$d - + Pages %1$s of %2$d PDF saved PDF tools Pencil Preparing selection - + Preparing %1$s Previous page Previous search result @@ -1829,14 +1887,14 @@ Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app, then desktop will use the upgraded account after sign-in. Reader AI features are hidden. Desktop AI is not configured for this build. - Applies to vertical reading mode. + Applies to vertical reading and two-page spreads. Round highlighter - + Saved to %1$s Scroll Search in PDF Select text - + Selected %1$s Show search results Sign in with Google to use this feature on desktop. @@ -1847,12 +1905,12 @@ Text note text note Text style - + Thickness %1$s TOC Type to search this PDF Untitled - View Pro and credits + View account & credits Voice cache cleared Zoom Zoom in @@ -1871,7 +1929,7 @@ Folder Browse Categories - + Ch. %1$d Chapter Turns Choose font @@ -1886,7 +1944,7 @@ Covers Custom colors Custom theme preview - + Decrease %1$s Define page This removes the highlight and its note. @@ -1903,9 +1961,9 @@ Fixed-layout appearance Folder is empty No supported files or subfolders are available here. - + %1$s, %2$s - + %1$s - %2$s Hide filters Hide reader tools @@ -1914,21 +1972,23 @@ Import books Import folder Imported fonts - + %1$s %2$s - + Increase %1$s Jump history Layout and Spacing Import files into app storage or add a folder to read files in place. Browse your collection - + + AI keys + Smart %1$d - + Unread %1$d - + In progress %1$d - + Complete %1$d List Navigation @@ -1947,7 +2007,7 @@ No supported files were imported. Catalog - + Delete "%1$s"? Streamed books from this catalog may stop opening if credentials change later. No catalogs Add an OPDS catalog to browse remote books. @@ -1965,12 +2025,12 @@ Pin Reader-managed PDF tools Auto-scroll, OCR, annotation defaults, and PDF-only tool visibility are managed inside the active PDF reader. - + %1$s %2$s of %3$d (%4$d%%) Reader toolbar defaults are managed from the reader on this platform. Reader tools Save image - + Search: %1$s Search in reader Search settings @@ -2004,6 +2064,6 @@ Visible Replace only what is spoken Reader text, highlights, and locations stay unchanged. - + %1$s -> %2$s diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 2be1632..f07cfbb 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -19,4 +19,5 @@ + diff --git a/app/src/oss/java/com/aryan/reader/Auth.kt b/app/src/oss/java/com/dueattendant149/bookreader/reader/Auth.kt similarity index 92% rename from app/src/oss/java/com/aryan/reader/Auth.kt rename to app/src/oss/java/com/dueattendant149/bookreader/reader/Auth.kt index 1ede8d4..83792eb 100644 --- a/app/src/oss/java/com/aryan/reader/Auth.kt +++ b/app/src/oss/java/com/dueattendant149/bookreader/reader/Auth.kt @@ -1,5 +1,5 @@ // src\oss -package com.aryan.reader +package org.dueattendant149.bookreader import android.content.Context import kotlinx.coroutines.flow.Flow diff --git a/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt b/app/src/oss/java/com/dueattendant149/bookreader/reader/BillingClientWrapper.kt similarity index 92% rename from app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt rename to app/src/oss/java/com/dueattendant149/bookreader/reader/BillingClientWrapper.kt index 638d535..97ff7ce 100644 --- a/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt +++ b/app/src/oss/java/com/dueattendant149/bookreader/reader/BillingClientWrapper.kt @@ -1,10 +1,10 @@ // src\oss -package com.aryan.reader +package org.dueattendant149.bookreader import android.app.Activity import android.content.Context -import com.aryan.reader.data.ProductDetailsEntity -import com.aryan.reader.data.PurchaseEntity +import org.dueattendant149.bookreader.data.ProductDetailsEntity +import org.dueattendant149.bookreader.data.PurchaseEntity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/app/src/oss/java/com/aryan/reader/GoogleDriveAuthManager.kt b/app/src/oss/java/com/dueattendant149/bookreader/reader/GoogleDriveAuthManager.kt similarity index 90% rename from app/src/oss/java/com/aryan/reader/GoogleDriveAuthManager.kt rename to app/src/oss/java/com/dueattendant149/bookreader/reader/GoogleDriveAuthManager.kt index 3671d5e..ef0c09c 100644 --- a/app/src/oss/java/com/aryan/reader/GoogleDriveAuthManager.kt +++ b/app/src/oss/java/com/dueattendant149/bookreader/reader/GoogleDriveAuthManager.kt @@ -1,5 +1,5 @@ // src/oss -package com.aryan.reader +package org.dueattendant149.bookreader import android.content.Context import android.content.Intent diff --git a/app/src/oss/java/com/aryan/reader/OcrEngine.kt b/app/src/oss/java/com/dueattendant149/bookreader/reader/OcrEngine.kt similarity index 71% rename from app/src/oss/java/com/aryan/reader/OcrEngine.kt rename to app/src/oss/java/com/dueattendant149/bookreader/reader/OcrEngine.kt index 568a8db..46d1462 100644 --- a/app/src/oss/java/com/aryan/reader/OcrEngine.kt +++ b/app/src/oss/java/com/dueattendant149/bookreader/reader/OcrEngine.kt @@ -1,8 +1,8 @@ -package com.aryan.reader +package org.dueattendant149.bookreader import android.graphics.Bitmap -import com.aryan.reader.pdf.OcrLanguage -import com.aryan.reader.pdf.ocr.OcrResult +import org.dueattendant149.bookreader.pdf.OcrLanguage +import org.dueattendant149.bookreader.pdf.ocr.OcrResult import timber.log.Timber object OcrEngine { diff --git a/app/src/oss/java/com/aryan/reader/data/CloudflareRepository.kt b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/CloudflareRepository.kt similarity index 92% rename from app/src/oss/java/com/aryan/reader/data/CloudflareRepository.kt rename to app/src/oss/java/com/dueattendant149/bookreader/reader/data/CloudflareRepository.kt index 5e0b171..818491a 100644 --- a/app/src/oss/java/com/aryan/reader/data/CloudflareRepository.kt +++ b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/CloudflareRepository.kt @@ -1,5 +1,5 @@ // src\oss -package com.aryan.reader.data +package org.dueattendant149.bookreader.data import kotlinx.serialization.Serializable diff --git a/app/src/oss/java/com/aryan/reader/data/FeedbackRepository.kt b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/FeedbackRepository.kt similarity index 98% rename from app/src/oss/java/com/aryan/reader/data/FeedbackRepository.kt rename to app/src/oss/java/com/dueattendant149/bookreader/reader/data/FeedbackRepository.kt index 5f39841..656c9fc 100644 --- a/app/src/oss/java/com/aryan/reader/data/FeedbackRepository.kt +++ b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/FeedbackRepository.kt @@ -1,5 +1,5 @@ // src\oss -package com.aryan.reader.data +package org.dueattendant149.bookreader.data import android.content.Context import android.net.Uri diff --git a/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/FirestoreRepository.kt similarity index 97% rename from app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt rename to app/src/oss/java/com/dueattendant149/bookreader/reader/data/FirestoreRepository.kt index 1b82a8c..a46830f 100644 --- a/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt +++ b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/FirestoreRepository.kt @@ -1,7 +1,7 @@ // src\oss @file:Suppress("unused", "RedundantSuspendModifier") -package com.aryan.reader.data +package org.dueattendant149.bookreader.data import java.util.Date @@ -21,6 +21,8 @@ data class BookMetadata( var isRecent: Boolean = true, var isDeleted: Boolean = false, val lastModifiedTimestamp: Long = 0L, + val readingPositionModifiedTimestamp: Long = 0L, + val annotationModifiedTimestamp: Long = 0L, val bookmarksJson: String? = null, val hasAnnotations: Boolean = false, val fileContentModifiedTimestamp: Long = 0L, diff --git a/app/src/oss/java/com/aryan/reader/data/GoogleDriveRepository.kt b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/GoogleDriveRepository.kt similarity index 92% rename from app/src/oss/java/com/aryan/reader/data/GoogleDriveRepository.kt rename to app/src/oss/java/com/dueattendant149/bookreader/reader/data/GoogleDriveRepository.kt index d4e3576..ee98848 100644 --- a/app/src/oss/java/com/aryan/reader/data/GoogleDriveRepository.kt +++ b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/GoogleDriveRepository.kt @@ -1,9 +1,9 @@ // src\oss -package com.aryan.reader.data +package org.dueattendant149.bookreader.data import android.content.Context import android.content.Intent -import com.aryan.reader.FileType +import org.dueattendant149.bookreader.FileType import java.io.File data class DriveFileList( @@ -12,7 +12,8 @@ data class DriveFileList( data class DriveFile( val id: String, - val name: String + val name: String, + val modifiedTimeMillis: Long = 0L ) data class ShelfMetadata( @@ -75,4 +76,4 @@ class GoogleDriveRepository { fun handleSignInResult(data: Intent?): Boolean { return false } -} \ No newline at end of file +} diff --git a/app/src/oss/java/com/aryan/reader/data/PlatformFeaturesRepository.kt b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/PlatformFeaturesRepository.kt similarity index 89% rename from app/src/oss/java/com/aryan/reader/data/PlatformFeaturesRepository.kt rename to app/src/oss/java/com/dueattendant149/bookreader/reader/data/PlatformFeaturesRepository.kt index 6ae27e7..3d420d8 100644 --- a/app/src/oss/java/com/aryan/reader/data/PlatformFeaturesRepository.kt +++ b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/PlatformFeaturesRepository.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.data +package org.dueattendant149.bookreader.data import android.app.Activity import android.content.Context diff --git a/app/src/oss/java/com/aryan/reader/data/RemoteConfigRepository.kt b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/RemoteConfigRepository.kt similarity index 67% rename from app/src/oss/java/com/aryan/reader/data/RemoteConfigRepository.kt rename to app/src/oss/java/com/dueattendant149/bookreader/reader/data/RemoteConfigRepository.kt index f9b60bd..9c45103 100644 --- a/app/src/oss/java/com/aryan/reader/data/RemoteConfigRepository.kt +++ b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/RemoteConfigRepository.kt @@ -1,5 +1,5 @@ // src\oss -package com.aryan.reader.data +package org.dueattendant149.bookreader.data class RemoteConfigRepository { fun init() { diff --git a/app/src/oss/java/com/aryan/reader/data/SyncWorker.kt b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/SyncWorker.kt similarity index 89% rename from app/src/oss/java/com/aryan/reader/data/SyncWorker.kt rename to app/src/oss/java/com/dueattendant149/bookreader/reader/data/SyncWorker.kt index e788903..bc1cf6d 100644 --- a/app/src/oss/java/com/aryan/reader/data/SyncWorker.kt +++ b/app/src/oss/java/com/dueattendant149/bookreader/reader/data/SyncWorker.kt @@ -1,5 +1,5 @@ // src\oss -package com.aryan.reader.data +package org.dueattendant149.bookreader.data import android.content.Context import androidx.work.CoroutineWorker diff --git a/app/src/oss/java/com/aryan/reader/ml/SpeechBubbleDetector.kt b/app/src/oss/java/com/dueattendant149/bookreader/reader/ml/SpeechBubbleDetector.kt similarity index 91% rename from app/src/oss/java/com/aryan/reader/ml/SpeechBubbleDetector.kt rename to app/src/oss/java/com/dueattendant149/bookreader/reader/ml/SpeechBubbleDetector.kt index c920bb3..ed74b9a 100644 --- a/app/src/oss/java/com/aryan/reader/ml/SpeechBubbleDetector.kt +++ b/app/src/oss/java/com/dueattendant149/bookreader/reader/ml/SpeechBubbleDetector.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.ml +package org.dueattendant149.bookreader.ml import android.graphics.Bitmap import timber.log.Timber diff --git a/app/src/pro/java/com/aryan/reader/ml/SpeechBubbleDetector.kt b/app/src/pro/java/com/dueattendant149/bookreader/reader/ml/SpeechBubbleDetector.kt similarity index 99% rename from app/src/pro/java/com/aryan/reader/ml/SpeechBubbleDetector.kt rename to app/src/pro/java/com/dueattendant149/bookreader/reader/ml/SpeechBubbleDetector.kt index de2156a..eb23acc 100644 --- a/app/src/pro/java/com/aryan/reader/ml/SpeechBubbleDetector.kt +++ b/app/src/pro/java/com/dueattendant149/bookreader/reader/ml/SpeechBubbleDetector.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.ml +package org.dueattendant149.bookreader.ml import ai.onnxruntime.OnnxTensor import ai.onnxruntime.OrtEnvironment diff --git a/app/src/proTest/java/com/dueattendant149/bookreader/reader/data/FirestoreRepositoryMappingTest.kt b/app/src/proTest/java/com/dueattendant149/bookreader/reader/data/FirestoreRepositoryMappingTest.kt new file mode 100644 index 0000000..7d1850f --- /dev/null +++ b/app/src/proTest/java/com/dueattendant149/bookreader/reader/data/FirestoreRepositoryMappingTest.kt @@ -0,0 +1,30 @@ +package org.dueattendant149.bookreader.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FirestoreRepositoryMappingTest { + @Test + fun `book metadata map includes content reading and annotation timestamps`() { + val metadata = BookMetadata( + bookId = "book-1", + displayName = "Book.epub", + type = "EPUB", + lastModifiedTimestamp = 2_000L, + readingPositionModifiedTimestamp = 1_750L, + annotationModifiedTimestamp = 1_650L, + fileContentModifiedTimestamp = 1_500L + ) + + val fields = metadata.toFirestoreMap(originDeviceId = "device-1") + + assertTrue(fields.containsKey("fileContentModifiedTimestamp")) + assertTrue(fields.containsKey("readingPositionModifiedTimestamp")) + assertTrue(fields.containsKey("annotationModifiedTimestamp")) + assertEquals(1_500L, fields["fileContentModifiedTimestamp"]) + assertEquals(1_750L, fields["readingPositionModifiedTimestamp"]) + assertEquals(1_650L, fields["annotationModifiedTimestamp"]) + assertEquals("device-1", fields["originDeviceId"]) + } +} diff --git a/app/src/proTest/java/com/dueattendant149/bookreader/reader/data/GoogleDriveRepositoryUploadMetadataTest.kt b/app/src/proTest/java/com/dueattendant149/bookreader/reader/data/GoogleDriveRepositoryUploadMetadataTest.kt new file mode 100644 index 0000000..5d4e089 --- /dev/null +++ b/app/src/proTest/java/com/dueattendant149/bookreader/reader/data/GoogleDriveRepositoryUploadMetadataTest.kt @@ -0,0 +1,18 @@ +package org.dueattendant149.bookreader.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GoogleDriveRepositoryUploadMetadataTest { + @Test + fun `drive upload metadata writes app data parent only on create`() { + val createMetadata = googleDriveUploadMetadata("book.epub", isCreate = true) + val updateMetadata = googleDriveUploadMetadata("book.epub", isCreate = false) + + assertEquals("book.epub", createMetadata.name) + assertEquals(listOf("appDataFolder"), createMetadata.parents) + assertEquals("book.epub", updateMetadata.name) + assertNull(updateMetadata.parents) + } +} diff --git a/app/src/test/java/com/aryan/reader/ReaderBrightnessSettingsTest.kt b/app/src/test/java/com/aryan/reader/ReaderBrightnessSettingsTest.kt deleted file mode 100644 index e6339e5..0000000 --- a/app/src/test/java/com/aryan/reader/ReaderBrightnessSettingsTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.aryan.reader - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class ReaderBrightnessSettingsTest { - - @Test - fun `brightness settings default to system and clamp custom values`() { - val defaults = ReaderBrightnessSettings() - - assertTrue(defaults.useSystemBrightness) - assertEquals(0.75f, defaults.safeCustomBrightness, 0.0001f) - assertEquals(0.05f, defaults.copy(customBrightness = 0f).safeCustomBrightness, 0.0001f) - assertEquals(1f, defaults.copy(customBrightness = 2f).safeCustomBrightness, 0.0001f) - } -} diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubTtsChunkMatchingTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubTtsChunkMatchingTest.kt deleted file mode 100644 index 58dd627..0000000 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubTtsChunkMatchingTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.aryan.reader.epubreader - -import com.aryan.reader.paginatedreader.TtsChunk -import org.junit.Assert.assertEquals -import org.junit.Test - -class EpubTtsChunkMatchingTest { - @Test - fun `chunk start matching tolerates child cfi path and whitespace text differences`() { - val chunks = listOf( - TtsChunk( - text = "The first paragraph begins here.", - sourceCfi = "/4/22/2", - startOffsetInSource = 0 - ), - TtsChunk( - text = "The second paragraph begins here.", - sourceCfi = "/4/24/2", - startOffsetInSource = 0 - ) - ) - val extracted = TtsChunk( - text = "The second paragraph begins here.", - sourceCfi = "/4/24", - startOffsetInSource = 0 - ) - - assertEquals(1, findTtsChunkStartIndex(chunks, extracted)) - } - - @Test - fun `resume matching falls back to current chunk index before leaving chapter`() { - val chunks = listOf( - TtsChunk("One", "/4/2", 0), - TtsChunk("Two", "/4/4", 0), - TtsChunk("Three", "/4/6", 0) - ) - - assertEquals( - 1, - findTtsChunkResumeIndex( - chunks = chunks, - sourceCfi = "/mismatched", - startOffsetInSource = 0, - currentText = "unknown", - currentChunkIndexFallback = 1 - ) - ) - } -} diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt deleted file mode 100644 index 89559da..0000000 --- a/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.aryan.reader.paginatedreader - -import androidx.compose.ui.text.AnnotatedString -import com.aryan.reader.epubreader.HighlightColor -import com.aryan.reader.epubreader.UserHighlight -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class PaginatedHighlightMappingTest { - - @Test - fun `single cfi highlight does not leak onto later matching block`() { - val block = paragraph( - text = "repeat", - cfi = "/4/4", - startOffset = 20 - ) - val highlight = highlight( - cfi = "/4/2:0", - text = "repeat" - ) - - assertNull(getHighlightOffsetsInBlock(block, highlight)) - } - - @Test - fun `multipart highlight can fill strict intermediate block`() { - val block = paragraph( - text = "middle", - cfi = "/4/4", - startOffset = 20 - ) - val highlight = highlight( - cfi = "/4/2:0|/4/6:10", - text = "start middle end" - ) - - assertEquals(0 until 6, getHighlightOffsetsInBlock(block, highlight)) - } - - @Test - fun `same path split block outside stored offsets is ignored`() { - val block = paragraph( - text = "repeat", - cfi = "/4/2", - startOffset = 20 - ) - val highlight = highlight( - cfi = "/4/2:0|/4/2:6", - text = "repeat" - ) - - assertNull(getHighlightOffsetsInBlock(block, highlight)) - } - - @Test - fun `paginated page highlights are scoped to page chapter`() { - val chapterFourHighlight = highlight( - cfi = "/4/10:11|/4/12:79", - text = "Original chapter text", - chapterIndex = 4 - ) - val chapterFiveHighlight = highlight( - cfi = "/4/10:11|/4/12:79", - text = "Different chapter text", - chapterIndex = 5 - ) - - assertEquals( - listOf(chapterFiveHighlight), - highlightsForPaginatedPage( - pageChapterIndex = 5, - userHighlights = listOf(chapterFourHighlight, chapterFiveHighlight) - ) - ) - assertEquals( - emptyList(), - highlightsForPaginatedPage( - pageChapterIndex = null, - userHighlights = listOf(chapterFourHighlight) - ) - ) - } - - private fun paragraph( - text: String, - cfi: String, - startOffset: Int - ): ParagraphBlock { - return ParagraphBlock( - content = AnnotatedString(text), - cfi = cfi, - startCharOffsetInSource = startOffset, - endCharOffsetInSource = startOffset + text.length, - blockIndex = startOffset - ) - } - - private fun highlight( - cfi: String, - text: String, - chapterIndex: Int = 0 - ): UserHighlight { - return UserHighlight( - id = "highlight", - cfi = cfi, - text = text, - color = HighlightColor.YELLOW, - chapterIndex = chapterIndex - ) - } -} diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/AndroidLegalLinksTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/AndroidLegalLinksTest.kt new file mode 100644 index 0000000..a5a8e8c --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/AndroidLegalLinksTest.kt @@ -0,0 +1,22 @@ +package org.dueattendant149.bookreader + +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidLegalLinksTest { + @Test + fun `android oss flavor maps to oss legal pages`() { + val links = legalLinksForAndroidFlavor("oss") + + assertTrue(links.privacyPolicyUrl.endsWith("/oss-privacy-policy.html")) + assertTrue(links.termsUrl.endsWith("/oss-terms-of-service.html")) + } + + @Test + fun `android pro flavor maps to standard legal pages`() { + val links = legalLinksForAndroidFlavor("pro") + + assertTrue(links.privacyPolicyUrl.endsWith("/privacy-policy.html")) + assertTrue(links.termsUrl.endsWith("/terms-and-conditions.html")) + } +} diff --git a/app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/AndroidSettingsHubModelsTest.kt similarity index 86% rename from app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/AndroidSettingsHubModelsTest.kt index d50c511..405b13f 100644 --- a/app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/AndroidSettingsHubModelsTest.kt @@ -1,10 +1,11 @@ -package com.aryan.reader +package org.dueattendant149.bookreader -import com.aryan.reader.shared.SharedSettingsAction -import com.aryan.reader.shared.SharedSettingsDestination -import com.aryan.reader.shared.SharedSettingsHubModel -import com.aryan.reader.shared.SharedSettingsItemModel -import com.aryan.reader.shared.sharedSettingsHubModel +import org.dueattendant149.bookreader.shared.SharedSettingsAction +import org.dueattendant149.bookreader.shared.SharedSettingsDestination +import org.dueattendant149.bookreader.shared.SharedFeaturePolicy +import org.dueattendant149.bookreader.shared.SharedSettingsHubModel +import org.dueattendant149.bookreader.shared.SharedSettingsItemModel +import org.dueattendant149.bookreader.shared.sharedSettingsHubModel import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -37,24 +38,23 @@ class AndroidSettingsHubModelsTest { @Test fun `oss online settings hide sync rows but keep oss ai key settings`() { - val model = sharedSettingsHubModel( - androidSettingsHubInput( - uiState = ReaderScreenState( - currentUser = UserData( - uid = "user-id", - displayName = "Reader", - photoUrl = null, - email = "reader@example.com" - ), - isProUser = true, - isSyncEnabled = true, - isFolderSyncEnabled = true + val input = androidSettingsHubInput( + uiState = ReaderScreenState( + currentUser = UserData( + uid = "user-id", + displayName = "Reader", + photoUrl = null, + email = "reader@example.com" ), - isOssBuild = true, - isOfflineBuild = false, - isDebugBuild = true - ) + isProUser = true, + isSyncEnabled = true, + isFolderSyncEnabled = true + ), + isOssBuild = true, + isOfflineBuild = false, + isDebugBuild = true ) + val model = sharedSettingsHubModel(input) val actions = model.visibleNestedActions() assertTrue(SharedSettingsAction.AI_SETTINGS in actions) @@ -65,6 +65,7 @@ class AndroidSettingsHubModelsTest { assertFalse(SharedSettingsAction.DEVICE_MANAGEMENT in actions) assertFalse(SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA in actions) assertTrue(SharedSettingsAction.SUPPORT in actions) + assertEquals(SharedFeaturePolicy.OssOnline, input.featurePolicy) assertEquals( "TTS & AI", model.rootCategories.single { it.destination == SharedSettingsDestination.TTS_AI }.title diff --git a/app/src/test/java/com/aryan/reader/AndroidSharedStateBridgeTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/AndroidSharedStateBridgeTest.kt similarity index 94% rename from app/src/test/java/com/aryan/reader/AndroidSharedStateBridgeTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/AndroidSharedStateBridgeTest.kt index 67b77ac..584568b 100644 --- a/app/src/test/java/com/aryan/reader/AndroidSharedStateBridgeTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/AndroidSharedStateBridgeTest.kt @@ -1,12 +1,12 @@ -package com.aryan.reader +package org.dueattendant149.bookreader -import com.aryan.reader.data.BookTagCrossRef -import com.aryan.reader.data.RecentFileItem -import com.aryan.reader.data.TagEntity -import com.aryan.reader.shared.AppAction as SharedAppAction -import com.aryan.reader.shared.AppFontPreference as SharedAppFontPreference -import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode -import com.aryan.reader.shared.LibraryAction as SharedLibraryAction +import org.dueattendant149.bookreader.data.BookTagCrossRef +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.AppFontPreference as SharedAppFontPreference +import org.dueattendant149.bookreader.shared.AppThemeMode as SharedAppThemeMode +import org.dueattendant149.bookreader.shared.LibraryAction as SharedLibraryAction import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/AndroidStringFormatResourcesTest.kt similarity index 72% rename from app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/AndroidStringFormatResourcesTest.kt index 2375e83..bed450c 100644 --- a/app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/AndroidStringFormatResourcesTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader +package org.dueattendant149.bookreader import java.io.File import java.util.Date @@ -10,6 +10,34 @@ import org.junit.Test class AndroidStringFormatResourcesTest { + @Test + fun `vietnamese strings cover translatable base resources`() { + assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-vi", localeName = "Vietnamese") + } + + @Test + fun `estonian strings cover translatable base resources`() { + assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-et", localeName = "Estonian") + } + + private fun assertLocaleCoversTranslatableBaseResources( + localeDirectory: String, + localeName: String + ) { + val resDirectory = findResDirectory() + val baseNames = readResourceNames( + stringsFile = File(resDirectory, "values/strings.xml"), + includeNonTranslatable = false + ) + val localizedNames = readResourceNames(File(resDirectory, "$localeDirectory/strings.xml")) + val missingNames = baseNames.filterNot { it in localizedNames } + + assertTrue( + "Missing $localeName strings:\n${missingNames.joinToString(separator = "\n")}", + missingNames.isEmpty() + ) + } + @Test fun `localized formatted strings use valid formatter syntax`() { val resDirectory = findResDirectory() @@ -48,6 +76,28 @@ class AndroidStringFormatResourcesTest { ).first { it.isDirectory } } + private fun readResourceNames( + stringsFile: File, + includeNonTranslatable: Boolean = true + ): List { + val document = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(stringsFile) + val nodes = document.documentElement.childNodes + + return buildList { + for (index in 0 until nodes.length) { + val node = nodes.item(index) + val attributes = node.attributes ?: continue + val name = attributes.getNamedItem("name")?.nodeValue ?: continue + val translatable = attributes.getNamedItem("translatable")?.nodeValue + if (includeNonTranslatable || translatable != "false") { + add(name) + } + } + } + } + private fun readStringResources(stringsFile: File): Map { val document = DocumentBuilderFactory.newInstance() .newDocumentBuilder() diff --git a/app/src/test/java/com/aryan/reader/AppFontResolverTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/AppFontResolverTest.kt similarity index 91% rename from app/src/test/java/com/aryan/reader/AppFontResolverTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/AppFontResolverTest.kt index 906df99..98315d9 100644 --- a/app/src/test/java/com/aryan/reader/AppFontResolverTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/AppFontResolverTest.kt @@ -1,6 +1,6 @@ -package com.aryan.reader +package org.dueattendant149.bookreader -import com.aryan.reader.data.CustomFontEntity +import org.dueattendant149.bookreader.data.CustomFontEntity import org.junit.Assert.assertNull import org.junit.Test diff --git a/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/AppLanguageOptionsTest.kt similarity index 88% rename from app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/AppLanguageOptionsTest.kt index a69cce7..29eff97 100644 --- a/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/AppLanguageOptionsTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader +package org.dueattendant149.bookreader import java.io.File import javax.xml.parsers.DocumentBuilderFactory @@ -15,11 +15,11 @@ class AppLanguageOptionsTest { assertEquals( listOf( "en", "ar", "de", "nl", "tr", "fr", "ru", "uk", "be", "es", "pt-BR", "it", "pl", - "id", "vi", "ja", "ko", "hi", "zh-CN" + "id", "vi", "ja", "ko", "hi", "zh-CN", "et" ), supportedAppLanguageOptions.mapNotNull { it.tag } ) - assertEquals(R.string.language_chinese_simplified, supportedAppLanguageOptions.last().labelRes) + assertEquals(R.string.language_estonian, supportedAppLanguageOptions.last().labelRes) } @Test @@ -51,6 +51,7 @@ class AppLanguageOptionsTest { val vietnamese = supportedAppLanguageOptions.first { it.tag == "vi" } val japanese = supportedAppLanguageOptions.first { it.tag == "ja" } val korean = supportedAppLanguageOptions.first { it.tag == "ko" } + val estonian = supportedAppLanguageOptions.first { it.tag == "et" } assertTrue(turkish.matchesLanguageSearch(label = "Türkçe (Turkish)", query = "turkce")) assertTrue(dutch.matchesLanguageSearch(label = "Nederlands", query = "dutch")) @@ -66,6 +67,7 @@ class AppLanguageOptionsTest { assertTrue(vietnamese.matchesLanguageSearch(label = "Tiếng Việt", query = "tieng viet")) assertTrue(japanese.matchesLanguageSearch(label = "日本語", query = "nihongo")) assertTrue(korean.matchesLanguageSearch(label = "한국어", query = "hangul")) + assertTrue(estonian.matchesLanguageSearch(label = "Eesti", query = "eesti")) } @Test @@ -99,6 +101,19 @@ class AppLanguageOptionsTest { assertEquals("true", autoStoreLocales!!.androidAttribute("value")) } + @Test + fun `android manifest exposes cbt comic archive mime types`() { + val mimeTypes = readAndroidManifest() + .getElementsByTagName("data") + .asElements() + .mapNotNull { it.androidAttribute("mimeType") } + + assertTrue("application/x-cbt" in mimeTypes) + assertTrue("application/vnd.comicbook+tar" in mimeTypes) + assertTrue("application/x-tar" in mimeTypes) + assertTrue("application/tar" in mimeTypes) + } + private fun readLocaleConfigTags(): List { val localeConfig = listOf( File("src/main/res/xml/locales_config.xml"), diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/BookReplacementHtmlTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/BookReplacementHtmlTest.kt new file mode 100644 index 0000000..8733dd8 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/BookReplacementHtmlTest.kt @@ -0,0 +1,73 @@ +package org.dueattendant149.bookreader + +import org.dueattendant149.bookreader.shared.ReaderBookReplacementPreferences +import org.dueattendant149.bookreader.shared.ReaderWordReplacementRule +import org.jsoup.Jsoup +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BookReplacementHtmlTest { + @Test + fun `html replacement rewrites visible text for matching book`() { + val document = Jsoup.parse( + """ + + +

Alice & Alice

+ Alice link + + + """.trimIndent(), + ) + val preferences = ReaderBookReplacementPreferences( + fileRules = mapOf( + "book" to listOf(rule(from = "Alice", to = "Alicia")), + ), + ) + + val changed = applyBookReplacementsToHtmlDocument(document, preferences, "book") + + assertTrue(changed) + assertEquals("Alicia & Alicia", document.selectFirst("p")?.text()) + assertEquals("Alicia link", document.selectFirst("a")?.text()) + assertEquals("chapter.xhtml", document.selectFirst("a")?.attr("href")) + } + + @Test + fun `html replacement skips blocked script text`() { + val document = Jsoup.parse( + """ + + +

Alice

+ + + + """.trimIndent(), + ) + val preferences = ReaderBookReplacementPreferences( + fileRules = mapOf( + "book" to listOf(rule(from = "Alice", to = "Alicia")), + ), + ) + + val changed = applyBookReplacementsToHtmlDocument(document, preferences, "book") + + assertTrue(changed) + assertEquals("Alicia", document.selectFirst("p")?.text()) + assertTrue(document.selectFirst("script")?.html()?.contains("Alice") == true) + } + + private fun rule( + id: String = "rule", + from: String, + to: String, + ): ReaderWordReplacementRule { + return ReaderWordReplacementRule( + id = id, + from = from, + to = to, + ) + } +} diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/ClipboardUtilsTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/ClipboardUtilsTest.kt new file mode 100644 index 0000000..6bed5ec --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/ClipboardUtilsTest.kt @@ -0,0 +1,17 @@ +package org.dueattendant149.bookreader + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ClipboardUtilsTest { + @Test + fun `set primary clip reports success`() { + assertTrue(setPrimaryClipSafely {}) + } + + @Test + fun `set primary clip handles security rejection`() { + assertFalse(setPrimaryClipSafely { throw SecurityException("denied") }) + } +} diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/CloudEpubAnnotationMetadataTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/CloudEpubAnnotationMetadataTest.kt new file mode 100644 index 0000000..98b8241 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/CloudEpubAnnotationMetadataTest.kt @@ -0,0 +1,97 @@ +package org.dueattendant149.bookreader + +import org.dueattendant149.bookreader.data.BookMetadata +import org.dueattendant149.bookreader.data.RecentFileItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CloudEpubAnnotationMetadataTest { + @Test + fun `remote epub annotations fill local null annotation fields without changing timestamp`() { + val local = localBook( + highlightsJson = null, + bookmarksJson = null, + lastModifiedTimestamp = 2_000L + ) + val remote = remoteBook( + highlightsJson = """[{"cfi":"/4/2:1"}]""", + bookmarksJson = """["{\"cfi\":\"/4/2\",\"chapterTitle\":\"One\",\"snippet\":\"A\",\"chapterIndex\":0}"]""", + lastModifiedTimestamp = 3_000L + ) + + val merged = local.mergeRemoteEpubAnnotationMetadata(remote) + + assertEquals(remote.highlightsJson, merged.highlightsJson) + assertEquals(remote.bookmarksJson, merged.bookmarksJson) + assertEquals(2_000L, merged.lastModifiedTimestamp) + } + + @Test + fun `explicit local empty epub annotations are not replaced by remote annotations`() { + val local = localBook( + highlightsJson = "[]", + bookmarksJson = "[]" + ) + val remote = remoteBook( + highlightsJson = """[{"cfi":"/4/2:1"}]""", + bookmarksJson = """["{\"cfi\":\"/4/2\",\"chapterTitle\":\"One\",\"snippet\":\"A\",\"chapterIndex\":0}"]""" + ) + + val merged = local.mergeRemoteEpubAnnotationMetadata(remote) + + assertEquals("[]", merged.highlightsJson) + assertEquals("[]", merged.bookmarksJson) + } + + @Test + fun `non epub books do not use epub annotation preservation guard`() { + val local = localBook(type = FileType.PDF, highlightsJson = null) + val remote = remoteBook(type = FileType.PDF.name, highlightsJson = """[{"cfi":"/4/2:1"}]""") + + assertFalse(local.needsRemoteEpubAnnotationMetadataGuard()) + assertEquals(local, local.mergeRemoteEpubAnnotationMetadata(remote)) + } + + @Test + fun `blank and empty annotation json are equivalent noops`() { + assertTrue(annotationJsonEquivalentForNoop(null, "[]")) + assertTrue(annotationJsonEquivalentForNoop("", "[]")) + assertFalse(annotationJsonEquivalentForNoop("""[{"id":"h1"}]""", "[]")) + } + + private fun localBook( + type: FileType = FileType.EPUB, + highlightsJson: String? = null, + bookmarksJson: String? = null, + lastModifiedTimestamp: Long = 1_000L + ): RecentFileItem { + return RecentFileItem( + bookId = "book-1", + uriString = "content://book", + type = type, + displayName = "Book.epub", + timestamp = 1_000L, + lastModifiedTimestamp = lastModifiedTimestamp, + bookmarksJson = bookmarksJson, + highlightsJson = highlightsJson + ) + } + + private fun remoteBook( + type: String = FileType.EPUB.name, + highlightsJson: String? = null, + bookmarksJson: String? = null, + lastModifiedTimestamp: Long = 2_000L + ): BookMetadata { + return BookMetadata( + bookId = "book-1", + displayName = "Book.epub", + type = type, + lastModifiedTimestamp = lastModifiedTimestamp, + bookmarksJson = bookmarksJson, + highlightsJson = highlightsJson + ) + } +} diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/CloudPdfAnnotationSidecarDecisionsTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/CloudPdfAnnotationSidecarDecisionsTest.kt new file mode 100644 index 0000000..e985828 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/CloudPdfAnnotationSidecarDecisionsTest.kt @@ -0,0 +1,167 @@ +package org.dueattendant149.bookreader + +import org.dueattendant149.bookreader.data.BookMetadata +import org.dueattendant149.bookreader.data.effectiveAnnotationModifiedTimestamp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class CloudPdfAnnotationSidecarDecisionsTest { + @Test + fun `layout-only sidecar does not block newer remote annotations`() { + val local = AndroidPdfCloudSidecarState( + hasInk = false, + inkTimestamp = 0L, + hasRichText = false, + richTextTimestamp = 0L, + hasLayout = true, + layoutTimestamp = 2_000L, + hasTextBoxes = false, + textBoxesTimestamp = 0L, + hasHighlights = false, + highlightsTimestamp = 0L + ) + + val localShouldUpload = shouldUploadLocalPdfCloudAnnotations( + localSidecars = local, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 1_500L + ) + + assertFalse(localShouldUpload) + assertTrue( + shouldDownloadRemotePdfCloudAnnotations( + localSidecars = local, + localAnnotationsShouldUpload = localShouldUpload, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 1_500L + ) + ) + } + + @Test + fun `newer local ink payload uploads instead of downloading remote annotations`() { + val local = AndroidPdfCloudSidecarState( + hasInk = true, + inkTimestamp = 2_000L, + hasRichText = false, + richTextTimestamp = 0L, + hasLayout = true, + layoutTimestamp = 2_500L, + hasTextBoxes = false, + textBoxesTimestamp = 0L, + hasHighlights = false, + highlightsTimestamp = 0L + ) + + val localShouldUpload = shouldUploadLocalPdfCloudAnnotations( + localSidecars = local, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 1_500L + ) + + assertTrue(localShouldUpload) + assertFalse( + shouldDownloadRemotePdfCloudAnnotations( + localSidecars = local, + localAnnotationsShouldUpload = localShouldUpload, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 1_500L + ) + ) + } + + @Test + fun `newer local deletion tombstone uploads instead of downloading remote annotations`() { + val local = AndroidPdfCloudSidecarState( + hasInk = false, + inkTimestamp = 0L, + hasDeletedInk = true, + deletedInkTimestamp = 2_000L, + hasRichText = false, + richTextTimestamp = 0L, + hasLayout = false, + layoutTimestamp = 0L, + hasTextBoxes = false, + textBoxesTimestamp = 0L, + hasHighlights = false, + highlightsTimestamp = 0L + ) + + val localShouldUpload = shouldUploadLocalPdfCloudAnnotations( + localSidecars = local, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 1_500L + ) + + assertTrue(localShouldUpload) + assertFalse( + shouldDownloadRemotePdfCloudAnnotations( + localSidecars = local, + localAnnotationsShouldUpload = localShouldUpload, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 1_500L + ) + ) + } + + @Test + fun `newer remote metadata alone does not make equal annotation payload download`() { + val local = AndroidPdfCloudSidecarState( + hasInk = true, + inkTimestamp = 2_000L, + hasRichText = false, + richTextTimestamp = 0L, + hasLayout = false, + layoutTimestamp = 0L, + hasTextBoxes = false, + textBoxesTimestamp = 0L, + hasHighlights = false, + highlightsTimestamp = 0L + ) + + val localShouldUpload = shouldUploadLocalPdfCloudAnnotations( + localSidecars = local, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 2_000L + ) + + assertFalse(localShouldUpload) + assertFalse( + shouldDownloadRemotePdfCloudAnnotations( + localSidecars = local, + localAnnotationsShouldUpload = localShouldUpload, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 2_000L + ) + ) + } + + @Test + fun `annotation freshness does not fall back to book metadata timestamp`() { + val remote = BookMetadata( + bookId = "book-1", + lastModifiedTimestamp = 5_000L, + hasAnnotations = true + ) + + assertEquals(0L, remote.effectiveAnnotationModifiedTimestamp()) + assertEquals(3_000L, remote.effectiveAnnotationModifiedTimestamp(sidecarModifiedTimestamp = 3_000L)) + } + + @Test + fun `empty sidecar placeholder is not syncable annotation payload`() { + assertFalse(tempSidecar("[]").hasSyncableCloudAnnotationPayload()) + assertFalse(tempSidecar("{}").hasSyncableCloudAnnotationPayload()) + assertTrue(tempSidecar("[{\"pageIndex\":0}]").hasSyncableCloudAnnotationPayload()) + } + + private fun tempSidecar(content: String): File { + return File.createTempFile("cloud-sidecar", ".json").apply { + writeText(content) + deleteOnExit() + } + } +} diff --git a/app/src/test/java/com/aryan/reader/EmbeddedEbookMetadataExtractorTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/EmbeddedEbookMetadataExtractorTest.kt similarity index 99% rename from app/src/test/java/com/aryan/reader/EmbeddedEbookMetadataExtractorTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/EmbeddedEbookMetadataExtractorTest.kt index 09c23d9..b716382 100644 --- a/app/src/test/java/com/aryan/reader/EmbeddedEbookMetadataExtractorTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/EmbeddedEbookMetadataExtractorTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader +package org.dueattendant149.bookreader import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals diff --git a/app/src/test/java/com/aryan/reader/ExampleUnitTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/ExampleUnitTest.kt similarity index 88% rename from app/src/test/java/com/aryan/reader/ExampleUnitTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/ExampleUnitTest.kt index a978d73..a4d44bc 100644 --- a/app/src/test/java/com/aryan/reader/ExampleUnitTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/ExampleUnitTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader +package org.dueattendant149.bookreader import org.junit.Test diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/ExternalFileOpenRouteDeciderTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/ExternalFileOpenRouteDeciderTest.kt new file mode 100644 index 0000000..fa144b0 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/ExternalFileOpenRouteDeciderTest.kt @@ -0,0 +1,28 @@ +package org.dueattendant149.bookreader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ExternalFileOpenRouteDeciderTest { + @Test + fun `temporary behavior routes to temporary activity`() { + assertTrue(ExternalFileOpenRouteDecider.shouldOpenTemporary("TEMPORARY")) + assertEquals( + TemporaryExternalFileActivity::class.java, + ExternalFileOpenRouteDecider.targetActivityClass("TEMPORARY") + ) + } + + @Test + fun `existing behaviors route to main activity`() { + listOf(null, "ASK", "KEEP", "DELETE").forEach { behavior -> + assertFalse(ExternalFileOpenRouteDecider.shouldOpenTemporary(behavior)) + assertEquals( + MainActivity::class.java, + ExternalFileOpenRouteDecider.targetActivityClass(behavior) + ) + } + } +} diff --git a/app/src/test/java/com/aryan/reader/FileHasherTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/FileHasherTest.kt similarity index 97% rename from app/src/test/java/com/aryan/reader/FileHasherTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/FileHasherTest.kt index 279948f..c6f2aed 100644 --- a/app/src/test/java/com/aryan/reader/FileHasherTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/FileHasherTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader +package org.dueattendant149.bookreader import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals diff --git a/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/FileTypeResolverTest.kt similarity index 88% rename from app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/FileTypeResolverTest.kt index 5464efd..c7f9db0 100644 --- a/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/FileTypeResolverTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader +package org.dueattendant149.bookreader import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -21,7 +21,7 @@ class FileTypeResolverTest { assertEquals(FileType.HTML, resolveFileTypeFromName("table.csv")) assertEquals(FileType.HTML, resolveFileTypeFromName("script.kt")) assertEquals(FileType.HTML, resolveFileTypeFromName("payload.json.txt")) - assertEquals(com.aryan.reader.shared.SharedFileCapabilities.resolveFileTypeForName("payload.json.txt"), resolveFileTypeFromName("payload.json.txt")) + assertEquals(org.dueattendant149.bookreader.shared.SharedFileCapabilities.resolveFileTypeForName("payload.json.txt"), resolveFileTypeFromName("payload.json.txt")) } @Test @@ -34,8 +34,10 @@ class FileTypeResolverTest { assertEquals(FileType.TXT, resolveFileTypeFromMetadata("notes", "text/plain")) assertEquals(FileType.HTML, resolveFileTypeFromMetadata("payload", "application/json")) assertEquals(FileType.CBZ, resolveFileTypeFromMetadata("comic.cbz", "application/zip")) + assertEquals(FileType.CBT, resolveFileTypeFromMetadata("comic.cbt", "application/x-tar")) assertEquals(FileType.FB2, resolveFileTypeFromMetadata("book.fb2.zip", "application/zip")) assertNull(resolveFileTypeFromMetadata("archive.zip", "application/zip")) + assertNull(resolveFileTypeFromMetadata("archive.tar", "application/x-tar")) } @Test @@ -56,6 +58,7 @@ class FileTypeResolverTest { fun `plain txt remains txt when inner extension is unsupported`() { assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt")) assertEquals(FileType.PPTX, resolveFileTypeFromName("deck.pptx")) + assertEquals(FileType.CBT, resolveFileTypeFromName("comic.cbt")) assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt")) assertNull(resolveFileTypeFromName("archive.zip")) } diff --git a/app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/LibraryStateProjectorTest.kt similarity index 98% rename from app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/LibraryStateProjectorTest.kt index 9a97319..a13e1dd 100644 --- a/app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/LibraryStateProjectorTest.kt @@ -1,10 +1,10 @@ -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 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.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull diff --git a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/MainViewModelTest.kt similarity index 78% rename from app/src/test/java/com/aryan/reader/MainViewModelTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/MainViewModelTest.kt index 3eab3fe..f05df07 100644 --- a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/MainViewModelTest.kt @@ -1,6 +1,7 @@ -package com.aryan.reader +package org.dueattendant149.bookreader import android.app.Application +import android.content.ContentResolver import android.content.SharedPreferences import android.content.res.Resources import android.net.Uri @@ -8,22 +9,28 @@ import android.util.Log import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.credentials.CredentialManager +import androidx.lifecycle.ViewModel import androidx.work.WorkManager -import com.aryan.reader.data.* -import com.aryan.reader.paginatedreader.Locator -import com.aryan.reader.paginatedreader.data.BookCacheDao -import com.aryan.reader.paginatedreader.data.BookCacheDatabase -import com.aryan.reader.tts.TtsController -import com.aryan.reader.tts.TtsPlaybackManager +import org.dueattendant149.bookreader.data.* +import org.dueattendant149.bookreader.paginatedreader.Locator +import org.dueattendant149.bookreader.paginatedreader.data.BookCacheDao +import org.dueattendant149.bookreader.paginatedreader.data.BookCacheDatabase +import org.dueattendant149.bookreader.pdf.data.PdfMetaDao +import org.dueattendant149.bookreader.pdf.data.PdfTextDao +import org.dueattendant149.bookreader.pdf.data.PdfTextDatabase +import org.dueattendant149.bookreader.tts.TtsController +import org.dueattendant149.bookreader.tts.TtsPlaybackManager import io.mockk.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.* import org.junit.After +import org.junit.AfterClass import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -34,12 +41,13 @@ import java.io.File @OptIn(ExperimentalCoroutinesApi::class) class MainViewModelTest { - private val testDispatcher = StandardTestDispatcher() + private lateinit var testDispatcher: TestDispatcher private lateinit var viewModel: MainViewModel private lateinit var mockApplication: Application private lateinit var mockPrefs: SharedPreferences private lateinit var mockEditor: SharedPreferences.Editor + private val prefsStringSets = mutableMapOf>() private val billingStateFlow = MutableStateFlow(ProUpgradeState()) private val customFontsFlow = MutableStateFlow>(emptyList()) @@ -50,8 +58,32 @@ class MainViewModelTest { private val tagsFlow = MutableStateFlow>(emptyList()) private val tagRefsFlow = MutableStateFlow>(emptyList()) + companion object { + @JvmStatic + @AfterClass + fun resetMainDispatcher() { + Dispatchers.resetMain() + } + } + + private class TestMainViewModel(application: Application) : MainViewModel(application) { + val locallyCleanedBookIds = mutableListOf() + + override suspend fun cleanupBookDataLocally(bookId: String) { + locallyCleanedBookIds += bookId + } + + fun clearForTest() { + ViewModel::class.java + .getDeclaredMethod("clear\$lifecycle_viewmodel_release") + .invoke(this) + } + } + @Before fun setup() { + testDispatcher = StandardTestDispatcher() + recentFilesFlow.value = emptyList() shelvesFlow.value = emptyList() shelfRefsFlow.value = emptyList() @@ -60,6 +92,7 @@ class MainViewModelTest { billingStateFlow.value = ProUpgradeState() customFontsFlow.value = emptyList() ttsStateFlow.value = TtsPlaybackManager.TtsState() + prefsStringSets.clear() mockkStatic(Log::class) every { Log.isLoggable(any(), any()) } returns false @@ -82,13 +115,18 @@ class MainViewModelTest { every { mockApplication.applicationContext } returns mockApplication every { mockApplication.getSharedPreferences(any(), any()) } returns mockPrefs every { mockApplication.resources } returns mockResources - every { mockApplication.packageName } returns "com.aryan.reader" + every { mockApplication.packageName } returns "org.dueattendant149.bookreader" every { mockApplication.filesDir } returns filesDir every { mockApplication.cacheDir } returns cacheDir every { mockApplication.getExternalFilesDir(any()) } returns externalFilesDir + every { mockApplication.getString(any()) } answers { "res-${firstArg()}" } + every { mockApplication.getString(any(), *anyVararg()) } answers { "res-${firstArg()}" } every { mockPrefs.edit() } returns mockEditor every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? } + every { mockPrefs.getStringSet(any(), any()) } answers { + prefsStringSets[firstArg()]?.toMutableSet() ?: secondArg?>()?.toMutableSet() + } every { mockPrefs.getBoolean(any(), any()) } answers { secondArg() as Boolean } every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int } every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float } @@ -100,6 +138,11 @@ class MainViewModelTest { val mockBookCacheDb = mockk(relaxed = true) every { mockBookCacheDb.bookCacheDao() } returns mockk(relaxed = true) every { BookCacheDatabase.getDatabase(any()) } returns mockBookCacheDb + mockkObject(PdfTextDatabase.Companion) + val mockPdfTextDb = mockk(relaxed = true) + every { mockPdfTextDb.pdfTextDao() } returns mockk(relaxed = true) + every { mockPdfTextDb.pdfMetaDao() } returns mockk(relaxed = true) + every { PdfTextDatabase.getDatabase(any()) } returns mockPdfTextDb mockkObject(WorkManager.Companion) val mockWorkManager = mockk(relaxed = true) @@ -114,6 +157,7 @@ class MainViewModelTest { mockkConstructor(FeedbackRepository::class) mockkConstructor(FontsRepository::class) mockkConstructor(TtsController::class) + mockkConstructor(BookImporter::class) every { anyConstructed().proUpgradeState } returns billingStateFlow every { anyConstructed().initializeConnection() } just Runs @@ -126,6 +170,7 @@ class MainViewModelTest { every { anyConstructed().launchPurchaseFlow(any(), any(), any()) } just Runs every { anyConstructed().getSignedInUser() } returns null every { anyConstructed().observeAuthState() } returns flowOf(null) + every { anyConstructed().removeListener(any()) } just Runs every { anyConstructed().init() } just Runs every { anyConstructed().ttsState } returns ttsStateFlow every { anyConstructed().connect() } just Runs @@ -143,21 +188,31 @@ class MainViewModelTest { coEvery { anyConstructed().removeBooksFromShelf(any(), any()) } just Runs coEvery { anyConstructed().addBooksToShelf(any(), any()) } just Runs coEvery { anyConstructed().deleteShelf(any()) } just Runs + coEvery { anyConstructed().deleteFilePermanently(any()) } just Runs + coEvery { anyConstructed().addRecentFile(any()) } just Runs + coEvery { anyConstructed().deleteBookByUriString(any()) } returns true every { anyConstructed().getAllFonts() } returns customFontsFlow coEvery { anyConstructed().deleteFont(any()) } just Runs - viewModel = MainViewModel(mockApplication) + viewModel = TestMainViewModel(mockApplication) } @After fun tearDown() { - Dispatchers.resetMain() - unmockkAll() + try { + if (::viewModel.isInitialized) { + testDispatcher.scheduler.advanceUntilIdle() + (viewModel as? TestMainViewModel)?.clearForTest() + testDispatcher.scheduler.advanceUntilIdle() + } + } finally { + unmockkAll() + } } @Test - fun `search query updates uiState when search is active`() = runTest { + fun `search query updates uiState when search is active`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -173,7 +228,7 @@ class MainViewModelTest { } @Test - fun `setSearchActive false clears the search query`() = runTest { + fun `setSearchActive false clears the search query`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -187,7 +242,7 @@ class MainViewModelTest { } @Test - fun `search query change is ignored while search is inactive`() = runTest { + fun `search query change is ignored while search is inactive`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -199,7 +254,7 @@ class MainViewModelTest { } @Test - fun `switching theme updates internal state and preferences`() = runTest { + fun `switching theme updates internal state and preferences`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -212,7 +267,7 @@ class MainViewModelTest { } @Test - fun `setAppFontPreference persists app font preference`() = runTest { + fun `setAppFontPreference persists app font preference`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -227,7 +282,7 @@ class MainViewModelTest { } @Test - fun `deleteFont resets matching app custom font preference`() = runTest { + fun `deleteFont resets matching app custom font preference`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -243,7 +298,59 @@ class MainViewModelTest { } @Test - fun `setTabsEnabled persists to shared preferences`() = runTest { + fun `deleteFonts deletes unique selected fonts and resets matching app custom font preference`() = runTest(testDispatcher) { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.setAppFontPreference(AppFontPreference.custom("font-b")) + viewModel.deleteFonts(listOf("font-a", "font-b", "font-a", "")) + advanceUntilIdle() + + coVerify(exactly = 1) { anyConstructed().deleteFont("font-a") } + coVerify(exactly = 1) { anyConstructed().deleteFont("font-b") } + coVerify(exactly = 0) { anyConstructed().deleteFont("") } + assertEquals(AppFontPreference.System, viewModel.uiState.value.appFontPreference) + verify { mockEditor.putString("app_font_kind", AppFontPreferenceKind.SYSTEM.name) } + verify { mockEditor.remove("app_font_custom_id") } + } + + @Test + fun `importFonts imports every selected font`() = runTest(testDispatcher) { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val firstUri = mockk() + val secondUri = mockk() + val firstFont = CustomFontEntity( + id = "font-1", + displayName = "First", + fileName = "font_1.ttf", + fileExtension = "ttf", + path = "/fonts/font_1.ttf", + timestamp = 1L + ) + val secondFont = CustomFontEntity( + id = "font-2", + displayName = "Second", + fileName = "font_2.otf", + fileExtension = "otf", + path = "/fonts/font_2.otf", + timestamp = 2L + ) + coEvery { anyConstructed().importFont(firstUri) } returns Result.success(firstFont) + coEvery { anyConstructed().importFont(secondUri) } returns Result.success(secondFont) + + viewModel.importFonts(listOf(firstUri, secondUri)) + advanceUntilIdle() + + coVerify(exactly = 1) { anyConstructed().importFont(firstUri) } + coVerify(exactly = 1) { anyConstructed().importFont(secondUri) } + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `setTabsEnabled persists to shared preferences`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -256,7 +363,7 @@ class MainViewModelTest { } @Test - fun `setRenderMode persists mode without touching saved epub position`() = runTest { + fun `setRenderMode persists mode without touching saved epub position`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -272,7 +379,7 @@ class MainViewModelTest { } @Test - fun `saveEpubReadingPosition forwards cfi locator and progress to repository`() = runTest { + fun `saveEpubReadingPosition forwards cfi locator and progress to repository`() = runTest(testDispatcher) { val uriString = "content://books/one" val uri = mockUri(uriString) val locator = Locator(chapterIndex = 5, blockIndex = 77, charOffset = 14) @@ -301,7 +408,7 @@ class MainViewModelTest { } @Test - fun `setRecentFilesLimit persists and limits visible home recents`() = runTest { + fun `setRecentFilesLimit persists and limits visible home recents`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -318,7 +425,7 @@ class MainViewModelTest { } @Test - fun `strict file filter pdf filename display and external file behavior persist preferences`() = runTest { + fun `strict file filter pdf filename display and external file behavior persist preferences`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -326,20 +433,103 @@ class MainViewModelTest { viewModel.setStrictFileFilter(true) viewModel.setUsePdfFileNameAsDisplayName(true) viewModel.setExternalFileBehavior("KEEP") + viewModel.setExternalFileBehavior("TEMPORARY") val state = viewModel.uiState.first { - it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "KEEP" + it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "TEMPORARY" } assertTrue(state.useStrictFileFilter) assertTrue(state.usePdfFileNameAsDisplayName) - assertEquals("KEEP", state.externalFileBehavior) + assertEquals("TEMPORARY", state.externalFileBehavior) verify { mockEditor.putBoolean("use_strict_file_filter", true) } verify { mockEditor.putBoolean("use_pdf_file_name_as_display_name", true) } verify { mockEditor.putString("external_file_behavior", "KEEP") } + verify { mockEditor.putString("external_file_behavior", "TEMPORARY") } } @Test - fun `screen capture protection persists and updates state`() = runTest { + fun `startup removes pending external always-remove file before restoring session`() = runTest(testDispatcher) { + val pendingUri = "file:///data/user/0/org.dueattendant149.bookreader/files/books/external.epub" + val pendingEntry = """{"bookId":"external-book","uriString":"$pendingUri"}""" + prefsStringSets["pending_external_file_removals"] = setOf(pendingEntry) + every { mockPrefs.getString("last_open_book_id", null) } returns "external-book" + every { mockPrefs.getString("last_open_file_type", null) } returns FileType.EPUB.name + + val restored = TestMainViewModel(mockApplication) + try { + advanceUntilIdle() + coVerify(timeout = 1_000) { + anyConstructed().deleteFilePermanently(listOf("external-book")) + } + + coVerify { + anyConstructed().deleteBookByUriString(pendingUri) + } + assertEquals(listOf("external-book"), restored.locallyCleanedBookIds) + verify(atLeast = 1) { mockEditor.remove("last_open_book_id") } + verify(atLeast = 1) { mockEditor.remove("last_open_file_type") } + verify { mockEditor.remove("pending_external_file_removals") } + } finally { + restored.clearForTest() + } + } + + @Test + fun `temporary external pdf opens directly without importing or adding to library`() = runTest(testDispatcher) { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val externalUri = mockUri("content://external/temp.pdf", path = "/temp.pdf", lastPathSegment = "temp.pdf") + val resolver = mockk() + every { mockApplication.contentResolver } returns resolver + every { resolver.getType(externalUri) } returns "application/pdf" + every { resolver.query(externalUri, null, null, null, null) } returns null + coEvery { anyConstructed().getFileByBookId(match { it.startsWith("temporary-") }) } returns null + + viewModel.onFileSelected( + externalUri, + isFromRecent = false, + isExternalIntent = true, + isTemporaryExternalIntent = true + ) + advanceUntilIdle() + + val selected = viewModel.uiState.first { it.selectedBookId?.startsWith("temporary-") == true && it.selectedPdfUri != null } + assertEquals(externalUri, selected.selectedPdfUri) + assertEquals(null, selected.showExternalFileSavePromptFor) + coVerify(exactly = 0) { anyConstructed().importBook(any()) } + coVerify(exactly = 0) { anyConstructed().addRecentFile(any()) } + verify(exactly = 0) { mockEditor.putStringSet("pending_external_file_removals", any()) } + } + + @Test + fun `closing temporary external direct book signals activity finish without library cleanup`() = runTest(testDispatcher) { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val item = recentFile("external-book", type = FileType.PDF) + coEvery { anyConstructed().getFileByBookId(item.bookId) } returns item + viewModel.trackExternalOpenForClose( + bookId = item.bookId, + importedCopyUriString = null, + isTemporaryExternalIntent = true + ) + viewModel.onRecentFileClicked(item) + advanceUntilIdle() + viewModel.uiState.first { it.selectedBookId == item.bookId } + val finishEvent = backgroundScope.async { viewModel.temporaryExternalOpenFinished.first() } + + viewModel.clearSelectedFile() + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.showExternalFileSavePromptFor) + coVerify(exactly = 0) { anyConstructed().deleteFilePermanently(listOf(item.bookId)) } + coVerify(exactly = 0) { anyConstructed().deleteBookByUriString(item.uriString!!) } + assertTrue(finishEvent.isCompleted) + } + + @Test + fun `screen capture protection persists and updates state`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -358,7 +548,7 @@ class MainViewModelTest { } @Test - fun `setSortOrder persists preference and reorders visible home and library lists`() = runTest { + fun `setSortOrder persists preference and reorders visible home and library lists`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -380,7 +570,7 @@ class MainViewModelTest { } @Test - fun `setMainScreenPage clamps to bottom navigation bounds and persists`() = runTest { + fun `setMainScreenPage clamps to bottom navigation bounds and persists`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -393,7 +583,7 @@ class MainViewModelTest { } @Test - fun `setLibraryScreenPage clamps to available library tabs and persists`() = runTest { + fun `setLibraryScreenPage clamps to available library tabs and persists`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -407,7 +597,7 @@ class MainViewModelTest { } @Test - fun `create shelf dialog state opens and dismisses`() = runTest { + fun `create shelf dialog state opens and dismisses`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -422,7 +612,7 @@ class MainViewModelTest { } @Test - fun `selectAllRecentFiles toggles only visible recent home items`() = runTest { + fun `selectAllRecentFiles toggles only visible recent home items`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -445,7 +635,7 @@ class MainViewModelTest { } @Test - fun `selectAllLibraryFiles toggles all filtered library items`() = runTest { + fun `selectAllLibraryFiles toggles all filtered library items`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -465,7 +655,7 @@ class MainViewModelTest { } @Test - fun `selectAllLibraryFiles clears selection when all visible library items are already selected`() = runTest { + fun `selectAllLibraryFiles clears selection when all visible library items are already selected`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -483,7 +673,7 @@ class MainViewModelTest { } @Test - fun `togglePinForContextualItems pins selected home items and clears selection`() = runTest { + fun `togglePinForContextualItems pins selected home items and clears selection`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -503,7 +693,7 @@ class MainViewModelTest { } @Test - fun `togglePinForContextualItems unpins when every selected item is already pinned`() = runTest { + fun `togglePinForContextualItems unpins when every selected item is already pinned`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -525,7 +715,7 @@ class MainViewModelTest { } @Test - fun `clearContextualAction clears selected books without disturbing pinned state`() = runTest { + fun `clearContextualAction clears selected books without disturbing pinned state`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -544,7 +734,7 @@ class MainViewModelTest { } @Test - fun `togglePinForContextualItems pins selected library items separately from home pins`() = runTest { + fun `togglePinForContextualItems pins selected library items separately from home pins`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -564,7 +754,7 @@ class MainViewModelTest { } @Test - fun `updateLibraryFilters updates state and persists every filter dimension`() = runTest { + fun `updateLibraryFilters updates state and persists every filter dimension`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -586,7 +776,7 @@ class MainViewModelTest { } @Test - fun `updateLibraryFilters drops unknown file type before state and prefs`() = runTest { + fun `updateLibraryFilters drops unknown file type before state and prefs`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -607,16 +797,20 @@ class MainViewModelTest { } @Test - fun `saved library file filters drop stale unknown values during restore`() = runTest { + fun `saved library file filters drop stale unknown values during restore`() = runTest(testDispatcher) { every { mockPrefs.getStringSet(KEY_FILTER_FILE_TYPES, any()) } returns mutableSetOf("PDF", "UNKNOWN") - val restored = MainViewModel(mockApplication) - - assertEquals(setOf(FileType.PDF), restored.uiState.value.libraryFilters.fileTypes) + val restored = TestMainViewModel(mockApplication) + try { + assertEquals(setOf(FileType.PDF), restored.uiState.value.libraryFilters.fileTypes) + } finally { + restored.clearForTest() + testDispatcher.scheduler.advanceUntilIdle() + } } @Test - fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest { + fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -641,7 +835,7 @@ class MainViewModelTest { } @Test - fun `tag selection ignores empty targets and closes after opening`() = runTest { + fun `tag selection ignores empty targets and closes after opening`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -659,7 +853,7 @@ class MainViewModelTest { } @Test - fun `toggleTagForBooks assigns and removes tags for sanitized book ids`() = runTest { + fun `toggleTagForBooks assigns and removes tags for sanitized book ids`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -679,7 +873,7 @@ class MainViewModelTest { } @Test - fun `rename and delete shelf dialogs store their target and dismiss cleanly`() = runTest { + fun `rename and delete shelf dialogs store their target and dismiss cleanly`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -701,7 +895,7 @@ class MainViewModelTest { } @Test - fun `shelf selection only allows manual mutable shelves and toggles by click`() = runTest { + fun `shelf selection only allows manual mutable shelves and toggles by click`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -723,7 +917,7 @@ class MainViewModelTest { } @Test - fun `onShelfClick navigates when shelf contextual mode is inactive`() = runTest { + fun `onShelfClick navigates when shelf contextual mode is inactive`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -740,7 +934,7 @@ class MainViewModelTest { } @Test - fun `shelf navigation sets library landing state and can be cleared`() = runTest { + fun `shelf navigation sets library landing state and can be cleared`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -759,7 +953,7 @@ class MainViewModelTest { } @Test - fun `clearShelfContextualAction clears selected shelves`() = runTest { + fun `clearShelfContextualAction clears selected shelves`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -776,7 +970,7 @@ class MainViewModelTest { } @Test - fun `deleteSelectedShelves deletes only mutable selected shelves and clears selection`() = runTest { + fun `deleteSelectedShelves deletes only mutable selected shelves and clears selection`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -803,7 +997,7 @@ class MainViewModelTest { } @Test - fun `add books mode resets selection and tracks source changes`() = runTest { + fun `add books mode resets selection and tracks source changes`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -837,7 +1031,7 @@ class MainViewModelTest { } @Test - fun `toggleBookSelectionForAdding toggles individual books`() = runTest { + fun `toggleBookSelectionForAdding toggles individual books`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -852,7 +1046,7 @@ class MainViewModelTest { } @Test - fun `addBooksToShelf saves selected books for mutable shelves and exits add mode`() = runTest { + fun `addBooksToShelf saves selected books for mutable shelves and exits add mode`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -876,7 +1070,7 @@ class MainViewModelTest { } @Test - fun `addBooksToShelf dismisses add mode when target shelf is not mutable`() = runTest { + fun `addBooksToShelf dismisses add mode when target shelf is not mutable`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -893,7 +1087,7 @@ class MainViewModelTest { } @Test - fun `removeContextualItemsFromShelf removes selected books from the current mutable shelf`() = runTest { + fun `removeContextualItemsFromShelf removes selected books from the current mutable shelf`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -915,7 +1109,7 @@ class MainViewModelTest { } @Test - fun `app appearance settings persist contrast brightness seed and custom themes`() = runTest { + fun `app appearance settings persist contrast brightness seed and custom themes`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -951,7 +1145,7 @@ class MainViewModelTest { } @Test - fun `setAppSeedColor can clear a selected seed color`() = runTest { + fun `setAppSeedColor can clear a selected seed color`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -968,7 +1162,7 @@ class MainViewModelTest { } @Test - fun `addCustomAppTheme replaces existing theme with the same id`() = runTest { + fun `addCustomAppTheme replaces existing theme with the same id`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -985,7 +1179,7 @@ class MainViewModelTest { } @Test - fun `banner message logic works correctly`() = runTest { + fun `banner message logic works correctly`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -1004,7 +1198,7 @@ class MainViewModelTest { } @Test - fun `persistent banner is not auto dismissed`() = runTest { + fun `persistent banner is not auto dismissed`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } diff --git a/app/src/test/java/com/aryan/reader/PurchaseAccountObfuscatorTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/PurchaseAccountObfuscatorTest.kt similarity index 95% rename from app/src/test/java/com/aryan/reader/PurchaseAccountObfuscatorTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/PurchaseAccountObfuscatorTest.kt index 4682e73..18634fe 100644 --- a/app/src/test/java/com/aryan/reader/PurchaseAccountObfuscatorTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/PurchaseAccountObfuscatorTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader +package org.dueattendant149.bookreader import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/ReaderBrightnessSettingsTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/ReaderBrightnessSettingsTest.kt new file mode 100644 index 0000000..fb744fd --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/ReaderBrightnessSettingsTest.kt @@ -0,0 +1,28 @@ +package org.dueattendant149.bookreader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReaderBrightnessSettingsTest { + + @Test + fun `brightness settings default to system and clamp custom values`() { + val defaults = ReaderBrightnessSettings() + + assertTrue(defaults.useSystemBrightness) + assertEquals(0.75f, defaults.safeCustomBrightness, 0.0001f) + assertEquals(0.01f, defaults.copy(customBrightness = 0f).safeCustomBrightness, 0.0001f) + assertEquals(0.02f, defaults.copy(customBrightness = 0.02f).safeCustomBrightness, 0.0001f) + assertEquals(0.23f, defaults.copy(customBrightness = 0.234f).safeCustomBrightness, 0.0001f) + assertEquals(1f, defaults.copy(customBrightness = 2f).safeCustomBrightness, 0.0001f) + } + + @Test + fun `brightness step controls move by one percent and clamp`() { + assertEquals(0.74f, stepReaderBrightness(0.75f, -1), 0.0001f) + assertEquals(0.76f, stepReaderBrightness(0.75f, 1), 0.0001f) + assertEquals(0.01f, stepReaderBrightness(0.01f, -1), 0.0001f) + assertEquals(1f, stepReaderBrightness(1f, 1), 0.0001f) + } +} diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/ReaderPopupSizingTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/ReaderPopupSizingTest.kt new file mode 100644 index 0000000..134997f --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/ReaderPopupSizingTest.kt @@ -0,0 +1,22 @@ +package org.dueattendant149.bookreader + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReaderPopupSizingTest { + + @Test + fun `modal max height leaves edge margin on landscape-height screens`() { + assertEquals(306, readerModalMaxHeightDp(screenHeightDp = 360)) + } + + @Test + fun `modal max height uses preferred minimum when there is room`() { + assertEquals(220, readerModalMaxHeightDp(screenHeightDp = 252)) + } + + @Test + fun `modal max height stays within tiny screens`() { + assertEquals(168, readerModalMaxHeightDp(screenHeightDp = 200)) + } +} diff --git a/app/src/test/java/com/aryan/reader/ReaderScreenOrientationTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/ReaderScreenOrientationTest.kt similarity index 99% rename from app/src/test/java/com/aryan/reader/ReaderScreenOrientationTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/ReaderScreenOrientationTest.kt index a4b31b5..749aaf1 100644 --- a/app/src/test/java/com/aryan/reader/ReaderScreenOrientationTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/ReaderScreenOrientationTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader +package org.dueattendant149.bookreader import android.content.Context import android.content.SharedPreferences diff --git a/app/src/test/java/com/aryan/reader/ReaderSliderChromeStateTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/ReaderSliderChromeStateTest.kt similarity index 67% rename from app/src/test/java/com/aryan/reader/ReaderSliderChromeStateTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/ReaderSliderChromeStateTest.kt index 1283ade..c80771f 100644 --- a/app/src/test/java/com/aryan/reader/ReaderSliderChromeStateTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/ReaderSliderChromeStateTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader +package org.dueattendant149.bookreader import androidx.compose.ui.graphics.Color import org.junit.Assert.assertEquals @@ -80,6 +80,68 @@ class ReaderSliderChromeStateTest { ) } + @Test + fun `one based slider stepping clamps to epub page range`() { + assertEquals( + 1, + readerSliderStepPage( + currentPage = 1, + delta = -1, + minPage = 1, + maxPage = 20 + ) + ) + assertEquals( + 11, + readerSliderStepPage( + currentPage = 10, + delta = 1, + minPage = 1, + maxPage = 20 + ) + ) + assertEquals( + 20, + readerSliderStepPage( + currentPage = 20, + delta = 1, + minPage = 1, + maxPage = 20 + ) + ) + } + + @Test + fun `zero based slider stepping clamps to pdf display page range`() { + assertEquals( + 0, + readerSliderStepPage( + currentPage = 0, + delta = -1, + minPage = 0, + maxPage = 9 + ) + ) + assertEquals( + 6, + readerSliderStepPage( + currentPage = 5, + delta = 1, + minPage = 0, + maxPage = 9 + ) + ) + assertEquals( + 9, + readerSliderStepPage( + currentPage = 9, + delta = 1, + minPage = 0, + maxPage = 9 + ) + ) + } + @Test fun `slider content color falls back on light page when theme text is low contrast`() { val colors = readerSliderChromeColors( diff --git a/app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/SharedModelMappersTest.kt similarity index 57% rename from app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/SharedModelMappersTest.kt index b55a261..24e3a48 100644 --- a/app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/SharedModelMappersTest.kt @@ -1,13 +1,15 @@ -package com.aryan.reader +package org.dueattendant149.bookreader -import com.aryan.reader.data.BookTagCrossRef -import com.aryan.reader.data.RecentFileItem -import com.aryan.reader.data.TagEntity -import com.aryan.reader.shared.ReaderFeatureSurface -import com.aryan.reader.shared.FileType as SharedFileType -import com.aryan.reader.shared.SharedReaderScreenState -import com.aryan.reader.shared.Shelf as SharedShelf -import com.aryan.reader.shared.ShelfType as SharedShelfType +import org.dueattendant149.bookreader.data.BookTagCrossRef +import org.dueattendant149.bookreader.data.RecentFileItem +import org.dueattendant149.bookreader.data.TagEntity +import org.dueattendant149.bookreader.data.toBookMetadata +import org.dueattendant149.bookreader.data.toRecentFileItem +import org.dueattendant149.bookreader.shared.ReaderFeatureSurface +import org.dueattendant149.bookreader.shared.FileType as SharedFileType +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.Shelf as SharedShelf +import org.dueattendant149.bookreader.shared.ShelfType as SharedShelfType import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame @@ -46,6 +48,48 @@ class SharedModelMappersTest { assertEquals(listOf(tag), mapped.tags) } + @Test + fun `book mapper carries android epub block locator through shared model`() { + val original = recentFile( + id = "book", + type = FileType.EPUB, + lastChapterIndex = 3, + lastPage = 18, + lastPositionCfi = "android-locator:3:44:120", + locatorBlockIndex = 44, + locatorCharOffset = 120 + ) + + val shared = original.toSharedBookItem() + val mapped = shared.toRecentFileItem() + + assertEquals(3, shared.readerPosition?.chapterIndex) + assertEquals(18, shared.readerPosition?.pageIndex) + assertEquals(44, shared.readerPosition?.blockIndex) + assertEquals(120, shared.readerPosition?.charOffset) + assertEquals(original.lastChapterIndex, mapped.lastChapterIndex) + assertEquals(original.lastPage, mapped.lastPage) + assertEquals(original.lastPositionCfi, mapped.lastPositionCfi) + assertEquals(original.locatorBlockIndex, mapped.locatorBlockIndex) + assertEquals(original.locatorCharOffset, mapped.locatorCharOffset) + } + + @Test + fun `android cloud metadata preserves file content timestamp`() { + val original = recentFile( + id = "book", + type = FileType.EPUB, + fileContentModifiedTimestamp = 1_500L + ) + + val metadata = original.toBookMetadata() + val restored = metadata.toRecentFileItem() + + assertEquals(1_500L, metadata.fileContentModifiedTimestamp) + assertEquals(1_500L, restored.fileContentModifiedTimestamp) + assertFalse(restored.isAvailable) + } + @Test fun `shared projection state maps shelves tabs selections and tags back to android state`() { val tag = TagEntity(id = "tag", name = "Queued", createdAt = 1L) @@ -92,6 +136,41 @@ class SharedModelMappersTest { assertEquals(listOf(tag), android.allTags) } + @Test + fun `shared projection state reuses mapped android book instances by id`() { + val book = recentFile("book") + val sharedBook = book.toSharedBookItem() + val sharedShelf = SharedShelf( + id = "manual", + name = "Manual", + type = SharedShelfType.MANUAL, + books = listOf(sharedBook), + directBooks = listOf(sharedBook) + ) + val projected = SharedReaderScreenState( + recentBooks = listOf(sharedBook), + libraryBooks = listOf(sharedBook), + rawLibraryBooks = listOf(sharedBook), + shelves = listOf(sharedShelf), + openTabs = listOf(sharedBook), + booksAvailableForAdding = listOf(sharedBook) + ) + + val android = projected.toAndroidReaderScreenState( + base = ReaderScreenState(), + androidBooksById = mapOf(book.bookId to book) + ) + + val mappedBook = android.rawLibraryFiles.single() + assertSame(book, mappedBook) + assertSame(mappedBook, android.recentFiles.single()) + assertSame(mappedBook, android.allRecentFiles.single()) + assertSame(mappedBook, android.shelves.single().books.single()) + assertSame(mappedBook, android.shelves.single().directBooks.single()) + assertSame(mappedBook, android.openTabs.single()) + assertSame(mappedBook, android.booksAvailableForAdding.single()) + } + @Test fun `enum filter and folder mappers round trip between android and shared`() { val filters = LibraryFilters( @@ -115,6 +194,7 @@ class SharedModelMappersTest { assertEquals(filters, filters.toSharedLibraryFilters().toAndroidLibraryFilters()) assertEquals(folder, folder.toSharedSyncedFolder().toAndroidSyncedFolder()) assertTrue(FileType.PPTX in PDF_VIEWER_FILE_TYPES) + assertTrue(FileType.CBT in PDF_VIEWER_FILE_TYPES) assertEquals(ReaderFeatureSurface.PDF_VIEWER, FileType.PPTX.readerSurfaceOnAndroid()) assertFalse(FileType.UNKNOWN in ANDROID_READABLE_FILE_TYPES) assertFalse(FileType.UNKNOWN in ANDROID_SYNCABLE_FILE_TYPES) @@ -133,6 +213,18 @@ class SharedModelMappersTest { assertEquals(listOf(tag), tagged.single().tags) } + @Test + fun `tag resolver reuses book item when resolved tags are unchanged`() { + val file = recentFile("book") + + val resolved = listOf(file).withResolvedTags( + dbTags = emptyList(), + tagRefs = emptyList() + ) + + assertSame(file, resolved.single()) + } + private fun recentFile( id: String, type: FileType = FileType.EPUB, @@ -141,6 +233,12 @@ class SharedModelMappersTest { isAvailable: Boolean = true, bookmarksJson: String? = null, sourceFolderUri: String? = null, + lastChapterIndex: Int? = null, + lastPage: Int? = null, + lastPositionCfi: String? = null, + locatorBlockIndex: Int? = null, + locatorCharOffset: Int? = null, + fileContentModifiedTimestamp: Long = 0L, tags: List = emptyList() ) = RecentFileItem( bookId = id, @@ -152,6 +250,12 @@ class SharedModelMappersTest { bookmarksJson = bookmarksJson, sourceFolderUri = sourceFolderUri, customName = customName, + lastChapterIndex = lastChapterIndex, + lastPage = lastPage, + lastPositionCfi = lastPositionCfi, + locatorBlockIndex = locatorBlockIndex, + locatorCharOffset = locatorCharOffset, + fileContentModifiedTimestamp = fileContentModifiedTimestamp, tags = tags ) diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/SyncedFolderPrefsTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/SyncedFolderPrefsTest.kt new file mode 100644 index 0000000..c0558eb --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/SyncedFolderPrefsTest.kt @@ -0,0 +1,69 @@ +package org.dueattendant149.bookreader + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncedFolderPrefsTest { + @Test + fun `missing local sync flag defaults enabled`() { + val folders = SyncedFolderPrefs.decodeSyncedFolders( + jsonString = """ + [ + { + "uri": "content://folder", + "name": "Books", + "lastScanTime": 12, + "allowedFileTypes": ["PDF"] + } + ] + """.trimIndent(), + legacyUri = null, + syncableTypes = setOf(FileType.PDF, FileType.EPUB) + ) + + assertTrue(folders.single().localSyncEnabled) + assertTrue( + SyncedFolderPrefs.isLocalSyncEnabled( + jsonString = SyncedFolderPrefs.encodeSyncedFolders( + folders, + syncableTypes = setOf(FileType.PDF, FileType.EPUB) + ), + legacyUri = null, + folderUriString = "content://folder", + syncableTypes = setOf(FileType.PDF, FileType.EPUB) + ) + ) + } + + @Test + fun `disabled local sync flag persists and is checked`() { + val encoded = SyncedFolderPrefs.encodeSyncedFolders( + listOf( + SyncedFolder( + uriString = "content://folder", + name = "Books", + lastScanTime = 12L, + allowedFileTypes = setOf(FileType.PDF), + localSyncEnabled = false + ) + ), + syncableTypes = setOf(FileType.PDF, FileType.EPUB) + ) + val decoded = SyncedFolderPrefs.decodeSyncedFolders( + jsonString = encoded, + legacyUri = null, + syncableTypes = setOf(FileType.PDF, FileType.EPUB) + ) + + assertFalse(decoded.single().localSyncEnabled) + assertFalse( + SyncedFolderPrefs.isLocalSyncEnabled( + jsonString = encoded, + legacyUri = null, + folderUriString = "content://folder", + syncableTypes = setOf(FileType.PDF, FileType.EPUB) + ) + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/TtsReplacementChunkTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/TtsReplacementChunkTest.kt similarity index 83% rename from app/src/test/java/com/aryan/reader/TtsReplacementChunkTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/TtsReplacementChunkTest.kt index e0c8a23..42816bb 100644 --- a/app/src/test/java/com/aryan/reader/TtsReplacementChunkTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/TtsReplacementChunkTest.kt @@ -1,8 +1,8 @@ -package com.aryan.reader +package org.dueattendant149.bookreader -import com.aryan.reader.paginatedreader.TtsChunk -import com.aryan.reader.shared.ReaderTtsReplacementPreferences -import com.aryan.reader.shared.ReaderTtsReplacementRule +import org.dueattendant149.bookreader.paginatedreader.TtsChunk +import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences +import org.dueattendant149.bookreader.shared.ReaderTtsReplacementRule import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/com/aryan/reader/data/FileTypeConverterTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/data/FileTypeConverterTest.kt similarity index 87% rename from app/src/test/java/com/aryan/reader/data/FileTypeConverterTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/data/FileTypeConverterTest.kt index ee19115..3232051 100644 --- a/app/src/test/java/com/aryan/reader/data/FileTypeConverterTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/data/FileTypeConverterTest.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.data +package org.dueattendant149.bookreader.data -import com.aryan.reader.FileType +import org.dueattendant149.bookreader.FileType import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test diff --git a/app/src/test/java/com/aryan/reader/data/FolderBookMetadataTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/data/FolderBookMetadataTest.kt similarity index 97% rename from app/src/test/java/com/aryan/reader/data/FolderBookMetadataTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/data/FolderBookMetadataTest.kt index 5392302..e1af358 100644 --- a/app/src/test/java/com/aryan/reader/data/FolderBookMetadataTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/data/FolderBookMetadataTest.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.data +package org.dueattendant149.bookreader.data -import com.aryan.reader.FileType +import org.dueattendant149.bookreader.FileType import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/data/ImportedFontFileNameTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/data/ImportedFontFileNameTest.kt new file mode 100644 index 0000000..3ba8221 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/data/ImportedFontFileNameTest.kt @@ -0,0 +1,32 @@ +package org.dueattendant149.bookreader.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ImportedFontFileNameTest { + @Test + fun importedFontFileNamePreservesVariableFontVariantTokens() { + val fileName = importedFontFileName( + displayName = "Pliant-Italic-VariableFont_wdth,wght", + extension = "TTF" + ) + + assertEquals("Pliant-Italic-VariableFont_wdth,wght.ttf", fileName) + } + + @Test + fun importedFontFileNameRemovesPathUnsafeCharacters() { + val fileName = importedFontFileName( + displayName = """Pliant/Italic:VariableFont*wdth?wght""", + extension = "t/tf" + ) + + assertEquals("Pliant_Italic_VariableFont_wdth_wght.ttf", fileName) + } + + @Test + fun importedFontFileNameFallsBackForBlankNames() { + assertTrue(importedFontFileName("...", "ttf").startsWith("font.")) + } +} diff --git a/app/src/test/java/com/aryan/reader/data/RecentFileDaoMetadataExtractionTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/data/RecentFileDaoMetadataExtractionTest.kt similarity index 98% rename from app/src/test/java/com/aryan/reader/data/RecentFileDaoMetadataExtractionTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/data/RecentFileDaoMetadataExtractionTest.kt index 5ab3abe..07356e9 100644 --- a/app/src/test/java/com/aryan/reader/data/RecentFileDaoMetadataExtractionTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/data/RecentFileDaoMetadataExtractionTest.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.data +package org.dueattendant149.bookreader.data import androidx.room.Room -import com.aryan.reader.FileType +import org.dueattendant149.bookreader.FileType import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals diff --git a/app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/data/RecentFileDaoReadingPositionTest.kt similarity index 80% rename from app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/data/RecentFileDaoReadingPositionTest.kt index bfc810b..61ff85b 100644 --- a/app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/data/RecentFileDaoReadingPositionTest.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.data +package org.dueattendant149.bookreader.data import androidx.room.Room -import com.aryan.reader.FileType +import org.dueattendant149.bookreader.FileType import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.After @@ -56,6 +56,7 @@ class RecentFileDaoReadingPositionTest { assertEquals(58.5f, saved.progressPercentage) assertEquals(9_000L, saved.timestamp) assertEquals(9_000L, saved.lastModifiedTimestamp) + assertEquals(9_000L, saved.readingPositionModifiedTimestamp) } @Test @@ -100,9 +101,30 @@ class RecentFileDaoReadingPositionTest { assertEquals(21, item.locatorBlockIndex) assertEquals(12, item.locatorCharOffset) assertEquals(44f, item.progressPercentage) + assertEquals(3_000L, item.readingPositionModifiedTimestamp) assertTrue(item.isRecent) } + @Test + fun `recent file summary caps oversized descriptions while full lookup keeps metadata`() = runTest { + val longDescription = "Summary ".repeat(2_000) + val longOriginalDescription = "Original ".repeat(2_000) + dao.insertOrUpdateFile( + recentFileEntity().copy( + description = longDescription, + originalDescription = longOriginalDescription + ) + ) + + val summary = dao.getRecentFiles().first().single() + val full = dao.getFileByBookId("book-1")!! + + assertEquals(4_096, summary.description?.length) + assertEquals(4_096, summary.originalDescription?.length) + assertEquals(longDescription, full.description) + assertEquals(longOriginalDescription, full.originalDescription) + } + private fun recentFileEntity(lastPositionCfi: String? = null): RecentFileEntity { return RecentFileEntity( bookId = "book-1", diff --git a/app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/data/RecentFileItemReadingPositionMappingTest.kt similarity index 92% rename from app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/data/RecentFileItemReadingPositionMappingTest.kt index 831c304..e306e49 100644 --- a/app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/data/RecentFileItemReadingPositionMappingTest.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.data +package org.dueattendant149.bookreader.data -import com.aryan.reader.FileType +import org.dueattendant149.bookreader.FileType import org.junit.Assert.assertEquals import org.junit.Test @@ -18,6 +18,7 @@ class RecentFileItemReadingPositionMappingTest { assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset) assertEquals(item.progressPercentage, roundTripped.progressPercentage) assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp) + assertEquals(item.readingPositionModifiedTimestamp, roundTripped.readingPositionModifiedTimestamp) } @Test @@ -32,6 +33,7 @@ class RecentFileItemReadingPositionMappingTest { assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset) assertEquals(item.progressPercentage, roundTripped.progressPercentage) assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp) + assertEquals(item.readingPositionModifiedTimestamp, roundTripped.readingPositionModifiedTimestamp) } @Test @@ -101,6 +103,7 @@ class RecentFileItemReadingPositionMappingTest { locatorCharOffset = 88, progressPercentage = 61.5f, lastModifiedTimestamp = 2_000L, + readingPositionModifiedTimestamp = 1_900L, fileContentModifiedTimestamp = 3_000L, bookmarksJson = """[{"cfi":"/4/2"}]""", highlightsJson = """[{"cfi":"/4/2/6:88"}]""" diff --git a/app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt similarity index 82% rename from app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt index 2057e0e..3871cbc 100644 --- a/app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.data +package org.dueattendant149.bookreader.data import android.content.Context -import com.aryan.reader.FileType +import org.dueattendant149.bookreader.FileType import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify @@ -104,6 +104,8 @@ class RecentFilesRepositoryReadingPositionMergeTest { locatorBlockIndex = 31, locatorCharOffset = 12, progressPercentage = 82f, + lastModifiedTimestamp = 2_000L, + readingPositionModifiedTimestamp = 2_000L, isRecent = true ) ) @@ -113,6 +115,41 @@ class RecentFilesRepositoryReadingPositionMergeTest { assertEquals(31, inserted.captured.locatorBlockIndex) assertEquals(12, inserted.captured.locatorCharOffset) assertEquals(82f, inserted.captured.progressPercentage) + assertEquals(2_000L, inserted.captured.readingPositionModifiedTimestamp) + } + + @Test + fun `addRecentFile preserves newer existing reading position when incoming metadata timestamp is newer`() = runTest { + val inserted = slot() + coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity().copy( + readingPositionModifiedTimestamp = 1_800L + ) + coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs + + repository.addRecentFile( + RecentFileItem( + bookId = "book-1", + uriString = "content://new", + type = FileType.EPUB, + displayName = "New.epub", + timestamp = 3_000L, + lastChapterIndex = 1, + lastPositionCfi = "/old/remote", + locatorBlockIndex = 2, + locatorCharOffset = 3, + progressPercentage = 12f, + lastModifiedTimestamp = 3_000L, + readingPositionModifiedTimestamp = 1_200L, + isRecent = true + ) + ) + + assertEquals("/4/2/6:44", inserted.captured.lastPositionCfi) + assertEquals(6, inserted.captured.lastChapterIndex) + assertEquals(24, inserted.captured.locatorBlockIndex) + assertEquals(44, inserted.captured.locatorCharOffset) + assertEquals(71.5f, inserted.captured.progressPercentage) + assertEquals(1_800L, inserted.captured.readingPositionModifiedTimestamp) } @Test diff --git a/app/src/test/java/com/aryan/reader/data/SmartCollectionEngineTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/data/SmartCollectionEngineTest.kt similarity index 98% rename from app/src/test/java/com/aryan/reader/data/SmartCollectionEngineTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/data/SmartCollectionEngineTest.kt index ae64fb4..2951758 100644 --- a/app/src/test/java/com/aryan/reader/data/SmartCollectionEngineTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/data/SmartCollectionEngineTest.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.data +package org.dueattendant149.bookreader.data -import com.aryan.reader.FileType +import org.dueattendant149.bookreader.FileType import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/epub/EpubImportSecurityTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epub/EpubImportSecurityTest.kt new file mode 100644 index 0000000..8f158f8 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epub/EpubImportSecurityTest.kt @@ -0,0 +1,132 @@ +package org.dueattendant149.bookreader.epub + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.Base64 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@RunWith(RobolectricTestRunner::class) +class EpubImportSecurityTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `safeFileInRoot rejects traversal outside extraction root`() { + val root = temp.newFolder("root") + + assertNotNull(safeFileInRoot(root, "OEBPS/image.png")) + assertTrue(safeFileInRoot(root, "../outside.txt") == null) + } + + @Test + fun `xml parser rejects doctypes from untrusted book metadata`() { + val xml = """ + ]> + &xxe; + """.trimIndent() + + assertThrows(Exception::class.java) { + parseXMLFile(ByteArrayInputStream(xml.toByteArray(Charsets.UTF_8))) + } + } + + @Test + fun `odt parser skips zip entries that escape extraction root`() = runTest { + val extractionDir = temp.newFolder("odt-root") + val outside = File(extractionDir.parentFile, "odt-evil.txt") + val parser = OdtParser(contextWithCache(temp.newFolder("odt-cache"))) + + parser.createOdtBook( + inputStream = ByteArrayInputStream( + zipBytes( + "content.xml" to minimalOdtContent().toByteArray(Charsets.UTF_8), + "../odt-evil.txt" to "evil".toByteArray(Charsets.UTF_8) + ) + ), + bookId = "odt-book", + originalBookNameHint = "book.odt", + isFlat = false, + parseContent = false, + extractionDirOverride = extractionDir + ) + + assertFalse(outside.exists()) + } + + @Test + fun `fb2 parser sanitizes binary image ids before writing files`() = runTest { + val extractionDir = temp.newFolder("fb2-root") + val outside = File(extractionDir.parentFile, "fb2-evil.png") + val parser = Fb2Parser(contextWithCache(temp.newFolder("fb2-cache"))) + + parser.createFb2Book( + inputStream = ByteArrayInputStream(minimalFb2WithUnsafeImage().toByteArray(Charsets.UTF_8)), + bookId = "fb2-book", + originalBookNameHint = "book.fb2", + parseContent = true, + extractionDirOverride = extractionDir + ) + + assertFalse(outside.exists()) + assertTrue(extractionDir.listFiles().orEmpty().any { it.name.startsWith("fb2-evil_") && it.extension == "png" }) + } + + private fun contextWithCache(cacheDir: File): Context { + val context = mockk(relaxed = true) + every { context.cacheDir } returns cacheDir + return context + } + + private fun zipBytes(vararg entries: Pair): ByteArray { + val output = ByteArrayOutputStream() + ZipOutputStream(output).use { zip -> + entries.forEach { (name, bytes) -> + zip.putNextEntry(ZipEntry(name)) + zip.write(bytes) + zip.closeEntry() + } + } + return output.toByteArray() + } + + private fun minimalOdtContent(): String { + return """ + + Hello + + """.trimIndent() + } + + private fun minimalFb2WithUnsafeImage(): String { + val payload = Base64.getEncoder().encodeToString(byteArrayOf(1, 2, 3, 4)) + return """ + + Unsafe image + +
+

Hello

+ +
+ + $payload +
+ """.trimIndent() + } +} diff --git a/app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epub/EpubParserUnitTest.kt similarity index 85% rename from app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/epub/EpubParserUnitTest.kt index 1b96604..4303833 100644 --- a/app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epub/EpubParserUnitTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.epub +package org.dueattendant149.bookreader.epub import android.content.Context import io.mockk.every @@ -104,6 +104,30 @@ class EpubParserUnitTest { assertTrue(extractionDir.list().isNullOrEmpty()) } + @Test + fun `createEpubBook uses spine toc id when manifest contains volume ncx files first`() = runTest { + val cacheDir = temp.newFolder("cache-merged-toc") + val extractionDir = temp.newFolder("extract-merged-toc") + val parser = EpubParser(contextWithCache(cacheDir)) + + val book = parser.createEpubBook( + inputStream = ByteArrayInputStream(mergedVolumeTocEpubBytes()), + bookId = "book-id", + shouldUseToc = true, + originalBookNameHint = "merged.epub", + parseContent = true, + extractionDirOverride = extractionDir + ) + + assertEquals( + listOf("Volume 1", "Chapter 1", "Volume 2", "Chapter 2"), + book.tableOfContents.map { it.label } + ) + assertEquals(listOf(0, 1, 0, 1), book.tableOfContents.map { it.depth }) + assertEquals("Volume 2", book.chapters[2].title) + assertEquals("Chapter 2", book.chapters[3].title) + } + @Test fun `metadata only extraction streams images to disk without retaining image bytes`() { val cacheDir = temp.newFolder("cache-metadata-stream") @@ -449,6 +473,50 @@ class EpubParserUnitTest { "OEBPS/images/unlisted.png" to "not-real-image" ) + private fun mergedVolumeTocEpubBytes(): ByteArray = zipBytes( + "META-INF/container.xml" to """ + + """.trimIndent(), + "OEBPS/content.opf" to """ + + Merged Volumes + + + + + + + + + + + + + + + + """.trimIndent(), + "OEBPS/1/toc.ncx" to """ + + Volume 1 + + """.trimIndent(), + "OEBPS/toc.ncx" to """ + + Volume 1 + Chapter 1 + + Volume 2 + Chapter 2 + + + """.trimIndent(), + "OEBPS/1/title.xhtml" to "

HTML Volume 1

Volume one.

", + "OEBPS/1/chapter1.xhtml" to "

HTML Chapter 1

Chapter one.

", + "OEBPS/2/title.xhtml" to "

HTML Volume 2

Volume two.

", + "OEBPS/2/chapter1.xhtml" to "

HTML Chapter 2

Chapter two.

" + ) + private fun minimalEpubBytesWithoutOptionalMetadata(): ByteArray = zipBytes( "META-INF/container.xml" to """ diff --git a/app/src/test/java/com/aryan/reader/epub/ImportedFileCacheTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epub/ImportedFileCacheTest.kt similarity index 99% rename from app/src/test/java/com/aryan/reader/epub/ImportedFileCacheTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/epub/ImportedFileCacheTest.kt index b1c40c1..466ffa2 100644 --- a/app/src/test/java/com/aryan/reader/epub/ImportedFileCacheTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epub/ImportedFileCacheTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.epub +package org.dueattendant149.bookreader.epub import android.content.Context import io.mockk.every diff --git a/app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epub/SingleFileImporterTest.kt similarity index 79% rename from app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/epub/SingleFileImporterTest.kt index 7a03a90..e340698 100644 --- a/app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epub/SingleFileImporterTest.kt @@ -1,7 +1,7 @@ -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 io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -56,7 +56,11 @@ class SingleFileImporterTest { assertEquals("Part 1", book.chapters.single().title) assertTrue(book.chapters.single().plainTextContent.contains("First continues")) assertTrue(File(book.extractionBasePath, "part_1.html").readText().contains("First <line>")) - assertTrue(File(book.extractionBasePath, "book_metadata.json").isFile) + val metadata = File(book.extractionBasePath, "book_metadata.json") + assertTrue(metadata.isFile) + val metadataText = metadata.readText() + assertFalse(metadataText.contains("First continues")) + assertTrue(metadataText.contains("plainTextLength")) } @Test @@ -79,8 +83,29 @@ class SingleFileImporterTest { ) assertEquals(first.title, second.title) - assertEquals(first.chapters.single().plainTextContent, second.chapters.single().plainTextContent) - assertTrue(second.chapters.single().plainTextContent.contains("Cached content")) + assertEquals(first.chapters.single().plainTextLength, second.chapters.single().plainTextLength) + assertEquals("", second.chapters.single().plainTextContent) + assertTrue(File(second.extractionBasePath, second.chapters.single().htmlFilePath).readText().contains("Cached content")) + } + + @Test + fun `plain text import ignores oversized legacy cached metadata before reading it`() = runTest { + val cache = temp.newFolder("txt-cache-oversized") + val context = contextWithCache(cache) + val bookId = "oversized-cache-book" + val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId) + File(extractionDir, "book_metadata.json").writeText("x".repeat((2L * 1024L * 1024L + 1L).toInt())) + val importer = SingleFileImporter(context) + + val book = importer.importSingleFile( + inputStream = ByteArrayInputStream("Fresh content after oversized cache".toByteArray()), + type = FileType.TXT, + originalBookNameHint = "Fresh.txt", + bookId = bookId + ) + + assertEquals("Fresh", book.title) + assertTrue(book.chapters.single().plainTextContent.contains("Fresh content")) } @Test diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/ChapterWebViewHighlightJsonTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/ChapterWebViewHighlightJsonTest.kt new file mode 100644 index 0000000..c9e36e2 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/ChapterWebViewHighlightJsonTest.kt @@ -0,0 +1,38 @@ +package org.dueattendant149.bookreader.epubreader + +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.json.JSONArray +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChapterWebViewHighlightJsonTest { + + @Test + fun `webview highlight json keeps shared locator offsets`() { + val highlight = UserHighlight( + id = "highlight-1", + cfi = "desktop:6:120:145", + text = "synced desktop text", + color = HighlightColor.GREEN, + chapterIndex = 6, + locator = ReaderLocator( + chapterIndex = 6, + pageIndex = 2, + startOffset = 120, + endOffset = 145, + textQuote = "synced desktop text", + cfi = "desktop:6:120:145" + ) + ) + + val obj = JSONArray(highlightsJsonForWebView(listOf(highlight))).getJSONObject(0) + val locator = obj.getJSONObject("locator") + + assertEquals("desktop:6:120:145", obj.getString("cfi")) + assertEquals("user-highlight-green", obj.getString("cssClass")) + assertEquals(6, locator.getInt("chapterIndex")) + assertEquals(120, locator.getInt("startOffset")) + assertEquals(145, locator.getInt("endOffset")) + assertEquals("synced desktop text", locator.getString("textQuote")) + } +} diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderBridgeAndControlsTest.kt similarity index 93% rename from app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderBridgeAndControlsTest.kt index 833efee..616f808 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderBridgeAndControlsTest.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.epubreader +package org.dueattendant149.bookreader.epubreader import android.webkit.WebView -import com.aryan.reader.RenderMode +import org.dueattendant149.bookreader.RenderMode import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.CompletableDeferred @@ -257,7 +257,8 @@ class EpubReaderBridgeAndControlsTest { val sections = epubOverflowMenuSections( hiddenTools = setOf( ReaderTool.TTS_SETTINGS.name, - ReaderTool.TTS_REPLACEMENTS.name + ReaderTool.TTS_REPLACEMENTS.name, + ReaderTool.BOOK_REPLACEMENTS.name ), hasHiddenToolbarTools = false, hasToggleReflow = false, @@ -267,6 +268,21 @@ class EpubReaderBridgeAndControlsTest { assertEquals(EpubOverflowMenuSection.AUTO_SCROLL, sections.last()) assertTrue(EpubOverflowMenuSection.TTS_SETTINGS !in sections) + assertTrue(EpubOverflowMenuSection.BOOK_REPLACEMENTS !in sections) + } + + @Test + fun `epub overflow sections expose book replacements when visible`() { + val sections = epubOverflowMenuSections( + hiddenTools = emptySet(), + hasHiddenToolbarTools = false, + hasToggleReflow = false, + hasDeleteReflow = false, + hasFileInfo = false + ) + + assertTrue(EpubOverflowMenuSection.BOOK_REPLACEMENTS in sections) + assertTrue(sections.indexOf(EpubOverflowMenuSection.BOOK_REPLACEMENTS) < sections.indexOf(EpubOverflowMenuSection.TTS_SETTINGS)) } @Test diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderContentTest.kt similarity index 95% rename from app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderContentTest.kt index c44196c..1c4d602 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderContentTest.kt @@ -1,12 +1,12 @@ -package com.aryan.reader.epubreader +package org.dueattendant149.bookreader.epubreader import android.content.Context -import com.aryan.reader.R -import com.aryan.reader.epub.EpubBook -import com.aryan.reader.epub.EpubChapter -import com.aryan.reader.epub.hasReadableExtractedContent -import com.aryan.reader.paginatedreader.Locator -import com.aryan.reader.paginatedreader.LocatorConverter +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.epub.EpubBook +import org.dueattendant149.bookreader.epub.EpubChapter +import org.dueattendant149.bookreader.epub.hasReadableExtractedContent +import org.dueattendant149.bookreader.paginatedreader.Locator +import org.dueattendant149.bookreader.paginatedreader.LocatorConverter import io.mockk.coEvery import io.mockk.every import io.mockk.mockk diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderImagesTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderImagesTest.kt similarity index 95% rename from app/src/test/java/com/aryan/reader/epubreader/EpubReaderImagesTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderImagesTest.kt index 2830ee4..55b1086 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderImagesTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderImagesTest.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.epubreader +package org.dueattendant149.bookreader.epubreader -import com.aryan.reader.epub.EpubBook -import com.aryan.reader.epub.EpubChapter +import org.dueattendant149.bookreader.epub.EpubBook +import org.dueattendant149.bookreader.epub.EpubChapter import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Rule diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt similarity index 98% rename from app/src/test/java/com/aryan/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt index 60bfc0f..95f43fa 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt @@ -1,10 +1,10 @@ -package com.aryan.reader.epubreader +package org.dueattendant149.bookreader.epubreader import android.content.Context import android.content.SharedPreferences import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import com.aryan.reader.epub.EpubChapter +import org.dueattendant149.bookreader.epub.EpubChapter import io.mockk.every import io.mockk.mockk import org.json.JSONArray @@ -40,6 +40,7 @@ class EpubReaderPreferencesAndAnnotationsTest { assertEquals(ReaderFont.ORIGINAL, format.font) assertEquals(ReaderTextAlign.DEFAULT, format.textAlign) assertNull(format.customPath) + assertFalse(loadNativeVerticalRenderer(context)) } @Test @@ -136,6 +137,7 @@ class EpubReaderPreferencesAndAnnotationsTest { saveVolumeScrollSetting(context, true) saveRemoveEdgePadding(context, true) saveFormatIsLocal(context, "book", true) + saveNativeVerticalRenderer(context, true) assertEquals(1.35f, loadTtsSpeechRate(context), 0.0001f) assertEquals(0.85f, loadTtsPitch(context), 0.0001f) @@ -149,6 +151,7 @@ class EpubReaderPreferencesAndAnnotationsTest { assertTrue(loadVolumeScrollSetting(context)) assertTrue(loadRemoveEdgePadding(context)) assertTrue(loadFormatIsLocal(context, "book")) + assertTrue(loadNativeVerticalRenderer(context)) assertEquals(0f, loadHorizontalMargin(context), 0.0001f) } diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderSearchTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderSearchTest.kt similarity index 90% rename from app/src/test/java/com/aryan/reader/epubreader/EpubReaderSearchTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderSearchTest.kt index a7b825a..099f89e 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderSearchTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderSearchTest.kt @@ -1,13 +1,13 @@ -package com.aryan.reader.epubreader +package org.dueattendant149.bookreader.epubreader import androidx.compose.ui.text.buildAnnotatedString -import com.aryan.reader.RenderMode -import com.aryan.reader.SearchResult -import com.aryan.reader.SearchState -import com.aryan.reader.epub.EpubBook -import com.aryan.reader.epub.EpubChapter -import com.aryan.reader.paginatedreader.IPaginator -import com.aryan.reader.paginatedreader.Page +import org.dueattendant149.bookreader.RenderMode +import org.dueattendant149.bookreader.SearchResult +import org.dueattendant149.bookreader.SearchState +import org.dueattendant149.bookreader.epub.EpubBook +import org.dueattendant149.bookreader.epub.EpubChapter +import org.dueattendant149.bookreader.paginatedreader.IPaginator +import org.dueattendant149.bookreader.paginatedreader.Page import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.CoroutineScope @@ -70,6 +70,24 @@ class EpubReaderSearchTest { assertEquals("Chunky", result.locationTitle) } + @Test + fun `search scans oversized text nodes in bounded windows`() = runTest { + val root = temp.newFolder("bounded-window") + val filler = "alpha ".repeat(7_000) + writeChapter( + root, + "chapter.xhtml", + "

${filler}Needle ${filler}pineedle ${filler}Needle

" + ) + val book = epubBook(root, listOf(chapter("ch1", "Large", "chapter.xhtml"))) + + val results = createEpubSearcher(book)("needle") + + assertEquals(2, results.size) + assertEquals(listOf(0, 1), results.map { it.occurrenceIndexInLocation }) + assertTrue(results.all { it.snippet.text.contains("Needle", ignoreCase = true) }) + } + @Test fun `search currently requires only a word start and highlights the matched substring`() = runTest { val root = temp.newFolder("word-start") diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderTtsHighlightAssetTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderTtsHighlightAssetTest.kt new file mode 100644 index 0000000..4ad2ab2 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderTtsHighlightAssetTest.kt @@ -0,0 +1,29 @@ +package org.dueattendant149.bookreader.epubreader + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class EpubReaderTtsHighlightAssetTest { + + @Test + fun `tts highlight is constrained to one readable block and does not inherit spacing`() { + val js = epubReaderAsset().readText() + + assertTrue(js.contains("const TTS_HIGHLIGHT_BLOCK_SELECTOR")) + assertTrue(js.contains("getTtsHighlightBlock(baseNode)")) + assertTrue(js.contains("document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT")) + assertTrue(js.contains("text-align-last: auto !important;")) + assertTrue(js.contains("letter-spacing: normal !important;")) + assertTrue(js.contains("word-spacing: normal !important;")) + } + + private fun epubReaderAsset(): File { + val candidates = listOf( + File("src/main/assets/epub_reader.js"), + File("app/src/main/assets/epub_reader.js") + ) + return candidates.firstOrNull { it.isFile } + ?: error("Unable to locate epub_reader.js from ${File(".").absolutePath}") + } +} diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderVisualOptionsStateTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderVisualOptionsStateTest.kt new file mode 100644 index 0000000..4b6823e --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderVisualOptionsStateTest.kt @@ -0,0 +1,47 @@ +package org.dueattendant149.bookreader.epubreader + +import org.dueattendant149.bookreader.shared.PageInfoMode +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class EpubReaderVisualOptionsStateTest { + + @Test + fun `page info always show remains visible independent of reader chrome`() { + assertTrue( + shouldShowEpubPageInfoBar( + pageInfoMode = PageInfoMode.DEFAULT, + showReaderChrome = false + ) + ) + assertTrue( + shouldShowEpubPageInfoBar( + pageInfoMode = PageInfoMode.DEFAULT, + showReaderChrome = true + ) + ) + } + + @Test + fun `page info sync follows reader chrome and hidden never shows`() { + assertFalse( + shouldShowEpubPageInfoBar( + pageInfoMode = PageInfoMode.SYNC, + showReaderChrome = false + ) + ) + assertTrue( + shouldShowEpubPageInfoBar( + pageInfoMode = PageInfoMode.SYNC, + showReaderChrome = true + ) + ) + assertFalse( + shouldShowEpubPageInfoBar( + pageInfoMode = PageInfoMode.HIDDEN, + showReaderChrome = true + ) + ) + } +} diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubTtsChunkMatchingTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubTtsChunkMatchingTest.kt new file mode 100644 index 0000000..63c6371 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/EpubTtsChunkMatchingTest.kt @@ -0,0 +1,106 @@ +package org.dueattendant149.bookreader.epubreader + +import org.dueattendant149.bookreader.paginatedreader.TtsChunk +import org.junit.Assert.assertEquals +import org.junit.Test + +class EpubTtsChunkMatchingTest { + @Test + fun `chunk start matching tolerates child cfi path and whitespace text differences`() { + val chunks = listOf( + TtsChunk( + text = "The first paragraph begins here.", + sourceCfi = "/4/22/2", + startOffsetInSource = 0 + ), + TtsChunk( + text = "The second paragraph begins here.", + sourceCfi = "/4/24/2", + startOffsetInSource = 0 + ) + ) + val extracted = TtsChunk( + text = "The second paragraph begins here.", + sourceCfi = "/4/24", + startOffsetInSource = 0 + ) + + assertEquals(1, findTtsChunkStartIndex(chunks, extracted)) + } + + @Test + fun `resume matching falls back to current chunk index before leaving chapter`() { + val chunks = listOf( + TtsChunk("One", "/4/2", 0), + TtsChunk("Two", "/4/4", 0), + TtsChunk("Three", "/4/6", 0) + ) + + assertEquals( + 1, + findTtsChunkResumeIndex( + chunks = chunks, + sourceCfi = "/mismatched", + startOffsetInSource = 0, + currentText = "unknown", + currentChunkIndexFallback = 1 + ) + ) + } + + @Test + fun `chunk start matching accepts target offset inside matching source block`() { + val chunks = listOf( + TtsChunk("Alpha beta gamma", "/4/8/2", 10), + TtsChunk("Delta epsilon", "/4/10/2", 0) + ) + val nativeVerticalTarget = TtsChunk( + text = "", + sourceCfi = "/4/8", + startOffsetInSource = 16 + ) + + assertEquals(0, findTtsChunkStartIndex(chunks, nativeVerticalTarget)) + } + + @Test + fun `vertical continuation falls back to loaded chunk boundary when resume match is unavailable`() { + val chunks = listOf( + TtsChunk("Loaded one", "/4/2", 0), + TtsChunk("Loaded two", "/4/4", 0), + TtsChunk("Remaining three", "/4/6", 0), + TtsChunk("Remaining four", "/4/8", 0) + ) + + assertEquals( + 2, + resolveTtsContinuationStartIndex( + chunks = chunks, + loadedChunkCount = 2, + sourceCfi = "/does/not/match", + startOffsetInSource = 0, + currentText = "not present" + ) + ) + } + + @Test + fun `vertical continuation starts after matched spoken chunk`() { + val chunks = listOf( + TtsChunk("Loaded one", "/4/2", 0), + TtsChunk("Loaded two", "/4/4", 0), + TtsChunk("Remaining three", "/4/6", 0) + ) + + assertEquals( + 2, + resolveTtsContinuationStartIndex( + chunks = chunks, + loadedChunkCount = 1, + sourceCfi = "/4/4", + startOffsetInSource = 0, + currentText = "Loaded two" + ) + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/epubreader/TestSharedPreferences.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/TestSharedPreferences.kt similarity index 98% rename from app/src/test/java/com/aryan/reader/epubreader/TestSharedPreferences.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/TestSharedPreferences.kt index 660c9ad..4b12af3 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/TestSharedPreferences.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/epubreader/TestSharedPreferences.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.epubreader +package org.dueattendant149.bookreader.epubreader import android.content.SharedPreferences diff --git a/app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/opds/OpdsParserTest.kt similarity index 98% rename from app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/opds/OpdsParserTest.kt index 6d665f5..f47d0c9 100644 --- a/app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/opds/OpdsParserTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.opds +package org.dueattendant149.bookreader.opds import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -193,7 +193,8 @@ class OpdsParserTest { "application/vnd.openxmlformats-officedocument.presentationml.presentation" ), OpdsAcquisition("epub", "application/epub+zip"), - OpdsAcquisition("unknown", "application/octet-stream") + OpdsAcquisition("unknown", "application/octet-stream"), + OpdsAcquisition("cbt", "application/vnd.comicbook+tar") ) val entry = OpdsEntry( id = "id", @@ -208,6 +209,7 @@ class OpdsParserTest { assertEquals("PPTX", acquisitions[2].formatName) assertEquals("TXT", acquisitions[0].formatName) assertEquals("OCTET-STREAM", acquisitions[4].formatName) + assertEquals("CBT", acquisitions[5].formatName) assertEquals(acquisitions[3], entry.bestAcquisition) } } diff --git a/app/src/test/java/com/aryan/reader/opds/OpdsRepositoryTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/opds/OpdsRepositoryTest.kt similarity index 88% rename from app/src/test/java/com/aryan/reader/opds/OpdsRepositoryTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/opds/OpdsRepositoryTest.kt index b081ac5..9cfb78d 100644 --- a/app/src/test/java/com/aryan/reader/opds/OpdsRepositoryTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/opds/OpdsRepositoryTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.opds +package org.dueattendant149.bookreader.opds import okhttp3.Protocol import okhttp3.Request @@ -102,6 +102,24 @@ class OpdsRepositoryTest { assertNotNull(Regex("""response="[a-f0-9]{32}"""").find(header)) } + @Test + fun `digest authenticator selects auth from qop list`() { + val request = Request.Builder() + .url("https://example.org/catalog/feed") + .build() + val response = responseFor( + request, + "Digest realm=\"realm\", nonce=\"abc\", qop=\"auth,auth-int\"" + ) + + val authenticated = OpdsRepository.OpdsAuthenticator("user", "pass") + .authenticate(null, response) + val header = authenticated?.header("Authorization").orEmpty() + + assertTrue(header.contains("qop=auth")) + assertTrue(!header.contains("auth,auth-int")) + } + @Test fun `authenticator ignores unsupported challenge`() { val request = Request.Builder().url("https://example.org/feed").build() diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/AndroidEpubKeyCommandsTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/AndroidEpubKeyCommandsTest.kt new file mode 100644 index 0000000..11d0db1 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/AndroidEpubKeyCommandsTest.kt @@ -0,0 +1,75 @@ +package org.dueattendant149.bookreader.paginatedreader + +import android.view.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AndroidEpubKeyCommandsTest { + @Test + fun `left and right map to page changes`() { + assertEquals( + AndroidEpubKeyCommand.PREVIOUS_PAGE, + androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_LEFT) + ) + assertEquals( + AndroidEpubKeyCommand.NEXT_PAGE, + androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_RIGHT) + ) + } + + @Test + fun `left and right respect right to left pagination`() { + assertEquals( + AndroidEpubKeyCommand.NEXT_PAGE, + androidEpubKeyCommandOrNull( + KeyEvent.KEYCODE_DPAD_LEFT, + rightToLeftPagination = true + ) + ) + assertEquals( + AndroidEpubKeyCommand.PREVIOUS_PAGE, + androidEpubKeyCommandOrNull( + KeyEvent.KEYCODE_DPAD_RIGHT, + rightToLeftPagination = true + ) + ) + } + + @Test + fun `up and down map to vertical scroll`() { + assertEquals( + AndroidEpubKeyCommand.SCROLL_UP, + androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_UP) + ) + assertEquals( + AndroidEpubKeyCommand.SCROLL_DOWN, + androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_DOWN) + ) + } + + @Test + fun `page home and end keys map to reader navigation`() { + assertEquals( + AndroidEpubKeyCommand.PREVIOUS_PAGE, + androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_PAGE_UP) + ) + assertEquals( + AndroidEpubKeyCommand.NEXT_PAGE, + androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_PAGE_DOWN) + ) + assertEquals( + AndroidEpubKeyCommand.FIRST_PAGE, + androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_MOVE_HOME) + ) + assertEquals( + AndroidEpubKeyCommand.LAST_PAGE, + androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_MOVE_END) + ) + } + + @Test + fun `ctrl shortcuts are left for reader chrome and search handling`() { + assertNull(androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_RIGHT, isCtrlPressed = true)) + } +} diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/AndroidHtmlResourceResolverTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/AndroidHtmlResourceResolverTest.kt new file mode 100644 index 0000000..9ee21a0 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/AndroidHtmlResourceResolverTest.kt @@ -0,0 +1,45 @@ +package org.dueattendant149.bookreader.paginatedreader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class AndroidHtmlResourceResolverTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `resolvePath returns files inside extraction root`() { + val root = temp.newFolder("book") + val image = File(root, "OEBPS/images/picture.png").apply { + parentFile?.mkdirs() + writeText("image") + } + + assertEquals( + image.canonicalPath, + AndroidHtmlResourceResolver.resolvePath( + chapterAbsPath = "OEBPS/chapter.xhtml", + extractionBasePath = root.absolutePath, + src = "images/picture.png" + ) + ) + } + + @Test + fun `resolvePath rejects paths that escape extraction root`() { + val root = temp.newFolder("book") + File(root.parentFile, "outside.png").writeText("outside") + + assertNull( + AndroidHtmlResourceResolver.resolvePath( + chapterAbsPath = "OEBPS/chapter.xhtml", + extractionBasePath = root.absolutePath, + src = "../../outside.png" + ) + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/CfiUtilsTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/CfiUtilsTest.kt similarity index 97% rename from app/src/test/java/com/aryan/reader/paginatedreader/CfiUtilsTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/CfiUtilsTest.kt index 3e5290c..9baedfa 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/CfiUtilsTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/CfiUtilsTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/ContentStylerTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/ContentStylerTest.kt similarity index 70% rename from app/src/test/java/com/aryan/reader/paginatedreader/ContentStylerTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/ContentStylerTest.kt index fc01dcc..7adfdc9 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/ContentStylerTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/ContentStylerTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified @@ -39,6 +39,7 @@ class ContentStylerTest { ).single() as ParagraphBlock assertEquals(TextAlign.Justify, block.textAlign) + assertEquals(TextAlign.Justify, block.content.paragraphStyles.first().item.textAlign) assertEquals("p1", block.elementId) assertEquals("/4/2", block.cfi) assertEquals(7, block.startCharOffsetInSource) @@ -46,6 +47,22 @@ class ContentStylerTest { assertEquals("Aligned text", block.content.text) } + @Test + fun `paragraph styling downgrades css justify unless user explicitly forces alignment`() { + val block = styler(userTextAlign = null).style( + listOf( + paragraph( + text = "Justified text", + blockIndex = 20, + style = CssStyle(paragraphStyle = androidx.compose.ui.text.ParagraphStyle(textAlign = TextAlign.Justify)) + ) + ) + ).single() as ParagraphBlock + + assertEquals(TextAlign.Left, block.textAlign) + assertEquals(TextAlign.Left, block.content.paragraphStyles.first().item.textAlign) + } + @Test fun `floating image is grouped with following paragraphs until clear`() { val blocks = styler().style( @@ -136,6 +153,52 @@ class ContentStylerTest { }) } + @Test + fun `link styling is applied after nested epub span styling`() { + val label = "Nested link" + val paragraph = SemanticParagraph( + text = label, + spans = listOf( + SemanticSpan( + start = 0, + end = label.length, + style = CssStyle(), + linkHref = "https://example.org", + tag = "a" + ), + SemanticSpan( + start = 0, + end = label.length, + style = CssStyle( + spanStyle = SpanStyle( + color = Color.Red, + background = Color.Yellow, + textDecoration = TextDecoration.None + ) + ), + tag = "span" + ) + ), + style = CssStyle(), + elementId = null, + cfi = "/4/2", + blockIndex = 21 + ) + + val styled = styler().style(listOf(paragraph)).single() as ParagraphBlock + val finalCoveringStyle = styled.content.spanStyles + .filter { it.start <= 0 && it.end >= label.length } + .last() + .item + + assertEquals("https://example.org", styled.content.getStringAnnotations("URL", 0, label.length).single().item) + assertTrue(finalCoveringStyle.color.isSpecified) + assertTrue(finalCoveringStyle.color != Color.Red) + assertTrue(finalCoveringStyle.background.isSpecified) + assertTrue(finalCoveringStyle.background != Color.Yellow) + assertTrue(finalCoveringStyle.textDecoration?.contains(TextDecoration.Underline) == true) + } + @Test fun `runtime theme reapplies visible link style for cached paginated text`() { val linkText = "Cached link" @@ -166,6 +229,30 @@ class ContentStylerTest { range.item.color != Color(0xFFE0E0E0) && range.item.background.isSpecified && range.item.textDecoration?.contains(TextDecoration.Underline) == true + }) + } + + @Test + fun `block anchor from html is styled and annotated as paginated link`() { + val semanticBlocks = htmlToSemanticBlocks( + html = """

Continue reading

""", + cssRules = OptimizedCssRules(), + textStyle = TextStyle(fontSize = 16.sp, color = Color.Black), + chapterAbsPath = "OEBPS/chapter1.xhtml", + extractionBasePath = "", + density = Density(1f), + fontFamilyMap = emptyMap(), + constraints = androidx.compose.ui.unit.Constraints(maxWidth = 400, maxHeight = 800) + ) + + val paragraph = styler().style(semanticBlocks).single() as ParagraphBlock + + assertEquals("chapter2.xhtml#start", paragraph.content.getStringAnnotations("URL", 0, paragraph.content.length).single().item) + assertTrue(paragraph.content.spanStyles.any { range -> + range.start == 0 && + range.end == paragraph.content.length && + range.item.background.isSpecified && + range.item.textDecoration?.contains(TextDecoration.Underline) == true }) } diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/CssParserThemeModeTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/CssParserThemeModeTest.kt similarity index 97% rename from app/src/test/java/com/aryan/reader/paginatedreader/CssParserThemeModeTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/CssParserThemeModeTest.kt index 34bf957..9d53fb8 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/CssParserThemeModeTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/CssParserThemeModeTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Constraints diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/EpubFontFaceSiblingsTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/EpubFontFaceSiblingsTest.kt new file mode 100644 index 0000000..b2b4c58 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/EpubFontFaceSiblingsTest.kt @@ -0,0 +1,128 @@ +package org.dueattendant149.bookreader.paginatedreader + +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class EpubFontFaceSiblingsTest { + + @Test + fun expandFontFacesWithSiblings_addsItalicAndBoldItalicVariants() { + val root = createTempRoot() + val fontsDir = File(root, "OEBPS/fonts").apply { mkdirs() } + File(fontsDir, "Literata-Regular.ttf").writeText("regular") + File(fontsDir, "Literata-Italic.ttf").writeText("italic") + File(fontsDir, "Literata-BoldItalic.ttf").writeText("bold italic") + File(fontsDir, "Other-Italic.ttf").writeText("other") + + val expanded = expandFontFacesWithSiblings( + fontFaces = listOf( + FontFaceInfo( + fontFamily = "literata", + src = "OEBPS/fonts/Literata-Regular.ttf", + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal + ) + ), + extractionPath = root.absolutePath + ) + + assertEquals(3, expanded.size) + assertTrue(expanded.any { it.src == "OEBPS/fonts/Literata-Italic.ttf" && it.fontStyle == FontStyle.Italic }) + assertTrue( + expanded.any { + it.src == "OEBPS/fonts/Literata-BoldItalic.ttf" && + it.fontStyle == FontStyle.Italic && + it.fontWeight == FontWeight.Bold + } + ) + assertTrue(expanded.none { it.src.contains("Other") }) + } + + @Test + fun buildEpubFontFaceCss_emitsVariantDescriptorsForSiblings() { + val root = createTempRoot() + val fontsDir = File(root, "fonts").apply { mkdirs() } + File(fontsDir, "LoraRegular.ttf").writeText("regular") + File(fontsDir, "LoraBoldItalic.ttf").writeText("bold italic") + + val css = buildEpubFontFaceCss( + fontFaces = listOf( + FontFaceInfo( + fontFamily = "lora", + src = "fonts/LoraRegular.ttf", + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal + ) + ), + extractionPath = root.absolutePath + ) + + assertTrue(css.contains("font-family: 'lora'")) + assertTrue(css.contains("font-weight: 700")) + assertTrue(css.contains("font-style: italic")) + assertTrue(css.contains("LoraBoldItalic.ttf")) + } + + @Test + fun expandFontFacesWithSiblings_groupsVariableRegularAndItalicFiles() { + val root = createTempRoot() + val fontsDir = File(root, "fonts").apply { mkdirs() } + File(fontsDir, "Pliant-VariableFont_wdth,wght.ttf").writeText("regular variable") + File(fontsDir, "Pliant-Italic-VariableFont_wdth,wght.ttf").writeText("italic variable") + + val expanded = expandFontFacesWithSiblings( + fontFaces = listOf( + FontFaceInfo( + fontFamily = "pliant", + src = "fonts/Pliant-VariableFont_wdth,wght.ttf", + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal + ) + ), + extractionPath = root.absolutePath + ) + + assertEquals(2, expanded.size) + assertTrue( + expanded.any { + it.src == "fonts/Pliant-Italic-VariableFont_wdth,wght.ttf" && + it.fontStyle == FontStyle.Italic && + it.fontWeight == FontWeight.Normal + } + ) + } + + @Test + fun buildEpubFontFaceCss_usesWeightRangeForVariableWeightFonts() { + val root = createTempRoot() + val fontsDir = File(root, "fonts").apply { mkdirs() } + File(fontsDir, "Pliant-VariableFont_wdth,wght.ttf").writeText("regular variable") + File(fontsDir, "Pliant-Italic-VariableFont_wdth,wght.ttf").writeText("italic variable") + + val css = buildEpubFontFaceCss( + fontFaces = listOf( + FontFaceInfo( + fontFamily = "pliant", + src = "fonts/Pliant-VariableFont_wdth,wght.ttf", + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal + ) + ), + extractionPath = root.absolutePath + ) + + assertTrue(css.contains("font-weight: 100 900")) + assertTrue(css.contains("font-style: italic")) + assertTrue(css.contains("Pliant-Italic-VariableFont_wdth,wght.ttf")) + } + + private fun createTempRoot(): File { + return kotlin.io.path.createTempDirectory("epub-font-siblings").toFile().also { + it.deleteOnExit() + } + } +} diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/LocatorConverterTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/LocatorConverterTest.kt similarity index 75% rename from app/src/test/java/com/aryan/reader/paginatedreader/LocatorConverterTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/LocatorConverterTest.kt index 2c5b05d..0be8a66 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/LocatorConverterTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/LocatorConverterTest.kt @@ -1,18 +1,18 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import android.content.Context -import com.aryan.reader.epub.EpubBook -import com.aryan.reader.epub.EpubChapter -import com.aryan.reader.paginatedreader.data.AnchorIndexEntry -import com.aryan.reader.paginatedreader.data.BookCacheDao -import com.aryan.reader.paginatedreader.data.ConfigurationCache -import com.aryan.reader.paginatedreader.data.PageCacheChunk -import com.aryan.reader.paginatedreader.data.PageCacheMetadata -import com.aryan.reader.paginatedreader.data.PageIndexEntry -import com.aryan.reader.paginatedreader.data.ProcessedBook -import com.aryan.reader.paginatedreader.data.ProcessedChapter -import com.aryan.reader.paginatedreader.data.ProcessedChapterChunk -import com.aryan.reader.paginatedreader.data.ProcessedChapterMetadata +import org.dueattendant149.bookreader.epub.EpubBook +import org.dueattendant149.bookreader.epub.EpubChapter +import org.dueattendant149.bookreader.paginatedreader.data.AnchorIndexEntry +import org.dueattendant149.bookreader.paginatedreader.data.BookCacheDao +import org.dueattendant149.bookreader.paginatedreader.data.ConfigurationCache +import org.dueattendant149.bookreader.paginatedreader.data.PageCacheChunk +import org.dueattendant149.bookreader.paginatedreader.data.PageCacheMetadata +import org.dueattendant149.bookreader.paginatedreader.data.PageIndexEntry +import org.dueattendant149.bookreader.paginatedreader.data.ProcessedBook +import org.dueattendant149.bookreader.paginatedreader.data.ProcessedChapter +import org.dueattendant149.bookreader.paginatedreader.data.ProcessedChapterChunk +import org.dueattendant149.bookreader.paginatedreader.data.ProcessedChapterMetadata import io.mockk.mockk import kotlinx.coroutines.test.runTest import kotlinx.serialization.ExperimentalSerializationApi @@ -22,6 +22,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File +import java.nio.file.Files @OptIn(ExperimentalSerializationApi::class) class LocatorConverterTest { @@ -41,6 +43,31 @@ class LocatorConverterTest { assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 13), locator) } + @Test + fun `cfi local offsets become absolute locators and serialize back locally`() = runTest { + val converter = converterFor( + listOf(paragraph("Offset paragraph", blockIndex = 2, cfi = "/4/2/6", offset = 100)) + ) + val book = book() + + val locator = converter.getLocatorFromCfi(book, chapterIndex = 0, cfi = "/4/2/6:7") + val cfi = locator?.let { converter.getCfiFromLocator(book, it) } + + assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 107), locator) + assertEquals("/4/2/6:7", cfi) + } + + @Test + fun `multipart cfi uses first point local offset when resolving locator`() = runTest { + val converter = converterFor( + listOf(paragraph("Offset paragraph", blockIndex = 2, cfi = "/4/2/6", offset = 100)) + ) + + val locator = converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2/6:7|/4/2/6:12") + + assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 107), locator) + } + @Test fun `zero estimate semantic cache remains usable`() = runTest { val converter = converterFor(semanticBlocks(), estimatedPageCount = 0) @@ -170,6 +197,27 @@ class LocatorConverterTest { assertNull(converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2")) } + @Test + fun `large uncached chapter file is skipped instead of parsed on demand`() = runTest { + val tempDir = Files.createTempDirectory("large-locator-chapter").toFile() + try { + File(tempDir, "c1.xhtml").writeText("${"x".repeat(2_200_000)}") + val dao = FakeBookCacheDao(null) + val converter = LocatorConverter(dao, proto, mockk(relaxed = true)) + + val locator = converter.getLocatorFromCfi( + book = book(extractionBasePath = tempDir.absolutePath), + chapterIndex = 0, + cfi = "/4/2" + ) + + assertNull(locator) + assertTrue(dao.insertedChapters.isEmpty()) + } finally { + tempDir.deleteRecursively() + } + } + private fun converterFor(blocks: List, estimatedPageCount: Int = 1): LocatorConverter { val chapter = ProcessedChapter( bookId = "Book", @@ -211,7 +259,7 @@ class LocatorConverterTest { ) } - private fun book(): EpubBook { + private fun book(extractionBasePath: String = ""): EpubBook { return EpubBook( fileName = "book.epub", title = "Book", @@ -228,7 +276,7 @@ class LocatorConverterTest { htmlContent = "" ) ), - extractionBasePath = "" + extractionBasePath = extractionBasePath ) } @@ -236,12 +284,15 @@ class LocatorConverterTest { private val chapter: ProcessedChapter? ) : BookCacheDao() { val requestedBookIds = mutableListOf() + val insertedChapters = mutableListOf() - override suspend fun getProcessedChapter(bookId: String, chapterIndex: Int): ProcessedChapter? { + override suspend fun getProcessedChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int?): ProcessedChapter? { requestedBookIds += bookId return chapter } - override suspend fun insertProcessedChapters(chapters: List) = Unit + override suspend fun insertProcessedChapters(chapters: List) { + insertedChapters += chapters + } override suspend fun getProcessedBook(bookId: String): ProcessedBook? = null override suspend fun insertProcessedBook(book: ProcessedBook) = Unit @@ -260,11 +311,13 @@ class LocatorConverterTest { override suspend fun getPageIndexEntries(bookId: String, configHash: Int, chapterIndex: Int): List = emptyList() override suspend fun cleanupOldPageCaches(bookId: String) = Unit - protected override suspend fun getChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? = null - protected override suspend fun getChapterChunks(bookId: String, chapterIndex: Int): List = emptyList() + protected override suspend fun getChapterMetadata(bookId: String, chapterIndex: Int, styleConfigHash: Int): ProcessedChapterMetadata? = null + protected override suspend fun getAnyChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? = null + protected override suspend fun getChapterChunks(bookId: String, chapterIndex: Int, styleConfigHash: Int): List = emptyList() protected override suspend fun insertChapterMetadata(metadata: ProcessedChapterMetadata) = Unit protected override suspend fun insertChapterChunks(chunks: List) = Unit protected override suspend fun deleteChapterMetadataForBook(bookId: String) = Unit + protected override suspend fun deleteChapterChunksForChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int) = Unit protected override suspend fun deleteAllChapterMetadata() = Unit protected override suspend fun deletePageCacheMetadataForBook(bookId: String) = Unit protected override suspend fun deletePageCacheMetadataForChapter(bookId: String, configHash: Int, chapterIndex: Int) = Unit diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/NativeVerticalLocationTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/NativeVerticalLocationTest.kt new file mode 100644 index 0000000..ae354c5 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/NativeVerticalLocationTest.kt @@ -0,0 +1,184 @@ +package org.dueattendant149.bookreader.paginatedreader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeVerticalLocationTest { + + @Test + fun `compat page follows native progress`() { + assertEquals(0, nativeVerticalCompatPageForProgress(0f, 101)) + assertEquals(50, nativeVerticalCompatPageForProgress(50f, 101)) + assertEquals(100, nativeVerticalCompatPageForProgress(100f, 101)) + } + + @Test + fun `progress follows compat page`() { + assertEquals(0f, nativeVerticalProgressForCompatPage(0, 101), 0.001f) + assertEquals(50f, nativeVerticalProgressForCompatPage(50, 101), 0.001f) + assertEquals(100f, nativeVerticalProgressForCompatPage(100, 101), 0.001f) + } + + @Test + fun `progress target skips zero weight chapter gaps`() { + val weights = listOf(0, 100, 300, 600) + + assertEquals(1, nativeVerticalProgressToItemIndex(weights, 0f)) + assertEquals(2, nativeVerticalProgressToItemIndex(weights, 25f)) + assertEquals(3, nativeVerticalProgressToItemIndex(weights, 100f)) + } + + @Test + fun `scroll progress updates within visible item offset`() { + val weights = listOf(100, 300, 600) + + assertEquals( + 25f, + estimateNativeVerticalWeightedScrollProgressPercent( + itemWeights = weights, + firstVisibleItemIndex = 1, + firstVisibleItemScrollOffset = 500, + firstVisibleItemSize = 1000 + ), + 0.001f + ) + assertEquals( + 40f, + estimateNativeVerticalWeightedScrollProgressPercent( + itemWeights = weights, + firstVisibleItemIndex = 1, + firstVisibleItemScrollOffset = 1000, + firstVisibleItemSize = 1000 + ), + 0.001f + ) + } + + @Test + fun `chapter page info uses chapter local locator offset`() { + val pageInfo = nativeVerticalChapterPageInfo( + chapterCharOffset = 500, + chapterLengthChars = 1000, + chapterPageCount = 11, + compatPageIndex = 900, + chapterStartPageIndex = 850 + ) + + assertEquals(6, pageInfo?.currentPage) + assertEquals(11, pageInfo?.totalPages) + } + + @Test + fun `chapter page info falls back to absolute page within chapter`() { + val pageInfo = nativeVerticalChapterPageInfo( + chapterCharOffset = null, + chapterLengthChars = 0, + chapterPageCount = 7, + compatPageIndex = 24, + chapterStartPageIndex = 20 + ) + + assertEquals(5, pageInfo?.currentPage) + assertEquals(7, pageInfo?.totalPages) + } + + @Test + fun `chapter page info follows scroll weight within current chapter`() { + val pageInfo = nativeVerticalChapterPageInfoForScroll( + itemChapterIndices = listOf(0, 0, 1, 1), + itemWeights = listOf(100, 300, 100, 300), + firstVisibleItemIndex = 1, + firstVisibleItemScrollOffset = 500, + firstVisibleItemSize = 1000, + chapterPageCount = 9 + ) + + assertEquals(6, pageInfo?.currentPage) + assertEquals(9, pageInfo?.totalPages) + } + + @Test + fun `native vertical image model decodes svg data uris for coil svg fetcher`() { + val model = nativeVerticalImageModelData( + "data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2010%2010%22%3E%3Ccircle%20r%3D%225%22%2F%3E%3C%2Fsvg%3E" + ) + + assertTrue(model is SvgData) + assertEquals("""""", (model as SvgData).content) + } + + @Test + fun `native vertical svg data uri decoding preserves plus signs`() { + assertEquals( + """""", + nativeVerticalSvgContentFromDataUri( + "data:image/svg+xml,%3Csvg%3E%3Cpath%20d%3D%22M1+2%22%2F%3E%3C%2Fsvg%3E" + ) + ) + } + + @Test + fun `native vertical persistence locator prefers visible text range`() { + val location = NativeVerticalLocation( + locator = Locator(chapterIndex = 2, blockIndex = 10, charOffset = 100), + chapterIndex = 2, + progressPercent = 42f, + compatPageIndex = 20, + compatTotalPages = 100, + firstVisibleItemIndex = 4, + firstVisibleItemScrollOffset = 250, + firstVisibleItemSize = 1000, + isAtStart = false, + isAtEnd = false, + visibleTextRanges = listOf( + NativeVerticalVisibleTextRange( + chapterIndex = 2, + blockIndex = 10, + startCharOffset = 380, + endCharOffset = 520 + ) + ) + ) + + assertEquals(Locator(chapterIndex = 2, blockIndex = 10, charOffset = 380), location.locatorForPersistence()) + } + + @Test + fun `native vertical initial restore does not fallback to compat page when locator exists`() { + assertEquals( + false, + shouldFallbackNativeVerticalInitialScrollToCompatPage( + hasInitialLocator = true, + didLocatorScroll = false + ) + ) + assertEquals( + true, + shouldFallbackNativeVerticalInitialScrollToCompatPage( + hasInitialLocator = false, + didLocatorScroll = false + ) + ) + } + + @Test + fun `native vertical tts follow centers target offset in viewport`() { + assertEquals( + 100f, + nativeVerticalCenteredScrollDelta( + targetOffsetInViewport = 500f, + viewportHeight = 800f + ), + 0.001f + ) + assertEquals( + -200f, + nativeVerticalCenteredScrollDelta( + targetOffsetInViewport = 200f, + viewportHeight = 800f + ), + 0.001f + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/PageCountEstimatorTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/PageCountEstimatorTest.kt similarity index 95% rename from app/src/test/java/com/aryan/reader/paginatedreader/PageCountEstimatorTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/PageCountEstimatorTest.kt index 96a1939..8455ed5 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/PageCountEstimatorTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/PageCountEstimatorTest.kt @@ -1,10 +1,10 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.sp -import com.aryan.reader.epub.EpubChapter +import org.dueattendant149.bookreader.epub.EpubChapter import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedHighlightMappingTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedHighlightMappingTest.kt new file mode 100644 index 0000000..b1ce337 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedHighlightMappingTest.kt @@ -0,0 +1,258 @@ +package org.dueattendant149.bookreader.paginatedreader + +import androidx.compose.ui.text.AnnotatedString +import org.dueattendant149.bookreader.epubreader.HighlightColor +import org.dueattendant149.bookreader.epubreader.UserHighlight +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PaginatedHighlightMappingTest { + + @Test + fun `single cfi highlight does not leak onto later matching block`() { + val block = paragraph( + text = "repeat", + cfi = "/4/4", + startOffset = 20 + ) + val highlight = highlight( + cfi = "/4/2:0", + text = "repeat" + ) + + assertNull(getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `multipart highlight can fill strict intermediate block`() { + val block = paragraph( + text = "middle", + cfi = "/4/4", + startOffset = 20 + ) + val highlight = highlight( + cfi = "/4/2:0|/4/6:10", + text = "start middle end" + ) + + assertEquals(0 until 6, getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `same path split uses cfi offsets as local to block`() { + val block = paragraph( + text = "repeat", + cfi = "/4/2", + startOffset = 20 + ) + val highlight = highlight( + cfi = "/4/2:0|/4/2:6", + text = "repeat" + ) + + assertEquals(0 until 6, getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `same path split block outside stored offsets is ignored`() { + val block = paragraph( + text = "repeat", + cfi = "/4/2", + startOffset = 20 + ) + val highlight = highlight( + cfi = "/4/2:40|/4/2:46", + text = "repeat" + ) + + assertNull(getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `desktop locator highlight maps by source offsets`() { + val block = paragraph( + text = "alpha beta gamma", + cfi = null, + startOffset = 20 + ) + val highlight = highlight( + cfi = "desktop:0:26:30", + text = "beta" + ) + + assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `locator offsets win over cfi offsets for synced highlights`() { + val block = paragraph( + text = "alpha beta gamma", + cfi = "/4/2", + startOffset = 200 + ) + val highlight = highlight( + cfi = "/4/2:6|/4/2:10", + text = "beta", + locator = ReaderLocator( + chapterIndex = 0, + startOffset = 206, + endOffset = 210, + cfi = "/4/2:6|/4/2:10", + textQuote = "beta" + ) + ) + + assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `locator offsets prevent cfi fallback from painting unrelated duplicate block`() { + val block = paragraph( + text = "alpha beta gamma", + cfi = "/4/4", + startOffset = 300 + ) + val highlight = highlight( + cfi = "/4/2:6|/4/2:10", + text = "beta", + locator = ReaderLocator( + chapterIndex = 0, + startOffset = 206, + endOffset = 210, + cfi = "/4/2:6|/4/2:10", + textQuote = "beta" + ) + ) + + assertNull(getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `block local locator offsets do not paint sibling blocks with overlapping local ranges`() { + val highlight = highlight( + cfi = "/4/4/6:124|/4/4/6:248", + text = "selected text", + locator = ReaderLocator( + chapterIndex = 8, + pageIndex = 49, + startOffset = 124, + endOffset = 248, + blockIndex = 1, + charOffset = 124, + textQuote = "selected text", + cfi = "/4/4/6:124|/4/4/6:248" + ) + ) + val selectedBlock = paragraph( + text = "x".repeat(260), + cfi = "/4/4/6", + startOffset = 0, + blockIndex = 1 + ) + val siblingBlock = paragraph( + text = "x".repeat(684), + cfi = "/4/4/8", + startOffset = 0, + blockIndex = 2 + ) + + assertEquals(124 until 248, getHighlightOffsetsInBlock(selectedBlock, highlight)) + assertNull(getHighlightOffsetsInBlock(siblingBlock, highlight)) + } + + @Test + fun `source cfi local offsets map within nonzero source block`() { + val block = paragraph( + text = "alpha beta gamma", + cfi = "/4/2", + startOffset = 200 + ) + val highlight = highlight( + cfi = "/4/2:6|/4/2:10", + text = "beta" + ) + + assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `legacy absolute cfi offsets remain supported for synced highlights`() { + val block = paragraph( + text = "alpha beta gamma", + cfi = "/4/2", + startOffset = 200 + ) + val highlight = highlight( + cfi = "/4/2:206|/4/2:210", + text = "beta" + ) + + assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `paginated page highlights are scoped to page chapter`() { + val chapterFourHighlight = highlight( + cfi = "/4/10:11|/4/12:79", + text = "Original chapter text", + chapterIndex = 4 + ) + val chapterFiveHighlight = highlight( + cfi = "/4/10:11|/4/12:79", + text = "Different chapter text", + chapterIndex = 5 + ) + + assertEquals( + listOf(chapterFiveHighlight), + highlightsForPaginatedPage( + pageChapterIndex = 5, + userHighlights = listOf(chapterFourHighlight, chapterFiveHighlight) + ) + ) + assertEquals( + emptyList(), + highlightsForPaginatedPage( + pageChapterIndex = null, + userHighlights = listOf(chapterFourHighlight) + ) + ) + } + + private fun paragraph( + text: String, + cfi: String?, + startOffset: Int, + blockIndex: Int = startOffset + ): ParagraphBlock { + return ParagraphBlock( + content = AnnotatedString(text), + cfi = cfi, + startCharOffsetInSource = startOffset, + endCharOffsetInSource = startOffset + text.length, + blockIndex = blockIndex + ) + } + + private fun highlight( + cfi: String, + text: String, + chapterIndex: Int = 0, + locator: ReaderLocator = ReaderLocator.fromLegacy( + chapterIndex = chapterIndex, + cfi = cfi, + textQuote = text + ) + ): UserHighlight { + return UserHighlight( + id = "highlight", + cfi = cfi, + text = text, + color = HighlightColor.YELLOW, + chapterIndex = chapterIndex, + locator = locator + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedReconfigurationTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReconfigurationTest.kt similarity index 95% rename from app/src/test/java/com/aryan/reader/paginatedreader/PaginatedReconfigurationTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReconfigurationTest.kt index b343c26..fc78609 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedReconfigurationTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReconfigurationTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import org.junit.Assert.assertEquals import org.junit.Assert.assertNull diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatorMeasurementContractTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatorMeasurementContractTest.kt new file mode 100644 index 0000000..4db74f4 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/PaginatorMeasurementContractTest.kt @@ -0,0 +1,26 @@ +package org.dueattendant149.bookreader.paginatedreader + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PaginatorMeasurementContractTest { + @Test + fun measuredTextHeightForPagination_keepsLayoutHeightWhenItContainsLastLineBottom() { + val measuredHeight = measuredTextHeightForPagination( + layoutHeightPx = 120, + lastLineBottomPx = 119.2f + ) + + assertEquals(120, measuredHeight) + } + + @Test + fun measuredTextHeightForPagination_usesCeiledLastLineBottomWhenItExceedsLayoutHeight() { + val measuredHeight = measuredTextHeightForPagination( + layoutHeightPx = 120, + lastLineBottomPx = 132.1f + ) + + assertEquals(133, measuredHeight) + } +} diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderLinkAnnotationTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderLinkAnnotationTest.kt new file mode 100644 index 0000000..2fdb5d9 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderLinkAnnotationTest.kt @@ -0,0 +1,51 @@ +package org.dueattendant149.bookreader.paginatedreader + +import androidx.compose.ui.text.buildAnnotatedString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReaderLinkAnnotationTest { + @Test + fun urlAnnotationAtOffsetFindsLinkInsideRange() { + val text = linkText() + + assertEquals("chapter2.xhtml#start", text.readerUrlAnnotationAtOffset(4)) + } + + @Test + fun urlAnnotationAtOffsetFindsLinkAtEndBoundary() { + val text = linkText() + + assertEquals("chapter2.xhtml#start", text.readerUrlAnnotationAtOffset("Read more".length)) + } + + @Test + fun urlAnnotationAtOffsetReturnsNullOutsideRange() { + val text = buildAnnotatedString { + append("Read more later") + addStringAnnotation("URL", "chapter2.xhtml#start", 0, "Read more".length) + } + + assertNull(text.readerUrlAnnotationAtOffset(text.length)) + } + + @Test + fun readerExternalHrefDetectsCommonExternalSchemesCaseInsensitively() { + assertTrue("HTTPS://example.com".isReaderExternalHref()) + assertTrue("//example.com/path".isReaderExternalHref()) + assertTrue("mailto:test@example.com".isReaderExternalHref()) + assertTrue("tel:+1234567890".isReaderExternalHref()) + + assertFalse("chapter2.xhtml#start".isReaderExternalHref()) + assertFalse("#footnote-1".isReaderExternalHref()) + } + + private fun linkText() = buildAnnotatedString { + val label = "Read more" + append(label) + addStringAnnotation("URL", "chapter2.xhtml#start", 0, label.length) + } +} diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderNavigationTargetsTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderNavigationTargetsTest.kt new file mode 100644 index 0000000..a0326f9 --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/ReaderNavigationTargetsTest.kt @@ -0,0 +1,83 @@ +package org.dueattendant149.bookreader.paginatedreader + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import org.dueattendant149.bookreader.SearchResult +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReaderNavigationTargetsTest { + @Test + fun `search locator resolves exact occurrence offset in text block`() { + val blocks = listOf( + ParagraphBlock( + content = AnnotatedString("first target then second target"), + cfi = "/4/2", + startCharOffsetInSource = 100, + endCharOffsetInSource = 131, + blockIndex = 7 + ) + ) + val result = SearchResult( + locationInSource = 3, + locationTitle = "Chapter", + snippet = AnnotatedString("second target"), + query = "target", + occurrenceIndexInLocation = 1, + chunkIndex = 0 + ) + + assertEquals( + Locator(chapterIndex = 3, blockIndex = 7, charOffset = 125), + findLocatorForSearchResultInBlocks(result, blocks) + ) + } + + @Test + fun `anchor locator resolves string annotation offset`() { + val content = buildAnnotatedString { + append("before anchored text") + addStringAnnotation(tag = "ID", annotation = "anchor-1", start = 7, end = 15) + } + val blocks = listOf( + ParagraphBlock( + content = content, + cfi = "/4/4", + startCharOffsetInSource = 40, + endCharOffsetInSource = 60, + blockIndex = 9 + ) + ) + + assertEquals( + Locator(chapterIndex = 2, blockIndex = 9, charOffset = 47), + findLocatorForAnchorInBlocks(chapterIndex = 2, anchor = "anchor-1", blocks = blocks) + ) + } + + @Test + fun `anchor locator resolves non text block element id`() { + val blocks = listOf( + ImageBlock( + path = "images/cover.jpg", + altText = "Cover", + elementId = "cover-image", + cfi = "/4/6", + blockIndex = 11 + ) + ) + + assertEquals( + Locator(chapterIndex = 5, blockIndex = 11, charOffset = 0), + findLocatorForAnchorInBlocks(chapterIndex = 5, anchor = "cover-image", blocks = blocks) + ) + } + + @Test + fun `native vertical initial prefetch is bounded around requested chapter`() { + assertEquals( + listOf(4, 5), + nativeVerticalInitialChapterPrefetchOrder(chapterCount = 6, initialChapter = 3) + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/RenderThemeApplierTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/RenderThemeApplierTest.kt similarity index 98% rename from app/src/test/java/com/aryan/reader/paginatedreader/RenderThemeApplierTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/RenderThemeApplierTest.kt index 970edd8..6522f06 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/RenderThemeApplierTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/RenderThemeApplierTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/StablePaginatedNavigationTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/StablePaginatedNavigationTest.kt similarity index 97% rename from app/src/test/java/com/aryan/reader/paginatedreader/StablePaginatedNavigationTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/StablePaginatedNavigationTest.kt index a5a35e5..1491d75 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/StablePaginatedNavigationTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/StablePaginatedNavigationTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/data/BookCacheDaoTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/data/BookCacheDaoTest.kt similarity index 81% rename from app/src/test/java/com/aryan/reader/paginatedreader/data/BookCacheDaoTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/data/BookCacheDaoTest.kt index 3ff4c0a..7729538 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/data/BookCacheDaoTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/paginatedreader/data/BookCacheDaoTest.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.paginatedreader.data +package org.dueattendant149.bookreader.paginatedreader.data import androidx.room.Room -import com.aryan.reader.paginatedreader.Page +import org.dueattendant149.bookreader.paginatedreader.Page import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.encodeToByteArray import kotlinx.serialization.protobuf.ProtoBuf @@ -64,6 +64,42 @@ class BookCacheDaoTest { assertArrayEquals(largePayload, large.contentBlocksProto) } + @Test + fun `processed chapters are isolated by style config hash`() = runTest { + val firstPayload = ByteArray(950 * 1024) { 1 } + val secondPayload = byteArrayOf(2, 3, 4) + + dao.insertProcessedChapters( + listOf( + ProcessedChapter( + bookId = "book", + chapterIndex = 0, + contentBlocksProto = firstPayload, + estimatedPageCount = 10, + styleConfigHash = 111 + ) + ) + ) + dao.insertProcessedChapters( + listOf( + ProcessedChapter( + bookId = "book", + chapterIndex = 0, + contentBlocksProto = secondPayload, + estimatedPageCount = 2, + styleConfigHash = 222 + ) + ) + ) + + val firstCached = dao.getProcessedChapter("book", 0, 111)!! + val secondCached = dao.getProcessedChapter("book", 0, 222)!! + assertEquals(111, firstCached.styleConfigHash) + assertEquals(222, secondCached.styleConfigHash) + assertArrayEquals(firstPayload, firstCached.contentBlocksProto) + assertArrayEquals(secondPayload, secondCached.contentBlocksProto) + } + @Test fun `delete and clear operations remove book chapters anchors and configuration cache`() = runTest { dao.insertProcessedBook(ProcessedBook("book", LATEST_PROCESSING_VERSION, 10)) diff --git a/app/src/test/java/com/aryan/reader/pdf/MagnifierGeometryTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/MagnifierGeometryTest.kt similarity index 98% rename from app/src/test/java/com/aryan/reader/pdf/MagnifierGeometryTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/MagnifierGeometryTest.kt index ba97fbf..9616845 100644 --- a/app/src/test/java/com/aryan/reader/pdf/MagnifierGeometryTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/MagnifierGeometryTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.graphics.Rect import org.junit.Assert.assertEquals diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfBitmapPoolTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfBitmapPoolTest.kt similarity index 95% rename from app/src/test/java/com/aryan/reader/pdf/PdfBitmapPoolTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfBitmapPoolTest.kt index 6524c5a..cc3dfe0 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfBitmapPoolTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfBitmapPoolTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.core.graphics.createBitmap import org.junit.After diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfOneHandZoomTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfOneHandZoomTest.kt similarity index 98% rename from app/src/test/java/com/aryan/reader/pdf/PdfOneHandZoomTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfOneHandZoomTest.kt index ea2c6a9..3ec0b2d 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfOneHandZoomTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfOneHandZoomTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderCoreLogicTest.kt similarity index 93% rename from app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderCoreLogicTest.kt index 3bbdfda..135f291 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderCoreLogicTest.kt @@ -1,18 +1,18 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import android.graphics.RectF import android.graphics.Rect import android.net.Uri import androidx.compose.ui.graphics.Color -import com.aryan.reader.pdf.data.PdfAnnotation -import com.aryan.reader.pdf.data.PdfAnnotationRepository -import com.aryan.reader.pdf.data.PdfTextBox -import com.aryan.reader.pdf.data.VirtualPage -import com.aryan.reader.pdf.ocr.OcrBlock -import com.aryan.reader.pdf.ocr.OcrElement -import com.aryan.reader.pdf.ocr.OcrLine -import com.aryan.reader.pdf.ocr.OcrResult +import org.dueattendant149.bookreader.pdf.data.PdfAnnotation +import org.dueattendant149.bookreader.pdf.data.PdfAnnotationRepository +import org.dueattendant149.bookreader.pdf.data.PdfTextBox +import org.dueattendant149.bookreader.pdf.data.VirtualPage +import org.dueattendant149.bookreader.pdf.ocr.OcrBlock +import org.dueattendant149.bookreader.pdf.ocr.OcrElement +import org.dueattendant149.bookreader.pdf.ocr.OcrLine +import org.dueattendant149.bookreader.pdf.ocr.OcrResult import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -111,6 +111,20 @@ class PdfReaderCoreLogicTest { assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf"))) } + @Test + fun `pdf encrypt marker detection matches trailer encrypt entry`() { + val bytes = "%PDF-1.7\ntrailer\n<< /Size 4 /Encrypt 2 0 R >>".toByteArray(Charsets.US_ASCII) + + assertTrue(pdfBytesContainEncryptMarker(bytes)) + } + + @Test + fun `pdf encrypt marker detection ignores longer pdf names`() { + val bytes = "<< /EncryptMetadata false /Size 4 >>".toByteArray(Charsets.US_ASCII) + + assertFalse(pdfBytesContainEncryptMarker(bytes)) + } + @Test fun `getFastFileId uses stable file name and length for file uris`() { val file = File("build/test-tmp/pdf-reader/fast-id-${System.nanoTime()}.pdf").apply { @@ -383,6 +397,32 @@ class PdfReaderCoreLogicTest { assertTrue(limitedScale >= 0.01f) } + @Test + fun `spread page slot width fits page aspect instead of filling half landscape viewport`() { + val slotWidth = pdfSpreadPageSlotWidth( + containerWidth = 1920f, + containerHeight = 900f, + pageGap = 0f, + spreadPageCount = 2, + pageAspectRatio = 612f / 792f + ) + + assertEquals(695.4545f, slotWidth, 0.001f) + } + + @Test + fun `spread page slot width caps pages to available spread width`() { + val slotWidth = pdfSpreadPageSlotWidth( + containerWidth = 1000f, + containerHeight = 900f, + pageGap = 20f, + spreadPageCount = 2, + pageAspectRatio = 1.4f + ) + + assertEquals(490f, slotWidth, 0.0001f) + } + @Test fun `canUsePdfSidecarsForBook only accepts loaded sidecars for active book`() { assertTrue(canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = true)) diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderPreferencesTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderPreferencesTest.kt similarity index 86% rename from app/src/test/java/com/aryan/reader/pdf/PdfReaderPreferencesTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderPreferencesTest.kt index 0ce9b88..e0d944b 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderPreferencesTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderPreferencesTest.kt @@ -1,12 +1,12 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import android.content.SharedPreferences import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import com.aryan.reader.epubreader.SystemUiMode -import com.aryan.reader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.epubreader.SystemUiMode +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode import io.mockk.every import io.mockk.mockk import org.junit.Assert.assertEquals @@ -50,17 +50,54 @@ class PdfReaderPreferencesTest { val context = contextWithPrefs(prefs) savePdfHiddenTools(context, setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SHARE.name)) - savePdfBottomTools(context, setOf(PdfReaderTool.SEARCH.name)) + savePdfBottomTools(context, setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name)) savePdfToolOrder(context, listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH)) assertEquals(setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SHARE.name), loadPdfHiddenTools(context)) assertFalse(PdfReaderTool.SCREEN_ORIENTATION.name in loadPdfHiddenTools(context)) assertFalse(PdfReaderTool.HIGHLIGHT_ALL.name in loadPdfHiddenTools(context)) assertFalse(PdfReaderTool.BRIGHTNESS.name in loadPdfHiddenTools(context)) - assertEquals(setOf(PdfReaderTool.SEARCH.name), loadPdfBottomTools(context)) + assertEquals(setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name), loadPdfBottomTools(context)) assertEquals(listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH), loadPdfToolOrder(context).take(2)) } + @Test + fun `toolbar restore helpers keep saveable tab switch state sanitized`() { + val restoredOrder = restorePdfToolOrderNames( + listOf( + PdfReaderTool.SEARCH.name, + "NO_SUCH_TOOL", + PdfReaderTool.TOC.name, + PdfReaderTool.SEARCH.name + ) + ) + val expectedTools = PdfReaderTool.entries.filter(::isPdfReaderToolAvailable) + + assertEquals(listOf(PdfReaderTool.SEARCH, PdfReaderTool.TOC), restoredOrder.take(2)) + assertEquals(expectedTools.size, restoredOrder.size) + assertEquals(expectedTools.toSet(), restoredOrder.toSet()) + assertEquals( + setOf(PdfReaderTool.PRINT.name), + sanitizePdfHiddenToolNames(listOf(PdfReaderTool.PRINT.name, "NO_SUCH_TOOL")) + ) + assertEquals( + setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name), + sanitizePdfBottomToolNames(listOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name, PdfReaderTool.PRINT.name)) + ) + assertEquals( + defaultPdfBottomTools(), + loadPdfBottomTools( + contextWithPrefs(InMemorySharedPreferences(PDF_BOTTOM_TOOLS_KEY to setOf("NO_SUCH_TOOL"))) + ) + ) + assertEquals( + emptySet(), + loadPdfBottomTools( + contextWithPrefs(InMemorySharedPreferences(PDF_BOTTOM_TOOLS_KEY to emptySet())) + ) + ) + } + @Test fun `reader mode and enum preferences default safely when saved values are invalid`() { val prefs = InMemorySharedPreferences( diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderRepositoryTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderRepositoryTest.kt similarity index 62% rename from app/src/test/java/com/aryan/reader/pdf/PdfReaderRepositoryTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderRepositoryTest.kt index 661ad78..35c2ee9 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderRepositoryTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderRepositoryTest.kt @@ -1,15 +1,15 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color -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.VirtualPage +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.VirtualPage import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -60,6 +60,45 @@ class PdfReaderRepositoryTest { assertNull(repository.getAnnotationFileForSync("book")) } + @Test + fun `PdfAnnotationRepository does not rewrite unchanged annotation file`() = runTest { + val context = contextWithFilesDir(tempRoot("annotation-noop")) + val repository = PdfAnnotationRepository(context) + val annotations = mapOf( + 0 to listOf( + PdfAnnotation( + type = AnnotationType.INK, + inkType = InkType.PEN, + pageIndex = 0, + points = listOf(PdfPoint(0.1f, 0.2f, 123L)), + color = Color.Blue, + strokeWidth = 0.01f + ) + ) + ) + + repository.saveAnnotations("book", annotations) + val file = requireNotNull(repository.getAnnotationFileForSync("book")) + val previousModified = 1_700_000_000_000L + assertTrue(file.setLastModified(previousModified)) + + repository.saveAnnotations("book", annotations) + + assertEquals(previousModified, file.lastModified()) + } + + @Test + fun `PdfAnnotationRepository stores deleted annotation tombstones for sync`() = runTest { + val context = contextWithFilesDir(tempRoot("annotation-deleted")) + val repository = PdfAnnotationRepository(context) + + repository.markAnnotationsDeleted("book", listOf("old-ink"), deletedAt = 123L) + + val file = requireNotNull(repository.getDeletedAnnotationsFileForSync("book")) + assertTrue(file.readText().contains("old-ink")) + assertTrue(file.readText().contains("123")) + } + @Test fun `PdfHighlightRepository saves loads deletes empty highlights and clears all`() = runTest { val context = contextWithFilesDir(tempRoot("highlights")) @@ -86,6 +125,29 @@ class PdfReaderRepositoryTest { assertFalse(File(context.filesDir, "pdf_highlights").exists()) } + @Test + fun `PdfHighlightRepository does not rewrite unchanged highlight file`() = runTest { + val context = contextWithFilesDir(tempRoot("highlights-noop")) + val repository = PdfHighlightRepository(context) + val highlight = PdfUserHighlight( + id = "h1", + pageIndex = 2, + bounds = emptyList(), + color = PdfHighlightColor.GREEN, + text = "quote", + range = 5 to 10 + ) + + repository.saveHighlights("book", listOf(highlight)) + val file = repository.getFileForSync("book") + val previousModified = 1_700_000_000_000L + assertTrue(file.setLastModified(previousModified)) + + repository.saveHighlights("book", listOf(highlight)) + + assertEquals(previousModified, file.lastModified()) + } + @Test fun `PdfTextBoxRepository saves loads deletes and clears files`() = runTest { val context = contextWithFilesDir(tempRoot("textboxes")) @@ -112,6 +174,30 @@ class PdfReaderRepositoryTest { assertTrue(File(context.filesDir, "textboxes").listFiles().orEmpty().isEmpty()) } + @Test + fun `PdfTextBoxRepository does not rewrite unchanged textbox file`() = runTest { + val context = contextWithFilesDir(tempRoot("textboxes-noop")) + val repository = PdfTextBoxRepository(context) + val box = PdfTextBox( + id = "box", + pageIndex = 0, + relativeBounds = Rect(0.1f, 0.2f, 0.3f, 0.4f), + text = "Text box", + color = Color.Black, + backgroundColor = Color.White, + fontSize = 16f + ) + + repository.saveTextBoxes("book", listOf(box)) + val file = repository.getFileForSync("book") + val previousModified = 1_700_000_000_000L + assertTrue(file.setLastModified(previousModified)) + + repository.saveTextBoxes("book", listOf(box)) + + assertEquals(previousModified, file.lastModified()) + } + @Test fun `PageLayoutRepository returns default pdf pages when no layout exists`() = runTest { val repository = PageLayoutRepository(contextWithFilesDir(tempRoot("layout-default"))) diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderRichTextTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderRichTextTest.kt similarity index 96% rename from app/src/test/java/com/aryan/reader/pdf/PdfReaderRichTextTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderRichTextTest.kt index 9b60f9a..5674fd3 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderRichTextTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderRichTextTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context import androidx.compose.ui.graphics.Color @@ -10,7 +10,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.sp -import com.aryan.reader.pdf.data.VirtualPage +import org.dueattendant149.bookreader.pdf.data.VirtualPage import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -181,6 +181,13 @@ class PdfReaderRichTextTest { assertTrue("${PAGE_BREAK_CHAR}\nVisible".hasRenderableRichText()) } + @Test + fun `selection bounds normalize reversed and clamped rich text selections`() { + assertEquals(44 to 45, androidPdfRichTextSelectionBounds(45, 44, textLength = 45)) + assertEquals(0 to 5, androidPdfRichTextSelectionBounds(-3, 99, textLength = 5)) + assertEquals(null, androidPdfRichTextSelectionBounds(3, 3, textLength = 5)) + } + @Test fun `blank page insertion uses one page break when the rich text boundary is already explicit`() { val text = "Page 1${PAGE_BREAK_CHAR}Page 2" diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderSerializerTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderSerializerTest.kt similarity index 95% rename from app/src/test/java/com/aryan/reader/pdf/PdfReaderSerializerTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderSerializerTest.kt index bb87fe9..dfc16b7 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderSerializerTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderSerializerTest.kt @@ -1,15 +1,15 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.graphics.RectF import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import com.aryan.reader.pdf.data.AnnotationSerializer -import com.aryan.reader.pdf.data.HighlightSerializer -import com.aryan.reader.pdf.data.PdfAnnotation -import com.aryan.reader.pdf.data.PdfTextBox -import com.aryan.reader.pdf.data.TextBoxSerializer -import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment +import org.dueattendant149.bookreader.pdf.data.AnnotationSerializer +import org.dueattendant149.bookreader.pdf.data.HighlightSerializer +import org.dueattendant149.bookreader.pdf.data.PdfAnnotation +import org.dueattendant149.bookreader.pdf.data.PdfTextBox +import org.dueattendant149.bookreader.pdf.data.TextBoxSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationComment import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt similarity index 84% rename from app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt index e1a220d..ec020cd 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt @@ -1,22 +1,22 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import com.aryan.reader.BuildConfig -import com.aryan.reader.FileType -import com.aryan.reader.pdf.data.AnnotationSettingsRepository -import com.aryan.reader.pdf.data.AnnotationToolSettings -import com.aryan.reader.pdf.data.TextStyleConfig -import com.aryan.reader.pdf.data.ToolConfig -import com.aryan.reader.shared.pdf.PdfAnnotationKind -import com.aryan.reader.shared.pdf.PdfInkTool -import com.aryan.reader.shared.pdf.PdfPageBounds -import com.aryan.reader.shared.pdf.PdfPagePoint -import com.aryan.reader.shared.pdf.PdfZoomSpec -import com.aryan.reader.shared.pdf.SharedPdfAnnotation -import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults -import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer -import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.BuildConfig +import org.dueattendant149.bookreader.FileType +import org.dueattendant149.bookreader.pdf.data.AnnotationSettingsRepository +import org.dueattendant149.bookreader.pdf.data.AnnotationToolSettings +import org.dueattendant149.bookreader.pdf.data.TextStyleConfig +import org.dueattendant149.bookreader.pdf.data.ToolConfig +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint +import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationDefaults +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -121,7 +121,7 @@ class PdfReaderSettingsAndSharedModelsTest { @Test fun `SharedPdfAnnotationDefaults supplies expected tool defaults and palettes`() { assertEquals(5, SharedPdfAnnotationDefaults.penPalette.size) - assertEquals(5, SharedPdfAnnotationDefaults.highlighterPalette.size) + assertEquals(SharedPdfHighlighterPalette.MaxColors, SharedPdfAnnotationDefaults.highlighterPalette.size) val pen = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN) val eraser = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER) @@ -204,6 +204,17 @@ class PdfReaderSettingsAndSharedModelsTest { PdfToolbarSection.BOTTOM, defaultItems.single { it.tool == PdfReaderTool.SLIDER }.section ) + + val customPlacementItems = buildPdfToolbarItems( + hiddenTools = emptySet(), + toolOrder = defaultPdfToolOrder(), + bottomTools = setOf(PdfReaderTool.THEME.name) + ) + assertEquals( + PdfToolbarSection.BOTTOM, + customPlacementItems.single { it.tool == PdfReaderTool.THEME }.section + ) + val expectedMoreTools = buildSet { addAll( setOf( @@ -270,6 +281,23 @@ class PdfReaderSettingsAndSharedModelsTest { assertEquals(PdfOverflowMenuSection.FILE_ACTIONS, sections.last()) } + @Test + fun `pdf overflow sections hide file actions when only unavailable print remains`() { + val sections = pdfOverflowMenuSections( + hiddenTools = setOf( + PdfReaderTool.SHARE.name, + PdfReaderTool.SAVE_COPY.name + ), + hasHiddenToolbarTools = false, + isPro = false, + effectiveFileType = FileType.PDF, + hasFileInfo = false, + canPrintDocument = false + ) + + assertFalse(PdfOverflowMenuSection.FILE_ACTIONS in sections) + } + @Test fun `pdf overflow sections expose file info only when available and visible`() { val visibleSections = pdfOverflowMenuSections( diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReleaseRulesTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReleaseRulesTest.kt similarity index 64% rename from app/src/test/java/com/aryan/reader/pdf/PdfReleaseRulesTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReleaseRulesTest.kt index 8793996..aca08b2 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReleaseRulesTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfReleaseRulesTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import java.io.File import org.junit.Assert.assertTrue @@ -10,10 +10,10 @@ class PdfReleaseRulesTest { fun `release rules keep pdf reader and pdfium internals`() { val rules = readProguardRules() - assertTrue(rules.contains("-keep class com.aryan.reader.pdf.PdfViewerScreenKt")) - assertTrue(rules.contains("-keep class com.aryan.reader.pdf.PdfPageComposableKt")) + assertTrue(rules.contains("-keep class org.dueattendant149.bookreader.pdf.PdfViewerScreenKt")) + assertTrue(rules.contains("-keep class org.dueattendant149.bookreader.pdf.PdfPageComposableKt")) assertTrue(rules.contains("-keep class io.legere.pdfiumandroid.**")) - assertTrue(rules.contains("-keep class com.aryan.reader.pdf.NativePdfiumBridge")) + assertTrue(rules.contains("-keep class org.dueattendant149.bookreader.pdf.NativePdfiumBridge")) } private fun readProguardRules(): String { diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfTextRepositoryTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfTextRepositoryTest.kt similarity index 91% rename from app/src/test/java/com/aryan/reader/pdf/PdfTextRepositoryTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfTextRepositoryTest.kt index c32a053..77f2578 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfTextRepositoryTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfTextRepositoryTest.kt @@ -1,15 +1,15 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.content.Context -import com.aryan.reader.SearchResult -import com.aryan.reader.pdf.data.PdfMetaDao -import com.aryan.reader.pdf.data.PdfMetadata -import com.aryan.reader.pdf.data.PdfSearchIndex -import com.aryan.reader.pdf.data.PdfSearchMatch -import com.aryan.reader.pdf.data.PdfTextDao -import com.aryan.reader.pdf.data.PdfTextDatabase -import com.aryan.reader.pdf.data.PdfTextRepository -import com.aryan.reader.pdf.data.SmartSearchResult +import org.dueattendant149.bookreader.SearchResult +import org.dueattendant149.bookreader.pdf.data.PdfMetaDao +import org.dueattendant149.bookreader.pdf.data.PdfMetadata +import org.dueattendant149.bookreader.pdf.data.PdfSearchIndex +import org.dueattendant149.bookreader.pdf.data.PdfSearchMatch +import org.dueattendant149.bookreader.pdf.data.PdfTextDao +import org.dueattendant149.bookreader.pdf.data.PdfTextDatabase +import org.dueattendant149.bookreader.pdf.data.PdfTextRepository +import org.dueattendant149.bookreader.pdf.data.SmartSearchResult import io.mockk.coEvery import io.mockk.coVerify import io.mockk.coVerifyOrder diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfVerticalReaderThemeTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfVerticalReaderThemeTest.kt similarity index 91% rename from app/src/test/java/com/aryan/reader/pdf/PdfVerticalReaderThemeTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfVerticalReaderThemeTest.kt index 5576483..71859dd 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfVerticalReaderThemeTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfVerticalReaderThemeTest.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.ui.graphics.Color -import com.aryan.reader.ReaderTheme +import org.dueattendant149.bookreader.ReaderTheme import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Test diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfZoomLockStateTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfZoomLockStateTest.kt similarity index 68% rename from app/src/test/java/com/aryan/reader/pdf/PdfZoomLockStateTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfZoomLockStateTest.kt index db34456..43988b2 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfZoomLockStateTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfZoomLockStateTest.kt @@ -1,8 +1,8 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import androidx.compose.ui.geometry.Offset -import com.aryan.reader.shared.reader.ReaderPageSpreadMode -import com.aryan.reader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -96,6 +96,86 @@ class PdfZoomLockStateTest { ) } + @Test + fun `vertical pdf high res tiles render for settled zoom below one hundred percent`() { + assertTrue( + shouldRenderPdfHighResTiles( + effectiveScale = 0.82f, + targetWidthPx = 1080, + targetHeightPx = 1600, + isVerticalScroll = true, + isActivePage = true + ) + ) + } + + @Test + fun `vertical pdf high res tiles skip exact one hundred percent unless page is large`() { + assertFalse( + shouldRenderPdfHighResTiles( + effectiveScale = 1f, + targetWidthPx = 1080, + targetHeightPx = 1600, + isVerticalScroll = true, + isActivePage = true + ) + ) + assertTrue( + shouldRenderPdfHighResTiles( + effectiveScale = 1f, + targetWidthPx = 3200, + targetHeightPx = 1600, + isVerticalScroll = true, + isActivePage = true + ) + ) + } + + @Test + fun `paginated pdf high res tiles keep existing zoom threshold`() { + assertFalse( + shouldRenderPdfHighResTiles( + effectiveScale = 0.82f, + targetWidthPx = 1080, + targetHeightPx = 1600, + isVerticalScroll = false, + isActivePage = true + ) + ) + assertTrue( + shouldRenderPdfHighResTiles( + effectiveScale = 1.25f, + targetWidthPx = 1080, + targetHeightPx = 1600, + isVerticalScroll = false, + isActivePage = true + ) + ) + assertFalse( + shouldRenderPdfHighResTiles( + effectiveScale = 1.25f, + targetWidthPx = 1080, + targetHeightPx = 1600, + isVerticalScroll = false, + isActivePage = false + ) + ) + } + + @Test + fun `zoom indicator percent rounds displayed scale`() { + assertEquals(82, pdfZoomIndicatorPercent(0.824f)) + assertEquals(83, pdfZoomIndicatorPercent(0.826f)) + assertEquals(100, pdfZoomIndicatorPercent(0.996f)) + } + + @Test + fun `zoom indicator hides only at displayed one hundred percent`() { + assertFalse(shouldShowPdfZoomIndicator(100)) + assertTrue(shouldShowPdfZoomIndicator(99)) + assertTrue(shouldShowPdfZoomIndicator(125)) + } + @Test fun `page change preserves locked zoom scale only in paginated lock mode`() { val lockedState = Triple(2.25f, -12f, 32f) diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfiumAnnotationExporterTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfiumAnnotationExporterTest.kt similarity index 97% rename from app/src/test/java/com/aryan/reader/pdf/PdfiumAnnotationExporterTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfiumAnnotationExporterTest.kt index 676e18d..1026079 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfiumAnnotationExporterTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pdf/PdfiumAnnotationExporterTest.kt @@ -1,13 +1,13 @@ -package com.aryan.reader.pdf +package org.dueattendant149.bookreader.pdf import android.graphics.RectF import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import com.aryan.reader.pdf.data.PdfAnnotation -import com.aryan.reader.pdf.data.PdfTextBox -import com.aryan.reader.pdf.data.VirtualPage -import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment +import org.dueattendant149.bookreader.pdf.data.PdfAnnotation +import org.dueattendant149.bookreader.pdf.data.PdfTextBox +import org.dueattendant149.bookreader.pdf.data.VirtualPage +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationComment import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/app/src/test/java/com/aryan/reader/pptx/PptxDocumentParserTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/pptx/PptxDocumentParserTest.kt similarity index 99% rename from app/src/test/java/com/aryan/reader/pptx/PptxDocumentParserTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/pptx/PptxDocumentParserTest.kt index 5176540..10aeb30 100644 --- a/app/src/test/java/com/aryan/reader/pptx/PptxDocumentParserTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/pptx/PptxDocumentParserTest.kt @@ -1,10 +1,10 @@ -package com.aryan.reader.pptx +package org.dueattendant149.bookreader.pptx import android.content.ContentResolver import android.content.Context import android.net.Uri -import com.aryan.reader.FileType -import com.aryan.reader.pdf.DocumentFactory +import org.dueattendant149.bookreader.FileType +import org.dueattendant149.bookreader.pdf.DocumentFactory import io.legere.pdfiumandroid.suspend.PdfiumCoreKt import io.mockk.every import io.mockk.mockk diff --git a/app/src/test/java/com/dueattendant149/bookreader/reader/tts/TtsCacheManagerSecurityTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/tts/TtsCacheManagerSecurityTest.kt new file mode 100644 index 0000000..f8338af --- /dev/null +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/tts/TtsCacheManagerSecurityTest.kt @@ -0,0 +1,40 @@ +package org.dueattendant149.bookreader.tts + +import android.content.Context +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class TtsCacheManagerSecurityTest { + private lateinit var context: Context + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + } + + @Test + fun `book cache directory for traversal title remains inside tts cache root`() { + val manager = TtsCacheManager(context) + val root = File(context.filesDir, "TTS_Cache").canonicalFile + val cacheDir = manager.getBookCacheDir("..").canonicalFile + + assertTrue(cacheDir.path.startsWith(root.path + File.separator)) + } + + @Test + fun `clearBookCache with traversal title does not delete app files directory`() { + val sentinel = File(context.filesDir, "tts-sentinel-${System.nanoTime()}.txt") + sentinel.writeText("keep") + + TtsCacheManager(context).clearBookCache("..") + + assertTrue(sentinel.exists()) + sentinel.delete() + } +} diff --git a/app/src/test/java/com/aryan/reader/tts/TtsChunkNavigationTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/tts/TtsChunkNavigationTest.kt similarity index 64% rename from app/src/test/java/com/aryan/reader/tts/TtsChunkNavigationTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/tts/TtsChunkNavigationTest.kt index 335112a..ccbc788 100644 --- a/app/src/test/java/com/aryan/reader/tts/TtsChunkNavigationTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/tts/TtsChunkNavigationTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.tts +package org.dueattendant149.bookreader.tts import androidx.media3.common.util.UnstableApi import org.junit.Assert.assertEquals @@ -7,6 +7,7 @@ import org.junit.Test import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder +import java.util.concurrent.ConcurrentHashMap class TtsChunkNavigationTest { @Test @@ -53,6 +54,25 @@ class TtsChunkNavigationTest { assertEquals(false, shouldAdvanceToTtsPlaylistChunk(currentChunkIndex = 8, playlistChunkIndex = null)) } + @Test + fun `automatic playlist advance can step over chunks marked skipped after generation failures`() { + assertEquals( + true, + shouldAdvanceToTtsPlaylistChunk( + currentChunkIndex = 8, + playlistChunkIndex = 10, + skippedChunkIndices = setOf(9) + ) + ) + assertEquals(10, resolveNextPlayableTtsChunkIndex(8, 12, setOf(9))) + } + + @Test + fun `chunk generation gives up after bounded failures`() { + assertEquals(false, shouldGiveUpTtsChunkGeneration(failureCount = 1, maxFailures = 2)) + assertEquals(true, shouldGiveUpTtsChunkGeneration(failureCount = 2, maxFailures = 2)) + } + @Test fun `transition prefetch is deferred only for the rebuilding generation`() { assertEquals(false, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = 6)) @@ -60,6 +80,13 @@ class TtsChunkNavigationTest { assertEquals(true, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = -1)) } + @Test + fun `prefetch stops only when generated chunk is neither loaded nor queued`() { + assertEquals(true, shouldStopTtsPrefetchAfterMissingChunk(isLoaded = false, playlistIndex = null)) + assertEquals(false, shouldStopTtsPrefetchAfterMissingChunk(isLoaded = true, playlistIndex = null)) + assertEquals(false, shouldStopTtsPrefetchAfterMissingChunk(isLoaded = false, playlistIndex = 2)) + } + @androidx.annotation.OptIn(UnstableApi::class) @Test fun `reader tts mini bar is visible only for active reader playback outside reader routes`() { @@ -90,6 +117,45 @@ class TtsChunkNavigationTest { assertEquals(16, readerTtsMiniBarBottomPaddingDp(isOnMainRoute = false)) } + @Test + fun `reader tts overlay size exposes the other two sizes as choices`() { + assertEquals( + listOf(ReaderTtsOverlaySize.MEDIUM, ReaderTtsOverlaySize.SMALL), + readerTtsOverlayAlternativeSizes(ReaderTtsOverlaySize.LARGE) + ) + assertEquals( + listOf(ReaderTtsOverlaySize.LARGE, ReaderTtsOverlaySize.SMALL), + readerTtsOverlayAlternativeSizes(ReaderTtsOverlaySize.MEDIUM) + ) + assertEquals( + listOf(ReaderTtsOverlaySize.LARGE, ReaderTtsOverlaySize.MEDIUM), + readerTtsOverlayAlternativeSizes(ReaderTtsOverlaySize.SMALL) + ) + } + + @Test + fun `reader tts overlay stored size defaults to large for missing or invalid values`() { + assertEquals(ReaderTtsOverlaySize.MEDIUM, resolveReaderTtsOverlaySize("MEDIUM")) + assertEquals(ReaderTtsOverlaySize.LARGE, resolveReaderTtsOverlaySize(null)) + assertEquals(ReaderTtsOverlaySize.LARGE, resolveReaderTtsOverlaySize("FULL")) + } + + @Test + fun `reader tts overlay only aligns small state to the trailing edge`() { + assertEquals(0f, readerTtsOverlayAlignmentBias(ReaderTtsOverlaySize.LARGE), 0f) + assertEquals(0f, readerTtsOverlayAlignmentBias(ReaderTtsOverlaySize.MEDIUM), 0f) + assertEquals(1f, readerTtsOverlayAlignmentBias(ReaderTtsOverlaySize.SMALL), 0f) + } + + @Test + fun `reader tts chunk label uses one based progress`() { + assertEquals("Chunk 1/4", formatReaderTtsChunkLabel(currentChunkIndex = 0, totalChunks = 4)) + assertEquals("Chunk 4/4", formatReaderTtsChunkLabel(currentChunkIndex = 3, totalChunks = 4)) + assertNull(formatReaderTtsChunkLabel(currentChunkIndex = -1, totalChunks = 4)) + assertNull(formatReaderTtsChunkLabel(currentChunkIndex = 4, totalChunks = 4)) + assertNull(formatReaderTtsChunkLabel(currentChunkIndex = 0, totalChunks = 0)) + } + @Test fun `stream pcm duration uses cloud tts audio format`() { assertEquals(1_000L, resolveTtsStreamPcmDurationMs(totalBytes = 44L + 48_000L)) @@ -104,6 +170,16 @@ class TtsChunkNavigationTest { assertNull(estimateTtsNotificationDurationMs(text = " ")) } + @Test + fun `stable sorted snapshot copies concurrent cache keys`() { + val cache = ConcurrentHashMap() + cache[3] = "three" + cache[1] = "one" + cache[2] = "two" + + assertEquals(listOf(1, 2, 3), stableSortedIntSnapshot(cache.keys)) + } + @Test fun `wav file duration is read from pcm byte rate`() { val file = createTempWavFile(pcmBytes = 48_000) diff --git a/app/src/test/java/com/aryan/reader/tts/TtsModePolicyTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/tts/TtsModePolicyTest.kt similarity index 87% rename from app/src/test/java/com/aryan/reader/tts/TtsModePolicyTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/tts/TtsModePolicyTest.kt index 14029b6..eec0bbd 100644 --- a/app/src/test/java/com/aryan/reader/tts/TtsModePolicyTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/tts/TtsModePolicyTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.tts +package org.dueattendant149.bookreader.tts import android.speech.tts.Voice import androidx.media3.common.util.UnstableApi @@ -76,6 +76,14 @@ class TtsModePolicyTest { assertEquals(TtsPlaybackManager.TtsMode.BASE, mode) } + @Test + fun `native tts voice list is resolved only when required`() { + assertEquals(false, shouldResolveNativeTtsVoice(preferredVoiceName = null, isOfflineBuild = false)) + assertEquals(false, shouldResolveNativeTtsVoice(preferredVoiceName = " ", isOfflineBuild = false)) + assertEquals(true, shouldResolveNativeTtsVoice(preferredVoiceName = "voice-id", isOfflineBuild = false)) + assertEquals(true, shouldResolveNativeTtsVoice(preferredVoiceName = null, isOfflineBuild = true)) + } + @Test fun `offline native tts ignores saved network voice`() { val localVoice = voice("local", requiresNetwork = false) diff --git a/app/src/test/java/com/aryan/reader/tts/TtsSpeakerPreferencesTest.kt b/app/src/test/java/com/dueattendant149/bookreader/reader/tts/TtsSpeakerPreferencesTest.kt similarity index 96% rename from app/src/test/java/com/aryan/reader/tts/TtsSpeakerPreferencesTest.kt rename to app/src/test/java/com/dueattendant149/bookreader/reader/tts/TtsSpeakerPreferencesTest.kt index 85f084d..3cff4e5 100644 --- a/app/src/test/java/com/aryan/reader/tts/TtsSpeakerPreferencesTest.kt +++ b/app/src/test/java/com/dueattendant149/bookreader/reader/tts/TtsSpeakerPreferencesTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.tts +package org.dueattendant149.bookreader.tts import android.content.Context import org.junit.Assert.assertEquals diff --git a/build.gradle.kts b/build.gradle.kts index 32c90e6..5568021 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,3 +8,24 @@ plugins { alias(libs.plugins.compose.multiplatform) apply false alias(libs.plugins.kover) apply false } + +val test by tasks.registering { + group = "verification" + description = "Runs available unit tests for the included projects." +} + +subprojects { + val rootTest = rootProject.tasks.named("test") + tasks.matching { + it.name == "allTests" || + it.name == "desktopTest" || + it.name.endsWith("DebugUnitTest") + }.configureEach { + rootTest.configure { + dependsOn(this@configureEach) + } + } + tasks.withType().configureEach { + maxHeapSize = "4g" + } +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 0873355..e0cd987 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,17 +1,40 @@ import org.gradle.api.GradleException import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.Exec import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.Sync import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.bundling.Compression +import org.gradle.api.tasks.bundling.Tar import org.gradle.jvm.tasks.Jar +import org.gradle.process.ExecOperations import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.gradle.work.DisableCachingByDefault import java.io.File +import java.security.MessageDigest +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.util.Properties +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream +import javax.imageio.ImageIO +import javax.inject.Inject plugins { alias(libs.plugins.kotlin.multiplatform) @@ -19,33 +42,6 @@ plugins { alias(libs.plugins.compose.multiplatform) } -@DisableCachingByDefault(because = "Verification task has no outputs.") -abstract class CheckBundledWebViewRuntimeTask : DefaultTask() { - @get:Input - abstract val bundleRootPath: Property - - @get:Input - abstract val osName: Property - - @get:Input - abstract val osArch: Property - - @get:Input - abstract val requiredPaths: ListProperty - - @TaskAction - fun checkRuntime() { - val bundleRoot = File(bundleRootPath.get()) - val missingFiles = requiredPaths.get().filterNot { bundleRoot.resolve(it).exists() } - if (missingFiles.isNotEmpty()) { - throw GradleException( - "Missing bundled KCEF runtime at ${bundleRoot.absolutePath}. " + - "Expected ${missingFiles.joinToString()} for ${osName.get()} ${osArch.get()} desktop packages." - ) - } - } -} - @DisableCachingByDefault(because = "Verification task has no outputs.") abstract class CheckBundledPdfiumRuntimeTask : DefaultTask() { @get:Input @@ -67,6 +63,686 @@ abstract class CheckBundledPdfiumRuntimeTask : DefaultTask() { } } +@DisableCachingByDefault(because = "Renames package output produced by jpackage.") +abstract class RenameDesktopMsiOutputTask : DefaultTask() { + @get:Input + abstract val msiDirectoryPath: Property + + @get:Input + abstract val packageName: Property + + @get:Input + abstract val packageVersion: Property + + @get:Input + abstract val architecture: Property + + @TaskAction + fun renameOutput() { + val msiDirectory = File(msiDirectoryPath.get()) + val outputPackageName = packageName.get() + val outputPackageVersion = packageVersion.get() + val source = msiDirectory.resolve("$outputPackageName-$outputPackageVersion.msi") + if (!source.isFile) return + + val target = msiDirectory.resolve("$outputPackageName-$outputPackageVersion-${architecture.get()}.msi") + if (target.exists() && !target.delete()) { + throw GradleException("Could not replace existing MSI at ${target.absolutePath}.") + } + if (!source.renameTo(target)) { + throw GradleException("Could not rename MSI from ${source.absolutePath} to ${target.absolutePath}.") + } + } +} + +@DisableCachingByDefault(because = "Generates an MSIX manifest from package metadata.") +abstract class GenerateDesktopMsixManifestTask : DefaultTask() { + @get:Input + abstract val identityName: Property + + @get:Input + abstract val publisher: Property + + @get:Input + abstract val publisherDisplayName: Property + + @get:Input + abstract val packageName: Property + + @get:Input + abstract val packageDescription: Property + + @get:Input + abstract val packageVersion: Property + + @get:Input + abstract val architecture: Property + + @get:Input + abstract val executablePath: Property + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + fun xmlEscaped(value: String): String { + return value.replace("&", "&") + .replace("\"", """) + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + } + + val file = outputFile.get().asFile + file.parentFile.mkdirs() + file.writeText( + """ + + + + + ${xmlEscaped(packageName.get())} + ${xmlEscaped(publisherDisplayName.get())} + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + """.trimIndent() + "\n", + Charsets.UTF_8 + ) + } +} + +@DisableCachingByDefault(because = "Generates fixed-size MSIX logo assets from the desktop icon.") +abstract class GenerateDesktopMsixAssetsTask : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val sourceIconFile: RegularFileProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun generate() { + val source = ImageIO.read(sourceIconFile.get().asFile) + ?: throw GradleException("Could not read MSIX source icon ${sourceIconFile.get().asFile.absolutePath}.") + val output = outputDirectory.get().asFile + output.mkdirs() + writePng(source, output.resolve("Square44x44Logo.png"), 44) + writePng(source, output.resolve("Square150x150Logo.png"), 150) + writePng(source, output.resolve("StoreLogo.png"), 50) + } + + private fun writePng(source: BufferedImage, target: File, size: Int) { + val image = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + try { + graphics.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BICUBIC + ) + graphics.setRenderingHint( + RenderingHints.KEY_RENDERING, + RenderingHints.VALUE_RENDER_QUALITY + ) + graphics.drawImage(source, 0, 0, size, size, null) + } finally { + graphics.dispose() + } + ImageIO.write(image, "png", target) + } +} + +@DisableCachingByDefault(because = "Packages the staged MSIX app image with Windows SDK makeappx.") +abstract class PackageDesktopMsixTask @Inject constructor( + private val execOperations: ExecOperations +) : DefaultTask() { + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val packageRootDirectory: DirectoryProperty + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @get:Input + abstract val makeAppxPath: Property + + @get:Input + abstract val hostOsId: Property + + @get:Input + abstract val hostArchId: Property + + @TaskAction + fun packageMsix() { + if (hostOsId.get() != "windows" || hostArchId.get() != "x64") { + throw GradleException( + "MSIX packaging requires a Windows x64 packaging host. " + + "Current host: ${hostOsId.get()} ${hostArchId.get()}." + ) + } + + val makeAppx = File(makeAppxPath.get()) + if (!makeAppx.isFile) { + throw GradleException( + "Windows SDK makeappx.exe was not found at ${makeAppx.absolutePath}. " + + "Install the Windows SDK MSIX packaging tools or set " + + "-PdesktopMakeAppxPath=." + ) + } + + val output = outputFile.get().asFile + output.parentFile.mkdirs() + if (output.exists() && !output.delete()) { + throw GradleException("Could not replace existing MSIX at ${output.absolutePath}.") + } + + execOperations.exec { + executable = makeAppx.absolutePath + args( + "pack", + "/d", + packageRootDirectory.get().asFile.absolutePath, + "/p", + output.absolutePath, + "/o" + ) + } + } +} + +@DisableCachingByDefault(because = "Signs the MSIX package with Windows SDK signtool.") +abstract class SignDesktopMsixTask @Inject constructor( + private val execOperations: ExecOperations +) : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val unsignedMsixFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val certificateFile: RegularFileProperty + + @get:Input + abstract val signToolPath: Property + + @get:Input + abstract val certificatePassword: Property + + @get:Input + abstract val timestampUrl: Property + + @TaskAction + fun signMsix() { + val signTool = File(signToolPath.get()) + if (!signTool.isFile) { + throw GradleException( + "Windows SDK signtool.exe was not found at ${signTool.absolutePath}. " + + "Install the Windows SDK or set -PdesktopSignToolPath=." + ) + } + + val signArgs = mutableListOf( + "sign", + "/fd", + "SHA256", + "/f", + certificateFile.get().asFile.absolutePath + ) + val password = certificatePassword.get().trim() + if (password.isNotEmpty()) { + signArgs += listOf("/p", password) + } + val timestamp = timestampUrl.get().trim() + if (timestamp.isNotEmpty()) { + signArgs += listOf("/tr", timestamp, "/td", "SHA256") + } + signArgs += unsignedMsixFile.get().asFile.absolutePath + + execOperations.exec { + executable = signTool.absolutePath + args(signArgs) + } + } +} + +@DisableCachingByDefault(because = "Generates local desktop service config for native packages.") +abstract class GenerateDesktopCloudConfigTask : DefaultTask() { + @get:Input + abstract val configValues: MapProperty + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + val file = outputFile.get().asFile + file.parentFile.mkdirs() + file.writeText( + configValues.get().entries.joinToString(separator = "\n", postfix = "\n") { (key, value) -> + "$key=${value.replace("\\", "\\\\").replace("\n", "")}" + } + ) + } +} + +@DisableCachingByDefault(because = "Verification task has no outputs.") +abstract class VerifyDesktopNativePackagingTask : DefaultTask() { + @get:Input + abstract val supportedHost: Property + + @get:Input + abstract val hostOsId: Property + + @get:Input + abstract val hostArchId: Property + + @get:Input + abstract val missingStandardServiceConfig: ListProperty + + @TaskAction + fun verify() { + if (!supportedHost.get()) { + throw GradleException( + "Desktop native packaging is currently release-supported only on Windows x64 and Linux x64. " + + "Current host: ${hostOsId.get()} ${hostArchId.get()}." + ) + } + val missing = missingStandardServiceConfig.get() + if (missing.isNotEmpty()) { + throw GradleException( + "Standard desktop packages require account/sync service config. Missing: " + + missing.joinToString(", ") + ". " + + "Set DESKTOP_FIREBASE_WEB_API_KEY and DESKTOP_GOOGLE_OAUTH_CLIENT_ID, " + + "use -PdesktopFlavor=oss for the offline build, or set " + + "-PdesktopAllowUnconfiguredStandardServices=true for a local non-GA package." + ) + } + } +} + +@DisableCachingByDefault(because = "Generates AUR package metadata from the local Linux distributable.") +abstract class PrepareDesktopAurPackageTask : DefaultTask() { + @get:Input + abstract val aurPackageName: Property + + @get:Input + abstract val providedPackageName: Property + + @get:Input + abstract val packageVersion: Property + + @get:Input + abstract val packageRelease: Property + + @get:Input + abstract val packageDescription: Property + + @get:Input + abstract val appDisplayName: Property + + @get:Input + abstract val installDirectoryName: Property + + @get:Input + abstract val launcherName: Property + + @get:Input + abstract val executableName: Property + + @get:Input + abstract val sourceUrl: Property + + @get:Input + abstract val projectUrl: Property + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val linuxTarFile: RegularFileProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun prepare() { + val output = outputDirectory.get().asFile + val sourceTar = linuxTarFile.get().asFile + if (!sourceTar.isFile) { + throw GradleException("Missing Linux tarball for AUR packaging: ${sourceTar.absolutePath}") + } + + output.deleteRecursively() + output.mkdirs() + + val stagedTar = output.resolve(sourceTar.name) + sourceTar.copyTo(stagedTar, overwrite = true) + val sha256 = stagedTar.sha256() + val configuredSourceUrl = sourceUrl.get().trim() + val sourceEntry = if (configuredSourceUrl.isBlank()) { + stagedTar.name + } else { + "${stagedTar.name}::$configuredSourceUrl" + } + + output.resolve("PKGBUILD").writeText( + aurPkgbuild( + pkgname = aurPackageName.get(), + providedPackage = providedPackageName.get(), + pkgver = packageVersion.get(), + pkgrel = packageRelease.get(), + pkgdesc = packageDescription.get(), + appName = appDisplayName.get(), + installDir = installDirectoryName.get(), + launcher = launcherName.get(), + executable = executableName.get(), + source = sourceEntry, + sha256 = sha256, + projectUrl = projectUrl.get() + ) + ) + output.resolve(".SRCINFO").writeText( + aurSrcInfo( + pkgname = aurPackageName.get(), + providedPackage = providedPackageName.get(), + pkgver = packageVersion.get(), + pkgrel = packageRelease.get(), + pkgdesc = packageDescription.get(), + source = sourceEntry, + sha256 = sha256, + projectUrl = projectUrl.get() + ) + ) + } + + private fun File.sha256(): String { + val digest = MessageDigest.getInstance("SHA-256") + inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun shellSingleQuoted(value: String): String { + return "'" + value.replace("'", "'\"'\"'") + "'" + } + + private fun archRuntimeDependencies(): List { + return listOf( + "alsa-lib", + "atk", + "cairo", + "dbus", + "expat", + "fontconfig", + "freetype2", + "gcc-libs", + "gdk-pixbuf2", + "glib2", + "glibc", + "gtk3", + "libcups", + "libarchive", + "libsecret", + "libx11", + "libxcomposite", + "libxdamage", + "libxext", + "libxi", + "libxrandr", + "libxrender", + "libxtst", + "nss", + "pango", + "zlib" + ) + } + + private fun aurPkgbuild( + pkgname: String, + providedPackage: String, + pkgver: String, + pkgrel: String, + pkgdesc: String, + appName: String, + installDir: String, + launcher: String, + executable: String, + source: String, + sha256: String, + projectUrl: String + ): String { + val desktopFile = "$providedPackage.desktop" + val iconName = providedPackage + val depends = archRuntimeDependencies() + val mimeTypes = archDesktopMimeTypes() + return """ +pkgname=${shellSingleQuoted(pkgname)} +pkgver=${shellSingleQuoted(pkgver)} +pkgrel=${shellSingleQuoted(pkgrel)} +pkgdesc=${shellSingleQuoted(pkgdesc)} +arch=('x86_64') +url=${shellSingleQuoted(projectUrl)} +license=('AGPL-3.0-only') +depends=(${depends.joinToString(" ") { shellSingleQuoted(it) }}) +provides=(${shellSingleQuoted(providedPackage)}) +conflicts=(${shellSingleQuoted(providedPackage)}) +source=(${shellSingleQuoted(source)}) +sha256sums=(${shellSingleQuoted(sha256)}) +options=('!debug') + +package() { + install -dm755 "${'$'}pkgdir/opt/$installDir" + cp -a "$installDir/." "${'$'}pkgdir/opt/$installDir/" + chmod 755 "${'$'}pkgdir/opt/$installDir/bin/$executable" + + install -dm755 "${'$'}pkgdir/usr/bin" + ln -sf "/opt/$installDir/bin/$executable" "${'$'}pkgdir/usr/bin/$launcher" + + install -Dm644 "${'$'}pkgdir/opt/$installDir/share/licenses/LICENSE" "${'$'}pkgdir/usr/share/licenses/${'$'}pkgname/LICENSE" + + local icon_path + icon_path="${'$'}(find "${'$'}pkgdir/opt/$installDir" -name 'episteme_icon.png' -print -quit)" + if [[ -n "${'$'}icon_path" ]]; then + install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/icons/hicolor/512x512/apps/$iconName.png" + install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/pixmaps/$iconName.png" + fi + + install -Dm644 /dev/stdin "${'$'}pkgdir/usr/share/applications/$desktopFile" <<'EOF' +[Desktop Entry] +Type=Application +Name=$appName +Comment=$pkgdesc +Exec=$launcher %F +Icon=$iconName +Terminal=false +Categories=Office;Viewer; +MimeType=${mimeTypes.joinToString(";")}; +EOF +} +""".trimIndent() + "\n" + } + + private fun aurSrcInfo( + pkgname: String, + providedPackage: String, + pkgver: String, + pkgrel: String, + pkgdesc: String, + source: String, + sha256: String, + projectUrl: String + ): String { + val depends = archRuntimeDependencies() + return """ +pkgbase = $pkgname + pkgdesc = $pkgdesc + pkgver = $pkgver + pkgrel = $pkgrel + url = $projectUrl + arch = x86_64 + license = AGPL-3.0-only +${depends.joinToString("\n") { "\tdepends = $it" }} + provides = $providedPackage + conflicts = $providedPackage + source = $source + sha256sums = $sha256 + +pkgname = $pkgname +""".trimIndent() + "\n" + } + + private fun archDesktopMimeTypes(): List { + return listOf( + "application/pdf", + "application/epub+zip", + "application/x-mobipocket-ebook", + "application/vnd.amazon.ebook", + "application/vnd.amazon.mobi8-ebook", + "text/markdown", + "text/x-markdown", + "text/plain", + "text/html", + "application/xhtml+xml", + "application/x-fictionbook+xml", + "application/x-zip-compressed-fb2", + "application/zip", + "application/vnd.comicbook+zip", + "application/x-cbz", + "application/vnd.comicbook-rar", + "application/x-cbr", + "application/x-rar-compressed", + "application/x-cb7", + "application/x-7z-compressed", + "application/vnd.comicbook+tar", + "application/x-cbt", + "application/x-tar", + "application/tar", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.oasis.opendocument.text", + "application/x-vnd.oasis.opendocument.text-flat-xml" + ) + } +} + +@DisableCachingByDefault(because = "Strips stale jar signatures in-place after ProGuard rewrites signed dependencies.") +abstract class StripInvalidJarSignaturesTask : DefaultTask() { + @get:Input + abstract val jarDirectoryPath: Property + + @TaskAction + fun stripSignatures() { + val jarDirectory = File(jarDirectoryPath.get()) + if (!jarDirectory.isDirectory) return + + var strippedJarCount = 0 + jarDirectory.walkTopDown() + .filter { it.isFile && it.extension.equals("jar", ignoreCase = true) } + .forEach { jar -> + val strippedEntries = stripInvalidJarSignatures(jar) + if (strippedEntries > 0) { + strippedJarCount += 1 + logger.lifecycle("Stripped $strippedEntries stale jar signature entr${if (strippedEntries == 1) "y" else "ies"} from ${jar.name}") + } + } + + if (strippedJarCount > 0) { + logger.lifecycle("Stripped stale jar signatures from $strippedJarCount ProGuard output jar${if (strippedJarCount == 1) "" else "s"}.") + } + } + + private fun stripInvalidJarSignatures(jar: File): Int { + val temp = Files.createTempFile(jar.parentFile.toPath(), "${jar.nameWithoutExtension}-unsigned-", ".jar") + var strippedEntries = 0 + + ZipFile(jar).use { source -> + ZipOutputStream(Files.newOutputStream(temp)).use { target -> + val seenEntries = mutableSetOf() + val entries = source.entries() + while (entries.hasMoreElements()) { + val sourceEntry = entries.nextElement() + val entryName = sourceEntry.name + if (!seenEntries.add(entryName)) continue + if (isJarSignatureResource(entryName)) { + strippedEntries += 1 + continue + } + + val targetEntry = ZipEntry(entryName) + if (sourceEntry.time >= 0) { + targetEntry.time = sourceEntry.time + } + target.putNextEntry(targetEntry) + if (!sourceEntry.isDirectory) { + source.getInputStream(sourceEntry).use { input -> + input.copyTo(target) + } + } + target.closeEntry() + } + } + } + + if (strippedEntries > 0) { + try { + Files.move(temp, jar.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temp, jar.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } else { + Files.deleteIfExists(temp) + } + + return strippedEntries + } + + private fun isJarSignatureResource(entryName: String): Boolean { + val normalized = entryName.replace('\\', '/').uppercase() + if (!normalized.startsWith("META-INF/")) return false + + val metaInfName = normalized.removePrefix("META-INF/") + if (metaInfName.contains("/")) return false + + return metaInfName.startsWith("SIG-") || + metaInfName.endsWith(".SF") || + metaInfName.endsWith(".DSA") || + metaInfName.endsWith(".RSA") || + metaInfName.endsWith(".EC") + } +} + fun desktopOsId(osName: String = System.getProperty("os.name")): String { val normalized = osName.lowercase() return when { @@ -86,24 +762,27 @@ fun desktopArchId(osArch: String = System.getProperty("os.arch")): String { } } -fun desktopKcefBundleDirectoryName( +fun desktopSwtArtifactId( osName: String = System.getProperty("os.name"), osArch: String = System.getProperty("os.arch") -): String { +): String? { return when (desktopOsId(osName)) { - "windows" -> "kcef-bundle" - "linux" -> "kcef-bundle-linux-${desktopArchId(osArch)}" - "macos" -> "kcef-bundle-macos-${desktopArchId(osArch)}" - else -> "kcef-bundle-${desktopArchId(osArch)}" - } -} + "windows" -> when (desktopArchId(osArch)) { + "arm64" -> "org.eclipse.swt.win32.win32.aarch64" + else -> "org.eclipse.swt.win32.win32.x86_64" + } -fun bundledWebViewRequiredPaths(osName: String, osArch: String): List { - return when (desktopOsId(osName)) { - "windows" -> listOf("jcef.dll", "libcef.dll") - "linux" -> listOf("libcef.so", "chrome-sandbox", "icudtl.dat", "locales") - "macos" -> listOf("jcef Helper.app", "Chromium Embedded Framework.framework") - else -> emptyList() + "linux" -> when (desktopArchId(osArch)) { + "arm64" -> "org.eclipse.swt.gtk.linux.aarch64" + else -> "org.eclipse.swt.gtk.linux.x86_64" + } + + "macos" -> when (desktopArchId(osArch)) { + "arm64" -> "org.eclipse.swt.cocoa.macosx.aarch64" + else -> "org.eclipse.swt.cocoa.macosx.x86_64" + } + + else -> null } } @@ -313,25 +992,155 @@ fun normalizeDesktopPackageArchitecture(osArch: String): String { } } -fun renameDesktopMsiOutput( - msiDirectory: File, - packageName: String, - packageVersion: String, - architecture: String -) { - val source = msiDirectory.resolve("$packageName-$packageVersion.msi") - if (!source.isFile) return - - val target = msiDirectory.resolve("$packageName-$packageVersion-$architecture.msi") - if (target.exists() && !target.delete()) { - throw GradleException("Could not replace existing MSI at ${target.absolutePath}.") - } - if (!source.renameTo(target)) { - throw GradleException("Could not rename MSI from ${source.absolutePath} to ${target.absolutePath}.") +fun desktopDefaultPackageFormats(osName: String = System.getProperty("os.name")): String { + return when (desktopOsId(osName)) { + "windows" -> "msi" + "linux" -> "deb,rpm" + "macos" -> "dmg" + else -> "" } } -val desktopVersionName = "1.0.0" +fun desktopTargetFormatForId(format: String): TargetFormat { + return when (format.lowercase()) { + "exe" -> TargetFormat.Exe + "msi" -> TargetFormat.Msi + "deb" -> TargetFormat.Deb + "rpm" -> TargetFormat.Rpm + "dmg" -> TargetFormat.Dmg + "pkg" -> TargetFormat.Pkg + else -> throw GradleException( + "Unsupported desktopPackageFormats entry '$format'. " + + "Use one or more of: msi, exe, deb, rpm, dmg, pkg." + ) + } +} + +fun desktopPackageFormatId(format: TargetFormat): String { + return when (format) { + TargetFormat.Exe -> "exe" + TargetFormat.Msi -> "msi" + TargetFormat.Deb -> "deb" + TargetFormat.Rpm -> "rpm" + TargetFormat.Dmg -> "dmg" + TargetFormat.Pkg -> "pkg" + else -> format.name.lowercase() + } +} + +fun desktopPackageFormatSupportedOnHost( + format: TargetFormat, + osName: String = System.getProperty("os.name") +): Boolean { + return when (desktopOsId(osName)) { + "windows" -> format == TargetFormat.Msi || format == TargetFormat.Exe + "linux" -> format == TargetFormat.Deb || format == TargetFormat.Rpm + "macos" -> format == TargetFormat.Dmg || format == TargetFormat.Pkg + else -> false + } +} + +fun normalizeDesktopPackageFormats( + rawFormats: String, + osName: String = System.getProperty("os.name") +): List { + val formats = rawFormats + .split(',', ';', ' ', '\n', '\t') + .map { it.trim() } + .filter { it.isNotBlank() } + .map(::desktopTargetFormatForId) + .distinct() + if (formats.isEmpty()) { + throw GradleException( + "desktopPackageFormats resolved to no package formats for ${desktopOsId(osName)}. " + + "Set -PdesktopPackageFormats=msi on Windows or -PdesktopPackageFormats=deb,rpm on Linux." + ) + } + val unsupported = formats.filterNot { desktopPackageFormatSupportedOnHost(it, osName) } + if (unsupported.isNotEmpty()) { + throw GradleException( + "desktopPackageFormats=${formats.joinToString(",") { desktopPackageFormatId(it) }} does not match " + + "the current packaging host ${desktopOsId(osName)}. Unsupported here: " + + unsupported.joinToString(",") { desktopPackageFormatId(it) } + "." + ) + } + return formats +} + +fun normalizeDesktopMsixVersion(rawVersion: String): String { + val parts = rawVersion.trim().split('.') + if (parts.size !in 3..4 || parts.any { it.isBlank() || it.all(Char::isDigit).not() }) { + throw GradleException( + "desktopMsixVersion must be a numeric Windows package version with three or four parts, " + + "for example 1.0.1 or 1.0.1.0." + ) + } + val normalized = if (parts.size == 3) parts + "0" else parts + normalized.forEach { part -> + val value = part.toIntOrNull() + if (value == null || value !in 0..65535) { + throw GradleException("desktopMsixVersion part '$part' is outside the MSIX range 0..65535.") + } + } + return normalized.joinToString(".") +} + +fun normalizeDesktopMsixIdentityName(rawName: String): String { + val normalized = rawName.trim() + if (!Regex("[A-Za-z0-9][A-Za-z0-9.-]{2,49}").matches(normalized)) { + throw GradleException( + "desktopMsixIdentityName must be 3-50 characters using letters, numbers, dots, or hyphens." + ) + } + return normalized +} + +fun desktopMsixArchitecture(osArch: String = System.getProperty("os.arch")): String { + return when (desktopArchId(osArch)) { + "x64" -> "x64" + "arm64" -> "arm64" + "x86" -> "x86" + else -> "neutral" + } +} + +fun latestExistingFile(candidates: List): File? { + return candidates.filter { it.isFile }.maxByOrNull { it.absolutePath } +} + +fun windowsSdkToolCandidates(toolName: String): List { + val roots = listOfNotNull( + System.getenv("WindowsSdkDir")?.let(::File), + File("C:/Program Files (x86)/Windows Kits/10"), + File("C:/Program Files/Windows Kits/10"), + File("C:/Program Files (x86)/Windows Kits/10/App Certification Kit"), + File("C:/Program Files/Windows Kits/10/App Certification Kit") + ).distinctBy { it.absolutePath.lowercase() } + val sdkBins = roots.flatMap { root -> + safeChildDirectories(root.resolve("bin")).flatMap { versionDir -> + listOf( + versionDir.resolve("x64/$toolName.exe"), + versionDir.resolve("x86/$toolName.exe"), + versionDir.resolve(toolName) + ) + } + } + val directBins = roots.map { root -> root.resolve("$toolName.exe") } + val pathBins = (System.getenv("PATH") ?: "") + .split(File.pathSeparator) + .filter { it.isNotBlank() } + .map { File(it).resolve("$toolName.exe") } + return sdkBins + directBins + pathBins +} + +fun findWindowsSdkTool(toolName: String, explicitPath: String?): File { + val explicit = explicitPath?.trim()?.takeIf { it.isNotEmpty() }?.let(::File) + if (explicit != null) return explicit + return latestExistingFile(windowsSdkToolCandidates(toolName)) + ?: File(rootProject.projectDir, "__missing_windows_sdk_tool__/$toolName.exe") +} + +val desktopVersionName = "1.0.1" val desktopFlavor = providers.gradleProperty("desktopFlavor") .orElse("standard") .map(::normalizeDesktopFlavor) @@ -350,16 +1159,60 @@ val desktopPackageVersion = providers.gradleProperty("desktopPackageVersion") .orElse(desktopResolvedVersionName) .map(::normalizeDesktopPackageVersion) val desktopPackageName = if (isOssOfflineDesktop) "Episteme oss" else "Episteme" +val desktopLinuxPackageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme" val desktopPackageDescription = if (isOssOfflineDesktop) { "Episteme oss offline desktop reader" } else { "Episteme desktop reader" } val desktopVendor = providers.gradleProperty("desktopVendor").orElse("Aryan") +val desktopVendorName = desktopVendor.get() +val desktopProjectUrl = providers.gradleProperty("desktopProjectUrl") + .orElse("https://github.com/Aryan-Raj3112/episteme") val desktopOsName = System.getProperty("os.name") val desktopOsArch = System.getProperty("os.arch") val desktopPackageArchitecture = normalizeDesktopPackageArchitecture(desktopOsArch) +val desktopAurPackageName = providers.gradleProperty("desktopAurPackageName") + .orElse(if (isOssOfflineDesktop) "episteme-oss-bin" else "episteme-bin") +val desktopAurPackageRelease = providers.gradleProperty("desktopAurPackageRelease") + .orElse("1") +val desktopAurSourceUrl = providers.gradleProperty("desktopAurSourceUrl") + .orElse("") +val desktopPackageTargetFormats = providers.gradleProperty("desktopPackageFormats") + .orElse(desktopDefaultPackageFormats(desktopOsName)) + .map { normalizeDesktopPackageFormats(it, desktopOsName) } + .get() +val desktopMsixIdentityName = providers.gradleProperty("desktopMsixIdentityName") + .orElse(if (isOssOfflineDesktop) "Aryan.EpistemeOss" else "Aryan.Episteme") + .map(::normalizeDesktopMsixIdentityName) + .get() +val desktopMsixPublisher = providers.gradleProperty("desktopMsixPublisher") + .orElse("CN=$desktopVendorName") +val desktopMsixPublisherDisplayName = providers.gradleProperty("desktopMsixPublisherDisplayName") + .orElse(desktopVendor) +val desktopMsixVersion = providers.gradleProperty("desktopMsixVersion") + .orElse(desktopPackageVersion) + .map(::normalizeDesktopMsixVersion) + .get() +val desktopMsixArchitecture = desktopMsixArchitecture(desktopOsArch) +val desktopMakeAppxPath = providers.gradleProperty("desktopMakeAppxPath").orNull +val desktopSignToolPath = providers.gradleProperty("desktopSignToolPath").orNull +val desktopMsixCertificatePath = providers.gradleProperty("desktopMsixCertificatePath").orNull +val desktopMsixCertificatePassword = providers.gradleProperty("desktopMsixCertificatePassword") + .orElse("") +val desktopMsixTimestampUrl = providers.gradleProperty("desktopMsixTimestampUrl") + .orElse("http://timestamp.digicert.com") +val desktopNativePackageSupportedHost = desktopOsId(desktopOsName) in setOf("windows", "linux") && + desktopArchId(desktopOsArch) == "x64" +val desktopReleaseProguardEnabled = providers.gradleProperty("desktopReleaseProguard") + .map { it.equals("true", ignoreCase = true) } + .orElse(false) + .get() +val desktopSwtVersion = "3.133.0" +val desktopSwtDependency = desktopSwtArtifactId(desktopOsName, desktopOsArch) + ?.let { artifactId -> "org.eclipse.platform:$artifactId:$desktopSwtVersion" } val generatedDesktopResourcesDir = layout.buildDirectory.dir("generated/desktopAppResources") +val generatedDesktopCloudConfigFile = layout.buildDirectory.file("generated/desktopCloudConfig/desktop-cloud.properties") val generatedDesktopStringResourcesDir = layout.buildDirectory.dir("generated/desktopStringResources") val rootLocalProperties = Properties() val rootLocalPropertiesFile = rootProject.file("local.properties") @@ -375,60 +1228,26 @@ fun desktopConfigValue(vararg keys: String): String { } val desktopCloudConfig = mapOf( "AI_WORKER_URL" to desktopConfigValue("DESKTOP_AI_WORKER_URL", "AI_WORKER_URL"), - "TTS_WORKER_URL" to desktopConfigValue("DESKTOP_TTS_WORKER_URL", "TTS_WORKER_URL", "AI_WORKER_URL"), + "TTS_WORKER_URL" to desktopConfigValue("DESKTOP_TTS_WORKER_URL", "TTS_WORKER_URL"), "FIREBASE_WEB_API_KEY" to desktopConfigValue("DESKTOP_FIREBASE_WEB_API_KEY", "FIREBASE_WEB_API_KEY", "GOOGLE_API_KEY"), "FIREBASE_PROJECT_ID" to desktopConfigValue("DESKTOP_FIREBASE_PROJECT_ID", "FIREBASE_PROJECT_ID").ifBlank { "reader-9fc469d7" }, "GOOGLE_OAUTH_CLIENT_ID" to desktopConfigValue("DESKTOP_GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_WEB_CLIENT_ID", "DEFAULT_WEB_CLIENT_ID"), "GOOGLE_OAUTH_CLIENT_SECRET" to desktopConfigValue("DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_WEB_CLIENT_SECRET", "DEFAULT_WEB_CLIENT_SECRET") ) -val bundledWebViewDir = layout.projectDirectory.dir(desktopKcefBundleDirectoryName(desktopOsName, desktopOsArch)) +val desktopAllowUnconfiguredStandardServices = providers.gradleProperty("desktopAllowUnconfiguredStandardServices") + .map { it.equals("true", ignoreCase = true) } + .orElse(false) + .get() +val desktopMissingStandardServiceConfig = if (isOssOfflineDesktop || desktopAllowUnconfiguredStandardServices) { + emptyList() +} else { + listOf("FIREBASE_WEB_API_KEY", "GOOGLE_OAUTH_CLIENT_ID") + .filter { key -> desktopCloudConfig[key].isNullOrBlank() } +} val bundledPdfiumDir = layout.projectDirectory.dir( "../third_party/pdfium/${desktopPdfiumDirectoryName(desktopOsName, desktopOsArch)}" ) val bundledPdfiumLibraryPath = desktopPdfiumLibraryPath(desktopOsName, desktopOsArch) -val bundledWebViewKeptLocales = setOf( - "ar.pak", - "de.pak", - "en-GB.pak", - "en-US.pak", - "es-419.pak", - "es.pak", - "fr.pak", - "hi.pak", - "pt-BR.pak", - "ru.pak", - "tr.pak", - "vi.pak" -) -val bundledWebViewTrimmedRuntimeFiles = listOf( - "ct.sym", - "jawt.lib", - "jvm.lib", - "jaccessinspector.exe", - "jaccesswalker.exe", - "jabswitch.exe", - "javac.exe", - "javadoc.exe", - "jcmd.exe", - "jdb.exe", - "jfr.exe", - "jhsdb.exe", - "jinfo.exe", - "jmap.exe", - "jps.exe", - "jrunscript.exe", - "jstack.exe", - "jstat.exe", - "jwebserver.exe", - "keytool.exe", - "kinit.exe", - "klist.exe", - "ktab.exe", - "rmiregistry.exe", - "serialver.exe", - "server/classes.jsa", - "server/classes_nocoops.jsa" -) val desktopWindowsIconFile = layout.projectDirectory.file("src/desktopMain/resources/episteme.ico") val desktopLinuxIconFile = layout.projectDirectory.file("src/desktopMain/resources/episteme_icon.png") val desktopWindowsUpgradeUuid = if (isOssOfflineDesktop) { @@ -455,49 +1274,23 @@ val desktopPackagingJavaHome = findDesktopPackagingJavaHome( osName = desktopOsName )?.absolutePath -val checkBundledWebViewRuntime by tasks.registering(CheckBundledWebViewRuntimeTask::class) { - val requiredPaths = bundledWebViewRequiredPaths(desktopOsName, desktopOsArch) - bundleRootPath.set(bundledWebViewDir.asFile.absolutePath) - osName.set(desktopOsName) - osArch.set(desktopOsArch) - this.requiredPaths.set(requiredPaths) -} - val checkBundledPdfiumRuntime by tasks.registering(CheckBundledPdfiumRuntimeTask::class) { bundleRootPath.set(bundledPdfiumDir.asFile.absolutePath) libraryPath.set(bundledPdfiumLibraryPath) } +val generateDesktopCloudConfig by tasks.registering(GenerateDesktopCloudConfigTask::class) { + configValues.set(desktopCloudConfig) + outputFile.set(generatedDesktopCloudConfigFile) +} + val prepareBundledDesktopResources by tasks.registering(Sync::class) { - dependsOn(checkBundledWebViewRuntime, checkBundledPdfiumRuntime) - from(bundledWebViewDir) { - exclude(bundledWebViewTrimmedRuntimeFiles) - val localeExcludes = bundledWebViewDir.asFile - .resolve("locales") - .listFiles { file -> file.isFile && file.extension.equals("pak", ignoreCase = true) } - .orEmpty() - .map { it.name } - .filterNot { it in bundledWebViewKeptLocales } - .map { "locales/$it" } - exclude(localeExcludes) - into("common/kcef-bundle") - } + dependsOn(checkBundledPdfiumRuntime, generateDesktopCloudConfig) from(bundledPdfiumDir) { into("common/third_party/pdfium/${desktopPdfiumDirectoryName(desktopOsName, desktopOsArch)}") } into("common") { - from( - providers.provider { - temporaryDir.resolve("desktop-cloud.properties").also { file -> - file.parentFile.mkdirs() - file.writeText( - desktopCloudConfig.entries.joinToString(separator = "\n", postfix = "\n") { (key, value) -> - "$key=${value.replace("\\", "\\\\").replace("\n", "")}" - } - ) - } - } - ) + from(generatedDesktopCloudConfigFile) } into(generatedDesktopResourcesDir) } @@ -512,6 +1305,134 @@ val prepareDesktopStringResources by tasks.registering(Sync::class) { into(generatedDesktopStringResourcesDir) } +val verifyDesktopNativePackaging by tasks.registering(VerifyDesktopNativePackagingTask::class) { + supportedHost.set(desktopNativePackageSupportedHost) + hostOsId.set(desktopOsId(desktopOsName)) + hostArchId.set(desktopArchId(desktopOsArch)) + missingStandardServiceConfig.set(desktopMissingStandardServiceConfig) +} + +val desktopDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main/app/$desktopPackageName") +val desktopReleaseDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main-release/app/$desktopPackageName") +val desktopLinuxTarFileName = "${desktopLinuxPackageName}-${desktopPackageVersion.get()}-linux-$desktopPackageArchitecture.tar.gz" +val desktopAurOutputDir = layout.buildDirectory.dir("aur/${desktopAurPackageName.get()}") +val desktopMsixPackageDir = layout.buildDirectory.dir("msix/package") +val desktopMsixAssetsDir = layout.buildDirectory.dir("msix/generated/assets") +val desktopMsixManifestFile = layout.buildDirectory.file("msix/generated/AppxManifest.xml") +val desktopMsixOutputFile = layout.buildDirectory.file( + "compose/binaries/main-release/msix/${desktopLinuxPackageName}-${desktopPackageVersion.get()}-windows-$desktopPackageArchitecture.msix" +) + +val packageLinuxTar by tasks.registering(Tar::class) { + group = "distribution" + description = "Packages the Linux desktop distributable as a tar.gz for Arch/AUR packaging." + dependsOn("createDistributable") + + archiveFileName.set(desktopLinuxTarFileName) + destinationDirectory.set(layout.buildDirectory.dir("compose/binaries/main/linux-tar")) + compression = Compression.GZIP + + from(desktopDistributableAppDir) { + into(desktopLinuxPackageName) + } + from(desktopLinuxIconFile) { + into("$desktopLinuxPackageName/share") + } + from(rootProject.layout.projectDirectory.file("LICENSE")) { + into("$desktopLinuxPackageName/share/licenses") + } +} + +val prepareAurPackage by tasks.registering(PrepareDesktopAurPackageTask::class) { + group = "distribution" + description = "Generates a local AUR package directory with PKGBUILD and .SRCINFO." + dependsOn(packageLinuxTar) + + aurPackageName.set(desktopAurPackageName) + providedPackageName.set(desktopLinuxPackageName) + packageVersion.set(desktopPackageVersion) + packageRelease.set(desktopAurPackageRelease) + packageDescription.set(desktopPackageDescription) + appDisplayName.set(desktopPackageName) + installDirectoryName.set(desktopLinuxPackageName) + launcherName.set(desktopLinuxPackageName) + executableName.set(desktopPackageName) + sourceUrl.set(desktopAurSourceUrl) + projectUrl.set(desktopProjectUrl) + linuxTarFile.set(packageLinuxTar.flatMap { it.archiveFile }) + outputDirectory.set(desktopAurOutputDir) +} + +tasks.register("packageAur") { + group = "distribution" + description = "Builds the generated AUR package with makepkg. Run this on Arch Linux." + dependsOn(prepareAurPackage) + + commandLine("makepkg", "-sf", "--cleanbuild") + workingDir = desktopAurOutputDir.get().asFile +} + +val generateDesktopMsixManifest by tasks.registering(GenerateDesktopMsixManifestTask::class) { + identityName.set(desktopMsixIdentityName) + publisher.set(desktopMsixPublisher) + publisherDisplayName.set(desktopMsixPublisherDisplayName) + packageName.set(desktopPackageName) + packageDescription.set(desktopPackageDescription) + packageVersion.set(desktopMsixVersion) + architecture.set(desktopMsixArchitecture) + executablePath.set("$desktopPackageName.exe") + outputFile.set(desktopMsixManifestFile) +} + +val generateDesktopMsixAssets by tasks.registering(GenerateDesktopMsixAssetsTask::class) { + sourceIconFile.set(desktopLinuxIconFile) + outputDirectory.set(desktopMsixAssetsDir) +} + +val prepareReleaseMsixPackage by tasks.registering(Sync::class) { + group = "distribution" + description = "Stages the release Windows app image and MSIX metadata for makeappx." + dependsOn("createReleaseDistributable", generateDesktopMsixManifest, generateDesktopMsixAssets) + + from(desktopReleaseDistributableAppDir) + from(desktopMsixManifestFile) + from(desktopMsixAssetsDir) { + into("Assets") + } + into(desktopMsixPackageDir) +} + +val packageReleaseMsix by tasks.registering(PackageDesktopMsixTask::class) { + group = "distribution" + description = "Packages the release Windows app image as an MSIX using Windows SDK makeappx." + dependsOn(prepareReleaseMsixPackage) + + val makeAppx = findWindowsSdkTool("makeappx", desktopMakeAppxPath) + packageRootDirectory.set(desktopMsixPackageDir) + outputFile.set(desktopMsixOutputFile) + makeAppxPath.set(makeAppx.absolutePath) + hostOsId.set(desktopOsId(desktopOsName)) + hostArchId.set(desktopArchId(desktopOsArch)) +} + +val signReleaseMsix = desktopMsixCertificatePath?.trim()?.takeIf { it.isNotEmpty() }?.let { certificatePath -> + tasks.register("signReleaseMsix") { + group = "distribution" + description = "Signs the release MSIX with signtool when -PdesktopMsixCertificatePath is configured." + dependsOn(packageReleaseMsix) + + val signTool = findWindowsSdkTool("signtool", desktopSignToolPath) + val resolvedCertificateFile = File(certificatePath).let { file -> + if (file.isAbsolute) file else project.file(certificatePath) + } + unsignedMsixFile.set(desktopMsixOutputFile) + certificateFile.set(resolvedCertificateFile) + signToolPath.set(signTool.absolutePath) + certificatePassword.set(desktopMsixCertificatePassword) + timestampUrl.set(desktopMsixTimestampUrl) + } +} + kotlin { jvm("desktop") jvmToolchain(21) @@ -523,12 +1444,14 @@ kotlin { implementation(project(":shared")) implementation(compose.desktop.currentOs) implementation(compose.material3) - implementation(compose.materialIconsExtended) - implementation("io.github.kevinnzou:compose-webview-multiplatform:2.0.3") + desktopSwtDependency?.let { dependency -> + compileOnly(dependency) + runtimeOnly(dependency) + } + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("net.java.dev.jna:jna:5.17.0") implementation("org.apache.commons:commons-compress:1.28.0") - implementation("org.apache.thrift:libthrift:0.22.0") implementation("org.tukaani:xz:1.10") implementation("com.twelvemonkeys.imageio:imageio-webp:3.13.1") } @@ -543,7 +1466,7 @@ kotlin { compose.desktop { application { - mainClass = "com.aryan.reader.desktop.LauncherKt" + mainClass = "org.dueattendant149.bookreader.desktop.LauncherKt" desktopPackagingJavaHome?.let { javaHome = it } jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") @@ -554,6 +1477,10 @@ compose.desktop { jvmArgs("-Depisteme.desktop.version=${desktopResolvedVersionName.get()}") buildTypes.release.proguard { + // ProGuard still rewrites and shrinks release jars even when optimization and + // obfuscation are disabled. That has produced invalid stack-map frames in large + // Compose/PDF lambdas and stripped WebView bridge behavior in packaged MSIs. + isEnabled.set(desktopReleaseProguardEnabled) obfuscate.set(false) // Compose/Kotlin generated methods can produce very large stack-map frames. // ProGuard optimization has emitted invalid frames for SharedAppTheme in release builds. @@ -562,8 +1489,17 @@ compose.desktop { } nativeDistributions { - targetFormats(TargetFormat.Exe, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) - modules("java.net.http") + targetFormats(*desktopPackageTargetFormats.toTypedArray()) + modules( + "java.datatransfer", + "java.desktop", + "java.logging", + "java.management", + "java.net.http", + "jdk.charsets", + "jdk.httpserver", + "jdk.unsupported" + ) packageName = desktopPackageName packageVersion = desktopPackageVersion.get() description = desktopPackageDescription @@ -580,7 +1516,7 @@ compose.desktop { } linux { iconFile.set(desktopLinuxIconFile) - packageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme" + packageName = desktopLinuxPackageName debMaintainer = "epistemereader@gmail.com" menuGroup = "Office" appCategory = "Office" @@ -612,19 +1548,42 @@ tasks.withType().configureEach { } } +val stripReleaseProguardJarSignatures = if (desktopReleaseProguardEnabled) { + tasks.registering(StripInvalidJarSignaturesTask::class) { + dependsOn("proguardReleaseJars") + jarDirectoryPath.set(layout.buildDirectory.dir("compose/tmp/main-release/proguard").map { it.asFile.absolutePath }) + } +} else { + null +} + +tasks.matching { + it.name in setOf( + "createReleaseDistributable", + "packageReleaseDistributionForCurrentOS", + "packageReleaseExe", + "packageReleaseMsi", + "packageReleaseMsix", + "packageReleaseDeb", + "packageReleaseRpm", + "runReleaseDistributable" + ) +}.configureEach { + stripReleaseProguardJarSignatures?.let { dependsOn(it) } +} + mapOf( "packageMsi" to "main", "packageReleaseMsi" to "main-release" ).forEach { (taskName, distributionName) -> + val renameTask = tasks.register("rename${taskName.replaceFirstChar(Char::titlecase)}Output") { + msiDirectoryPath.set(layout.buildDirectory.dir("compose/binaries/$distributionName/msi").get().asFile.absolutePath) + packageName.set(desktopPackageName) + packageVersion.set(desktopPackageVersion.get()) + architecture.set(desktopPackageArchitecture) + } tasks.matching { it.name == taskName }.configureEach { - doLast { - renameDesktopMsiOutput( - msiDirectory = layout.buildDirectory.dir("compose/binaries/$distributionName/msi").get().asFile, - packageName = desktopPackageName, - packageVersion = desktopPackageVersion.get(), - architecture = desktopPackageArchitecture - ) - } + finalizedBy(renameTask) } } @@ -640,14 +1599,21 @@ tasks.matching { "packageReleaseExe", "packageMsi", "packageReleaseMsi", + "prepareReleaseMsixPackage", + "packageReleaseMsix", + "signReleaseMsix", "packageDeb", "packageReleaseDeb", "packageRpm", "packageReleaseRpm", + "packageLinuxTar", + "prepareAurPackage", + "packageAur", "runDistributable", "runReleaseDistributable" ) }.configureEach { + dependsOn(verifyDesktopNativePackaging) dependsOn(prepareBundledDesktopResources) inputs.dir(generatedDesktopResourcesDir) .withPropertyName("bundledDesktopResources") diff --git a/desktopApp/compose-desktop.pro b/desktopApp/compose-desktop.pro index bfec64c..d3805a8 100644 --- a/desktopApp/compose-desktop.pro +++ b/desktopApp/compose-desktop.pro @@ -1,5 +1,3 @@ --keep class org.cef.** { *; } --keep class org.apache.thrift.** { *; } -keep class io.ktor.serialization.kotlinx.** { *; } -keep class io.ktor.serialization.kotlinx.json.** { *; } -keep class com.sun.jna.** { *; } @@ -7,17 +5,13 @@ -keep class * extends com.sun.jna.Structure { *; } -keep class kotlinx.coroutines.swing.SwingDispatcherFactory -# Desktop release shrinking sees optional integrations from JCEF/KCEF, JOGL, Commons -# Compress Pack200, and OkHttp platform probes. These references are not bundled for -# the Windows MSI path, so keep ProGuard from treating them as release blockers. --dontwarn com.jetbrains.cef.** +# Desktop release shrinking sees optional integrations from JOGL, Commons Compress +# Pack200, and OkHttp platform probes, so keep ProGuard from treating them as blockers. -dontwarn com.jetbrains.JBR --dontwarn org.cef.** -dontwarn com.jogamp.** -dontwarn jogamp.** -dontwarn org.apache.commons.compress.harmony.pack200.** -dontwarn org.objectweb.asm.** --dontwarn org.apache.thrift.** -dontwarn io.ktor.serialization.kotlinx.** -dontwarn com.sun.jna.** -dontwarn org.eclipse.swt.** diff --git a/desktopApp/packaging/README.md b/desktopApp/packaging/README.md new file mode 100644 index 0000000..65cd664 --- /dev/null +++ b/desktopApp/packaging/README.md @@ -0,0 +1,229 @@ +# Desktop package builds + +Build Linux packages on the matching distro VM when testing manually: + +```bash +cd ~/Reader +./gradlew :desktopApp:packageDeb -x test +./gradlew :desktopApp:packageRpm -x test +./gradlew :desktopApp:packageAur -x test +``` + +Build a Windows MSIX locally on Windows with the Windows SDK installed: + +```powershell +cd C:\Users\aryan\Desktop\Reader +.\gradlew.bat -PdesktopOnly=true -PdesktopAllowUnconfiguredStandardServices=true :desktopApp:packageReleaseMsix -x test +``` + +Copy the newest generated MSIX to your desktop: + +```powershell +$msix = Get-ChildItem .\desktopApp\build\compose\binaries\main-release\msix -Filter *.msix | Sort-Object LastWriteTime -Descending | Select-Object -First 1 +Copy-Item -Force $msix.FullName "$env:USERPROFILE\Desktop\" +``` + +The MSIX task is separate from MSI packaging. It stages the release app image at +`desktopApp/build/msix/package`, packages it with Windows SDK `makeappx.exe`, and +writes the MSIX to: + +```text +desktopApp/build/compose/binaries/main-release/msix +``` + +For Microsoft Store submission, set the package identity values from Partner +Center so `AppxManifest.xml` matches the reserved app identity: + +```powershell +.\gradlew.bat ` + -PdesktopOnly=true ` + -PdesktopMsixIdentityName= ` + -PdesktopMsixPublisher= ` + -PdesktopMsixPublisherDisplayName= ` + :desktopApp:packageReleaseMsix -x test +``` + +If Windows SDK tools are not on `PATH`, pass them explicitly: + +```powershell +.\gradlew.bat ` + -PdesktopMakeAppxPath="C:\Program Files (x86)\Windows Kits\10\bin\\x64\makeappx.exe" ` + :desktopApp:packageReleaseMsix -x test +``` + +Local signing is optional and separate: + +```powershell +.\gradlew.bat ` + -PdesktopMsixCertificatePath=C:\path\to\certificate.pfx ` + -PdesktopMsixCertificatePassword= ` + :desktopApp:signReleaseMsix -x test +``` + +Recommended VM split: + +- Ubuntu: `./gradlew :desktopApp:packageDeb -x test` +- Fedora: `./gradlew :desktopApp:packageRpm -x test` +- Arch: `./gradlew :desktopApp:packageAur -x test` + +Desktop-only Gradle invocations automatically skip the Android app module and the +Android target in `:shared`, so desktop packaging does not require `sdk.dir`, +Android SDK installation, or Android release signing values. You can force that +mode for unusual command shapes with: + +```bash +./gradlew -PdesktopOnly=true :desktopApp:packageDeb -x test +``` + +Desktop release values are centralized in `gradle.properties`: + +```properties +desktopVersion=1.0.1 +desktopPackageVersion=1.0.1 +desktopAurPackageRelease=1 +``` + +The AUR path is native Arch packaging. It does not wrap the `.deb` or `.rpm`. +`packageAur` first creates a Linux app tarball, then generates an AUR worktree at: + +```text +desktopApp/build/aur/episteme-bin +``` + +On Arch, install/test the generated package with: + +```bash +sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-bin/*.pkg.tar.zst +episteme +``` + +For the OSS/offline flavor: + +```bash +./gradlew :desktopApp:packageAur -PdesktopFlavor=oss -x test +sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-oss-bin/*.pkg.tar.zst +episteme-oss +``` + +To inspect the AUR recipe manually instead: + +```bash +./gradlew :desktopApp:prepareAurPackage -x test +cd ~/Reader/desktopApp/build/aur/episteme-bin +makepkg -si +``` + +For publish-ready AUR metadata, pass the release tarball URL: + +```bash +./gradlew :desktopApp:prepareAurPackage \ + -PdesktopAurSourceUrl=https://example.com/releases/episteme-1.0.1-linux-x64.tar.gz \ + -x test +``` + +Then publish the generated `PKGBUILD` and `.SRCINFO` from the AUR directory. + +The generated AUR recipes use `license=('AGPL-3.0-only')` and install the root +`LICENSE` file into `/usr/share/licenses/$pkgname/`. + +## AUR repository setup + +Create an account at: + +```text +https://aur.archlinux.org/register/ +``` + +Add your public SSH key in the account settings, then confirm SSH works: + +```bash +ssh aur@aur.archlinux.org +``` + +The command should authenticate and print AUR help text. It will not open a +normal shell. + +Create the package repos by cloning their not-yet-existing names: + +```bash +git clone ssh://aur@aur.archlinux.org/episteme-bin.git +git clone ssh://aur@aur.archlinux.org/episteme-oss-bin.git +``` + +If a name already exists, inspect it first. If it is abandoned, follow the AUR +orphan/adoption process instead of creating a duplicate package name. + +For each release, extract the matching `aur--.tar.gz` metadata +archive from the GitHub release, copy `PKGBUILD` and `.SRCINFO` into the matching +AUR clone, then commit and push: + +```bash +tar -xzf aur-episteme-bin-1.0.1.tar.gz -C episteme-bin +cd episteme-bin +git add PKGBUILD .SRCINFO +git commit -m "Update to 1.0.1" +git push +``` + +Repeat the same flow for `episteme-oss-bin`. + +## CI release workflow + +`Desktop release` in GitHub Actions builds desktop artifacts for standard and +OSS flavors: + +- Windows MSI +- Ubuntu/Debian DEB +- Fedora RPM +- Linux tarball used by AUR +- Direct Arch `.pkg.tar.zst` +- AUR metadata archives containing `PKGBUILD` and `.SRCINFO` +- `SHA256SUMS.txt` + +Before running it, publish Pdfium once from a machine that has the ignored +`third_party/pdfium` folders: + +```powershell +.\scripts\desktop\publish-pdfium-release.ps1 ` + -Repository Aryan-Raj3112/episteme ` + -Tag pdfium-desktop-v1 +``` + +That release must contain: + +```text +pdfium-linux-x64-v8.zip +pdfium-win-x64-v8.zip +``` + +The desktop release workflow downloads those assets with: + +```powershell +.\scripts\desktop\download-pdfium.ps1 -Tag pdfium-desktop-v1 +``` + +Required GitHub Secrets for standard desktop packages: + +```text +DESKTOP_FIREBASE_PROJECT_ID +DESKTOP_FIREBASE_WEB_API_KEY +DESKTOP_GOOGLE_OAUTH_CLIENT_ID +DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET +``` + +`MYAPP_RELEASE_STORE_FILE` is not used by desktop packaging. Android is skipped +for `:desktopApp:*` tasks. + +AUR publishing still needs the two AUR repos: + +```text +episteme-bin +episteme-oss-bin +``` + +Upload the generated `PKGBUILD` and `.SRCINFO` from: + +```text +aur-episteme-bin-.tar.gz +aur-episteme-oss-bin-.tar.gz +``` diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSidecarSync.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSidecarSync.kt deleted file mode 100644 index 03bcbec..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSidecarSync.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.aryan.reader.desktop - -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer -import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec -import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import java.io.File - -internal object DesktopCloudSidecarSync { - fun hasLocalAnnotationData(book: BookItem): Boolean { - val path = book.path?.takeIf { it.isNotBlank() } ?: return false - if (book.type != FileType.PDF) return false - return desktopPdfAnnotationFile(path).isFile || - desktopPdfBookmarkFile(path).isFile || - desktopPdfRichTextFile(path).isFile - } - - fun localAnnotationTimestamp(book: BookItem): Long { - val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L - if (book.type != FileType.PDF) return 0L - return maxOf( - desktopPdfAnnotationFile(path).lastModifiedIfFile(), - desktopPdfBookmarkFile(path).lastModifiedIfFile(), - desktopPdfRichTextFile(path).lastModifiedIfFile() - ) - } - - fun exportAnnotationBundle(book: BookItem): File? { - val path = book.path?.takeIf { it.isNotBlank() } ?: return null - if (book.type != FileType.PDF) return null - val annotationFile = desktopPdfAnnotationFile(path) - val bookmarkFile = desktopPdfBookmarkFile(path) - val richTextFile = desktopPdfRichTextFile(path) - val data = buildMap { - if (annotationFile.isFile) { - val annotations = SharedPdfAnnotationSerializer.decode(annotationFile.readText()) - put( - SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, - SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations) - ) - } - if (bookmarkFile.isFile) { - cloudSidecarJson.parseElementOrNull(bookmarkFile.readText())?.let { put("bookmarks", it) } - } - if (richTextFile.isFile) { - cloudSidecarJson.parseElementOrNull(richTextFile.readText())?.let { element -> - put("text", SharedPdfRichTextSerializer.encodeElement(SharedPdfRichTextSerializer.decodeElement(element))) - } - } - } - if (data.isEmpty()) return null - val payload = JsonObject(mapOf("version" to JsonPrimitive(2)) + data) - val canonical = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson( - cloudSidecarJson.encodeToString(JsonElement.serializer(), payload) - ) - val tempFile = File( - desktopUserCacheRoot(), - "sync_bundle_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json" - ) - tempFile.parentFile?.mkdirs() - tempFile.writeText(canonical) - return tempFile - } - - fun importAnnotationBundle(book: BookItem, rawJson: String, timestamp: Long): Boolean { - val path = book.path?.takeIf { it.isNotBlank() } ?: return false - if (book.type != FileType.PDF) return false - val root = cloudSidecarJson.parseElementOrNull(rawJson)?.jsonObjectOrNull() ?: return false - val data = root["data"]?.jsonObjectOrNull() ?: root - val canonicalData = SharedPdfAnnotationSidecarCodec.withCanonicalAnnotations(data) - val annotationFile = desktopPdfAnnotationFile(path) - val bookmarkFile = desktopPdfBookmarkFile(path) - val richTextFile = desktopPdfRichTextFile(path) - - if (canonicalData.hasPdfAnnotationPayload()) { - val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(canonicalData) - annotationFile.parentFile?.mkdirs() - annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) - annotationFile.setLastModified(timestamp) - } else if (annotationFile.isFile) { - annotationFile.delete() - } - - canonicalData["bookmarks"]?.let { bookmarks -> - bookmarkFile.parentFile?.mkdirs() - bookmarkFile.writeText(cloudSidecarJson.encodeToString(JsonElement.serializer(), bookmarks)) - bookmarkFile.setLastModified(timestamp) - } ?: run { - if (bookmarkFile.isFile) bookmarkFile.delete() - } - - canonicalData["text"]?.let { richText -> - val richDocument = SharedPdfRichTextSerializer.decodeElement(richText) - richTextFile.parentFile?.mkdirs() - richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument)) - richTextFile.setLastModified(timestamp) - } ?: run { - if (richTextFile.isFile) richTextFile.delete() - } - return true - } -} - -private val cloudSidecarJson = Json { - ignoreUnknownKeys = true - prettyPrint = true - encodeDefaults = true -} - -private fun Json.parseElementOrNull(raw: String): JsonElement? { - return runCatching { parseToJsonElement(raw) }.getOrNull() -} - -private fun JsonElement.jsonObjectOrNull(): JsonObject? { - if (this is JsonNull) return null - return runCatching { jsonObject }.getOrNull() -} - -private fun JsonObject.hasPdfAnnotationPayload(): Boolean { - return containsKey(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) || - containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_INK) || - containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_TEXT_BOXES) || - containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_HIGHLIGHTS) -} - -private fun File.lastModifiedIfFile(): Long { - return if (isFile) lastModified() else 0L -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSync.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSync.kt deleted file mode 100644 index 9b0303d..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSync.kt +++ /dev/null @@ -1,668 +0,0 @@ -package com.aryan.reader.desktop - -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.BookShelfRef -import com.aryan.reader.shared.CustomFontItem -import com.aryan.reader.shared.EpubAnnotationSerializer -import com.aryan.reader.shared.EpubBookmark -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.ReaderLocator -import com.aryan.reader.shared.SharedFileCapabilities -import com.aryan.reader.shared.SharedReaderScreenState -import com.aryan.reader.shared.ShelfRecord -import com.aryan.reader.shared.reader.ReaderBookmark -import java.io.File - -internal data class DesktopCloudSyncInput( - val userId: String, - val idToken: String, - val driveAccessToken: String, - val deviceId: String, - val state: SharedReaderScreenState, - val shelfRecords: List, - val shelfRefs: List, - val customFonts: List, - val includeFolderBooks: Boolean -) - -internal data class DesktopCloudSyncResult( - val state: SharedReaderScreenState, - val shelfRecords: List, - val shelfRefs: List, - val customFonts: List, - val uploadedBooks: Int = 0, - val downloadedBooks: Int = 0 -) - -internal class DesktopCloudSync( - private val firestoreRepository: DesktopFirestoreRepository, - private val driveRepository: DesktopGoogleDriveRepository, - private val bookImporter: DesktopBookImporter, - private val customFontStore: DesktopCustomFontStore -) { - suspend fun sync(input: DesktopCloudSyncInput): DesktopCloudSyncResult { - var state = input.state - var shelfRecords = input.shelfRecords - var shelfRefs = input.shelfRefs - var customFonts = input.customFonts - var uploadedBooks = 0 - var downloadedBooks = 0 - - val remoteBooks = firestoreRepository.getAllBooks(input.userId, input.idToken) - .filterNot { isDesktopPdfReflowBookId(it.bookId) } - .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } - val remoteShelves = firestoreRepository.getAllShelves(input.userId, input.idToken) - val remoteFonts = firestoreRepository.getAllFonts(input.userId, input.idToken) - var driveFiles = driveRepository.getFiles(input.driveAccessToken).associateBy { it.name } - - val localBooks = state.rawLibraryBooks - .filterNot { isDesktopPdfReflowBookId(it.id) } - .filter { input.includeFolderBooks || it.sourceFolder == null } - .filterNot { it.path?.startsWith("opds-pse") == true } - .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } - val localBooksMap = localBooks.associateBy { it.id } - val remoteBooksMap = remoteBooks.associateBy { it.bookId } - val allBookIds = (localBooksMap.keys + remoteBooksMap.keys).distinct() - - allBookIds.forEach { bookId -> - val local = localBooksMap[bookId] - val remote = remoteBooksMap[bookId] - if (local?.sourceFolder != null) return@forEach - - when { - local != null && remote == null -> { - uploadBookAndMetadata(input, local, uploadContent = true)?.let { synced -> - state = state.upsertCloudBook(synced) - uploadedBooks += 1 - } - } - - local == null && remote != null -> { - if (remote.isDeleted) return@forEach - val downloaded = downloadRemoteBook(input.driveAccessToken, remote, null, driveFiles) - val remoteBook = downloaded ?: remote.toDesktopBookItem() - state = state.upsertCloudBook(remoteBook) - if (downloaded != null) downloadedBooks += 1 - if (remote.hasAnnotations) { - downloadAnnotations(input.driveAccessToken, remoteBook, remote.lastModifiedTimestamp) - } - } - - local != null && remote != null -> { - if (remote.isDeleted) { - state = state.removeCloudBook(bookId) - return@forEach - } - - val remoteBook = remote.toDesktopBookItem(existing = local) - val shouldDownloadContent = shouldDownloadRemoteBookContent(local, remote) - val downloaded = if (shouldDownloadContent) { - downloadRemoteBook(input.driveAccessToken, remote, local, driveFiles) - } else { - null - } - val localSidecarTimestampBeforeMerge = DesktopCloudSidecarSync.localAnnotationTimestamp(local) - val localMetadataTimestamp = maxOf(local.timestamp, localSidecarTimestampBeforeMerge) - - if (localMetadataTimestamp > remote.lastModifiedTimestamp) { - uploadBookAndMetadata(input, local, uploadContent = shouldUploadLocalBookContent(local, remote))?.let { synced -> - state = state.upsertCloudBook(synced) - uploadedBooks += 1 - } - } else if (remote.lastModifiedTimestamp > local.timestamp || downloaded != null) { - state = state.upsertCloudBook(downloaded ?: remoteBook) - } - - val localSidecarTimestamp = DesktopCloudSidecarSync.localAnnotationTimestamp(downloaded ?: local) - val needsAnnotationDownload = remote.hasAnnotations && - (remote.lastModifiedTimestamp > localSidecarTimestamp || localSidecarTimestamp == 0L) - if (needsAnnotationDownload) { - val targetBook = downloaded ?: state.rawLibraryBooks.firstOrNull { it.id == bookId } ?: local - downloadAnnotations(input.driveAccessToken, targetBook, remote.lastModifiedTimestamp) - } - } - } - } - - driveFiles = driveRepository.getFiles(input.driveAccessToken).associateBy { it.name } - state.rawLibraryBooks - .filterNot { isDesktopPdfReflowBookId(it.id) } - .filter { it.sourceFolder == null } - .filterNot { it.path?.startsWith("opds-pse") == true } - .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } - .forEach { book -> - val driveName = desktopCloudBookDriveFileName(book.id, book.type) ?: return@forEach - val localFile = book.path?.let(::File) - when { - localFile?.isFile == true && driveFiles[driveName] == null -> { - if (driveRepository.uploadFile(input.driveAccessToken, book.id, localFile, book.type) != null) { - uploadedBooks += 1 - } - } - - (localFile == null || !localFile.isFile) && driveFiles[driveName] != null -> { - val remote = remoteBooksMap[book.id] ?: return@forEach - downloadRemoteBook(input.driveAccessToken, remote, book, driveFiles)?.let { downloaded -> - state = state.upsertCloudBook(downloaded) - downloadedBooks += 1 - } - } - } - } - - val shelfSync = syncShelves( - userId = input.userId, - idToken = input.idToken, - deviceId = input.deviceId, - shelfRecords = shelfRecords, - shelfRefs = shelfRefs, - syncableBookIds = state.rawLibraryBooks - .filterNot { isDesktopPdfReflowBookId(it.id) } - .mapTo(mutableSetOf()) { it.id }, - remoteShelves = remoteShelves - ) - shelfRecords = shelfSync.records - shelfRefs = shelfSync.refs - - customFonts = syncFonts( - userId = input.userId, - idToken = input.idToken, - accessToken = input.driveAccessToken, - localFonts = customFonts, - remoteFonts = remoteFonts - ) - - return DesktopCloudSyncResult( - state = state, - shelfRecords = shelfRecords, - shelfRefs = shelfRefs, - customFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }, - uploadedBooks = uploadedBooks, - downloadedBooks = downloadedBooks - ) - } - - suspend fun uploadBookAndMetadata( - input: DesktopCloudSyncInput, - book: BookItem, - uploadContent: Boolean - ): BookItem? { - if (isDesktopPdfReflowBookId(book.id)) return null - if (book.sourceFolder != null) return null - if (book.path?.startsWith("opds-pse") == true) return null - if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) return null - if (uploadContent) { - val source = book.path?.let(::File)?.takeIf { it.isFile } - if (source != null && driveRepository.uploadFile(input.driveAccessToken, book.id, source, book.type) == null) { - return null - } - } - - val bundle = DesktopCloudSidecarSync.exportAnnotationBundle(book) - try { - if (bundle != null && driveRepository.uploadAnnotationFile(input.driveAccessToken, book.id, bundle) == null) { - return null - } - } finally { - bundle?.delete() - } - - val now = System.currentTimeMillis() - val syncedBook = book.copy(timestamp = now) - firestoreRepository.syncBookMetadata( - userId = input.userId, - book = syncedBook.toDesktopCloudBookMetadata( - hasAnnotations = bundle != null, - timestamp = now - ), - originDeviceId = input.deviceId, - idToken = input.idToken - ) - return syncedBook - } - - suspend fun deleteBooksFromCloud( - userId: String, - idToken: String, - accessToken: String, - deviceId: String, - books: List - ) { - val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name } - books - .filterNot { isDesktopPdfReflowBookId(it.id) } - .filter { it.sourceFolder == null } - .filterNot { it.path?.startsWith("opds-pse") == true } - .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } - .forEach { book -> - firestoreRepository.syncBookMetadata( - userId = userId, - book = book.toDesktopCloudBookMetadata( - hasAnnotations = false, - timestamp = System.currentTimeMillis() - ).copy(isDeleted = true), - originDeviceId = deviceId, - idToken = idToken - ) - desktopCloudBookDriveFileName(book.id, book.type) - ?.let { driveFiles[it]?.id } - ?.let { driveRepository.deleteDriveFile(accessToken, it) } - driveFiles["annotation_${book.id}.json"]?.id - ?.let { driveRepository.deleteDriveFile(accessToken, it) } - } - } - - suspend fun syncShelfChange( - userId: String, - idToken: String, - deviceId: String, - record: ShelfRecord, - refs: List, - isDeleted: Boolean = false - ) { - if (record.isSmart) return - firestoreRepository.syncShelf( - userId = userId, - shelf = DesktopCloudShelfMetadata( - name = record.name, - bookIds = refs.filter { it.shelfId == record.id }.map { it.bookId }.distinct(), - lastModifiedTimestamp = System.currentTimeMillis(), - isDeleted = isDeleted - ), - originDeviceId = deviceId, - idToken = idToken - ) - } - - suspend fun clearCloudData(userId: String, idToken: String, accessToken: String) { - driveRepository.deleteAllFiles(accessToken) - firestoreRepository.deleteAllUserFirestoreData(userId, idToken) - } - - suspend fun deleteFontFromCloud( - userId: String, - idToken: String, - accessToken: String, - font: CustomFontItem - ) { - val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name } - driveFiles[font.fileName]?.id?.let { driveRepository.deleteDriveFile(accessToken, it) } - firestoreRepository.deleteFontMetadata(userId, font.id, idToken) - } - - private suspend fun downloadAnnotations(accessToken: String, book: BookItem, timestamp: Long): Boolean { - val temp = File(desktopUserCacheRoot(), "temp_download_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json") - return try { - if (!driveRepository.downloadAnnotationFile(accessToken, book.id, temp) || !temp.isFile) return false - DesktopCloudSidecarSync.importAnnotationBundle(book, temp.readText(), timestamp) - } finally { - temp.delete() - } - } - - private suspend fun downloadRemoteBook( - accessToken: String, - remote: DesktopCloudBookMetadata, - existing: BookItem?, - driveFiles: Map - ): BookItem? { - val type = remote.fileType() - val driveName = desktopCloudBookDriveFileName(remote.bookId, type) ?: return null - val driveFile = driveFiles[driveName] ?: return null - val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null - val destination = bookImporter.createBookFile("${remote.bookId.toDesktopSafeFileName()}.$extension") - if (!driveRepository.downloadFile(accessToken, driveFile.id, destination)) { - destination.delete() - return null - } - val contentTimestamp = remote.fileContentModifiedTimestamp.takeIf { it > 0L } ?: destination.lastModified() - if (contentTimestamp > 0L) destination.setLastModified(contentTimestamp) - return remote.toDesktopBookItem(existing = existing, downloadedPath = destination.absolutePath).copy( - fileSize = destination.length(), - fileContentModifiedTimestamp = contentTimestamp - ) - } - - private suspend fun syncFonts( - userId: String, - idToken: String, - accessToken: String, - localFonts: List, - remoteFonts: List - ): List { - val localFontsMap = localFonts.associateBy { it.id } - val remoteFontsMap = remoteFonts.associateBy { it.id } - val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name } - val nextFonts = localFonts.toMutableList() - - (localFontsMap.keys + remoteFontsMap.keys).forEach { fontId -> - val local = localFontsMap[fontId] - val remote = remoteFontsMap[fontId] - when { - local != null && remote == null -> { - firestoreRepository.syncFontMetadata(userId, local.toDesktopCloudFontMetadata(), idToken) - } - - local == null && remote != null && !remote.isDeleted -> { - val target = customFontStore.getFontFile(remote.fileName) - driveFiles[remote.fileName]?.id?.let { driveRepository.downloadFile(accessToken, it, target) } - nextFonts += customFontStore.syncedFontItem(remote) - } - - local != null && remote != null -> { - when { - local.isDeleted && !remote.isDeleted -> { - firestoreRepository.syncFontMetadata(userId, remote.copy(isDeleted = true), idToken) - } - - !local.isDeleted && remote.isDeleted -> { - customFontStore.deleteFont(local) - nextFonts.removeAll { it.id == local.id } - } - } - } - } - } - - nextFonts.toList().forEach { font -> - val localFile = File(font.path) - if (!font.isDeleted && localFile.isFile && driveFiles[font.fileName] == null) { - driveRepository.uploadFont(accessToken, font.fileName, localFile, font.fileExtension) - } else if (!font.isDeleted && !localFile.isFile) { - driveFiles[font.fileName]?.id?.let { driveRepository.downloadFile(accessToken, it, localFile) } - } - } - return nextFonts.distinctBy { it.id } - } - - private suspend fun syncShelves( - userId: String, - idToken: String, - deviceId: String, - shelfRecords: List, - shelfRefs: List, - syncableBookIds: Set, - remoteShelves: List - ): ShelfSyncResult { - val localShelves = shelfRecords - .filterNot { it.isSmart } - .map { record -> - DesktopCloudShelfRecord( - record = record, - metadata = DesktopCloudShelfMetadata( - name = record.name, - bookIds = shelfRefs.filter { it.shelfId == record.id } - .map { it.bookId } - .filter { it in syncableBookIds } - .distinct(), - lastModifiedTimestamp = desktopShelfTimestamp(record, shelfRefs), - isDeleted = false - ) - ) - } - val localShelvesByName = localShelves.associateBy { it.metadata.name } - val remoteShelvesByName = remoteShelves.associateBy { it.name } - var records = shelfRecords - var refs = shelfRefs - - (localShelvesByName.keys + remoteShelvesByName.keys).forEach { shelfName -> - val local = localShelvesByName[shelfName] - val remote = remoteShelvesByName[shelfName] - when { - local != null && remote == null -> { - firestoreRepository.syncShelf(userId, local.metadata, deviceId, idToken) - } - - local == null && remote != null -> { - if (!remote.isDeleted) { - val record = ShelfRecord(id = "shelf_${remote.lastModifiedTimestamp}_${shelfName.hashCode()}", name = remote.name) - records += record - refs = refs.filterNot { it.shelfId == record.id } + - remote.bookIds.filter { it in syncableBookIds }.map { bookId -> - BookShelfRef(bookId, record.id, remote.lastModifiedTimestamp) - } - } - } - - local != null && remote != null -> { - if (local.metadata.lastModifiedTimestamp > remote.lastModifiedTimestamp) { - firestoreRepository.syncShelf(userId, local.metadata, deviceId, idToken) - } else if (remote.lastModifiedTimestamp > local.metadata.lastModifiedTimestamp) { - if (remote.isDeleted) { - records = records.filterNot { it.id == local.record.id } - refs = refs.filterNot { it.shelfId == local.record.id } - } else { - refs = refs.filterNot { it.shelfId == local.record.id } + - remote.bookIds.filter { it in syncableBookIds }.map { bookId -> - BookShelfRef(bookId, local.record.id, remote.lastModifiedTimestamp) - } - } - } - } - } - } - return ShelfSyncResult(records, refs) - } -} - -internal fun BookItem.toDesktopCloudBookMetadata( - hasAnnotations: Boolean, - timestamp: Long = this.timestamp -): DesktopCloudBookMetadata { - val position = readerPosition - val bookmarksJson = readerBookmarks - .mapNotNull { it.toDesktopCloudEpubBookmarkOrNull() } - .takeIf { it.isNotEmpty() } - ?.let(EpubAnnotationSerializer::bookmarksToJson) - val highlightsJson = readerHighlights - .takeIf { it.isNotEmpty() } - ?.let(EpubAnnotationSerializer::highlightsToJson) - val localFile = path?.let(::File) - val contentTimestamp = fileContentModifiedTimestamp.takeIf { it > 0L } - ?: localFile?.takeIf { it.isFile }?.lastModified() - ?: 0L - return DesktopCloudBookMetadata( - bookId = id, - title = title, - author = author, - displayName = displayName, - type = type.name, - lastPositionCfi = position?.cloudPositionCfi(), - lastChapterIndex = position?.chapterIndex, - locatorBlockIndex = null, - locatorCharOffset = null, - lastPage = position?.pageIndex ?: lastPageIndex, - progressPercentage = progressPercentage, - isRecent = isRecent, - isDeleted = false, - lastModifiedTimestamp = timestamp, - bookmarksJson = bookmarksJson, - hasAnnotations = hasAnnotations, - fileContentModifiedTimestamp = contentTimestamp, - customName = null, - highlightsJson = highlightsJson, - seriesName = seriesName, - seriesIndex = seriesIndex, - description = description, - originalTitle = originalTitle ?: title, - originalAuthor = originalAuthor ?: author, - originalSeriesName = originalSeriesName ?: seriesName, - originalSeriesIndex = originalSeriesIndex ?: seriesIndex, - originalDescription = originalDescription ?: description - ) -} - -internal fun DesktopCloudBookMetadata.toDesktopBookItem( - existing: BookItem? = null, - downloadedPath: String? = null -): BookItem { - val type = fileType() - val pageIndex = lastPage - val locator = ReaderLocator.fromLegacy( - chapterIndex = lastChapterIndex, - cfi = lastPositionCfi, - pageIndex = pageIndex - ) - return BookItem( - id = bookId, - path = downloadedPath ?: existing?.path, - type = type, - displayName = displayName.ifBlank { existing?.displayName ?: bookId }, - timestamp = lastModifiedTimestamp, - coverImagePath = existing?.coverImagePath, - title = title ?: existing?.title, - author = author ?: existing?.author, - description = description ?: existing?.description, - originalTitle = originalTitle ?: existing?.originalTitle, - originalAuthor = originalAuthor ?: existing?.originalAuthor, - originalSeriesName = originalSeriesName ?: existing?.originalSeriesName, - originalSeriesIndex = originalSeriesIndex ?: existing?.originalSeriesIndex, - originalDescription = originalDescription ?: existing?.originalDescription, - progressPercentage = progressPercentage ?: existing?.progressPercentage, - isRecent = isRecent, - fileSize = existing?.fileSize ?: 0L, - fileContentModifiedTimestamp = fileContentModifiedTimestamp.takeIf { it > 0L } - ?: existing?.fileContentModifiedTimestamp - ?: 0L, - sourceFolder = null, - folderTextMetadataParsed = existing?.folderTextMetadataParsed ?: false, - seriesName = seriesName ?: existing?.seriesName, - seriesIndex = seriesIndex ?: existing?.seriesIndex, - tags = existing?.tags.orEmpty(), - lastPageIndex = pageIndex ?: existing?.lastPageIndex, - readerPosition = locator.takeIf { - it.chapterIndex != null || it.pageIndex != null || it.cfi != null || it.startOffset != null - } ?: existing?.readerPosition, - readerSettings = existing?.readerSettings, - readerBookmarks = if (bookmarksJson.isNullOrBlank()) { - existing?.readerBookmarks.orEmpty() - } else { - EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson).map { bookmark -> - ReaderBookmark( - id = "${bookmark.chapterIndex}:${bookmark.cfi}", - pageIndex = bookmark.pageInChapter?.minus(1) ?: bookmark.locator.pageIndex ?: 0, - chapterTitle = bookmark.chapterTitle, - preview = bookmark.snippet, - locator = bookmark.locator - ) - } - }, - readerHighlights = if (highlightsJson.isNullOrBlank()) { - existing?.readerHighlights.orEmpty() - } else { - EpubAnnotationSerializer.parseHighlightsJson(highlightsJson) - }, - pdfReaderViewport = existing?.pdfReaderViewport - ) -} - -internal fun CustomFontItem.toDesktopCloudFontMetadata(): DesktopCloudFontMetadata { - return DesktopCloudFontMetadata( - id = id, - displayName = displayName, - fileName = fileName, - fileExtension = fileExtension, - timestamp = timestamp, - isDeleted = isDeleted - ) -} - -internal fun desktopCloudBookDriveFileName(bookId: String, type: FileType): String? { - val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null - return "$bookId.$extension" -} - -private data class DesktopCloudShelfRecord( - val record: ShelfRecord, - val metadata: DesktopCloudShelfMetadata -) - -private data class ShelfSyncResult( - val records: List, - val refs: List -) - -private fun DesktopCloudBookMetadata.fileType(): FileType { - return runCatching { FileType.valueOf(type) }.getOrDefault(FileType.EPUB) -} - -private fun SharedReaderScreenState.upsertCloudBook(book: BookItem): SharedReaderScreenState { - val existing = rawLibraryBooks.any { it.id == book.id } - val nextBooks = if (existing) { - rawLibraryBooks.map { if (it.id == book.id) book else it } - } else { - listOf(book) + rawLibraryBooks - } - return copy(rawLibraryBooks = nextBooks) -} - -private fun SharedReaderScreenState.removeCloudBook(bookId: String): SharedReaderScreenState { - return copy( - rawLibraryBooks = rawLibraryBooks.filterNot { it.id == bookId }, - selectedBookIds = selectedBookIds - bookId, - pinnedHomeBookIds = pinnedHomeBookIds - bookId, - pinnedLibraryBookIds = pinnedLibraryBookIds - bookId, - openTabIds = openTabIds.filterNot { it == bookId }, - activeTabBookId = activeTabBookId?.takeUnless { it == bookId } - ) -} - -private fun shouldDownloadRemoteBookContent(local: BookItem, remote: DesktopCloudBookMetadata): Boolean { - val localFile = local.path?.let(::File) - val localTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } - ?: localFile?.takeIf { it.isFile }?.lastModified() - ?: 0L - return local.sourceFolder == null && - !remote.isDeleted && - remote.fileType() == local.type && - remote.fileContentModifiedTimestamp > 0L && - (localFile == null || !localFile.isFile || remote.fileContentModifiedTimestamp > localTimestamp) -} - -private fun shouldUploadLocalBookContent(local: BookItem, remote: DesktopCloudBookMetadata?): Boolean { - val localFile = local.path?.let(::File)?.takeIf { it.isFile } ?: return false - val localTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } ?: localFile.lastModified() - return local.sourceFolder == null && - localTimestamp > 0L && - localTimestamp > (remote?.fileContentModifiedTimestamp ?: 0L) -} - -private fun desktopShelfTimestamp(record: ShelfRecord, refs: List): Long { - val idTimestamp = record.id.split('_').firstNotNullOfOrNull { it.toLongOrNull() } - val refsTimestamp = refs.filter { it.shelfId == record.id }.maxOfOrNull { it.addedAt } - return maxOf(idTimestamp ?: 0L, refsTimestamp ?: 0L) -} - -private fun ReaderLocator.cloudPositionCfi(): String? { - cfi?.let { return it } - val chapter = chapterIndex - val start = startOffset - val end = endOffset ?: start - return if (chapter != null && start != null && end != null) { - "desktop:$chapter:$start:$end" - } else if (chapter != null && pageIndex != null) { - "desktop:$chapter:$pageIndex" - } else { - null - } -} - -private fun ReaderBookmark.toDesktopCloudEpubBookmarkOrNull(): EpubBookmark? { - val chapterIndex = locator.chapterIndex ?: 0 - val cfi = locator.cloudPositionCfi() ?: "desktop:$chapterIndex:$pageIndex" - return EpubBookmark( - cfi = cfi, - chapterTitle = chapterTitle, - label = null, - snippet = preview, - pageInChapter = pageIndex + 1, - totalPagesInChapter = null, - chapterIndex = chapterIndex, - locator = locator.withFallbacks( - chapterIndex = chapterIndex, - cfi = cfi, - pageIndex = pageIndex, - textQuote = preview - ) - ) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopDiagnostics.kt deleted file mode 100644 index 35a2855..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopDiagnostics.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.aryan.reader.desktop - -internal const val DesktopDiagnosticsProperty = "episteme.desktop.diagnostics" - -internal val DesktopDiagnosticsEnabled: Boolean = - desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty)) - -internal fun desktopDiagnosticsFlag(rawValue: String?): Boolean { - return rawValue?.trim()?.equals("true", ignoreCase = true) == true -} - -internal inline fun logDesktopDiagnostic(tag: String, message: () -> String) { - if (DesktopDiagnosticsEnabled) { - println("$tag ${message()}") - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubLoader.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubLoader.kt deleted file mode 100644 index d165b07..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubLoader.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.aryan.reader.desktop - -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.reader.SharedEpubBook -import com.aryan.reader.shared.reader.SharedJvmBookLoader -import java.io.File - -object DesktopEpubLoader { - fun load(file: File): SharedEpubBook { - return SharedJvmBookLoader.load(file, FileType.EPUB) - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubWebView.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubWebView.kt deleted file mode 100644 index 1d351a3..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubWebView.kt +++ /dev/null @@ -1,378 +0,0 @@ -package com.aryan.reader.desktop - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Modifier -import com.aryan.reader.shared.EpubAnnotationSerializer -import com.aryan.reader.shared.ReaderLocator -import com.aryan.reader.shared.UserHighlight -import com.aryan.reader.shared.reader.ReaderReadingMode -import com.aryan.reader.shared.ui.ReaderContentNavigationTarget -import com.multiplatform.webview.jsbridge.IJsMessageHandler -import com.multiplatform.webview.jsbridge.JsMessage -import com.multiplatform.webview.jsbridge.rememberWebViewJsBridge -import com.multiplatform.webview.request.RequestInterceptor -import com.multiplatform.webview.request.WebRequest -import com.multiplatform.webview.request.WebRequestInterceptResult -import com.multiplatform.webview.web.LoadingState -import com.multiplatform.webview.web.WebContent -import com.multiplatform.webview.web.WebView -import com.multiplatform.webview.web.WebViewNavigator -import com.multiplatform.webview.web.WebViewState -import com.multiplatform.webview.web.rememberWebViewNavigator -import kotlinx.coroutines.launch -import java.awt.AWTEvent -import java.awt.Toolkit -import java.awt.event.AWTEventListener -import java.awt.event.MouseEvent - -@Composable -internal fun DesktopEpubWebView( - html: String, - appearanceScript: String, - navigationTarget: ReaderContentNavigationTarget, - highlights: List, - onHighlightCreated: (UserHighlight) -> Unit, - onHighlightSelected: (String) -> Unit, - isFullscreen: Boolean, - onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, - onSelectionAction: (DesktopReaderSelectionAction, String) -> Unit, - onLinkClicked: (DesktopEpubLinkClick) -> Unit, - onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, - onPointerActivity: () -> Unit = {}, - networkAccessEnabled: Boolean, - modifier: Modifier = Modifier -) { - val latestOnHighlightCreated by rememberUpdatedState(onHighlightCreated) - val latestOnHighlightSelected by rememberUpdatedState(onHighlightSelected) - val latestOnKeyboardNavigation by rememberUpdatedState(onKeyboardNavigation) - val latestOnSelectionAction by rememberUpdatedState(onSelectionAction) - val latestOnLinkClicked by rememberUpdatedState(onLinkClicked) - val latestOnVisiblePageChanged by rememberUpdatedState(onVisiblePageChanged) - val latestOnPointerActivity by rememberUpdatedState(onPointerActivity) - val scope = rememberCoroutineScope() - val linkRequestInterceptor = remember(scope, networkAccessEnabled) { - object : RequestInterceptor { - override fun onInterceptUrlRequest( - request: WebRequest, - navigator: WebViewNavigator - ): WebRequestInterceptResult { - if (!networkAccessEnabled && request.url.isRemoteNetworkUrl()) { - logEpubLink("request_blocked_offline url=\"${request.url.logPreview()}\"") - return WebRequestInterceptResult.Reject - } - if (!request.isForMainFrame) return WebRequestInterceptResult.Allow - val link = request.url.readerLinkClickFromIntercept() ?: return WebRequestInterceptResult.Allow - logEpubLink( - "request_intercept method=${request.method} redirect=${request.isRedirect} " + - "url=\"${request.url.logPreview()}\" href=\"${link.href.logPreview()}\"" - ) - scope.launch { - latestOnLinkClicked(link.copy(source = "request")) - } - return WebRequestInterceptResult.Reject - } - } - } - val navigator = rememberWebViewNavigator(requestInterceptor = linkRequestInterceptor) - val bridge = rememberWebViewJsBridge() - - DisposableEffect(bridge) { - val handlers = listOf( - desktopEpubBridgeHandler("readerHighlightCreated") { message -> - val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(message.params) - if (highlight == null) { - logEpubSelectionDebug("highlight_parse_failed params=${message.params.logPreview(900)}") - } else { - scope.launch { latestOnHighlightCreated(highlight) } - } - }, - desktopEpubBridgeHandler("readerHighlightClicked") { message -> - message.params.readerHighlightClickOrNull()?.let { highlightClick -> - scope.launch { latestOnHighlightSelected(highlightClick.highlightId) } - } - }, - desktopEpubBridgeHandler("readerPositionChanged") { message -> - message.params.readerPositionOrNull()?.let { position -> - scope.launch { latestOnVisiblePageChanged(position.pageIndex, position.locator) } - } - }, - desktopEpubBridgeHandler("readerSelectionAction") { message -> - val selectionAction = message.params.readerSelectionActionOrNull() - if (selectionAction != null) { - scope.launch { latestOnSelectionAction(selectionAction.action, selectionAction.text) } - } - }, - desktopEpubBridgeHandler("readerKeyNavigation") { message -> - message.params.readerKeyNavigationOrNull()?.let { action -> - scope.launch { latestOnKeyboardNavigation(action) } - } - }, - desktopEpubBridgeHandler("readerPointerActivity") { _ -> - scope.launch { latestOnPointerActivity() } - }, - desktopEpubBridgeHandler("readerTtsHighlightLog") { message -> - logDesktopTts("epub_highlight_js ${message.params.logPreview(500)}") - }, - desktopEpubBridgeHandler("readerSelectionDebugLog") { message -> - logEpubSelectionDebug(message.params.readerSelectionDebugMessageOrNull() ?: message.params.logPreview(900)) - }, - desktopEpubBridgeHandler("readerPaginationLayoutLog") { message -> - logEpubPagination(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900)) - }, - desktopEpubBridgeHandler("readerGapLayoutLog") { message -> - logReaderGap(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900)) - }, - desktopEpubBridgeHandler("readerLinkClicked") { message -> - logEpubLink("bridge_message params=\"${message.params.logPreview()}\"") - val link = message.params.readerLinkClickOrNull() - if (link == null) { - logEpubLink("bridge_message_ignored reason=parse_failed") - } else { - logEpubLink( - "bridge_message_parsed href=\"${link.href.logPreview()}\" " + - "chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\"" - ) - scope.launch { latestOnLinkClicked(link) } - } - } - ) - handlers.forEach { bridge.register(it) } - onDispose { - handlers.forEach { bridge.unregister(it) } - } - } - - val state = remember { - WebViewState( - WebContent.Data( - data = html, - baseUrl = null, - encoding = "utf-8", - mimeType = "text/html", - historyUrl = null - ) - ) - } - - LaunchedEffect(html) { - navigator.loadHtml( - html = html, - baseUrl = null, - mimeType = "text/html", - encoding = "utf-8", - historyUrl = null - ) - } - - DisposableEffect(Unit) { - var lastActivityAt = 0L - var lastMouseX: Int? = null - var lastMouseY: Int? = null - val listener = AWTEventListener { event -> - val mouseEvent = event as? MouseEvent ?: return@AWTEventListener - if ( - mouseEvent.id != MouseEvent.MOUSE_MOVED && - mouseEvent.id != MouseEvent.MOUSE_DRAGGED && - mouseEvent.id != MouseEvent.MOUSE_PRESSED && - mouseEvent.id != MouseEvent.MOUSE_WHEEL - ) { - return@AWTEventListener - } - if (mouseEvent.id == MouseEvent.MOUSE_MOVED || mouseEvent.id == MouseEvent.MOUSE_DRAGGED) { - val screenX = mouseEvent.xOnScreen - val screenY = mouseEvent.yOnScreen - if (lastMouseX == screenX && lastMouseY == screenY) return@AWTEventListener - lastMouseX = screenX - lastMouseY = screenY - } else { - lastMouseX = mouseEvent.xOnScreen - lastMouseY = mouseEvent.yOnScreen - } - val now = mouseEvent.`when`.takeIf { it > 0L } ?: System.currentTimeMillis() - if (now - lastActivityAt < 120L) return@AWTEventListener - lastActivityAt = now - scope.launch { latestOnPointerActivity() } - } - val eventMask = AWTEvent.MOUSE_MOTION_EVENT_MASK or - AWTEvent.MOUSE_EVENT_MASK or - AWTEvent.MOUSE_WHEEL_EVENT_MASK - Toolkit.getDefaultToolkit().addAWTEventListener(listener, eventMask) - onDispose { - Toolkit.getDefaultToolkit().removeAWTEventListener(listener) - } - } - - Box(modifier = modifier) { - WebView( - state = state, - modifier = Modifier.fillMaxSize(), - captureBackPresses = false, - navigator = navigator, - webViewJsBridge = bridge - ) - - LaunchedEffect(state.loadingState) { - if (!state.loadingState.isFinished()) return@LaunchedEffect - navigator.evaluateJavaScript(DesktopEpubKeyNavigationScript) - } - - LaunchedEffect(isFullscreen, state.loadingState) { - if (!state.loadingState.isFinished()) return@LaunchedEffect - navigator.evaluateJavaScript("window.readerDesktopFullscreen = ${if (isFullscreen) "true" else "false"};") - } - - LaunchedEffect(html, state.loadingState) { - if (!state.loadingState.isFinished()) return@LaunchedEffect - navigator.evaluateJavaScript("window.readerPaginationLayoutLog && window.readerPaginationLayoutLog('desktop_finished');") - } - - LaunchedEffect(appearanceScript, state.loadingState) { - if (!state.loadingState.isFinished()) return@LaunchedEffect - navigator.evaluateJavaScript(appearanceScript) - } - - LaunchedEffect( - navigationTarget.autoScroll, - navigationTarget.readingMode, - state.loadingState - ) { - if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect - if (!state.loadingState.isFinished()) return@LaunchedEffect - val autoScroll = navigationTarget.autoScroll.sanitized() - val command = if (autoScroll.enabled) { - "window.readerAutoScroll && window.readerAutoScroll.start(${autoScroll.speed});" - } else { - "window.readerAutoScroll && window.readerAutoScroll.stop();" - } - navigator.evaluateJavaScript(command) - } - - LaunchedEffect( - navigationTarget.requestId, - navigationTarget.readingMode, - state.loadingState - ) { - if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect - if (!state.loadingState.isFinished()) return@LaunchedEffect - val locator = navigationTarget.locator ?: return@LaunchedEffect - navigator.evaluateJavaScript("window.readerScrollToLocator && window.readerScrollToLocator(${locator.toReaderLocatorJson()});") - } - - LaunchedEffect( - navigationTarget.ttsRequestId, - navigationTarget.ttsLocator, - navigationTarget.readingMode, - state.loadingState - ) { - if (!state.loadingState.isFinished()) return@LaunchedEffect - val locator = navigationTarget.ttsLocator - val command = if (locator == null) { - logDesktopTts( - "epub_highlight_command clear mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId}" - ) - "window.readerSetTtsLocator && window.readerSetTtsLocator(null, false);" - } else { - val follow = navigationTarget.readingMode == ReaderReadingMode.VERTICAL - logDesktopTts( - "epub_highlight_command set mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId} " + - "follow=$follow chapter=${locator.chapterIndex} page=${locator.pageIndex} " + - "offsets=${locator.startOffset}..${locator.endOffset} cfi=\"${locator.cfi.orEmpty().logPreview()}\" " + - "text=\"${locator.textQuote.orEmpty().logPreview()}\"" - ) - "window.readerSetTtsLocator && window.readerSetTtsLocator(${locator.toReaderLocatorJson()}, $follow);" - } - navigator.evaluateJavaScript(command) - } - - LaunchedEffect(highlights, state.loadingState) { - if (!state.loadingState.isFinished()) return@LaunchedEffect - val highlightsJson = EpubAnnotationSerializer.highlightsToJson(highlights) - navigator.evaluateJavaScript("window.readerApplyHighlights && window.readerApplyHighlights($highlightsJson);") - } - - val loadingState = state.loadingState - if (loadingState is LoadingState.Loading) { - LinearProgressIndicator( - progress = { loadingState.progress }, - modifier = Modifier.fillMaxWidth() - ) - } - } -} - -private fun desktopEpubBridgeHandler( - methodName: String, - onMessage: (JsMessage) -> Unit -): IJsMessageHandler { - return object : IJsMessageHandler { - override fun methodName(): String = methodName - - override fun handle( - message: JsMessage, - navigator: WebViewNavigator?, - callback: (String) -> Unit - ) { - onMessage(message) - } - } -} - -private fun LoadingState.isFinished(): Boolean = this is LoadingState.Finished - -private val DesktopEpubKeyNavigationScript = """ - (function () { - if (!window.readerDesktopPointerActivityInstalled) { - window.readerDesktopPointerActivityInstalled = true; - var lastPointerActivityAt = 0; - var lastPointerX = null; - var lastPointerY = null; - function notifyPointerActivity(event, requireMovement) { - if (requireMovement && event) { - var x = Math.round(event.screenX || event.clientX || 0); - var y = Math.round(event.screenY || event.clientY || 0); - if (lastPointerX === x && lastPointerY === y) return; - lastPointerX = x; - lastPointerY = y; - } - var now = Date.now(); - if (now - lastPointerActivityAt < 120) return; - lastPointerActivityAt = now; - if (!window.kmpJsBridge || !window.kmpJsBridge.callNative) return; - window.kmpJsBridge.callNative('readerPointerActivity', '{}'); - } - document.addEventListener('mousemove', function (event) { notifyPointerActivity(event, true); }, true); - document.addEventListener('pointermove', function (event) { notifyPointerActivity(event, true); }, true); - document.addEventListener('pointerdown', function (event) { notifyPointerActivity(event, false); }, true); - document.addEventListener('wheel', function (event) { notifyPointerActivity(event, false); }, true); - } - if (window.readerDesktopKeyNavigationInstalled) return; - window.readerDesktopKeyNavigationInstalled = true; - document.addEventListener('keydown', function (event) { - var target = event.target; - var tag = target && target.tagName ? target.tagName.toLowerCase() : ''; - if (target && (target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select')) return; - var action = null; - if (event.ctrlKey && (event.key === 'f' || event.key === 'F')) action = 'search'; - else if (event.ctrlKey && (event.key === 'g' || event.key === 'G')) action = 'nextSearch'; - else if (event.key === 'ArrowRight' || event.key === 'PageDown') action = 'next'; - else if (event.key === 'ArrowLeft' || event.key === 'PageUp') action = 'previous'; - else if (event.key === 'Home') action = 'first'; - else if (event.key === 'End') action = 'last'; - else if (event.key === 'Escape' && window.readerDesktopFullscreen) action = 'exitFullscreen'; - if (!action || !window.kmpJsBridge || !window.kmpJsBridge.callNative) return; - event.preventDefault(); - event.stopPropagation(); - window.kmpJsBridge.callNative('readerKeyNavigation', JSON.stringify({ action: action })); - }, true); - })(); -""".trimIndent() diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt deleted file mode 100644 index 0d60e2a..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.aryan.reader.desktop - -import com.aryan.reader.shared.SharedLibrarySnapshot -import com.aryan.reader.shared.SharedLibrarySnapshotJson -import java.io.File - -class DesktopLibraryDatabase( - private val databaseFile: File = defaultDatabaseFile() -) { - fun load(): SharedLibrarySnapshot { - if (!databaseFile.exists()) return SharedLibrarySnapshot() - return SharedLibrarySnapshotJson.decodeOrEmpty(databaseFile.readText()) - } - - fun save(snapshot: SharedLibrarySnapshot) { - databaseFile.parentFile?.mkdirs() - databaseFile.writeText(SharedLibrarySnapshotJson.encode(snapshot)) - } - - companion object { - fun defaultDatabaseFile(): File { - return File(desktopUserDataRoot(), "library.json") - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAnnotationUi.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAnnotationUi.kt deleted file mode 100644 index 56d7302..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAnnotationUi.kt +++ /dev/null @@ -1,482 +0,0 @@ -package com.aryan.reader.desktop - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -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.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -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.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.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.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.pdf.PdfAnnotationKind -import com.aryan.reader.shared.pdf.PdfInkTool -import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors -import com.aryan.reader.shared.pdf.SharedPdfAnnotation -import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults -import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation -import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette -import com.aryan.reader.shared.pdf.sharedPdfStrokePercent -import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange -import com.aryan.reader.shared.pdf.sharedPdfTextStyle -import com.aryan.reader.shared.pdf.withSharedPdfTextStyle -import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog -import com.aryan.reader.shared.ui.SharedPdfTextStyleControls -import com.aryan.reader.shared.ui.SharedStableOutlinedTextField -import com.aryan.reader.shared.ui.readerString - -internal val DesktopPdfAnnotationTools = listOf( - PdfInkTool.PEN, - PdfInkTool.FOUNTAIN_PEN, - PdfInkTool.PENCIL, - PdfInkTool.HIGHLIGHTER, - PdfInkTool.HIGHLIGHTER_ROUND, - PdfInkTool.TEXT, - PdfInkTool.ERASER -) - -@Composable -internal fun DesktopPdfAnnotationEditor( - annotation: SharedPdfAnnotation, - onUpdate: (SharedPdfAnnotation) -> Unit, - onDelete: () -> Unit, - onClose: () -> Unit, - onCopy: () -> Unit, - showSearch: Boolean, - highlighterPalette: List = SharedPdfHighlighterPalette.defaultColors, - onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {}, - onSearch: () -> Unit -) { - val highlighterColors = remember(highlighterPalette) { - SharedPdfAndroidHighlightColors.palette - } - var editingHighlighterSlot by remember(annotation.id, highlighterColors) { mutableStateOf(null) } - val isHighlighterAnnotation = annotation.kind == PdfAnnotationKind.HIGHLIGHT || - annotation.tool == PdfInkTool.HIGHLIGHTER || - annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND - - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - readerString("desktop_selected_annotation_format", "Selected %1\$s", annotation.desktopLabel()), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) - ) - TextButton(onClick = onClose) { - Text(readerString("action_close", "Close")) - } - } - Text( - readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - if (annotation.text.isNotBlank()) { - Surface( - color = Color(annotation.colorArgb).copy(alpha = 0.10f), - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, Color(annotation.colorArgb).copy(alpha = 0.28f)), - modifier = Modifier.fillMaxWidth() - ) { - Row(modifier = Modifier.heightIn(min = 72.dp)) { - Box( - modifier = Modifier - .width(6.dp) - .fillMaxHeight() - .background(Color(annotation.colorArgb)) - ) - Text( - "\"${annotation.text}\"", - style = MaterialTheme.typography.bodyMedium, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f), - modifier = Modifier.padding(14.dp) - ) - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - DesktopBottomSheetToolButton( - icon = Icons.Default.ContentCopy, - label = readerString("action_copy", "Copy"), - onClick = onCopy - ) - if (showSearch) { - DesktopBottomSheetToolButton( - icon = Icons.Default.Search, - label = readerString("action_search", "Search"), - onClick = onSearch - ) - } - } - } - if (annotation.kind == PdfAnnotationKind.TEXT) { - SharedStableOutlinedTextField( - value = annotation.text, - onValueChange = { onUpdate(annotation.copy(text = it)) }, - label = { Text(readerString("desktop_text_note", "Text note")) }, - minLines = 2, - modifier = Modifier.fillMaxWidth(), - selectionKey = annotation.id - ) - SharedPdfTextStyleControls( - style = annotation.sharedPdfTextStyle(), - onStyleChange = { onUpdate(annotation.withSharedPdfTextStyle(it)) } - ) - } - if (annotation.kind != PdfAnnotationKind.TEXT) { - val palette = if (isHighlighterAnnotation) { - highlighterColors - } else { - SharedPdfAnnotationDefaults.penPalette - } - Text(readerString("desktop_color", "Color"), style = MaterialTheme.typography.labelLarge) - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - palette.forEachIndexed { _, argb -> - Surface( - modifier = Modifier - .size(26.dp) - .clickable { - val nextColor = if (isHighlighterAnnotation) { - SharedPdfAndroidHighlightColors.nearestArgb(argb) - } else { - argb - } - onUpdate(annotation.copy(colorArgb = nextColor)) - }, - color = Color(argb), - shape = RoundedCornerShape(13.dp), - content = {} - ) - } - if (isHighlighterAnnotation) { - Box( - modifier = Modifier - .size(30.dp) - .clip(RoundedCornerShape(15.dp)) - .background( - Brush.sweepGradient( - listOf( - Color.Red, - Color.Yellow, - Color.Green, - Color.Cyan, - Color.Blue, - Color.Magenta, - Color.Red - ) - ) - ) - .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(15.dp)) - .clickable { - editingHighlighterSlot = highlighterColors - .indexOf(annotation.colorArgb) - .takeIf { it >= 0 } - ?: 0 - } - ) - } - } - SharedStableOutlinedTextField( - value = annotation.note.orEmpty(), - onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) }, - label = { Text(readerString("label_note", "Note")) }, - minLines = 3, - maxLines = 5, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - selectionKey = annotation.id - ) - } - if (annotation.kind == PdfAnnotationKind.INK) { - val strokeRange = annotation.tool.sharedPdfStrokeWidthRange() - val strokeValue = annotation.strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive) - Text( - readerString( - "desktop_thickness_format", - "Thickness %1\$s", - strokeValue.sharedPdfStrokePercent(strokeRange) - ), - style = MaterialTheme.typography.labelLarge - ) - Slider( - value = strokeValue, - onValueChange = { onUpdate(annotation.copy(strokeWidth = it.coerceAtLeast(0.0001f))) }, - valueRange = strokeRange - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = onDelete) { - Text(readerString("action_delete", "Delete")) - } - } - } - } - editingHighlighterSlot?.let { requestedSlot -> - val slot = requestedSlot.coerceIn(0, highlighterColors.lastIndex) - val initialColor = Color(highlighterColors[slot]).copy(alpha = 1f) - SharedHsvColorPickerDialog( - initialColor = initialColor, - title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1), - onDismiss = { editingHighlighterSlot = null }, - onSave = { color -> - val nextArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() - val syncedArgb = SharedPdfAndroidHighlightColors.nearestArgb(nextArgb) - onHighlighterPaletteChange( - SharedPdfHighlighterPalette(highlighterColors).withColorAt( - slotIndex = slot, - colorArgb = nextArgb - ) - ) - onUpdate(annotation.copy(colorArgb = syncedArgb)) - editingHighlighterSlot = null - } - ) { liveColor -> - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - highlighterColors.forEachIndexed { index, argb -> - val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) - Box( - modifier = Modifier - .size(42.dp) - .clip(RoundedCornerShape(21.dp)) - .background(color) - .border( - width = if (index == slot) 3.dp else 1.dp, - color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), - shape = RoundedCornerShape(21.dp) - ) - .clickable { editingHighlighterSlot = index }, - contentAlignment = Alignment.Center - ) { - Text( - text = "${index + 1}", - color = if (color.luminance() > 0.5f) Color.Black else Color.White, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold - ) - } - } - } - } - } -} - -@Composable -private fun DesktopBottomSheetToolButton( - icon: ImageVector, - label: String, - onClick: () -> Unit -) { - Column( - modifier = Modifier - .clickable(onClick = onClick) - .padding(horizontal = 10.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - imageVector = icon, - contentDescription = label, - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.78f), - modifier = Modifier.size(22.dp) - ) - Text( - label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } -} - -@Composable -internal fun DesktopPdfEmbeddedAnnotationPanel( - annotation: SharedPdfEmbeddedAnnotation, - onCopy: () -> Unit, - onClose: () -> Unit -) { - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - readerString("desktop_embedded_pdf_comment", "Embedded PDF comment"), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) - ) - TextButton(onClick = onClose) { - Text(readerString("action_close", "Close")) - } - } - Text( - annotation.author.takeIf { it.isNotBlank() }?.let { author -> - readerString( - "desktop_pdf_page_author_format", - "Page %1\$d - %2\$s", - annotation.pageIndex + 1, - author - ) - } ?: readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - DesktopPdfEmbeddedComment( - author = annotation.author, - contents = annotation.contents, - depth = 0 - ) - DesktopPdfEmbeddedReplies(annotation.replies, depth = 1) - TextButton(onClick = onCopy) { - Text(readerString("action_copy_thread", "Copy thread")) - } - } - } -} - -@Composable -private fun DesktopPdfEmbeddedReplies( - replies: List, - depth: Int -) { - replies.forEach { reply -> - HorizontalDivider() - DesktopPdfEmbeddedComment( - author = reply.author, - contents = reply.contents, - depth = depth - ) - if (reply.replies.isNotEmpty()) { - DesktopPdfEmbeddedReplies(reply.replies, depth + 1) - } - } -} - -@Composable -private fun DesktopPdfEmbeddedComment( - author: String, - contents: String, - depth: Int -) { - Column( - modifier = Modifier.padding(start = (depth * 12).dp), - verticalArrangement = Arrangement.spacedBy(3.dp) - ) { - Text( - author.ifBlank { readerString("unknown", "Unknown") }, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - contents.ifBlank { readerString("desktop_no_comment", "No comment") }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -@Composable -internal fun SharedPdfAnnotation.desktopLabel(): String { - return when (kind) { - PdfAnnotationKind.HIGHLIGHT -> readerString("label_highlight_color", "highlight") - PdfAnnotationKind.INK -> tool.desktopLabel() - PdfAnnotationKind.TEXT -> readerString("desktop_text_note_lowercase", "text note") - } -} - -@Composable -internal fun SharedPdfAnnotation.desktopSheetTitle(): String { - return when (kind) { - PdfAnnotationKind.HIGHLIGHT -> readerString("label_highlight_color", "Highlight") - PdfAnnotationKind.INK -> readerString("desktop_annotation", "Annotation") - PdfAnnotationKind.TEXT -> readerString("desktop_text_note", "Text note") - } -} - -@Composable -private fun PdfInkTool.desktopLabel(): String { - return when (this) { - PdfInkTool.PEN -> readerString("content_desc_pen", "Pen") - PdfInkTool.FOUNTAIN_PEN -> readerString("desktop_fountain_pen", "Fountain pen") - PdfInkTool.PENCIL -> readerString("desktop_pencil", "Pencil") - PdfInkTool.HIGHLIGHTER -> readerString("content_desc_highlighter", "Highlighter") - PdfInkTool.HIGHLIGHTER_ROUND -> readerString("desktop_round_highlighter", "Round highlighter") - PdfInkTool.TEXT -> readerString("desktop_text_note", "Text note") - PdfInkTool.ERASER -> readerString("content_desc_eraser", "Eraser") - PdfInkTool.NONE -> readerString("label_none", "None") - } -} - -internal fun SharedPdfEmbeddedAnnotation.threadText(): String { - return buildString { - append(author.ifBlank { "Unknown" }) - append(": ") - appendLine(contents.ifBlank { "No comment" }) - fun appendReplies(replies: List, indent: String) { - replies.forEach { reply -> - append(indent) - append(reply.author.ifBlank { "Unknown" }) - append(": ") - appendLine(reply.contents.ifBlank { "No comment" }) - appendReplies(reply.replies, "$indent ") - } - } - appendReplies(replies, " ") - }.trimEnd() -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecars.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecars.kt deleted file mode 100644 index 9cdc61c..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecars.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.aryan.reader.desktop - -import java.io.File -import java.util.Base64 - -private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t1" - -internal fun desktopPdfAnnotationFile(documentPath: String): File { - val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(desktopUserDataRoot(), "annotations/pdf_$safeName.json") -} - -internal fun desktopPdfBookmarkFile(documentPath: String): File { - val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json") -} - -internal fun desktopPdfRichTextFile(documentPath: String): File { - val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json") -} - -internal fun desktopPdfSearchIndexFile(documentPath: String): File { - val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(desktopUserCacheRoot(), "search/pdf_${safeName}_text_index.tsv") -} - -internal fun restoreDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File): Int { - val sourceFile = File(document.path) - val lines = runCatching { indexFile.readLines(Charsets.UTF_8) }.getOrNull() ?: return document.indexedSearchTextPageCount() - if (lines.firstOrNull() != DesktopPdfSearchIndexHeader) return 0 - val metadata = lines - .asSequence() - .drop(1) - .takeWhile { !it.startsWith("page\t") } - .mapNotNull { line -> - val parts = line.split('\t', limit = 2) - if (parts.size == 2) parts[0] to parts[1] else null - } - .toMap() - val isFresh = metadata["pathHash"] == document.path.hashCode().toString() && - metadata["fileSize"] == sourceFile.length().toString() && - metadata["lastModified"] == sourceFile.lastModified().toString() && - metadata["pageCount"] == document.pageCount.toString() - if (!isFresh) return 0 - - val decoder = Base64.getDecoder() - lines.asSequence() - .filter { it.startsWith("page\t") } - .forEach { line -> - val parts = line.split('\t', limit = 3) - val pageIndex = parts.getOrNull(1)?.toIntOrNull() ?: return@forEach - val text = runCatching { - String(decoder.decode(parts.getOrNull(2).orEmpty()), Charsets.UTF_8) - }.getOrDefault("") - document.cacheSearchTextPage(pageIndex, text) - } - return document.indexedSearchTextPageCount() -} - -internal fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File) { - val sourceFile = File(document.path) - val pages = document.indexedSearchPages() - if (pages.isEmpty()) return - val encoder = Base64.getEncoder() - val payload = buildString { - appendLine(DesktopPdfSearchIndexHeader) - appendLine("pathHash\t${document.path.hashCode()}") - appendLine("fileSize\t${sourceFile.length()}") - appendLine("lastModified\t${sourceFile.lastModified()}") - appendLine("pageCount\t${document.pageCount}") - pages.forEach { page -> - append("page\t") - append(page.pageIndex) - append('\t') - appendLine(encoder.encodeToString(page.text.toByteArray(Charsets.UTF_8))) - } - } - runCatching { - indexFile.parentFile?.mkdirs() - indexFile.writeText(payload, Charsets.UTF_8) - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfTheme.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfTheme.kt deleted file mode 100644 index f79df97..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfTheme.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.aryan.reader.desktop - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.isSpecified -import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.PdfDisplayMode -import com.aryan.reader.shared.ReaderTheme - -internal val DesktopDefaultPdfDisplayMode = PdfDisplayMode.VERTICAL_SCROLL -internal val DesktopDefaultPdfVerticalPageGap = 8.dp - -internal fun desktopPdfPageBackgroundColor( - theme: ReaderTheme, - displayMode: PdfDisplayMode -): Color { - return when (theme.id) { - "no_theme", "system" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black - "reverse" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.Black else Color.White - else -> theme.backgroundColor.takeIf { it.isSpecified } - ?: if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black - } -} - -internal fun desktopPdfVerticalViewportBackgroundColor( - pageBackgroundColor: Color, - gapBackgroundColor: Color, - isPageGapVisible: Boolean -): Color { - return if (isPageGapVisible) gapBackgroundColor else pageBackgroundColor -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfZoom.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfZoom.kt deleted file mode 100644 index 3296c39..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfZoom.kt +++ /dev/null @@ -1,290 +0,0 @@ -package com.aryan.reader.desktop - -import androidx.compose.foundation.gestures.calculateCentroid -import androidx.compose.foundation.gestures.calculateZoom -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isCtrlPressed as isPointerCtrlPressed -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import com.aryan.reader.shared.PdfDisplayMode -import com.aryan.reader.shared.pdf.PdfZoomSpec -import kotlin.math.abs -import kotlin.math.exp -import kotlin.math.roundToInt - -private const val DesktopPdfZoomGestureFrameMillis = 16L -internal const val DesktopPdfPaginationFastFirstRenderMaxScale = 2.0f - -internal fun desktopPdfScrollZoomFactor(scrollDelta: Float): Float { - if (!scrollDelta.isFinite() || abs(scrollDelta) < 0.01f) return 1f - val normalizedDelta = scrollDelta.coerceIn(-8f, 8f) - return exp((-normalizedDelta * 0.12f).toDouble()).toFloat() -} - -internal fun desktopPdfZoomTarget( - currentZoom: Float, - zoomSpec: PdfZoomSpec, - factor: Float -): Float { - val baseZoom = currentZoom.takeIf { it.isFinite() } ?: zoomSpec.default - val safeFactor = factor.takeIf { it.isFinite() && it > 0f } ?: 1f - return zoomSpec.clamp(baseZoom * safeFactor) -} - -internal fun desktopPdfAnchoredScrollTarget( - currentScroll: Int, - anchor: Float, - oldZoom: Float, - newZoom: Float -): Int { - if ( - !anchor.isFinite() || - !oldZoom.isFinite() || - !newZoom.isFinite() || - oldZoom <= 0f || - newZoom <= 0f - ) { - return currentScroll.coerceAtLeast(0) - } - val zoomRatio = newZoom / oldZoom - return (((currentScroll + anchor) * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) -} - -internal fun desktopPdfAnchoredLazyItemScrollOffset( - itemOffset: Int, - anchor: Float, - oldZoom: Float, - newZoom: Float -): Int { - if ( - !anchor.isFinite() || - !oldZoom.isFinite() || - !newZoom.isFinite() || - oldZoom <= 0f || - newZoom <= 0f - ) { - return (-itemOffset).coerceAtLeast(0) - } - val zoomRatio = newZoom / oldZoom - val offsetWithinItem = anchor - itemOffset - return ((offsetWithinItem * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) -} - -internal fun desktopPdfAnchoredPageScrollDelta( - viewportRootOffset: Offset, - oldPageRootOffset: Offset, - currentPageRootOffset: Offset, - anchor: Offset, - oldZoom: Float, - newZoom: Float -): IntOffset? { - if ( - !anchor.x.isFinite() || - !anchor.y.isFinite() || - !oldZoom.isFinite() || - !newZoom.isFinite() || - oldZoom <= 0f || - newZoom <= 0f - ) { - return null - } - val rootAnchor = viewportRootOffset + anchor - val oldPageLocal = rootAnchor - oldPageRootOffset - val zoomRatio = newZoom / oldZoom - val newPageLocal = Offset(oldPageLocal.x * zoomRatio, oldPageLocal.y * zoomRatio) - val desiredPageRoot = rootAnchor - newPageLocal - val delta = currentPageRootOffset - desiredPageRoot - return IntOffset(delta.x.roundToInt(), delta.y.roundToInt()) -} - -internal fun desktopPdfPaginationFirstRenderScale( - requestedScale: Float, - hasPageRender: Boolean, - isOpeningRender: Boolean = false -): Float { - if (hasPageRender || isOpeningRender || !requestedScale.isFinite() || requestedScale <= 0f) { - return requestedScale - } - return requestedScale.coerceAtMost(DesktopPdfPaginationFastFirstRenderMaxScale) -} - -internal data class DesktopPdfZoomPreview( - val baseZoom: Float, - val zoom: Float, - val anchor: Offset?, - val displayMode: PdfDisplayMode, - val pageIndex: Int? -) - -internal data class DesktopPdfCachedPageRender( - val render: DesktopPdfPageRender, - val scale: Float -) - -internal fun desktopPdfZoomPreviewPivotFraction( - viewportRootOffset: Offset, - pageRootOffset: Offset, - anchor: Offset, - pageCanvasSize: IntSize -): Offset? { - if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null - if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null - val pageAnchor = viewportRootOffset + anchor - pageRootOffset - if (!pageAnchor.x.isFinite() || !pageAnchor.y.isFinite()) return null - return Offset( - x = (pageAnchor.x / pageCanvasSize.width).coerceIn(0f, 1f), - y = (pageAnchor.y / pageCanvasSize.height).coerceIn(0f, 1f) - ) -} - -internal fun desktopPdfDocumentZoomPreviewTranslation( - viewportRootOffset: Offset, - pageRootOffset: Offset, - anchor: Offset, - previewScale: Float -): Offset? { - if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null - if (!previewScale.isFinite() || previewScale <= 0f) return null - val rootAnchor = viewportRootOffset + anchor - if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return null - return Offset( - x = (pageRootOffset.x - rootAnchor.x) * (previewScale - 1f), - y = (pageRootOffset.y - rootAnchor.y) * (previewScale - 1f) - ) -} - -internal fun Modifier.desktopPdfZoomPreviewLayer( - preview: DesktopPdfZoomPreview?, - currentZoom: Float, - viewportRootOffset: Offset, - pageRootOffset: Offset, - pageCanvasSize: IntSize -): Modifier { - val activePreview = preview ?: return this - if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return this - if (!currentZoom.isFinite() || currentZoom <= 0f) return this - if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this - val previewScale = activePreview.zoom / currentZoom - if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this - val transformOrigin = activePreview.anchor?.let { anchor -> - desktopPdfZoomPreviewPivotFraction( - viewportRootOffset = viewportRootOffset, - pageRootOffset = pageRootOffset, - anchor = anchor, - pageCanvasSize = pageCanvasSize - )?.let { pivot -> - TransformOrigin(pivotFractionX = pivot.x, pivotFractionY = pivot.y) - } ?: TransformOrigin.Center - } ?: TransformOrigin.Center - return graphicsLayer { - scaleX = previewScale - scaleY = previewScale - this.transformOrigin = transformOrigin - } -} - -internal fun Modifier.desktopPdfDocumentZoomPreviewLayer( - preview: DesktopPdfZoomPreview?, - currentZoom: Float, - viewportRootOffset: Offset, - pageRootOffset: Offset -): Modifier { - val activePreview = preview ?: return this - if (!currentZoom.isFinite() || currentZoom <= 0f) return this - if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this - val previewScale = activePreview.zoom / currentZoom - if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this - val translation = activePreview.anchor?.let { anchor -> - desktopPdfDocumentZoomPreviewTranslation( - viewportRootOffset = viewportRootOffset, - pageRootOffset = pageRootOffset, - anchor = anchor, - previewScale = previewScale - ) - } ?: Offset.Zero - return graphicsLayer { - scaleX = previewScale - scaleY = previewScale - translationX = translation.x - translationY = translation.y - transformOrigin = TransformOrigin(0f, 0f) - } -} - -@Composable -internal fun Modifier.desktopPdfZoomGestures( - currentZoom: Float, - zoomSpec: PdfZoomSpec, - onZoomChanged: (oldZoom: Float, newZoom: Float, anchor: Offset?) -> Unit -): Modifier { - val latestZoom by rememberUpdatedState(currentZoom) - val latestOnZoomChanged by rememberUpdatedState(onZoomChanged) - return this.pointerInput(zoomSpec) { - var gestureZoom = latestZoom - var appliedGestureZoom = latestZoom - var lastZoomEventAt = 0L - var lastAppliedZoomAt = 0L - fun applyZoomFactor(factor: Float, eventTime: Long, anchor: Offset?) { - if (lastZoomEventAt == 0L || eventTime - lastZoomEventAt > 180L) { - gestureZoom = latestZoom - appliedGestureZoom = latestZoom - lastAppliedZoomAt = 0L - } - val newZoom = desktopPdfZoomTarget(gestureZoom, zoomSpec, factor) - gestureZoom = newZoom - lastZoomEventAt = eventTime - val shouldApplyNow = lastAppliedZoomAt == 0L || - eventTime - lastAppliedZoomAt >= DesktopPdfZoomGestureFrameMillis || - newZoom == zoomSpec.min || - newZoom == zoomSpec.max - if (shouldApplyNow && newZoom != appliedGestureZoom) { - latestOnZoomChanged(appliedGestureZoom, newZoom, anchor) - appliedGestureZoom = newZoom - lastAppliedZoomAt = eventTime - } - } - - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - val eventTime = event.changes.maxOfOrNull { it.uptimeMillis } ?: 0L - if (event.type == PointerEventType.Scroll && event.keyboardModifiers.isPointerCtrlPressed) { - val scrollDelta = event.changes.fold(Offset.Zero) { total, change -> - total + change.scrollDelta - } - val zoomDelta = if (abs(scrollDelta.y) >= abs(scrollDelta.x)) scrollDelta.y else scrollDelta.x - val factor = desktopPdfScrollZoomFactor(zoomDelta) - if (abs(factor - 1f) > 0.0001f) { - applyZoomFactor(factor, eventTime, event.changes.firstOrNull()?.position) - event.changes.forEach { it.consume() } - } - continue - } - - val pressedPointers = event.changes.count { it.pressed } - if (pressedPointers > 1) { - val zoomChange = event.calculateZoom() - if (zoomChange.isFinite() && abs(zoomChange - 1f) > 0.005f) { - val centroid = event.calculateCentroid(useCurrent = false) - val anchor = if (centroid == Offset.Unspecified) { - event.changes.firstOrNull { it.pressed }?.position - } else { - centroid - } - applyZoomFactor(zoomChange, eventTime, anchor) - } - event.changes.forEach { it.consume() } - } - } - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDiagnostics.kt deleted file mode 100644 index 84f1647..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDiagnostics.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.aryan.reader.desktop - -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.unit.IntSize - -private const val PdfZoomPerfLogTag = "EpistemePdfZoomPerf" -private const val PdfLinkLogTag = "EpistemePdfLink" -private const val EpubLinkLogTag = "EpistemeEpubLink" -private const val EpubPaginationLogTag = "EpistemeEpubPagination" -private const val ReaderGapLogTag = "EpistemeReaderGap" -private const val EpubSelectionDebugLogTag = "EPUB_SELECTION_DEBUG" - -internal fun logPdfSelection(message: String) { -} - -internal fun logPdfZoomPerf(message: String) { - logDesktopDiagnostic(PdfZoomPerfLogTag) { message } -} - -internal fun logPdfZoomPerf(message: () -> String) { - logDesktopDiagnostic(PdfZoomPerfLogTag, message) -} - -internal fun logPdfLink(message: String) { - logDesktopDiagnostic(PdfLinkLogTag) { message } -} - -internal fun logEpubLink(message: String) { - logDesktopDiagnostic(EpubLinkLogTag) { message } -} - -internal fun logEpubPagination(message: String) { - logDesktopDiagnostic(EpubPaginationLogTag) { message } -} - -internal fun logReaderGap(message: String) { - logDesktopDiagnostic(ReaderGapLogTag) { message } -} - -internal fun logEpubSelectionDebug(message: String) { - logDesktopDiagnostic(EpubSelectionDebugLogTag) { message } -} - -internal fun DesktopPdfLinkTarget.formatLogTarget(): String { - return "dest=${destPageIndex?.let { it + 1 } ?: "null"} uri=\"${uri.orEmpty().logPreview()}\"" -} - -internal fun Float.formatLogFloat(): String { - return String.format("%.3f", this) -} - -internal fun Offset?.formatLogOffset(): String { - if (this == null) return "none" - return "${x.formatLogFloat()},${y.formatLogFloat()}" -} - -internal fun IntSize.formatLogSize(): String { - return "${width}x${height}" -} - -internal fun DesktopPdfCharHit?.formatLogHit(prefix: String): String { - if (this == null) { - return "${prefix}Index=null ${prefix}Source=none ${prefix}X=null ${prefix}Y=null ${prefix}Nx=null ${prefix}Ny=null" - } - return "${prefix}Index=$index ${prefix}Source=$source " + - "${prefix}X=${point.x.formatLogFloat()} ${prefix}Y=${point.y.formatLogFloat()} " + - "${prefix}Nx=${normalized.x.formatLogFloat()} ${prefix}Ny=${normalized.y.formatLogFloat()}" -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderScreen.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderScreen.kt deleted file mode 100644 index 44cb459..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderScreen.kt +++ /dev/null @@ -1,501 +0,0 @@ -package com.aryan.reader.desktop - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -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 -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.isSpecified -import com.aryan.reader.paginatedreader.CssStyle -import com.aryan.reader.paginatedreader.SemanticImage -import com.aryan.reader.shared.CustomFontItem -import com.aryan.reader.shared.ReaderAction -import com.aryan.reader.shared.ReaderAiByokSettings -import com.aryan.reader.shared.ReaderAiFeature -import com.aryan.reader.shared.ReaderAutoScrollState -import com.aryan.reader.shared.ReaderExtrasState -import com.aryan.reader.shared.ReaderExternalLookupAction -import com.aryan.reader.shared.ReaderHighlightPalette -import com.aryan.reader.shared.ReaderToolbarPreferences -import com.aryan.reader.shared.ReaderTtsChunk -import com.aryan.reader.shared.ReaderTtsReadScope -import com.aryan.reader.shared.ReaderTtsReplacementPreferences -import com.aryan.reader.shared.reader.ReaderEngine -import com.aryan.reader.shared.reader.ReaderImageReference -import com.aryan.reader.shared.reader.ReaderLinkTarget -import com.aryan.reader.shared.reader.ReaderReadingMode -import com.aryan.reader.shared.reader.ReaderSettings -import com.aryan.reader.shared.reader.ReaderSessionState -import com.aryan.reader.shared.reader.ReaderViewportSpec -import com.aryan.reader.shared.reader.SharedEpubPaginationCache -import com.aryan.reader.shared.reader.SharedMeasuredEpubPaginator -import com.aryan.reader.shared.reader.layoutSignature -import com.aryan.reader.shared.reduce -import com.aryan.reader.shared.ui.DesktopEpubNativeImage -import com.aryan.reader.shared.ui.ReaderContentRenderPlan -import com.aryan.reader.shared.ui.SharedNativePaginatedReader -import com.aryan.reader.shared.ui.SharedNativeReaderSelectionAction -import com.aryan.reader.shared.ui.SharedReaderScreen -import kotlinx.coroutines.delay -import java.awt.event.KeyEvent as AwtKeyEvent - -@Composable -internal fun DesktopReaderScreen( - session: ReaderSessionState, - readerEngine: ReaderEngine, - onSessionChange: (ReaderSessionState) -> Unit, - onReturnToLibrary: (() -> Unit)? = null, - onFullscreenChange: (Boolean) -> Unit = {}, - toolbarPreferences: ReaderToolbarPreferences, - onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit, - highlightPalette: ReaderHighlightPalette, - onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit, - ttsReplacementPreferences: ReaderTtsReplacementPreferences, - ttsReplacementBookId: String?, - onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, - onPickCustomFont: () -> String?, - customFonts: List, - readerExtrasState: ReaderExtrasState, - aiByokSettings: ReaderAiByokSettings, - externalLookupAvailable: Boolean, - cloudTtsControlsAvailable: Boolean, - onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, - onAiAction: (ReaderAiFeature, String) -> Unit, - onAiResultDismiss: () -> Unit, - onCloudTtsToggle: (String) -> Unit, - onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit, - onCloudTtsPauseResume: () -> Unit, - onCloudTtsStop: () -> Unit, - onCloudTtsClearCache: () -> Unit, - onOpenAiHub: (() -> Unit)? = null, - onAutoScrollChange: (ReaderAutoScrollState) -> Unit, - onDownloadReaderImage: (ReaderImageReference) -> Unit, - readerTextureDataUri: (String) -> String?, - readerCustomTextureIds: List, - onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?, - bottomChromeExtraContent: @Composable ColumnScope.() -> Unit = {}, - webViewRuntimeState: DesktopWebViewRuntimeState, - webViewNetworkAccessEnabled: Boolean, - epubPaginationCache: SharedEpubPaginationCache, - epubPaginationCacheGeneration: Int, - useDetachedChromeLayer: Boolean = true, - useDetachedPanelLayer: Boolean = true -) { - val clipboardManager = LocalClipboardManager.current - val density = LocalDensity.current - val textMeasurer = rememberTextMeasurer() - val paginationCacheWriteScope = rememberCoroutineScope() - val measuredPaginator = remember( - textMeasurer, - density, - session.reader.settings.fontFamily, - session.reader.settings.customFontPath, - epubPaginationCache, - paginationCacheWriteScope - ) { - SharedMeasuredEpubPaginator( - textMeasurer = textMeasurer, - density = density, - fontFamily = session.reader.settings.toDesktopReaderFontFamily(), - pageCache = epubPaginationCache, - cacheWriteScope = paginationCacheWriteScope - ) - } - var readerViewport by remember(session.reader.book.id) { mutableStateOf(ReaderViewportSpec(0, 0)) } - val paginationLayoutSignature = session.reader.settings.layoutSignature() - val paginationContentSignature = remember(session.reader.book) { - session.reader.book.desktopPaginationContentSignature() - } - val paginationDensitySignature = DesktopEpubPaginationDensity( - density = density.density, - fontScale = density.fontScale - ) - val measuredPaginationRequest = remember( - session.reader.book.id, - paginationContentSignature, - paginationLayoutSignature, - readerViewport, - paginationDensitySignature, - epubPaginationCacheGeneration - ) { - if (session.reader.settings.readingMode == ReaderReadingMode.PAGINATED && readerViewport.isSpecified) { - DesktopEpubPaginationRequest( - bookId = session.reader.book.id, - chapterSignature = paginationContentSignature, - layoutSignature = paginationLayoutSignature, - viewport = readerViewport, - density = paginationDensitySignature, - cacheGeneration = epubPaginationCacheGeneration - ) - } else { - null - } - } - var completedMeasuredPaginationRequest by remember(session.reader.book.id) { - mutableStateOf(null) - } - var runningMeasuredPaginationRequest by remember(session.reader.book.id) { - mutableStateOf(null) - } - val paginatedLayoutReady = session.reader.settings.readingMode != ReaderReadingMode.PAGINATED || - (measuredPaginationRequest != null && completedMeasuredPaginationRequest == measuredPaginationRequest) - val latestSession by rememberUpdatedState(session) - val latestOnSessionChange by rememberUpdatedState(onSessionChange) - var externalLinkDialogUrl by remember { mutableStateOf(null) } - var lastHandledLink by remember { mutableStateOf(null) } - var isFullscreen by remember(session.reader.book.id) { mutableStateOf(false) } - val currentReaderFullscreen by rememberUpdatedState(isFullscreen) - val currentOnReaderFullscreenChange by rememberUpdatedState(onFullscreenChange) - - fun setReaderFullscreen(enabled: Boolean) { - isFullscreen = enabled - onFullscreenChange(enabled) - } - - DesktopExternalLinkDialog( - url = externalLinkDialogUrl, - onDismiss = { externalLinkDialogUrl = null } - ) - - fun handleReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean { - val action = event.desktopReaderKeyNavigationOrNull(fullscreen = isFullscreen) ?: return false - val currentSession = latestSession - val nextSession = currentSession.reduceDesktopReaderKeyNavigation(action, readerEngine) - if (nextSession == null) { - if (action == DesktopReaderKeyNavigation.EXIT_FULLSCREEN && isFullscreen) { - setReaderFullscreen(false) - } - } else { - latestOnSessionChange(nextSession) - } - return true - } - DesktopReaderFullscreenKeyEffect( - enabled = isFullscreen && externalLinkDialogUrl == null, - onKeyPressed = { event -> handleReaderFullscreenAwtKeyEvent(event) } - ) - - LaunchedEffect(session.reader.settings.readingMode) { - if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) { - completedMeasuredPaginationRequest = null - runningMeasuredPaginationRequest = null - } - } - - DisposableEffect(session.reader.book.id) { - onDispose { - if (currentReaderFullscreen) { - currentOnReaderFullscreenChange(false) - } - } - } - - LaunchedEffect( - measuredPaginationRequest, - measuredPaginator - ) { - val request = measuredPaginationRequest ?: return@LaunchedEffect - if (completedMeasuredPaginationRequest == request) { - logEpubPagination( - "reflow_skip reason=request_already_measured book=\"${session.reader.book.title.logPreview()}\" " + - "viewport=${request.viewport.widthPx}x${request.viewport.heightPx}" - ) - return@LaunchedEffect - } - delay(280L) - val settings = latestSession.reader.settings - if (settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect - if (settings.layoutSignature() != request.layoutSignature) return@LaunchedEffect - runningMeasuredPaginationRequest = request - try { - val reflowStartSession = latestSession - val reflowStartRequestId = reflowStartSession.navigationRequestId - val reflowAnchor = readerEngine.reflowAnchorFor(reflowStartSession) - logEpubPagination( - "reflow_start book=\"${session.reader.book.title.logPreview()}\" " + - "viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + - "spread=${settings.pageSpreadMode} font=${settings.fontSize} lineSpacing=${settings.lineSpacing} " + - "margins=${settings.resolvedHorizontalMargin}x${settings.resolvedVerticalMargin} " + - "pageWidthSetting=${settings.pageWidth} oldPages=${reflowStartSession.reader.pages.size} " + - "anchorPage=${reflowAnchor?.pageIndex} anchorOffsets=${reflowAnchor?.startOffset}..${reflowAnchor?.endOffset}" - ) - val pages = measuredPaginator.paginate( - book = session.reader.book, - settings = settings, - viewport = request.viewport - ) - val layoutChanged = pages.isNotEmpty() && !latestSession.reader.pages.samePageLayoutAs(pages) - logEpubPagination( - "reflow_result book=\"${session.reader.book.title.logPreview()}\" pages=${pages.size} " + - "layoutChanged=$layoutChanged currentPages=${latestSession.reader.pages.size}" - ) - if (layoutChanged) { - latestOnSessionChange( - readerEngine.replacePages( - state = latestSession, - pages = pages, - reflowAnchor = reflowAnchor, - navigationRequestIdAtReflowStart = reflowStartRequestId - ) - ) - } - if (pages.isNotEmpty()) { - completedMeasuredPaginationRequest = request - } - } finally { - if (runningMeasuredPaginationRequest == request) { - runningMeasuredPaginationRequest = null - } - } - } - - val handleDesktopSelectionAction: (DesktopReaderSelectionAction, String) -> Unit = { action, text -> - val settings = aiByokSettings.sanitized() - when (action) { - DesktopReaderSelectionAction.DEFINE -> { - if (settings.areReaderAiFeaturesAvailable) onAiAction(ReaderAiFeature.DEFINE, text) - } - DesktopReaderSelectionAction.SPEAK -> { - if (settings.isCloudTtsAvailable) onCloudTtsToggle(text) - } - DesktopReaderSelectionAction.SEARCH -> onExternalLookup(ReaderExternalLookupAction.SEARCH, text) - } - } - val nativeSelectionActions = buildSet { - val settings = aiByokSettings.sanitized() - if (settings.areReaderAiFeaturesAvailable) add(SharedNativeReaderSelectionAction.DEFINE) - if (externalLookupAvailable) add(SharedNativeReaderSelectionAction.SEARCH) - if (settings.isCloudTtsAvailable) add(SharedNativeReaderSelectionAction.SPEAK) - } - val handleNativeSelectionAction: (SharedNativeReaderSelectionAction, String) -> Unit = { action, text -> - when (action) { - SharedNativeReaderSelectionAction.DEFINE -> - handleDesktopSelectionAction(DesktopReaderSelectionAction.DEFINE, text) - SharedNativeReaderSelectionAction.SPEAK -> - handleDesktopSelectionAction(DesktopReaderSelectionAction.SPEAK, text) - SharedNativeReaderSelectionAction.SEARCH -> - handleDesktopSelectionAction(DesktopReaderSelectionAction.SEARCH, text) - } - } - val handleDesktopEpubLinkClicked: (DesktopEpubLinkClick) -> Unit = { link -> - val now = System.currentTimeMillis() - val last = lastHandledLink - if (last != null && last.href == link.href && now - last.handledAtMs < 900L) { - logEpubLink( - "click_duplicate_ignored source=${link.source} href=\"${link.href.logPreview()}\" " + - "ageMs=${now - last.handledAtMs}" - ) - } else { - lastHandledLink = DesktopEpubHandledLink(link.href, now) - logEpubLink( - "click source=${link.source} href=\"${link.href.logPreview()}\" " + - "chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\" " + - "text=\"${link.text.orEmpty().logPreview()}\"" - ) - when (val target = readerEngine.resolveLink(session, link.href, link.chapterIndex)) { - is ReaderLinkTarget.External -> { - logEpubLink("resolved_external url=\"${target.url.logPreview()}\"") - if (externalLookupAvailable) { - externalLinkDialogUrl = target.url - } - } - is ReaderLinkTarget.Internal -> { - logEpubLink( - "resolved_internal chapter=${target.locator.chapterIndex} " + - "page=${target.locator.pageIndex} offset=${target.locator.startOffset}" - ) - onSessionChange(readerEngine.jumpToLocator(session, target.locator)) - } - ReaderLinkTarget.Ignored -> { - logEpubLink("resolved_ignored href=\"${link.href.logPreview()}\"") - } - } - } - } - - SharedReaderScreen( - session = session, - readerEngine = readerEngine, - onSessionChange = onSessionChange, - onReturnToLibrary = onReturnToLibrary, - isFullscreen = isFullscreen, - onFullscreenChange = ::setReaderFullscreen, - toolbarPreferences = toolbarPreferences, - onToolbarPreferencesChange = onToolbarPreferencesChange, - highlightPalette = highlightPalette, - onHighlightPaletteChange = onHighlightPaletteChange, - ttsReplacementPreferences = ttsReplacementPreferences, - ttsReplacementBookId = ttsReplacementBookId, - onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange, - onPickCustomFont = onPickCustomFont, - customFonts = customFonts, - readerExtrasState = readerExtrasState, - aiByokSettings = aiByokSettings, - externalLookupAvailable = externalLookupAvailable, - cloudTtsControlsAvailable = cloudTtsControlsAvailable, - onExternalLookup = onExternalLookup, - onAiAction = onAiAction, - onAiResultDismiss = onAiResultDismiss, - onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) }, - onCloudTtsStart = onCloudTtsStart, - onCloudTtsPauseResume = onCloudTtsPauseResume, - onCloudTtsStop = onCloudTtsStop, - onCloudTtsClearCache = onCloudTtsClearCache, - onOpenAiHub = onOpenAiHub, - onAutoScrollChange = onAutoScrollChange, - onDownloadReaderImage = onDownloadReaderImage, - readerImagePreviewContent = { image, previewModifier -> - DesktopEpubNativeImage( - image = image.toDesktopPreviewSemanticImage(), - modifier = previewModifier.clip(RoundedCornerShape(3.dp)) - ) - }, - readerTextureDataUri = readerTextureDataUri, - readerCustomTextureIds = readerCustomTextureIds, - onImportReaderTexture = onImportReaderTexture, - bottomChromeExtraContent = bottomChromeExtraContent, - useDetachedChromeLayer = useDetachedChromeLayer, - useDetachedPanelLayer = useDetachedPanelLayer - ) { renderPlan, onVisiblePageChanged, onHighlightSelected, onChromeActivity -> - Surface( - color = renderPlan.background, - shape = RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp), - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .clip(RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) - .onSizeChanged { size -> - val next = ReaderViewportSpec(size.width, size.height) - logReaderGap( - "desktop_epub_reader_surface size=${size.width}x${size.height} " + - "mode=${session.reader.settings.readingMode} " + - "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}" - ) - if (next != readerViewport) { - logEpubPagination( - "viewport_changed width=${next.widthPx} height=${next.heightPx} " + - "previous=${readerViewport.widthPx}x${readerViewport.heightPx}" - ) - readerViewport = next - } - } - ) { - if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages && !paginatedLayoutReady) { - DesktopEpubPaginationPreparing( - active = runningMeasuredPaginationRequest != null, - modifier = Modifier.fillMaxSize() - ) - } else { - when (renderPlan) { - is ReaderContentRenderPlan.WebDocument -> { - if (webViewRuntimeState.initialized) { - DesktopEpubWebView( - html = renderPlan.html, - appearanceScript = renderPlan.appearanceScript, - navigationTarget = renderPlan.navigationTarget, - highlights = renderPlan.highlights, - onHighlightCreated = { highlight -> - onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine)) - }, - onHighlightSelected = onHighlightSelected, - isFullscreen = isFullscreen, - onKeyboardNavigation = { action -> - val nextSession = session.reduceDesktopReaderKeyNavigation(action, readerEngine) - if (nextSession == null) { - if (action == DesktopReaderKeyNavigation.EXIT_FULLSCREEN && isFullscreen) { - setReaderFullscreen(false) - } - } else { - onSessionChange(nextSession) - } - }, - onSelectionAction = handleDesktopSelectionAction, - onLinkClicked = handleDesktopEpubLinkClicked, - onVisiblePageChanged = onVisiblePageChanged, - onPointerActivity = onChromeActivity, - networkAccessEnabled = webViewNetworkAccessEnabled, - modifier = Modifier.fillMaxSize() - ) - } else { - DesktopWebViewRuntimeIndicator( - state = webViewRuntimeState, - modifier = Modifier.fillMaxSize() - ) - } - } - is ReaderContentRenderPlan.NativePaginatedPages -> { - SharedNativePaginatedReader( - renderPlan = renderPlan, - readerFontFamily = renderPlan.settings.toDesktopReaderFontFamily(), - searchHighlight = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f), - onVisiblePageChanged = onVisiblePageChanged, - enabledSelectionActions = nativeSelectionActions, - onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) }, - onSelectionAction = handleNativeSelectionAction, - onHighlightCreated = { highlight -> - onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine)) - }, - onHighlightSelected = onHighlightSelected, - onLinkClicked = { link -> - handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick()) - }, - imageContent = { image, imageModifier -> - DesktopEpubNativeImage( - image = image, - modifier = imageModifier - ) - }, - modifier = Modifier.fillMaxSize() - ) - } - } - } - } - } -} - -private fun ReaderImageReference.toDesktopPreviewSemanticImage(): SemanticImage { - return SemanticImage( - path = source, - altText = altText, - intrinsicWidth = intrinsicWidth, - intrinsicHeight = intrinsicHeight, - style = CssStyle(), - elementId = null, - cfi = cfi, - blockIndex = blockIndex - ) -} - -private fun ReaderSessionState.reduceDesktopReaderKeyNavigation( - action: DesktopReaderKeyNavigation, - readerEngine: ReaderEngine -): ReaderSessionState? { - return when (action) { - DesktopReaderKeyNavigation.NEXT -> reduce(ReaderAction.NextPage, readerEngine) - DesktopReaderKeyNavigation.PREVIOUS -> reduce(ReaderAction.PreviousPage, readerEngine) - DesktopReaderKeyNavigation.FIRST -> reduce(ReaderAction.JumpToPage(0), readerEngine) - DesktopReaderKeyNavigation.LAST -> reduce(ReaderAction.JumpToPage(reader.pages.lastIndex), readerEngine) - DesktopReaderKeyNavigation.SEARCH -> reduce(ReaderAction.SearchOpened, readerEngine) - DesktopReaderKeyNavigation.NEXT_SEARCH -> reduce(ReaderAction.JumpToNextSearchResult, readerEngine) - DesktopReaderKeyNavigation.EXIT_FULLSCREEN -> null - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt deleted file mode 100644 index e28b413..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.aryan.reader.desktop - -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.platform.Font as DesktopFont -import com.aryan.reader.shared.AppFontPreference -import com.aryan.reader.shared.AppFontPreferenceKind -import com.aryan.reader.shared.CustomFontItem -import com.aryan.reader.shared.reader.ReaderPage -import com.aryan.reader.shared.reader.ReaderSettings -import java.io.File - -internal fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily { - customFontPath?.takeIf { it.isNotBlank() }?.let { path -> - runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull()?.let { return it } - } - return fontFamily.toComposeFontFamily() -} - -private fun String.toComposeFontFamily(): FontFamily { - return when (this) { - "Serif" -> FontFamily.Serif - "Sans" -> FontFamily.SansSerif - "Mono" -> FontFamily.Monospace - else -> FontFamily.Default - } -} - -internal fun List.samePageLayoutAs(other: List): Boolean { - if (size != other.size) return false - return indices.all { index -> - val left = this[index] - val right = other[index] - left.pageIndex == right.pageIndex && - left.chapterIndex == right.chapterIndex && - left.startOffset == right.startOffset && - left.endOffset == right.endOffset && - left.text.length == right.text.length && - left.semanticBlocks.size == right.semanticBlocks.size - } -} - -internal fun CustomFontItem.toDesktopPreviewFontFamily(): FontFamily? { - val file = File(path).takeIf { it.isFile } ?: return null - return runCatching { FontFamily(DesktopFont(file)) }.getOrNull() -} - -internal fun AppFontPreference.toDesktopAppFontFamily(customFonts: List): FontFamily? { - val sanitized = sanitized() - return when (sanitized.kind) { - AppFontPreferenceKind.SYSTEM -> null - AppFontPreferenceKind.SERIF -> FontFamily.Serif - AppFontPreferenceKind.SANS_SERIF -> FontFamily.SansSerif - AppFontPreferenceKind.MONOSPACE -> FontFamily.Monospace - AppFontPreferenceKind.CUSTOM -> { - val fontId = sanitized.customFontId ?: return null - customFonts.firstOrNull { it.id == fontId && !it.isDeleted } - ?.toDesktopPreviewFontFamily() - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt deleted file mode 100644 index 26f63ce..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.aryan.reader.desktop - -private const val DesktopTtsLogTag = "EpistemeDesktopTts" - -internal fun logDesktopTts(message: String) { - logDesktopDiagnostic(DesktopTtsLogTag) { message } -} - -internal fun Throwable.desktopTtsSummary(): String { - val type = this::class.java.simpleName.ifBlank { "Throwable" } - return "$type: ${message.orEmpty().desktopTtsPreview(220)}" -} - -internal fun String.desktopTtsPreview(maxLength: Int = 120): String { - return replace(Regex("\\s+"), " ") - .trim() - .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } - .replace("\"", "\\\"") -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAccountProfileRepository.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt similarity index 50% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAccountProfileRepository.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt index 03e4324..03181c1 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAccountProfileRepository.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -8,18 +8,49 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import java.io.File import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder +import java.util.Properties internal data class DesktopAccountProfile( val isProUser: Boolean = false, - val credits: Int = 0 + val credits: Int = 0, + val fetchedAtEpochMillis: Long = 0L ) +// Credits and Pro status are server-owned, so startup only trusts a recent snapshot. +internal const val DesktopAccountProfileCacheTtlMillis: Long = 30L * 60L * 1000L + +internal fun DesktopAccountProfile.isFresh( + nowEpochMillis: Long = System.currentTimeMillis(), + ttlMillis: Long = DesktopAccountProfileCacheTtlMillis +): Boolean { + if (fetchedAtEpochMillis <= 0L || ttlMillis <= 0L) return false + val ageMillis = nowEpochMillis - fetchedAtEpochMillis + return ageMillis in 0L..ttlMillis +} + internal class DesktopAccountProfileRepository( - private val config: DesktopCloudConfig + private val config: DesktopCloudConfig, + private val store: DesktopAccountProfileStore = DesktopAccountProfileStore() ) { + fun cachedProfile( + uid: String, + nowEpochMillis: Long = System.currentTimeMillis() + ): DesktopAccountProfile? { + return store.load(uid)?.takeIf { profile -> profile.isFresh(nowEpochMillis) } + } + + fun saveFetchedProfile(uid: String, profile: DesktopAccountProfile) { + store.save(uid, profile) + } + + fun clearCachedProfiles() { + store.clear() + } + suspend fun fetchProfile(uid: String, idToken: String): DesktopAccountProfile = withContext(Dispatchers.IO) { if (uid.isBlank() || idToken.isBlank()) return@withContext DesktopAccountProfile() val url = "https://firestore.googleapis.com/v1/projects/${urlEncode(config.firebaseProjectId)}/databases/(default)/documents/users/${urlEncode(uid)}" @@ -31,23 +62,60 @@ internal class DesktopAccountProfileRepository( readTimeout = 20_000 } try { - if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) return@withContext DesktopAccountProfile() + if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) { + return@withContext DesktopAccountProfile(fetchedAtEpochMillis = System.currentTimeMillis()) + } val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() if (connection.responseCode !in 200..299) { throw IllegalStateException("Could not check account status: HTTP ${connection.responseCode}") } val fields = DesktopAccountJson.parseToJsonElement(text).jsonObject["fields"].jsonObjectOrNull() - DesktopAccountProfile( + val profile = DesktopAccountProfile( isProUser = fields?.booleanField("isPro") == true, - credits = fields?.numberField("credits")?.toInt() ?: 0 + credits = fields?.numberField("credits")?.toInt() ?: 0, + fetchedAtEpochMillis = System.currentTimeMillis() ) + profile } finally { connection.disconnect() } } } +internal class DesktopAccountProfileStore( + private val settingsFile: File = File(desktopUserConfigRoot(), "account_profile.properties") +) { + fun load(uid: String): DesktopAccountProfile? { + if (uid.isBlank() || !settingsFile.isFile) return null + val properties = Properties() + return runCatching { + settingsFile.inputStream().use(properties::load) + if (properties.getProperty("uid", "") != uid) return null + DesktopAccountProfile( + isProUser = properties.getProperty("isProUser", "false").toBooleanStrictOrNull() ?: false, + credits = properties.getProperty("credits", "0").toIntOrNull() ?: 0, + fetchedAtEpochMillis = properties.getProperty("fetchedAtEpochMillis", "0").toLongOrNull() ?: 0L + ) + }.getOrNull() + } + + fun save(uid: String, profile: DesktopAccountProfile) { + if (uid.isBlank()) return + val properties = Properties().apply { + setProperty("uid", uid) + setProperty("isProUser", profile.isProUser.toString()) + setProperty("credits", profile.credits.toString()) + setProperty("fetchedAtEpochMillis", profile.fetchedAtEpochMillis.toString()) + } + settingsFile.storePropertiesAtomically(properties, "Episteme desktop account profile") + } + + fun clear() { + settingsFile.delete() + } +} + private val DesktopAccountJson = Json { ignoreUnknownKeys = true } private fun JsonObject?.booleanField(key: String): Boolean? { diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiByokStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt similarity index 52% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiByokStore.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt index a2905e6..5a5dfdf 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiByokStore.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt @@ -1,8 +1,9 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.DEFAULT_CLOUD_TTS_SPEAKER_ID -import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID -import com.aryan.reader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.DEFAULT_CLOUD_TTS_SPEAKER_ID +import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL_ID +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import com.sun.jna.Library import com.sun.jna.Memory import com.sun.jna.Native import com.sun.jna.Pointer @@ -13,10 +14,20 @@ import com.sun.jna.win32.StdCallLibrary import java.io.File import java.util.Base64 import java.util.Properties +import java.util.concurrent.TimeUnit private const val WINDOWS_CRED_TYPE_GENERIC = 1 private const val WINDOWS_CRED_PERSIST_LOCAL_MACHINE = 2 private const val WINDOWS_ERROR_NOT_FOUND = 1168 +private const val LINUX_SECRET_SCHEMA_DONT_MATCH_NAME = 2 +private const val LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING = 0 +private const val LINUX_SECRET_SCHEMA_NAME = "org.dueattendant149.bookreader.Secret" +private const val LINUX_SECRET_COLLECTION_DEFAULT = "default" +private const val LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE = "application" +private const val LINUX_SECRET_SERVICE_APPLICATION_VALUE = "Episteme.Reader" +private const val LINUX_SECRET_SERVICE_KEY_ATTRIBUTE = "key" +private const val LINUX_LIBSECRET_PREFIX = "linux-libsecret:" +private const val LINUX_SECRET_TOOL_PREFIX = "secret-tool:" internal class DesktopAiByokStore( private val settingsFile: File = defaultSettingsFile(), @@ -60,7 +71,7 @@ internal class DesktopAiByokStore( loadedSettings.copy(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID) } else { loadedSettings - } + }.toDesktopPersistableAiSettings() if (secureStorageAvailable && (legacyGeminiKey.isNotBlank() || legacyGroqKey.isNotBlank() || settings != loadedSettings) ) { @@ -82,7 +93,7 @@ internal class DesktopAiByokStore( } fun save(settings: ReaderAiByokSettings) { - val sanitized = settings.sanitized() + val sanitized = settings.toDesktopPersistableAiSettings() logDesktopTts( "settings_save_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " + "secureStorage=${secretCodec.isAvailable} geminiKey=${sanitized.geminiKey.isNotBlank()} " + @@ -101,9 +112,7 @@ internal class DesktopAiByokStore( setProperty("ttsSpeakerId", sanitized.ttsSpeakerId) } settingsFile.parentFile?.mkdirs() - settingsFile.outputStream().use { output -> - properties.store(output, "Episteme desktop AI keys and models") - } + settingsFile.storePropertiesAtomically(properties, "Episteme desktop AI keys and models") logDesktopTts( "settings_save_complete geminiProtected=${properties.getProperty(GeminiKey, "").isNotBlank()} " + "groqProtected=${properties.getProperty(GroqKey, "").isNotBlank()}" @@ -173,10 +182,10 @@ internal interface DesktopSecretCodec { companion object { fun platform(): DesktopSecretCodec { val osName = System.getProperty("os.name").orEmpty() - val codec = if (osName.startsWith("Windows", ignoreCase = true)) { - WindowsSecretCodec - } else { - UnavailableDesktopSecretCodec + val codec = when { + osName.startsWith("Windows", ignoreCase = true) -> WindowsSecretCodec + osName.contains("Linux", ignoreCase = true) -> LinuxSecretServiceCodec() + else -> UnavailableDesktopSecretCodec } logDesktopTts("settings_platform os=\"${osName.desktopTtsPreview()}\" codec=${codec.name}") return codec @@ -193,6 +202,487 @@ private object UnavailableDesktopSecretCodec : DesktopSecretCodec { override fun unprotect(value: String): String = "" } +internal data class DesktopSecretCommandResult( + val exitCode: Int, + val stdout: String, + val stderr: String +) { + val isSuccess: Boolean get() = exitCode == 0 + val errorSummary: String + get() = stderr.ifBlank { stdout }.desktopTtsPreview(240).ifBlank { "exit code $exitCode" } +} + +internal interface DesktopSecretCommandRunner { + fun isExecutableAvailable(command: String): Boolean + fun run(command: List, input: String? = null, timeoutMillis: Long = 5_000L): DesktopSecretCommandResult +} + +private object DesktopProcessSecretCommandRunner : DesktopSecretCommandRunner { + override fun isExecutableAvailable(command: String): Boolean { + val path = System.getenv("PATH").orEmpty() + return path.split(File.pathSeparator) + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .any { directory -> + File(directory, command).let { it.isFile && it.canExecute() } + } + } + + override fun run(command: List, input: String?, timeoutMillis: Long): DesktopSecretCommandResult { + require(command.isNotEmpty()) { "Secret command cannot be empty." } + val process = ProcessBuilder(command).start() + input?.let { value -> + process.outputStream.use { output -> + output.write(value.toByteArray(Charsets.UTF_8)) + } + } ?: process.outputStream.close() + + val completed = process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS) + if (!completed) { + process.destroyForcibly() + throw IllegalStateException("Timed out waiting for ${command.first()} secure storage command.") + } + return DesktopSecretCommandResult( + exitCode = process.exitValue(), + stdout = process.inputStream.readBytes().toString(Charsets.UTF_8), + stderr = process.errorStream.readBytes().toString(Charsets.UTF_8) + ) + } +} + +internal class LinuxSecretServiceCodec( + private val libsecretCodec: DesktopSecretCodec = LinuxLibsecretCodec(), + private val secretToolCodec: DesktopSecretCodec = LinuxSecretToolCodec() +) : DesktopSecretCodec { + private val codecs: List = listOf(libsecretCodec, secretToolCodec) + + private val selectedCodec: DesktopSecretCodec? by lazy { + codecs.firstOrNull { codec -> + runCatching { codec.isAvailable } + .onFailure { error -> + logDesktopTts("settings_linux_secret_service_probe_failed codec=${codec.name} error=\"${error.desktopTtsSummary()}\"") + } + .getOrDefault(false) + } + } + + override val name: String = "linux-secret-service" + + override val isAvailable: Boolean + get() = selectedCodec != null + + override fun protect(value: String): String { + return protect("secret", value) + } + + override fun unprotect(value: String): String { + return unprotect("secret", value) + } + + override fun protect(keyName: String, value: String): String { + val codec = selectedCodec ?: throw IllegalStateException( + "Linux Secret Service is unavailable. Install gnome-keyring or another Secret Service provider." + ) + return codec.protect(keyName, value) + } + + override fun unprotect(keyName: String, value: String): String { + val orderedCodecs = when { + value.startsWith(LINUX_LIBSECRET_PREFIX) -> listOf(libsecretCodec, secretToolCodec) + value.startsWith(LINUX_SECRET_TOOL_PREFIX) -> listOf(libsecretCodec, secretToolCodec) + else -> codecs + } + for (codec in orderedCodecs) { + val secret = runCatching { + if (!codec.isAvailable) "" else codec.unprotect(keyName, value) + }.onFailure { error -> + logDesktopTts( + "settings_linux_secret_service_read_failed codec=${codec.name} key=$keyName " + + "error=\"${error.desktopTtsSummary()}\"" + ) + }.getOrDefault("") + if (secret.isNotBlank()) return secret + } + return "" + } + + override fun delete(keyName: String) { + codecs.forEach { codec -> + runCatching { codec.delete(keyName) } + .onFailure { error -> + logDesktopTts( + "settings_linux_secret_service_delete_failed codec=${codec.name} key=$keyName " + + "error=\"${error.desktopTtsSummary()}\"" + ) + } + } + } +} + +internal interface LinuxSecretServiceClient { + val isAvailable: Boolean + fun store(key: String, label: String, password: String) + fun lookup(key: String): String? + fun clear(key: String) +} + +internal class LinuxLibsecretCodec( + private val client: LinuxSecretServiceClient = JnaLinuxSecretServiceClient +) : DesktopSecretCodec { + override val name: String = "linux-libsecret" + + override val isAvailable: Boolean by lazy { + val available = if (!client.isAvailable) { + false + } else { + val probeKey = linuxSecretKey("probe") + val probeSecret = "episteme-linux-libsecret-probe" + runCatching { + client.store(probeKey, "Episteme secure storage probe", probeSecret) + client.lookup(probeKey) == probeSecret + }.onFailure { error -> + logDesktopTts("settings_linux_libsecret_unavailable error=\"${error.desktopTtsSummary()}\"") + }.also { + runCatching { client.clear(probeKey) } + }.getOrDefault(false) + } + logDesktopTts("settings_linux_libsecret_available available=$available") + available + } + + override fun protect(value: String): String { + return protect("secret", value) + } + + override fun unprotect(value: String): String { + return unprotect("secret", value) + } + + override fun protect(keyName: String, value: String): String { + if (!isAvailable) { + throw IllegalStateException( + "Linux Secret Service is unavailable. Install gnome-keyring or another Secret Service provider." + ) + } + val key = linuxSecretKey(keyName) + logDesktopTts("settings_linux_libsecret_write_start key=$keyName valueChars=${value.length}") + client.store(key, "Episteme $keyName", value) + logDesktopTts("settings_linux_libsecret_write_result key=$keyName") + return LINUX_LIBSECRET_PREFIX + key + } + + override fun unprotect(keyName: String, value: String): String { + if (!isAvailable) return "" + val key = linuxSecretReferenceKey(keyName, value) + logDesktopTts("settings_linux_libsecret_read_start key=$keyName") + val secret = client.lookup(key).orEmpty() + logDesktopTts("settings_linux_libsecret_read_result key=$keyName chars=${secret.length}") + return secret + } + + override fun delete(keyName: String) { + if (!client.isAvailable) return + val key = linuxSecretKey(keyName) + runCatching { client.clear(key) } + .onFailure { error -> + logDesktopTts("settings_linux_libsecret_delete_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") + } + } +} + +private object JnaLinuxSecretServiceClient : LinuxSecretServiceClient { + override val isAvailable: Boolean by lazy { + runCatching { + LinuxLibsecretNative.INSTANCE + LinuxGlibNative.INSTANCE + true + }.onFailure { error -> + logDesktopTts("settings_linux_libsecret_load_failed error=\"${error.desktopTtsSummary()}\"") + }.getOrDefault(false) + } + + private val schema: Pointer by lazy { + LinuxLibsecretNative.INSTANCE.secret_schema_new( + LINUX_SECRET_SCHEMA_NAME, + LINUX_SECRET_SCHEMA_DONT_MATCH_NAME, + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING, + null + ) ?: throw IllegalStateException("Linux Secret Service schema creation failed.") + } + + override fun store(key: String, label: String, password: String) { + val error = PointerByReference() + val stored = LinuxLibsecretNative.INSTANCE.secret_password_store_sync( + schema, + LINUX_SECRET_COLLECTION_DEFAULT, + label, + password, + null, + error, + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SERVICE_APPLICATION_VALUE, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + key, + null + ) + takeLibsecretError(error)?.let { message -> + throw IllegalStateException("Linux Secret Service write failed: $message") + } + if (!stored) { + throw IllegalStateException("Linux Secret Service write failed: libsecret returned false.") + } + } + + override fun lookup(key: String): String? { + val error = PointerByReference() + val passwordPointer = LinuxLibsecretNative.INSTANCE.secret_password_lookup_sync( + schema, + null, + error, + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SERVICE_APPLICATION_VALUE, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + key, + null + ) + val errorMessage = takeLibsecretError(error) + if (errorMessage != null) { + passwordPointer?.let { pointer -> LinuxLibsecretNative.INSTANCE.secret_password_free(pointer) } + throw IllegalStateException("Linux Secret Service read failed: $errorMessage") + } + return passwordPointer?.let { pointer -> + try { + pointer.getString(0, Charsets.UTF_8.name()) + } finally { + LinuxLibsecretNative.INSTANCE.secret_password_free(pointer) + } + } + } + + override fun clear(key: String) { + val error = PointerByReference() + LinuxLibsecretNative.INSTANCE.secret_password_clear_sync( + schema, + null, + error, + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SERVICE_APPLICATION_VALUE, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + key, + null + ) + takeLibsecretError(error)?.let { message -> + throw IllegalStateException("Linux Secret Service delete failed: $message") + } + } + + private fun takeLibsecretError(error: PointerByReference): String? { + val errorPointer = error.value ?: return null + return try { + LinuxGError(errorPointer).message + ?.getString(0, Charsets.UTF_8.name()) + ?.ifBlank { null } + ?: "unknown libsecret error" + } finally { + LinuxGlibNative.INSTANCE.g_error_free(errorPointer) + } + } +} + +private interface LinuxLibsecretNative : Library { + fun secret_schema_new(name: String, flags: Int, vararg attributes: Any?): Pointer? + + fun secret_password_store_sync( + schema: Pointer, + collection: String, + label: String, + password: String, + cancellable: Pointer?, + error: PointerByReference, + vararg attributes: Any? + ): Boolean + + fun secret_password_lookup_sync( + schema: Pointer, + cancellable: Pointer?, + error: PointerByReference, + vararg attributes: Any? + ): Pointer? + + fun secret_password_clear_sync( + schema: Pointer, + cancellable: Pointer?, + error: PointerByReference, + vararg attributes: Any? + ): Boolean + + fun secret_password_free(password: Pointer?) + + companion object { + val INSTANCE: LinuxLibsecretNative by lazy { + loadLinuxNativeLibrary( + LinuxLibsecretNative::class.java, + "secret-1", + "libsecret-1.so.0" + ) + } + } +} + +private interface LinuxGlibNative : Library { + fun g_error_free(error: Pointer?) + + companion object { + val INSTANCE: LinuxGlibNative by lazy { + loadLinuxNativeLibrary( + LinuxGlibNative::class.java, + "glib-2.0", + "libglib-2.0.so.0" + ) + } + } +} + +@Structure.FieldOrder("domain", "code", "message") +internal class LinuxGError(pointer: Pointer) : Structure(pointer) { + @JvmField + var domain: Int = 0 + + @JvmField + var code: Int = 0 + + @JvmField + var message: Pointer? = null + + init { + read() + } +} + +private fun loadLinuxNativeLibrary(type: Class, vararg names: String): T { + var lastError: Throwable? = null + for (name in names) { + val loaded = runCatching { Native.load(name, type) as T } + .onFailure { error -> lastError = error } + .getOrNull() + if (loaded != null) return loaded + } + throw IllegalStateException("Could not load Linux native library ${names.joinToString(" or ")}.", lastError) +} + +internal class LinuxSecretToolCodec( + private val commandRunner: DesktopSecretCommandRunner = DesktopProcessSecretCommandRunner +) : DesktopSecretCodec { + override val name: String = "linux-secret-tool" + + override val isAvailable: Boolean by lazy { + val available = commandRunner.isExecutableAvailable(SecretToolCommand) && + runCatching { + commandRunner.run(listOf(SecretToolCommand, "--help"), timeoutMillis = 3_000L).isSuccess + }.getOrDefault(false) + logDesktopTts("settings_linux_secret_tool_available available=$available") + available + } + + override fun protect(value: String): String { + return protect("secret", value) + } + + override fun unprotect(value: String): String { + return unprotect("secret", value) + } + + override fun protect(keyName: String, value: String): String { + if (!isAvailable) { + throw IllegalStateException( + "Linux Secret Service is unavailable. Install libsecret-tools and make sure a desktop keyring is running." + ) + } + val key = linuxSecretKey(keyName) + logDesktopTts("settings_linux_secret_tool_write_start key=$keyName valueChars=${value.length}") + val result = commandRunner.run( + command = listOf( + SecretToolCommand, + "store", + "--label", + "Episteme $keyName", + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SERVICE_APPLICATION_VALUE, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + key + ), + input = value, + timeoutMillis = 15_000L + ) + logDesktopTts("settings_linux_secret_tool_write_result key=$keyName exit=${result.exitCode}") + if (!result.isSuccess) { + throw IllegalStateException("Linux Secret Service write failed: ${result.errorSummary}") + } + return LINUX_SECRET_TOOL_PREFIX + key + } + + override fun unprotect(keyName: String, value: String): String { + if (!isAvailable) return "" + val key = linuxSecretReferenceKey(keyName, value) + logDesktopTts("settings_linux_secret_tool_read_start key=$keyName") + val result = commandRunner.run( + command = listOf( + SecretToolCommand, + "lookup", + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SERVICE_APPLICATION_VALUE, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + key + ), + timeoutMillis = 8_000L + ) + logDesktopTts("settings_linux_secret_tool_read_result key=$keyName exit=${result.exitCode} chars=${result.stdout.length}") + if (!result.isSuccess) { + throw IllegalStateException("Linux Secret Service read failed: ${result.errorSummary}") + } + return result.stdout.trimEnd('\r', '\n') + } + + override fun delete(keyName: String) { + val key = linuxSecretKey(keyName) + runCatching { + commandRunner.run( + command = listOf( + SecretToolCommand, + "clear", + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SERVICE_APPLICATION_VALUE, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + key + ), + timeoutMillis = 8_000L + ) + }.onFailure { error -> + logDesktopTts("settings_linux_secret_tool_delete_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") + } + } + + private companion object { + const val SecretToolCommand = "secret-tool" + } +} + +private fun linuxSecretKey(keyName: String): String { + return "Episteme.Reader.$keyName" +} + +private fun linuxSecretReferenceKey(keyName: String, reference: String): String { + return when { + reference.startsWith(LINUX_LIBSECRET_PREFIX) -> reference.removePrefix(LINUX_LIBSECRET_PREFIX) + reference.startsWith(LINUX_SECRET_TOOL_PREFIX) -> reference.removePrefix(LINUX_SECRET_TOOL_PREFIX) + else -> "" + }.ifBlank { linuxSecretKey(keyName) } +} + private object WindowsSecretCodec : DesktopSecretCodec { override val name: String = "windows" diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiHub.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt similarity index 62% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiHub.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt index 80acd9c..391979b 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiHub.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.verticalScroll @@ -14,11 +14,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.VolumeUp -import androidx.compose.material3.AssistChip import androidx.compose.material3.Button -import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -42,15 +38,10 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.ReaderAiByokSettings -import com.aryan.reader.shared.ReaderCloudTtsState -import com.aryan.reader.shared.ReaderCloudTtsVoices -import com.aryan.reader.shared.ReaderTtsCacheSummary -import com.aryan.reader.shared.RecapResult -import com.aryan.reader.shared.SummarizationResult -import com.aryan.reader.shared.readerCloudTtsVoiceById -import com.aryan.reader.shared.ui.SharedMarkdownText -import com.aryan.reader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.RecapResult +import org.dueattendant149.bookreader.shared.SummarizationResult +import org.dueattendant149.bookreader.shared.ui.SharedMarkdownText +import org.dueattendant149.bookreader.shared.ui.readerString @Composable internal fun DesktopAiHubSheet( @@ -345,169 +336,3 @@ private fun DesktopSummaryCachePanel( } } } - -@Composable -internal fun DesktopCloudTtsChromeControls( - settings: ReaderAiByokSettings, - cloudTts: ReaderCloudTtsState, - credits: Int, - showCredits: Boolean, - onRead: () -> Unit, - onPauseResume: () -> Unit, - onStop: () -> Unit, - onOpenSettings: () -> Unit -) { - val sanitized = settings.sanitized() - val voice = readerCloudTtsVoiceById(sanitized.ttsSpeakerId) - val ttsBusy = cloudTts.isLoading || cloudTts.isPlaying || cloudTts.isPaused - Surface( - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = RoundedCornerShape(8.dp), - tonalElevation = 1.dp - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Default.VolumeUp, contentDescription = null, tint = MaterialTheme.colorScheme.primary) - Column(modifier = Modifier.weight(1f)) { - Text( - when { - cloudTts.isLoading -> readerString("desktop_preparing_audio", "Preparing audio") - cloudTts.isPaused -> readerString("desktop_paused", "Paused") - cloudTts.isPlaying -> readerString("label_reading", "Reading") - sanitized.isCloudTtsAvailable -> readerString("desktop_cloud_tts_ready", "Cloud TTS ready") - else -> readerString("desktop_cloud_tts_unavailable", "Cloud TTS unavailable") - }, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) - Text( - cloudTts.errorMessage - ?: cloudTts.progress.currentPositionLabel - ?: cloudTts.statusMessage - ?: voice?.let { "${it.name}: ${it.description}" } - ?: "", - style = MaterialTheme.typography.labelSmall, - color = if (cloudTts.errorMessage != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - if (showCredits) { - AssistChip(onClick = {}, label = { Text(readerString("credits_count", "%1\$d credits", credits)) }) - } - if (cloudTts.isPlaying || cloudTts.isPaused) { - TextButton(onClick = onPauseResume) { - Text(if (cloudTts.isPaused) readerString("tooltip_tts_resume", "Resume") else readerString("tooltip_tts_pause", "Pause")) - } - } - TextButton( - enabled = sanitized.isCloudTtsAvailable || ttsBusy, - onClick = { if (ttsBusy) onStop() else onRead() } - ) { - Text(if (ttsBusy) readerString("action_stop", "Stop") else readerString("action_read", "Read")) - } - IconButton(onClick = onOpenSettings) { - Icon(Icons.Default.Settings, contentDescription = readerString("desktop_cloud_tts_settings", "Cloud TTS settings")) - } - } - } -} - -@Composable -internal fun DesktopCloudTtsSettingsOverlay( - settings: ReaderAiByokSettings, - isTtsActive: Boolean, - showCredits: Boolean, - credits: Int, - cacheSummary: ReaderTtsCacheSummary = ReaderTtsCacheSummary(), - onClearCache: (() -> Unit)? = null, - onSettingsChange: (ReaderAiByokSettings) -> Unit -) { - val sanitized = settings.sanitized() - Surface( - color = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - shape = RoundedCornerShape(8.dp), - tonalElevation = 4.dp, - shadowElevation = 8.dp - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - Text( - if (isTtsActive) { - readerString("desktop_stop_reading_change_voices", "Stop reading to change voices.") - } else { - readerString("desktop_choose_cloud_tts_voice", "Choose the Gemini voice used for cloud read aloud.") - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (showCredits) { - Surface( - color = MaterialTheme.colorScheme.tertiaryContainer, - shape = RoundedCornerShape(10.dp) - ) { - Text( - readerString("credits_count", "%1\$d credits", credits), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onTertiaryContainer - ) - } - } - } - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - ReaderCloudTtsVoices.forEach { voice -> - FilterChip( - selected = sanitized.ttsSpeakerId == voice.id, - enabled = !isTtsActive, - onClick = { onSettingsChange(sanitized.copy(ttsSpeakerId = voice.id)) }, - label = { - Column { - Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - voice.description, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - ) - } - } - if (cacheSummary.hasCachedAudio) { - HorizontalDivider() - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(readerString("desktop_voice_cache", "Voice cache"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - Text( - cacheSummary.currentVoiceLabel, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (cacheSummary.hasCurrentVoiceCachedAudio && onClearCache != null) { - TextButton(enabled = !isTtsActive, onClick = onClearCache) { - Text(readerString("desktop_clear_voice_cache", "Clear voice cache")) - } - } - } - } - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppHost.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt similarity index 68% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppHost.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt index e154fe0..ac63f0b 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppHost.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -36,11 +36,13 @@ import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState -import com.aryan.reader.shared.AppContrastOption -import com.aryan.reader.shared.AppThemeMode -import com.aryan.reader.shared.ReaderFeatureSurface -import com.aryan.reader.shared.ui.SharedAppTheme -import com.aryan.reader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.AppContrastOption +import org.dueattendant149.bookreader.shared.AppThemeMode +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoadSemanticMode +import org.dueattendant149.bookreader.shared.ui.SharedAppTheme +import org.dueattendant149.bookreader.shared.ui.readerString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -58,6 +60,8 @@ import java.util.concurrent.atomic.AtomicReference internal val DesktopDefaultAppSeedColor = Color(0xFFFFB300) +private val DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis = longArrayOf(160L, 200L) + internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) { configureComposeSwingInterop() application { @@ -190,35 +194,69 @@ internal const val ComposeInteropBlendingProperty = "compose.interop.blending" internal const val ComposeInteropBlendingEnabled = "true" private const val DesktopWindowStatePersistDebounceMillis = 450L -internal fun configureComposeSwingInterop() { - // Must run before Compose creates the desktop window. Vertical EPUB embeds a Swing-backed - // JCEF WebView, and current Compose interop can leave a stale black native rectangle after - // that reader surface is removed unless interop blending is enabled. - if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) { - System.setProperty(ComposeInteropBlendingProperty, ComposeInteropBlendingEnabled) +internal fun composeInteropBlendingDefault( + platform: DesktopPlatform = currentDesktopPlatform() +): String? { + return if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) { + null + } else { + ComposeInteropBlendingEnabled } } +internal fun configureComposeSwingInterop( + platform: DesktopPlatform = currentDesktopPlatform() +) { + // Must run before Compose creates the desktop window. Vertical EPUB embeds native SWT/AWT + // browser surfaces; the blending path can prevent those native children from painting. + if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) { + composeInteropBlendingDefault(platform)?.let { defaultValue -> + System.setProperty(ComposeInteropBlendingProperty, defaultValue) + } + } + logDesktopWebView2( + "compose_interop platform=${platform.os} blending=${System.getProperty(ComposeInteropBlendingProperty).orEmpty().ifBlank { "default" }}" + ) +} + @Composable -private fun DesktopWindowStatePersistenceEffect( +internal fun DesktopWindowStatePersistenceEffect( windowState: WindowState, store: DesktopWindowStateStore, - enabled: Boolean + enabled: Boolean, + transformSnapshot: (DesktopWindowStateSnapshot) -> DesktopWindowStateSnapshot? = { it }, + onSnapshotSaved: (DesktopWindowStateSnapshot) -> Unit = {} ) { val persistenceEnabled by rememberUpdatedState(enabled) + val latestTransformSnapshot by rememberUpdatedState(transformSnapshot) + val latestOnSnapshotSaved by rememberUpdatedState(onSnapshotSaved) LaunchedEffect(windowState, store) { snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) } .distinctUntilChanged() .collectLatest { snapshot -> if (!persistenceEnabled || snapshot == null) return@collectLatest + val persistableSnapshot = latestTransformSnapshot(snapshot) ?: return@collectLatest delay(DesktopWindowStatePersistDebounceMillis) if (persistenceEnabled) { withContext(Dispatchers.IO) { - store.save(snapshot) + store.save(persistableSnapshot) } + latestOnSnapshotSaved(persistableSnapshot) } } } + DisposableEffect(windowState, store) { + onDispose { + if (persistenceEnabled) { + DesktopWindowStateSnapshot.fromWindowState(windowState) + ?.let(latestTransformSnapshot) + ?.let { snapshot -> + runCatching { store.save(snapshot) } + latestOnSnapshotSaved(snapshot) + } + } + } + } } @Composable @@ -259,6 +297,14 @@ internal fun DesktopReaderFullscreenEffect( } awtWindow.refreshDesktopReaderWindowFocus() } + if (!enabled) { + for (delayMillis in DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis) { + delay(delayMillis) + EventQueue.invokeLater { + awtWindow.refreshDesktopReaderWindowFocus() + } + } + } } DisposableEffect(awtWindow) { @@ -444,16 +490,43 @@ private fun java.awt.Window.refreshDesktopReaderWindowFocus() { internal fun DesktopReaderFullscreenKeyEffect( enabled: Boolean, onKeyPressed: (AwtKeyEvent) -> Boolean +) { + DesktopReaderKeyDispatcherEffect( + enabled = enabled, + allowChromeModalWindows = false, + onKeyPressed = onKeyPressed + ) +} + +@Composable +internal fun DesktopReaderKeyDispatcherEffect( + enabled: Boolean, + allowChromeModalWindows: Boolean = false, + allowPanelModalWindows: Boolean = false, + dispatchWhenOwnerWindowActive: Boolean = true, + onKeyPressed: (AwtKeyEvent) -> Boolean ) { val currentOnKeyPressed by rememberUpdatedState(onKeyPressed) - DisposableEffect(enabled) { + DisposableEffect( + enabled, + allowChromeModalWindows, + allowPanelModalWindows, + dispatchWhenOwnerWindowActive + ) { if (!enabled) { onDispose {} } else { val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager() val dispatcher = java.awt.KeyEventDispatcher { event -> - val modalWindowActive = focusManager.activeWindow?.isDesktopReaderModalWindow() == true - !modalWindowActive && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event) + val keyWindow = focusManager.focusedWindow ?: focusManager.activeWindow + val activeReaderModalKind = keyWindow?.desktopReaderModalWindowKind() + val activeWindowAllowed = desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = activeReaderModalKind, + allowChromeModalWindows = allowChromeModalWindows, + allowPanelModalWindows = allowPanelModalWindows, + dispatchWhenOwnerWindowActive = dispatchWhenOwnerWindowActive + ) + activeWindowAllowed && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event) } focusManager.addKeyEventDispatcher(dispatcher) onDispose { @@ -463,18 +536,62 @@ internal fun DesktopReaderFullscreenKeyEffect( } } -private fun java.awt.Window.isDesktopReaderModalWindow(): Boolean { +internal enum class DesktopReaderModalWindowKind { + CHROME, + PANEL, + POPUP +} + +internal fun desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind: DesktopReaderModalWindowKind?, + allowChromeModalWindows: Boolean, + allowPanelModalWindows: Boolean, + dispatchWhenOwnerWindowActive: Boolean +): Boolean { + return when (activeReaderModalKind) { + null -> dispatchWhenOwnerWindowActive + DesktopReaderModalWindowKind.CHROME -> allowChromeModalWindows + DesktopReaderModalWindowKind.PANEL -> allowPanelModalWindows + DesktopReaderModalWindowKind.POPUP -> false + } +} + +private fun java.awt.Window.desktopReaderModalWindowKind(): DesktopReaderModalWindowKind? { val windowTitle = when (this) { is java.awt.Dialog -> title is Frame -> title else -> "" } - return name?.startsWith(DesktopReaderModalWindowNamePrefix) == true || - windowTitle.startsWith("Reader Panel") || - windowTitle.startsWith("Reader Popup") + return desktopReaderModalWindowKind( + windowName = name.orEmpty(), + windowTitle = windowTitle + ) } -private const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:" +internal fun desktopReaderModalWindowKind( + windowName: String, + windowTitle: String +): DesktopReaderModalWindowKind? { + return when { + windowName == "${DesktopReaderModalWindowNamePrefix}ChromeTop" || + windowName == "${DesktopReaderModalWindowNamePrefix}ChromeBottom" || + windowTitle.startsWith("Reader Chrome") -> DesktopReaderModalWindowKind.CHROME + + windowName == "${DesktopReaderModalWindowNamePrefix}Panel" || + windowName == "${DesktopReaderModalWindowNamePrefix}PanelLeft" || + windowName == "${DesktopReaderModalWindowNamePrefix}PanelRight" || + windowTitle.startsWith("Reader Panel") || + windowTitle.startsWith("Reader Navigation") || + windowTitle.startsWith("Reader Tools") -> DesktopReaderModalWindowKind.PANEL + + windowName.startsWith(DesktopReaderModalWindowNamePrefix) || + windowTitle.startsWith("Reader Popup") -> DesktopReaderModalWindowKind.POPUP + + else -> null + } +} + +internal const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:" internal data class DesktopWebViewRuntimeState( val initialized: Boolean = false, @@ -483,15 +600,63 @@ internal data class DesktopWebViewRuntimeState( val errorMessage: String? = null ) -internal fun shouldRequestDesktopWebViewRuntime(readerSurface: ReaderFeatureSurface?): Boolean { - return readerSurface == ReaderFeatureSurface.TEXT_READER +internal enum class DesktopEpubWebViewBackend( + val logName: String, + val displayName: String +) { + WINDOWS_WEBVIEW2("webview2", "Microsoft Edge WebView2"), + WEBKIT("webkit", "WebKit"), + UNSUPPORTED("unsupported", "native webview") } -internal fun shouldStartDesktopWebViewRuntime( - requested: Boolean, - state: DesktopWebViewRuntimeState +internal fun desktopEpubWebViewBackend( + platform: DesktopPlatform = currentDesktopPlatform() +): DesktopEpubWebViewBackend { + return when (platform.os) { + DesktopOperatingSystem.WINDOWS -> DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 + DesktopOperatingSystem.LINUX, + DesktopOperatingSystem.MACOS -> DesktopEpubWebViewBackend.WEBKIT + DesktopOperatingSystem.OTHER -> DesktopEpubWebViewBackend.UNSUPPORTED + } +} + +internal fun desktopEpubWebViewUsesNativeSwtBrowser( + platform: DesktopPlatform = currentDesktopPlatform() ): Boolean { - return requested && !state.initialized && !state.restartRequired && state.errorMessage == null + return desktopEpubWebViewBackend(platform) != DesktopEpubWebViewBackend.UNSUPPORTED +} + +internal fun desktopEpubWebViewUsesWebView2( + platform: DesktopPlatform = currentDesktopPlatform() +): Boolean { + return desktopEpubWebViewBackend(platform) == DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 +} + +internal fun desktopShouldUseNativeVerticalEpubReader( + platform: DesktopPlatform = currentDesktopPlatform() +): Boolean { + return platform.os == DesktopOperatingSystem.LINUX +} + +internal fun desktopEpubBookLoadSemanticMode( + settings: ReaderSettings, + platform: DesktopPlatform = currentDesktopPlatform() +): SharedJvmBookLoadSemanticMode { + return if ( + settings.readingMode == ReaderReadingMode.VERTICAL && + !desktopShouldUseNativeVerticalEpubReader(platform) + ) { + SharedJvmBookLoadSemanticMode.SKIP + } else { + SharedJvmBookLoadSemanticMode.FULL + } +} + +internal fun desktopEpubWebViewCanRender( + state: DesktopWebViewRuntimeState, + platform: DesktopPlatform = currentDesktopPlatform() +): Boolean { + return desktopEpubWebViewUsesNativeSwtBrowser(platform) } @Composable @@ -499,7 +664,10 @@ internal fun DesktopWebViewRuntimeIndicator( state: DesktopWebViewRuntimeState, modifier: Modifier = Modifier ) { + val platform = currentDesktopPlatform() val message = when { + !desktopEpubWebViewUsesNativeSwtBrowser(platform) -> + readerString("desktop_webview_unsupported", "Embedded webview is unavailable on this desktop platform.") state.errorMessage != null -> readerString("desktop_webview_start_error", "Embedded webview could not start: %1\$s", state.errorMessage) state.restartRequired -> readerString("desktop_webview_restart_required", "Embedded webview installed. Restart Episteme to finish setup.") state.downloadProgress >= 0f -> readerString("desktop_webview_preparing_progress", "Preparing bundled embedded webview %1\$d%%", state.downloadProgress.toInt()) @@ -515,7 +683,9 @@ internal fun DesktopWebViewRuntimeIndicator( verticalArrangement = Arrangement.spacedBy(12.dp) ) { if (state.errorMessage == null && !state.restartRequired) { - CircularProgressIndicator() + if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) { + CircularProgressIndicator() + } } Text( text = message, diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppState.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt similarity index 64% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppState.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt index f8f8765..686e4ae 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppState.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt @@ -1,14 +1,18 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.BookShelfRef -import com.aryan.reader.shared.CustomFontItem -import com.aryan.reader.shared.SharedLibraryProjectionInput -import com.aryan.reader.shared.SharedLibrarySnapshot -import com.aryan.reader.shared.SharedLibraryStateProjector -import com.aryan.reader.shared.SharedReaderScreenState -import com.aryan.reader.shared.ShelfRecord -import com.aryan.reader.shared.reader.SharedEpubBook -import com.aryan.reader.shared.reader.SharedEpubChapter +import org.dueattendant149.bookreader.shared.BookShelfRef +import org.dueattendant149.bookreader.shared.CustomFontItem +import org.dueattendant149.bookreader.shared.SharedLibraryProjectionInput +import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot +import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.ShelfRecord +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.SharedEpubBook +import org.dueattendant149.bookreader.shared.reader.SharedEpubChapter +import org.dueattendant149.bookreader.shared.ui.SharedAppTab + +internal val DesktopInitialAppTab = SharedAppTab.LIBRARY internal fun desktopEmptyReaderBook(): SharedEpubBook { val noBookOpen = loadDesktopStringResolver().string("desktop_no_book_open", "No book open") @@ -27,11 +31,37 @@ internal fun desktopEmptyReaderBook(): SharedEpubBook { } internal fun SharedLibrarySnapshot.withDesktopDefaults(): SharedLibrarySnapshot { - return if (appSeedColor == null) { - copy(appSeedColor = DesktopDefaultAppSeedColor) + val shouldMigrateReaderDefaults = desktopReaderDefaultsVersion < DesktopReaderDefaultsVersion + val migratedTextDefaults = if (shouldMigrateReaderDefaults && readerDefaultSettings == ReaderSettings()) { + DesktopDefaultTextReaderSettings } else { - this + readerDefaultSettings } + val migratedPdfDefaults = if (shouldMigrateReaderDefaults && pdfReaderDefaultSettings == ReaderSettings(themeId = "no_theme")) { + DesktopDefaultPdfReaderSettings + } else { + pdfReaderDefaultSettings + } + val migratedBooks = if (shouldMigrateReaderDefaults) { + books.map { book -> + when { + book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.TEXT) && + book.readerSettings == ReaderSettings() -> book.copy(readerSettings = migratedTextDefaults) + book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.PDF) && + book.readerSettings == ReaderSettings(themeId = "no_theme") -> book.copy(readerSettings = migratedPdfDefaults) + else -> book + } + } + } else { + books + } + return copy( + books = migratedBooks, + appSeedColor = appSeedColor ?: DesktopDefaultAppSeedColor, + readerDefaultSettings = migratedTextDefaults, + pdfReaderDefaultSettings = migratedPdfDefaults, + desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion + ) } internal fun SharedLibrarySnapshot.toDesktopReaderScreenState(): SharedReaderScreenState { @@ -54,6 +84,7 @@ internal fun SharedLibrarySnapshot.toDesktopReaderScreenState(): SharedReaderScr appSeedColor = appSeedColor, appFontPreference = appFontPreference, customAppThemes = customAppThemes, + customReaderThemes = customReaderThemes, readerDefaultSettings = readerDefaultSettings, pdfReaderDefaultSettings = pdfReaderDefaultSettings, readerToolbarPreferences = readerToolbarPreferences, @@ -116,8 +147,10 @@ internal fun SharedReaderScreenState.toDesktopLibrarySnapshot( appSeedColor = appSeedColor, appFontPreference = appFontPreference, customAppThemes = customAppThemes, + customReaderThemes = customReaderThemes, readerDefaultSettings = readerDefaultSettings, pdfReaderDefaultSettings = pdfReaderDefaultSettings, + desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion, readerToolbarPreferences = readerToolbarPreferences, readerHighlightPalette = readerHighlightPalette, pdfHighlighterPalette = pdfHighlighterPalette, diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt new file mode 100644 index 0000000..c58f3df --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt @@ -0,0 +1,56 @@ +package org.dueattendant149.bookreader.desktop + +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.Properties + +internal fun File.writeTextAtomically(text: String) { + parentFile?.mkdirs() + val temp = createSiblingTempFile() + try { + temp.writeText(text) + moveReplacing(temp, this) + } finally { + runCatching { if (temp.exists()) temp.delete() } + } +} + +internal fun File.storePropertiesAtomically(properties: Properties, comments: String) { + parentFile?.mkdirs() + val temp = createSiblingTempFile() + try { + temp.outputStream().use { output -> + properties.store(output, comments) + } + moveReplacing(temp, this) + } finally { + runCatching { if (temp.exists()) temp.delete() } + } +} + +private fun File.createSiblingTempFile(): File { + val directory = parentFile ?: File(".") + directory.mkdirs() + val prefix = ".$name." + return Files.createTempFile(directory.toPath(), prefix, ".tmp").toFile() +} + +private fun moveReplacing(source: File, target: File) { + target.parentFile?.mkdirs() + try { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBookImporter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt similarity index 93% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBookImporter.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt index fada6ce..55268cf 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBookImporter.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt @@ -1,8 +1,8 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.ImportedBookFile -import com.aryan.reader.shared.ReaderPlatform -import com.aryan.reader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.ImportedBookFile +import org.dueattendant149.bookreader.shared.ReaderPlatform +import org.dueattendant149.bookreader.shared.SharedFileCapabilities import java.io.File import java.nio.file.Files import java.nio.file.StandardCopyOption diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBuildProfile.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt similarity index 50% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBuildProfile.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt index c0e5d4e..4055007 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBuildProfile.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt @@ -1,8 +1,10 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.ReaderAiByokSettings -import com.aryan.reader.shared.SharedFeaturePolicy -import java.io.File +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.SharedFeaturePolicy +import org.dueattendant149.bookreader.shared.SharedLegalLinks +import org.dueattendant149.bookreader.shared.SharedLegalProfile +import org.dueattendant149.bookreader.shared.sharedLegalLinksForProfile internal const val DesktopFlavorProperty = "episteme.desktop.flavor" internal const val DesktopVersionProperty = "episteme.desktop.version" @@ -16,10 +18,21 @@ internal data class DesktopBuildProfile( val flavor: String, val appName: String, val buildLabel: String, - val featurePolicy: SharedFeaturePolicy + val featurePolicy: SharedFeaturePolicy, + val legalProfile: SharedLegalProfile = if (featurePolicy.byokAi) { + SharedLegalProfile.OSS + } else { + SharedLegalProfile.STANDARD + } ) { val isOssOffline: Boolean get() = flavor == DesktopFlavorOssOffline + val aiKeySettingsAvailable: Boolean + get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && legalProfile != SharedLegalProfile.OSS val byokAiAvailable: Boolean get() = featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess + val creditBackedCloudTtsControlsAvailable: Boolean + get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && !byokAiAvailable + val legalLinks: SharedLegalLinks + get() = sharedLegalLinksForProfile(legalProfile) } internal fun currentDesktopBuildProfile(): DesktopBuildProfile { @@ -35,13 +48,15 @@ internal fun desktopBuildProfileForFlavor(rawFlavor: String?): DesktopBuildProfi flavor = DesktopFlavorOssOffline, appName = EpistemeDesktopOssAppName, buildLabel = "Offline OSS edition", - featurePolicy = SharedFeaturePolicy.OssOffline + featurePolicy = SharedFeaturePolicy.OssOffline, + legalProfile = SharedLegalProfile.OSS ) else -> DesktopBuildProfile( flavor = DesktopFlavorStandard, appName = EpistemeDesktopStandardAppName, buildLabel = "Standard edition", - featurePolicy = SharedFeaturePolicy.Standard + featurePolicy = SharedFeaturePolicy.Standard, + legalProfile = SharedLegalProfile.STANDARD ) } } @@ -59,48 +74,12 @@ internal fun ReaderAiByokSettings.withDesktopFeaturePolicy( featurePolicy: SharedFeaturePolicy ): ReaderAiByokSettings { return if (featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess) { - sanitized() + toDesktopPersistableAiSettings() } else { - ReaderAiByokSettings(hideReaderAiFeatures = true) + ReaderAiByokSettings() } } -internal fun bundledDesktopWebViewDir(): File { - val platform = currentDesktopPlatform() - val resourceDir = System.getProperty(ComposeApplicationResourcesDirProperty) - ?.takeIf { it.isNotBlank() } - ?.let(::File) - return listOfNotNull( - resourceDir?.resolve("kcef-bundle"), - File(System.getProperty("user.dir"), "kcef-bundle"), - File(System.getProperty("user.dir"), "desktopApp/${platform.kcefBundleDirectoryName}"), - File(System.getProperty("user.dir"), "desktopApp/kcef-bundle"), - File("desktopApp/${platform.kcefBundleDirectoryName}"), - File("desktopApp/kcef-bundle"), - File(platform.kcefBundleDirectoryName), - File("kcef-bundle") - ).firstOrNull(::isBundledDesktopWebViewPresent) - ?: resourceDir?.resolve("kcef-bundle") - ?: File(platform.kcefBundleDirectoryName) -} - -internal fun isBundledDesktopWebViewPresent( - dir: File, - platform: DesktopPlatform = currentDesktopPlatform() -): Boolean { - return dir.isDirectory && - bundledDesktopWebViewRequiredPaths(platform).all { requiredPath -> - dir.resolve(requiredPath).exists() - } -} - -internal fun bundledDesktopWebViewRequiredPaths( - platform: DesktopPlatform = currentDesktopPlatform() -): List { - return when (platform.os) { - DesktopOperatingSystem.WINDOWS -> listOf("jcef.dll", "libcef.dll") - DesktopOperatingSystem.LINUX -> listOf("libcef.so", "chrome-sandbox", "icudtl.dat", "locales") - DesktopOperatingSystem.MACOS -> listOf("jcef Helper.app", "Chromium Embedded Framework.framework") - DesktopOperatingSystem.OTHER -> emptyList() - } +internal fun ReaderAiByokSettings.toDesktopPersistableAiSettings(): ReaderAiByokSettings { + return sanitized().copy(hideReaderAiFeatures = false) } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopByokAiAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt similarity index 95% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopByokAiAdapter.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt index 2e002c4..e51f5dc 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopByokAiAdapter.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt @@ -1,14 +1,14 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.AiAdapter -import com.aryan.reader.shared.AiDefinitionResult -import com.aryan.reader.shared.ReaderAiByokSettings -import com.aryan.reader.shared.ReaderAiFeature -import com.aryan.reader.shared.ReaderByokTextRequest -import com.aryan.reader.shared.ReaderByokTextRequestResult -import com.aryan.reader.shared.ReaderByokTextRequests -import com.aryan.reader.shared.RecapResult -import com.aryan.reader.shared.SummarizationResult +import org.dueattendant149.bookreader.shared.AiAdapter +import org.dueattendant149.bookreader.shared.AiDefinitionResult +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.ReaderAiFeature +import org.dueattendant149.bookreader.shared.ReaderByokTextRequest +import org.dueattendant149.bookreader.shared.ReaderByokTextRequestResult +import org.dueattendant149.bookreader.shared.ReaderByokTextRequests +import org.dueattendant149.bookreader.shared.RecapResult +import org.dueattendant149.bookreader.shared.SummarizationResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudConfig.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt similarity index 70% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudConfig.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt index 75a9a6a..2ca06c6 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudConfig.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import java.io.File import java.util.Properties @@ -21,7 +21,14 @@ internal data class DesktopCloudConfig( } internal fun loadDesktopCloudConfig(): DesktopCloudConfig { - val resourceProperties = Properties().apply { + return desktopCloudConfigFromProperties( + resourceProperties = loadDesktopCloudResourceProperties(), + localProperties = loadDesktopLocalProperties() + ) +} + +private fun loadDesktopCloudResourceProperties(): Properties { + return Properties().apply { val classLoader = DesktopCloudConfig::class.java.classLoader val stream = classLoader.getResourceAsStream("desktop-cloud.properties") ?: classLoader.getResourceAsStream("common/desktop-cloud.properties") @@ -35,18 +42,27 @@ internal fun loadDesktopCloudConfig(): DesktopCloudConfig { } stream?.use { input -> load(input) } } - val localProperties = Properties().apply { - File("local.properties") - .takeIf { it.isFile } +} + +private fun loadDesktopLocalProperties(file: File = File("local.properties")): Properties { + return Properties().apply { + file.takeIf { it.isFile } ?.inputStream() ?.use { input -> load(input) } } +} +internal fun desktopCloudConfigFromProperties( + resourceProperties: Properties, + localProperties: Properties = Properties(), + systemProperty: (String) -> String? = { key -> System.getProperty("episteme.desktop.$key") }, + environment: (String) -> String? = { key -> System.getenv(key) } +): DesktopCloudConfig { fun value(vararg keys: String): String { return keys.firstNotNullOfOrNull { key -> - System.getProperty("episteme.desktop.$key") - ?: System.getenv("EPISTEME_DESKTOP_${key.uppercase()}") - ?: System.getenv(key) + systemProperty(key) + ?: environment("EPISTEME_DESKTOP_${key.uppercase()}") + ?: environment(key) ?: localProperties.getProperty("DESKTOP_$key") ?: localProperties.getProperty(key) ?: resourceProperties.getProperty(key) @@ -56,7 +72,7 @@ internal fun loadDesktopCloudConfig(): DesktopCloudConfig { val aiWorkerUrl = value("AI_WORKER_URL").ifBlank { "https://reader-ai.aryanrajttps.workers.dev" } - val ttsWorkerUrl = value("TTS_WORKER_URL").ifBlank { aiWorkerUrl } + val ttsWorkerUrl = value("TTS_WORKER_URL") return DesktopCloudConfig( aiWorkerUrl = aiWorkerUrl, diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudRepositories.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt similarity index 93% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudRepositories.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt index bba804e..5b3ea79 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudRepositories.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.SharedFileCapabilities import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString @@ -32,6 +32,7 @@ import java.net.http.HttpResponse import java.nio.file.Files import java.nio.file.StandardCopyOption import java.time.Duration +import java.time.Instant import java.util.Collections import java.util.UUID @@ -50,6 +51,8 @@ internal data class DesktopCloudBookMetadata( val isRecent: Boolean = true, val isDeleted: Boolean = false, val lastModifiedTimestamp: Long = 0L, + val readingPositionModifiedTimestamp: Long = 0L, + val annotationModifiedTimestamp: Long = 0L, val bookmarksJson: String? = null, val hasAnnotations: Boolean = false, val fileContentModifiedTimestamp: Long = 0L, @@ -83,7 +86,8 @@ internal data class DesktopCloudFontMetadata( internal data class DesktopDriveFile( val id: String, - val name: String + val name: String, + val modifiedTimeMillis: Long = 0L ) internal class DesktopFirestoreRepository( @@ -266,6 +270,10 @@ internal class DesktopGoogleDriveRepository( listFiles(accessToken = accessToken, query = null) } + suspend fun getFileByName(accessToken: String, fileName: String): DesktopDriveFile? = withContext(Dispatchers.IO) { + listFiles(accessToken, "name = '${driveQueryStringValue(fileName)}' and trashed = false").firstOrNull() + } + suspend fun uploadFont(accessToken: String, fileName: String, file: File, extension: String): DesktopDriveFile? = uploadNamedFile( accessToken = accessToken, @@ -293,18 +301,20 @@ internal class DesktopGoogleDriveRepository( suspend fun uploadAnnotationFile(accessToken: String, bookId: String, file: File): DesktopDriveFile? { return uploadNamedFile( accessToken = accessToken, - fileName = "annotation_$bookId.json", + fileName = desktopCloudAnnotationDriveFileName(bookId), file = file, contentType = "application/json" ) } suspend fun downloadAnnotationFile(accessToken: String, bookId: String, destination: File): Boolean { - val fileId = listFiles(accessToken, "name = '${driveQueryStringValue("annotation_$bookId.json")}' and trashed = false") - .firstOrNull() - ?.id + val driveFile = getFileByName(accessToken, desktopCloudAnnotationDriveFileName(bookId)) ?: return false - return downloadFile(accessToken, fileId, destination) + return downloadFile(accessToken, driveFile.id, destination).also { downloaded -> + if (downloaded && driveFile.modifiedTimeMillis > 0L) { + destination.setLastModified(driveFile.modifiedTimeMillis) + } + } } suspend fun downloadFile(accessToken: String, fileId: String, destination: File): Boolean = withContext(Dispatchers.IO) { @@ -375,9 +385,9 @@ internal class DesktopGoogleDriveRepository( }.toByteArray(Charsets.UTF_8) val suffix = "\r\n--$boundary--\r\n".toByteArray(Charsets.UTF_8) val uploadUri = if (existingFileId == null) { - URI.create("https://www.googleapis.com/upload/drive/v3/files?${query("uploadType" to "multipart", "fields" to "id,name")}") + URI.create("https://www.googleapis.com/upload/drive/v3/files?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}") } else { - URI.create("https://www.googleapis.com/upload/drive/v3/files/${pathEncode(existingFileId)}?${query("uploadType" to "multipart", "fields" to "id,name")}") + URI.create("https://www.googleapis.com/upload/drive/v3/files/${pathEncode(existingFileId)}?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}") } val request = HttpRequest.newBuilder(uploadUri) .timeout(Duration.ofMinutes(5)) @@ -399,14 +409,15 @@ internal class DesktopGoogleDriveRepository( val root = DesktopCloudJson.parseToJsonElement(response.body()).jsonObject DesktopDriveFile( id = root.string("id").orEmpty(), - name = root.string("name").orEmpty() + name = root.string("name").orEmpty(), + modifiedTimeMillis = parseDriveModifiedTimeMillis(root.string("modifiedTime")) ) } private fun listFiles(accessToken: String, query: String?): List { val params = buildList { add("spaces" to "appDataFolder") - add("fields" to "files(id,name)") + add("fields" to "files(id,name,modifiedTime)") if (!query.isNullOrBlank()) add("q" to query) } val request = HttpRequest.newBuilder( @@ -426,11 +437,20 @@ internal class DesktopGoogleDriveRepository( val obj = element.jsonObjectOrNull() ?: return@mapNotNull null val id = obj.string("id") ?: return@mapNotNull null val name = obj.string("name") ?: return@mapNotNull null - DesktopDriveFile(id = id, name = name) + DesktopDriveFile( + id = id, + name = name, + modifiedTimeMillis = parseDriveModifiedTimeMillis(obj.string("modifiedTime")) + ) } } } +private fun parseDriveModifiedTimeMillis(value: String?): Long { + if (value.isNullOrBlank()) return 0L + return runCatching { Instant.parse(value).toEpochMilli() }.getOrDefault(0L) +} + private data class DesktopFirestoreDocument( val id: String, val fields: JsonObject? @@ -464,6 +484,8 @@ private fun DesktopCloudBookMetadata.toFirestoreFields(): Map) { + val path = book.path?.takeIf { it.isNotBlank() } ?: return + if (book.type != FileType.PDF) return + recordAnnotationDeletions(path, book.id, annotationIds) + } + + fun recordAnnotationDeletions(documentPath: String, logBookId: String, annotationIds: Collection) { + val ids = annotationIds.mapNotNull { it.takeIf(String::isNotBlank) }.toSet() + if (ids.isEmpty()) return + val file = desktopPdfAnnotationDeletionFile(documentPath) + val existing = if (file.isFile) { + SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText()) + } else { + emptyMap() + } + val now = System.currentTimeMillis() + val next = existing.toMutableMap() + ids.forEach { id -> next[id] = maxOf(next[id] ?: 0L, now) } + val nextJson = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(next) + if (file.isFile && file.readText() == nextJson) return + file.parentFile?.mkdirs() + file.writeText(nextJson) + logDesktopCloudAnnotations { + "desktop.local.mark_deleted_annotations book=$logBookId ids=${ids.sorted()} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + } + + fun exportAnnotationBundle(book: BookItem): File? { + val path = book.path?.takeIf { it.isNotBlank() } ?: return null + if (book.type != FileType.PDF) return null + val annotationFile = desktopPdfAnnotationFile(path) + val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path) + val richTextFile = desktopPdfRichTextFile(path) + logDesktopCloudAnnotations { + "desktop.export.inspect book=${book.id} ${localAnnotationDebugSummary(book)}" + } + val data = buildMap { + if (annotationFile.isFile) { + desktopPdfAnnotationElementForSync(annotationFile.readText())?.let { annotations -> + put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations) + } + } + if (deletedAnnotationFile.hasSyncablePdfAnnotationDeletions()) { + val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(deletedAnnotationFile.readText()) + put( + SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS, + SharedPdfAnnotationSidecarCodec.encodeAnnotationDeletionsElement(deletions) + ) + } + if (richTextFile.isFile) { + desktopPdfRichTextElementForSync(richTextFile.readText())?.let { put("text", it) } + } + } + if (data.isEmpty()) { + logDesktopCloudAnnotations { "desktop.export.skip book=${book.id} reason=no_syncable_payload" } + return null + } + val payload = JsonObject(mapOf("version" to JsonPrimitive(2)) + data) + val canonical = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson( + cloudSidecarJson.encodeToString(JsonElement.serializer(), payload) + ) + val tempFile = File( + desktopUserCacheRoot(), + "sync_bundle_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json" + ) + tempFile.parentFile?.mkdirs() + tempFile.writeText(canonical) + logDesktopCloudAnnotations { + "desktop.export.bundle_ready book=${book.id} keys=${data.keys.toList()} " + + "canonicalBytes=${canonical.length} fileBytes=${tempFile.length()} temp=${tempFile.name}" + } + return tempFile + } + + fun importAnnotationBundle(book: BookItem, rawJson: String, timestamp: Long): Boolean { + val path = book.path?.takeIf { it.isNotBlank() } ?: run { + logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=missing_path bytes=${rawJson.length}" } + return false + } + if (book.type != FileType.PDF) { + logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=not_pdf type=${book.type} bytes=${rawJson.length}" } + return false + } + val root = cloudSidecarJson.parseElementOrNull(rawJson)?.jsonObjectOrNull() ?: run { + logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=parse_failed bytes=${rawJson.length}" } + return false + } + val data = root["data"]?.jsonObjectOrNull() ?: root + val canonicalData = SharedPdfAnnotationSidecarCodec.withCanonicalAnnotations(data) + val annotationFile = desktopPdfAnnotationFile(path) + val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path) + val bookmarkFile = desktopPdfBookmarkFile(path) + val richTextFile = desktopPdfRichTextFile(path) + logDesktopCloudAnnotations { + "desktop.import.inspect book=${book.id} remoteTs=$timestamp rawBytes=${rawJson.length} " + + "rawKeys=${data.keys.toList()} canonicalKeys=${canonicalData.keys.toList()} " + + localAnnotationDebugSummary(book) + } + + if (canonicalData.hasPdfAnnotationPayload()) { + val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(canonicalData) + if (annotations.isEmpty()) { + if (annotationFile.isFile) { + val deleted = annotationFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_annotations book=${book.id} deleted=$deleted" } + } else { + logDesktopCloudAnnotations { "desktop.import.annotations_empty book=${book.id} existing=false" } + } + } else { + annotationFile.parentFile?.mkdirs() + annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) + annotationFile.setLastModified(timestamp) + logDesktopCloudAnnotations { + "desktop.import.write_annotations book=${book.id} count=${annotations.size} " + + "bytes=${annotationFile.length()} ts=${annotationFile.lastModified()}" + } + } + } else if (annotationFile.isFile) { + val deleted = annotationFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_annotations_missing_payload book=${book.id} deleted=$deleted" } + } else { + logDesktopCloudAnnotations { "desktop.import.no_annotation_payload book=${book.id} existing=false" } + } + + val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromData(canonicalData) + if (deletions.isEmpty()) { + if (deletedAnnotationFile.isFile) { + val deleted = deletedAnnotationFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_annotation_tombstones book=${book.id} deleted=$deleted" } + } + } else { + deletedAnnotationFile.parentFile?.mkdirs() + deletedAnnotationFile.writeText(SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(deletions)) + deletedAnnotationFile.setLastModified(timestamp) + logDesktopCloudAnnotations { + "desktop.import.write_annotation_tombstones book=${book.id} count=${deletions.size} " + + "bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModified()}" + } + } + + canonicalData["bookmarks"]?.let { bookmarks -> + bookmarkFile.parentFile?.mkdirs() + bookmarkFile.writeText(cloudSidecarJson.encodeToString(JsonElement.serializer(), bookmarks)) + bookmarkFile.setLastModified(timestamp) + logDesktopCloudAnnotations { + "desktop.import.write_bookmarks book=${book.id} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModified()}" + } + } + + canonicalData["text"]?.let { richText -> + val richDocument = SharedPdfRichTextSerializer.decodeElement(richText) + if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) { + if (richTextFile.isFile) { + val deleted = richTextFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_text_empty book=${book.id} deleted=$deleted" } + } else { + logDesktopCloudAnnotations { "desktop.import.text_empty book=${book.id} existing=false" } + } + } else { + richTextFile.parentFile?.mkdirs() + richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument)) + richTextFile.setLastModified(timestamp) + logDesktopCloudAnnotations { + "desktop.import.write_text book=${book.id} textChars=${richDocument.text.length} " + + "spans=${richDocument.spans.size} bytes=${richTextFile.length()} ts=${richTextFile.lastModified()}" + } + } + } ?: run { + if (richTextFile.isFile) { + val deleted = richTextFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_text_missing book=${book.id} deleted=$deleted" } + } else { + logDesktopCloudAnnotations { "desktop.import.no_text_payload book=${book.id} existing=false" } + } + } + logDesktopCloudAnnotations { + "desktop.import.done book=${book.id} remoteTs=$timestamp ${localAnnotationDebugSummary(book)}" + } + return true + } +} + +private fun localAnnotationPayloadTimestamp(path: String): Long { + return maxOf( + desktopPdfAnnotationFile(path).lastModifiedIfSyncableAnnotations(), + desktopPdfAnnotationDeletionFile(path).lastModifiedIfSyncableAnnotationDeletions(), + desktopPdfRichTextFile(path).lastModifiedIfSyncableRichText() + ) +} + +private val cloudSidecarJson = Json { + ignoreUnknownKeys = true + prettyPrint = true + encodeDefaults = true +} + +private fun Json.parseElementOrNull(raw: String): JsonElement? { + return runCatching { parseToJsonElement(raw) }.getOrNull() +} + +private fun JsonElement.jsonObjectOrNull(): JsonObject? { + if (this is JsonNull) return null + return runCatching { jsonObject }.getOrNull() +} + +private fun JsonObject.hasPdfAnnotationPayload(): Boolean { + return containsKey(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) || + containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_INK) || + containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_TEXT_BOXES) || + containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_HIGHLIGHTS) +} + +private fun File.lastModifiedIfFile(): Long { + return if (isFile) lastModified() else 0L +} + +private fun File.hasSyncablePdfAnnotations(): Boolean { + return isFile && desktopPdfAnnotationElementForSync(readText()) != null +} + +private fun File.lastModifiedIfSyncableAnnotations(): Long { + return if (hasSyncablePdfAnnotations()) lastModified() else 0L +} + +private fun File.annotationDeletionCount(): Int { + return if (isFile) SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(readText()).size else 0 +} + +private fun File.hasSyncablePdfAnnotationDeletions(): Boolean { + return annotationDeletionCount() > 0 +} + +private fun File.lastModifiedIfSyncableAnnotationDeletions(): Long { + return if (hasSyncablePdfAnnotationDeletions()) lastModified() else 0L +} + +private fun File.hasSyncablePdfRichText(): Boolean { + return isFile && desktopPdfRichTextElementForSync(readText()) != null +} + +private fun File.lastModifiedIfSyncableRichText(): Long { + return if (hasSyncablePdfRichText()) lastModified() else 0L +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt new file mode 100644 index 0000000..382ac97 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt @@ -0,0 +1,1204 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.BookShelfRef +import org.dueattendant149.bookreader.shared.CustomFontItem +import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer +import org.dueattendant149.bookreader.shared.EpubBookmark +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.SharedCloudBookMetadataWinner +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.ShelfRecord +import org.dueattendant149.bookreader.shared.sharedCloudBookMetadataWinner +import org.dueattendant149.bookreader.shared.shouldDownloadRemoteCloudBookContent +import org.dueattendant149.bookreader.shared.shouldUploadLocalCloudBookContent +import org.dueattendant149.bookreader.shared.sharedCloudBookContentFileName +import org.dueattendant149.bookreader.shared.toStablePositionCfi +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport +import org.dueattendant149.bookreader.shared.reader.ReaderBookmark +import java.io.File + +internal data class DesktopCloudSyncInput( + val userId: String, + val idToken: String, + val driveAccessToken: String, + val deviceId: String, + val state: SharedReaderScreenState, + val shelfRecords: List, + val shelfRefs: List, + val customFonts: List, + val includeFolderBooks: Boolean +) + +internal data class DesktopCloudSyncResult( + val state: SharedReaderScreenState, + val shelfRecords: List, + val shelfRefs: List, + val customFonts: List, + val uploadedBooks: Int = 0, + val downloadedBooks: Int = 0, + val pendingContentDownloads: Int = 0 +) + +internal class DesktopCloudSync( + private val firestoreRepository: DesktopFirestoreRepository, + private val driveRepository: DesktopGoogleDriveRepository, + private val bookImporter: DesktopBookImporter, + private val customFontStore: DesktopCustomFontStore +) { + suspend fun sync(input: DesktopCloudSyncInput): DesktopCloudSyncResult { + var state = input.state + var shelfRecords = input.shelfRecords + var shelfRefs = input.shelfRefs + var customFonts = input.customFonts + var uploadedBooks = 0 + var downloadedBooks = 0 + var pendingContentDownloads = 0 + + logDesktopCloudSync { + "desktop.engine.full_sync.start user=${input.userId} device=${input.deviceId} " + + "localBooks=${input.state.rawLibraryBooks.size} includeFolderBooks=${input.includeFolderBooks}" + } + val remoteBooks = firestoreRepository.getAllBooks(input.userId, input.idToken) + .filterNot { isDesktopPdfReflowBookId(it.bookId) } + .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } + val remoteShelves = firestoreRepository.getAllShelves(input.userId, input.idToken) + val remoteFonts = firestoreRepository.getAllFonts(input.userId, input.idToken) + var driveFiles = driveRepository.getFiles(input.driveAccessToken).associateBy { it.name } + logDesktopCloudSync { + "desktop.engine.full_sync.loaded user=${input.userId} remoteBooks=${remoteBooks.size} " + + "remoteShelves=${remoteShelves.size} remoteFonts=${remoteFonts.size} driveFiles=${driveFiles.size}" + } + + val localBooks = state.rawLibraryBooks + .filterNot { isDesktopPdfReflowBookId(it.id) } + .filter { input.includeFolderBooks || it.sourceFolder == null } + .filterNot { it.path?.startsWith("opds-pse") == true } + .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } + val localBooksMap = localBooks.associateBy { it.id } + val remoteBooksMap = remoteBooks.associateBy { it.bookId } + val allBookIds = (localBooksMap.keys + remoteBooksMap.keys).distinct() + + allBookIds.forEach { bookId -> + val local = localBooksMap[bookId] + val remote = remoteBooksMap[bookId] + if (local?.sourceFolder != null) return@forEach + + when { + local != null && remote == null -> { + logDesktopCloudSync { "desktop.engine.book_decision action=upload_new ${local.desktopCloudSyncSummary()}" } + uploadBookAndMetadata(input, local, uploadContent = true)?.let { synced -> + state = state.upsertCloudBook(synced) + uploadedBooks += 1 + } + } + + local == null && remote != null -> { + if (remote.isDeleted) { + logDesktopCloudSync { "desktop.engine.book_decision action=skip_deleted_remote_only ${remote.desktopCloudSyncSummary()}" } + return@forEach + } + logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_new ${remote.desktopCloudSyncSummary()}" } + val downloaded = downloadRemoteBook(input.driveAccessToken, remote, null, driveFiles) + if (downloaded == null) { + pendingContentDownloads += 1 + logDesktopCloudSync { + "desktop.engine.book_decision action=defer_remote_new_pending_content " + + remote.desktopCloudSyncSummary() + } + return@forEach + } + val remoteBook = downloaded + state = state.upsertCloudBook(remoteBook) + downloadedBooks += 1 + importDesktopPdfBookmarksMetadata(remoteBook, remote.bookmarksJson, remote.lastModifiedTimestamp) + if (remote.hasAnnotations) { + val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp( + remoteAnnotationDriveFileTimestamp(remote.bookId, driveFiles) + ) + downloadAnnotations(input.driveAccessToken, remoteBook, remoteAnnotationTimestamp) + } + } + + local != null && remote != null -> { + val remoteBook = remote.toDesktopBookItem(existing = local) + val shouldDownloadContent = shouldDownloadRemoteBookContent(local, remote) + val downloaded = if (shouldDownloadContent) { + downloadRemoteBook(input.driveAccessToken, remote, local, driveFiles) + } else { + null + } + if (shouldDownloadContent && downloaded == null) { + pendingContentDownloads += 1 + } + val localContentAvailable = local.path?.let(::File)?.isFile == true + if (shouldDownloadContent && downloaded == null && !localContentAvailable) { + logDesktopCloudSync { + "desktop.engine.book_decision action=defer_existing_pending_content book=$bookId " + + local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary() + } + state = state.removeCloudBook(bookId) + return@forEach + } + val localSidecarTimestampBeforeMerge = DesktopCloudSidecarSync.localAnnotationTimestamp(local) + val metadataWinner = sharedCloudBookMetadataWinner( + localModifiedTimestamp = local.timestamp, + remoteModifiedTimestamp = remote.lastModifiedTimestamp + ) + val localMetadataWins = metadataWinner == SharedCloudBookMetadataWinner.LOCAL + val localReadingTimestamp = local.effectiveCloudReadingPositionModifiedTimestamp() + val remoteReadingTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp() + val remoteAnnotationDriveTimestamp = remoteAnnotationDriveFileTimestamp(bookId, driveFiles) + val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp(remoteAnnotationDriveTimestamp) + val localReadingPositionShouldUpload = localReadingTimestamp > remoteReadingTimestamp + val localAnnotationsShouldUpload = shouldUploadLocalAnnotations( + local = local, + remote = remote, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, + localSidecarTimestamp = localSidecarTimestampBeforeMerge + ) + logDesktopCloudAnnotations { + "desktop.sync.inspect book=$bookId remoteHas=${remote.hasAnnotations} " + + "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " + + "remoteDriveAnnTs=$remoteAnnotationDriveTimestamp localTs=${local.timestamp} " + + "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " + + "localShouldUpload=$localAnnotationsShouldUpload " + + DesktopCloudSidecarSync.localAnnotationDebugSummary(local) + } + logDesktopCloudSync { + "desktop.engine.book_compare book=$bookId winner=$metadataWinner shouldDownloadContent=$shouldDownloadContent " + + "downloadedContent=${downloaded != null} sidecarTs=$localSidecarTimestampBeforeMerge " + + "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " + + local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary() + } + + if (remote.isDeleted) { + if (localMetadataWins) { + logDesktopCloudSync { "desktop.engine.book_decision action=resurrect_upload_local book=$bookId" } + uploadBookAndMetadata( + input = input, + book = local, + uploadContent = shouldUploadLocalBookContent(local, null), + uploadAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(local) + )?.let { synced -> + state = state.upsertCloudBook(synced) + uploadedBooks += 1 + } + } else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE) { + logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_delete book=$bookId" } + state = state.removeCloudBook(bookId) + } else { + logDesktopCloudSync { "desktop.engine.book_decision action=skip_equal_delete book=$bookId" } + } + return@forEach + } + + if (localMetadataWins) { + logDesktopCloudSync { + "desktop.engine.book_decision action=upload_local book=$bookId " + + "uploadContent=${shouldUploadLocalBookContent(local, remote)} " + + "uploadAnnotations=$localAnnotationsShouldUpload " + + "preserveRemoteReading=${remoteReadingTimestamp > localReadingTimestamp}" + } + val localForMetadata = if (remoteReadingTimestamp > localReadingTimestamp) { + local.withCloudReadingPosition(remote) + } else { + local + } + val bookForMetadata = localForMetadata.withDownloadedCloudContent(downloaded, replacePath = false) + uploadBookAndMetadata( + input = input, + book = bookForMetadata, + uploadContent = shouldUploadLocalBookContent(local, remote), + uploadAnnotations = localAnnotationsShouldUpload, + remoteHasAnnotations = remote.hasAnnotations, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, + remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp + )?.let { synced -> + state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded)) + uploadedBooks += 1 + } + } else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE || downloaded != null) { + logDesktopCloudSync { + "desktop.engine.book_decision action=apply_remote book=$bookId " + + "metadataWinner=$metadataWinner downloadedContent=${downloaded != null}" + } + val mergedBook = downloaded ?: remoteBook + state = state.upsertCloudBook(mergedBook) + importDesktopPdfBookmarksMetadata(mergedBook, remote.bookmarksJson, remote.lastModifiedTimestamp) + } + + if (!localMetadataWins && (localAnnotationsShouldUpload || localReadingPositionShouldUpload)) { + val metadataBook = state.rawLibraryBooks.firstOrNull { it.id == bookId } + ?: remoteBook + logDesktopCloudAnnotations { + "desktop.sync.upload_local_supplement book=$bookId winner=$metadataWinner " + + "remoteHas=${remote.hasAnnotations} remoteTs=${remote.lastModifiedTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestamp " + + "localSidecarTs=$localSidecarTimestampBeforeMerge " + + "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " + + "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp" + } + logDesktopCloudSync { + "desktop.engine.book_decision action=upload_local_supplement book=$bookId " + + "metadataWinner=$metadataWinner sidecarTs=$localSidecarTimestampBeforeMerge " + + "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " + + metadataBook.desktopCloudSyncSummary() + } + uploadBookAndMetadata( + input = input, + book = metadataBook, + uploadContent = false, + uploadAnnotations = localAnnotationsShouldUpload, + remoteHasAnnotations = remote.hasAnnotations, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, + remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp + )?.let { synced -> + state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded)) + uploadedBooks += 1 + } + } + + val localSidecarTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(downloaded ?: local) + val needsAnnotationDownload = !localMetadataWins && + !localAnnotationsShouldUpload && + remote.hasAnnotations && + (remoteAnnotationTimestamp > localSidecarTimestamp || localSidecarTimestamp == 0L) + if (needsAnnotationDownload) { + logDesktopCloudAnnotations { + "desktop.sync.download_remote_annotations book=$bookId remoteTs=${remote.lastModifiedTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestamp " + + "localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " + + "localShouldUpload=$localAnnotationsShouldUpload" + } + logDesktopCloudSync { + "desktop.engine.sidecar_download_start book=$bookId remoteTs=${remote.lastModifiedTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins" + } + val targetBook = downloaded ?: state.rawLibraryBooks.firstOrNull { it.id == bookId } ?: local + downloadAnnotations(input.driveAccessToken, targetBook, remoteAnnotationTimestamp) + } else { + logDesktopCloudAnnotations { + "desktop.sync.skip_remote_annotations book=$bookId remoteHas=${remote.hasAnnotations} " + + "remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " + + "localShouldUpload=$localAnnotationsShouldUpload" + } + logDesktopCloudSync { + "desktop.engine.sidecar_download_skip book=$bookId remoteHasAnnotations=${remote.hasAnnotations} " + + "localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins" + } + } + } + } + } + + driveFiles = driveRepository.getFiles(input.driveAccessToken).associateBy { it.name } + state.rawLibraryBooks + .filterNot { isDesktopPdfReflowBookId(it.id) } + .filter { it.sourceFolder == null } + .filterNot { it.path?.startsWith("opds-pse") == true } + .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } + .forEach { book -> + val driveName = desktopCloudBookDriveFileName(book.id, book.type) ?: return@forEach + val localFile = book.path?.let(::File) + when { + localFile?.isFile == true && driveFiles[driveName] == null -> { + val remote = remoteBooksMap[book.id] + if (remote == null || shouldUploadLocalBookContent(book, remote)) { + logDesktopCloudSync { "desktop.engine.content_upload_missing_remote book=${book.id} driveName=$driveName" } + uploadBookAndMetadata( + input = input, + book = book, + uploadContent = true, + uploadAnnotations = false, + remoteHasAnnotations = remote?.hasAnnotations == true, + remoteAnnotationModifiedTimestamp = remote?.effectiveCloudAnnotationModifiedTimestamp( + remoteAnnotationDriveFileTimestamp(book.id, driveFiles) + ) ?: 0L, + remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp + )?.let { synced -> + state = state.upsertCloudBook(synced) + uploadedBooks += 1 + } + } else { + pendingContentDownloads += 1 + logDesktopCloudSync { + "desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName " + + "localContentTs=${book.fileContentModifiedTimestamp} remoteContentTs=${remote.fileContentModifiedTimestamp}" + } + } + } + + (localFile == null || !localFile.isFile) && driveFiles[driveName] != null -> { + val remote = remoteBooksMap[book.id] ?: return@forEach + logDesktopCloudSync { "desktop.engine.content_download_missing_local book=${book.id} driveName=$driveName" } + val downloaded = downloadRemoteBook(input.driveAccessToken, remote, book, driveFiles) + if (downloaded != null) { + state = state.upsertCloudBook(downloaded) + downloadedBooks += 1 + } else { + pendingContentDownloads += 1 + } + } + + (localFile == null || !localFile.isFile) && driveFiles[driveName] == null -> { + pendingContentDownloads += 1 + logDesktopCloudSync { "desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName" } + state = state.removeCloudBook(book.id) + } + } + } + + val shelfSync = syncShelves( + userId = input.userId, + idToken = input.idToken, + deviceId = input.deviceId, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + syncableBookIds = state.rawLibraryBooks + .filterNot { isDesktopPdfReflowBookId(it.id) } + .mapTo(mutableSetOf()) { it.id }, + remoteShelves = remoteShelves + ) + shelfRecords = shelfSync.records + shelfRefs = shelfSync.refs + + customFonts = syncFonts( + userId = input.userId, + idToken = input.idToken, + accessToken = input.driveAccessToken, + localFonts = customFonts, + remoteFonts = remoteFonts + ) + + logDesktopCloudSync { + "desktop.engine.full_sync.complete user=${input.userId} uploaded=$uploadedBooks downloaded=$downloadedBooks " + + "pendingContent=$pendingContentDownloads books=${state.rawLibraryBooks.size}" + } + return DesktopCloudSyncResult( + state = state, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + customFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }, + uploadedBooks = uploadedBooks, + downloadedBooks = downloadedBooks, + pendingContentDownloads = pendingContentDownloads + ) + } + + suspend fun uploadBookAndMetadata( + input: DesktopCloudSyncInput, + book: BookItem, + uploadContent: Boolean, + uploadAnnotations: Boolean = true, + remoteHasAnnotations: Boolean = false, + remoteAnnotationModifiedTimestamp: Long = 0L, + remoteContentModifiedTimestamp: Long? = null + ): BookItem? { + if (isDesktopPdfReflowBookId(book.id)) { + logDesktopCloudSync { "desktop.upload.skip reason=reflow ${book.desktopCloudSyncSummary()}" } + return null + } + if (book.sourceFolder != null) { + logDesktopCloudSync { "desktop.upload.skip reason=folder_book ${book.desktopCloudSyncSummary()}" } + return null + } + if (book.path?.startsWith("opds-pse") == true) { + logDesktopCloudSync { "desktop.upload.skip reason=opds_stream ${book.desktopCloudSyncSummary()}" } + return null + } + if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) { + logDesktopCloudSync { "desktop.upload.skip reason=manual_only ${book.desktopCloudSyncSummary()}" } + return null + } + logDesktopCloudSync { + "desktop.upload.start uploadContent=$uploadContent uploadAnnotations=$uploadAnnotations " + + "remoteHasAnnotations=$remoteHasAnnotations ${book.desktopCloudSyncSummary()}" + } + if (uploadContent) { + val source = book.path?.let(::File)?.takeIf { it.isFile } + if (source != null && driveRepository.uploadFile(input.driveAccessToken, book.id, source, book.type) == null) { + logDesktopCloudSync { "desktop.upload.content_failed book=${book.id} path=${source.absolutePath}" } + return null + } + logDesktopCloudSync { "desktop.upload.content_success book=${book.id} path=${source?.absolutePath ?: "none"}" } + } + + val hasLocalAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(book) + val shouldUploadAnnotations = uploadAnnotations || (!remoteHasAnnotations && hasLocalAnnotations) + val bundle = if (shouldUploadAnnotations) DesktopCloudSidecarSync.exportAnnotationBundle(book) else null + var uploadedAnnotationTimestamp = 0L + logDesktopCloudAnnotations { + "desktop.upload.annotation_decision book=${book.id} uploadAnnotations=$uploadAnnotations " + + "remoteHas=$remoteHasAnnotations hasLocal=$hasLocalAnnotations shouldUpload=$shouldUploadAnnotations " + + "bundleBytes=${bundle?.length() ?: 0L} " + DesktopCloudSidecarSync.localAnnotationDebugSummary(book) + } + try { + if (bundle != null) { + val mergedRemoteIntoUpload = mergeRemoteAnnotationsIntoUploadBundle( + accessToken = input.driveAccessToken, + book = book, + bundle = bundle, + remoteHasAnnotations = remoteHasAnnotations + ) + val uploadedAnnotationFile = driveRepository.uploadAnnotationFile(input.driveAccessToken, book.id, bundle) + if (uploadedAnnotationFile == null) { + logDesktopCloudAnnotations { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" } + logDesktopCloudSync { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" } + return null + } + uploadedAnnotationTimestamp = uploadedAnnotationFile.modifiedTimeMillis + if (mergedRemoteIntoUpload) { + val appliedMergedLocal = DesktopCloudSidecarSync.importAnnotationBundle( + book = book, + rawJson = bundle.readText(), + timestamp = uploadedAnnotationTimestamp + ) + logDesktopCloudAnnotations { + "desktop.upload.local_apply_merged book=${book.id} applied=$appliedMergedLocal " + + "driveTs=$uploadedAnnotationTimestamp bytes=${bundle.length()}" + } + } + DesktopCloudSidecarSync.markAnnotationPayloadSynced(book, uploadedAnnotationTimestamp) + } + if (bundle != null) { + logDesktopCloudAnnotations { + "desktop.upload.sidecar_success book=${book.id} bytes=${bundle.length()} driveTs=$uploadedAnnotationTimestamp" + } + } else { + logDesktopCloudAnnotations { + "desktop.upload.sidecar_skipped book=${book.id} shouldUpload=$shouldUploadAnnotations hasLocal=$hasLocalAnnotations" + } + } + logDesktopCloudSync { + "desktop.upload.sidecar_decision book=${book.id} hasLocal=$hasLocalAnnotations " + + "shouldUpload=$shouldUploadAnnotations uploaded=${bundle != null} bytes=${bundle?.length() ?: 0L}" + } + } finally { + bundle?.delete() + } + + val now = System.currentTimeMillis() + val syncedBook = book.copy( + timestamp = now, + readingPositionModifiedTimestamp = book.effectiveCloudReadingPositionModifiedTimestamp() + ) + val localAnnotationTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(book) + val syncedAnnotationTimestamp = if (bundle != null) { + uploadedAnnotationTimestamp.takeIf { it > 0L } ?: maxOf(localAnnotationTimestamp, now) + } else if (remoteHasAnnotations) { + remoteAnnotationModifiedTimestamp + } else { + 0L + } + val syncedHasAnnotations = if (uploadAnnotations) { + syncedAnnotationTimestamp > 0L || (bundle != null && hasLocalAnnotations) + } else { + remoteHasAnnotations || syncedAnnotationTimestamp > 0L || bundle != null || hasLocalAnnotations + } + firestoreRepository.syncBookMetadata( + userId = input.userId, + book = syncedBook.toDesktopCloudBookMetadata( + hasAnnotations = syncedHasAnnotations, + timestamp = now, + annotationModifiedTimestamp = syncedAnnotationTimestamp, + contentTimestampOverride = if (uploadContent) null else remoteContentModifiedTimestamp + ), + originDeviceId = input.deviceId, + idToken = input.idToken + ) + logDesktopCloudSync { + "desktop.upload.metadata_success user=${input.userId} device=${input.deviceId} " + + "oldTs=${book.timestamp} newTs=$now hasAnnotations=$syncedHasAnnotations " + + syncedBook.desktopCloudSyncSummary("synced") + } + logDesktopCloudAnnotations { + "desktop.upload.metadata_success book=${book.id} oldTs=${book.timestamp} newTs=$now " + + "readTs=${syncedBook.effectiveCloudReadingPositionModifiedTimestamp()} " + + "annTs=$syncedAnnotationTimestamp hasAnnotations=$syncedHasAnnotations" + } + return syncedBook + } + + private suspend fun mergeRemoteAnnotationsIntoUploadBundle( + accessToken: String, + book: BookItem, + bundle: File, + remoteHasAnnotations: Boolean + ): Boolean { + if (!remoteHasAnnotations || !bundle.isFile) return false + val remoteTemp = File(desktopUserCacheRoot(), "remote_annotation_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json") + try { + val didDownload = driveRepository.downloadAnnotationFile(accessToken, book.id, remoteTemp) + if (!didDownload || !remoteTemp.isFile) { + logDesktopCloudAnnotations { + "desktop.upload.merge_remote_missing book=${book.id} didDownload=$didDownload " + + "tempExists=${remoteTemp.exists()} localBytes=${bundle.length()}" + } + return false + } + val localRaw = bundle.readText() + val remoteRaw = remoteTemp.readText() + val mergedRaw = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson( + localDataJson = localRaw, + remoteDataJson = remoteRaw, + preferRemoteOnConflict = false + ) + val localCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(localRaw) + val remoteCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(remoteRaw) + val mergedCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(mergedRaw) + if (mergedRaw != localRaw) { + bundle.writeText(mergedRaw) + logDesktopCloudAnnotations { + "desktop.upload.merge_remote_applied book=${book.id} localCount=$localCount " + + "remoteCount=$remoteCount mergedCount=$mergedCount mergedBytes=${bundle.length()}" + } + return true + } else { + logDesktopCloudAnnotations { + "desktop.upload.merge_remote_noop book=${book.id} localCount=$localCount " + + "remoteCount=$remoteCount mergedCount=$mergedCount" + } + } + } catch (error: Exception) { + logDesktopCloudAnnotations { + "desktop.upload.merge_remote_failed book=${book.id} error=${error.message.orEmpty().logPreview(240)}" + } + } finally { + remoteTemp.delete() + } + return false + } + + suspend fun deleteBooksFromCloud( + userId: String, + idToken: String, + accessToken: String, + deviceId: String, + books: List + ) { + val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name } + books + .filterNot { isDesktopPdfReflowBookId(it.id) } + .filter { it.sourceFolder == null } + .filterNot { it.path?.startsWith("opds-pse") == true } + .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } + .forEach { book -> + firestoreRepository.syncBookMetadata( + userId = userId, + book = book.toDesktopCloudBookMetadata( + hasAnnotations = false, + timestamp = System.currentTimeMillis() + ).copy(isDeleted = true), + originDeviceId = deviceId, + idToken = idToken + ) + desktopCloudBookDriveFileName(book.id, book.type) + ?.let { driveFiles[it]?.id } + ?.let { driveRepository.deleteDriveFile(accessToken, it) } + driveFiles[desktopCloudAnnotationDriveFileName(book.id)]?.id + ?.let { driveRepository.deleteDriveFile(accessToken, it) } + } + } + + suspend fun syncShelfChange( + userId: String, + idToken: String, + deviceId: String, + record: ShelfRecord, + refs: List, + isDeleted: Boolean = false + ) { + if (record.isSmart) return + firestoreRepository.syncShelf( + userId = userId, + shelf = DesktopCloudShelfMetadata( + name = record.name, + bookIds = refs.filter { it.shelfId == record.id }.map { it.bookId }.distinct(), + lastModifiedTimestamp = System.currentTimeMillis(), + isDeleted = isDeleted + ), + originDeviceId = deviceId, + idToken = idToken + ) + } + + suspend fun clearCloudData(userId: String, idToken: String, accessToken: String) { + driveRepository.deleteAllFiles(accessToken) + firestoreRepository.deleteAllUserFirestoreData(userId, idToken) + } + + suspend fun deleteFontFromCloud( + userId: String, + idToken: String, + accessToken: String, + font: CustomFontItem + ) { + val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name } + driveFiles[font.fileName]?.id?.let { driveRepository.deleteDriveFile(accessToken, it) } + firestoreRepository.deleteFontMetadata(userId, font.id, idToken) + } + + private suspend fun downloadAnnotations(accessToken: String, book: BookItem, timestamp: Long): Boolean { + val temp = File(desktopUserCacheRoot(), "temp_download_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json") + return try { + logDesktopCloudAnnotations { + "desktop.download.start book=${book.id} remoteTs=$timestamp temp=${temp.name} " + + DesktopCloudSidecarSync.localAnnotationDebugSummary(book) + } + logDesktopCloudSync { "desktop.sidecar_download.start book=${book.id} remoteTs=$timestamp temp=${temp.name}" } + if (!driveRepository.downloadAnnotationFile(accessToken, book.id, temp) || !temp.isFile) { + logDesktopCloudAnnotations { + "desktop.download.missing book=${book.id} remoteTs=$timestamp tempExists=${temp.exists()} tempBytes=${temp.length()}" + } + logDesktopCloudSync { "desktop.sidecar_download.missing book=${book.id} remoteTs=$timestamp" } + return false + } + val raw = temp.readText() + logDesktopCloudAnnotations { + "desktop.download.success book=${book.id} remoteTs=$timestamp bytes=${raw.length}" + } + val appliedTimestamp = timestamp.takeIf { it > 0L } ?: temp.lastModified().takeIf { it > 0L } ?: 0L + val applied = DesktopCloudSidecarSync.importAnnotationBundle(book, raw, appliedTimestamp) + logDesktopCloudAnnotations { + "desktop.download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp applied=$applied " + + DesktopCloudSidecarSync.localAnnotationDebugSummary(book) + } + logDesktopCloudSync { + "desktop.sidecar_download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp bytes=${temp.length()} applied=$applied" + } + applied + } finally { + temp.delete() + } + } + + private suspend fun downloadRemoteBook( + accessToken: String, + remote: DesktopCloudBookMetadata, + existing: BookItem?, + driveFiles: Map + ): BookItem? { + val type = remote.fileType() + val driveName = desktopCloudBookDriveFileName(remote.bookId, type) ?: return null + val driveFile = driveFiles[driveName] ?: return null + val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null + val destination = bookImporter.createBookFile("${remote.bookId.toDesktopSafeFileName()}.$extension") + logDesktopCloudSync { "desktop.content_download.start book=${remote.bookId} driveName=$driveName remoteContentTs=${remote.fileContentModifiedTimestamp}" } + if (!driveRepository.downloadFile(accessToken, driveFile.id, destination)) { + destination.delete() + logDesktopCloudSync { "desktop.content_download.failed book=${remote.bookId} driveName=$driveName" } + return null + } + val contentTimestamp = remote.fileContentModifiedTimestamp.takeIf { it > 0L } ?: destination.lastModified() + if (contentTimestamp > 0L) destination.setLastModified(contentTimestamp) + val downloaded = remote.toDesktopBookItem(existing = existing, downloadedPath = destination.absolutePath).copy( + fileSize = destination.length(), + fileContentModifiedTimestamp = contentTimestamp + ) + logDesktopCloudSync { + "desktop.content_download.success book=${remote.bookId} bytes=${destination.length()} contentTs=$contentTimestamp " + + downloaded.desktopCloudSyncSummary("downloaded") + } + return downloaded + } + + private suspend fun syncFonts( + userId: String, + idToken: String, + accessToken: String, + localFonts: List, + remoteFonts: List + ): List { + val localFontsMap = localFonts.associateBy { it.id } + val remoteFontsMap = remoteFonts.associateBy { it.id } + val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name } + val nextFonts = localFonts.toMutableList() + + (localFontsMap.keys + remoteFontsMap.keys).forEach { fontId -> + val local = localFontsMap[fontId] + val remote = remoteFontsMap[fontId] + when { + local != null && remote == null -> { + firestoreRepository.syncFontMetadata(userId, local.toDesktopCloudFontMetadata(), idToken) + } + + local == null && remote != null && !remote.isDeleted -> { + val target = customFontStore.getFontFile(remote.fileName) + driveFiles[remote.fileName]?.id?.let { driveRepository.downloadFile(accessToken, it, target) } + nextFonts += customFontStore.syncedFontItem(remote) + } + + local != null && remote != null -> { + when { + local.isDeleted && !remote.isDeleted -> { + firestoreRepository.syncFontMetadata(userId, remote.copy(isDeleted = true), idToken) + } + + !local.isDeleted && remote.isDeleted -> { + customFontStore.deleteFont(local) + nextFonts.removeAll { it.id == local.id } + } + } + } + } + } + + nextFonts.toList().forEach { font -> + val localFile = File(font.path) + if (!font.isDeleted && localFile.isFile && driveFiles[font.fileName] == null) { + driveRepository.uploadFont(accessToken, font.fileName, localFile, font.fileExtension) + } else if (!font.isDeleted && !localFile.isFile) { + driveFiles[font.fileName]?.id?.let { driveRepository.downloadFile(accessToken, it, localFile) } + } + } + return nextFonts.distinctBy { it.id } + } + + private suspend fun syncShelves( + userId: String, + idToken: String, + deviceId: String, + shelfRecords: List, + shelfRefs: List, + syncableBookIds: Set, + remoteShelves: List + ): ShelfSyncResult { + val localShelves = shelfRecords + .filterNot { it.isSmart } + .map { record -> + DesktopCloudShelfRecord( + record = record, + metadata = DesktopCloudShelfMetadata( + name = record.name, + bookIds = shelfRefs.filter { it.shelfId == record.id } + .map { it.bookId } + .filter { it in syncableBookIds } + .distinct(), + lastModifiedTimestamp = desktopShelfTimestamp(record, shelfRefs), + isDeleted = false + ) + ) + } + val localShelvesByName = localShelves.associateBy { it.metadata.name } + val remoteShelvesByName = remoteShelves.associateBy { it.name } + var records = shelfRecords + var refs = shelfRefs + + (localShelvesByName.keys + remoteShelvesByName.keys).forEach { shelfName -> + val local = localShelvesByName[shelfName] + val remote = remoteShelvesByName[shelfName] + when { + local != null && remote == null -> { + firestoreRepository.syncShelf(userId, local.metadata, deviceId, idToken) + } + + local == null && remote != null -> { + if (!remote.isDeleted) { + val record = ShelfRecord(id = "shelf_${remote.lastModifiedTimestamp}_${shelfName.hashCode()}", name = remote.name) + records += record + refs = refs.filterNot { it.shelfId == record.id } + + remote.bookIds.filter { it in syncableBookIds }.map { bookId -> + BookShelfRef(bookId, record.id, remote.lastModifiedTimestamp) + } + } + } + + local != null && remote != null -> { + if (local.metadata.lastModifiedTimestamp > remote.lastModifiedTimestamp) { + firestoreRepository.syncShelf(userId, local.metadata, deviceId, idToken) + } else if (remote.lastModifiedTimestamp > local.metadata.lastModifiedTimestamp) { + if (remote.isDeleted) { + records = records.filterNot { it.id == local.record.id } + refs = refs.filterNot { it.shelfId == local.record.id } + } else { + refs = refs.filterNot { it.shelfId == local.record.id } + + remote.bookIds.filter { it in syncableBookIds }.map { bookId -> + BookShelfRef(bookId, local.record.id, remote.lastModifiedTimestamp) + } + } + } + } + } + } + return ShelfSyncResult(records, refs) + } +} + +internal fun BookItem.toDesktopCloudBookMetadata( + hasAnnotations: Boolean, + timestamp: Long = this.timestamp, + annotationModifiedTimestamp: Long = 0L, + contentTimestampOverride: Long? = null +): DesktopCloudBookMetadata { + val position = readerPosition.takeIf { type.usesCloudLocatorMetadata() } + val supportsReaderAnnotations = type.usesCloudLocatorMetadata() + val bookmarksJson = desktopPdfBookmarksMetadataJson(this) + ?: if (supportsReaderAnnotations) { + readerBookmarks + .mapNotNull { it.toDesktopCloudEpubBookmarkOrNull() } + .let(EpubAnnotationSerializer::bookmarksToJson) + } else { + null + } + val highlightsJson = if (supportsReaderAnnotations) { + EpubAnnotationSerializer.highlightsToJson(readerHighlights) + } else { + null + } + val localFile = path?.let(::File) + val contentTimestamp = contentTimestampOverride + ?: fileContentModifiedTimestamp.takeIf { it > 0L } + ?: localFile?.takeIf { it.isFile }?.lastModified() + ?: 0L + return DesktopCloudBookMetadata( + bookId = id, + title = title, + author = author, + displayName = displayName, + type = type.name, + lastPositionCfi = position?.cloudPositionCfi(), + lastChapterIndex = position?.chapterIndex, + locatorBlockIndex = position?.blockIndex, + locatorCharOffset = position?.charOffset, + lastPage = if (type.usesCloudLocatorMetadata()) position?.pageIndex ?: lastPageIndex else lastPageIndex, + progressPercentage = progressPercentage, + isRecent = isRecent, + isDeleted = false, + lastModifiedTimestamp = timestamp, + readingPositionModifiedTimestamp = effectiveCloudReadingPositionModifiedTimestamp(), + annotationModifiedTimestamp = annotationModifiedTimestamp, + bookmarksJson = bookmarksJson, + hasAnnotations = hasAnnotations, + fileContentModifiedTimestamp = contentTimestamp, + customName = null, + highlightsJson = highlightsJson, + seriesName = seriesName, + seriesIndex = seriesIndex, + description = description, + originalTitle = originalTitle ?: title, + originalAuthor = originalAuthor ?: author, + originalSeriesName = originalSeriesName ?: seriesName, + originalSeriesIndex = originalSeriesIndex ?: seriesIndex, + originalDescription = originalDescription ?: description + ) +} + +internal fun DesktopCloudBookMetadata.toDesktopBookItem( + existing: BookItem? = null, + downloadedPath: String? = null +): BookItem { + val type = fileType() + val pageIndex = lastPage + val locator = if (type.usesCloudLocatorMetadata()) { + ReaderLocator.fromLegacy( + chapterIndex = lastChapterIndex, + cfi = lastPositionCfi, + pageIndex = pageIndex + ).withFallbacks( + blockIndex = locatorBlockIndex, + charOffset = locatorCharOffset + ) + } else { + null + } + val remoteReadingTimestamp = effectiveCloudReadingPositionModifiedTimestamp() + val localReadingTimestamp = existing?.effectiveCloudReadingPositionModifiedTimestamp() ?: 0L + val useRemoteReadingPosition = existing == null || + remoteReadingTimestamp > localReadingTimestamp || + (localReadingTimestamp == 0L && hasCloudReadingPosition()) + val restoredPageIndex = if (useRemoteReadingPosition) pageIndex ?: existing?.lastPageIndex else existing?.lastPageIndex + val restoredReaderPosition = if (type.usesCloudLocatorMetadata()) { + if (useRemoteReadingPosition) { + locator?.takeIf { + it.chapterIndex != null || + it.pageIndex != null || + it.cfi != null || + it.startOffset != null || + it.blockIndex != null + } ?: existing?.readerPosition + } else { + existing?.readerPosition + } + } else { + null + } + return BookItem( + id = bookId, + path = downloadedPath ?: existing?.path, + type = type, + displayName = displayName.ifBlank { existing?.displayName ?: bookId }, + timestamp = lastModifiedTimestamp, + coverImagePath = existing?.coverImagePath, + title = title ?: existing?.title, + author = author ?: existing?.author, + description = description ?: existing?.description, + originalTitle = originalTitle ?: existing?.originalTitle, + originalAuthor = originalAuthor ?: existing?.originalAuthor, + originalSeriesName = originalSeriesName ?: existing?.originalSeriesName, + originalSeriesIndex = originalSeriesIndex ?: existing?.originalSeriesIndex, + originalDescription = originalDescription ?: existing?.originalDescription, + progressPercentage = if (useRemoteReadingPosition) progressPercentage ?: existing?.progressPercentage else existing?.progressPercentage, + isRecent = isRecent, + fileSize = existing?.fileSize ?: 0L, + fileContentModifiedTimestamp = fileContentModifiedTimestamp.takeIf { it > 0L } + ?: existing?.fileContentModifiedTimestamp + ?: 0L, + sourceFolder = null, + folderTextMetadataParsed = existing?.folderTextMetadataParsed ?: false, + seriesName = seriesName ?: existing?.seriesName, + seriesIndex = seriesIndex ?: existing?.seriesIndex, + tags = existing?.tags.orEmpty(), + lastPageIndex = restoredPageIndex, + readerPosition = restoredReaderPosition, + readerSettings = existing?.readerSettings, + readerBookmarks = if (type == FileType.PDF || bookmarksJson.isNullOrBlank()) { + existing?.readerBookmarks.orEmpty() + } else { + EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson).map { bookmark -> + ReaderBookmark( + id = "${bookmark.chapterIndex}:${bookmark.cfi}", + pageIndex = bookmark.pageInChapter?.minus(1) ?: bookmark.locator.pageIndex ?: 0, + chapterTitle = bookmark.chapterTitle, + preview = bookmark.snippet, + locator = bookmark.locator + ) + } + }, + readerHighlights = if (highlightsJson.isNullOrBlank()) { + existing?.readerHighlights.orEmpty() + } else { + EpubAnnotationSerializer.parseHighlightsJson(highlightsJson) + }, + pdfReaderViewport = if (useRemoteReadingPosition) remotePdfViewport(existing, pageIndex) else existing?.pdfReaderViewport, + readingPositionModifiedTimestamp = if (useRemoteReadingPosition) remoteReadingTimestamp else localReadingTimestamp + ) +} + +internal fun BookItem.withCloudReadingPosition(remote: DesktopCloudBookMetadata): BookItem { + val remoteType = remote.fileType() + val pageIndex = remote.lastPage + val locator = if (remoteType.usesCloudLocatorMetadata()) { + ReaderLocator.fromLegacy( + chapterIndex = remote.lastChapterIndex, + cfi = remote.lastPositionCfi, + pageIndex = pageIndex + ).withFallbacks( + blockIndex = remote.locatorBlockIndex, + charOffset = remote.locatorCharOffset + ).takeIf { + it.chapterIndex != null || + it.pageIndex != null || + it.cfi != null || + it.startOffset != null || + it.blockIndex != null + } + } else { + null + } + return copy( + lastPageIndex = pageIndex ?: lastPageIndex, + readerPosition = if (remoteType.usesCloudLocatorMetadata()) locator ?: readerPosition else null, + progressPercentage = remote.progressPercentage ?: progressPercentage, + pdfReaderViewport = if (remoteType.usesCloudLocatorMetadata()) { + pdfReaderViewport + } else { + remote.remotePdfViewport(this, pageIndex) + }, + readingPositionModifiedTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp() + ) +} + +private fun DesktopCloudBookMetadata.remotePdfViewport( + existing: BookItem?, + pageIndex: Int? +): SharedPdfReaderViewport? { + if (fileType().usesCloudLocatorMetadata() || pageIndex == null) return existing?.pdfReaderViewport + val base = existing?.pdfReaderViewport ?: SharedPdfReaderViewport() + return base.copy( + pageIndex = pageIndex, + horizontalScrollOffset = 0, + paginatedVerticalScrollOffset = 0, + verticalFirstPageIndex = pageIndex, + verticalFirstPageScrollOffset = 0 + ) +} + +private fun FileType.usesCloudLocatorMetadata(): Boolean { + return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this) +} + +internal fun BookItem.hasCloudReadingPosition(): Boolean { + return lastPageIndex != null || + readerPosition != null || + (progressPercentage ?: 0f) > 0f +} + +internal fun BookItem.effectiveCloudReadingPositionModifiedTimestamp(): Long { + return readingPositionModifiedTimestamp.takeIf { it > 0L } + ?: timestamp.takeIf { hasCloudReadingPosition() } + ?: 0L +} + +internal fun DesktopCloudBookMetadata.hasCloudReadingPosition(): Boolean { + return lastChapterIndex != null || + lastPage != null || + !lastPositionCfi.isNullOrBlank() || + locatorBlockIndex != null || + locatorCharOffset != null || + (progressPercentage ?: 0f) > 0f +} + +internal fun DesktopCloudBookMetadata.effectiveCloudReadingPositionModifiedTimestamp(): Long { + return readingPositionModifiedTimestamp.takeIf { it > 0L } + ?: lastModifiedTimestamp.takeIf { hasCloudReadingPosition() } + ?: 0L +} + +internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(): Long { + return annotationModifiedTimestamp.takeIf { it > 0L } + ?: 0L +} + +internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(sidecarModifiedTimestamp: Long): Long { + return sidecarModifiedTimestamp.takeIf { it > 0L } + ?: effectiveCloudAnnotationModifiedTimestamp() +} + +internal fun CustomFontItem.toDesktopCloudFontMetadata(): DesktopCloudFontMetadata { + return DesktopCloudFontMetadata( + id = id, + displayName = displayName, + fileName = fileName, + fileExtension = fileExtension, + timestamp = timestamp, + isDeleted = isDeleted + ) +} + +internal fun desktopCloudBookDriveFileName(bookId: String, type: FileType): String? { + return sharedCloudBookContentFileName(bookId, type) +} + +private data class DesktopCloudShelfRecord( + val record: ShelfRecord, + val metadata: DesktopCloudShelfMetadata +) + +private data class ShelfSyncResult( + val records: List, + val refs: List +) + +private fun DesktopCloudBookMetadata.fileType(): FileType { + return runCatching { FileType.valueOf(type) }.getOrDefault(FileType.EPUB) +} + +private fun SharedReaderScreenState.upsertCloudBook(book: BookItem): SharedReaderScreenState { + val existing = rawLibraryBooks.any { it.id == book.id } + val nextBooks = if (existing) { + rawLibraryBooks.map { if (it.id == book.id) book else it } + } else { + listOf(book) + rawLibraryBooks + } + return copy(rawLibraryBooks = nextBooks) +} + +private fun SharedReaderScreenState.removeCloudBook(bookId: String): SharedReaderScreenState { + return copy( + rawLibraryBooks = rawLibraryBooks.filterNot { it.id == bookId }, + selectedBookIds = selectedBookIds - bookId, + pinnedHomeBookIds = pinnedHomeBookIds - bookId, + pinnedLibraryBookIds = pinnedLibraryBookIds - bookId, + openTabIds = openTabIds.filterNot { it == bookId }, + activeTabBookId = activeTabBookId?.takeUnless { it == bookId } + ) +} + +private fun shouldDownloadRemoteBookContent(local: BookItem, remote: DesktopCloudBookMetadata): Boolean { + val localFile = local.path?.let(::File) + val localTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: localFile?.takeIf { it.isFile }?.lastModified() + ?: 0L + return local.sourceFolder == null && + remote.fileType() == local.type && + shouldDownloadRemoteCloudBookContent( + localFileAvailable = localFile?.isFile == true, + localContentModifiedTimestamp = localTimestamp, + remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp, + remoteDeleted = remote.isDeleted + ) +} + +private fun shouldUploadLocalBookContent(local: BookItem, remote: DesktopCloudBookMetadata?): Boolean { + val localFile = local.path?.let(::File)?.takeIf { it.isFile } ?: return false + val localTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } ?: localFile.lastModified() + return local.sourceFolder == null && + shouldUploadLocalCloudBookContent( + localFileAvailable = true, + localContentModifiedTimestamp = localTimestamp, + remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp + ) +} + +private fun shouldUploadLocalAnnotations( + local: BookItem, + remote: DesktopCloudBookMetadata?, + remoteAnnotationModifiedTimestamp: Long = remote?.effectiveCloudAnnotationModifiedTimestamp() ?: 0L, + localSidecarTimestamp: Long = DesktopCloudSidecarSync.localAnnotationTimestamp(local) +): Boolean { + return DesktopCloudSidecarSync.hasLocalAnnotationData(local) && + (remote == null || !remote.hasAnnotations || localSidecarTimestamp > remoteAnnotationModifiedTimestamp) +} + +private fun remoteAnnotationDriveFileTimestamp( + bookId: String, + driveFiles: Map +): Long { + return driveFiles[desktopCloudAnnotationDriveFileName(bookId)]?.modifiedTimeMillis ?: 0L +} + +internal fun desktopCloudAnnotationDriveFileName(bookId: String): String = "annotation_$bookId.json" + +private fun BookItem.withDownloadedCloudContent(downloaded: BookItem?, replacePath: Boolean = true): BookItem { + if (downloaded == null) return this + return copy( + path = if (replacePath) downloaded.path ?: path else path, + fileSize = downloaded.fileSize.takeIf { it > 0L } ?: fileSize, + fileContentModifiedTimestamp = downloaded.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: fileContentModifiedTimestamp + ) +} + +private fun desktopShelfTimestamp(record: ShelfRecord, refs: List): Long { + val idTimestamp = record.id.split('_').firstNotNullOfOrNull { it.toLongOrNull() } + val refsTimestamp = refs.filter { it.shelfId == record.id }.maxOfOrNull { it.addedAt } + return maxOf(idTimestamp ?: 0L, refsTimestamp ?: 0L) +} + +private fun ReaderLocator.cloudPositionCfi(): String? { + return toStablePositionCfi() +} + +private fun ReaderBookmark.toDesktopCloudEpubBookmarkOrNull(): EpubBookmark? { + val chapterIndex = locator.chapterIndex ?: 0 + val cfi = locator.cloudPositionCfi() ?: "desktop:$chapterIndex:$pageIndex" + return EpubBookmark( + cfi = cfi, + chapterTitle = chapterTitle, + label = null, + snippet = preview, + pageInChapter = pageIndex + 1, + totalPagesInChapter = null, + chapterIndex = chapterIndex, + locator = locator.withFallbacks( + chapterIndex = chapterIndex, + cfi = cfi, + pageIndex = pageIndex, + textQuote = preview + ) + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt new file mode 100644 index 0000000..4ac30f5 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt @@ -0,0 +1,82 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.SharedFileCapabilities + +internal const val DesktopCloudSyncLogTag = "EpistemeCloudSync" +internal const val DesktopCloudAnnotationSyncLogTag = "EpistemeCloudAnnotations" + +internal fun logDesktopCloudSync(message: () -> String) { + logDesktopDiagnostic(DesktopCloudSyncLogTag, message) +} + +internal fun logDesktopCloudAnnotations(message: () -> String) { + logDesktopDiagnostic(DesktopCloudAnnotationSyncLogTag, message) +} + +internal fun BookItem.desktopCloudSyncSummary(prefix: String = "local"): String { + val position = readerPosition + val page = if (type.usesCloudLocatorForDiagnostics()) { + position?.pageIndex ?: lastPageIndex + } else { + lastPageIndex + } + return "$prefix{id=$id type=$type ts=$timestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " + + "contentTs=$fileContentModifiedTimestamp " + + "page=$page chapter=${position?.chapterIndex} " + + "block=${position?.blockIndex} char=${position?.charOffset} progress=$progressPercentage " + + "cfi=${position?.cfi.cloudSyncPreview()} sourceFolder=${sourceFolder != null} " + + "bookmarks=${readerBookmarks.size} highlights=${readerHighlights.size}}" +} + +internal fun DesktopCloudBookMetadata.desktopCloudSyncSummary(prefix: String = "remote"): String { + return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " + + "annTs=${effectiveCloudAnnotationModifiedTimestamp()} 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 BookItem.hasSameCloudReaderPosition(other: BookItem): Boolean { + val thisPage = if (type.usesCloudLocatorForDiagnostics()) readerPosition?.pageIndex ?: lastPageIndex else lastPageIndex + val otherPage = if (other.type.usesCloudLocatorForDiagnostics()) { + other.readerPosition?.pageIndex ?: other.lastPageIndex + } else { + other.lastPageIndex + } + val thisProgress = progressPercentage + val otherProgress = other.progressPercentage + val progressMatches = when { + thisProgress == null && otherProgress == null -> true + thisProgress != null && otherProgress != null -> kotlin.math.abs(thisProgress - otherProgress) < 0.001f + else -> false + } + val locatorMatches = if (type.usesCloudLocatorForDiagnostics() || other.type.usesCloudLocatorForDiagnostics()) { + readerPosition == other.readerPosition + } else { + true + } + return thisPage == otherPage && + locatorMatches && + progressMatches +} + +private fun org.dueattendant149.bookreader.shared.FileType.usesCloudLocatorForDiagnostics(): Boolean { + return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this) +} + +private fun String?.cloudSyncPreview(maxLength: Int = 80): String { + val value = this ?: return "null" + return if (value.length <= maxLength) value else value.take(maxLength) + "..." +} + +private fun String?.cloudSyncAnnotationSummary(): String { + val value = this?.trim() ?: return "null" + return when { + value.isEmpty() -> "blank" + value == "[]" -> "empty" + else -> "present(${value.length})" + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSyncSettingsStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt similarity index 97% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSyncSettingsStore.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt index 5749fce..0b2b0e9 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSyncSettingsStore.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import java.io.File import java.util.Properties diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopComicArchive.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt similarity index 93% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopComicArchive.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt index e5ef065..ab7ad33 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopComicArchive.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt @@ -1,15 +1,17 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.opds.OpdsCatalog -import com.aryan.reader.shared.opds.OpdsStreamReference +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.opds.OpdsCatalog +import org.dueattendant149.bookreader.shared.opds.OpdsStreamReference import com.sun.jna.Library import com.sun.jna.Native import com.sun.jna.Pointer import com.sun.jna.ptr.PointerByReference import org.apache.commons.compress.archivers.sevenz.SevenZFile -import java.awt.Font +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream import java.awt.Color +import java.awt.Font import java.awt.RenderingHints import java.awt.image.BufferedImage import java.io.ByteArrayInputStream @@ -24,7 +26,7 @@ import javax.imageio.ImageIO import kotlin.math.roundToInt internal object DesktopComicArchive { - private val comicTypes = setOf(FileType.CBZ, FileType.CBR, FileType.CB7) + private val comicTypes = SharedFileCapabilities.comicArchiveTypes private val imageExtensions = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif") fun canLoad(type: FileType): Boolean = type in comicTypes @@ -36,6 +38,7 @@ internal object DesktopComicArchive { FileType.CBZ -> loadZip(file) FileType.CBR -> loadRar(file) FileType.CB7 -> loadSevenZ(file) + FileType.CBT -> loadTar(file) else -> error("${type.name} is not a comic archive type.") } } @@ -162,6 +165,31 @@ internal object DesktopComicArchive { } } + private fun loadTar(file: File): DesktopComicDocument { + val tempDir = Files.createTempDirectory("reader-comic-").toFile() + return try { + val extracted = mutableListOf() + TarArchiveInputStream(file.inputStream().buffered()).use { archive -> + var entry = archive.nextEntry + while (entry != null) { + val name = entry.name.orEmpty() + if (!entry.isDirectory && name.isComicImageName()) { + val target = File(tempDir, "page_${extracted.size}.${name.imageExtension()}") + target.outputStream().use { output -> + archive.copyTo(output) + } + extracted += ExtractedComicPage(name = name, file = target) + } + entry = archive.nextEntry + } + } + documentFromExtracted(file, extracted, tempDir) + } catch (throwable: Throwable) { + runCatching { tempDir.deleteRecursively() } + throw throwable + } + } + private fun loadWithArchiveCommand(file: File): DesktopComicDocument { val tempDir = Files.createTempDirectory("reader-comic-").toFile() return try { diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCustomFontStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt similarity index 98% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCustomFontStore.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt index 6d4aad5..54a86b9 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCustomFontStore.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.CustomFontItem +import org.dueattendant149.bookreader.shared.CustomFontItem import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt new file mode 100644 index 0000000..ded1895 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt @@ -0,0 +1,40 @@ +package org.dueattendant149.bookreader.desktop + +internal const val DesktopDiagnosticsProperty = "episteme.desktop.diagnostics" +private const val DesktopDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags" +private const val DesktopDiagnosticsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS" +private const val DesktopDiagnosticsTagsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS_TAGS" + +private val DesktopDiagnosticTags: Set = + listOfNotNull( + System.getProperty(DesktopDiagnosticsTagsProperty), + System.getenv(DesktopDiagnosticsTagsEnv) + ) + .joinToString(" ") + .split(',', ';', ' ', '\t', '\n') + .mapNotNull { rawTag -> + rawTag.trim() + .takeIf { it.isNotBlank() } + ?.lowercase() + } + .toSet() + +internal val DesktopDiagnosticsEnabled: Boolean = + desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty)) || + desktopDiagnosticsFlag(System.getenv(DesktopDiagnosticsEnv)) || + DesktopDiagnosticTags.isNotEmpty() + +internal fun desktopDiagnosticsFlag(rawValue: String?): Boolean { + return rawValue?.trim()?.equals("true", ignoreCase = true) == true +} + +private fun isDesktopDiagnosticTagEnabled(tag: String): Boolean { + if (DesktopDiagnosticTags.isEmpty()) return true + return "*" in DesktopDiagnosticTags || tag.lowercase() in DesktopDiagnosticTags +} + +internal fun logDesktopDiagnostic(tag: String, message: () -> String) { + if (DesktopDiagnosticsEnabled && isDesktopDiagnosticTagEnabled(tag)) { + println("$tag ${message()}") + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsing.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt similarity index 78% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsing.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt index cff8f9f..9884b1e 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsing.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt @@ -1,7 +1,8 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.ReaderLocator -import com.aryan.reader.shared.ui.SharedNativeReaderLinkClick +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.toStableReaderPositionCfi +import org.dueattendant149.bookreader.shared.ui.SharedNativeReaderLinkClick import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.contentOrNull @@ -46,7 +47,8 @@ internal data class DesktopEpubHandledLink( internal enum class DesktopReaderSelectionAction { DEFINE, SPEAK, - SEARCH + SEARCH, + PALETTE } internal enum class DesktopReaderKeyNavigation { @@ -59,7 +61,10 @@ internal enum class DesktopReaderKeyNavigation { EXIT_FULLSCREEN } -internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): DesktopReaderKeyNavigation? { +internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull( + fullscreen: Boolean, + rightToLeftPagination: Boolean = false +): DesktopReaderKeyNavigation? { if (id != AwtKeyEvent.KEY_PRESSED) return null if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) { return DesktopReaderKeyNavigation.EXIT_FULLSCREEN @@ -71,9 +76,17 @@ internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): return DesktopReaderKeyNavigation.NEXT_SEARCH } return when (keyCode) { - AwtKeyEvent.VK_RIGHT, + AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) { + DesktopReaderKeyNavigation.PREVIOUS + } else { + DesktopReaderKeyNavigation.NEXT + } + AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) { + DesktopReaderKeyNavigation.NEXT + } else { + DesktopReaderKeyNavigation.PREVIOUS + } AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT - AwtKeyEvent.VK_LEFT, AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST @@ -83,7 +96,8 @@ internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): internal data class DesktopReaderSelectionActionPayload( val action: DesktopReaderSelectionAction, - val text: String + val text: String, + val locator: ReaderLocator? = null ) internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? { @@ -128,9 +142,27 @@ internal fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionP "define" -> DesktopReaderSelectionAction.DEFINE "speak" -> DesktopReaderSelectionAction.SPEAK "web-search", "search" -> DesktopReaderSelectionAction.SEARCH + "palette" -> DesktopReaderSelectionAction.PALETTE else -> return@runCatching null } - DesktopReaderSelectionActionPayload(action, text) + val locator = obj["locator"] + ?.takeUnless { it is JsonNull } + ?.jsonObject + ?.let { locatorObj -> + ReaderLocator( + chapterIndex = locatorObj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + chapterId = locatorObj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + href = locatorObj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + pageIndex = locatorObj["pageIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + startOffset = locatorObj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + endOffset = locatorObj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + blockIndex = locatorObj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + charOffset = locatorObj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + textQuote = locatorObj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + cfi = locatorObj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi() + ) + } + DesktopReaderSelectionActionPayload(action, text, locator) }.getOrNull() parse(this)?.let { return it } @@ -181,11 +213,15 @@ internal fun String.readerPositionOrNull(): DesktopReaderPosition? { ?: return@runCatching null val locator = ReaderLocator( chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + href = obj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, pageIndex = pageIndex, startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + blockIndex = obj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + charOffset = obj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, - cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull + cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi() ) DesktopReaderPosition(pageIndex, locator) }.getOrNull() @@ -284,9 +320,7 @@ private fun String.readerHrefFromIntercept(): String? { val trimmed = trim() if (trimmed.isBlank()) return null if (trimmed.equals("about:blank", ignoreCase = true)) return null - if (trimmed.startsWith("file:///kcefbrowser/", ignoreCase = true)) return null - if (trimmed.startsWith("file:/kcefbrowser/", ignoreCase = true)) return null - if (trimmed.startsWith("file://", ignoreCase = true)) return null + if (trimmed.startsWith("file:/", ignoreCase = true)) return null if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}" if (trimmed.startsWith("data:", ignoreCase = true)) return null if (trimmed.startsWith("blob:", ignoreCase = true)) return null @@ -298,9 +332,13 @@ internal fun ReaderLocator.toReaderLocatorJson(): String { append("{") val values = buildList { chapterIndex?.let { add("\"chapterIndex\":$it") } + chapterId?.let { add("\"chapterId\":${it.toJsonStringLiteral()}") } + href?.let { add("\"href\":${it.toJsonStringLiteral()}") } pageIndex?.let { add("\"pageIndex\":$it") } startOffset?.let { add("\"startOffset\":$it") } endOffset?.let { add("\"endOffset\":$it") } + blockIndex?.let { add("\"blockIndex\":$it") } + charOffset?.let { add("\"charOffset\":$it") } cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") } textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") } } diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt new file mode 100644 index 0000000..e5e9e62 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt @@ -0,0 +1,12 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.reader.SharedEpubBook +import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoader +import java.io.File + +object DesktopEpubLoader { + fun load(file: File): SharedEpubBook { + return SharedJvmBookLoader.load(file, FileType.EPUB) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubPagination.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt similarity index 51% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubPagination.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt index dc0be71..7d7b5de 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubPagination.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -10,9 +10,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.reader.ReaderLayoutSignature -import com.aryan.reader.shared.reader.ReaderViewportSpec -import com.aryan.reader.shared.reader.SharedEpubBook +import org.dueattendant149.bookreader.shared.reader.ReaderLayoutSignature +import org.dueattendant149.bookreader.shared.reader.ReaderPage +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderViewportSpec +import org.dueattendant149.bookreader.shared.reader.SharedEpubBook internal data class DesktopEpubPaginationRequest( val bookId: String, @@ -28,6 +30,44 @@ internal data class DesktopEpubPaginationDensity( val fontScale: Float ) +internal fun desktopMeasuredPaginationReady( + request: DesktopEpubPaginationRequest?, + completedRequest: DesktopEpubPaginationRequest?, + currentPages: List, + measuredPages: List +): Boolean { + return request != null && + completedRequest == request && + measuredPages.isNotEmpty() && + currentPages.samePageLayoutAs(measuredPages) +} + +internal fun desktopPaginatedLayoutReadyForDisplay( + readingMode: ReaderReadingMode, + measuredPagesApplied: Boolean +): Boolean { + return readingMode != ReaderReadingMode.PAGINATED || measuredPagesApplied +} + +internal fun desktopPagesWithMeasuredChapter( + currentPages: List, + chapterIndex: Int, + measuredChapterPages: List +): List { + if (currentPages.isEmpty() || measuredChapterPages.isEmpty()) return currentPages + val firstChapterPage = currentPages.indexOfFirst { it.chapterIndex == chapterIndex } + if (firstChapterPage < 0) return currentPages + val lastChapterPage = currentPages.indexOfLast { it.chapterIndex == chapterIndex } + val combined = currentPages.take(firstChapterPage) + + measuredChapterPages + + currentPages.drop(lastChapterPage + 1) + return combined.mapIndexed { index, page -> page.copy(pageIndex = index) } +} + +internal fun List.firstPageIndexForChapter(chapterIndex: Int): Int? { + return indexOfFirst { it.chapterIndex == chapterIndex }.takeIf { it >= 0 } +} + internal fun SharedEpubBook.desktopPaginationContentSignature(): Int { return chapters.fold(31 * id.hashCode() + css.hashCode()) { acc, chapter -> 31 * acc + diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt new file mode 100644 index 0000000..8013732 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt @@ -0,0 +1,279 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.UserHighlight +import org.dueattendant149.bookreader.shared.ui.ReaderContentNavigationTarget +import kotlinx.coroutines.launch + +@Composable +internal fun DesktopEpubWebView( + html: String, + appearanceScript: String, + highlightPaletteScript: String, + navigationTarget: ReaderContentNavigationTarget, + highlights: List, + onHighlightCreated: (UserHighlight) -> Unit, + onHighlightSelected: (String) -> Unit, + isFullscreen: Boolean, + onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, + onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit, + onLinkClicked: (DesktopEpubLinkClick) -> Unit, + onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, + onPointerActivity: () -> Unit = {}, + networkAccessEnabled: Boolean, + backgroundColor: Color, + modifier: Modifier = Modifier +) { + val backend = desktopEpubWebViewBackend() + LaunchedEffect(html, networkAccessEnabled, highlights.size, navigationTarget.readingMode, backend) { + logDesktopWebView2( + "backend_selected backend=${backend.logName} htmlChars=${html.length} htmlHash=${html.hashCode()} " + + "network=$networkAccessEnabled highlights=${highlights.size} navMode=${navigationTarget.readingMode}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_selected backend=${backend.logName} htmlChars=${html.length} " + + "htmlHash=${html.hashCode()} network=$networkAccessEnabled highlights=${highlights.size} " + + "navMode=${navigationTarget.readingMode}" + } + } + DesktopNativeSwtEpubWebView( + html = html, + appearanceScript = appearanceScript, + highlightPaletteScript = highlightPaletteScript, + navigationTarget = navigationTarget, + highlights = highlights, + onHighlightCreated = onHighlightCreated, + onHighlightSelected = onHighlightSelected, + isFullscreen = isFullscreen, + onKeyboardNavigation = onKeyboardNavigation, + onSelectionAction = onSelectionAction, + onLinkClicked = onLinkClicked, + onVisiblePageChanged = onVisiblePageChanged, + onPointerActivity = onPointerActivity, + networkAccessEnabled = networkAccessEnabled, + backgroundColor = backgroundColor, + modifier = modifier + ) +} + +internal data class DesktopEpubBridgeHandler( + val methodName: String, + val onMessage: (String) -> Unit +) + +@Composable +internal fun rememberDesktopEpubBridgeHandlers( + onHighlightCreated: (UserHighlight) -> Unit, + onHighlightSelected: (String) -> Unit, + onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, + onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit, + onLinkClicked: (DesktopEpubLinkClick) -> Unit, + onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, + onPointerActivity: () -> Unit +): List { + val latestOnHighlightCreated by rememberUpdatedState(onHighlightCreated) + val latestOnHighlightSelected by rememberUpdatedState(onHighlightSelected) + val latestOnKeyboardNavigation by rememberUpdatedState(onKeyboardNavigation) + val latestOnSelectionAction by rememberUpdatedState(onSelectionAction) + val latestOnLinkClicked by rememberUpdatedState(onLinkClicked) + val latestOnVisiblePageChanged by rememberUpdatedState(onVisiblePageChanged) + val latestOnPointerActivity by rememberUpdatedState(onPointerActivity) + val scope = rememberCoroutineScope() + return remember(scope) { + listOf( + DesktopEpubBridgeHandler("readerHighlightCreated") { params -> + logEpubHighlightFlow("bridge_received method=readerHighlightCreated params=\"${params.logPreview(900)}\"") + val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(params) + if (highlight == null) { + logEpubHighlightFlow("bridge_parse_failed method=readerHighlightCreated") + logEpubSelectionDebug("highlight_parse_failed params=${params.logPreview(900)}") + } else { + logEpubHighlightFlow( + "bridge_parse_success id=${highlight.id} color=${highlight.color.id} " + + "chapter=${highlight.chapterIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + + "page=${highlight.locator.pageIndex} textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\"" + ) + logDesktopHighlightMap( + "bridge_highlight_created id=${highlight.id} color=${highlight.color.id} " + + "chapter=${highlight.chapterIndex} locatorChapter=${highlight.locator.chapterIndex} " + + "page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + + "block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " + + "chapterId=${highlight.locator.chapterId.orEmpty().logPreview()} href=${highlight.locator.href.orEmpty().logPreview()} " + + "textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\"" + ) + scope.launch { latestOnHighlightCreated(highlight) } + } + }, + DesktopEpubBridgeHandler("readerHighlightClicked") { params -> + params.readerHighlightClickOrNull()?.let { highlightClick -> + scope.launch { latestOnHighlightSelected(highlightClick.highlightId) } + } + }, + DesktopEpubBridgeHandler("readerPositionChanged") { params -> + params.readerPositionOrNull()?.let { position -> + logDesktopPositionTrace( + "event=bridge_position_changed page=${position.pageIndex} " + + "locator=${position.locator.desktopPositionTraceSummary()}" + ) + logDesktopHighlightMap( + "bridge_position_changed page=${position.pageIndex} chapter=${position.locator?.chapterIndex} " + + "offsets=${position.locator?.startOffset}..${position.locator?.endOffset} " + + "block=${position.locator?.blockIndex} char=${position.locator?.charOffset} " + + "chapterId=${position.locator?.chapterId.orEmpty().logPreview()} href=${position.locator?.href.orEmpty().logPreview()} " + + "text=\"${position.locator?.textQuote.orEmpty().logPreview(120)}\" " + + "cfi=\"${position.locator?.cfi.orEmpty().logPreview(160)}\"" + ) + logDesktopTtsStartTrace { + "event=bridge_position_changed page=${position.pageIndex} " + + "locator=${position.locator.desktopPositionTraceSummary(160)}" + } + scope.launch { latestOnVisiblePageChanged(position.pageIndex, position.locator) } + } + }, + DesktopEpubBridgeHandler("readerDesktopPositionTraceLog") { params -> + logDesktopPositionTrace(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900)) + }, + DesktopEpubBridgeHandler("readerTtsStartTraceLog") { params -> + logDesktopTtsStartTrace { params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900) } + }, + DesktopEpubBridgeHandler("readerSelectionAction") { params -> + val selectionAction = params.readerSelectionActionOrNull() + if (selectionAction != null) { + scope.launch { latestOnSelectionAction(selectionAction) } + } + }, + DesktopEpubBridgeHandler("readerKeyNavigation") { params -> + params.readerKeyNavigationOrNull()?.let { action -> + scope.launch { latestOnKeyboardNavigation(action) } + } + }, + DesktopEpubBridgeHandler("readerPointerActivity") { + scope.launch { latestOnPointerActivity() } + }, + DesktopEpubBridgeHandler("readerTtsHighlightLog") { params -> + logDesktopTts("epub_highlight_js ${params.logPreview(500)}") + }, + DesktopEpubBridgeHandler("readerSelectionDebugLog") { params -> + logEpubSelectionDebug(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900)) + }, + DesktopEpubBridgeHandler("readerHighlightFlowLog") { params -> + logEpubHighlightFlow(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900)) + }, + DesktopEpubBridgeHandler("readerDesktopHighlightMapLog") { params -> + logDesktopHighlightMap(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900)) + }, + DesktopEpubBridgeHandler("readerPaginationLayoutLog") { params -> + logEpubPagination(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900)) + }, + DesktopEpubBridgeHandler("readerGapLayoutLog") { params -> + logReaderGap(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900)) + }, + DesktopEpubBridgeHandler("readerLinkClicked") { params -> + logEpubLink("bridge_message params=\"${params.logPreview()}\"") + val link = params.readerLinkClickOrNull() + if (link == null) { + logEpubLink("bridge_message_ignored reason=parse_failed") + } else { + logEpubLink( + "bridge_message_parsed href=\"${link.href.logPreview()}\" " + + "chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\"" + ) + scope.launch { latestOnLinkClicked(link) } + } + } + ) + } +} + +internal val DesktopEpubKeyNavigationScript = """ + (function () { + if (!window.readerDesktopChromeTapInstalled) { + window.readerDesktopChromeTapInstalled = true; + var chromeTapStart = null; + var lastChromeTapNotifiedAt = 0; + function notifyChromeTap() { + if (!window.kmpJsBridge || !window.kmpJsBridge.callNative) return; + window.kmpJsBridge.callNative('readerPointerActivity', '{}'); + lastChromeTapNotifiedAt = Date.now(); + } + function chromeTapIgnored(target) { + if (!target || !target.closest) return false; + return !!target.closest( + 'a[href], button, input, textarea, select, [contenteditable="true"], #reader-selection-menu, .reader-selection-handle' + ); + } + function hasActiveReaderSelection() { + var selection = window.getSelection && window.getSelection(); + return !!selection && selection.toString().trim().length > 0; + } + function beginChromeTap(event) { + if (event.button !== undefined && event.button !== 0) return; + if (chromeTapIgnored(event.target)) { + chromeTapStart = null; + return; + } + chromeTapStart = { + pointerId: event.pointerId, + x: event.clientX || 0, + y: event.clientY || 0, + at: Date.now() + }; + } + function finishChromeTap(event) { + if (!chromeTapStart) return; + if (event.pointerId !== undefined && chromeTapStart.pointerId !== undefined && event.pointerId !== chromeTapStart.pointerId) return; + var dx = (event.clientX || 0) - chromeTapStart.x; + var dy = (event.clientY || 0) - chromeTapStart.y; + var elapsed = Date.now() - chromeTapStart.at; + chromeTapStart = null; + if ((dx * dx + dy * dy) > 64 || elapsed > 650) return; + if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return; + notifyChromeTap(); + } + function maybeNotifyChromeTapFromClick(event) { + if (Date.now() - lastChromeTapNotifiedAt < 250) return; + if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return; + notifyChromeTap(); + } + document.addEventListener('pointerdown', beginChromeTap, true); + document.addEventListener('pointerup', finishChromeTap, true); + document.addEventListener('pointercancel', function () { chromeTapStart = null; }, true); + document.addEventListener('click', function (event) { + if (window.PointerEvent) { + maybeNotifyChromeTapFromClick(event); + return; + } + beginChromeTap(event); + finishChromeTap(event); + }, true); + } + if (window.readerDesktopKeyNavigationInstalled) return; + window.readerDesktopKeyNavigationInstalled = true; + document.addEventListener('keydown', function (event) { + var target = event.target; + var tag = target && target.tagName ? target.tagName.toLowerCase() : ''; + if (target && (target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select')) return; + var action = null; + if (event.ctrlKey && (event.key === 'f' || event.key === 'F')) action = 'search'; + else if (event.ctrlKey && (event.key === 'g' || event.key === 'G')) action = 'nextSearch'; + else if (event.key === 'ArrowRight' || event.key === 'PageDown') action = 'next'; + else if (event.key === 'ArrowLeft' || event.key === 'PageUp') action = 'previous'; + else if (event.key === 'Home') action = 'first'; + else if (event.key === 'End') action = 'last'; + else if (event.key === 'Escape' && window.readerDesktopFullscreen) action = 'exitFullscreen'; + if (!action || !window.kmpJsBridge || !window.kmpJsBridge.callNative) return; + event.preventDefault(); + event.stopPropagation(); + window.kmpJsBridge.callNative('readerKeyNavigation', JSON.stringify({ action: action })); + }, true); + })(); +""".trimIndent() diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopExternalLinks.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopExternalLinks.kt similarity index 98% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopExternalLinks.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopExternalLinks.kt index b3ad451..2b8c4a9 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopExternalLinks.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopExternalLinks.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement @@ -17,7 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.readerString import java.awt.Desktop import java.net.URI import java.net.URLEncoder diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacement.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacement.kt new file mode 100644 index 0000000..a106401 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacement.kt @@ -0,0 +1,13 @@ +package org.dueattendant149.bookreader.desktop + +internal data class DesktopFeatureNoticePlacement( + val readerWindowId: String? = null +) { + fun rendersInMainWindow(): Boolean = readerWindowId == null + + fun rendersInReaderWindow(windowId: String): Boolean = readerWindowId == windowId +} + +internal fun desktopFeatureNoticePlacement(readerWindowId: String?): DesktopFeatureNoticePlacement { + return DesktopFeatureNoticePlacement(readerWindowId = readerWindowId?.takeIf { it.isNotBlank() }) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDialogs.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDialogs.kt similarity index 84% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDialogs.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDialogs.kt index da09c30..ebacf70 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDialogs.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDialogs.kt @@ -1,9 +1,9 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.ImportedBookFile -import com.aryan.reader.shared.ReaderPlatform -import com.aryan.reader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.ImportedBookFile +import org.dueattendant149.bookreader.shared.ReaderPlatform +import org.dueattendant149.bookreader.shared.SharedFileCapabilities import java.awt.FileDialog import java.awt.Frame import java.io.File @@ -77,6 +77,16 @@ internal fun chooseSaveImageFile(defaultFileName: String): File? { return File(directory, file) } +internal fun chooseSaveBookFile(defaultFileName: String): File? { + val dialog = FileDialog(null as Frame?, desktopDialogString("action_save_copy_to_device", "Save copy to device"), FileDialog.SAVE).apply { + file = defaultFileName + isVisible = true + } + val directory = dialog.directory ?: return null + val file = dialog.file ?: return null + return File(directory, file) +} + internal fun chooseFolder(): File? { val chooser = JFileChooser().apply { dialogTitle = desktopDialogString("desktop_import_folder", "Import folder") diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDropTarget.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDropTarget.kt similarity index 95% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDropTarget.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDropTarget.kt index e83341e..240ef38 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDropTarget.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDropTarget.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background @@ -20,11 +20,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import com.aryan.reader.shared.ImportedBookFile -import com.aryan.reader.shared.ReaderPlatform -import com.aryan.reader.shared.SharedFileCapabilities -import com.aryan.reader.shared.ui.readerQuantityString -import com.aryan.reader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ImportedBookFile +import org.dueattendant149.bookreader.shared.ReaderPlatform +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.ui.readerQuantityString +import org.dueattendant149.bookreader.shared.ui.readerString import java.awt.Component import java.awt.Container import java.awt.EventQueue diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFirebaseAuthRepository.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFirebaseAuthRepository.kt similarity index 89% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFirebaseAuthRepository.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFirebaseAuthRepository.kt index 0594804..89f21c4 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFirebaseAuthRepository.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFirebaseAuthRepository.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.UserData +import org.dueattendant149.bookreader.shared.UserData import com.sun.net.httpserver.HttpServer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -68,7 +68,7 @@ internal class DesktopFirebaseAuthRepository( googleAccessTokenExpiresAtEpochMillis = googleTokens.expiresAtEpochMillis ) session = nextSession - store.save(nextSession) + persistSession(nextSession) return nextSession } @@ -86,24 +86,30 @@ internal class DesktopFirebaseAuthRepository( val current = session ?: store.load()?.also { session = it } ?: return null if (current.isGoogleAccessTokenFresh) return current.googleAccessToken if (current.googleRefreshToken.isBlank()) return null - return runCatching { + val refreshed = runCatching { refreshGoogleAccessToken(current) - }.onSuccess { refreshed -> - session = refreshed - store.save(refreshed) - }.getOrNull()?.googleAccessToken + }.getOrNull() ?: return null + session = refreshed + persistSession(refreshed) + return refreshed.googleAccessToken } private suspend fun refreshSessionIfNeeded(current: DesktopAuthSession): DesktopAuthSession? { if (current.isFresh) return current - return runCatching { + val refreshed = runCatching { refreshFirebaseSession(current) - }.onSuccess { refreshed -> - session = refreshed - store.save(refreshed) }.onFailure { signOut() - }.getOrNull() + }.getOrNull() ?: return null + session = refreshed + persistSession(refreshed) + return refreshed + } + + private suspend fun persistSession(session: DesktopAuthSession) { + withContext(Dispatchers.IO) { + store.save(session) + } } private suspend fun requestGoogleOAuthCode(openUrl: (String) -> Unit): DesktopOAuthCode = withContext(Dispatchers.IO) { @@ -364,24 +370,36 @@ internal class DesktopAuthStore( } fun save(session: DesktopAuthSession) { + if (session.refreshToken.isBlank()) { + throw IllegalArgumentException("Cannot save a desktop account without a refresh token.") + } + val protectedTokens = runCatching { + ProtectedDesktopAuthTokens( + refreshToken = protectRequired(RefreshTokenKey, session.refreshToken), + googleRefreshToken = session.googleRefreshToken + .takeIf { it.isNotBlank() } + ?.let { protectRequired(GoogleRefreshTokenKey, it) } + ) + }.getOrElse { error -> + if (secretCodec.isAvailable) { + throw error + } + logDesktopCloudSync { + "desktop.auth.persist_skipped reason=secure_storage_unavailable codec=${secretCodec.name} " + + "error=\"${error.desktopTtsSummary()}\"" + } + clear() + return + } val properties = Properties().apply { setProperty("uid", session.user.uid) setProperty("displayName", session.user.displayName.orEmpty()) setProperty("photoUrl", session.user.photoUrl.orEmpty()) setProperty("email", session.user.email.orEmpty()) - runCatching { secretCodec.protect(RefreshTokenKey, session.refreshToken) } - .getOrNull() - ?.takeIf { it.isNotBlank() } - ?.let { setProperty(RefreshTokenKey, it) } - runCatching { secretCodec.protect(GoogleRefreshTokenKey, session.googleRefreshToken) } - .getOrNull() - ?.takeIf { it.isNotBlank() } - ?.let { setProperty(GoogleRefreshTokenKey, it) } - } - settingsFile.parentFile?.mkdirs() - settingsFile.outputStream().use { output -> - properties.store(output, "Episteme desktop account") + setProperty(RefreshTokenKey, protectedTokens.refreshToken) + protectedTokens.googleRefreshToken?.let { setProperty(GoogleRefreshTokenKey, it) } } + settingsFile.storePropertiesAtomically(properties, "Episteme desktop account") } fun clear() { @@ -394,6 +412,19 @@ internal class DesktopAuthStore( const val RefreshTokenKey = "firebaseRefreshTokenProtected" const val GoogleRefreshTokenKey = "googleRefreshTokenProtected" } + + private data class ProtectedDesktopAuthTokens( + val refreshToken: String, + val googleRefreshToken: String? + ) + + private fun protectRequired(keyName: String, value: String): String { + val protectedValue = secretCodec.protect(keyName, value) + if (protectedValue.isBlank()) { + throw IllegalStateException("Desktop secure key storage returned an empty value for $keyName.") + } + return protectedValue + } } private val DesktopAuthJson = Json { ignoreUnknownKeys = true } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractor.kt similarity index 97% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractor.kt index 77e5f27..7a1a4bb 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractor.kt @@ -1,10 +1,10 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.ReaderPlatform -import com.aryan.reader.shared.SharedFileCapabilities -import com.aryan.reader.shared.reader.SharedJvmBookLoader +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.ReaderPlatform +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoader import java.awt.Color import java.awt.Font import java.awt.GradientPaint @@ -59,6 +59,16 @@ object DesktopFolderMetadataExtractor { return enrichBooks(books) { book -> book.sourceFolder == sourceFolder } } + fun enrichFolderBooks( + books: List, + sourceFolders: Set + ): DesktopFolderMetadataExtractionResult { + if (sourceFolders.isEmpty()) { + return DesktopFolderMetadataExtractionResult(books) + } + return enrichBooks(books) { book -> book.sourceFolder in sourceFolders } + } + fun enrichImportedBooks( books: List, importedBookIds: Set @@ -465,7 +475,7 @@ object DesktopFolderMetadataExtractor { return when (type) { FileType.PDF -> Color(156, 65, 70) FileType.EPUB -> Color(0, 108, 76) - FileType.CBZ, FileType.CBR, FileType.CB7 -> Color(112, 93, 73) + FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT -> Color(112, 93, 73) FileType.MD -> Color(83, 101, 120) FileType.HTML -> Color(122, 87, 42) FileType.TXT -> Color(74, 92, 112) diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncFeedback.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncFeedback.kt new file mode 100644 index 0000000..e612816 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncFeedback.kt @@ -0,0 +1,16 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.SharedReaderScreenState + +internal fun desktopFolderSyncCompletedState( + state: SharedReaderScreenState, + message: String, + failedFolderCount: Int, + showBanner: Boolean +): SharedReaderScreenState { + return if (showBanner) { + state.withBanner(message, isError = failedFolderCount > 0) + } else { + state + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderSyncLog.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncLog.kt similarity index 92% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderSyncLog.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncLog.kt index 818f43d..44149df 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderSyncLog.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncLog.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop private const val DesktopFolderSyncLogTag = "EpistemeFolderSync" diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopGeminiCloudTtsAdapter.kt similarity index 86% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopGeminiCloudTtsAdapter.kt index 30923d1..2cba6df 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopGeminiCloudTtsAdapter.kt @@ -1,15 +1,15 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL -import com.aryan.reader.shared.ReaderAiByokSettings -import com.aryan.reader.shared.ReaderTtsCacheSummary -import com.aryan.reader.shared.ReaderTtsChunk -import com.aryan.reader.shared.ReaderTtsFileCacheManager -import com.aryan.reader.shared.ReaderTtsReadScope -import com.aryan.reader.shared.TtsAdapter -import com.aryan.reader.shared.createReaderTtsWavHeaderUnknownLength -import com.aryan.reader.shared.patchReaderTtsWavHeader -import com.aryan.reader.shared.splitReaderTextIntoTtsChunks +import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.ReaderTtsCacheSummary +import org.dueattendant149.bookreader.shared.ReaderTtsChunk +import org.dueattendant149.bookreader.shared.ReaderTtsFileCacheManager +import org.dueattendant149.bookreader.shared.ReaderTtsReadScope +import org.dueattendant149.bookreader.shared.TtsAdapter +import org.dueattendant149.bookreader.shared.createReaderTtsWavHeaderUnknownLength +import org.dueattendant149.bookreader.shared.patchReaderTtsWavHeader +import org.dueattendant149.bookreader.shared.splitReaderTextIntoTtsChunks import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive @@ -33,6 +33,7 @@ import java.net.URI import java.net.URLEncoder import java.net.http.HttpClient import java.net.http.WebSocket +import java.net.http.WebSocketHandshakeException import java.nio.ByteBuffer import java.util.Base64 import java.util.concurrent.CompletableFuture @@ -56,7 +57,7 @@ class DesktopGeminiCloudTtsAdapter( private val networkAccess: () -> Boolean = { true }, private val workerUrlProvider: () -> String = { "" }, private val authTokenProvider: suspend () -> String? = { null }, - private val useWorkerProvider: () -> Boolean = { false }, + private val useWorkerProvider: () -> Boolean = { true }, private val onWorkerUsageCompleted: suspend () -> Unit = {}, httpClient: HttpClient? = null, private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot()) @@ -75,14 +76,15 @@ class DesktopGeminiCloudTtsAdapter( @Volatile private var activePlayer: DesktopStreamingPcmPlayer? = null + val isPlaybackActive: Boolean + get() = activePlayer != null || activeWebSocket != null || activeLine != null + override val isAvailable: Boolean get() { val settings = settingsProvider().sanitized() - return networkAccess() && if (useWorkerProvider()) { - settings.serverBackedCloudTts && workerUrlProvider().isNotBlank() - } else { - settings.isByokCloudTtsAvailable - } + return networkAccess() && + (settings.isByokCloudTtsAvailable || + (useWorkerProvider() && settings.serverBackedCloudTts && workerUrlProvider().isNotBlank())) } override suspend fun speak(text: String) { @@ -125,6 +127,12 @@ class DesktopGeminiCloudTtsAdapter( ) } .filter { it.text.isNotBlank() } + logDesktopTtsStartTrace { + "event=adapter_speak_chunks book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " + + "inputChunks=${chunks.size} sequenceChunks=${sequenceChunks.size} " + + "inputFirst=${chunks.firstOrNull().desktopTtsStartTraceSummary(160)} " + + "sequenceFirstText=\"${sequenceChunks.firstOrNull()?.text.orEmpty().desktopTtsPreview(180)}\"" + } logDesktopTts( "chunk_sequence_speak_start book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " + "chunks=${sequenceChunks.size} totalTextChars=${sequenceChunks.sumOf { it.text.length }}" @@ -182,28 +190,27 @@ class DesktopGeminiCloudTtsAdapter( onChunkStart: suspend (Int) -> Unit ) = withContext(Dispatchers.IO) { val settings = settingsProvider().sanitized() - val useWorker = useWorkerProvider() - val authToken = if (useWorker) authTokenProvider() else null + val useWorker = useWorkerProvider() && !settings.isByokCloudTtsAvailable val totalTextChars = chunks.sumOf { it.text.length } logDesktopTts( "stream_start book=\"${bookTitle.desktopTtsPreview()}\" chunks=${chunks.size} totalTextChars=$totalTextChars keyPresent=${settings.geminiKey.isNotBlank()} " + "ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\" " + - "available=${settings.isCloudTtsAvailable} worker=$useWorker" + "available=${settings.isCloudTtsAvailable} serverBacked=${settings.serverBackedCloudTts} worker=$useWorker" ) if (!networkAccess()) { logDesktopTts("stream_blocked reason=network_disabled") throw IllegalStateException("Cloud TTS is unavailable in this desktop build.") } - if (!settings.isCloudTtsAvailable) { - logDesktopTts("stream_blocked reason=not_available") - throw IllegalStateException( - if (useWorker) { - "Cloud TTS needs a signed-in account with credits." - } else { - "Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected." - } - ) + if (useWorker) { + if (!settings.serverBackedCloudTts || workerUrlProvider().isBlank()) { + logDesktopTts("stream_blocked reason=server_backed_not_available") + throw IllegalStateException("Cloud TTS needs a signed-in account with credits.") + } + } else if (!settings.isByokCloudTtsAvailable) { + logDesktopTts("stream_blocked reason=byok_not_available") + throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.") } + val authToken = if (useWorker) authTokenProvider() else null if (useWorker && authToken.isNullOrBlank()) { logDesktopTts("stream_blocked reason=missing_auth_token") throw IllegalStateException("Sign in with Google to use cloud TTS.") @@ -220,6 +227,7 @@ class DesktopGeminiCloudTtsAdapter( val messageBuffer = StringBuilder() var webSocket: WebSocket? = null var activeTempCacheFile: File? = null + var workerGeneratedAudio = false fun handleMessage(message: String) { handleGeminiTtsMessage( @@ -329,7 +337,7 @@ class DesktopGeminiCloudTtsAdapter( .get(15, TimeUnit.SECONDS) }.getOrElse { error -> logDesktopTts("ws_connect_failed error=\"${error.desktopTtsSummary()}\"") - throw error + throw IllegalStateException(desktopTtsConnectionMessage(error), error) } activeWebSocket = connectedWebSocket webSocket = connectedWebSocket @@ -361,6 +369,10 @@ class DesktopGeminiCloudTtsAdapter( currentTurnAudioBytesReceived.set(0) currentTurnComplete.set(turnComplete) logDesktopTts("sequence_turn_start index=${index + 1}/${chunks.size} textChars=${text.length}") + logDesktopTtsStartTrace { + "event=adapter_turn_start index=${index + 1}/${chunks.size} chapter=\"${chunk.chapterTitle.orEmpty().desktopTtsPreview()}\" " + + "textChars=${text.length} text=\"${text.desktopTtsPreview(220)}\"" + } withContext(callbackContext) { onChunkStart(index) } @@ -420,6 +432,10 @@ class DesktopGeminiCloudTtsAdapter( logDesktopTts("stream_failed reason=empty_turn_audio index=${index + 1}/${chunks.size}") throw IllegalStateException("Cloud TTS returned no audio for a text chunk.") } + if (useWorker) { + workerGeneratedAudio = true + onWorkerUsageCompleted() + } activeCacheOutput.getAndSet(null)?.close() runCatching { patchReaderTtsWavHeader(tempCacheFile, turnAudioBytes.toInt()) @@ -454,8 +470,15 @@ class DesktopGeminiCloudTtsAdapter( activeWebSocket = null activePlayer = null logDesktopTts("stream_complete chunks=${chunks.size} audioBytes=${audioBytesReceived.get()}") - if (useWorker) onWorkerUsageCompleted() + if (useWorker && workerGeneratedAudio) onWorkerUsageCompleted() } catch (error: Throwable) { + if (useWorker && desktopTtsShouldRefreshAccountAfterError(error)) { + try { + onWorkerUsageCompleted() + } catch (_: Throwable) { + // Keep the original TTS failure as the visible error. + } + } currentTurnComplete.set(null) activeCacheOutput.getAndSet(null)?.let { output -> runCatching { output.close() } } activeTempCacheFile?.delete() @@ -629,6 +652,39 @@ private fun ByteArray.upsample16BitMonoLe2x(): ByteArray { return output } +private fun desktopTtsConnectionMessage(error: Throwable): String { + val causes = generateSequence(error) { it.cause }.toList() + val handshake = causes.filterIsInstance().firstOrNull() + return when (handshake?.response?.statusCode()) { + 401 -> "Sign in again to use cloud TTS." + 402 -> "Out of credits. Pro and credits can only be purchased from the Android app." + 403 -> "Cloud TTS is unavailable for this account." + 405 -> "Cloud TTS is not configured for this desktop build." + 426 -> "Cloud TTS is not configured for this desktop build." + 502 -> "Cloud TTS service is temporarily unavailable." + else -> { + val details = causes + .joinToString(" ") { it.message.orEmpty() } + .trim() + when { + details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) -> + "Out of credits. Pro and credits can only be purchased from the Android app." + details.contains("401") || details.contains("Unauthorized", ignoreCase = true) -> + "Sign in again to use cloud TTS." + else -> "Cloud TTS failed to connect." + } + } + } +} + +private fun desktopTtsShouldRefreshAccountAfterError(error: Throwable): Boolean { + val details = generateSequence(error) { it.cause } + .joinToString(" ") { it.message.orEmpty() } + return details.contains("Out of credits", ignoreCase = true) || + details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) || + details.contains("402", ignoreCase = true) +} + private class DesktopStreamingPcmPlayer( private val onLineChanged: (SourceDataLine?) -> Unit ) { @@ -742,7 +798,6 @@ private class DesktopStreamingPcmPlayer( openLine(24_000f) }.onFailure { secondError -> logDesktopTts("play_fallback_failed sampleRate=24000 error=\"${secondError.desktopTtsSummary()}\"") - secondError.printStackTrace() }.getOrElse { throw firstError } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLanguageSettings.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLanguageSettings.kt similarity index 97% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLanguageSettings.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLanguageSettings.kt index d8d9bc2..71f3cf2 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLanguageSettings.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLanguageSettings.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement @@ -22,7 +22,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.readerString import java.io.File import java.util.Properties @@ -58,7 +58,8 @@ internal val DesktopLanguageOptions = listOf( DesktopLanguageOption("zh-CN", "language_chinese_simplified", "Chinese, Simplified"), DesktopLanguageOption("nl", "language_dutch", "Dutch"), DesktopLanguageOption("uk", "language_ukrainian", "Ukrainian"), - DesktopLanguageOption("id", "language_indonesian", "Indonesian") + DesktopLanguageOption("id", "language_indonesian", "Indonesian"), + DesktopLanguageOption("et", "language_estonian", "Estonian") ) internal fun selectedDesktopLanguageOption(languageTag: String?): DesktopLanguageOption { diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabase.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabase.kt new file mode 100644 index 0000000..1b087c9 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabase.kt @@ -0,0 +1,47 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot +import org.dueattendant149.bookreader.shared.SharedLibrarySnapshotJson +import java.io.File +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject + +class DesktopLibraryDatabase( + private val databaseFile: File = defaultDatabaseFile() +) { + fun load(): SharedLibrarySnapshot { + return loadFile(databaseFile) + ?: loadFile(backupFile()) + ?: SharedLibrarySnapshot() + } + + fun save(snapshot: SharedLibrarySnapshot) { + val encoded = SharedLibrarySnapshotJson.encode(snapshot) + databaseFile.writeTextAtomically(encoded) + runCatching { + backupFile().writeTextAtomically(encoded) + } + } + + private fun loadFile(file: File): SharedLibrarySnapshot? { + if (!file.isFile) return null + val raw = runCatching { file.readText() }.getOrNull() ?: return null + val isJsonObject = runCatching { + libraryDatabaseJson.parseToJsonElement(raw).jsonObject + }.isSuccess + if (!isJsonObject) return null + return SharedLibrarySnapshotJson.decodeOrEmpty(raw) + } + + private fun backupFile(): File { + return File(databaseFile.parentFile ?: File("."), "${databaseFile.name}.bak") + } + + companion object { + fun defaultDatabaseFile(): File { + return File(desktopUserDataRoot(), "library.json") + } + } +} + +private val libraryDatabaseJson = Json { ignoreUnknownKeys = true } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryUi.kt similarity index 80% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryUi.kt index 086b58d..b93b635 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryUi.kt @@ -1,5 +1,6 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,34 +20,36 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.AppAction -import com.aryan.reader.shared.BannerMessage -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.ReaderPlatform -import com.aryan.reader.shared.SharedFolderPathResolver -import com.aryan.reader.shared.SharedReaderScreenState -import com.aryan.reader.shared.Shelf -import com.aryan.reader.shared.SmartCollectionDefinition -import com.aryan.reader.shared.SmartField -import com.aryan.reader.shared.SmartOperator -import com.aryan.reader.shared.SmartRule -import com.aryan.reader.shared.Tag -import com.aryan.reader.shared.reader.ReaderSettings -import com.aryan.reader.shared.reduce -import com.aryan.reader.shared.ui.NonReaderLibraryTab -import com.aryan.reader.shared.ui.SharedLibraryScreen -import com.aryan.reader.shared.ui.SharedShelvesScreen -import com.aryan.reader.shared.ui.SharedStableOutlinedTextField -import com.aryan.reader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.AppAction +import org.dueattendant149.bookreader.shared.BannerMessage +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.ReaderPlatform +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.SharedFolderPathResolver +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.Shelf +import org.dueattendant149.bookreader.shared.SmartCollectionDefinition +import org.dueattendant149.bookreader.shared.SmartField +import org.dueattendant149.bookreader.shared.SmartOperator +import org.dueattendant149.bookreader.shared.SmartRule +import org.dueattendant149.bookreader.shared.Tag +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reduce +import org.dueattendant149.bookreader.shared.ui.NonReaderLibraryTab +import org.dueattendant149.bookreader.shared.ui.SharedLibraryScreen +import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField +import org.dueattendant149.bookreader.shared.ui.readerString import java.io.File internal fun BookItem.hasEmbeddedMetadataChange(updated: BookItem): Boolean { @@ -61,6 +64,24 @@ internal fun String.toDesktopSafeFileName(): String { return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" } } +internal fun BookItem.desktopSuggestedOriginalFileName(): String { + val extension = path + ?.let(::File) + ?.extension + ?.takeIf { it.isNotBlank() } + ?: SharedFileCapabilities.primaryExtensionFor(type) + val safeName = displayName + .takeIf { it.isNotBlank() } + ?: title?.takeIf { it.isNotBlank() } + ?: "book" + val sanitized = safeName.toDesktopSafeFileName() + return if (extension != null && !sanitized.endsWith(".$extension", ignoreCase = true)) { + "$sanitized.$extension" + } else { + sanitized + } +} + internal fun BookItem.withDesktopImportMetadata( enriched: BookItem, original: BookItem? @@ -117,82 +138,62 @@ internal fun resolvedDesktopReaderSettings( @Composable internal fun DesktopReaderOpeningScreen( - opening: DesktopReaderOpening + opening: DesktopReaderOpening, + readerSettings: ReaderSettings? = null ) { + LaunchedEffect(opening.requestId) { + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_opening_screen_composed") + } + } + val background = readerSettings?.desktopOpeningBackgroundColor() ?: MaterialTheme.colorScheme.background + val foreground = readerSettings?.desktopOpeningForegroundColor() ?: MaterialTheme.colorScheme.onBackground Box( - modifier = Modifier.fillMaxSize().padding(32.dp), + modifier = Modifier + .fillMaxSize() + .background(background) + .padding(32.dp), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - CircularProgressIndicator() + CircularProgressIndicator(color = foreground) Text( text = readerString("desktop_opening_title", "Opening %1\$s", opening.title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, + color = foreground, textAlign = TextAlign.Center ) Text( text = opening.formatLabel, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = foreground.copy(alpha = 0.72f), textAlign = TextAlign.Center ) } } } -@Composable -internal fun HomeScreen( - state: SharedReaderScreenState, - selectedLibraryTab: NonReaderLibraryTab, - onLibraryTabChange: (NonReaderLibraryTab) -> Unit, - onStateChange: (SharedReaderScreenState) -> Unit, - onImportBooks: () -> Unit, - onImportFolder: () -> Unit, - onRead: (BookItem) -> Unit, - onSelect: (String) -> Unit, - onClearSelection: () -> Unit, - onRemoveSelected: () -> Unit, - onShowBookInfo: (BookItem) -> Unit, - onEditBook: (BookItem) -> Unit, - onCreateShelf: () -> Unit, - onCreateSmartShelf: () -> Unit, - onRenameShelf: (Shelf) -> Unit, - onDeleteShelf: (Shelf) -> Unit, - onRemoveFolder: (Shelf) -> Unit, - onTagSelectedBooks: () -> Unit, - onAddSelectedBooksToShelf: () -> Unit, - onSyncFolderMetadata: () -> Unit, - onScanFolders: () -> Unit, - onTogglePinned: (BookItem) -> Unit -) { - LibraryScreen( - state = state, - selectedLibraryTab = selectedLibraryTab, - onLibraryTabChange = onLibraryTabChange, - onStateChange = onStateChange, - onImportBooks = onImportBooks, - onImportFolder = onImportFolder, - onRead = onRead, - onSelect = onSelect, - onClearSelection = onClearSelection, - onRemoveSelected = onRemoveSelected, - onShowBookInfo = onShowBookInfo, - onEditBook = onEditBook, - onCreateShelf = onCreateShelf, - onCreateSmartShelf = onCreateSmartShelf, - onRenameShelf = onRenameShelf, - onDeleteShelf = onDeleteShelf, - onRemoveFolder = onRemoveFolder, - onTagSelectedBooks = onTagSelectedBooks, - onAddSelectedBooksToShelf = onAddSelectedBooksToShelf, - onSyncFolderMetadata = onSyncFolderMetadata, - onScanFolders = onScanFolders, - onTogglePinned = onTogglePinned - ) +private fun ReaderSettings.desktopOpeningBackgroundColor(): Color { + return backgroundColorArgb?.toDesktopOpeningComposeColor() + ?: if (darkMode) Color(0xFF171A17) else Color(0xFFFFFCF5) +} + +private fun ReaderSettings.desktopOpeningForegroundColor(): Color { + return textColorArgb?.toDesktopOpeningComposeColor() + ?: if (darkMode) Color(0xFFE7E3D8) else Color(0xFF24231F) +} + +private fun Long.toDesktopOpeningComposeColor(): Color { + val value = this and 0xFFFFFFFFL + val alpha = ((value shr 24) and 0xFF) / 255f + val red = ((value shr 16) and 0xFF) / 255f + val green = ((value shr 8) and 0xFF) / 255f + val blue = (value and 0xFF) / 255f + return Color(red = red, green = green, blue = blue, alpha = alpha.takeIf { it > 0f } ?: 1f) } @Composable @@ -209,16 +210,20 @@ internal fun LibraryScreen( onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, onCreateShelf: () -> Unit, + onCreateShelfWithBooks: (String, Set) -> Unit, onCreateSmartShelf: () -> Unit, onRenameShelf: (Shelf) -> Unit, onDeleteShelf: (Shelf) -> Unit, onRemoveFolder: (Shelf) -> Unit, onTagSelectedBooks: () -> Unit, onAddSelectedBooksToShelf: () -> Unit, + onAddBooksToShelf: (Set) -> Unit, + onManageShelfBooks: (Shelf) -> Unit, onImportFolder: () -> Unit, onSyncFolderMetadata: () -> Unit, onScanFolders: () -> Unit, - onTogglePinned: (BookItem) -> Unit + onTogglePinned: (BookItem) -> Unit, + onSaveOriginalFile: (BookItem) -> Unit = {} ) { SharedLibraryScreen( state = state, @@ -233,54 +238,25 @@ internal fun LibraryScreen( onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, onCreateShelf = onCreateShelf, + onCreateShelfWithBooks = onCreateShelfWithBooks, onCreateSmartShelf = onCreateSmartShelf, onRenameShelf = onRenameShelf, onDeleteShelf = onDeleteShelf, onRemoveFolder = onRemoveFolder, onTagSelectedBooks = onTagSelectedBooks, onAddSelectedBooksToShelf = onAddSelectedBooksToShelf, + onAddBooksToShelf = onAddBooksToShelf, + onManageShelfBooks = onManageShelfBooks, onImportFolder = onImportFolder, onSyncFolderMetadata = onSyncFolderMetadata, onScanFolders = onScanFolders, onTogglePinned = onTogglePinned, + onSaveOriginalFile = onSaveOriginalFile, platform = ReaderPlatform.DESKTOP, useImportEmptyStateWhenLibraryEmpty = true ) } -@Composable -internal fun ShelvesScreen( - shelves: List, - selectedBookIds: Set, - pinnedBookIds: Set, - onRead: (BookItem) -> Unit, - onSelect: (String) -> Unit, - onShowBookInfo: (BookItem) -> Unit, - onEditBook: (BookItem) -> Unit, - onTogglePinned: (BookItem) -> Unit, - onCreateShelf: () -> Unit, - onCreateSmartShelf: () -> Unit, - onRenameShelf: (Shelf) -> Unit, - onDeleteShelf: (Shelf) -> Unit, - onRemoveFolder: (Shelf) -> Unit -) { - SharedShelvesScreen( - shelves = shelves, - selectedBookIds = selectedBookIds, - pinnedBookIds = pinnedBookIds, - onOpenBook = onRead, - onToggleSelection = onSelect, - onShowBookInfo = onShowBookInfo, - onEditBook = onEditBook, - onTogglePinned = onTogglePinned, - onCreateShelf = onCreateShelf, - onCreateSmartShelf = onCreateSmartShelf, - onRenameShelf = onRenameShelf, - onDeleteShelf = onDeleteShelf, - onRemoveFolder = onRemoveFolder - ) -} - private data class DesktopSmartRuleDraft( val field: SmartField = SmartField.TITLE, val operator: SmartOperator = SmartOperator.CONTAINS, diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSync.kt similarity index 81% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSync.kt index d99fc9e..9c078af 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSync.kt @@ -1,29 +1,30 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.BookShelfRef -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.LOCAL_FOLDER_ANNOTATION_SUFFIX -import com.aryan.reader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX -import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR -import com.aryan.reader.shared.LocalFolderSyncEngine -import com.aryan.reader.shared.LocalFolderSyncStats -import com.aryan.reader.shared.ReaderPlatform -import com.aryan.reader.shared.SharedFileCapabilities -import com.aryan.reader.shared.SharedFolderBookMetadata -import com.aryan.reader.shared.SharedFolderScannedFile -import com.aryan.reader.shared.SharedReaderScreenState -import com.aryan.reader.shared.SyncedFolder -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 com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer -import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec -import com.aryan.reader.shared.pdf.SharedPdfRichTextLog -import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer -import com.aryan.reader.shared.toSharedFolderBookMetadata +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.BookShelfRef +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_ANNOTATION_SUFFIX +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.LocalFolderSyncEngine +import org.dueattendant149.bookreader.shared.LocalFolderSyncStats +import org.dueattendant149.bookreader.shared.ReaderPlatform +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.SharedFolderBookMetadata +import org.dueattendant149.bookreader.shared.SharedFolderScannedFile +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.SyncedFolder +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 org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextLog +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer +import org.dueattendant149.bookreader.shared.toSharedFolderBookMetadata +import org.dueattendant149.bookreader.shared.toStablePositionCfi import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -46,7 +47,8 @@ data class DesktopLocalFolderSyncResult( val metadataStats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats(), val idMigrations: Map = emptyMap(), val removedBookIds: Set = emptySet(), - val failedFolders: List = emptyList() + val failedFolders: List = emptyList(), + val processedFolderUris: List = emptyList() ) object DesktopLocalFolderSync { @@ -56,10 +58,18 @@ object DesktopLocalFolderSync { if (!folder.isDirectory) return false return folder.walkTopDown() .onEnter { it == folder || it.shouldEnterSyncedFolder() } + .onFail { file, error -> + logDesktopFolderSync( + "folder.supportedFiles.skipInaccessible path=\"${file.absolutePath.folderSyncPreview()}\" " + + "error=${error.folderSyncSummary()}" + ) + } .any { file -> - file.isFile && - file.shouldSyncBookFile() && - SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes + runCatching { + file.isFile && + file.shouldSyncBookFile() && + SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes + }.getOrDefault(false) } } @@ -68,9 +78,11 @@ object DesktopLocalFolderSync { shelfRefs: List, targetFolder: File? = null, nowMillis: Long = System.currentTimeMillis(), - metadataOnly: Boolean = false + metadataOnly: Boolean = false, + extractMetadata: Boolean = true ): DesktopLocalFolderSyncResult { val requestedFolders = foldersToSync(state, targetFolder, nowMillis) + .filter { it.localSyncEnabled } val mode = if (metadataOnly) "metadata" else "full" logDesktopFolderSync( "sync.start mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " + @@ -84,6 +96,7 @@ object DesktopLocalFolderSync { val allMigrations = linkedMapOf() val allRemovedBookIds = linkedSetOf() val failedFolders = mutableListOf() + val processedFolderUris = mutableListOf() requestedFolders.forEach { folder -> val root = File(folder.uriString) @@ -95,6 +108,7 @@ object DesktopLocalFolderSync { failedFolders += folder.name return@forEach } + processedFolderUris += folder.uriString logDesktopFolderSync( "folder.start mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + @@ -138,8 +152,27 @@ object DesktopLocalFolderSync { logDesktopFolderSync( "folder.sidecars.importCheck mode=$mode name=\"${folder.name.folderSyncPreview()}\" books=${syncedBooks.size}" ) - importAnnotationSidecars(root, syncedBooks) - if (!metadataOnly) { + runCatching { + importAnnotationSidecars(root, syncedBooks) + }.onFailure { error -> + logDesktopFolderSync( + "annotation.import.failed mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + + "root=\"${root.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}" + ) + } + syncedBooks.forEach { book -> + remoteMetadata[book.id]?.let { metadata -> + runCatching { + importDesktopPdfBookmarksMetadata(book, metadata.bookmarksJson, metadata.lastModifiedTimestamp) + }.onFailure { error -> + logDesktopFolderSync( + "metadata.bookmarks.importFailed book=${book.id} " + + "error=${error.folderSyncSummary()}" + ) + } + } + } + if (!metadataOnly && extractMetadata) { val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks( books = nextState.rawLibraryBooks, sourceFolder = folder.uriString @@ -173,7 +206,8 @@ object DesktopLocalFolderSync { metadataStats = totalMetadataStats, idMigrations = allMigrations, removedBookIds = allRemovedBookIds, - failedFolders = failedFolders + failedFolders = failedFolders, + processedFolderUris = processedFolderUris ) logDesktopFolderSync( "sync.done mode=$mode failed=${failedFolders.size} new=${totalStats.newBooks} " + @@ -188,8 +222,13 @@ object DesktopLocalFolderSync { savePdfAnnotationSidecar(book) } + fun deleteSyncDataFolder(root: File): Boolean { + val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR) + return !syncDir.exists() || syncDir.isDirectory && syncDir.deleteRecursively() + } + fun saveBookMetadata(book: BookItem) { - val metadata = book.toSharedFolderBookMetadata() + val metadata = book.toDesktopFolderBookMetadata() if (metadata == null) { logDesktopFolderSync( "metadata.export.skipClean book=${book.id} title=\"${book.title.orEmpty().folderSyncPreview()}\" " + @@ -242,11 +281,9 @@ object DesktopLocalFolderSync { val data = buildMap { if (annotationFile.isFile) { val annotationJson = annotationFile.readText().trim() - val annotations = SharedPdfAnnotationSerializer.decode(annotationJson) - put( - SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, - SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations) - ) + desktopPdfAnnotationElementForSync(annotationJson)?.let { annotations -> + put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations) + } } if (bookmarkFile.isFile) { val bookmarksJson = bookmarkFile.readText().trim() @@ -267,7 +304,7 @@ object DesktopLocalFolderSync { "file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length} " + "textLen=${richTextDocument.text.length} spans=${richTextDocument.spans.size}" ) - put("text", SharedPdfRichTextSerializer.encodeElement(richTextDocument)) + desktopPdfRichTextElementForSync(richTextJson)?.let { put("text", it) } } } } @@ -279,9 +316,9 @@ object DesktopLocalFolderSync { return } val timestamp = maxOf( - annotationFile.lastModifiedIfFile(), + annotationFile.lastModifiedIfSyncableAnnotations(), bookmarkFile.lastModifiedIfFile(), - richTextFile.lastModifiedIfFile(), + richTextFile.lastModifiedIfSyncableRichText(), System.currentTimeMillis() ) val dataJson = desktopFolderSyncJson.encodeToString( @@ -329,7 +366,15 @@ object DesktopLocalFolderSync { val rootPath = root.toPath().toAbsolutePath().normalize() return root.walkTopDown() .onEnter { it == root || it.shouldEnterSyncedFolder() } - .filter { it.isFile && it.shouldSyncBookFile() } + .onFail { file, error -> + logDesktopFolderSync( + "folder.scan.skipInaccessible root=\"${root.absolutePath.folderSyncPreview()}\" " + + "path=\"${file.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}" + ) + } + .filter { file -> + runCatching { file.isFile && file.shouldSyncBookFile() }.getOrDefault(false) + } .mapNotNull { file -> val type = SharedFileCapabilities.fileTypeForName(file.name) .takeIf { it in desktopSyncableTypes } @@ -344,8 +389,8 @@ object DesktopLocalFolderSync { sourceFolder = sourceFolder, relativePath = relativePath, type = type, - size = file.length(), - lastModified = file.lastModified() + size = runCatching { file.length() }.getOrDefault(0L), + lastModified = runCatching { file.lastModified() }.getOrDefault(0L) ) } .toList() @@ -559,13 +604,18 @@ object DesktopLocalFolderSync { } if (sidecar.data.hasPdfAnnotationPayload()) { val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(sidecar.data) - annotationFile.parentFile?.mkdirs() - annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) - annotationFile.setLastModified(sidecar.timestamp) - logDesktopFolderSync( - "annotation.import.writeAnnotations book=${book.id} count=${annotations.size} " + - "file=\"${annotationFile.absolutePath.folderSyncPreview()}\"" - ) + if (annotations.isEmpty()) { + if (annotationFile.isFile) annotationFile.delete() + logDesktopFolderSync("annotation.import.deleteEmptyAnnotations book=${book.id}") + } else { + annotationFile.parentFile?.mkdirs() + annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) + annotationFile.setLastModified(sidecar.timestamp) + logDesktopFolderSync( + "annotation.import.writeAnnotations book=${book.id} count=${annotations.size} " + + "file=\"${annotationFile.absolutePath.folderSyncPreview()}\"" + ) + } } sidecar.data["bookmarks"]?.let { bookmarks -> bookmarkFile.parentFile?.mkdirs() @@ -582,13 +632,18 @@ object DesktopLocalFolderSync { "textLen=${richDocument.text.length} spans=${richDocument.spans.size} " + "file=\"${richTextFile.absolutePath.richSyncPreview()}\"" ) - richTextFile.parentFile?.mkdirs() - richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument)) - richTextFile.setLastModified(sidecar.timestamp) - logDesktopFolderSync( - "annotation.import.writeText book=${book.id} textLen=${richDocument.text.length} " + - "spans=${richDocument.spans.size} file=\"${richTextFile.absolutePath.folderSyncPreview()}\"" - ) + if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) { + if (richTextFile.isFile) richTextFile.delete() + logDesktopFolderSync("annotation.import.deleteEmptyText book=${book.id}") + } else { + richTextFile.parentFile?.mkdirs() + richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument)) + richTextFile.setLastModified(sidecar.timestamp) + logDesktopFolderSync( + "annotation.import.writeText book=${book.id} textLen=${richDocument.text.length} " + + "spans=${richDocument.spans.size} file=\"${richTextFile.absolutePath.folderSyncPreview()}\"" + ) + } } } } @@ -814,6 +869,56 @@ private fun File.lastModifiedIfFile(): Long { return if (isFile) lastModified() else 0L } +private fun File.hasSyncablePdfAnnotations(): Boolean { + return isFile && desktopPdfAnnotationElementForSync(readText()) != null +} + +private fun File.lastModifiedIfSyncableAnnotations(): Long { + return if (hasSyncablePdfAnnotations()) lastModified() else 0L +} + +private fun File.hasSyncablePdfRichText(): Boolean { + return isFile && desktopPdfRichTextElementForSync(readText()) != null +} + +private fun File.lastModifiedIfSyncableRichText(): Long { + return if (hasSyncablePdfRichText()) lastModified() else 0L +} + +private fun BookItem.toDesktopFolderBookMetadata(): SharedFolderBookMetadata? { + val base = toSharedFolderBookMetadata() + val pdfBookmarksJson = desktopPdfBookmarksMetadataJson(this) + if (base == null && pdfBookmarksJson == null) return null + + val timestamp = maxOf( + base?.lastModifiedTimestamp ?: 0L, + desktopPdfBookmarkMetadataTimestamp(this), + this.timestamp + ) + + return (base ?: SharedFolderBookMetadata( + bookId = id, + title = null, + author = null, + displayName = displayName, + type = type.name, + lastChapterIndex = readerPosition?.chapterIndex, + lastPage = readerPosition?.pageIndex ?: lastPageIndex, + lastPositionCfi = readerPosition?.toStablePositionCfi(), + progressPercentage = progressPercentage ?: 0f, + isRecent = isRecent, + lastModifiedTimestamp = timestamp, + bookmarksJson = null, + locatorBlockIndex = readerPosition?.blockIndex, + locatorCharOffset = readerPosition?.charOffset, + customName = null, + highlightsJson = null + )).copy( + lastModifiedTimestamp = timestamp, + bookmarksJson = pdfBookmarksJson ?: base?.bookmarksJson + ) +} + private fun uniqueFolderSyncTempName(baseName: String): String { val stem = baseName.removeSuffix(".tmp") val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}" diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLogFormatting.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLogFormatting.kt similarity index 83% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLogFormatting.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLogFormatting.kt index 673db6a..95d9de9 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLogFormatting.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLogFormatting.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop internal fun String.logPreview(maxLength: Int = 96): String { return replace(Regex("\\s+"), " ") diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsCoverImage.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsCoverImage.kt similarity index 95% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsCoverImage.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsCoverImage.kt index e39304c..7a984be 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsCoverImage.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsCoverImage.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -18,8 +18,8 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight -import com.aryan.reader.shared.opds.OpdsCatalog -import com.aryan.reader.shared.opds.OpdsEntry +import org.dueattendant149.bookreader.shared.opds.OpdsCatalog +import org.dueattendant149.bookreader.shared.opds.OpdsEntry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.skia.Image as SkiaImage diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsRepository.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepository.kt similarity index 94% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsRepository.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepository.kt index 1e46f0f..f694c4e 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsRepository.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepository.kt @@ -1,13 +1,13 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.opds.OpdsAcquisition -import com.aryan.reader.shared.opds.OpdsCatalog -import com.aryan.reader.shared.opds.OpdsEntry -import com.aryan.reader.shared.opds.OpdsFeed -import com.aryan.reader.shared.opds.SharedOpdsCatalogs -import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer -import com.aryan.reader.shared.opds.SharedOpdsParser -import com.aryan.reader.shared.opds.SharedOpdsRepository +import org.dueattendant149.bookreader.shared.opds.OpdsAcquisition +import org.dueattendant149.bookreader.shared.opds.OpdsCatalog +import org.dueattendant149.bookreader.shared.opds.OpdsEntry +import org.dueattendant149.bookreader.shared.opds.OpdsFeed +import org.dueattendant149.bookreader.shared.opds.SharedOpdsCatalogs +import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadNamer +import org.dueattendant149.bookreader.shared.opds.SharedOpdsParser +import org.dueattendant149.bookreader.shared.opds.SharedOpdsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.Closeable diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPaidAiAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiAdapter.kt similarity index 84% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPaidAiAdapter.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiAdapter.kt index 6787c48..95a9d08 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPaidAiAdapter.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiAdapter.kt @@ -1,9 +1,9 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.AiAdapter -import com.aryan.reader.shared.AiDefinitionResult -import com.aryan.reader.shared.RecapResult -import com.aryan.reader.shared.SummarizationResult +import org.dueattendant149.bookreader.shared.AiAdapter +import org.dueattendant149.bookreader.shared.AiDefinitionResult +import org.dueattendant149.bookreader.shared.RecapResult +import org.dueattendant149.bookreader.shared.SummarizationResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -16,6 +16,7 @@ import kotlinx.serialization.json.jsonPrimitive import java.io.InputStream import java.net.HttpURLConnection import java.net.URL +import kotlin.math.ceil internal class DesktopPaidAiAdapter( private val config: DesktopCloudConfig, @@ -25,7 +26,7 @@ internal class DesktopPaidAiAdapter( private val currentSignedIn: () -> Boolean, private val currentIsProUser: () -> Boolean, private val currentCredits: () -> Int, - private val onUsageCompleted: suspend () -> Unit = {} + private val onUsageReported: (DesktopPaidAiUsage) -> Unit = {} ) : AiAdapter { override val isAvailable: Boolean get() = networkAccess() && @@ -196,13 +197,18 @@ internal class DesktopPaidAiAdapter( val responseCode = connection.responseCode val stream = if (responseCode in 200..299) connection.inputStream else connection.errorStream if (responseCode in 200..299) { - val parsed = readWorkerStream(stream, onChunk, onUsageReceived) + val parsed = readWorkerStream( + stream = stream, + onChunk = onChunk, + onUsageReceived = onUsageReceived, + onUsageReported = onUsageReported + ) if (parsed.text.isBlank()) throw IllegalStateException("The AI service returned an empty response.") - onUsageCompleted() return@runCatching parsed } val responseText = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() if (connection.responseCode == 402 || responseText.contains("INSUFFICIENT_CREDITS")) { + onUsageReported(DesktopPaidAiUsage()) throw IllegalStateException("Out of credits. Pro and credits can only be purchased from the Android app.") } if (connection.responseCode == 401) { @@ -222,6 +228,19 @@ internal class DesktopPaidAiAdapter( } } +internal data class DesktopPaidAiUsage( + val cost: Double? = null, + val freeRemaining: Int? = null +) + +internal fun desktopCreditsAfterPaidAiUsage(currentCredits: Int, cost: Double?): Int { + val deducted = cost + ?.takeIf { it.isFinite() && it > 0.0 } + ?.let { ceil(it).toInt() } + ?: return currentCredits + return (currentCredits - deducted).coerceAtLeast(0) +} + private data class DesktopPaidAiResponse( val text: String, val cost: Double? = null, @@ -233,18 +252,35 @@ private val DesktopPaidAiJson = Json { ignoreUnknownKeys = true } private fun readWorkerStream( stream: InputStream?, onChunk: (String) -> Unit, - onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit + onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit, + onUsageReported: (DesktopPaidAiUsage) -> Unit ): DesktopPaidAiResponse { val output = StringBuilder() var cost: Double? = null var freeRemaining: Int? = null + var paidUsageReported = false + var freeUsageReported = false stream?.bufferedReader(Charsets.UTF_8)?.useLines { lines -> lines.forEach { line -> - val parsed = parseWorkerStreamLine(line) ?: return@forEach + val parsed = try { + parseWorkerStreamLine(line) + } catch (error: IllegalStateException) { + if (desktopPaidAiShouldRefreshAccountAfterError(error)) { + onUsageReported(DesktopPaidAiUsage()) + } + throw error + } ?: return@forEach parsed.cost?.let { cost = it } parsed.freeRemaining?.let { freeRemaining = it } if (parsed.cost != null || parsed.freeRemaining != null) { onUsageReceived(parsed.cost, parsed.freeRemaining) + if (parsed.cost != null && !paidUsageReported) { + paidUsageReported = true + onUsageReported(DesktopPaidAiUsage(cost = parsed.cost, freeRemaining = parsed.freeRemaining)) + } else if (parsed.freeRemaining != null && !freeUsageReported) { + freeUsageReported = true + onUsageReported(DesktopPaidAiUsage(freeRemaining = parsed.freeRemaining)) + } } parsed.chunk?.let { chunk -> output.append(chunk) @@ -275,6 +311,14 @@ private data class DesktopPaidAiStreamLine( val freeRemaining: Int? = null ) +private fun desktopPaidAiShouldRefreshAccountAfterError(error: Throwable): Boolean { + val details = generateSequence(error) { it.cause } + .joinToString(" ") { it.message.orEmpty() } + return details.contains("Out of credits", ignoreCase = true) || + details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) || + details.contains("402", ignoreCase = true) +} + private fun workerErrorMessage(errorBody: String): String? { return when { errorBody.contains("INSUFFICIENT_CREDITS") -> "Out of credits. Pro and credits can only be purchased from the Android app." diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAnnotationUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAnnotationUi.kt new file mode 100644 index 0000000..5b6d718 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAnnotationUi.kt @@ -0,0 +1,880 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.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.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.dueattendant149.bookreader.shared.pdf.DEFAULT_SHARED_PDF_COMMENT_AUTHOR +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationComment +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationDefaults +import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.shared.pdf.pdfCommentChildren +import org.dueattendant149.bookreader.shared.pdf.sharedPdfStrokePercent +import org.dueattendant149.bookreader.shared.pdf.sharedPdfStrokeWidthRange +import org.dueattendant149.bookreader.shared.pdf.sharedPdfTextStyle +import org.dueattendant149.bookreader.shared.pdf.visiblePdfAnnotationComments +import org.dueattendant149.bookreader.shared.pdf.withoutPdfCommentThread +import org.dueattendant149.bookreader.shared.pdf.withSharedPdfTextStyle +import org.dueattendant149.bookreader.shared.ui.SharedHsvColorPickerDialog +import org.dueattendant149.bookreader.shared.ui.SharedPdfTextStyleControls +import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField +import org.dueattendant149.bookreader.shared.ui.readerString +import java.text.DateFormat +import java.util.Date +import java.util.UUID + +internal val DesktopPdfAnnotationTools = listOf( + PdfInkTool.PEN, + PdfInkTool.FOUNTAIN_PEN, + PdfInkTool.PENCIL, + PdfInkTool.HIGHLIGHTER, + PdfInkTool.HIGHLIGHTER_ROUND, + PdfInkTool.TEXT, + PdfInkTool.ERASER +) + +private enum class DesktopPdfAnnotationSheetSection { + NOTE, + COMMENTS +} + +@Composable +internal fun DesktopPdfAnnotationEditor( + annotation: SharedPdfAnnotation, + onUpdate: (SharedPdfAnnotation) -> Unit, + onDelete: () -> Unit, + onClose: () -> Unit, + onCopy: () -> Unit, + showSearch: Boolean, + highlighterPalette: List = SharedPdfHighlighterPalette.defaultColors, + onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {}, + onSearch: () -> Unit +) { + val highlighterColors = remember(highlighterPalette) { + SharedPdfHighlighterPalette(highlighterPalette).sanitized().colors + } + var editingHighlighterSlot by remember(annotation.id, highlighterColors) { mutableStateOf(null) } + var editingHighlighterDraftColors by remember(annotation.id, highlighterColors) { + mutableStateOf>(emptyList()) + } + val isHighlighterAnnotation = annotation.kind == PdfAnnotationKind.HIGHLIGHT || + annotation.tool == PdfInkTool.HIGHLIGHTER || + annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND + var selectedSection by remember(annotation.id) { mutableStateOf(DesktopPdfAnnotationSheetSection.NOTE) } + var commentText by remember(annotation.id) { mutableStateOf("") } + var replyTargetId by remember(annotation.id) { mutableStateOf(null) } + var editingCommentId by remember(annotation.id) { mutableStateOf(null) } + var commentAuthor by remember(annotation.id) { + mutableStateOf( + annotation.comments + .lastOrNull { it.author.isNotBlank() } + ?.author + ?: DEFAULT_SHARED_PDF_COMMENT_AUTHOR + ) + } + + fun updateComments(nextComments: List) { + onUpdate(annotation.copy(comments = nextComments)) + } + + fun highlighterDraftColors(): List { + return editingHighlighterDraftColors.ifEmpty { highlighterColors } + } + + fun updateHighlighterDraft(slotIndex: Int, color: Color): List { + val nextColors = highlighterDraftColors().toMutableList() + if (slotIndex in nextColors.indices) { + nextColors[slotIndex] = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() + editingHighlighterDraftColors = nextColors + } + return nextColors + } + + fun openHighlighterEditor(slotIndex: Int) { + if (editingHighlighterSlot == null) { + editingHighlighterDraftColors = highlighterColors + } + editingHighlighterSlot = slotIndex + } + + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + readerString("desktop_selected_annotation_format", "Selected %1\$s", annotation.desktopLabel()), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onClose) { + Text(readerString("action_close", "Close")) + } + } + Text( + readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + if (annotation.text.isNotBlank()) { + Surface( + color = Color(annotation.colorArgb).copy(alpha = 0.10f), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, Color(annotation.colorArgb).copy(alpha = 0.28f)), + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.heightIn(min = 72.dp)) { + Box( + modifier = Modifier + .width(6.dp) + .fillMaxHeight() + .background(Color(annotation.colorArgb)) + ) + Text( + "\"${annotation.text}\"", + style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), + maxLines = 4, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f), + modifier = Modifier.padding(14.dp) + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + DesktopBottomSheetToolButton( + icon = Icons.Default.ContentCopy, + label = readerString("action_copy", "Copy"), + onClick = onCopy + ) + if (showSearch) { + DesktopBottomSheetToolButton( + icon = Icons.Default.Search, + label = readerString("action_search", "Search"), + onClick = onSearch + ) + } + } + } + if (annotation.kind == PdfAnnotationKind.TEXT) { + SharedStableOutlinedTextField( + value = annotation.text, + onValueChange = { onUpdate(annotation.copy(text = it)) }, + label = { Text(readerString("desktop_text_note", "Text note")) }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + selectionKey = annotation.id + ) + SharedPdfTextStyleControls( + style = annotation.sharedPdfTextStyle(), + onStyleChange = { onUpdate(annotation.withSharedPdfTextStyle(it)) } + ) + } + if (annotation.kind != PdfAnnotationKind.TEXT) { + val palette = if (isHighlighterAnnotation) { + highlighterColors + } else { + SharedPdfAnnotationDefaults.penPalette + } + Text(readerString("desktop_color", "Color"), style = MaterialTheme.typography.labelLarge) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + palette.forEachIndexed { _, argb -> + Surface( + modifier = Modifier + .size(26.dp) + .clickable { + onUpdate(annotation.copy(colorArgb = argb)) + }, + color = Color(argb), + shape = RoundedCornerShape(13.dp), + content = {} + ) + } + if (isHighlighterAnnotation) { + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(15.dp)) + .background( + Brush.sweepGradient( + listOf( + Color.Red, + Color.Yellow, + Color.Green, + Color.Cyan, + Color.Blue, + Color.Magenta, + Color.Red + ) + ) + ) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(15.dp)) + .clickable { + openHighlighterEditor( + highlighterColors + .indexOf(annotation.colorArgb) + .takeIf { it >= 0 } + ?: 0 + ) + } + ) + } + } + DesktopPdfAnnotationSheetTabs( + selectedSection = selectedSection, + commentCount = annotation.comments.count { it.contents.isNotBlank() }, + onSectionChange = { selectedSection = it } + ) + if (selectedSection == DesktopPdfAnnotationSheetSection.NOTE) { + SharedStableOutlinedTextField( + value = annotation.note.orEmpty(), + onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) }, + label = { Text(readerString("label_note", "Note")) }, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + selectionKey = annotation.id + ) + } else { + DesktopPdfHighlightCommentsEditor( + comments = annotation.comments, + commentText = commentText, + commentAuthor = commentAuthor, + replyTargetId = replyTargetId, + editingCommentId = editingCommentId, + onCommentTextChange = { commentText = it }, + onCommentAuthorChange = { commentAuthor = it }, + onReply = { comment -> + editingCommentId = null + replyTargetId = comment.id + commentText = "" + }, + onCancelReply = { replyTargetId = null }, + onEdit = { comment -> + editingCommentId = comment.id + replyTargetId = null + commentText = comment.contents + commentAuthor = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } + }, + onCancelEdit = { + editingCommentId = null + commentText = "" + }, + onDelete = { comment -> + val nextComments = annotation.comments.withoutPdfCommentThread(comment.id) + updateComments(nextComments) + if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) { + replyTargetId = null + } + if (editingCommentId != null && (editingCommentId == comment.id || nextComments.none { it.id == editingCommentId })) { + editingCommentId = null + commentText = "" + } + }, + onAddComment = { + val contents = commentText.trim() + if (contents.isNotBlank()) { + val now = System.currentTimeMillis() + val author = commentAuthor.trim().ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } + val nextComments = if (editingCommentId != null) { + annotation.comments.map { comment -> + if (comment.id == editingCommentId) { + comment.copy( + author = author, + contents = contents, + modifiedAt = now + ) + } else { + comment + } + } + } else { + annotation.comments + SharedPdfAnnotationComment( + id = UUID.randomUUID().toString(), + parentId = replyTargetId, + author = author, + contents = contents, + createdAt = now, + modifiedAt = now + ) + } + updateComments(nextComments) + commentText = "" + replyTargetId = null + editingCommentId = null + } + } + ) + } + } + if (annotation.kind == PdfAnnotationKind.INK) { + val strokeRange = annotation.tool.sharedPdfStrokeWidthRange() + val strokeValue = annotation.strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive) + Text( + readerString( + "desktop_thickness_format", + "Thickness %1\$s", + strokeValue.sharedPdfStrokePercent(strokeRange) + ), + style = MaterialTheme.typography.labelLarge + ) + Slider( + value = strokeValue, + onValueChange = { onUpdate(annotation.copy(strokeWidth = it.coerceAtLeast(0.0001f))) }, + valueRange = strokeRange + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onDelete) { + Text(readerString("action_delete", "Delete")) + } + } + } + } + editingHighlighterSlot?.let { requestedSlot -> + val draftColors = highlighterDraftColors() + val safeDraftColors = draftColors.ifEmpty { SharedPdfHighlighterPalette.defaultColors } + val slot = requestedSlot.coerceIn(0, safeDraftColors.lastIndex) + val initialColor = remember(slot) { Color(safeDraftColors[slot]).copy(alpha = 1f) } + SharedHsvColorPickerDialog( + initialColor = initialColor, + title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1), + onDismiss = { editingHighlighterSlot = null }, + onSave = { color -> + val nextArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() + val nextColors = updateHighlighterDraft(slot, color) + onHighlighterPaletteChange( + SharedPdfHighlighterPalette(nextColors).sanitized() + ) + onUpdate(annotation.copy(colorArgb = nextArgb)) + editingHighlighterSlot = null + }, + resetColor = Color(SharedPdfHighlighterPalette.defaultColors.getOrElse(slot) { + SharedPdfHighlighterPalette.defaultColors.first() + }).copy(alpha = 1f), + stateKey = slot, + onLiveColorChange = { color -> + updateHighlighterDraft(slot, color) + } + ) { liveColor -> + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + highlighterDraftColors().forEachIndexed { index, argb -> + val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(21.dp)) + .background(color) + .border( + width = if (index == slot) 3.dp else 1.dp, + color = if (index == slot) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) + }, + shape = RoundedCornerShape(21.dp) + ) + .clickable { openHighlighterEditor(index) }, + contentAlignment = Alignment.Center + ) { + Text( + text = "${index + 1}", + color = if (color.luminance() > 0.5f) Color.Black else Color.White, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} + +@Composable +private fun DesktopPdfAnnotationSheetTabs( + selectedSection: DesktopPdfAnnotationSheetSection, + commentCount: Int, + onSectionChange: (DesktopPdfAnnotationSheetSection) -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.padding(4.dp)) { + DesktopPdfAnnotationSheetTab( + label = readerString("label_note", "Note"), + selected = selectedSection == DesktopPdfAnnotationSheetSection.NOTE, + modifier = Modifier.weight(1f), + onClick = { onSectionChange(DesktopPdfAnnotationSheetSection.NOTE) } + ) + DesktopPdfAnnotationSheetTab( + label = "${readerString("label_comments", "Comments")} ($commentCount)", + selected = selectedSection == DesktopPdfAnnotationSheetSection.COMMENTS, + modifier = Modifier.weight(1f), + onClick = { onSectionChange(DesktopPdfAnnotationSheetSection.COMMENTS) } + ) + } + } +} + +@Composable +private fun DesktopPdfAnnotationSheetTab( + label: String, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Surface( + color = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(6.dp), + modifier = modifier + .height(40.dp) + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onClick) + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth().fillMaxHeight()) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun DesktopPdfHighlightCommentsEditor( + comments: List, + commentText: String, + commentAuthor: String, + replyTargetId: String?, + editingCommentId: String?, + onCommentTextChange: (String) -> Unit, + onCommentAuthorChange: (String) -> Unit, + onReply: (SharedPdfAnnotationComment) -> Unit, + onCancelReply: () -> Unit, + onEdit: (SharedPdfAnnotationComment) -> Unit, + onCancelEdit: () -> Unit, + onDelete: (SharedPdfAnnotationComment) -> Unit, + onAddComment: () -> Unit +) { + val visibleComments = comments.visiblePdfAnnotationComments() + val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId } + val editingComment = visibleComments.firstOrNull { it.id == editingCommentId } + + Column { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 220.dp) + .verticalScroll(rememberScrollState()) + ) { + DesktopPdfHighlightCommentThread( + comments = visibleComments, + parentId = null, + depth = 0, + visitedIds = emptySet(), + onReply = onReply, + onEdit = onEdit, + onDelete = onDelete + ) + } + + if (editingComment != null || replyTarget != null) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (editingComment != null) { + readerString("label_editing_comment", "Editing comment") + } else { + readerString( + "label_replying_to", + "Replying to %1\$s", + replyTarget?.author?.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }.orEmpty() + ) + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = if (editingComment != null) onCancelEdit else onCancelReply) { + Text(readerString("action_cancel", "Cancel")) + } + } + } + + SharedStableOutlinedTextField( + value = commentAuthor, + onValueChange = onCommentAuthorChange, + label = { Text(readerString("author", "Author")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + selectionKey = "comment-author-${editingCommentId ?: replyTargetId ?: "new"}" + ) + + Spacer(Modifier.height(8.dp)) + + SharedStableOutlinedTextField( + value = commentText, + onValueChange = onCommentTextChange, + placeholder = { Text(readerString("placeholder_add_comment", "Add a comment...")) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp), + minLines = 3, + maxLines = 4, + shape = RoundedCornerShape(12.dp), + selectionKey = "comment-text-${editingCommentId ?: replyTargetId ?: "new"}" + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onAddComment, enabled = commentText.isNotBlank()) { + Text( + readerString( + if (editingComment != null) "action_save_comment" else "action_add_comment", + if (editingComment != null) "Save Comment" else "Add Comment" + ) + ) + } + } + } +} + +@Composable +private fun DesktopPdfHighlightCommentThread( + comments: List, + parentId: String?, + depth: Int, + visitedIds: Set, + onReply: (SharedPdfAnnotationComment) -> Unit, + onEdit: (SharedPdfAnnotationComment) -> Unit, + onDelete: (SharedPdfAnnotationComment) -> Unit +) { + comments.pdfCommentChildren(parentId).forEach { comment -> + if (comment.id in visitedIds) return@forEach + DesktopPdfHighlightCommentItem( + comment = comment, + depth = depth, + onReply = { onReply(comment) }, + onEdit = { onEdit(comment) }, + onDelete = { onDelete(comment) } + ) + DesktopPdfHighlightCommentThread( + comments = comments, + parentId = comment.id, + depth = depth + 1, + visitedIds = visitedIds + comment.id, + onReply = onReply, + onEdit = onEdit, + onDelete = onDelete + ) + } +} + +@Composable +private fun DesktopPdfHighlightCommentItem( + comment: SharedPdfAnnotationComment, + depth: Int, + onReply: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit +) { + val indentSize = (depth * 16).dp + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = indentSize, top = 6.dp, bottom = 6.dp) + ) { + if (depth > 0) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant) + ) + Spacer(modifier = Modifier.width(12.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + val timestamp = comment.createdAt.formatDesktopPdfCommentTimestamp() + if (timestamp.isNotBlank()) { + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Spacer(Modifier.height(2.dp)) + Text( + text = comment.contents, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Row { + TextButton(onClick = onReply) { + Text(readerString("action_reply", "Reply")) + } + TextButton(onClick = onEdit) { + Text(readerString("label_edit", "Edit")) + } + TextButton(onClick = onDelete) { + Text(readerString("action_delete", "Delete"), color = MaterialTheme.colorScheme.error) + } + } + } + } +} + +private fun Long.formatDesktopPdfCommentTimestamp(): String { + if (this <= 0L) return "" + return runCatching { + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(this)) + }.getOrDefault("") +} + +@Composable +private fun DesktopBottomSheetToolButton( + icon: ImageVector, + label: String, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.78f), + modifier = Modifier.size(22.dp) + ) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +internal fun DesktopPdfEmbeddedAnnotationPanel( + annotation: SharedPdfEmbeddedAnnotation, + onCopy: () -> Unit, + onClose: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + readerString("desktop_embedded_pdf_comment", "Embedded PDF comment"), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onClose) { + Text(readerString("action_close", "Close")) + } + } + Text( + annotation.author.takeIf { it.isNotBlank() }?.let { author -> + readerString( + "desktop_pdf_page_author_format", + "Page %1\$d - %2\$s", + annotation.pageIndex + 1, + author + ) + } ?: readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + DesktopPdfEmbeddedComment( + author = annotation.author, + contents = annotation.contents, + depth = 0 + ) + DesktopPdfEmbeddedReplies(annotation.replies, depth = 1) + TextButton(onClick = onCopy) { + Text(readerString("action_copy_thread", "Copy thread")) + } + } + } +} + +@Composable +private fun DesktopPdfEmbeddedReplies( + replies: List, + depth: Int +) { + replies.forEach { reply -> + HorizontalDivider() + DesktopPdfEmbeddedComment( + author = reply.author, + contents = reply.contents, + depth = depth + ) + if (reply.replies.isNotEmpty()) { + DesktopPdfEmbeddedReplies(reply.replies, depth + 1) + } + } +} + +@Composable +private fun DesktopPdfEmbeddedComment( + author: String, + contents: String, + depth: Int +) { + Column( + modifier = Modifier.padding(start = (depth * 12).dp), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Text( + author.ifBlank { readerString("unknown", "Unknown") }, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + contents.ifBlank { readerString("desktop_no_comment", "No comment") }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +internal fun SharedPdfAnnotation.desktopLabel(): String { + return when (kind) { + PdfAnnotationKind.HIGHLIGHT -> readerString("label_highlight_color", "highlight") + PdfAnnotationKind.INK -> tool.desktopLabel() + PdfAnnotationKind.TEXT -> readerString("desktop_text_note_lowercase", "text note") + } +} + +@Composable +internal fun SharedPdfAnnotation.desktopSheetTitle(): String { + return when (kind) { + PdfAnnotationKind.HIGHLIGHT -> readerString("label_highlight_color", "Highlight") + PdfAnnotationKind.INK -> readerString("desktop_annotation", "Annotation") + PdfAnnotationKind.TEXT -> readerString("desktop_text_note", "Text note") + } +} + +@Composable +private fun PdfInkTool.desktopLabel(): String { + return when (this) { + PdfInkTool.PEN -> readerString("content_desc_pen", "Pen") + PdfInkTool.FOUNTAIN_PEN -> readerString("desktop_fountain_pen", "Fountain pen") + PdfInkTool.PENCIL -> readerString("desktop_pencil", "Pencil") + PdfInkTool.HIGHLIGHTER -> readerString("content_desc_highlighter", "Highlighter") + PdfInkTool.HIGHLIGHTER_ROUND -> readerString("desktop_round_highlighter", "Round highlighter") + PdfInkTool.TEXT -> readerString("desktop_text_note", "Text note") + PdfInkTool.ERASER -> readerString("content_desc_eraser", "Eraser") + PdfInkTool.NONE -> readerString("label_none", "None") + } +} + +internal fun SharedPdfEmbeddedAnnotation.threadText(): String { + return buildString { + append(author.ifBlank { "Unknown" }) + append(": ") + appendLine(contents.ifBlank { "No comment" }) + fun appendReplies(replies: List, indent: String) { + replies.forEach { reply -> + append(indent) + append(reply.author.ifBlank { "Unknown" }) + append(": ") + appendLine(reply.contents.ifBlank { "No comment" }) + appendReplies(reply.replies, "$indent ") + } + } + appendReplies(replies, " ") + }.trimEnd() +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAppearance.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAppearance.kt similarity index 82% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAppearance.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAppearance.kt index 8c0c060..b56fb3e 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAppearance.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAppearance.kt @@ -1,7 +1,6 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.Canvas -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -20,22 +19,29 @@ import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageShader import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.BuiltInPdfReaderThemes -import com.aryan.reader.shared.PdfDisplayMode -import com.aryan.reader.shared.ReaderTheme -import com.aryan.reader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.BuiltInPdfReaderThemes +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderTheme +import org.dueattendant149.bookreader.shared.reader.ReaderSettings internal enum class DesktopPdfInspectorTab(val title: String) { - VIEW("View"), + APPEARANCE("Appearance"), + APP_THEME("App theme"), + VISUAL("Visual"), MARKUP("Markup"), - ASSIST("Assist") + TTS("TTS") } internal data class DesktopPdfThemeStyle( @@ -56,15 +62,25 @@ internal fun DesktopPdfThemedPageImage( modifier: Modifier = Modifier ) { Box(modifier = modifier.background(themeStyle.pageBackgroundColor)) { - Image( - bitmap = bitmap, - contentDescription = contentDescription, - colorFilter = themeStyle.colorFilter, - modifier = Modifier.fillMaxSize() - ) val textureBitmap = themeStyle.textureBitmap - if (textureBitmap != null && themeStyle.textureAlpha > 0f) { - Canvas(modifier = Modifier.fillMaxSize()) { + Canvas( + modifier = Modifier + .fillMaxSize() + .semantics { this.contentDescription = contentDescription } + ) { + drawImage( + image = bitmap, + srcOffset = IntOffset.Zero, + srcSize = IntSize(bitmap.width, bitmap.height), + dstOffset = IntOffset.Zero, + dstSize = IntSize( + size.width.toInt().coerceAtLeast(1), + size.height.toInt().coerceAtLeast(1) + ), + colorFilter = themeStyle.colorFilter, + filterQuality = FilterQuality.High + ) + if (textureBitmap != null && themeStyle.textureAlpha > 0f) { drawRect( brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)), size = size, @@ -77,7 +93,7 @@ internal fun DesktopPdfThemedPageImage( } internal fun ReaderSettings?.toDesktopPdfReaderSettings(): ReaderSettings { - val defaults = ReaderSettings(themeId = "no_theme") + val defaults = DesktopDefaultPdfReaderSettings val settings = this ?: defaults val themeId = settings.themeId val hasPdfTheme = BuiltInPdfReaderThemes.any { it.id == themeId } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfChromeUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfChromeUi.kt similarity index 65% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfChromeUi.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfChromeUi.kt index 1040fff..5018a0a 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfChromeUi.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfChromeUi.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -35,7 +35,6 @@ import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.ZoomOut import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -49,15 +48,17 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import com.aryan.reader.shared.SearchHighlightMode -import com.aryan.reader.shared.pdf.SharedPdfSearchResult -import com.aryan.reader.shared.ui.ReaderMinimalSlider -import com.aryan.reader.shared.ui.SharedStableOutlinedTextField -import com.aryan.reader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.SearchHighlightMode +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult +import org.dueattendant149.bookreader.shared.ui.ReaderMinimalSlider +import org.dueattendant149.bookreader.shared.ui.ReaderTooltipIconButton +import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField +import org.dueattendant149.bookreader.shared.ui.readerString import kotlinx.coroutines.delay @Composable @@ -82,66 +83,75 @@ internal fun DesktopPdfFullscreenBottomChrome( val chromeBackground = MaterialTheme.colorScheme.surfaceVariant val chromeContent = MaterialTheme.colorScheme.onSurface val sliderActive = MaterialTheme.colorScheme.primary - val sliderInactive = MaterialTheme.colorScheme.surfaceVariant - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 0.dp), - shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp), - color = chromeBackground, - contentColor = chromeContent, - tonalElevation = 0.dp, - shadowElevation = 1.dp, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Column(modifier = Modifier.fillMaxWidth()) { - extraContent() - val hasJumpTargets = jumpBackPage != null || jumpForwardPage != null - DesktopPdfJumpHistoryControls( - visible = showJumpHistory, - backPage = jumpBackPage, - forwardPage = jumpForwardPage, - onBack = onJumpBack, - onForward = onJumpForward, - onClear = onClearJumpHistory - ) - if (showJumpHistory && hasJumpTargets) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - } - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onPrevious, enabled = canGoPrevious) { - Icon( - Icons.AutoMirrored.Filled.NavigateBefore, - contentDescription = readerString("desktop_previous_page", "Previous page"), - tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f) - ) + val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f) + Column(modifier = Modifier.fillMaxWidth()) { + extraContent() + Surface( + modifier = Modifier + .fillMaxWidth(), + shape = RoundedCornerShape(0.dp), + color = chromeBackground, + contentColor = chromeContent, + tonalElevation = 0.dp, + shadowElevation = 1.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + val hasJumpTargets = jumpBackPage != null || jumpForwardPage != null + DesktopPdfJumpHistoryControls( + visible = showJumpHistory, + backPage = jumpBackPage, + forwardPage = jumpForwardPage, + onBack = onJumpBack, + onForward = onJumpForward, + onClear = onClearJumpHistory + ) + if (showJumpHistory && hasJumpTargets) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } - Text( - pageLabel, - style = MaterialTheme.typography.labelSmall, - color = chromeContent.copy(alpha = 0.72f) - ) - ReaderMinimalSlider( - value = pageIndex.toFloat(), - onValueChange = onPageScrub, - onValueChangeFinished = onPageScrubFinished, - valueRange = 0f..(pageCount - 1).coerceAtLeast(0).toFloat(), - enabled = pageCount > 1, - activeColor = sliderActive, - inactiveColor = sliderInactive, - thumbColor = sliderActive, - modifier = Modifier.weight(1f) - ) - IconButton(onClick = onNext, enabled = canGoNext) { - Icon( - Icons.AutoMirrored.Filled.NavigateNext, - contentDescription = readerString("desktop_next_page", "Next page"), - tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ReaderTooltipIconButton( + tooltip = readerString("desktop_previous_page", "Previous page"), + onClick = onPrevious, + enabled = canGoPrevious + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = readerString("desktop_previous_page", "Previous page"), + tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f) + ) + } + Text( + pageLabel, + style = MaterialTheme.typography.labelSmall, + color = chromeContent.copy(alpha = 0.72f) ) + ReaderMinimalSlider( + value = pageIndex.toFloat(), + onValueChange = onPageScrub, + onValueChangeFinished = onPageScrubFinished, + valueRange = 0f..(pageCount - 1).coerceAtLeast(0).toFloat(), + enabled = pageCount > 1, + activeColor = sliderActive, + inactiveColor = sliderInactive, + thumbColor = sliderActive, + modifier = Modifier.weight(1f) + ) + ReaderTooltipIconButton( + tooltip = readerString("desktop_next_page", "Next page"), + onClick = onNext, + enabled = canGoNext + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = readerString("desktop_next_page", "Next page"), + tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f) + ) + } } } } @@ -171,71 +181,81 @@ internal fun DesktopPdfBottomChrome( val chromeBackground = MaterialTheme.colorScheme.surfaceVariant val chromeContent = MaterialTheme.colorScheme.onSurface val sliderActive = MaterialTheme.colorScheme.primary - val sliderInactive = MaterialTheme.colorScheme.surfaceVariant - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(6.dp), - color = chromeBackground, - contentColor = chromeContent, - tonalElevation = 0.dp, - shadowElevation = 1.dp, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Column(modifier = Modifier.fillMaxWidth()) { - extraContent() - DesktopPdfJumpHistoryControls( - visible = showJumpHistory, - backPage = jumpBackPage, - forwardPage = jumpForwardPage, - onBack = onJumpBack, - onForward = onJumpForward, - onClear = onClearJumpHistory - ) - if (showJumpHistory && (jumpBackPage != null || jumpForwardPage != null)) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - } - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onPrevious, enabled = canGoPrevious) { - Icon( - Icons.AutoMirrored.Filled.NavigateBefore, - contentDescription = readerString("desktop_previous_page", "Previous page"), - tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f) - ) - } - Text( - pageLabel, - style = MaterialTheme.typography.labelSmall, - color = chromeContent.copy(alpha = 0.72f) + val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f) + Column(modifier = Modifier.fillMaxWidth()) { + extraContent() + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(0.dp), + color = chromeBackground, + contentColor = chromeContent, + tonalElevation = 0.dp, + shadowElevation = 1.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + DesktopPdfJumpHistoryControls( + visible = showJumpHistory, + backPage = jumpBackPage, + forwardPage = jumpForwardPage, + onBack = onJumpBack, + onForward = onJumpForward, + onClear = onClearJumpHistory ) - if (pageCount > 1) { - ReaderMinimalSlider( - value = pageIndex.toFloat(), - onValueChange = onPageScrub, - onValueChangeFinished = onPageScrubFinished, - valueRange = 0f..(pageCount - 1).toFloat(), - activeColor = sliderActive, - inactiveColor = sliderInactive, - thumbColor = sliderActive, - modifier = Modifier.weight(1f) - ) - } else { - Spacer(Modifier.weight(1f)) + if (showJumpHistory && (jumpBackPage != null || jumpForwardPage != null)) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } - Text( - "${progressPercent.toInt()}%", - style = MaterialTheme.typography.labelSmall, - color = chromeContent.copy(alpha = 0.72f) - ) - IconButton(onClick = onNext, enabled = canGoNext) { - Icon( - Icons.AutoMirrored.Filled.NavigateNext, - contentDescription = readerString("desktop_next_page", "Next page"), - tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ReaderTooltipIconButton( + tooltip = readerString("desktop_previous_page", "Previous page"), + onClick = onPrevious, + enabled = canGoPrevious + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = readerString("desktop_previous_page", "Previous page"), + tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f) + ) + } + Text( + pageLabel, + style = MaterialTheme.typography.labelSmall, + color = chromeContent.copy(alpha = 0.72f) ) + if (pageCount > 1) { + ReaderMinimalSlider( + value = pageIndex.toFloat(), + onValueChange = onPageScrub, + onValueChangeFinished = onPageScrubFinished, + valueRange = 0f..(pageCount - 1).toFloat(), + activeColor = sliderActive, + inactiveColor = sliderInactive, + thumbColor = sliderActive, + modifier = Modifier.weight(1f) + ) + } else { + Spacer(Modifier.weight(1f)) + } + Text( + "${progressPercent.toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = chromeContent.copy(alpha = 0.72f) + ) + ReaderTooltipIconButton( + tooltip = readerString("desktop_next_page", "Next page"), + onClick = onNext, + enabled = canGoNext + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = readerString("desktop_next_page", "Next page"), + tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f) + ) + } } } } @@ -298,7 +318,7 @@ internal fun DesktopPdfSearchTopBar( Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(6.dp), + shape = RoundedCornerShape(0.dp), color = MaterialTheme.colorScheme.surface, tonalElevation = 2.dp ) { @@ -307,7 +327,11 @@ internal fun DesktopPdfSearchTopBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) { + ReaderTooltipIconButton( + tooltip = readerString("tooltip_close_search_desc", "Exit search and go back to the reader"), + onClick = onClose, + modifier = Modifier.size(36.dp) + ) { Icon(Icons.Default.Close, contentDescription = readerString("content_desc_close_search", "Close search")) } SharedStableOutlinedTextField( @@ -318,7 +342,10 @@ internal fun DesktopPdfSearchTopBar( modifier = Modifier.weight(1f).focusRequester(focusRequester), trailingIcon = if (query.isNotEmpty()) { { - IconButton(onClick = { onQueryChange("") }) { + ReaderTooltipIconButton( + tooltip = readerString("tooltip_clear_search_desc", "Erase your current search query and start over"), + onClick = { onQueryChange("") } + ) { Icon(Icons.Default.Close, contentDescription = readerString("tooltip_clear_search", "Clear search")) } } @@ -327,7 +354,16 @@ internal fun DesktopPdfSearchTopBar( }, selectionKey = "desktop-pdf-search" ) - IconButton(onClick = onToggleResults, modifier = Modifier.size(36.dp)) { + val resultsTooltip = if (showResultsPanel) { + readerString("tooltip_hide_results_desc", "Collapse the search results panel") + } else { + readerString("tooltip_show_results_desc", "Expand the panel to see all search matches") + } + ReaderTooltipIconButton( + tooltip = resultsTooltip, + onClick = onToggleResults, + modifier = Modifier.size(36.dp) + ) { Icon( if (showResultsPanel) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, contentDescription = if (showResultsPanel) { @@ -507,7 +543,15 @@ private fun DesktopPdfSearchNavigationPill( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - IconButton(onClick = onToggleHighlightMode, modifier = Modifier.size(36.dp)) { + ReaderTooltipIconButton( + tooltip = if (highlightMode == SearchHighlightMode.ALL) { + readerString("desktop_show_current_match_only", "Show current match only") + } else { + readerString("desktop_show_all_search_matches", "Show all search matches") + }, + onClick = onToggleHighlightMode, + modifier = Modifier.size(36.dp) + ) { Icon( if (highlightMode == SearchHighlightMode.ALL) Icons.Default.Visibility else Icons.Default.VisibilityOff, contentDescription = readerString("content_desc_toggle_search_highlights", "Toggle search highlights"), @@ -518,7 +562,12 @@ private fun DesktopPdfSearchNavigationPill( } ) } - IconButton(onClick = onPrevious, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) { + ReaderTooltipIconButton( + tooltip = readerString("tooltip_prev_result_desc", "Jump to the previous search match in the document"), + onClick = onPrevious, + enabled = resultCount > 0, + modifier = Modifier.size(36.dp) + ) { Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = readerString("desktop_previous_search_result", "Previous search result")) } Text( @@ -531,7 +580,12 @@ private fun DesktopPdfSearchNavigationPill( fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable(onClick = onShowResults).padding(horizontal = 8.dp) ) - IconButton(onClick = onNext, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) { + ReaderTooltipIconButton( + tooltip = readerString("tooltip_next_result_desc", "Jump to the next search match in the document"), + onClick = onNext, + enabled = resultCount > 0, + modifier = Modifier.size(36.dp) + ) { Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = readerString("desktop_next_search_result", "Next search result")) } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfFileActions.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActions.kt similarity index 97% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfFileActions.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActions.kt index 24b0089..59c88bf 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfFileActions.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActions.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -7,13 +7,13 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.isSpecified -import com.aryan.reader.shared.SaveMode -import com.aryan.reader.shared.pdf.PdfAnnotationKind -import com.aryan.reader.shared.pdf.SHARED_PDF_PAGE_BREAK_CHAR -import com.aryan.reader.shared.pdf.SharedPdfAnnotation -import com.aryan.reader.shared.pdf.SharedPdfAnnotationExportMapper -import com.aryan.reader.shared.pdf.SharedPdfRichPageLayout -import com.aryan.reader.shared.pdf.sharedPdfTextPageRelativeFontSize +import org.dueattendant149.bookreader.shared.SaveMode +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.SHARED_PDF_PAGE_BREAK_CHAR +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationExportMapper +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichPageLayout +import org.dueattendant149.bookreader.shared.pdf.sharedPdfTextPageRelativeFontSize import java.awt.FileDialog import java.awt.Font import java.awt.Frame diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfInspectorUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfInspectorUi.kt similarity index 54% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfInspectorUi.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfInspectorUi.kt index dbef1b4..780bc1b 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfInspectorUi.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfInspectorUi.kt @@ -1,198 +1,154 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ZoomIn -import androidx.compose.material.icons.filled.ZoomOut +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow -import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.BuiltInPdfReaderThemes -import com.aryan.reader.shared.PdfDisplayMode -import com.aryan.reader.shared.ReaderAiByokSettings -import com.aryan.reader.shared.ReaderAutoScrollState -import com.aryan.reader.shared.ReaderExtrasState -import com.aryan.reader.shared.ReaderExternalLookupAction -import com.aryan.reader.shared.ReaderTtsReadScope -import com.aryan.reader.shared.ReaderTtsReplacementPreferences -import com.aryan.reader.shared.pdf.PdfInkTool -import com.aryan.reader.shared.pdf.PdfSpreadLayout -import com.aryan.reader.shared.pdf.PdfZoomSpec -import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette -import com.aryan.reader.shared.pdf.SharedPdfRichTextController -import com.aryan.reader.shared.pdf.SharedPdfTextStyleConfig -import com.aryan.reader.shared.pdf.currentSharedPdfTextStyleConfig -import com.aryan.reader.shared.pdf.updateCurrentSharedPdfTextStyle -import com.aryan.reader.shared.reader.ReaderPageSpreadMode -import com.aryan.reader.shared.reader.ReaderSettings -import com.aryan.reader.shared.ui.ReaderMinimalSlider -import com.aryan.reader.shared.ui.SharedPdfAnnotationToolDock -import com.aryan.reader.shared.ui.SharedPdfHighlighterPaletteEditor -import com.aryan.reader.shared.ui.SharedPdfTextAnnotationDock -import com.aryan.reader.shared.ui.SharedReaderThemeControls -import com.aryan.reader.shared.ui.SharedReaderVerticalScrollbar -import com.aryan.reader.shared.ui.readerString -import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll +import org.dueattendant149.bookreader.shared.BuiltInPdfReaderThemes +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.ReaderExtrasState +import org.dueattendant149.bookreader.shared.ReaderTheme +import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextController +import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextStyleConfig +import org.dueattendant149.bookreader.shared.pdf.currentSharedPdfTextStyleConfig +import org.dueattendant149.bookreader.shared.pdf.updateCurrentSharedPdfTextStyle +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.ui.SharedPdfHighlighterPaletteEditor +import org.dueattendant149.bookreader.shared.ui.SharedPdfTextAnnotationDock +import org.dueattendant149.bookreader.shared.ui.SharedReaderThemeControls +import org.dueattendant149.bookreader.shared.ui.SharedReaderVerticalScrollbar +import org.dueattendant149.bookreader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.sharedAcceleratedLazyWheelScroll @Composable internal fun DesktopPdfInspectorPanel( document: DesktopPdfDocument, - pageIndex: Int, displayMode: PdfDisplayMode, pdfReaderSettings: ReaderSettings, + appThemeControls: (@Composable () -> Unit)? = null, + customReaderThemes: List, + onCustomReaderThemesChange: (List) -> Unit, customTextureIds: List, onImportTexture: ((ReaderSettings) -> ReaderSettings?)?, onReaderSettingsChange: (ReaderSettings) -> Unit, - zoomControlScale: Float, - zoomSpec: PdfZoomSpec, - isTextSelectionMode: Boolean, selectedTool: PdfInkTool, isRichTextMode: Boolean, - selectedColor: Int, - strokeWidth: Float, - pdfHighlighterColors: List, pdfHighlighterPalette: SharedPdfHighlighterPalette, - isHighlighterSnapEnabled: Boolean, effectiveTextStyleConfig: SharedPdfTextStyleConfig, richTextController: SharedPdfRichTextController, pdfExtrasState: ReaderExtrasState, aiByokSettings: ReaderAiByokSettings, - externalLookupAvailable: Boolean, cloudTtsFeatureAvailable: Boolean, ttsReplacementPreferences: ReaderTtsReplacementPreferences, - pageText: () -> String, onDisplayModeSelected: (PdfDisplayMode) -> Unit, - onPageScrub: (Float) -> Unit, - onPageScrubFinished: () -> Unit, - onZoomOut: () -> Unit, - onZoomIn: () -> Unit, - onZoomChange: (Float) -> Unit, - onSelectPanMode: () -> Unit, - onTextSelectionModeToggle: () -> Unit, onRichTextModeToggle: () -> Unit, - onToolSelected: (PdfInkTool) -> Unit, - onColorSelected: (Int) -> Unit, - onStrokeWidthChange: (Float) -> Unit, - onUndoPage: () -> Unit, - onClearPage: () -> Unit, - onHighlighterSnapChange: (Boolean) -> Unit, onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, onTextStyleChange: (SharedPdfTextStyleConfig) -> Unit, - onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, - onOpenAiHub: (() -> Unit)? = null, - onCloudTtsStart: (ReaderTtsReadScope) -> Unit, - onCloudTtsPauseResume: () -> Unit, - onCloudTtsStop: () -> Unit, onCloudTtsClearCache: () -> Unit, - onAutoScrollChange: (ReaderAutoScrollState) -> Unit, + onCloudTtsVoiceChange: (String) -> Unit, onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit ) { - var selectedPdfInspectorTab by remember(document.handleId) { mutableStateOf(DesktopPdfInspectorTab.VIEW) } - val viewInspectorListState = rememberLazyListState() + val inspectorTabs = remember(appThemeControls != null) { + desktopPdfInspectorTabs(appThemeControlsAvailable = appThemeControls != null) + } + var selectedPdfInspectorTab by remember(document.handleId) { mutableStateOf(DesktopPdfInspectorTab.VISUAL) } + LaunchedEffect(inspectorTabs) { + if (selectedPdfInspectorTab !in inspectorTabs) { + selectedPdfInspectorTab = DesktopPdfInspectorTab.VISUAL.takeIf { it in inspectorTabs } + ?: inspectorTabs.first() + } + } + val appThemeInspectorListState = rememberLazyListState() + val appearanceInspectorListState = rememberLazyListState() + val visualInspectorListState = rememberLazyListState() val markupInspectorListState = rememberLazyListState() - val assistInspectorListState = rememberLazyListState() + val ttsInspectorListState = rememberLazyListState() val pdfInspectorListState = when (selectedPdfInspectorTab) { - DesktopPdfInspectorTab.VIEW -> viewInspectorListState + DesktopPdfInspectorTab.APP_THEME -> appThemeInspectorListState + DesktopPdfInspectorTab.APPEARANCE -> appearanceInspectorListState + DesktopPdfInspectorTab.VISUAL -> visualInspectorListState DesktopPdfInspectorTab.MARKUP -> markupInspectorListState - DesktopPdfInspectorTab.ASSIST -> assistInspectorListState + DesktopPdfInspectorTab.TTS -> ttsInspectorListState } Surface( - modifier = Modifier - .width(340.dp) - .fillMaxHeight(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(8.dp) + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(0.dp) ) { Column(modifier = Modifier.fillMaxSize()) { DesktopPdfInspectorHeader( + tabs = inspectorTabs, selectedTab = selectedPdfInspectorTab, onTabSelected = { selectedPdfInspectorTab = it } ) HorizontalDivider() DesktopPdfInspectorContent( document = document, - pageIndex = pageIndex, displayMode = displayMode, pdfReaderSettings = pdfReaderSettings, + appThemeControls = appThemeControls, + customReaderThemes = customReaderThemes, + onCustomReaderThemesChange = onCustomReaderThemesChange, customTextureIds = customTextureIds, onImportTexture = onImportTexture, onReaderSettingsChange = onReaderSettingsChange, - zoomControlScale = zoomControlScale, - zoomSpec = zoomSpec, - isTextSelectionMode = isTextSelectionMode, selectedTool = selectedTool, isRichTextMode = isRichTextMode, - selectedColor = selectedColor, - strokeWidth = strokeWidth, - pdfHighlighterColors = pdfHighlighterColors, pdfHighlighterPalette = pdfHighlighterPalette, - isHighlighterSnapEnabled = isHighlighterSnapEnabled, effectiveTextStyleConfig = effectiveTextStyleConfig, richTextController = richTextController, pdfExtrasState = pdfExtrasState, aiByokSettings = aiByokSettings, - externalLookupAvailable = externalLookupAvailable, cloudTtsFeatureAvailable = cloudTtsFeatureAvailable, ttsReplacementPreferences = ttsReplacementPreferences, - pageText = pageText, selectedTab = selectedPdfInspectorTab, listState = pdfInspectorListState, onDisplayModeSelected = onDisplayModeSelected, - onPageScrub = onPageScrub, - onPageScrubFinished = onPageScrubFinished, - onZoomOut = onZoomOut, - onZoomIn = onZoomIn, - onZoomChange = onZoomChange, - onSelectPanMode = onSelectPanMode, - onTextSelectionModeToggle = onTextSelectionModeToggle, onRichTextModeToggle = onRichTextModeToggle, - onToolSelected = onToolSelected, - onColorSelected = onColorSelected, - onStrokeWidthChange = onStrokeWidthChange, - onUndoPage = onUndoPage, - onClearPage = onClearPage, - onHighlighterSnapChange = onHighlighterSnapChange, onHighlighterPaletteChange = onHighlighterPaletteChange, onTextStyleChange = onTextStyleChange, - onExternalLookup = onExternalLookup, - onOpenAiHub = onOpenAiHub, - onCloudTtsStart = onCloudTtsStart, - onCloudTtsPauseResume = onCloudTtsPauseResume, - onCloudTtsStop = onCloudTtsStop, onCloudTtsClearCache = onCloudTtsClearCache, - onAutoScrollChange = onAutoScrollChange, + onCloudTtsVoiceChange = onCloudTtsVoiceChange, onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange ) } @@ -201,32 +157,33 @@ internal fun DesktopPdfInspectorPanel( @Composable private fun DesktopPdfInspectorHeader( + tabs: List, selectedTab: DesktopPdfInspectorTab, onTabSelected: (DesktopPdfInspectorTab) -> Unit ) { - Column( - modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + ScrollableTabRow( + selectedTabIndex = tabs.indexOf(selectedTab).coerceAtLeast(0), + edgePadding = 0.dp, + modifier = Modifier.fillMaxWidth() ) { - Text(readerString("desktop_pdf_tools", "PDF tools"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - ScrollableTabRow( - selectedTabIndex = selectedTab.ordinal, - edgePadding = 0.dp, - modifier = Modifier.fillMaxWidth() - ) { - DesktopPdfInspectorTab.values().forEach { tab -> - Tab( - selected = selectedTab == tab, - onClick = { onTabSelected(tab) }, - text = { - Text( - tab.localizedTitle(), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - ) - } + tabs.forEach { tab -> + Tab( + selected = selectedTab == tab, + onClick = { onTabSelected(tab) }, + icon = { + Icon( + tab.icon(), + contentDescription = null + ) + }, + text = { + Text( + tab.localizedTitle(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) } } } @@ -234,56 +191,31 @@ private fun DesktopPdfInspectorHeader( @Composable private fun ColumnScope.DesktopPdfInspectorContent( document: DesktopPdfDocument, - pageIndex: Int, displayMode: PdfDisplayMode, pdfReaderSettings: ReaderSettings, + appThemeControls: (@Composable () -> Unit)?, + customReaderThemes: List, + onCustomReaderThemesChange: (List) -> Unit, customTextureIds: List, onImportTexture: ((ReaderSettings) -> ReaderSettings?)?, onReaderSettingsChange: (ReaderSettings) -> Unit, - zoomControlScale: Float, - zoomSpec: PdfZoomSpec, - isTextSelectionMode: Boolean, selectedTool: PdfInkTool, isRichTextMode: Boolean, - selectedColor: Int, - strokeWidth: Float, - pdfHighlighterColors: List, pdfHighlighterPalette: SharedPdfHighlighterPalette, - isHighlighterSnapEnabled: Boolean, effectiveTextStyleConfig: SharedPdfTextStyleConfig, richTextController: SharedPdfRichTextController, pdfExtrasState: ReaderExtrasState, aiByokSettings: ReaderAiByokSettings, - externalLookupAvailable: Boolean, cloudTtsFeatureAvailable: Boolean, ttsReplacementPreferences: ReaderTtsReplacementPreferences, - pageText: () -> String, selectedTab: DesktopPdfInspectorTab, listState: LazyListState, onDisplayModeSelected: (PdfDisplayMode) -> Unit, - onPageScrub: (Float) -> Unit, - onPageScrubFinished: () -> Unit, - onZoomOut: () -> Unit, - onZoomIn: () -> Unit, - onZoomChange: (Float) -> Unit, - onSelectPanMode: () -> Unit, - onTextSelectionModeToggle: () -> Unit, onRichTextModeToggle: () -> Unit, - onToolSelected: (PdfInkTool) -> Unit, - onColorSelected: (Int) -> Unit, - onStrokeWidthChange: (Float) -> Unit, - onUndoPage: () -> Unit, - onClearPage: () -> Unit, - onHighlighterSnapChange: (Boolean) -> Unit, onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, onTextStyleChange: (SharedPdfTextStyleConfig) -> Unit, - onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, - onOpenAiHub: (() -> Unit)?, - onCloudTtsStart: (ReaderTtsReadScope) -> Unit, - onCloudTtsPauseResume: () -> Unit, - onCloudTtsStop: () -> Unit, onCloudTtsClearCache: () -> Unit, - onAutoScrollChange: (ReaderAutoScrollState) -> Unit, + onCloudTtsVoiceChange: (String) -> Unit, onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit ) { Box(modifier = Modifier.weight(1f).fillMaxWidth()) { @@ -296,14 +228,54 @@ private fun ColumnScope.DesktopPdfInspectorContent( verticalArrangement = Arrangement.spacedBy(14.dp) ) { when (selectedTab) { - DesktopPdfInspectorTab.VIEW -> { + DesktopPdfInspectorTab.APP_THEME -> { + appThemeControls?.let { controls -> + item { + controls() + } + } + } + DesktopPdfInspectorTab.APPEARANCE -> { item { - DesktopPdfInspectorSection(readerString("label_reading", "Reading")) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + DesktopPdfInspectorSection(readerString("desktop_pdf_theme", "PDF theme")) { + SharedReaderThemeControls( + settings = pdfReaderSettings, + builtInThemes = BuiltInPdfReaderThemes, + customThemes = customReaderThemes, + onCustomThemesChange = onCustomReaderThemesChange, + customTextureIds = customTextureIds, + onImportTexture = onImportTexture, + texturePreviewContent = { textureId, previewModifier -> + DesktopReaderTexturePreview(textureId = textureId, modifier = previewModifier) + }, + onSettingsChange = onReaderSettingsChange + ) + } + } + } + DesktopPdfInspectorTab.VISUAL -> { + item { + DesktopPdfInspectorSection(readerString("visual_options_title", "Visual options")) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { FilterChip( - selected = displayMode == PdfDisplayMode.PAGINATION, - onClick = { onDisplayModeSelected(PdfDisplayMode.PAGINATION) }, - label = { Text(readerString("desktop_page", "Page")) } + selected = displayMode == PdfDisplayMode.PAGINATION && !pdfReaderSettings.rightToLeftPagination, + onClick = { + onReaderSettingsChange(pdfReaderSettings.copy(rightToLeftPagination = false)) + onDisplayModeSelected(PdfDisplayMode.PAGINATION) + }, + label = { Text(readerString("menu_reading_mode_paginated", "Paginated (left-to-right)")) } + ) + FilterChip( + selected = displayMode == PdfDisplayMode.PAGINATION && pdfReaderSettings.rightToLeftPagination, + onClick = { + onReaderSettingsChange(pdfReaderSettings.copy(rightToLeftPagination = true)) + onDisplayModeSelected(PdfDisplayMode.PAGINATION) + }, + label = { Text(readerString("menu_right_to_left_pagination", "Paginated (right-to-left)")) } ) FilterChip( selected = displayMode == PdfDisplayMode.VERTICAL_SCROLL, @@ -348,53 +320,11 @@ private fun ColumnScope.DesktopPdfInspectorContent( ) } } - } - } - item { - DesktopPdfInspectorSection(readerString("visual_options_progress_bar_position", "Position")) { - val pageRange = if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.pageRangeLabel(pageIndex, document.pageCount, pdfReaderSettings) - } else { - "${pageIndex + 1}" - } - Text( - if ('-' in pageRange) { - readerString("desktop_pdf_pages_of_count", "Pages %1\$s of %2\$d", pageRange, document.pageCount) - } else { - readerString("desktop_pdf_page_of_count", "Page %1\$s of %2\$d", pageRange, document.pageCount) - }, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (document.pageCount > 1) { - ReaderMinimalSlider( - value = pageIndex.toFloat(), - onValueChange = onPageScrub, - onValueChangeFinished = onPageScrubFinished, - valueRange = 0f..(document.pageCount - 1).toFloat() - ) - } - } - } - item { - DesktopPdfInspectorSection(readerString("app_theme_appearance", "Appearance")) { - SharedReaderThemeControls( - settings = pdfReaderSettings, - builtInThemes = BuiltInPdfReaderThemes, - customTextureIds = customTextureIds, - onImportTexture = onImportTexture, - onSettingsChange = onReaderSettingsChange - ) - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - Text( - readerString("visual_options_title", "Visual options"), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) DesktopPdfVisualOptionSwitch( title = readerString("visual_options_remove_page_gap", "Remove gap between pages"), description = readerString( "desktop_remove_gap_between_pages_desc", - "Applies to vertical reading mode." + "Applies to vertical reading and two-page spreads." ), checked = !pdfReaderSettings.pdfVerticalPageGapVisible, onCheckedChange = { removeGap -> @@ -418,43 +348,11 @@ private fun ColumnScope.DesktopPdfInspectorContent( ) } } - item { - DesktopPdfInspectorSection(readerString("desktop_zoom", "Zoom")) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onZoomOut) { - Icon(Icons.Default.ZoomOut, contentDescription = readerString("desktop_zoom_out", "Zoom out")) - } - Text( - "${(zoomControlScale * 100).toInt()}%", - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center - ) - IconButton(onClick = onZoomIn) { - Icon(Icons.Default.ZoomIn, contentDescription = readerString("desktop_zoom_in", "Zoom in")) - } - } - Slider( - value = zoomControlScale, - onValueChange = onZoomChange, - valueRange = zoomSpec.min..zoomSpec.max - ) - } - } } DesktopPdfInspectorTab.MARKUP -> { item { - DesktopPdfInspectorSection(readerString("desktop_interaction", "Interaction")) { + DesktopPdfInspectorSection(readerString("desktop_document_text", "Document text")) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - FilterChip( - selected = !isTextSelectionMode && selectedTool == PdfInkTool.NONE && !isRichTextMode, - onClick = onSelectPanMode, - label = { Text(readerString("desktop_pan", "Pan")) } - ) - FilterChip( - selected = isTextSelectionMode, - onClick = onTextSelectionModeToggle, - label = { Text(readerString("desktop_select_text", "Select text")) } - ) FilterChip( selected = isRichTextMode, onClick = onRichTextModeToggle, @@ -463,24 +361,6 @@ private fun ColumnScope.DesktopPdfInspectorContent( } } } - item { - DesktopPdfInspectorSection(readerString("desktop_annotation_tools", "Annotation tools")) { - SharedPdfAnnotationToolDock( - selectedTool = selectedTool, - selectedColor = selectedColor, - strokeWidth = strokeWidth, - tools = DesktopPdfAnnotationTools, - highlighterPalette = pdfHighlighterColors, - onToolSelected = onToolSelected, - onColorSelected = onColorSelected, - onStrokeWidthChange = onStrokeWidthChange, - onUndo = onUndoPage, - onClearPage = onClearPage, - isHighlighterSnapEnabled = isHighlighterSnapEnabled, - onHighlighterSnapChange = onHighlighterSnapChange - ) - } - } item { DesktopPdfInspectorSection(readerString("desktop_highlighter_palette", "Highlighter palette")) { SharedPdfHighlighterPaletteEditor( @@ -510,21 +390,14 @@ private fun ColumnScope.DesktopPdfInspectorContent( } } } - DesktopPdfInspectorTab.ASSIST -> { + DesktopPdfInspectorTab.TTS -> { item { - DesktopPdfExtrasPanel( - pageText = pageText(), + DesktopPdfTtsPanel( extrasState = pdfExtrasState, aiByokSettings = aiByokSettings, - externalLookupAvailable = externalLookupAvailable, cloudTtsFeatureAvailable = cloudTtsFeatureAvailable, - onExternalLookup = onExternalLookup, - onOpenAiHub = onOpenAiHub, - onCloudTtsStart = onCloudTtsStart, - onCloudTtsPauseResume = onCloudTtsPauseResume, - onCloudTtsStop = onCloudTtsStop, onCloudTtsClearCache = onCloudTtsClearCache, - onAutoScrollChange = onAutoScrollChange, + onCloudTtsVoiceChange = onCloudTtsVoiceChange, ttsReplacementPreferences = ttsReplacementPreferences, ttsReplacementBookId = document.path, onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange @@ -543,8 +416,29 @@ private fun ColumnScope.DesktopPdfInspectorContent( @Composable private fun DesktopPdfInspectorTab.localizedTitle(): String { return when (this) { - DesktopPdfInspectorTab.VIEW -> readerString("desktop_view", "View") + DesktopPdfInspectorTab.APP_THEME -> readerString("app_theme_title", "App theme") + DesktopPdfInspectorTab.APPEARANCE -> readerString("desktop_pdf_theme", "PDF theme") + DesktopPdfInspectorTab.VISUAL -> readerString("visual_options_title", "Visual") DesktopPdfInspectorTab.MARKUP -> readerString("desktop_markup", "Markup") - DesktopPdfInspectorTab.ASSIST -> readerString("desktop_assist", "Assist") + DesktopPdfInspectorTab.TTS -> readerString("menu_tts_settings", "TTS") + } +} + +private fun DesktopPdfInspectorTab.icon(): ImageVector { + return when (this) { + DesktopPdfInspectorTab.APP_THEME -> Icons.Default.Palette + DesktopPdfInspectorTab.APPEARANCE -> Icons.Default.Palette + DesktopPdfInspectorTab.VISUAL -> Icons.Default.Tune + DesktopPdfInspectorTab.MARKUP -> Icons.Default.Edit + DesktopPdfInspectorTab.TTS -> Icons.AutoMirrored.Filled.VolumeUp + } +} + +private fun desktopPdfInspectorTabs(appThemeControlsAvailable: Boolean): List { + return buildList { + add(DesktopPdfInspectorTab.APPEARANCE) + if (appThemeControlsAvailable) add(DesktopPdfInspectorTab.APP_THEME) + add(DesktopPdfInspectorTab.VISUAL) + add(DesktopPdfInspectorTab.TTS) } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfKeyCommands.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfKeyCommands.kt similarity index 74% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfKeyCommands.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfKeyCommands.kt index b337a00..a5b3adb 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfKeyCommands.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfKeyCommands.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent @@ -23,7 +23,8 @@ internal enum class DesktopPdfKeyCommand { internal fun KeyEvent.desktopPdfKeyCommandOrNull( fullscreen: Boolean, - editingText: Boolean + editingText: Boolean, + rightToLeftPagination: Boolean = false ): DesktopPdfKeyCommand? { if (type != KeyEventType.KeyDown) return null if (fullscreen && key == Key.Escape) { @@ -33,8 +34,16 @@ internal fun KeyEvent.desktopPdfKeyCommandOrNull( return null } return when { - key == Key.DirectionLeft -> DesktopPdfKeyCommand.PREVIOUS_PAGE - key == Key.DirectionRight -> DesktopPdfKeyCommand.NEXT_PAGE + key == Key.DirectionLeft -> if (rightToLeftPagination) { + DesktopPdfKeyCommand.NEXT_PAGE + } else { + DesktopPdfKeyCommand.PREVIOUS_PAGE + } + key == Key.DirectionRight -> if (rightToLeftPagination) { + DesktopPdfKeyCommand.PREVIOUS_PAGE + } else { + DesktopPdfKeyCommand.NEXT_PAGE + } key == Key.DirectionUp -> DesktopPdfKeyCommand.SCROLL_UP key == Key.DirectionDown -> DesktopPdfKeyCommand.SCROLL_DOWN key == Key.PageUp -> DesktopPdfKeyCommand.PREVIOUS_PAGE @@ -50,7 +59,8 @@ internal fun KeyEvent.desktopPdfKeyCommandOrNull( internal fun AwtKeyEvent.desktopPdfKeyCommandOrNull( fullscreen: Boolean, - editingText: Boolean + editingText: Boolean, + rightToLeftPagination: Boolean = false ): DesktopPdfKeyCommand? { if (id != AwtKeyEvent.KEY_PRESSED) return null if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) { @@ -60,8 +70,16 @@ internal fun AwtKeyEvent.desktopPdfKeyCommandOrNull( return null } return when (keyCode) { - AwtKeyEvent.VK_LEFT -> DesktopPdfKeyCommand.PREVIOUS_PAGE - AwtKeyEvent.VK_RIGHT -> DesktopPdfKeyCommand.NEXT_PAGE + AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) { + DesktopPdfKeyCommand.NEXT_PAGE + } else { + DesktopPdfKeyCommand.PREVIOUS_PAGE + } + AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) { + DesktopPdfKeyCommand.PREVIOUS_PAGE + } else { + DesktopPdfKeyCommand.NEXT_PAGE + } AwtKeyEvent.VK_UP -> DesktopPdfKeyCommand.SCROLL_UP AwtKeyEvent.VK_DOWN -> DesktopPdfKeyCommand.SCROLL_DOWN AwtKeyEvent.VK_PAGE_UP -> DesktopPdfKeyCommand.PREVIOUS_PAGE diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationUi.kt similarity index 71% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationUi.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationUi.kt index b1ec704..d9ee5b1 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationUi.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationUi.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -38,9 +39,11 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Surface @@ -59,17 +62,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.PdfTocEntry -import com.aryan.reader.shared.pdf.PdfAnnotationKind -import com.aryan.reader.shared.pdf.SharedPdfAnnotation -import com.aryan.reader.shared.pdf.SharedPdfBookmark -import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation -import com.aryan.reader.shared.ui.SharedReaderVerticalScrollbar -import com.aryan.reader.shared.ui.readerString -import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll +import org.dueattendant149.bookreader.shared.PdfTocEntry +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmark +import org.dueattendant149.bookreader.shared.ui.SharedReaderVerticalScrollbar +import org.dueattendant149.bookreader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.sharedAcceleratedLazyWheelScroll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -199,27 +202,28 @@ internal fun desktopVisiblePdfTocEntries( return result } +internal fun desktopPdfSidebarHighlights(annotations: List): List { + return annotations + .filter { it.kind == PdfAnnotationKind.HIGHLIGHT } + .sortedBy { it.pageIndex } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun DesktopPdfNavigationSidebar( document: DesktopPdfDocument, pageIndex: Int, - sortedAnnotations: List, - sortedEmbeddedAnnotations: List, + sortedHighlights: List, bookmarks: List, - selectedAnnotationId: String?, - selectedEmbeddedAnnotationId: String?, onPageSelected: (Int) -> Unit, onAnnotationOpened: (SharedPdfAnnotation) -> Unit, onAnnotationSelected: (SharedPdfAnnotation) -> Unit, - onAnnotationDeleted: (SharedPdfAnnotation) -> Unit, - onEmbeddedAnnotationOpened: (SharedPdfEmbeddedAnnotation) -> Unit, - onEmbeddedAnnotationSelected: (SharedPdfEmbeddedAnnotation) -> Unit + onAnnotationDeleted: (SharedPdfAnnotation) -> Unit ) { val documentHandleId = document.handleId val tabs = listOf( readerString("desktop_toc", "TOC"), - readerString("tab_annotations", "Annotations"), + readerString("tab_highlights", "Highlights"), readerString("tab_bookmarks", "Bookmarks"), readerString("tab_pages", "Pages") ) @@ -344,180 +348,158 @@ internal fun DesktopPdfNavigationSidebar( } } 1 -> { - if (sortedAnnotations.isEmpty() && sortedEmbeddedAnnotations.isEmpty()) { - DesktopPdfNavigationEmpty(readerString("desktop_no_annotations_yet", "No annotations yet")) + if (sortedHighlights.isEmpty()) { + DesktopPdfNavigationEmpty(readerString("no_highlights_yet", "No highlights yet")) } else { - val annotationsListState = rememberLazyListState() - var annotationMenuExpandedFor by remember { mutableStateOf(null) } - var embeddedAnnotationMenuExpandedFor by remember { mutableStateOf(null) } - var deleteAnnotationConfirmFor by remember { mutableStateOf(null) } - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( - state = annotationsListState, - modifier = Modifier - .fillMaxSize() - .sharedAcceleratedLazyWheelScroll(annotationsListState) - .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(sortedAnnotations, key = { "nav_annotation_${it.id}" }) { annotation -> - Surface( - color = if (annotation.id == selectedAnnotationId) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth() - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column( - modifier = Modifier - .weight(1f) - .clickable { onAnnotationOpened(annotation) } - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(3.dp) - ) { - Text( - annotation.desktopLabel(), - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - annotation.note?.takeIf { it.isNotBlank() }?.let { note -> - Text( - note, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } - Box { - IconButton(onClick = { annotationMenuExpandedFor = annotation }) { - Icon(Icons.Default.MoreVert, contentDescription = readerString("desktop_annotation_options", "Annotation options")) - } - DropdownMenu( - expanded = annotationMenuExpandedFor == annotation, - onDismissRequest = { annotationMenuExpandedFor = null } - ) { - DropdownMenuItem( - text = { - Text( - if (annotation.note.isNullOrBlank() && - annotation.kind != PdfAnnotationKind.TEXT - ) { - readerString("menu_add_note", "Add note") - } else { - readerString("action_edit", "Edit") - } - ) - }, - onClick = { - annotationMenuExpandedFor = null - onAnnotationSelected(annotation) - } - ) - DropdownMenuItem( - text = { Text(readerString("action_delete", "Delete")) }, - onClick = { - annotationMenuExpandedFor = null - deleteAnnotationConfirmFor = annotation - } - ) - } - } - } - } - } - items(sortedEmbeddedAnnotations, key = { "nav_embedded_${it.id}" }) { annotation -> - Surface( - color = if (annotation.id == selectedEmbeddedAnnotationId) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth() - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column( - modifier = Modifier - .weight(1f) - .clickable { onEmbeddedAnnotationOpened(annotation) } - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(3.dp) - ) { - Text( - annotation.author.ifBlank { readerString("desktop_pdf_comment", "PDF comment") }, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - annotation.contents.takeIf { it.isNotBlank() }?.let { contents -> - Text( - contents, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } - Box { - IconButton(onClick = { embeddedAnnotationMenuExpandedFor = annotation }) { - Icon(Icons.Default.MoreVert, contentDescription = readerString("desktop_comment_options", "Comment options")) - } - DropdownMenu( - expanded = embeddedAnnotationMenuExpandedFor == annotation, - onDismissRequest = { embeddedAnnotationMenuExpandedFor = null } - ) { - DropdownMenuItem( - text = { Text(readerString("desktop_open_comment", "Open comment")) }, - onClick = { - embeddedAnnotationMenuExpandedFor = null - onEmbeddedAnnotationSelected(annotation) - } - ) - } - } - } - } - } + val highlightsListState = rememberLazyListState() + var deleteHighlightConfirmFor by remember { mutableStateOf(null) } + var filterWithNotesOnly by remember { mutableStateOf(false) } + val filteredHighlights = remember(sortedHighlights, filterWithNotesOnly) { + if (filterWithNotesOnly) { + sortedHighlights.filter { !it.note.isNullOrBlank() } + } else { + sortedHighlights } - SharedReaderVerticalScrollbar( - listState = annotationsListState, - modifier = Modifier.align(Alignment.CenterEnd) - ) } - deleteAnnotationConfirmFor?.let { annotation -> + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = !filterWithNotesOnly, + onClick = { filterWithNotesOnly = false }, + label = { Text(readerString("read_status_all", "All")) } + ) + FilterChip( + selected = filterWithNotesOnly, + onClick = { filterWithNotesOnly = true }, + label = { Text(readerString("filter_with_notes", "With notes")) } + ) + } + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = highlightsListState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(highlightsListState) + .padding(end = 12.dp) + ) { + items(filteredHighlights, key = { "nav_highlight_${it.id}" }) { highlight -> + ListItem( + headlineContent = { + Text( + text = highlight.text.ifBlank { + readerString( + "msg_highlighted_section_default", + "Highlighted section" + ) + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.SemiBold + ) + }, + supportingContent = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(12.dp) + .background(Color(highlight.colorArgb).copy(alpha = 1f), CircleShape) + ) + Spacer(Modifier.width(8.dp)) + Text( + readerString("pdf_page_short", "Page %1\$d", highlight.pageIndex + 1), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + highlight.note?.takeIf { it.isNotBlank() }?.let { note -> + Spacer(Modifier.height(8.dp)) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = note, + style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic), + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + }, + trailingContent = { + Box { + var highlightMenuExpanded by remember(highlight.id) { mutableStateOf(false) } + IconButton(onClick = { highlightMenuExpanded = true }) { + Icon( + Icons.Default.MoreVert, + contentDescription = readerString("content_desc_options", "Options") + ) + } + DropdownMenu( + expanded = highlightMenuExpanded, + onDismissRequest = { highlightMenuExpanded = false } + ) { + DropdownMenuItem( + text = { + Text( + if (highlight.note.isNullOrBlank()) { + readerString("menu_add_note", "Add note") + } else { + readerString("menu_edit_note", "Edit note") + } + ) + }, + onClick = { + onAnnotationSelected(highlight) + highlightMenuExpanded = false + } + ) + DropdownMenuItem( + text = { Text(readerString("action_delete", "Delete")) }, + onClick = { + deleteHighlightConfirmFor = highlight + highlightMenuExpanded = false + } + ) + } + } + }, + modifier = Modifier.clickable { onAnnotationOpened(highlight) } + ) + HorizontalDivider() + } + } + SharedReaderVerticalScrollbar( + listState = highlightsListState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } + + deleteHighlightConfirmFor?.let { highlight -> AlertDialog( - onDismissRequest = { deleteAnnotationConfirmFor = null }, - title = { Text(readerString("desktop_delete_annotation_title", "Delete annotation?")) }, - text = { Text(readerString("desktop_delete_annotation_desc", "This removes the annotation from this PDF.")) }, + onDismissRequest = { deleteHighlightConfirmFor = null }, + title = { Text(readerString("dialog_delete_highlight", "Delete highlight?")) }, + text = { Text(readerString("dialog_delete_highlight_desc", "This removes the highlight from this PDF.")) }, confirmButton = { TextButton( onClick = { - deleteAnnotationConfirmFor = null - onAnnotationDeleted(annotation) + onAnnotationDeleted(highlight) + deleteHighlightConfirmFor = null } ) { - Text(readerString("action_delete", "Delete"), color = MaterialTheme.colorScheme.error) + Text(readerString("action_delete", "Delete")) } }, dismissButton = { - TextButton(onClick = { deleteAnnotationConfirmFor = null }) { + TextButton(onClick = { deleteHighlightConfirmFor = null }) { Text(readerString("action_cancel", "Cancel")) } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPage.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPage.kt similarity index 77% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPage.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPage.kt index df27031..bf7c1e0 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPage.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPage.kt @@ -1,5 +1,7 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown @@ -38,35 +40,37 @@ import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.ReaderTtsChunk -import com.aryan.reader.shared.SearchHighlightMode -import com.aryan.reader.shared.pdf.PdfAnnotationKind -import com.aryan.reader.shared.pdf.PdfInkTool -import com.aryan.reader.shared.pdf.PdfPageBounds -import com.aryan.reader.shared.pdf.PdfPagePoint -import com.aryan.reader.shared.pdf.PdfZoomSpec -import com.aryan.reader.shared.pdf.SharedPdfAnnotation -import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation -import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette -import com.aryan.reader.shared.pdf.SharedPdfRichTextController -import com.aryan.reader.shared.pdf.SharedPdfSearchEngine -import com.aryan.reader.shared.pdf.SharedPdfSearchResult -import com.aryan.reader.shared.pdf.SharedPdfTextDraft -import com.aryan.reader.shared.pdf.sharedPdfTextStyle -import com.aryan.reader.shared.ui.SharedPdfAnnotationOverlay -import com.aryan.reader.shared.ui.SharedPdfEmbeddedAnnotationOverlay -import com.aryan.reader.shared.ui.SharedPdfInlineTextEditorOverlay -import com.aryan.reader.shared.ui.SharedPdfPageNumberOverlay -import com.aryan.reader.shared.ui.SharedPdfRichTextLayer -import com.aryan.reader.shared.ui.SharedPdfTextBoxEditorOverlay -import com.aryan.reader.shared.ui.readerString -import com.aryan.reader.shared.ui.sharedPdfEmbeddedHitTest -import com.aryan.reader.shared.ui.sharedPdfHitTest -import com.aryan.reader.shared.ui.toSharedPdfPoint +import org.dueattendant149.bookreader.shared.ReaderTtsChunk +import org.dueattendant149.bookreader.shared.SearchHighlightMode +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint +import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextController +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchEngine +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult +import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextDraft +import org.dueattendant149.bookreader.shared.pdf.sharedPdfTextStyle +import org.dueattendant149.bookreader.shared.ui.SharedPdfAnnotationOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfEmbeddedAnnotationOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfInlineTextEditorOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfPageNumberOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfRichTextLayer +import org.dueattendant149.bookreader.shared.ui.SharedPdfTextBoxEditorOverlay +import org.dueattendant149.bookreader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.sharedPdfEmbeddedHitTest +import org.dueattendant149.bookreader.shared.ui.sharedPdfHitTest +import org.dueattendant149.bookreader.shared.ui.toSharedPdfPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +private const val DesktopVerticalPdfPageTurnAnimationMillis = 140 + @Composable internal fun DesktopVerticalPdfPage( document: DesktopPdfDocument, @@ -96,6 +100,8 @@ internal fun DesktopVerticalPdfPage( themeStyle: DesktopPdfThemeStyle, shouldRender: Boolean, zoomPreview: DesktopPdfZoomPreview?, + zoomPreviewAnchorPageRootOffset: Offset? = null, + zoomPreviewScrollBounds: DesktopPdfZoomScrollBounds? = null, zoomViewportRootOffset: Offset, showPageNumberOverlay: Boolean = true, onSelectPage: (Int) -> Unit, @@ -116,13 +122,16 @@ internal fun DesktopVerticalPdfPage( onTextDraftChanged: (String, IntSize) -> Unit, onTextDraftBoundsChanged: (PdfPageBounds) -> Unit, onPan: (Offset) -> Unit, + onPageSizeChanged: (Int, IntSize) -> Unit = { _, _ -> }, onPagePositioned: (Int, Offset) -> Unit ) { val documentHandleId = document.handleId val density = LocalDensity.current - var renderedPage by remember(documentHandleId, pageIndex) { mutableStateOf(null) } - var renderError by remember(documentHandleId, pageIndex) { mutableStateOf(null) } - var isRendering by remember(documentHandleId, pageIndex) { mutableStateOf(true) } + var renderedPage by remember(documentHandleId) { mutableStateOf(null) } + var renderedPageIndex by remember(documentHandleId) { mutableStateOf(null) } + var renderedPageScale by remember(documentHandleId) { mutableStateOf(null) } + var renderError by remember(documentHandleId) { mutableStateOf(null) } + var isRendering by remember(documentHandleId) { mutableStateOf(true) } var pageCanvasSize by remember(documentHandleId, pageIndex) { mutableStateOf(IntSize.Zero) } var pageRootOffset by remember(documentHandleId, pageIndex) { mutableStateOf(Offset.Zero) } var selectionStartIndex by remember(documentHandleId, pageIndex) { mutableStateOf(null) } @@ -156,34 +165,73 @@ internal fun DesktopVerticalPdfPage( LaunchedEffect(documentHandleId, pageIndex, scale, shouldRender) { if (!shouldRender) { + logPdfZoomSettle { + "item_render_skip page=${pageIndex + 1} reason=outside_window scale=${scale.formatLogFloat()}" + } renderedPage = null + renderedPageIndex = null + renderedPageScale = null renderError = null isRendering = false clearInteractionState() return@LaunchedEffect } - val hasPageRender = renderedPage != null + val hasPageRender = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) + logPdfZoomSettle { + "item_render_effect page=${pageIndex + 1} scale=${scale.formatLogFloat()} shouldRender=$shouldRender " + + "hasRender=$hasPageRender renderedPage=${renderedPageIndex?.plus(1) ?: "none"} " + + "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"}" + } if (!hasPageRender) { + renderedPage = null + renderedPageIndex = null + renderedPageScale = null isRendering = true } renderError = null val pageSize = document.pageSizes.getOrNull(pageIndex) if (pageSize == null) { renderedPage = null + renderedPageIndex = null + renderedPageScale = null renderError = failedRenderMessage isRendering = false return@LaunchedEffect } + val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) + if (hasPageRender && !desktopPdfRenderScaleNeedsUpgrade(renderedPageScale, safeScale)) { + logPdfZoomSettle { + "item_render_skip page=${pageIndex + 1} reason=no_scale_upgrade " + + "safeScale=${safeScale.formatLogFloat()} existingScale=${renderedPageScale?.formatLogFloat() ?: "none"}" + } + isRendering = false + return@LaunchedEffect + } + logPdfZoomSettle { + "item_render_scheduled page=${pageIndex + 1} safeScale=${safeScale.formatLogFloat()} " + + "delayMs=${if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L} hasRender=$hasPageRender" + } delay(if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L) isRendering = true - val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) + val renderStartedAt = System.currentTimeMillis() val result = withContext(Dispatchers.IO) { runCatching { DesktopPdfium.renderPage(document, pageIndex, safeScale) } } - result.getOrNull()?.let { renderedPage = it } + val renderElapsedMs = System.currentTimeMillis() - renderStartedAt + result.getOrNull()?.let { + renderedPage = it + renderedPageIndex = pageIndex + renderedPageScale = safeScale + } + val renderedCurrentPage = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) renderError = result.exceptionOrNull()?.message - ?: if (renderedPage == null) failedRenderMessage else null + ?: if (!renderedCurrentPage && renderedPage == null) failedRenderMessage else null isRendering = false + logPdfZoomSettle { + "item_render_end page=${pageIndex + 1} safeScale=${safeScale.formatLogFloat()} " + + "elapsedMs=$renderElapsedMs success=${result.isSuccess} bitmap=${renderedPage?.width ?: 0}x${renderedPage?.height ?: 0} " + + "canvas=${pageCanvasSize.formatLogSize()} root=${pageRootOffset.formatLogOffset()}" + } } LaunchedEffect(isTextSelectionMode) { @@ -205,6 +253,8 @@ internal fun DesktopVerticalPdfPage( verticalArrangement = Arrangement.spacedBy(6.dp) ) { val pageSize = document.pageSizes.getOrNull(pageIndex) + val displayPageIndex = renderedPageIndex ?: pageIndex + val displayPageIsCurrent = displayPageIndex == pageIndex val placeholderScale = zoomSpec.clamp(scale) val placeholderWidthDp = with(density) { ((pageSize?.width ?: 612f) * placeholderScale).toDp() } val placeholderHeightDp = with(density) { ((pageSize?.height ?: 792f) * placeholderScale).toDp() } @@ -224,24 +274,60 @@ internal fun DesktopVerticalPdfPage( .size(placeholderWidthDp, placeholderHeightDp) .onGloballyPositioned { coordinates -> val rootOffset = coordinates.positionInRoot() + if (rootOffset != pageRootOffset) { + logPdfZoomSettle { + "item_layout page=${pageIndex + 1} prevRoot=${pageRootOffset.formatLogOffset()} " + + "nextRoot=${rootOffset.formatLogOffset()} scale=${scale.formatLogFloat()} " + + "preview=${zoomPreview != null} canvas=${pageCanvasSize.formatLogSize()}" + } + } pageRootOffset = rootOffset onPagePositioned(pageIndex, rootOffset) } - .onSizeChanged { pageCanvasSize = it } + .onSizeChanged { size -> + if (pageCanvasSize != size) { + logPdfZoomSettle { + "item_size page=${pageIndex + 1} prev=${pageCanvasSize.formatLogSize()} " + + "next=${size.formatLogSize()} scale=${scale.formatLogFloat()} " + + "preview=${zoomPreview != null} renderScale=${pageRenderScale.formatLogFloat()}" + } + } + pageCanvasSize = size + onPageSizeChanged(pageIndex, size) + } .desktopPdfDocumentZoomPreviewLayer( preview = zoomPreview, currentZoom = scale, viewportRootOffset = zoomViewportRootOffset, - pageRootOffset = pageRootOffset + pageRootOffset = pageRootOffset, + anchorPageRootOffset = zoomPreviewAnchorPageRootOffset, + scrollBounds = zoomPreviewScrollBounds ) .background(themeStyle.pageBackgroundColor, RoundedCornerShape(2.dp)) - .pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, selectedTool, isRichTextMode) { - if (isRichTextMode) return@pointerInput + .pointerInput( + pageIndex, + displayPageIsCurrent, + pageCanvasSize, + isTextSelectionMode, + selectedTool, + isRichTextMode + ) { + if (!displayPageIsCurrent || isRichTextMode) return@pointerInput awaitPointerEventScope { while (true) { val event = awaitPointerEvent() val point = event.changes.firstOrNull()?.position ?: continue if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) { + if (isTextSelectionMode) { + logPdfChromeTap { + "page_press source=vertical_page page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "consumedBefore=${event.changes.any { it.isConsumed }} " + + "selectionActive=${currentTextSelection != null} " + + "selectionMenuOpen=${selectionMenuOffset != null} " + + "selectedTool=$selectedTool richText=$isRichTextMode" + } + } val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) { currentAnnotations.asReversed().firstOrNull { it.isDesktopTextSelectionHighlight && @@ -252,6 +338,10 @@ internal fun DesktopVerticalPdfPage( null } if (highlightHit != null) { + logPdfChromeTap { + "page_press_consume source=vertical_page page=${pageIndex + 1} " + + "reason=text_selection_highlight annotation=${highlightHit.id}" + } onSelectPage(pageIndex) onAnnotationSelected(highlightHit) clearInteractionState() @@ -261,6 +351,10 @@ internal fun DesktopVerticalPdfPage( if (selectedTool != PdfInkTool.TEXT) { val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize) if (linkTarget != null) { + logPdfChromeTap { + "page_press_consume source=vertical_page page=${pageIndex + 1} " + + "reason=link target=${linkTarget.formatLogTarget()}" + } logPdfLink( "tap_hit mode=vertical page=${pageIndex + 1} " + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + @@ -277,6 +371,10 @@ internal fun DesktopVerticalPdfPage( it.sharedPdfEmbeddedHitTest(point, pageCanvasSize) } if (embeddedHit != null) { + logPdfChromeTap { + "page_press_consume source=vertical_page page=${pageIndex + 1} " + + "reason=embedded_annotation annotation=${embeddedHit.id}" + } onSelectPage(pageIndex) onEmbeddedAnnotationSelected(embeddedHit) clearInteractionState() @@ -285,7 +383,16 @@ internal fun DesktopVerticalPdfPage( currentTextSelection != null && selectionMenuOffset == null ) { + logPdfChromeTap { + "page_press_passthrough source=vertical_page page=${pageIndex + 1} " + + "action=clear_selection consumed=false" + } clearSelection() + } else if (isTextSelectionMode) { + logPdfChromeTap { + "page_press_passthrough source=vertical_page page=${pageIndex + 1} " + + "action=none consumed=false" + } } } else if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) { val selection = currentTextSelection @@ -304,33 +411,41 @@ internal fun DesktopVerticalPdfPage( } } } - .pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, isRichTextMode) { - if (isRichTextMode || !isTextSelectionMode) return@pointerInput - detectTapGestures( - onLongPress = { point -> - val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize) - if (selection != null) { - onSelectPage(pageIndex) - selectionStartIndex = null - selectionEndIndex = null - selectionStartHit = null - selectionEndHit = null - activeSelectionHandle = null - textSelection = selection - selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point) - logPdfSelection( - "long_press page=${pageIndex + 1} " + - "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + - "range=${selection.startIndex}..${selection.endIndex} " + - "chars=${selection.text.length} " + - "text=\"${selection.text.logPreview()}\"" - ) - } + .pointerInput(pageIndex, displayPageIsCurrent, pageCanvasSize, isTextSelectionMode, isRichTextMode) { + if (!displayPageIsCurrent || isRichTextMode || !isTextSelectionMode) return@pointerInput + detectDesktopPdfTextSelectionLongPress( + source = "vertical_page", + pageIndex = pageIndex + ) { point -> + val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize) + logPdfChromeTap { + "long_press_selection source=vertical_page page=${pageIndex + 1} " + + "selectionFound=${selection != null} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()}" } - ) + if (selection != null) { + onSelectPage(pageIndex) + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + activeSelectionHandle = null + textSelection = selection + selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point) + logPdfSelection( + "long_press page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "chars=${selection.text.length} " + + "text=\"${selection.text.logPreview()}\"" + ) + } + } } - .pointerInput(pageIndex, selectedTool, isTextSelectionMode, isRichTextMode) { - if (isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) return@pointerInput + .pointerInput(pageIndex, displayPageIsCurrent, selectedTool, isTextSelectionMode, isRichTextMode) { + if (!displayPageIsCurrent || isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) { + return@pointerInput + } awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture @@ -371,9 +486,10 @@ internal fun DesktopVerticalPdfPage( isRichTextMode, pageCanvasSize, renderedPageWidth, - renderedPageHeight + renderedPageHeight, + displayPageIsCurrent ) { - if (renderedPageWidth > 0 && renderedPageHeight > 0) { + if (displayPageIsCurrent && renderedPageWidth > 0 && renderedPageHeight > 0) { if (isRichTextMode) return@pointerInput if (isTextSelectionMode) { var latestSelectionDragPoint: Offset? = null @@ -638,8 +754,21 @@ internal fun DesktopVerticalPdfPage( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - renderedPage != null -> { + renderError != null && renderedPageIndex != pageIndex -> Text( + renderError ?: readerString("desktop_failed_render_page", "Failed to render page."), + color = MaterialTheme.colorScheme.error + ) + renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) -> { + val currentRenderedPageIndex = renderedPageIndex!! + Crossfade( + targetState = currentRenderedPageIndex, + animationSpec = tween(DesktopVerticalPdfPageTurnAnimationMillis), + label = "DesktopVerticalPdfPage" + ) { pageIndex -> val pageRender = renderedPage!! + val pageEmbeddedAnnotations = remember(document.embeddedAnnotations, pageIndex) { + document.embeddedAnnotations.filter { it.pageIndex == pageIndex } + } val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) { annotations .filter { it.pageIndex == pageIndex } @@ -806,6 +935,10 @@ internal fun DesktopVerticalPdfPage( .matchParentSize() .pointerInput(pageIndex, selectionMenuOffset) { detectTapGestures { + logPdfChromeTap { + "selection_menu_scrim_tap source=vertical_page page=${pageIndex + 1} " + + "consumedByScrim=true" + } clearSelection() } } @@ -842,6 +975,7 @@ internal fun DesktopVerticalPdfPage( showSearch = externalLookupAvailable, onClear = ::clearSelection ) + } } isRendering -> CircularProgressIndicator() renderError != null -> Text( diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPageInteractions.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPageInteractions.kt similarity index 63% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPageInteractions.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPageInteractions.kt index 8c2750b..064ad73 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPageInteractions.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPageInteractions.kt @@ -1,19 +1,30 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.unit.IntSize -import com.aryan.reader.shared.pdf.PdfAnnotationKind -import com.aryan.reader.shared.pdf.PdfInkTool -import com.aryan.reader.shared.pdf.PdfNormalizedPoint -import com.aryan.reader.shared.pdf.PdfPageBounds -import com.aryan.reader.shared.pdf.PdfPagePoint -import com.aryan.reader.shared.pdf.PdfSelectionGeometry -import com.aryan.reader.shared.pdf.PdfTextCharBounds -import com.aryan.reader.shared.pdf.SharedPdfAnnotation -import com.aryan.reader.shared.pdf.SharedPdfInkRenderer -import com.aryan.reader.shared.pdf.SharedPdfTextDraft -import com.aryan.reader.shared.ui.sharedPdfHitTest -import com.aryan.reader.shared.ui.toSharedPdfPoint +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.PdfNormalizedPoint +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint +import org.dueattendant149.bookreader.shared.pdf.PdfSelectionGeometry +import org.dueattendant149.bookreader.shared.pdf.PdfTextCharBounds +import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfInkRenderer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderAction +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderState +import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextDraft +import org.dueattendant149.bookreader.shared.pdf.reduce +import org.dueattendant149.bookreader.shared.ui.sharedPdfHitTest +import org.dueattendant149.bookreader.shared.ui.toSharedPdfPoint +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout internal val PdfInkTool.isDesktopHighlighter: Boolean get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND @@ -24,6 +35,24 @@ internal val SharedPdfAnnotation.isDesktopTextSelectionHighlight: Boolean rangeStartIndex != null && rangeEndIndex != null +internal fun SharedPdfReaderState.withDesktopPdfTextSelectionHighlightAdded( + annotation: SharedPdfAnnotation, + zoomSpec: PdfZoomSpec = PdfZoomSpec() +): SharedPdfReaderState { + val next = reduce(SharedPdfReaderAction.AnnotationAdded(annotation), zoomSpec) + return if (annotation.isDesktopTextSelectionHighlight) { + next.reduce(SharedPdfReaderAction.AnnotationSelected(null), zoomSpec) + } else { + next + } +} + +internal fun SharedPdfReaderState.withDesktopPdfTextHighlightSheetDismissed( + zoomSpec: PdfZoomSpec = PdfZoomSpec() +): SharedPdfReaderState { + return reduce(SharedPdfReaderAction.AnnotationSelected(null), zoomSpec) +} + internal fun List.withDesktopPdfDragPoint( point: Offset, canvasSize: IntSize, @@ -46,6 +75,95 @@ internal fun List.withDesktopPdfDragPoint( return this + nextPoint } +internal suspend fun PointerInputScope.detectDesktopPdfTextSelectionLongPress( + source: String, + pageIndex: Int, + onLongPress: (Offset) -> Unit +) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val secondaryDown = currentEvent.buttons.isSecondaryPressed + logPdfChromeTap { + "long_press_down source=$source page=${pageIndex + 1} " + + "x=${down.position.x.formatLogFloat()} y=${down.position.y.formatLogFloat()} " + + "downConsumed=${down.isConsumed} secondary=$secondaryDown" + } + if (down.isConsumed || secondaryDown) { + logPdfChromeTap { + "long_press_skip source=$source page=${pageIndex + 1} " + + "reason=${if (down.isConsumed) "down_consumed" else "secondary_button"}" + } + return@awaitEachGesture + } + val pointerId = down.id + val start = down.position + var latestPosition = start + var canceledBeforeLongPress = false + var longPressReached = false + var cancelReason = "" + + try { + withTimeout(viewConfiguration.longPressTimeoutMillis) { + while (true) { + val event = awaitPointerEvent() + if (event.buttons.isSecondaryPressed) { + canceledBeforeLongPress = true + cancelReason = "secondary_button" + return@withTimeout + } + val change = event.changes.firstOrNull { it.id == pointerId } + if (change == null) { + canceledBeforeLongPress = true + cancelReason = "pointer_lost" + return@withTimeout + } + latestPosition = change.position + val distance = (latestPosition - start).getDistance() + when { + change.isConsumed -> { + canceledBeforeLongPress = true + cancelReason = "change_consumed" + return@withTimeout + } + change.changedToUp() || !change.pressed -> { + canceledBeforeLongPress = true + cancelReason = "up_before_long_press" + return@withTimeout + } + distance > viewConfiguration.touchSlop -> { + canceledBeforeLongPress = true + cancelReason = "moved distance=${distance.formatLogFloat()}" + return@withTimeout + } + } + } + } + } catch (_: TimeoutCancellationException) { + longPressReached = !canceledBeforeLongPress + } + + if (!longPressReached) { + logPdfChromeTap { + "long_press_cancel source=$source page=${pageIndex + 1} " + + "reason=${cancelReason.ifBlank { "unknown" }} " + + "x=${latestPosition.x.formatLogFloat()} y=${latestPosition.y.formatLogFloat()}" + } + return@awaitEachGesture + } + logPdfChromeTap { + "long_press_reached source=$source page=${pageIndex + 1} " + + "x=${latestPosition.x.formatLogFloat()} y=${latestPosition.y.formatLogFloat()}" + } + onLongPress(latestPosition) + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } ?: return@awaitEachGesture + change.consume() + if (change.changedToUp() || !change.pressed) return@awaitEachGesture + } + } +} + internal data class DesktopPdfCharHit( val index: Int, val source: String, @@ -318,7 +436,7 @@ private fun DesktopPdfTextChar.toPdfTextCharBounds(): PdfTextCharBounds { } internal const val DesktopPdfSelectionPreviewThrottleMillis = 32L -internal const val DesktopPdfZoomCommitDebounceMillis = 180L +internal const val DesktopPdfZoomCommitDebounceMillis = 260L internal const val DesktopPdfZoomRenderDebounceMillis = 300L internal const val DesktopPdfViewportPersistDebounceMillis = 300L internal const val DesktopPdfPaginationPrefetchDelayMillis = 450L diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPasswordDialog.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPasswordDialog.kt similarity index 94% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPasswordDialog.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPasswordDialog.kt index 9f14e03..adafc75 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPasswordDialog.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPasswordDialog.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Arrangement @@ -16,8 +16,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.ui.SharedStableOutlinedTextField -import com.aryan.reader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField +import org.dueattendant149.bookreader.shared.ui.readerString @Composable internal fun DesktopPdfPasswordDialog( diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfReaderScreen.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReaderScreen.kt similarity index 68% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfReaderScreen.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReaderScreen.kt index 2ca55c7..1be1c5c 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfReaderScreen.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReaderScreen.kt @@ -1,6 +1,8 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image @@ -37,6 +39,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -69,76 +72,79 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import com.aryan.reader.shared.AiAdapter -import com.aryan.reader.shared.PdfDisplayMode -import com.aryan.reader.shared.ReaderAiByokSettings -import com.aryan.reader.shared.ReaderAiFeature -import com.aryan.reader.shared.ReaderAiResultState -import com.aryan.reader.shared.ReaderAutoScrollState -import com.aryan.reader.shared.ReaderCloudTtsState -import com.aryan.reader.shared.ReaderExtrasState -import com.aryan.reader.shared.ReaderExternalLookupAction -import com.aryan.reader.shared.ReaderTtsChunk -import com.aryan.reader.shared.ReaderTtsPlanner -import com.aryan.reader.shared.ReaderTtsProgress -import com.aryan.reader.shared.ReaderTtsReadScope -import com.aryan.reader.shared.ReaderTtsReplacementPreferences -import com.aryan.reader.shared.SaveMode -import com.aryan.reader.shared.SearchHighlightMode -import com.aryan.reader.shared.SharedFeaturePolicy -import com.aryan.reader.shared.SummarizationResult -import com.aryan.reader.shared.externalLookupUrl -import com.aryan.reader.shared.withTtsReplacements -import com.aryan.reader.shared.pdf.PdfAnnotationKind -import com.aryan.reader.shared.pdf.PdfInkTool -import com.aryan.reader.shared.pdf.PdfPageBounds -import com.aryan.reader.shared.pdf.PdfPagePoint -import com.aryan.reader.shared.pdf.PdfSpreadLayout -import com.aryan.reader.shared.pdf.PdfVisiblePageLayout -import com.aryan.reader.shared.pdf.SharedPdfAnnotation -import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults -import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors -import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation -import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette -import com.aryan.reader.shared.pdf.SharedPdfJumpHistory -import com.aryan.reader.shared.pdf.SharedPdfReaderAction -import com.aryan.reader.shared.pdf.SharedPdfReaderState -import com.aryan.reader.shared.pdf.SharedPdfReaderViewport -import com.aryan.reader.shared.pdf.SharedPdfRichTextController -import com.aryan.reader.shared.pdf.SharedPdfRichTextLog -import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer -import com.aryan.reader.shared.pdf.SharedPdfSearchEngine -import com.aryan.reader.shared.pdf.SharedPdfSearchResult -import com.aryan.reader.shared.pdf.SharedPdfTextAnnotationDefaults -import com.aryan.reader.shared.pdf.SharedPdfTextDraft -import com.aryan.reader.shared.pdf.SharedPdfTextStyleConfig -import com.aryan.reader.shared.pdf.mostVisiblePdfPageIndex -import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp -import com.aryan.reader.shared.pdf.reduce -import com.aryan.reader.shared.pdf.sharedPdfTextStyle -import com.aryan.reader.shared.pdf.toAnnotation -import com.aryan.reader.shared.pdf.withBounds -import com.aryan.reader.shared.pdf.withSharedPdfTextStyle -import com.aryan.reader.shared.pdf.withStyle -import com.aryan.reader.shared.pdf.withText -import com.aryan.reader.shared.reader.ReaderSettings -import com.aryan.reader.shared.reduce -import com.aryan.reader.shared.ui.ReaderWorkspaceFileActionState -import com.aryan.reader.shared.ui.ReaderWorkspaceShell -import com.aryan.reader.shared.ui.LocalSharedStringResolver -import com.aryan.reader.shared.ui.SharedPdfAnnotationOverlay -import com.aryan.reader.shared.ui.SharedPdfEmbeddedAnnotationOverlay -import com.aryan.reader.shared.ui.SharedPdfInlineTextEditorOverlay -import com.aryan.reader.shared.ui.SharedPdfPageNumberOverlay -import com.aryan.reader.shared.ui.SharedPdfRichTextHiddenInput -import com.aryan.reader.shared.ui.SharedPdfRichTextLayer -import com.aryan.reader.shared.ui.SharedPdfTextBoxEditorOverlay -import com.aryan.reader.shared.ui.SharedPdfVerticalScrollbar -import com.aryan.reader.shared.ui.pdfReaderWorkspaceModel -import com.aryan.reader.shared.ui.readerString -import com.aryan.reader.shared.ui.sharedPdfEmbeddedHitTest -import com.aryan.reader.shared.ui.sharedPdfHitTest -import com.aryan.reader.shared.ui.toSharedPdfPoint +import org.dueattendant149.bookreader.shared.AiAdapter +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.ReaderAiFeature +import org.dueattendant149.bookreader.shared.ReaderAiResultState +import org.dueattendant149.bookreader.shared.ReaderCloudTtsState +import org.dueattendant149.bookreader.shared.ReaderExtrasState +import org.dueattendant149.bookreader.shared.ReaderExternalLookupAction +import org.dueattendant149.bookreader.shared.ReaderTtsChunk +import org.dueattendant149.bookreader.shared.ReaderTtsPlanner +import org.dueattendant149.bookreader.shared.ReaderTtsProgress +import org.dueattendant149.bookreader.shared.ReaderTtsReadScope +import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences +import org.dueattendant149.bookreader.shared.ReaderTheme +import org.dueattendant149.bookreader.shared.SaveMode +import org.dueattendant149.bookreader.shared.SearchHighlightMode +import org.dueattendant149.bookreader.shared.SharedFeaturePolicy +import org.dueattendant149.bookreader.shared.SummarizationResult +import org.dueattendant149.bookreader.shared.externalLookupUrl +import org.dueattendant149.bookreader.shared.withTtsReplacements +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint +import org.dueattendant149.bookreader.shared.pdf.PdfSpreadLayout +import org.dueattendant149.bookreader.shared.pdf.PdfVisiblePageLayout +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationDefaults +import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.shared.pdf.SharedPdfJumpHistory +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderAction +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderState +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextController +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextLog +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchEngine +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult +import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextAnnotationDefaults +import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextDraft +import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextStyleConfig +import org.dueattendant149.bookreader.shared.pdf.mostVisiblePdfPageIndex +import org.dueattendant149.bookreader.shared.pdf.pdfVerticalPageGapDp +import org.dueattendant149.bookreader.shared.pdf.reduce +import org.dueattendant149.bookreader.shared.pdf.sharedPdfTextStyle +import org.dueattendant149.bookreader.shared.pdf.toAnnotation +import org.dueattendant149.bookreader.shared.pdf.withBounds +import org.dueattendant149.bookreader.shared.pdf.withSharedPdfTextStyle +import org.dueattendant149.bookreader.shared.pdf.withStyle +import org.dueattendant149.bookreader.shared.pdf.withText +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.readerCloudTtsControlsModel +import org.dueattendant149.bookreader.shared.reduce +import org.dueattendant149.bookreader.shared.ui.ReaderWorkspaceFileActionState +import org.dueattendant149.bookreader.shared.ui.ReaderWorkspaceShell +import org.dueattendant149.bookreader.shared.ui.LocalSharedStringResolver +import org.dueattendant149.bookreader.shared.ui.SharedPdfAnnotationOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfEmbeddedAnnotationOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfInlineTextEditorOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfInteractionDock +import org.dueattendant149.bookreader.shared.ui.SharedPdfPageNumberOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfRichTextHiddenInput +import org.dueattendant149.bookreader.shared.ui.SharedPdfRichTextLayer +import org.dueattendant149.bookreader.shared.ui.SharedPdfTextBoxEditorOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfVerticalScrollbar +import org.dueattendant149.bookreader.shared.ui.SharedReaderTtsOverlayControls +import org.dueattendant149.bookreader.shared.ui.pdfReaderWorkspaceModel +import org.dueattendant149.bookreader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.sharedPdfEmbeddedHitTest +import org.dueattendant149.bookreader.shared.ui.sharedPdfHitTest +import org.dueattendant149.bookreader.shared.ui.toSharedPdfPoint +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -153,6 +159,35 @@ import java.util.concurrent.atomic.AtomicReference import kotlin.math.abs import kotlin.math.roundToInt +private val DesktopPdfReaderFullscreenFocusRetryDelaysMillis = longArrayOf(80L, 120L, 160L, 240L) +private const val DesktopPdfPaginationPageTurnAnimationMillis = 140 + +private data class DesktopPdfPaginatedPageDisplay( + val pageIndex: Int, + val render: DesktopPdfPageRender +) + +private data class DesktopPdfPendingPaginatedScrollRestore( + val requestId: Int, + val pageIndex: Int, + val zoom: Float, + val horizontalScroll: Int, + val verticalScroll: Int +) + +internal fun desktopPdfInitialPageIndex( + requestedPageIndex: Int, + pageCount: Int, + displayMode: PdfDisplayMode, + settings: ReaderSettings +): Int { + val clampedPageIndex = requestedPageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + return if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.normalizePageIndex(clampedPageIndex, pageCount, settings) + } else { + clampedPageIndex + } +} @Composable internal fun PdfReaderScreen( @@ -162,10 +197,13 @@ internal fun PdfReaderScreen( initialReaderSettings: ReaderSettings? = null, onReturnToLibrary: (() -> Unit)? = null, onFullscreenChange: (Boolean) -> Unit = {}, + appThemeControls: (@Composable () -> Unit)? = null, onPageStateChange: (pageIndex: Int, progress: Float, viewport: SharedPdfReaderViewport) -> Unit, onReaderSettingsChange: (ReaderSettings) -> Unit = {}, pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(), onPdfHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {}, + customReaderThemes: List = emptyList(), + onCustomReaderThemesChange: (List) -> Unit = {}, customTextureIds: List = emptyList(), onImportTexture: ((ReaderSettings) -> ReaderSettings?)? = null, onLocalSidecarsChanged: () -> Unit = {}, @@ -179,6 +217,7 @@ internal fun PdfReaderScreen( showPaidCredits: Boolean = false, onAiByokSettingsChange: (ReaderAiByokSettings) -> Unit = {}, featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard, + cloudTtsControlsAvailable: Boolean = true, onReaderAiEntitlementRequired: (ReaderAiFeature, String) -> Boolean = { _, _ -> false }, onCloudTtsEntitlementRequired: () -> Boolean = { false }, onPaidFeatureError: (String?) -> Unit = {}, @@ -192,20 +231,52 @@ internal fun PdfReaderScreen( return stringResolver.string(name, fallback, *args) } val zoomSpec = remember { DesktopPdfZoomSpec } - val restoredInitialViewport = remember(documentHandleId, initialViewport) { - initialViewport?.sanitized(document.pageCount, zoomSpec) + val initialDesktopPdfReaderSettings = remember(documentHandleId, initialReaderSettings) { + initialReaderSettings.toDesktopPdfReaderSettings() + } + val initialPdfDisplayMode = initialDesktopPdfReaderSettings.toDesktopPdfDisplayMode() + val restoredInitialViewport = remember( + documentHandleId, + initialViewport, + initialDesktopPdfReaderSettings, + initialPdfDisplayMode + ) { + initialViewport?.sanitized(document.pageCount, zoomSpec)?.let { viewport -> + viewport.copy( + pageIndex = desktopPdfInitialPageIndex( + requestedPageIndex = viewport.pageIndex, + pageCount = document.pageCount, + displayMode = initialPdfDisplayMode, + settings = initialDesktopPdfReaderSettings + ) + ) + } + } + val initialPdfPageIndex = remember( + documentHandleId, + initialPageIndex, + restoredInitialViewport, + initialPdfDisplayMode, + initialDesktopPdfReaderSettings + ) { + desktopPdfInitialPageIndex( + requestedPageIndex = restoredInitialViewport?.pageIndex ?: initialPageIndex, + pageCount = document.pageCount, + displayMode = initialPdfDisplayMode, + settings = initialDesktopPdfReaderSettings + ) } var pdfReaderSettings by remember(documentHandleId) { - mutableStateOf(initialReaderSettings.toDesktopPdfReaderSettings()) + mutableStateOf(initialDesktopPdfReaderSettings) } var pdfState by remember(documentHandleId) { mutableStateOf( SharedPdfReaderState.initial( pageCount = document.pageCount, - initialPageIndex = restoredInitialViewport?.pageIndex ?: initialPageIndex, + initialPageIndex = initialPdfPageIndex, zoomSpec = zoomSpec ).copy( - displayMode = restoredInitialViewport?.displayMode ?: DesktopDefaultPdfDisplayMode, + displayMode = initialPdfDisplayMode, zoom = restoredInitialViewport?.zoom ?: zoomSpec.clamp(zoomSpec.default) ) ) @@ -219,11 +290,19 @@ internal fun PdfReaderScreen( val zoomAnchorJob = remember(documentHandleId) { AtomicReference(null) } val zoomCommitJob = remember(documentHandleId) { AtomicReference(null) } var pdfZoomPreview by remember(documentHandleId) { mutableStateOf(null) } + var pdfZoomSettleSequence by remember(documentHandleId) { mutableIntStateOf(0) } + var pdfNavigationScrollRestoreSequence by remember(documentHandleId) { mutableIntStateOf(0) } + var pendingPdfNavigationScrollRestore by remember(documentHandleId) { + mutableStateOf(null) + } var activeTextDraft by remember(documentHandleId) { mutableStateOf(null) } var textStyleConfig by remember(documentHandleId) { mutableStateOf(SharedPdfTextStyleConfig()) } var pageCanvasSize by remember(documentHandleId) { mutableStateOf(IntSize.Zero) } var pdfZoomViewportRootOffset by remember(documentHandleId) { mutableStateOf(Offset.Zero) } + var pdfZoomViewportSize by remember(documentHandleId) { mutableStateOf(IntSize.Zero) } var paginatedPageRootOffset by remember(documentHandleId) { mutableStateOf(Offset.Zero) } + val paginatedPageRootOffsets = remember(documentHandleId) { mutableStateMapOf() } + val paginatedPageCanvasSizes = remember(documentHandleId) { mutableStateMapOf() } val verticalPageRootOffsets = remember(documentHandleId) { mutableStateMapOf() } val paginatedRenderCache = remember(documentHandleId) { mutableStateMapOf() } var activeStroke by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf>(emptyList()) } @@ -246,7 +325,7 @@ internal fun PdfReaderScreen( mutableStateOf( ReaderExtrasState( cloudTts = ReaderCloudTtsState( - isAvailable = aiByokSettings.isCloudTtsAvailable, + isAvailable = cloudTtsControlsAvailable && aiByokSettings.isCloudTtsAvailable, cacheSummary = ttsAdapter.cacheSummary(document.title, aiByokSettings.sanitized().ttsSpeakerId) ) ) @@ -258,7 +337,7 @@ internal fun PdfReaderScreen( var dismissedPdfAiResultRequestId by remember(documentHandleId) { mutableStateOf(null) } var pdfHubSummaryResult by remember(documentHandleId) { mutableStateOf(null) } var isPdfHubSummaryLoading by remember(documentHandleId) { mutableStateOf(false) } - var showPdfCloudTtsSettings by remember(documentHandleId) { mutableStateOf(false) } + var isPdfTtsOverlayCollapsed by remember(documentHandleId) { mutableStateOf(false) } val annotationFile = remember(documentHandleId) { desktopPdfAnnotationFile(document.path) } val bookmarkFile = remember(documentHandleId) { desktopPdfBookmarkFile(document.path) } val richTextFile = remember(documentHandleId) { desktopPdfRichTextFile(document.path) } @@ -330,15 +409,40 @@ internal fun PdfReaderScreen( ?: 0 ) val pdfReaderFocusRequester = remember(documentHandleId) { FocusRequester() } + var pdfReaderFocusRestoreRequest by remember(documentHandleId) { mutableIntStateOf(0) } val currentTextSelection by rememberUpdatedState(textSelection) val currentPdfAnnotations by rememberUpdatedState(pdfState.annotations) val currentPdfPageIndex by rememberUpdatedState(pdfState.pageIndex) val currentPdfScale by rememberUpdatedState(pdfState.zoom) val currentPdfDisplayMode by rememberUpdatedState(pdfState.displayMode) + val pdfSelectionSheetActive = pdfState.selectedAnnotationId?.let { selectedId -> + pdfState.annotations.any { it.id == selectedId && it.isDesktopTextSelectionHighlight } + } == true + val shouldRestorePdfReaderFocus = + !pdfState.isSearchActive && + !pdfSelectionSheetActive && + externalLinkDialogUrl == null && + !pdfExtrasState.aiResult.hasContent && + activeTextDraft == null && + !isRichTextMode && + (textSelection == null || selectionMenuOffset == null) + val currentShouldRestorePdfReaderFocus by rememberUpdatedState(shouldRestorePdfReaderFocus) + fun requestPdfReaderFocusRestore() { + pdfReaderFocusRestoreRequest += 1 + } LaunchedEffect(isFullscreen, documentHandleId) { - repeat(if (isFullscreen) 4 else 1) { attempt -> - delay(if (attempt == 0) 80L else 120L) + for (delayMillis in DesktopPdfReaderFullscreenFocusRetryDelaysMillis) { + delay(delayMillis) + if (currentShouldRestorePdfReaderFocus) { + runCatching { pdfReaderFocusRequester.requestFocus() } + } + } + } + + LaunchedEffect(shouldRestorePdfReaderFocus, documentHandleId) { + if (shouldRestorePdfReaderFocus) { + delay(120L) runCatching { pdfReaderFocusRequester.requestFocus() } } } @@ -357,7 +461,17 @@ internal fun PdfReaderScreen( fun dispatchPdf(action: SharedPdfReaderAction) { val previousPage = pdfState.pageIndex + val previousAnnotationIds = pdfState.annotations.mapTo(mutableSetOf()) { it.id } val next = pdfState.reduce(action, zoomSpec) + val nextAnnotationIds = next.annotations.mapTo(mutableSetOf()) { it.id } + val removedAnnotationIds = previousAnnotationIds - nextAnnotationIds + if (removedAnnotationIds.isNotEmpty()) { + DesktopCloudSidecarSync.recordAnnotationDeletions( + documentPath = document.path, + logBookId = documentHandleId.toString(), + annotationIds = removedAnnotationIds + ) + } pdfState = next if (next.pageIndex != previousPage) { clearPdfInteractionState() @@ -503,19 +617,16 @@ internal fun PdfReaderScreen( if (previousTool != tool) { dispatchPdf(SharedPdfReaderAction.ToolSelected(tool)) } - if (tool.isDesktopHighlighter && previousTool != tool) { - pdfHighlighterPalette.sanitized().colors.firstOrNull()?.let { colorArgb -> - dispatchPdf(SharedPdfReaderAction.ColorSelected(colorArgb)) - } - } } val pageIndex = pdfState.pageIndex val scale = pdfState.zoom val displayMode = pdfState.displayMode + val rightToLeftPdfPaginationActive = displayMode == PdfDisplayMode.PAGINATION && + pdfReaderSettings.rightToLeftPagination val isPdfTwoPageSpread = displayMode == PdfDisplayMode.PAGINATION && PdfSpreadLayout.isTwoPageSpreadEnabled(pdfReaderSettings) - val paginatedVisiblePageIndices: List = remember( + val paginatedSpreadPageIndices: List = remember( pageIndex, document.pageCount, displayMode, @@ -528,6 +639,12 @@ internal fun PdfReaderScreen( listOf(pageIndex.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0))) } } + val paginatedVisiblePageIndices = remember( + paginatedSpreadPageIndices, + rightToLeftPdfPaginationActive + ) { + if (rightToLeftPdfPaginationActive) paginatedSpreadPageIndices.asReversed() else paginatedSpreadPageIndices + } val pdfPageLabel = desktopPdfPageLabel(pageIndex, document.pageCount, displayMode, pdfReaderSettings) val pdfPageScrubPreviewLabel = pageScrubPreview?.let { desktopPdfPageLabel(it, document.pageCount, displayMode, pdfReaderSettings) @@ -569,6 +686,20 @@ internal fun PdfReaderScreen( } } + LaunchedEffect(documentHandleId, displayMode) { + if (!DesktopDiagnosticsEnabled) return@LaunchedEffect + snapshotFlow { + "mode=$displayMode page=${currentPdfPageIndex + 1} scale=${currentPdfScale.formatLogFloat()} " + + "preview=${pdfZoomPreview != null} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + verticalListState.firstVisibleItemScrollOffset + } + .distinctUntilChanged() + .collect { summary -> + logPdfZoomSettle { "scroll_state seq=$pdfZoomSettleSequence $summary" } + } + } + fun verticalZoomAnchorItem(anchor: Offset) = verticalListState.layoutInfo.visibleItemsInfo .firstOrNull { item -> anchor.y >= item.offset.toFloat() && anchor.y <= (item.offset + item.size).toFloat() @@ -581,19 +712,61 @@ internal fun PdfReaderScreen( } } - LaunchedEffect(scale, displayMode, pageIndex) { + fun paginatedZoomPageRoot(page: Int?): Offset? { + if (page == null) return null + return paginatedPageRootOffsets[page] + ?: paginatedPageRootOffset.takeIf { page == currentPdfPageIndex } + } + + fun paginatedZoomAnchorPageIndex(anchor: Offset?): Int { + val activePageIndex = currentPdfPageIndex + if (!isPdfTwoPageSpread) return activePageIndex + val rootOffsets = paginatedPageRootOffsets.toMutableMap() + paginatedSpreadPageIndices.firstOrNull()?.let { firstSpreadPage -> + rootOffsets.putIfAbsent(firstSpreadPage, paginatedPageRootOffset) + } + val pageSizes = paginatedPageCanvasSizes.toMutableMap() + if (pageCanvasSize.width > 0 && pageCanvasSize.height > 0) { + pageSizes.putIfAbsent(activePageIndex, pageCanvasSize) + } + return desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset = pdfZoomViewportRootOffset, + anchor = anchor, + visiblePageIndices = paginatedVisiblePageIndices, + pageRootOffsets = rootOffsets, + pageSizes = pageSizes, + fallbackPageIndex = activePageIndex + ) + } + + LaunchedEffect(scale, displayMode, pageIndex, isPdfTwoPageSpread, paginatedSpreadPageIndices) { val preview = pdfZoomPreview ?: return@LaunchedEffect + val paginationPreviewPageVisible = if (isPdfTwoPageSpread) { + preview.pageIndex in paginatedSpreadPageIndices + } else { + preview.pageIndex == pageIndex + } if ( preview.displayMode != displayMode || - (preview.pageIndex != pageIndex && displayMode == PdfDisplayMode.PAGINATION) || - abs(preview.baseZoom - scale) > 0.0001f + (!paginationPreviewPageVisible && displayMode == PdfDisplayMode.PAGINATION) || + !desktopPdfZoomPreviewMatchesScale(preview, scale) ) { + logPdfZoomSettle { + "preview_cancel seq=$pdfZoomSettleSequence reason=state_mismatch mode=$displayMode " + + "page=${pageIndex + 1} scale=${scale.formatLogFloat()} previewMode=${preview.displayMode} " + + "previewPage=${preview.pageIndex?.plus(1) ?: "none"} base=${preview.baseZoom.formatLogFloat()} " + + "zoom=${preview.zoom.formatLogFloat()}" + } pdfZoomPreview = null zoomCommitJob.getAndSet(null)?.cancel() } } fun applyAnchoredPdfZoom(oldZoom: Float, newZoom: Float, anchor: Offset?) { + if (pdfZoomSettleSequence == 0) { + pdfZoomSettleSequence = 1 + } + val settleSequence = pdfZoomSettleSequence val activePageIndex = currentPdfPageIndex val activeDisplayMode = currentPdfDisplayMode logPdfZoomPerf { @@ -603,15 +776,84 @@ internal fun PdfReaderScreen( "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + "renderJobActive=${renderJob?.isActive == true}" } - pdfZoomPreview = null - val viewportRootOffsetAtZoomStart = pdfZoomViewportRootOffset - val pageRootOffsetAtZoomStart = paginatedPageRootOffset - val targetHorizontalScroll = anchor?.let { + val committedPreview = pdfZoomPreview + val viewportRootOffsetAtZoomStart = committedPreview?.viewportRootOffset ?: pdfZoomViewportRootOffset + val committedPreviewPageIndex = committedPreview?.pageIndex ?: activePageIndex + val pageRootOffsetAtZoomStart = committedPreview?.pageRootOffset + ?: paginatedZoomPageRoot(committedPreviewPageIndex) + ?: paginatedPageRootOffset + val pageRootOffsetAtCommitStart = paginatedZoomPageRoot(committedPreviewPageIndex) + ?: paginatedPageRootOffset + logPdfZoomSettle { + "commit_start seq=$settleSequence mode=$activeDisplayMode page=${activePageIndex + 1} " + + "old=${oldZoom.formatLogFloat()} new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} " + + "preview=${committedPreview != null} previewPage=${committedPreview?.pageIndex?.plus(1) ?: "none"} " + + "previewBase=${committedPreview?.baseZoom?.formatLogFloat() ?: "none"} " + + "previewZoom=${committedPreview?.zoom?.formatLogFloat() ?: "none"} " + + "viewportStart=${viewportRootOffsetAtZoomStart.formatLogOffset()} " + + "pageStart=${pageRootOffsetAtZoomStart.formatLogOffset()} " + + "pageNow=${pageRootOffsetAtCommitStart.formatLogOffset()} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + "${verticalListState.firstVisibleItemScrollOffset} renderPage=${renderedPageIndex?.plus(1) ?: "none"} " + + "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} renderJob=${renderJob?.isActive == true}" + } + val rawTargetHorizontalScroll = anchor?.let { desktopPdfAnchoredScrollTarget(pageHorizontalScrollState.value, it.x, oldZoom, newZoom) } - val targetVerticalScroll = anchor?.let { + val rawTargetVerticalScroll = anchor?.let { desktopPdfAnchoredScrollTarget(pageVerticalScrollState.value, it.y, oldZoom, newZoom) } + val paginationCommitPrediction: DesktopPdfLayoutScrollPrediction? = if (activeDisplayMode == PdfDisplayMode.PAGINATION) { + val predictedScale = zoomSpec.clamp(newZoom) + if (isPdfTwoPageSpread) { + val predictedSizes = paginatedVisiblePageIndices.mapNotNull { visiblePageIndex -> + document.pageSizes.getOrNull(visiblePageIndex)?.let { pageSize -> + visiblePageIndex to IntSize( + width = (pageSize.width * predictedScale).roundToInt().coerceAtLeast(1), + height = (pageSize.height * predictedScale).roundToInt().coerceAtLeast(1) + ) + } + }.toMap() + desktopPdfSpreadLayoutPrediction( + viewportRootOffset = pdfZoomViewportRootOffset, + viewportSize = pdfZoomViewportSize, + visiblePageIndices = paginatedVisiblePageIndices, + pageCanvasSizes = predictedSizes, + horizontalScroll = rawTargetHorizontalScroll ?: pageHorizontalScrollState.value, + verticalScroll = rawTargetVerticalScroll ?: pageVerticalScrollState.value, + paddingPx = with(density) { 24.dp.toPx() }, + pageGapPx = with(density) { + desktopPdfSpreadPageGapDp(pdfReaderSettings.pdfVerticalPageGapVisible).toPx() + } + ) + } else { + document.pageSizes.getOrNull(committedPreviewPageIndex)?.let { pageSize -> + desktopPdfSinglePageLayoutPrediction( + viewportRootOffset = pdfZoomViewportRootOffset, + viewportSize = pdfZoomViewportSize, + pageCanvasSize = IntSize( + width = (pageSize.width * predictedScale).roundToInt().coerceAtLeast(1), + height = (pageSize.height * predictedScale).roundToInt().coerceAtLeast(1) + ), + horizontalScroll = rawTargetHorizontalScroll ?: pageHorizontalScrollState.value, + verticalScroll = rawTargetVerticalScroll ?: pageVerticalScrollState.value, + paddingPx = with(density) { 24.dp.toPx() } + ) + } + } + } else { + null + } + val targetHorizontalScroll = rawTargetHorizontalScroll?.let { target -> + paginationCommitPrediction?.maxHorizontalScroll?.let { maxScroll -> + target.coerceIn(0, maxScroll) + } ?: target + } + val targetVerticalScroll = rawTargetVerticalScroll?.let { target -> + paginationCommitPrediction?.maxVerticalScroll?.let { maxScroll -> + target.coerceIn(0, maxScroll) + } ?: target + } val targetVerticalItem = if (activeDisplayMode == PdfDisplayMode.VERTICAL_SCROLL && anchor != null) { verticalZoomAnchorItem(anchor) ?.let { item -> @@ -621,38 +863,118 @@ internal fun PdfReaderScreen( oldZoom = oldZoom, newZoom = newZoom ) - val pageRootOffset = verticalPageRootOffsets[item.index] + val pageRootOffset = if (committedPreview?.pageIndex == item.index) { + committedPreview.pageRootOffset + } else { + verticalPageRootOffsets[item.index] + } Triple(item.index, fallbackOffset, pageRootOffset) } } else { null } + logPdfZoomSettle { + "commit_targets seq=$settleSequence mode=$activeDisplayMode targetH=${targetHorizontalScroll ?: "none"} " + + "targetV=${targetVerticalScroll ?: "none"} rawH=${rawTargetHorizontalScroll ?: "none"} " + + "rawV=${rawTargetVerticalScroll ?: "none"} predictedMaxH=${paginationCommitPrediction?.maxHorizontalScroll ?: "none"} " + + "predictedMaxV=${paginationCommitPrediction?.maxVerticalScroll ?: "none"} " + + "targetItem=${targetVerticalItem?.first?.plus(1) ?: "none"} " + + "targetItemOffset=${targetVerticalItem?.second ?: "none"} targetItemRoot=${targetVerticalItem?.third.formatLogOffset()}" + } + var committedPreviewForClear = committedPreview + committedPreview?.let { preview -> + val previewWithCommitTargets = preview.copy( + commitTargetHorizontalScroll = targetHorizontalScroll, + commitTargetVerticalScroll = targetVerticalScroll.takeIf { + activeDisplayMode == PdfDisplayMode.PAGINATION + } + ) + if (pdfZoomPreview == preview) { + pdfZoomPreview = previewWithCommitTargets + committedPreviewForClear = previewWithCommitTargets + logPdfZoomSettle { + "preview_commit_targets seq=$settleSequence targetH=${targetHorizontalScroll ?: "none"} " + + "targetV=${targetVerticalScroll ?: "none"}" + } + } + } dispatchPdf(SharedPdfReaderAction.ZoomChanged(newZoom)) + fun clearCommittedPreview() { + val matchesCommittedPreview = pdfZoomPreview == committedPreviewForClear + logPdfZoomSettle { + "preview_clear seq=$settleSequence match=$matchesCommittedPreview " + + "current=${pdfZoomPreview != null} committed=${committedPreview != null}" + } + if (matchesCommittedPreview) { + pdfZoomPreview = null + } + } + logPdfZoomSettle { + "zoom_dispatched seq=$settleSequence new=${newZoom.formatLogFloat()} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + verticalListState.firstVisibleItemScrollOffset + } if (anchor != null) { - val nextAnchorJob = pdfScope.launch { - withFrameNanos { } + zoomAnchorJob.getAndSet(null)?.cancel() + val nextAnchorJob = pdfScope.launch(start = CoroutineStart.UNDISPATCHED) { when (activeDisplayMode) { PdfDisplayMode.PAGINATION -> { - suspend fun correctPageAnchor() { + if (targetHorizontalScroll != null || targetVerticalScroll != null) { + val beforeH = pageHorizontalScrollState.value + val beforeV = pageVerticalScrollState.value + targetHorizontalScroll?.let { pageHorizontalScrollState.scrollTo(it) } + targetVerticalScroll?.let { pageVerticalScrollState.scrollTo(it) } + logPdfZoomSettle { + "anchor_pre_scroll seq=$settleSequence mode=pagination beforeH=$beforeH beforeV=$beforeV " + + "targetH=${targetHorizontalScroll ?: "none"} targetV=${targetVerticalScroll ?: "none"} " + + "afterH=${pageHorizontalScrollState.value} afterV=${pageVerticalScrollState.value} " + + "maxH=${pageHorizontalScrollState.maxValue} maxV=${pageVerticalScrollState.maxValue}" + } + } + withFrameNanos { } + suspend fun correctPageAnchor(pass: Int) { + val beforeH = pageHorizontalScrollState.value + val beforeV = pageVerticalScrollState.value + val currentRoot = paginatedZoomPageRoot(committedPreviewPageIndex) + ?: paginatedPageRootOffset val pageDelta = desktopPdfAnchoredPageScrollDelta( viewportRootOffset = viewportRootOffsetAtZoomStart, oldPageRootOffset = pageRootOffsetAtZoomStart, - currentPageRootOffset = paginatedPageRootOffset, + currentPageRootOffset = currentRoot, anchor = anchor, oldZoom = oldZoom, newZoom = newZoom ) - if (pageDelta != null) { - if (abs(pageDelta.x) > 1) { + val reachableDelta = pageDelta?.let { + desktopPdfReachableScrollDelta( + requestedDelta = it, + scrollBounds = DesktopPdfZoomScrollBounds( + currentHorizontalScroll = pageHorizontalScrollState.value, + maxHorizontalScroll = pageHorizontalScrollState.maxValue, + currentVerticalScroll = pageVerticalScrollState.value, + maxVerticalScroll = pageVerticalScrollState.maxValue + ) + ) + } + logPdfZoomSettle { + "anchor_pass seq=$settleSequence pass=$pass mode=pagination beforeH=$beforeH " + + "beforeV=$beforeV delta=${pageDelta.formatLogIntOffset()} " + + "reachable=${reachableDelta.formatLogIntOffset()} " + + "maxH=${pageHorizontalScrollState.maxValue} maxV=${pageVerticalScrollState.maxValue} " + + "rootStart=${pageRootOffsetAtZoomStart.formatLogOffset()} " + + "rootNow=${currentRoot.formatLogOffset()} viewport=${viewportRootOffsetAtZoomStart.formatLogOffset()}" + } + if (reachableDelta != null) { + if (abs(reachableDelta.x) > 1) { pageHorizontalScrollState.scrollTo( - (pageHorizontalScrollState.value + pageDelta.x).coerceAtLeast( + (pageHorizontalScrollState.value + reachableDelta.x).coerceAtLeast( 0 ) ) } - if (abs(pageDelta.y) > 1) { + if (abs(reachableDelta.y) > 1) { pageVerticalScrollState.scrollTo( - (pageVerticalScrollState.value + pageDelta.y).coerceAtLeast( + (pageVerticalScrollState.value + reachableDelta.y).coerceAtLeast( 0 ) ) @@ -661,14 +983,23 @@ internal fun PdfReaderScreen( pageHorizontalScrollState.scrollTo(targetHorizontalScroll) pageVerticalScrollState.scrollTo(targetVerticalScroll) } + logPdfZoomSettle { + "anchor_pass_end seq=$settleSequence pass=$pass mode=pagination afterH=${pageHorizontalScrollState.value} " + + "afterV=${pageVerticalScrollState.value} delta=${pageDelta.formatLogIntOffset()} " + + "reachable=${reachableDelta.formatLogIntOffset()}" + } } - correctPageAnchor() + correctPageAnchor(pass = 1) withFrameNanos { } - correctPageAnchor() + correctPageAnchor(pass = 2) } PdfDisplayMode.VERTICAL_SCROLL -> { - suspend fun correctVerticalAnchor() { + withFrameNanos { } + suspend fun correctVerticalAnchor(pass: Int) { + val beforeH = pageHorizontalScrollState.value + val beforeItem = verticalListState.firstVisibleItemIndex + val beforeItemOffset = verticalListState.firstVisibleItemScrollOffset val oldPageRootOffset = targetVerticalItem?.third val currentPageRootOffset = targetVerticalItem?.first?.let { verticalPageRootOffsets[it] } @@ -685,6 +1016,14 @@ internal fun PdfReaderScreen( } else { null } + logPdfZoomSettle { + "anchor_pass seq=$settleSequence pass=$pass mode=vertical beforeH=$beforeH " + + "beforeList=$beforeItem:$beforeItemOffset delta=${pageDelta.formatLogIntOffset()} " + + "targetItem=${targetVerticalItem?.first?.plus(1) ?: "none"} " + + "oldRoot=${oldPageRootOffset.formatLogOffset()} " + + "currentRoot=${currentPageRootOffset.formatLogOffset()} " + + "viewport=${viewportRootOffsetAtZoomStart.formatLogOffset()}" + } if (pageDelta != null) { if (abs(pageDelta.x) > 1) { pageHorizontalScrollState.scrollTo( @@ -702,12 +1041,29 @@ internal fun PdfReaderScreen( verticalListState.scrollToItem(itemIndex, scrollOffset) } } + logPdfZoomSettle { + "anchor_pass_end seq=$settleSequence pass=$pass mode=vertical afterH=${pageHorizontalScrollState.value} " + + "afterList=${verticalListState.firstVisibleItemIndex}:${verticalListState.firstVisibleItemScrollOffset} " + + "delta=${pageDelta.formatLogIntOffset()}" + } } - correctVerticalAnchor() + correctVerticalAnchor(pass = 1) withFrameNanos { } - correctVerticalAnchor() + correctVerticalAnchor(pass = 2) } } + clearCommittedPreview() + } + zoomAnchorJob.set(nextAnchorJob) + } else { + val nextAnchorJob = pdfScope.launch { + withFrameNanos { } + logPdfZoomSettle { + "anchor_skip seq=$settleSequence reason=no_anchor mode=$activeDisplayMode h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + verticalListState.firstVisibleItemScrollOffset + } + clearCommittedPreview() } zoomAnchorJob.getAndSet(nextAnchorJob)?.cancel() } @@ -724,27 +1080,60 @@ internal fun PdfReaderScreen( "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + "renderJobActive=${renderJob?.isActive == true} cacheKeys=${paginatedRenderCache.keys.sorted().map { it + 1 }}" } - val existingPreview = pdfZoomPreview + val previewPageIndex = when (activeDisplayMode) { + PdfDisplayMode.PAGINATION -> paginatedZoomAnchorPageIndex(anchor) + PdfDisplayMode.VERTICAL_SCROLL -> anchor?.let(::verticalZoomAnchorItem)?.index ?: activePageIndex + } + val existingPreview = pdfZoomPreview?.takeIf { + it.displayMode == activeDisplayMode && + it.pageIndex == previewPageIndex && + it.baseZoom.isFinite() && + it.baseZoom > 0f && + abs(it.baseZoom - activeScale) <= 0.0001f + } + if (existingPreview == null && currentShouldRestorePdfReaderFocus) { + runCatching { pdfReaderFocusRequester.requestFocus() } + } + if (existingPreview == null) { + pdfZoomSettleSequence += 1 + } + val settleSequence = pdfZoomSettleSequence val baseZoom = existingPreview - ?.takeIf { it.displayMode == activeDisplayMode && it.baseZoom.isFinite() && it.baseZoom > 0f } ?.baseZoom ?: oldZoom.takeIf { it.isFinite() && it > 0f } ?: activeScale - val previewPageIndex = when (activeDisplayMode) { - PdfDisplayMode.PAGINATION -> activePageIndex - PdfDisplayMode.VERTICAL_SCROLL -> anchor?.let(::verticalZoomAnchorItem)?.index ?: activePageIndex + val previewPageRootOffset = existingPreview?.pageRootOffset ?: when (activeDisplayMode) { + PdfDisplayMode.PAGINATION -> paginatedZoomPageRoot(previewPageIndex) + PdfDisplayMode.VERTICAL_SCROLL -> verticalPageRootOffsets[previewPageIndex] } pdfZoomPreview = DesktopPdfZoomPreview( baseZoom = baseZoom, zoom = newZoom, anchor = anchor, displayMode = activeDisplayMode, - pageIndex = previewPageIndex + pageIndex = previewPageIndex, + viewportRootOffset = existingPreview?.viewportRootOffset ?: pdfZoomViewportRootOffset, + pageRootOffset = previewPageRootOffset, + diagnosticSequence = settleSequence ) + logPdfZoomSettle { + "preview_update seq=$settleSequence mode=$activeDisplayMode page=${activePageIndex + 1} " + + "previewPage=${previewPageIndex + 1} oldEvent=${oldZoom.formatLogFloat()} " + + "activeScale=${activeScale.formatLogFloat()} base=${baseZoom.formatLogFloat()} " + + "new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} existing=${existingPreview != null} " + + "viewport=${pdfZoomViewportRootOffset.formatLogOffset()} pageRoot=${previewPageRootOffset.formatLogOffset()} " + + "h=${pageHorizontalScrollState.value} v=${pageVerticalScrollState.value} " + + "list=${verticalListState.firstVisibleItemIndex}:${verticalListState.firstVisibleItemScrollOffset} " + + "renderPage=${renderedPageIndex?.plus(1) ?: "none"} renderScale=${renderedPageScale?.formatLogFloat() ?: "none"}" + } val nextCommitJob = pdfScope.launch { delay(DesktopPdfZoomCommitDebounceMillis) val preview = pdfZoomPreview ?: return@launch - pdfZoomPreview = null + logPdfZoomSettle { + "commit_debounce_fire seq=$settleSequence base=${preview.baseZoom.formatLogFloat()} " + + "zoom=${preview.zoom.formatLogFloat()} page=${preview.pageIndex?.plus(1) ?: "none"} " + + "anchor=${preview.anchor.formatLogOffset()}" + } applyAnchoredPdfZoom(preview.baseZoom, preview.zoom, preview.anchor) } zoomCommitJob.getAndSet(nextCommitJob)?.cancel() @@ -758,10 +1147,45 @@ internal fun PdfReaderScreen( } fun cancelPendingPdfZoomPreview() { + logPdfZoomSettle { + "preview_cancel seq=$pdfZoomSettleSequence reason=explicit pending=${pdfZoomPreview != null}" + } pdfZoomPreview = null zoomCommitJob.getAndSet(null)?.cancel() } + fun commitPendingPdfZoomPreviewForNavigation(targetPageIndex: Int) { + val snapshot = desktopPdfNavigationZoomSnapshot( + preview = pdfZoomPreview, + currentHorizontalScroll = pageHorizontalScrollState.value, + currentVerticalScroll = pageVerticalScrollState.value + ) ?: return + val committedZoom = zoomSpec.clamp(snapshot.zoom) + logPdfZoomSettle { + "preview_navigation_commit seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "target=${targetPageIndex + 1} zoom=${committedZoom.formatLogFloat()} " + + "h=${snapshot.horizontalScroll} v=${snapshot.verticalScroll}" + } + zoomCommitJob.getAndSet(null)?.cancel() + zoomAnchorJob.getAndSet(null)?.cancel() + pdfZoomPreview = null + dispatchPdf(SharedPdfReaderAction.ZoomChanged(committedZoom)) + if (displayMode == PdfDisplayMode.PAGINATION) { + pdfNavigationScrollRestoreSequence += 1 + pendingPdfNavigationScrollRestore = DesktopPdfPendingPaginatedScrollRestore( + requestId = pdfNavigationScrollRestoreSequence, + pageIndex = targetPageIndex.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)), + zoom = committedZoom, + horizontalScroll = snapshot.horizontalScroll, + verticalScroll = snapshot.verticalScroll + ) + } else { + pdfScope.launch { + pageHorizontalScrollState.scrollTo(snapshot.horizontalScroll) + } + } + } + fun cachePaginatedRender(page: Int, renderScale: Float, render: DesktopPdfPageRender) { paginatedRenderCache[page] = DesktopPdfCachedPageRender(render, renderScale) val activePageIndex = currentPdfPageIndex @@ -775,10 +1199,17 @@ internal fun PdfReaderScreen( "bitmap=${render.width}x${render.height} current=${activePageIndex + 1} " + "keys=${paginatedRenderCache.keys.sorted().map { it + 1 }} evicted=${evictedPages.map { it + 1 }}" } + logPdfZoomSettle { + "cache_put seq=$pdfZoomSettleSequence page=${page + 1} scale=${renderScale.formatLogFloat()} " + + "bitmap=${render.width}x${render.height} current=${activePageIndex + 1} " + + "keys=${paginatedRenderCache.keys.sorted().map { it + 1 }} evicted=${evictedPages.map { it + 1 }}" + } } - LaunchedEffect(documentHandleId, pageIndex, displayMode) { - runCatching { pdfReaderFocusRequester.requestFocus() } + LaunchedEffect(documentHandleId, pageIndex, displayMode, scale) { + if (currentShouldRestorePdfReaderFocus) { + runCatching { pdfReaderFocusRequester.requestFocus() } + } } val searchQuery = pdfState.searchQuery @@ -859,8 +1290,8 @@ internal fun PdfReaderScreen( annotations.firstOrNull { it.id == selectedAnnotationId } } val selectedTextHighlight = selectedAnnotation?.takeIf { it.isDesktopTextSelectionHighlight } - val sortedAnnotations = remember(annotations) { - annotations.sortedWith(compareBy { it.pageIndex }.thenBy { it.createdAt }) + val sortedSidebarHighlights = remember(annotations) { + desktopPdfSidebarHighlights(annotations) } val sortedEmbeddedAnnotations = remember(document.embeddedAnnotations) { document.embeddedAnnotations.sortedWith(compareBy { it.pageIndex }.thenBy { it.index }) @@ -983,10 +1414,12 @@ internal fun PdfReaderScreen( } fun updatePdfHighlighterPalette(nextPalette: SharedPdfHighlighterPalette) { - val previousSlot = pdfHighlighterPalette.sanitized().colors.indexOf(selectedColor) + fun sameRgb(left: Int, right: Int): Boolean = (left and 0x00FFFFFF) == (right and 0x00FFFFFF) + + val previousSlot = pdfHighlighterPalette.sanitized().colors.indexOfFirst { sameRgb(it, selectedColor) } val sanitizedPalette = nextPalette.sanitized() onPdfHighlighterPaletteChange(sanitizedPalette) - if (selectedTool.isDesktopHighlighter && selectedColor !in sanitizedPalette.colors) { + if (selectedTool.isDesktopHighlighter && sanitizedPalette.colors.none { sameRgb(it, selectedColor) }) { val colorArgb = sanitizedPalette.colors.getOrNull(previousSlot) ?: sanitizedPalette.colors.firstOrNull() colorArgb?.let { nextSelectedColor -> @@ -1005,6 +1438,10 @@ internal fun PdfReaderScreen( val pdfPopupActive = externalLinkDialogUrl != null || + showPdfAiHub || + showPdfSaveDialog || + pdfFileActionNotice != null || + isPdfFileActionLoading || selectedTextHighlight != null || selectedEmbeddedAnnotation != null || pdfExtrasState.aiResult.hasContent || @@ -1016,10 +1453,19 @@ internal fun PdfReaderScreen( } } - LaunchedEffect(aiByokSettings) { + LaunchedEffect(pdfReaderFocusRestoreRequest, documentHandleId) { + if (pdfReaderFocusRestoreRequest > 0) { + delay(140L) + if (currentShouldRestorePdfReaderFocus && !pdfPopupActive) { + runCatching { pdfReaderFocusRequester.requestFocus() } + } + } + } + + LaunchedEffect(aiByokSettings, cloudTtsControlsAvailable) { pdfExtrasState = pdfExtrasState.copy( cloudTts = pdfExtrasState.cloudTts.copy( - isAvailable = aiByokSettings.isCloudTtsAvailable, + isAvailable = cloudTtsControlsAvailable && aiByokSettings.isCloudTtsAvailable, errorMessage = null, cacheSummary = currentPdfTtsCacheSummary() ) @@ -1077,7 +1523,8 @@ internal fun PdfReaderScreen( target: Int, scrollVertical: Boolean = true, recordJump: Boolean = false, - saveRichTextBeforePageChange: Boolean = true + saveRichTextBeforePageChange: Boolean = true, + commitPendingZoomPreview: Boolean = true ) { val boundedTarget = target.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)) val clampedTarget = if (displayMode == PdfDisplayMode.PAGINATION) { @@ -1107,6 +1554,9 @@ internal fun PdfReaderScreen( pageCount = document.pageCount ) } + if (commitPendingZoomPreview) { + commitPendingPdfZoomPreviewForNavigation(clampedTarget) + } dispatchPdf(SharedPdfReaderAction.GoToPage(clampedTarget)) if (scrollVertical && displayMode == PdfDisplayMode.VERTICAL_SCROLL) { pdfScope.launch { @@ -1119,18 +1569,24 @@ internal fun PdfReaderScreen( if (pageScrubStartPage == null) { pageScrubStartPage = pdfState.pageIndex } - val targetPage = if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.normalizePageIndex(value.roundToInt(), document.pageCount, pdfReaderSettings) - } else { - value.roundToInt().coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)) - } + val targetPage = desktopPdfPageScrubTarget( + value = value, + pageCount = document.pageCount, + displayMode = displayMode, + settings = pdfReaderSettings + ) pageScrubPreview = targetPage - goToPage(targetPage) } fun finishPdfPageScrub() { val startPage = pageScrubStartPage - val targetPage = pdfState.pageIndex + val targetPage = desktopPdfPageScrubCommitTarget( + previewPage = pageScrubPreview, + currentPage = pdfState.pageIndex, + pageCount = document.pageCount + ) + pageScrubStartPage = null + pageScrubPreview = null if (startPage != null) { jumpHistory = jumpHistory.record( currentPageIndex = startPage, @@ -1138,8 +1594,7 @@ internal fun PdfReaderScreen( pageCount = document.pageCount ) } - pageScrubStartPage = null - pageScrubPreview = null + goToPage(targetPage) } fun previousPdfPageTarget(): Int { @@ -1249,24 +1704,21 @@ internal fun PdfReaderScreen( "right=${bounds.right.formatLogFloat()} bottom=${bounds.bottom.formatLogFloat()}" ) } - dispatchPdf( - SharedPdfReaderAction.AnnotationAdded( - SharedPdfAnnotation( - id = "highlight_${now}", - pageIndex = pageIndex, - kind = PdfAnnotationKind.HIGHLIGHT, - tool = PdfInkTool.HIGHLIGHTER, - bounds = highlightBounds.firstOrNull(), - boundsList = highlightBounds, - text = selection.text, - colorArgb = SharedPdfAndroidHighlightColors.nearestArgb(colorArgb), - rangeStartIndex = selection.startIndex, - rangeEndIndex = selection.endIndex, - createdAt = now - ) - ) + val annotation = SharedPdfAnnotation( + id = "highlight_${now}", + pageIndex = pageIndex, + kind = PdfAnnotationKind.HIGHLIGHT, + tool = PdfInkTool.HIGHLIGHTER, + bounds = highlightBounds.firstOrNull(), + boundsList = highlightBounds, + text = selection.text, + colorArgb = SharedPdfHighlighterPalette(listOf(colorArgb)).sanitized().colors.first(), + rangeStartIndex = selection.startIndex, + rangeEndIndex = selection.endIndex, + createdAt = now ) - dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + pdfState = pdfState.withDesktopPdfTextSelectionHighlightAdded(annotation, zoomSpec) + clearPdfInteractionState() } fun clearSelection() { @@ -1315,12 +1767,8 @@ internal fun PdfReaderScreen( } } - fun updatePdfAutoScroll(autoScroll: ReaderAutoScrollState) { - pdfExtrasState = pdfExtrasState.copy(autoScroll = autoScroll.sanitized()) - } - fun pdfCloudTtsStoppedState(statusMessage: String? = null, errorMessage: String? = null) = ReaderCloudTtsState( - isAvailable = aiByokSettings.sanitized().isCloudTtsAvailable, + isAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, statusMessage = statusMessage, errorMessage = errorMessage, cacheSummary = currentPdfTtsCacheSummary() @@ -1547,17 +1995,10 @@ internal fun PdfReaderScreen( } fun pdfCloudTtsUnavailableMessage(): String { - return if (aiByokSettings.serverBackedReaderAiFeatures || aiByokSettings.serverBackedCloudTts) { - pdfString( - "desktop_cloud_tts_signed_in_credits_required_desc", - "Cloud TTS needs a signed-in account with credits. Pro and credits can only be purchased from the Android app." - ) - } else { - pdfString( - "desktop_cloud_tts_needs_gemini_key_desc", - "Add a Gemini key and select Gemini cloud TTS in AI keys and models." - ) - } + return pdfString( + "desktop_cloud_tts_signed_in_credits_required_desc", + "Cloud TTS needs a signed-in account with credits. Pro and credits can only be purchased from the Android app." + ) } fun pdfReadScopeLabel(readScope: ReaderTtsReadScope): String { @@ -1568,7 +2009,12 @@ internal fun PdfReaderScreen( } } - fun startPdfCloudTts(readScope: ReaderTtsReadScope) { + fun startPdfCloudTts( + readScope: ReaderTtsReadScope, + startChunkIndex: Int = 0, + chunksOverride: List? = null, + restartActive: Boolean = false + ) { val settings = aiByokSettings.sanitized() logDesktopTts( "pdf_sequence_toggle scope=${readScope.name} startPage=${pageIndex + 1} " + @@ -1576,10 +2022,15 @@ internal fun PdfReaderScreen( "keyPresent=${settings.geminiKey.isNotBlank()} ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" " + "available=${ttsAdapter.isAvailable}" ) - if (pdfExtrasState.cloudTts.isPlaying || pdfExtrasState.cloudTts.isLoading || pdfExtrasState.cloudTts.isPaused) { + val ttsActive = pdfExtrasState.cloudTts.isPlaying || pdfExtrasState.cloudTts.isLoading || pdfExtrasState.cloudTts.isPaused + if (ttsActive && !restartActive) { stopPdfCloudTts() return } + if (ttsActive) { + pdfTtsJob?.cancel() + pdfTtsJob = null + } if (!ttsAdapter.isAvailable) { logDesktopTts("pdf_sequence_blocked reason=adapter_unavailable") onCloudTtsEntitlementRequired() @@ -1602,45 +2053,69 @@ internal fun PdfReaderScreen( "Preparing %1\$s", pdfReadScopeLabel(readScope) ), + progress = ReaderTtsProgress(sessionId = ttsSessionId, scope = readScope), cacheSummary = currentPdfTtsCacheSummary() ) ) + fun updatePdfTtsSession(transform: (ReaderExtrasState) -> ReaderExtrasState) { + if (pdfExtrasState.cloudTts.progress.sessionId == ttsSessionId) { + pdfExtrasState = transform(pdfExtrasState) + } + } val noTextMessage = pdfString("desktop_no_text_here_to_read", "There is no text here to read.") pdfTtsJob = pdfScope.launch { var completedChunkCount = 0 runCatching { - val ttsChunks = withContext(Dispatchers.IO) { - pdfTtsChunksForScope(readScope, pageIndex) - .filter { it.text.isNotBlank() } - .withTtsReplacements(ttsReplacementPreferences, document.path) - } + val ttsChunks = chunksOverride + ?.filter { it.text.isNotBlank() } + ?: withContext(Dispatchers.IO) { + pdfTtsChunksForScope(readScope, pageIndex) + .filter { it.text.isNotBlank() } + .withTtsReplacements(ttsReplacementPreferences, document.path) + } if (ttsChunks.isEmpty()) { logDesktopTts("pdf_sequence_ignored reason=blank_text scope=${readScope.name}") throw IllegalStateException(noTextMessage) } + val boundedStartChunkIndex = startChunkIndex.coerceIn(0, ttsChunks.lastIndex) + val playbackChunks = ttsChunks.drop(boundedStartChunkIndex) val initialProgress = ReaderTtsProgress( sessionId = ttsSessionId, scope = readScope, chunks = ttsChunks, - currentChunkIndex = -1 + currentChunkIndex = boundedStartChunkIndex - 1 ) - logDesktopTts("pdf_sequence_start scope=${readScope.name} chunks=${ttsChunks.size}") - ttsAdapter.speakChunks(document.title, readScope, ttsChunks) { index -> + updatePdfTtsSession { extras -> + extras.copy( + cloudTts = extras.cloudTts.copy( + progress = initialProgress, + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + } + logDesktopTts( + "pdf_sequence_start scope=${readScope.name} chunks=${ttsChunks.size} " + + "startChunk=${boundedStartChunkIndex + 1}" + ) + ttsAdapter.speakChunks(document.title, readScope, playbackChunks) { relativeIndex -> if (!isActive) throw kotlinx.coroutines.CancellationException("PDF cloud TTS stopped") + val index = boundedStartChunkIndex + relativeIndex val chunk = ttsChunks[index] val progress = initialProgress.copy(currentChunkIndex = index) if (chunk.pageIndex != pdfState.pageIndex) { goToPage(chunk.pageIndex, recordJump = false) } - pdfExtrasState = pdfExtrasState.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = true, - isPlaying = true, - statusMessage = progress.currentPositionLabel ?: pdfString("label_reading", "Reading"), - progress = progress, - cacheSummary = currentPdfTtsCacheSummary() + updatePdfTtsSession { extras -> + extras.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = true, + isPlaying = true, + statusMessage = progress.currentPositionLabel ?: pdfString("label_reading", "Reading"), + progress = progress, + cacheSummary = currentPdfTtsCacheSummary() + ) ) - ) + } logDesktopTts( "pdf_chunk_start scope=${readScope.name} index=${index + 1}/${ttsChunks.size} " + "page=${chunk.pageIndex + 1} offsets=${chunk.startOffset}..${chunk.endOffset} chars=${chunk.text.length}" @@ -1649,28 +2124,50 @@ internal fun PdfReaderScreen( } }.onFailure { error -> logDesktopTts("pdf_sequence_failed error=\"${error.desktopTtsSummary()}\"") - if (error !is kotlinx.coroutines.CancellationException && error.message != noTextMessage) error.printStackTrace() - pdfExtrasState = if (error is kotlinx.coroutines.CancellationException) { - pdfExtrasState.copy( - cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_stopped", "Stopped")) - ) - } else { - onPaidFeatureError(error.message) - pdfExtrasState.copy( - cloudTts = pdfCloudTtsStoppedState( - errorMessage = error.message ?: pdfString("desktop_cloud_tts_failed", "Cloud TTS failed.") + updatePdfTtsSession { extras -> + if (error is kotlinx.coroutines.CancellationException) { + extras.copy( + cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_stopped", "Stopped")) ) - ) + } else { + onPaidFeatureError(error.message) + extras.copy( + cloudTts = pdfCloudTtsStoppedState( + errorMessage = error.message ?: pdfString("desktop_cloud_tts_failed", "Cloud TTS failed.") + ) + ) + } } }.onSuccess { logDesktopTts("pdf_sequence_success chunks=$completedChunkCount") - pdfExtrasState = pdfExtrasState.copy( - cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_finished", "Finished")) - ) + updatePdfTtsSession { extras -> + extras.copy( + cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_finished", "Finished")) + ) + } } } } + fun skipPdfCloudTtsChunk(delta: Int) { + val progress = pdfExtrasState.cloudTts.progress + if (progress.chunks.isEmpty()) return + val currentIndex = progress.currentChunkIndex.takeIf { it >= 0 } ?: return + val targetIndex = (currentIndex + delta).coerceIn(0, progress.chunks.lastIndex) + if (targetIndex == currentIndex) return + startPdfCloudTts( + readScope = progress.scope, + startChunkIndex = targetIndex, + chunksOverride = progress.chunks, + restartActive = true + ) + } + + fun locatePdfCloudTtsChunk() { + val chunk = pdfExtrasState.cloudTts.progress.currentChunk ?: return + goToPage(chunk.pageIndex, recordJump = false) + } + fun togglePdfCloudTts(text: String) { val normalizedText = text.trim() val settings = aiByokSettings.sanitized() @@ -1720,59 +2217,10 @@ internal fun PdfReaderScreen( ) return } - pdfTtsJob = null - pdfExtrasState = pdfExtrasState.copy( - cloudTts = pdfExtrasState.cloudTts.copy(cacheSummary = currentPdfTtsCacheSummary()) + startPdfCloudTts( + readScope = ReaderTtsReadScope.PAGE, + chunksOverride = selectionChunks ) - pdfTtsJob = pdfScope.launch { - val initialProgress = ReaderTtsProgress( - sessionId = System.currentTimeMillis(), - scope = ReaderTtsReadScope.PAGE, - chunks = selectionChunks, - currentChunkIndex = -1 - ) - runCatching { - pdfExtrasState = pdfExtrasState.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = true, - isLoading = true, - statusMessage = pdfString("desktop_preparing_selection", "Preparing selection"), - progress = initialProgress, - cacheSummary = currentPdfTtsCacheSummary() - ) - ) - ttsAdapter.speakChunks(document.title, ReaderTtsReadScope.PAGE, selectionChunks) { index -> - val progress = initialProgress.copy(currentChunkIndex = index) - pdfExtrasState = pdfExtrasState.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = true, - isPlaying = true, - statusMessage = progress.currentPositionLabel ?: pdfString("label_reading", "Reading"), - progress = progress, - cacheSummary = currentPdfTtsCacheSummary() - ) - ) - } - }.onFailure { error -> - logDesktopTts("pdf_job_failed error=\"${error.desktopTtsSummary()}\"") - if (error !is kotlinx.coroutines.CancellationException) error.printStackTrace() - pdfExtrasState = pdfExtrasState.copy( - cloudTts = if (error is kotlinx.coroutines.CancellationException) { - pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_stopped", "Stopped")) - } else { - onPaidFeatureError(error.message) - pdfCloudTtsStoppedState( - errorMessage = error.message ?: pdfString("desktop_cloud_tts_failed", "Cloud TTS failed.") - ) - } - ) - }.onSuccess { - logDesktopTts("pdf_job_success") - pdfExtrasState = pdfExtrasState.copy( - cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_finished", "Finished")) - ) - } - } } fun updateAnnotation(annotation: SharedPdfAnnotation) { @@ -1805,6 +2253,19 @@ internal fun PdfReaderScreen( annotation?.let { goToPage(it.pageIndex, recordJump = true) } } + fun dismissSelectedTextHighlightSheet() { + clearPdfInteractionState() + pdfState = pdfState.withDesktopPdfTextHighlightSheetDismissed(zoomSpec) + requestPdfReaderFocusRestore() + } + + fun deleteSelectedTextHighlight(annotation: SharedPdfAnnotation) { + clearPdfInteractionState() + pdfState = pdfState.withDesktopPdfTextHighlightSheetDismissed(zoomSpec) + dispatchPdf(SharedPdfReaderAction.AnnotationDeleted(annotation.id)) + requestPdfReaderFocusRestore() + } + fun goToSearchResult(targetIndex: Int) { if (searchResults.isEmpty()) return val normalizedIndex = when { @@ -1898,6 +2359,37 @@ internal fun PdfReaderScreen( } } + LaunchedEffect( + documentHandleId, + pendingPdfNavigationScrollRestore?.requestId, + pageIndex, + scale, + displayMode + ) { + val restore = pendingPdfNavigationScrollRestore ?: return@LaunchedEffect + if ( + displayMode != PdfDisplayMode.PAGINATION || + restore.pageIndex != pageIndex || + abs(restore.zoom - scale) > 0.001f + ) { + return@LaunchedEffect + } + withFrameNanos { } + pageHorizontalScrollState.scrollTo(restore.horizontalScroll) + pageVerticalScrollState.scrollTo(restore.verticalScroll) + withFrameNanos { } + pageHorizontalScrollState.scrollTo(restore.horizontalScroll) + pageVerticalScrollState.scrollTo(restore.verticalScroll) + logPdfZoomSettle { + "preview_navigation_restore request=${restore.requestId} page=${pageIndex + 1} " + + "zoom=${scale.formatLogFloat()} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value}" + } + if (pendingPdfNavigationScrollRestore == restore) { + pendingPdfNavigationScrollRestore = null + } + } + fun selectPdfPanMode() { SharedPdfRichTextLog.d( "desktop.tool.select tool=${PdfInkTool.NONE} richMode=$isRichTextMode page=${pdfState.pageIndex}" @@ -1911,16 +2403,57 @@ internal fun PdfReaderScreen( dispatchPdf(SharedPdfReaderAction.ToolSelected(PdfInkTool.NONE)) } - LaunchedEffect(pdfExtrasState.autoScroll.sanitized(), pageIndex, canGoNext, displayMode) { - val autoScroll = pdfExtrasState.autoScroll.sanitized() - if (!autoScroll.enabled) return@LaunchedEffect - if (!canGoNext) { - updatePdfAutoScroll(autoScroll.copy(enabled = false)) - return@LaunchedEffect + fun togglePdfTextSelectionMode() { + val enabled = !isTextSelectionMode + if (enabled) { + deactivateRichTextMode() + commitActiveTextDraft() } - val delayMs = (180_000f / autoScroll.speed).roundToInt().coerceIn(1_200, 12_000) - delay(delayMs.toLong()) - goToPage(nextPdfPageTarget()) + dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(enabled)) + if (!enabled) { + clearPdfInteractionState() + } + } + + @Composable + fun DesktopPdfBottomMarkupDock(modifier: Modifier = Modifier) { + SharedPdfInteractionDock( + isTextSelectionMode = isTextSelectionMode, + selectedTool = selectedTool, + selectedColor = selectedColor, + strokeWidth = strokeWidth, + toolConfigs = pdfState.toolConfigs, + penPalette = pdfState.penPalette, + highlighterPalette = pdfHighlighterColors, + lastActivePenTool = pdfState.lastActivePenTool, + lastActiveHighlighterTool = pdfState.lastActiveHighlighterTool, + onPanSelected = ::selectPdfPanMode, + onTextSelectionSelected = ::togglePdfTextSelectionMode, + onToolSelected = ::selectPdfAnnotationTool, + onColorSelected = { dispatchPdf(SharedPdfReaderAction.ColorSelected(it)) }, + onStrokeWidthChange = { dispatchPdf(SharedPdfReaderAction.StrokeWidthChanged(it)) }, + onUndo = { dispatchPdf(SharedPdfReaderAction.UndoAnnotationEdit) }, + onRedo = { dispatchPdf(SharedPdfReaderAction.RedoAnnotationEdit) }, + onClearPage = { dispatchPdf(SharedPdfReaderAction.ClearPageAnnotations(pageIndex)) }, + modifier = modifier, + allowExpandedSettings = !isPdfSearchActive && + activeTextDraft == null && + !isRichTextMode && + textSelection == null && + selectionMenuOffset == null && + !pdfSelectionSheetActive && + externalLinkDialogUrl == null && + !pdfExtrasState.aiResult.hasContent, + canUndo = pdfState.canUndoAnnotationEdit, + canRedo = pdfState.canRedoAnnotationEdit, + canClearPage = annotations.any { it.pageIndex == pageIndex }, + isHighlighterSnapEnabled = isHighlighterSnapEnabled, + onHighlighterSnapChange = { isHighlighterSnapEnabled = it }, + onHighlighterPaletteChange = { colors -> + onPdfHighlighterPaletteChange(SharedPdfHighlighterPalette(colors).sanitized()) + }, + onPenPaletteChange = { colors -> dispatchPdf(SharedPdfReaderAction.PenPaletteChanged(colors)) } + ) } LaunchedEffect(documentHandleId, displayMode, verticalListState) { @@ -1948,7 +2481,7 @@ internal fun PdfReaderScreen( .distinctUntilChanged() .collect { visiblePage -> if (visiblePage in 0 until document.pageCount && visiblePage != currentPdfPageIndex) { - goToPage(visiblePage, scrollVertical = false) + goToPage(visiblePage, scrollVertical = false, commitPendingZoomPreview = false) } } } @@ -1970,12 +2503,23 @@ internal fun PdfReaderScreen( "searchIndexing=$isSearchIndexing indexed=$indexedSearchPageCount/${document.pageCount} " + "cacheKeys=${paginatedRenderCache.keys.sorted().map { it + 1 }}" } + logPdfZoomSettle { + "render_effect seq=$pdfZoomSettleSequence page=${pageIndex + 1} scale=${scale.formatLogFloat()} " + + "existingPage=${renderedPageIndex?.plus(1) ?: "none"} " + + "existingScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + + "preview=${pdfZoomPreview != null} h=${pageHorizontalScrollState.value} v=${pageVerticalScrollState.value} " + + "pageRoot=${paginatedPageRootOffset.formatLogOffset()} canvas=${pageCanvasSize.formatLogSize()}" + } if (renderedPageIndex != pageIndex) { paginatedRenderCache[pageIndex]?.let { cached -> logPdfZoomPerf { "cache_hit page=${pageIndex + 1} scale=${cached.scale.formatLogFloat()} " + "bitmap=${cached.render.width}x${cached.render.height}" } + logPdfZoomSettle { + "cache_hit seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "scale=${cached.scale.formatLogFloat()} bitmap=${cached.render.width}x${cached.render.height}" + } renderedPage = cached.render renderedPageIndex = pageIndex renderedPageScale = cached.scale @@ -1983,9 +2527,15 @@ internal fun PdfReaderScreen( isRendering = false } } - val hasPageRender = renderedPage != null && renderedPageIndex == pageIndex + val hasPageRender = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) if (!hasPageRender) { - logPdfZoomPerf { "cache_miss page=${pageIndex + 1}; showing spinner until first render" } + logPdfZoomPerf { + "cache_miss page=${pageIndex + 1}; stale=${renderedPageIndex?.let { it + 1 } ?: "none"}" + } + logPdfZoomSettle { + "cache_miss seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "stale=${renderedPageIndex?.plus(1) ?: "none"}" + } renderedPage = null renderedPageIndex = null renderedPageScale = null @@ -2016,6 +2566,11 @@ internal fun PdfReaderScreen( "safeScale=${safeScale.formatLogFloat()} firstScale=${firstRenderScale.formatLogFloat()} " + "hasRender=$hasPageRender opening=$isOpeningRender" } + logPdfZoomSettle { + "render_plan seq=$pdfZoomSettleSequence page=${pageIndex + 1} requestedScale=${scale.formatLogFloat()} " + + "safeScale=${safeScale.formatLogFloat()} firstScale=${firstRenderScale.formatLogFloat()} " + + "hasRender=$hasPageRender opening=$isOpeningRender existingScale=${renderedPageScale?.formatLogFloat() ?: "none"}" + } suspend fun renderAt(renderScale: Float, delayMillis: Long, showSpinner: Boolean): Boolean { logPdfZoomPerf { @@ -2023,6 +2578,11 @@ internal fun PdfReaderScreen( "requestedScale=${scale.formatLogFloat()} delayMs=$delayMillis showSpinner=$showSpinner " + "hasPageRender=$hasPageRender" } + logPdfZoomSettle { + "render_scheduled seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "renderScale=${renderScale.formatLogFloat()} requestedScale=${scale.formatLogFloat()} " + + "delayMs=$delayMillis showSpinner=$showSpinner preview=${pdfZoomPreview != null}" + } delay(delayMillis) if (showSpinner) { isRendering = true @@ -2043,6 +2603,12 @@ internal fun PdfReaderScreen( "elapsedMs=$elapsedMs currentPage=${currentPdfPageIndex + 1} " + "currentScale=${currentPdfScale.formatLogFloat()} mode=$currentPdfDisplayMode" } + logPdfZoomSettle { + "render_stale seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "renderScale=${renderScale.formatLogFloat()} elapsedMs=$elapsedMs " + + "currentPage=${currentPdfPageIndex + 1} currentScale=${currentPdfScale.formatLogFloat()} " + + "mode=$currentPdfDisplayMode" + } return false } result.getOrNull()?.let { render -> @@ -2062,6 +2628,13 @@ internal fun PdfReaderScreen( "requestedScale=${scale.formatLogFloat()} elapsedMs=$elapsedMs success=${result.isSuccess} " + "error=${result.exceptionOrNull()?.message?.logPreview() ?: "none"}" } + logPdfZoomSettle { + "render_end seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "renderScale=${renderScale.formatLogFloat()} requestedScale=${scale.formatLogFloat()} " + + "elapsedMs=$elapsedMs success=${result.isSuccess} bitmap=${renderedPage?.width ?: 0}x${renderedPage?.height ?: 0} " + + "h=${pageHorizontalScrollState.value} v=${pageVerticalScrollState.value} " + + "pageRoot=${paginatedPageRootOffset.formatLogOffset()} canvas=${pageCanvasSize.formatLogSize()}" + } renderedPage?.let { render -> logPdfSelection( "render page=${pageIndex + 1} " + @@ -2129,14 +2702,18 @@ internal fun PdfReaderScreen( val existingScale = renderedPageScale val needsFirstRender = !hasPageRender || - existingScale == null || - abs(existingScale - firstRenderScale) > DesktopPdfRenderScaleTolerance + desktopPdfRenderScaleNeedsUpgrade(existingScale, firstRenderScale) if (needsFirstRender) { renderAt( renderScale = firstRenderScale, delayMillis = if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L, showSpinner = !hasPageRender ) + } else { + logPdfZoomSettle { + "render_skip seq=$pdfZoomSettleSequence page=${pageIndex + 1} reason=no_scale_upgrade " + + "existingScale=${existingScale?.formatLogFloat() ?: "none"} firstScale=${firstRenderScale.formatLogFloat()}" + } } delay(DesktopPdfPaginationPrefetchDelayMillis) if (currentPdfPageIndex == pageIndex && currentPdfScale == scale && @@ -2154,19 +2731,18 @@ internal fun PdfReaderScreen( displayMode = displayMode, hasContents = document.toc.isNotEmpty(), hasBookmarks = bookmarks.isNotEmpty(), - hasAnnotations = sortedAnnotations.isNotEmpty(), + hasAnnotations = sortedSidebarHighlights.isNotEmpty(), hasEmbeddedComments = sortedEmbeddedAnnotations.isNotEmpty(), searchActive = isPdfSearchActive || searchQuery.isNotBlank(), annotationEditing = activeTextDraft != null || selectedAnnotation != null || - selectedTool != PdfInkTool.NONE || - isTextSelectionMode, + selectedTool != PdfInkTool.NONE, richTextEditing = isRichTextMode, loading = isRendering || isSearchIndexing || isPdfFileActionLoading || isReflowingThisBook, errorMessage = renderError, extrasState = pdfExtrasState, aiAvailable = featurePolicy.aiAndCloud && aiByokSettings.sanitized().areReaderAiFeaturesAvailable, - cloudTtsAvailable = featurePolicy.aiAndCloud && aiByokSettings.sanitized().isCloudTtsAvailable, + cloudTtsAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, externalLookupAvailable = featurePolicy.externalLookup ) @@ -2231,7 +2807,8 @@ internal fun PdfReaderScreen( fun handlePdfReaderKeyEvent(event: androidx.compose.ui.input.key.KeyEvent): Boolean { val command = event.desktopPdfKeyCommandOrNull( fullscreen = isFullscreen, - editingText = isPdfTextEditingActive() + editingText = isPdfTextEditingActive(), + rightToLeftPagination = rightToLeftPdfPaginationActive ) ?: return false return runPdfKeyCommand(command) } @@ -2239,14 +2816,46 @@ internal fun PdfReaderScreen( fun handlePdfReaderAwtKeyEvent(event: AwtKeyEvent): Boolean { val command = event.desktopPdfKeyCommandOrNull( fullscreen = isFullscreen, - editingText = isPdfTextEditingActive() + editingText = isPdfTextEditingActive(), + rightToLeftPagination = rightToLeftPdfPaginationActive ) ?: return false return runPdfKeyCommand(command) } + fun handlePdfReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean { + if (isPdfSearchActive) { + if (event.id == AwtKeyEvent.KEY_PRESSED && isFullscreen && event.keyCode == AwtKeyEvent.VK_ESCAPE) { + return runPdfKeyCommand(DesktopPdfKeyCommand.EXIT_FULLSCREEN) + } + return false + } + return handlePdfReaderAwtKeyEvent(event) + } + + fun handlePdfReaderGlobalShortcutAwtKeyEvent(event: AwtKeyEvent): Boolean { + if (event.id != AwtKeyEvent.KEY_PRESSED || !event.isControlDown) return false + return when (event.keyCode) { + AwtKeyEvent.VK_F -> runPdfKeyCommand(DesktopPdfKeyCommand.SEARCH) + else -> false + } + } + + DesktopReaderKeyDispatcherEffect( + enabled = !pdfPopupActive, + allowChromeModalWindows = true, + onKeyPressed = { event -> handlePdfReaderGlobalShortcutAwtKeyEvent(event) } + ) + + DesktopReaderKeyDispatcherEffect( + enabled = !pdfPopupActive && !isPdfSearchActive, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = false, + onKeyPressed = { event -> handlePdfReaderAwtKeyEvent(event) } + ) + DesktopReaderFullscreenKeyEffect( enabled = isFullscreen && !pdfPopupActive, - onKeyPressed = { event -> handlePdfReaderAwtKeyEvent(event) } + onKeyPressed = { event -> handlePdfReaderFullscreenAwtKeyEvent(event) } ) ReaderWorkspaceShell( @@ -2265,6 +2874,16 @@ internal fun PdfReaderScreen( isBookmarked = bookmarks.any { it.pageIndex == pageIndex }, onToggleBookmark = { toggleBookmark(pageIndex) }, onSearchAction = { dispatchPdf(SharedPdfReaderAction.SearchOpened) }, + onReadAloudAction = if (cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable) { + { startPdfCloudTts(ReaderTtsReadScope.BOOK) } + } else { + null + }, + onAiHubAction = if (aiByokSettings.sanitized().areReaderAiFeaturesAvailable) { + { showPdfAiHub = true } + } else { + null + }, fileActions = pdfFileActions, onSaveCopyAction = requestSaveCopy, onPrintAction = requestPrint, @@ -2286,80 +2905,47 @@ internal fun PdfReaderScreen( .focusRequester(pdfReaderFocusRequester) .onPreviewKeyEvent(::handlePdfReaderKeyEvent) .focusable(), + closeRightPanelOnReaderTap = true, + onReaderFocusRestoreRequest = ::requestPdfReaderFocusRestore, leftSidebar = { _ -> DesktopPdfNavigationSidebar( document = document, pageIndex = pageIndex, - sortedAnnotations = sortedAnnotations, - sortedEmbeddedAnnotations = sortedEmbeddedAnnotations, + sortedHighlights = sortedSidebarHighlights, bookmarks = bookmarks, - selectedAnnotationId = selectedAnnotationId, - selectedEmbeddedAnnotationId = selectedEmbeddedAnnotationId, onPageSelected = { page -> goToPage(page, recordJump = true) }, onAnnotationOpened = ::goToAnnotation, onAnnotationSelected = ::selectAnnotation, - onAnnotationDeleted = { annotation -> deleteAnnotation(annotation.id) }, - onEmbeddedAnnotationOpened = ::goToEmbeddedAnnotation, - onEmbeddedAnnotationSelected = ::selectEmbeddedAnnotation + onAnnotationDeleted = { annotation -> deleteAnnotation(annotation.id) } ) }, rightInspector = { DesktopPdfInspectorPanel( document = document, - pageIndex = pageIndex, displayMode = displayMode, pdfReaderSettings = pdfReaderSettings, + appThemeControls = appThemeControls, + customReaderThemes = customReaderThemes, + onCustomReaderThemesChange = onCustomReaderThemesChange, customTextureIds = customTextureIds, onImportTexture = onImportTexture, onReaderSettingsChange = ::updatePdfReaderSettings, - zoomControlScale = zoomControlScale, - zoomSpec = zoomSpec, - isTextSelectionMode = isTextSelectionMode, selectedTool = selectedTool, isRichTextMode = isRichTextMode, - selectedColor = selectedColor, - strokeWidth = strokeWidth, - pdfHighlighterColors = pdfHighlighterColors, pdfHighlighterPalette = pdfHighlighterPalette, - isHighlighterSnapEnabled = isHighlighterSnapEnabled, effectiveTextStyleConfig = effectiveTextStyleConfig, richTextController = richTextController, pdfExtrasState = pdfExtrasState, aiByokSettings = aiByokSettings, - externalLookupAvailable = featurePolicy.externalLookup, - cloudTtsFeatureAvailable = featurePolicy.aiAndCloud, + cloudTtsFeatureAvailable = cloudTtsControlsAvailable, ttsReplacementPreferences = ttsReplacementPreferences, - pageText = { currentPdfPageText() }, onDisplayModeSelected = { mode -> commitActiveTextDraft() + updatePdfReaderSettings( + pdfReaderSettings.copy(readingMode = mode.toDesktopReaderReadingMode()) + ) dispatchPdf(SharedPdfReaderAction.DisplayModeChanged(mode)) }, - onPageScrub = ::updatePdfPageScrub, - onPageScrubFinished = ::finishPdfPageScrub, - onZoomOut = { - cancelPendingPdfZoomPreview() - dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f)) - }, - onZoomIn = { - cancelPendingPdfZoomPreview() - dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f)) - }, - onZoomChange = { zoom -> - cancelPendingPdfZoomPreview() - dispatchPdf(SharedPdfReaderAction.ZoomChanged(zoom)) - }, - onSelectPanMode = ::selectPdfPanMode, - onTextSelectionModeToggle = { - val enabled = !isTextSelectionMode - if (enabled) { - deactivateRichTextMode() - commitActiveTextDraft() - } - dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(enabled)) - if (!enabled) { - clearPdfInteractionState() - } - }, onRichTextModeToggle = { if (isRichTextMode) { deactivateRichTextMode() @@ -2367,21 +2953,12 @@ internal fun PdfReaderScreen( activateRichTextMode() } }, - onToolSelected = ::selectPdfAnnotationTool, - onColorSelected = { dispatchPdf(SharedPdfReaderAction.ColorSelected(it)) }, - onStrokeWidthChange = { dispatchPdf(SharedPdfReaderAction.StrokeWidthChanged(it)) }, - onUndoPage = { dispatchPdf(SharedPdfReaderAction.UndoLastAnnotationOnPage(pageIndex)) }, - onClearPage = { dispatchPdf(SharedPdfReaderAction.ClearPageAnnotations(pageIndex)) }, - onHighlighterSnapChange = { isHighlighterSnapEnabled = it }, onHighlighterPaletteChange = ::updatePdfHighlighterPalette, onTextStyleChange = ::updateTextStyleConfig, - onExternalLookup = ::openPdfExternalLookup, - onOpenAiHub = { showPdfAiHub = true }, - onCloudTtsStart = ::startPdfCloudTts, - onCloudTtsPauseResume = ::pauseResumePdfCloudTts, - onCloudTtsStop = ::stopPdfCloudTts, onCloudTtsClearCache = ::clearPdfCloudTtsCache, - onAutoScrollChange = ::updatePdfAutoScroll, + onCloudTtsVoiceChange = { voiceId -> + onAiByokSettingsChange(aiByokSettings.sanitized().copy(ttsSpeakerId = voiceId)) + }, onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange ) }, @@ -2404,38 +2981,30 @@ internal fun PdfReaderScreen( onJumpForward = ::goForwardInJumpHistory, onClearJumpHistory = { jumpHistory = jumpHistory.clear() }, extraContent = { - if (featurePolicy.aiAndCloud) { - val ttsActive = pdfExtrasState.cloudTts.isLoading || - pdfExtrasState.cloudTts.isPlaying || - pdfExtrasState.cloudTts.isPaused - if (showPdfCloudTtsSettings) { - DesktopCloudTtsSettingsOverlay( + if (cloudTtsControlsAvailable) { + val ttsControls = readerCloudTtsControlsModel(pdfExtrasState.cloudTts) + if (ttsControls.isVisible) { + SharedReaderTtsOverlayControls( settings = aiByokSettings, - isTtsActive = ttsActive, - showCredits = showPaidCredits, + cloudTts = pdfExtrasState.cloudTts, credits = credits, - cacheSummary = pdfExtrasState.cloudTts.cacheSummary, - onClearCache = ::clearPdfCloudTtsCache, - onSettingsChange = { next -> - onAiByokSettingsChange( - aiByokSettings.sanitized().copy( - ttsSpeakerId = next.sanitized().ttsSpeakerId - ) - ) - } + showCredits = showPaidCredits, + isCollapsed = isPdfTtsOverlayCollapsed, + onCollapseChange = { isPdfTtsOverlayCollapsed = it }, + onPauseResume = ::pauseResumePdfCloudTts, + onSkipPrevious = { skipPdfCloudTtsChunk(-1) }, + onSkipNext = { skipPdfCloudTtsChunk(1) }, + onLocateCurrentChunk = ::locatePdfCloudTtsChunk, + onClose = ::stopPdfCloudTts, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 4.dp) ) } - DesktopCloudTtsChromeControls( - settings = aiByokSettings, - cloudTts = pdfExtrasState.cloudTts, - credits = credits, - showCredits = showPaidCredits, - onRead = { startPdfCloudTts(ReaderTtsReadScope.BOOK) }, - onPauseResume = ::pauseResumePdfCloudTts, - onStop = ::stopPdfCloudTts, - onOpenSettings = { showPdfCloudTtsSettings = !showPdfCloudTtsSettings } - ) } + DesktopPdfBottomMarkupDock( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 4.dp) + ) } ) }, @@ -2457,38 +3026,30 @@ internal fun PdfReaderScreen( onJumpForward = ::goForwardInJumpHistory, onClearJumpHistory = { jumpHistory = jumpHistory.clear() }, extraContent = { - if (featurePolicy.aiAndCloud) { - val ttsActive = pdfExtrasState.cloudTts.isLoading || - pdfExtrasState.cloudTts.isPlaying || - pdfExtrasState.cloudTts.isPaused - if (showPdfCloudTtsSettings) { - DesktopCloudTtsSettingsOverlay( + if (cloudTtsControlsAvailable) { + val ttsControls = readerCloudTtsControlsModel(pdfExtrasState.cloudTts) + if (ttsControls.isVisible) { + SharedReaderTtsOverlayControls( settings = aiByokSettings, - isTtsActive = ttsActive, - showCredits = showPaidCredits, + cloudTts = pdfExtrasState.cloudTts, credits = credits, - cacheSummary = pdfExtrasState.cloudTts.cacheSummary, - onClearCache = ::clearPdfCloudTtsCache, - onSettingsChange = { next -> - onAiByokSettingsChange( - aiByokSettings.sanitized().copy( - ttsSpeakerId = next.sanitized().ttsSpeakerId - ) - ) - } + showCredits = showPaidCredits, + isCollapsed = isPdfTtsOverlayCollapsed, + onCollapseChange = { isPdfTtsOverlayCollapsed = it }, + onPauseResume = ::pauseResumePdfCloudTts, + onSkipPrevious = { skipPdfCloudTtsChunk(-1) }, + onSkipNext = { skipPdfCloudTtsChunk(1) }, + onLocateCurrentChunk = ::locatePdfCloudTtsChunk, + onClose = ::stopPdfCloudTts, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 4.dp) ) } - DesktopCloudTtsChromeControls( - settings = aiByokSettings, - cloudTts = pdfExtrasState.cloudTts, - credits = credits, - showCredits = showPaidCredits, - onRead = { startPdfCloudTts(ReaderTtsReadScope.BOOK) }, - onPauseResume = ::pauseResumePdfCloudTts, - onStop = ::stopPdfCloudTts, - onOpenSettings = { showPdfCloudTtsSettings = !showPdfCloudTtsSettings } - ) } + DesktopPdfBottomMarkupDock( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 4.dp) + ) } ) } @@ -2520,22 +3081,32 @@ internal fun PdfReaderScreen( onNext = { goToSearchResult(activeSearchIndex + 1) }, onToggleHighlightMode = { dispatchPdf(SharedPdfReaderAction.SearchHighlightModeToggled) } ) + val pdfViewportBackground = desktopPdfViewportBackgroundColor( + displayMode = displayMode, + pageBackgroundColor = pdfThemeStyle.pageBackgroundColor, + appBackgroundColor = MaterialTheme.colorScheme.surfaceVariant, + isVerticalPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible + ) if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) { val verticalPageGap = pdfVerticalPageGapDp( isPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible, defaultGap = DesktopDefaultPdfVerticalPageGap ) - val verticalViewportBackground = desktopPdfVerticalViewportBackgroundColor( - pageBackgroundColor = pdfThemeStyle.pageBackgroundColor, - gapBackgroundColor = MaterialTheme.colorScheme.surfaceVariant, - isPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible - ) Box( modifier = Modifier - .fillMaxSize() - .background(verticalViewportBackground, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) - .onGloballyPositioned { coordinates -> - pdfZoomViewportRootOffset = coordinates.positionInRoot() + .fillMaxSize() + .background(pdfViewportBackground, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .onSizeChanged { size -> pdfZoomViewportSize = size } + .onGloballyPositioned { coordinates -> + val rootOffset = coordinates.positionInRoot() + if (rootOffset != pdfZoomViewportRootOffset) { + logPdfZoomSettle { + "viewport_layout seq=$pdfZoomSettleSequence mode=vertical " + + "prev=${pdfZoomViewportRootOffset.formatLogOffset()} next=${rootOffset.formatLogOffset()} " + + "scale=${scale.formatLogFloat()} preview=${pdfZoomPreview != null}" + } + } + pdfZoomViewportRootOffset = rootOffset } .desktopPdfZoomGestures( currentZoom = scale, @@ -2553,6 +3124,9 @@ internal fun PdfReaderScreen( horizontalAlignment = Alignment.CenterHorizontally ) { items((0 until document.pageCount).toList(), key = { it }) { verticalPageIndex -> + val verticalZoomPreview = pdfZoomPreview?.takeIf { + it.displayMode == PdfDisplayMode.VERTICAL_SCROLL + } DesktopVerticalPdfPage( document = document, pageIndex = verticalPageIndex, @@ -2576,12 +3150,20 @@ internal fun PdfReaderScreen( richTextController = richTextController, isRichTextMode = isRichTextMode, readerAiFeaturesAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, - cloudTtsAvailable = aiByokSettings.sanitized().isCloudTtsAvailable, + cloudTtsAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, externalLookupAvailable = featurePolicy.externalLookup, themeStyle = pdfThemeStyle, shouldRender = verticalPageIndex in verticalRenderWindow, - zoomPreview = pdfZoomPreview?.takeIf { - it.displayMode == PdfDisplayMode.VERTICAL_SCROLL + zoomPreview = verticalZoomPreview, + zoomPreviewAnchorPageRootOffset = verticalZoomPreview + ?.pageIndex + ?.let { verticalPageRootOffsets[it] }, + zoomPreviewScrollBounds = verticalZoomPreview?.let { + desktopPdfZoomScrollBoundsWithCommitTargets( + preview = it, + currentHorizontalScroll = pageHorizontalScrollState.value, + maxHorizontalScroll = pageHorizontalScrollState.maxValue + ) }, zoomViewportRootOffset = pdfZoomViewportRootOffset, showPageNumberOverlay = pdfReaderSettings.pdfPageNumberOverlayVisible, @@ -2615,6 +3197,16 @@ internal fun PdfReaderScreen( } }, onPagePositioned = { page, offset -> + val previousOffset = verticalPageRootOffsets[page] + if (previousOffset != offset) { + logPdfZoomSettle { + "page_layout seq=$pdfZoomSettleSequence mode=vertical page=${page + 1} " + + "prevRoot=${previousOffset.formatLogOffset()} nextRoot=${offset.formatLogOffset()} " + + "scale=${scale.formatLogFloat()} preview=${verticalZoomPreview != null} " + + "h=${pageHorizontalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + verticalListState.firstVisibleItemScrollOffset + } + } verticalPageRootOffsets[page] = offset } ) @@ -2624,7 +3216,7 @@ internal fun PdfReaderScreen( listState = verticalListState, pageCount = document.pageCount, currentPage = pageIndex, - isDarkMode = verticalViewportBackground.luminance() < 0.5f, + isDarkMode = pdfViewportBackground.luminance() < 0.5f, modifier = Modifier.align(Alignment.CenterEnd) ) DesktopPdfPageScrubOverlay( @@ -2638,9 +3230,18 @@ internal fun PdfReaderScreen( Box( modifier = Modifier .fillMaxSize() - .background(pdfThemeStyle.viewerBackgroundColor, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .background(pdfViewportBackground, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .onSizeChanged { size -> pdfZoomViewportSize = size } .onGloballyPositioned { coordinates -> - pdfZoomViewportRootOffset = coordinates.positionInRoot() + val rootOffset = coordinates.positionInRoot() + if (rootOffset != pdfZoomViewportRootOffset) { + logPdfZoomSettle { + "viewport_layout seq=$pdfZoomSettleSequence mode=spread " + + "prev=${pdfZoomViewportRootOffset.formatLogOffset()} next=${rootOffset.formatLogOffset()} " + + "scale=${scale.formatLogFloat()} preview=${pdfZoomPreview != null}" + } + } + pdfZoomViewportRootOffset = rootOffset } .desktopPdfZoomGestures( currentZoom = scale, @@ -2652,10 +3253,50 @@ internal fun PdfReaderScreen( .padding(24.dp), contentAlignment = Alignment.TopCenter ) { + val spreadPageGap = desktopPdfSpreadPageGapDp( + isPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible + ) Row( - horizontalArrangement = Arrangement.spacedBy(18.dp, Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy(spreadPageGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.Top ) { + val spreadZoomPreview = pdfZoomPreview?.takeIf { + it.displayMode == PdfDisplayMode.PAGINATION + } + val spreadPredictedPageCanvasSizes = paginatedVisiblePageIndices.mapNotNull { visiblePageIndex -> + document.pageSizes.getOrNull(visiblePageIndex)?.let { pageSize -> + val pageDisplayScale = zoomSpec.clamp(scale) + visiblePageIndex to IntSize( + width = (pageSize.width * pageDisplayScale).roundToInt().coerceAtLeast(1), + height = (pageSize.height * pageDisplayScale).roundToInt().coerceAtLeast(1) + ) + } + }.toMap() + val spreadLayoutPrediction = spreadZoomPreview?.let { + desktopPdfSpreadLayoutPrediction( + viewportRootOffset = pdfZoomViewportRootOffset, + viewportSize = pdfZoomViewportSize, + visiblePageIndices = paginatedVisiblePageIndices, + pageCanvasSizes = spreadPredictedPageCanvasSizes, + horizontalScroll = pageHorizontalScrollState.value, + verticalScroll = pageVerticalScrollState.value, + paddingPx = with(density) { 24.dp.toPx() }, + pageGapPx = with(density) { spreadPageGap.toPx() } + ) + } + val spreadZoomAnchorPageRootOffset = spreadZoomPreview + ?.pageIndex + ?.let { spreadLayoutPrediction?.pageRootOffsets?.get(it) ?: paginatedZoomPageRoot(it) } + val spreadZoomScrollBounds = spreadZoomPreview?.let { + DesktopPdfZoomScrollBounds( + currentHorizontalScroll = pageHorizontalScrollState.value, + maxHorizontalScroll = spreadLayoutPrediction?.maxHorizontalScroll + ?: pageHorizontalScrollState.maxValue, + currentVerticalScroll = pageVerticalScrollState.value, + maxVerticalScroll = spreadLayoutPrediction?.maxVerticalScroll + ?: pageVerticalScrollState.maxValue + ) + } paginatedVisiblePageIndices.forEach { spreadPageIndex -> DesktopVerticalPdfPage( document = document, @@ -2680,13 +3321,13 @@ internal fun PdfReaderScreen( richTextController = richTextController, isRichTextMode = isRichTextMode, readerAiFeaturesAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, - cloudTtsAvailable = aiByokSettings.sanitized().isCloudTtsAvailable, + cloudTtsAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, externalLookupAvailable = featurePolicy.externalLookup, themeStyle = pdfThemeStyle, shouldRender = true, - zoomPreview = pdfZoomPreview?.takeIf { - it.displayMode == PdfDisplayMode.PAGINATION - }, + zoomPreview = spreadZoomPreview, + zoomPreviewAnchorPageRootOffset = spreadZoomAnchorPageRootOffset, + zoomPreviewScrollBounds = spreadZoomScrollBounds, zoomViewportRootOffset = pdfZoomViewportRootOffset, showPageNumberOverlay = pdfReaderSettings.pdfPageNumberOverlayVisible, onSelectPage = { @@ -2719,8 +3360,21 @@ internal fun PdfReaderScreen( pageVerticalScrollState.scrollBy(-delta.y) } }, + onPageSizeChanged = { page, size -> + paginatedPageCanvasSizes[page] = size + }, onPagePositioned = { page, offset -> - if (page == paginatedVisiblePageIndices.firstOrNull()) { + paginatedPageRootOffsets[page] = offset + if (page == paginatedSpreadPageIndices.firstOrNull()) { + if (offset != paginatedPageRootOffset) { + logPdfZoomSettle { + "page_layout seq=$pdfZoomSettleSequence mode=spread page=${page + 1} " + + "prevRoot=${paginatedPageRootOffset.formatLogOffset()} " + + "nextRoot=${offset.formatLogOffset()} scale=${scale.formatLogFloat()} " + + "preview=${spreadZoomPreview != null} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value}" + } + } paginatedPageRootOffset = offset } } @@ -2737,9 +3391,18 @@ internal fun PdfReaderScreen( Box( modifier = Modifier .fillMaxSize() - .background(pdfThemeStyle.viewerBackgroundColor, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .background(pdfViewportBackground, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .onSizeChanged { size -> pdfZoomViewportSize = size } .onGloballyPositioned { coordinates -> - pdfZoomViewportRootOffset = coordinates.positionInRoot() + val rootOffset = coordinates.positionInRoot() + if (rootOffset != pdfZoomViewportRootOffset) { + logPdfZoomSettle { + "viewport_layout seq=$pdfZoomSettleSequence mode=pagination " + + "prev=${pdfZoomViewportRootOffset.formatLogOffset()} next=${rootOffset.formatLogOffset()} " + + "scale=${scale.formatLogFloat()} preview=${pdfZoomPreview != null}" + } + } + pdfZoomViewportRootOffset = rootOffset } .desktopPdfZoomGestures( currentZoom = scale, @@ -2751,17 +3414,48 @@ internal fun PdfReaderScreen( .padding(24.dp), contentAlignment = Alignment.TopCenter ) { - val currentPageRender = renderedPage.takeIf { renderedPageIndex == pageIndex } + val paginatedPageDisplay = renderedPageIndex + ?.takeIf { displayPageIndex -> + renderedPage != null && desktopPdfRenderBelongsToPage(displayPageIndex, pageIndex) + } + ?.let { displayPageIndex -> + renderedPage?.let { render -> + DesktopPdfPaginatedPageDisplay( + pageIndex = displayPageIndex, + render = render + ) + } + } when { - currentPageRender != null -> { + renderError != null && paginatedPageDisplay?.pageIndex != pageIndex -> Text( + renderError ?: readerString("desktop_failed_render_page", "Failed to render page."), + color = MaterialTheme.colorScheme.error + ) + paginatedPageDisplay != null -> { + Crossfade( + targetState = paginatedPageDisplay.pageIndex, + animationSpec = tween(DesktopPdfPaginationPageTurnAnimationMillis), + label = "DesktopPdfPaginatedPage" + ) { displayPageIndex -> + val displayPageIsCurrent = displayPageIndex == currentPdfPageIndex + val pageIndex = displayPageIndex + val currentPageRender = if (displayPageIndex == paginatedPageDisplay.pageIndex) { + paginatedPageDisplay.render + } else { + paginatedRenderCache[displayPageIndex]?.render ?: paginatedPageDisplay.render + } val pageSize = document.pageSizes.getOrNull(pageIndex) if (pageSize == null) { Text(readerString("desktop_failed_render_page", "Failed to render page."), color = MaterialTheme.colorScheme.error) - return@Box + return@Crossfade } val pageDisplayScale = zoomSpec.clamp(scale) val pageWidthDp = with(density) { (pageSize.width * pageDisplayScale).toDp() } val pageHeightDp = with(density) { (pageSize.height * pageDisplayScale).toDp() } + val predictedPageCanvasSize = IntSize( + width = (pageSize.width * pageDisplayScale).roundToInt().coerceAtLeast(1), + height = (pageSize.height * pageDisplayScale).roundToInt().coerceAtLeast(1) + ) val pageRenderScale = currentPageRender.width / pageSize.width val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) { annotations @@ -2843,14 +3537,41 @@ internal fun PdfReaderScreen( it.displayMode == PdfDisplayMode.PAGINATION && it.pageIndex == pageIndex } + val pageLayoutPrediction = pageZoomPreview?.let { + desktopPdfSinglePageLayoutPrediction( + viewportRootOffset = pdfZoomViewportRootOffset, + viewportSize = pdfZoomViewportSize, + pageCanvasSize = predictedPageCanvasSize, + horizontalScroll = pageHorizontalScrollState.value, + verticalScroll = pageVerticalScrollState.value, + paddingPx = with(density) { 24.dp.toPx() } + ) + } Box( modifier = Modifier .size(pageWidthDp, pageHeightDp) .onGloballyPositioned { coordinates -> - paginatedPageRootOffset = coordinates.positionInRoot() + val rootOffset = coordinates.positionInRoot() + if (rootOffset != paginatedPageRootOffset) { + logPdfZoomSettle { + "page_layout seq=$pdfZoomSettleSequence mode=pagination page=${pageIndex + 1} " + + "prevRoot=${paginatedPageRootOffset.formatLogOffset()} " + + "nextRoot=${rootOffset.formatLogOffset()} scale=${scale.formatLogFloat()} " + + "preview=${pageZoomPreview != null} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} canvas=${pageCanvasSize.formatLogSize()}" + } + } + paginatedPageRootOffset = rootOffset + paginatedPageRootOffsets[pageIndex] = rootOffset } .onSizeChanged { size -> if (pageCanvasSize != size) { + logPdfZoomSettle { + "page_size seq=$pdfZoomSettleSequence mode=pagination page=${pageIndex + 1} " + + "prev=${pageCanvasSize.formatLogSize()} next=${size.formatLogSize()} " + + "scale=${scale.formatLogFloat()} preview=${pageZoomPreview != null} " + + "bitmap=${currentPageRender.width}x${currentPageRender.height}" + } logPdfSelection( "layout page=${pageIndex + 1} " + "canvas=${size.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " + @@ -2859,22 +3580,51 @@ internal fun PdfReaderScreen( ) } pageCanvasSize = size + paginatedPageCanvasSizes[pageIndex] = size } .desktopPdfZoomPreviewLayer( preview = pageZoomPreview, currentZoom = scale, viewportRootOffset = pdfZoomViewportRootOffset, pageRootOffset = paginatedPageRootOffset, - pageCanvasSize = pageCanvasSize + pageCanvasSize = pageCanvasSize, + commitPageRootOffset = pageLayoutPrediction?.rootOffset, + scrollBounds = pageZoomPreview?.let { + DesktopPdfZoomScrollBounds( + currentHorizontalScroll = pageHorizontalScrollState.value, + maxHorizontalScroll = pageLayoutPrediction?.maxHorizontalScroll + ?: pageHorizontalScrollState.maxValue, + currentVerticalScroll = pageVerticalScrollState.value, + maxVerticalScroll = pageLayoutPrediction?.maxVerticalScroll + ?: pageVerticalScrollState.maxValue + ) + } ) .background(pdfThemeStyle.pageBackgroundColor, RoundedCornerShape(2.dp)) - .pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, selectedTool, isRichTextMode) { - if (isRichTextMode) return@pointerInput + .pointerInput( + pageIndex, + pageCanvasSize, + isTextSelectionMode, + selectedTool, + isRichTextMode, + displayPageIsCurrent + ) { + if (!displayPageIsCurrent || isRichTextMode) return@pointerInput awaitPointerEventScope { while (true) { val event = awaitPointerEvent() val point = event.changes.firstOrNull()?.position ?: continue if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) { + if (isTextSelectionMode) { + logPdfChromeTap { + "page_press source=paginated_inline_page page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "consumedBefore=${event.changes.any { it.isConsumed }} " + + "selectionActive=${currentTextSelection != null} " + + "selectionMenuOpen=${selectionMenuOffset != null} " + + "selectedTool=$selectedTool richText=$isRichTextMode" + } + } val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) { currentPdfAnnotations.asReversed().firstOrNull { it.isDesktopTextSelectionHighlight && @@ -2885,6 +3635,10 @@ internal fun PdfReaderScreen( null } if (highlightHit != null) { + logPdfChromeTap { + "page_press_consume source=paginated_inline_page page=${pageIndex + 1} " + + "reason=text_selection_highlight annotation=${highlightHit.id}" + } selectAnnotation(highlightHit) clearPdfInteractionState() event.changes.forEach { it.consume() } @@ -2893,6 +3647,10 @@ internal fun PdfReaderScreen( if (selectedTool != PdfInkTool.TEXT) { val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize) if (linkTarget != null) { + logPdfChromeTap { + "page_press_consume source=paginated_inline_page page=${pageIndex + 1} " + + "reason=link target=${linkTarget.formatLogTarget()}" + } logPdfLink( "tap_hit mode=page page=${pageIndex + 1} " + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + @@ -2907,6 +3665,10 @@ internal fun PdfReaderScreen( it.sharedPdfEmbeddedHitTest(point, pageCanvasSize) } if (embeddedHit != null) { + logPdfChromeTap { + "page_press_consume source=paginated_inline_page page=${pageIndex + 1} " + + "reason=embedded_annotation annotation=${embeddedHit.id}" + } selectEmbeddedAnnotation(embeddedHit) clearPdfInteractionState() event.changes.forEach { it.consume() } @@ -2914,10 +3676,19 @@ internal fun PdfReaderScreen( currentTextSelection != null && selectionMenuOffset == null ) { + logPdfChromeTap { + "page_press_passthrough source=paginated_inline_page page=${pageIndex + 1} " + + "action=clear_selection consumed=false" + } selectionMenuOffset = null textSelection = null selectionStartHit = null selectionEndHit = null + } else if (isTextSelectionMode) { + logPdfChromeTap { + "page_press_passthrough source=paginated_inline_page page=${pageIndex + 1} " + + "action=none consumed=false" + } } } else if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) { val selection = currentTextSelection @@ -2935,32 +3706,50 @@ internal fun PdfReaderScreen( } } } - .pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, isRichTextMode) { - if (isRichTextMode || !isTextSelectionMode) return@pointerInput - detectTapGestures( - onLongPress = { point -> - val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize) - if (selection != null) { - selectionStartIndex = null - selectionEndIndex = null - selectionStartHit = null - selectionEndHit = null - activeSelectionHandle = null - textSelection = selection - selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point) - logPdfSelection( - "long_press page=${pageIndex + 1} " + - "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + - "range=${selection.startIndex}..${selection.endIndex} " + - "chars=${selection.text.length} " + - "text=\"${selection.text.logPreview()}\"" - ) - } + .pointerInput( + pageIndex, + pageCanvasSize, + isTextSelectionMode, + isRichTextMode, + displayPageIsCurrent + ) { + if (!displayPageIsCurrent || isRichTextMode || !isTextSelectionMode) return@pointerInput + detectDesktopPdfTextSelectionLongPress( + source = "paginated_inline_page", + pageIndex = pageIndex + ) { point -> + val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize) + logPdfChromeTap { + "long_press_selection source=paginated_inline_page page=${pageIndex + 1} " + + "selectionFound=${selection != null} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()}" } - ) + if (selection != null) { + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + activeSelectionHandle = null + textSelection = selection + selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point) + logPdfSelection( + "long_press page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "chars=${selection.text.length} " + + "text=\"${selection.text.logPreview()}\"" + ) + } + } } - .pointerInput(pageIndex, selectedTool, isTextSelectionMode, isRichTextMode) { - if (isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) return@pointerInput + .pointerInput( + pageIndex, + selectedTool, + isTextSelectionMode, + isRichTextMode, + displayPageIsCurrent + ) { + if (!displayPageIsCurrent || isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) return@pointerInput awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture @@ -3003,10 +3792,11 @@ internal fun PdfReaderScreen( textStyleConfig, activeTextDraft?.id, isRichTextMode, + displayPageIsCurrent, pageCanvasSize, currentPageRender.width, currentPageRender.height ) { - if (isRichTextMode) return@pointerInput + if (!displayPageIsCurrent || isRichTextMode) return@pointerInput if (isTextSelectionMode) { var latestSelectionDragPoint: Offset? = null var lastSelectionPreviewAt = 0L @@ -3344,6 +4134,10 @@ internal fun PdfReaderScreen( .matchParentSize() .pointerInput(pageIndex, selectionMenuOffset) { detectTapGestures { + logPdfChromeTap { + "selection_menu_scrim_tap source=paginated_inline_page page=${pageIndex + 1} " + + "consumedByScrim=true" + } selectionMenuOffset = null textSelection = null selectionStartHit = null @@ -3381,11 +4175,12 @@ internal fun PdfReaderScreen( clearSelection() }, showDefine = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, - showSpeak = aiByokSettings.sanitized().isCloudTtsAvailable, + showSpeak = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, showSearch = featurePolicy.externalLookup, onClear = ::clearSelection ) } + } } isRendering -> CircularProgressIndicator(modifier = Modifier.padding(48.dp)) renderError != null -> Text( @@ -3442,23 +4237,23 @@ internal fun PdfReaderScreen( selectedTextHighlight != null -> { DesktopReaderBottomSheet( title = selectedTextHighlight.desktopSheetTitle(), - onDismiss = { dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) } + onDismiss = ::dismissSelectedTextHighlightSheet ) { DesktopPdfAnnotationEditor( annotation = selectedTextHighlight, onUpdate = ::updateAnnotation, - onDelete = { deleteAnnotation(selectedTextHighlight.id) }, - onClose = { dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) }, + onDelete = { deleteSelectedTextHighlight(selectedTextHighlight) }, + onClose = ::dismissSelectedTextHighlightSheet, onCopy = { clipboardManager.setText(AnnotatedString(selectedTextHighlight.text)) - dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + dismissSelectedTextHighlightSheet() }, showSearch = featurePolicy.externalLookup, highlighterPalette = pdfHighlighterColors, onHighlighterPaletteChange = ::updatePdfHighlighterPalette, onSearch = { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, selectedTextHighlight.text) - dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + dismissSelectedTextHighlightSheet() } ) } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfReflow.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflow.kt similarity index 94% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfReflow.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflow.kt index 82d464e..2e57abc 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfReflow.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflow.kt @@ -1,8 +1,8 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.pdf.SharedPdfReflowHtml +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowHtml import java.io.File private const val DesktopPdfReflowSuffix = "_reflow" diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbing.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbing.kt new file mode 100644 index 0000000..f77e5fa --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbing.kt @@ -0,0 +1,28 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.pdf.PdfSpreadLayout +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import kotlin.math.roundToInt + +internal fun desktopPdfPageScrubTarget( + value: Float, + pageCount: Int, + displayMode: PdfDisplayMode, + settings: ReaderSettings +): Int { + val clampedPage = value.roundToInt().coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + return if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.normalizePageIndex(clampedPage, pageCount, settings) + } else { + clampedPage + } +} + +internal fun desktopPdfPageScrubCommitTarget( + previewPage: Int?, + currentPage: Int, + pageCount: Int +): Int { + return (previewPage ?: currentPage).coerceIn(0, (pageCount - 1).coerceAtLeast(0)) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSelectionUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSelectionUi.kt similarity index 85% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSelectionUi.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSelectionUi.kt index 79ebe80..f1faa3f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSelectionUi.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSelectionUi.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas @@ -52,15 +52,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.pdf.PdfPageBounds -import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors -import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette -import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog -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.readerString -import com.aryan.reader.shared.ui.sharedSelectionMenuPlacement +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.shared.ui.SharedHsvColorPickerDialog +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.readerString +import org.dueattendant149.bookreader.shared.ui.sharedSelectionMenuPlacement import kotlin.math.roundToInt internal data class DesktopPdfTextSelection( @@ -292,11 +291,15 @@ internal fun PdfSelectionMenu( val anchor = menuOffset ?: return val selectionBounds = selection.canvasBounds(canvasSize) val paletteColors = remember(highlighterPalette) { - SharedPdfAndroidHighlightColors.palette + SharedPdfHighlighterPalette(highlighterPalette).sanitized().colors } + val density = LocalDensity.current var editingHighlighterSlot by remember(selection.startIndex, selection.endIndex, paletteColors) { mutableStateOf(null) } + var editingHighlighterDraftColors by remember(selection.startIndex, selection.endIndex, paletteColors) { + mutableStateOf>(emptyList()) + } val actions = buildList { add(PdfSelectionMenuAction(readerString("action_copy", "Copy"), DesktopPdfSelectionMenuIcons.Copy, onCopy)) if (showDefine) add(PdfSelectionMenuAction(readerString("action_define", "Define"), DesktopPdfSelectionMenuIcons.Dictionary, onDefine)) @@ -304,13 +307,38 @@ internal fun PdfSelectionMenu( if (showSearch) add(PdfSelectionMenuAction(readerString("action_search", "Search"), DesktopPdfSelectionMenuIcons.Search, onSearch)) add(PdfSelectionMenuAction(readerString("action_clear", "Clear"), Icons.Default.Close, onClear, isDestructive = true)) } - val estimatedHeight = PdfSelectionMenuPaletteHeightPx + - (((actions.size + 2) / 3).coerceAtLeast(1) * PdfSelectionMenuActionRowHeightPx) + + fun highlighterDraftColors(): List { + return editingHighlighterDraftColors.ifEmpty { paletteColors } + } + + fun updateHighlighterDraft(slotIndex: Int, color: Color): List { + val nextColors = highlighterDraftColors().toMutableList() + if (slotIndex in nextColors.indices) { + nextColors[slotIndex] = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() + editingHighlighterDraftColors = nextColors + } + return nextColors + } + + fun openHighlighterEditor(slotIndex: Int) { + if (editingHighlighterSlot == null) { + editingHighlighterDraftColors = paletteColors + } + editingHighlighterSlot = slotIndex + } + + val actionRowCount = ((actions.size + 2) / 3).coerceAtLeast(1) + val popupWidthPx = with(density) { PdfSelectionMenuWidth.toPx() } + val estimatedHeightPx = with(density) { + PdfSelectionMenuPaletteHeight.toPx() + + (actionRowCount * PdfSelectionMenuActionRowHeight.toPx()) + } val placement = sharedSelectionMenuPlacement( viewport = SharedSelectionMenuViewport(canvasSize.width, canvasSize.height), popup = SharedSelectionMenuSize( - width = PdfSelectionMenuWidthPx.roundToInt(), - height = estimatedHeight.roundToInt() + width = popupWidthPx.roundToInt(), + height = estimatedHeightPx.roundToInt() ), selection = if (selectionBounds != null) { SharedSelectionMenuRect( @@ -327,8 +355,8 @@ internal fun PdfSelectionMenu( bottom = anchor.y ) }, - marginPx = PdfSelectionMenuMarginPx, - gapPx = PdfSelectionMenuAnchorGapPx + marginPx = with(density) { PdfSelectionMenuMargin.toPx() }, + gapPx = with(density) { PdfSelectionMenuAnchorGap.toPx() } ) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { Surface( @@ -385,7 +413,7 @@ internal fun PdfSelectionMenu( ) ) .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(16.dp)) - .clickable { editingHighlighterSlot = 0 } + .clickable { openHighlighterEditor(0) } ) } HorizontalDivider() @@ -436,20 +464,27 @@ internal fun PdfSelectionMenu( } } editingHighlighterSlot?.let { requestedSlot -> - val slot = requestedSlot.coerceIn(0, paletteColors.lastIndex) - val initialColor = Color(paletteColors[slot]).copy(alpha = 1f) + val draftColors = highlighterDraftColors() + val safeDraftColors = draftColors.ifEmpty { SharedPdfHighlighterPalette.defaultColors } + val slot = requestedSlot.coerceIn(0, safeDraftColors.lastIndex) + val initialColor = remember(slot) { Color(safeDraftColors[slot]).copy(alpha = 1f) } SharedHsvColorPickerDialog( initialColor = initialColor, title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1), onDismiss = { editingHighlighterSlot = null }, onSave = { color -> + val nextColors = updateHighlighterDraft(slot, color) onHighlighterPaletteChange( - SharedPdfHighlighterPalette(paletteColors).withColorAt( - slotIndex = slot, - colorArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() - ) + SharedPdfHighlighterPalette(nextColors).sanitized() ) editingHighlighterSlot = null + }, + resetColor = Color(SharedPdfHighlighterPalette.defaultColors.getOrElse(slot) { + SharedPdfHighlighterPalette.defaultColors.first() + }).copy(alpha = 1f), + stateKey = slot, + onLiveColorChange = { color -> + updateHighlighterDraft(slot, color) } ) { liveColor -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -458,7 +493,7 @@ internal fun PdfSelectionMenu( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically ) { - paletteColors.forEachIndexed { index, argb -> + highlighterDraftColors().forEachIndexed { index, argb -> val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) Box( modifier = Modifier @@ -467,10 +502,14 @@ internal fun PdfSelectionMenu( .background(color) .border( width = if (index == slot) 3.dp else 1.dp, - color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), + color = if (index == slot) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) + }, shape = RoundedCornerShape(21.dp) ) - .clickable { editingHighlighterSlot = index }, + .clickable { openHighlighterEditor(index) }, contentAlignment = Alignment.Center ) { Text( @@ -494,11 +533,11 @@ private data class PdfSelectionMenuAction( val isDestructive: Boolean = false ) -private const val PdfSelectionMenuWidthPx = 220f -private const val PdfSelectionMenuPaletteHeightPx = 54f -private const val PdfSelectionMenuActionRowHeightPx = 66f -private const val PdfSelectionMenuAnchorGapPx = 16f -private const val PdfSelectionMenuMarginPx = 6f +private val PdfSelectionMenuWidth = 220.dp +private val PdfSelectionMenuPaletteHeight = 54.dp +private val PdfSelectionMenuActionRowHeight = 66.dp +private val PdfSelectionMenuAnchorGap = 16.dp +private val PdfSelectionMenuMargin = 6.dp private const val DesktopPdfSelectionHandleTouchWidthPx = 44f private const val DesktopPdfSelectionHandleTouchTopPx = 8f private const val DesktopPdfSelectionHandleTouchBottomPx = 40f diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecarEffects.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarEffects.kt similarity index 66% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecarEffects.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarEffects.kt index 0f23096..a0976ce 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecarEffects.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarEffects.kt @@ -1,16 +1,16 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import com.aryan.reader.shared.pdf.SharedPdfAnnotation -import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer -import com.aryan.reader.shared.pdf.SharedPdfBookmark -import com.aryan.reader.shared.pdf.SharedPdfBookmarkSerializer -import com.aryan.reader.shared.pdf.SharedPdfRichDocument -import com.aryan.reader.shared.pdf.SharedPdfRichTextController -import com.aryan.reader.shared.pdf.SharedPdfRichTextLog -import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer -import com.aryan.reader.shared.pdf.SharedPdfSearchResult +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmark +import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmarkSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichDocument +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextController +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextLog +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive @@ -36,18 +36,40 @@ internal fun DesktopPdfAnnotationSidecarEffect( emptyList() } onAnnotationsLoaded(loadedAnnotations) + logDesktopCloudAnnotations { + "desktop.local.load_annotations document=$documentHandleId count=${loadedAnnotations.size} " + + "exists=${annotationFile.exists()} bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFileForCloudLog()}" + } onAnnotationsLoadedChange(true) } LaunchedEffect(documentHandleId, annotations, annotationsLoaded) { if (!annotationsLoaded) return@LaunchedEffect - withContext(Dispatchers.IO) { + val changed = withContext(Dispatchers.IO) { runCatching { - annotationFile.parentFile?.mkdirs() - annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) - } + val nextJson = SharedPdfAnnotationSerializer.encode(annotations) + when { + annotations.isEmpty() && annotationFile.isFile -> { + annotationFile.delete() + } + annotations.isEmpty() -> false + annotationFile.isFile && annotationFile.readText() == nextJson -> false + else -> { + annotationFile.parentFile?.mkdirs() + annotationFile.writeText(nextJson) + true + } + } + }.getOrDefault(false) + } + if (changed) { + logDesktopCloudAnnotations { + "desktop.local.save_annotations document=$documentHandleId count=${annotations.size} " + + "bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFileForCloudLog()} " + + "path=${annotationFile.absolutePath.logPreview(140)}" + } + onLocalSidecarsChanged() } - onLocalSidecarsChanged() } } @@ -76,13 +98,23 @@ internal fun DesktopPdfBookmarkSidecarEffect( LaunchedEffect(documentHandleId, bookmarks, bookmarksLoaded) { if (!bookmarksLoaded) return@LaunchedEffect - withContext(Dispatchers.IO) { + val changed = withContext(Dispatchers.IO) { runCatching { - bookmarkFile.parentFile?.mkdirs() - bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks)) - } + val nextJson = SharedPdfBookmarkSerializer.encode(bookmarks) + when { + bookmarks.isEmpty() && !bookmarkFile.isFile -> false + bookmarkFile.isFile && bookmarkFile.readText() == nextJson -> false + else -> { + bookmarkFile.parentFile?.mkdirs() + bookmarkFile.writeText(nextJson) + true + } + } + }.getOrDefault(false) + } + if (changed) { + onLocalSidecarsChanged() } - onLocalSidecarsChanged() } } @@ -177,3 +209,7 @@ internal fun DesktopPdfSearchResultsEffect( onSearchResultsChange(results) } } + +private fun File.lastModifiedIfFileForCloudLog(): Long { + return if (isFile) lastModified() else 0L +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecars.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecars.kt new file mode 100644 index 0000000..433796c --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecars.kt @@ -0,0 +1,132 @@ +package org.dueattendant149.bookreader.desktop + +import java.io.File +import java.security.MessageDigest +import java.util.Base64 + +private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t2" + +internal fun desktopPdfAnnotationFile(documentPath: String): File { + val safeName = desktopPdfDocumentKey(documentPath) + val legacyName = desktopPdfLegacyDocumentKey(documentPath) + return sidecarFileWithLegacyMigration( + file = File(desktopUserDataRoot(), "annotations/pdf_$safeName.json"), + legacyFile = File(desktopUserDataRoot(), "annotations/pdf_$legacyName.json") + ) +} + +internal fun desktopPdfAnnotationDeletionFile(documentPath: String): File { + val safeName = desktopPdfDocumentKey(documentPath) + val legacyName = desktopPdfLegacyDocumentKey(documentPath) + return sidecarFileWithLegacyMigration( + file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_deleted_annotations.json"), + legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_deleted_annotations.json") + ) +} + +internal fun desktopPdfBookmarkFile(documentPath: String): File { + val safeName = desktopPdfDocumentKey(documentPath) + val legacyName = desktopPdfLegacyDocumentKey(documentPath) + return sidecarFileWithLegacyMigration( + file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json"), + legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_bookmarks.json") + ) +} + +internal fun desktopPdfRichTextFile(documentPath: String): File { + val safeName = desktopPdfDocumentKey(documentPath) + val legacyName = desktopPdfLegacyDocumentKey(documentPath) + return sidecarFileWithLegacyMigration( + file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json"), + legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_rich_text.json") + ) +} + +internal fun desktopPdfSearchIndexFile(documentPath: String): File { + val safeName = desktopPdfDocumentKey(documentPath) + return File(desktopUserCacheRoot(), "search/pdf_${safeName}_text_index.tsv") +} + +internal fun restoreDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File): Int { + val sourceFile = File(document.path) + val lines = runCatching { indexFile.readLines(Charsets.UTF_8) }.getOrNull() ?: return document.indexedSearchTextPageCount() + if (lines.firstOrNull() != DesktopPdfSearchIndexHeader) return 0 + val metadata = lines + .asSequence() + .drop(1) + .takeWhile { !it.startsWith("page\t") } + .mapNotNull { line -> + val parts = line.split('\t', limit = 2) + if (parts.size == 2) parts[0] to parts[1] else null + } + .toMap() + val isFresh = metadata["pathKey"] == desktopPdfDocumentKey(document.path) && + metadata["fileSize"] == sourceFile.length().toString() && + metadata["lastModified"] == sourceFile.lastModified().toString() && + metadata["pageCount"] == document.pageCount.toString() + if (!isFresh) return 0 + + val decoder = Base64.getDecoder() + lines.asSequence() + .filter { it.startsWith("page\t") } + .forEach { line -> + val parts = line.split('\t', limit = 3) + val pageIndex = parts.getOrNull(1)?.toIntOrNull() ?: return@forEach + val text = runCatching { + String(decoder.decode(parts.getOrNull(2).orEmpty()), Charsets.UTF_8) + }.getOrDefault("") + document.cacheSearchTextPage(pageIndex, text) + } + return document.indexedSearchTextPageCount() +} + +internal fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File) { + val sourceFile = File(document.path) + val pages = document.indexedSearchPages() + if (pages.isEmpty()) return + val encoder = Base64.getEncoder() + val payload = buildString { + appendLine(DesktopPdfSearchIndexHeader) + appendLine("pathKey\t${desktopPdfDocumentKey(document.path)}") + appendLine("fileSize\t${sourceFile.length()}") + appendLine("lastModified\t${sourceFile.lastModified()}") + appendLine("pageCount\t${document.pageCount}") + pages.forEach { page -> + append("page\t") + append(page.pageIndex) + append('\t') + appendLine(encoder.encodeToString(page.text.toByteArray(Charsets.UTF_8))) + } + } + runCatching { + indexFile.parentFile?.mkdirs() + indexFile.writeText(payload, Charsets.UTF_8) + } +} + +internal fun desktopPdfDocumentKey(documentPath: String): String { + val normalizedPath = runCatching { File(documentPath).canonicalPath } + .getOrElse { documentPath.trim() } + return sha256Hex(normalizedPath).take(32) +} + +private fun desktopPdfLegacyDocumentKey(documentPath: String): String { + return documentPath.hashCode().toString().replace("-", "n") +} + +private fun sidecarFileWithLegacyMigration(file: File, legacyFile: File): File { + if (!file.exists() && legacyFile.isFile && legacyFile != file) { + runCatching { + file.parentFile?.mkdirs() + if (!legacyFile.renameTo(file)) { + legacyFile.copyTo(file, overwrite = false) + } + } + } + return file +} + +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/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSyncSidecars.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSyncSidecars.kt new file mode 100644 index 0000000..d602e08 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSyncSidecars.kt @@ -0,0 +1,156 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec +import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmark +import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmarkSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +private val desktopPdfSyncJson = Json { + ignoreUnknownKeys = true + prettyPrint = true + encodeDefaults = true +} + +internal fun desktopPdfAnnotationElementForSync(rawJson: String): JsonElement? { + val annotations = SharedPdfAnnotationSerializer.decode(rawJson) + if (annotations.isEmpty()) return null + return SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations) +} + +internal fun desktopPdfRichTextElementForSync(rawJson: String): JsonElement? { + val element = runCatching { desktopPdfSyncJson.parseToJsonElement(rawJson) }.getOrNull() + ?: return null + val document = SharedPdfRichTextSerializer.decodeElement(element) + if (document.text.isEmpty() && document.spans.isEmpty()) return null + return SharedPdfRichTextSerializer.encodeElement(document) +} + +internal fun desktopPdfBookmarksMetadataJson(book: BookItem): String? { + if (book.type != FileType.PDF) return null + val path = book.path?.takeIf { it.isNotBlank() } ?: return null + val bookmarkFile = desktopPdfBookmarkFile(path).takeIf { it.isFile } ?: return null + return desktopPdfBookmarksMetadataJson( + bookmarks = SharedPdfBookmarkSerializer.decode(bookmarkFile.readText()), + lastPageIndex = book.lastPageIndex + ) +} + +internal fun desktopPdfBookmarksMetadataJson( + bookmarks: List, + lastPageIndex: Int? +): String { + val totalPages = maxOf( + (lastPageIndex ?: 0) + 1, + (bookmarks.maxOfOrNull { it.pageIndex } ?: 0) + 1 + ).coerceAtLeast(1) + return desktopPdfSyncJson.encodeToString( + JsonElement.serializer(), + JsonArray( + bookmarks.map { bookmark -> + JsonObject( + mapOf( + "pageIndex" to JsonPrimitive(bookmark.pageIndex.coerceAtLeast(0)), + "title" to JsonPrimitive(bookmark.label.ifBlank { "Page ${bookmark.pageIndex + 1}" }), + "totalPages" to JsonPrimitive(totalPages) + ) + ) + } + ) + ) +} + +internal fun desktopPdfBookmarkMetadataTimestamp(book: BookItem): Long { + if (book.type != FileType.PDF) return 0L + val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L + return desktopPdfBookmarkFile(path).lastModifiedIfFile() +} + +internal fun importDesktopPdfBookmarksMetadata( + book: BookItem, + bookmarksJson: String?, + timestamp: Long +): Boolean { + if (book.type != FileType.PDF) return false + val path = book.path?.takeIf { it.isNotBlank() } ?: return false + val rawJson = bookmarksJson?.takeIf { it.isNotBlank() } ?: return false + val bookmarks = desktopPdfBookmarksFromMetadataJson(rawJson) + val bookmarkFile = desktopPdfBookmarkFile(path) + val localTimestamp = bookmarkFile.lastModifiedIfFile() + if (timestamp <= localTimestamp + 1000L) return false + + if (bookmarks.isEmpty()) { + if (bookmarkFile.isFile) bookmarkFile.delete() + return true + } + + bookmarkFile.parentFile?.mkdirs() + bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks)) + bookmarkFile.setLastModified(timestamp) + return true +} + +internal fun desktopPdfBookmarksFromMetadataJson(rawJson: String): List { + val root = runCatching { desktopPdfSyncJson.parseToJsonElement(rawJson) }.getOrNull() + ?: return emptyList() + + root.jsonArrayOrNull()?.let { androidBookmarks -> + return androidBookmarks.mapNotNull { element -> + val obj = element.jsonObjectOrNull() ?: return@mapNotNull null + val pageIndex = obj.int("pageIndex") ?: return@mapNotNull null + SharedPdfBookmark( + pageIndex = pageIndex.coerceAtLeast(0), + label = obj.string("title") ?: obj.string("label") ?: "Page ${pageIndex + 1}", + createdAt = obj.longString("createdAt") ?: 0L + ) + } + } + + return SharedPdfBookmarkSerializer.decode(rawJson) +} + +private fun File.lastModifiedIfFile(): Long { + return if (isFile()) lastModified() else 0L +} + +private fun JsonElement.jsonArrayOrNull(): JsonArray? { + if (this is JsonNull) return null + return runCatching { jsonArray }.getOrNull() +} + +private fun JsonElement.jsonObjectOrNull(): JsonObject? { + if (this is JsonNull) return null + return runCatching { jsonObject }.getOrNull() +} + +private fun JsonObject.string(name: String): String? { + return this[name] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } +} + +private fun JsonObject.int(name: String): Int? { + return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull +} + +private fun JsonObject.longString(name: String): Long? { + return string(name)?.toLongOrNull() + ?: this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toLongOrNull() +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTheme.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTheme.kt new file mode 100644 index 0000000..bd23c2d --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTheme.kt @@ -0,0 +1,56 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderTheme +import org.dueattendant149.bookreader.shared.pdf.pdfVerticalPageGapDp + +internal val DesktopDefaultPdfDisplayMode = PdfDisplayMode.PAGINATION +internal val DesktopDefaultPdfVerticalPageGap = 8.dp +internal val DesktopDefaultPdfSpreadPageGap = 18.dp + +internal fun desktopPdfPageBackgroundColor( + theme: ReaderTheme, + displayMode: PdfDisplayMode +): Color { + return when (theme.id) { + "no_theme", "system" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black + "reverse" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.Black else Color.White + else -> theme.backgroundColor.takeIf { it.isSpecified } + ?: if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black + } +} + +internal fun desktopPdfVerticalViewportBackgroundColor( + pageBackgroundColor: Color, + gapBackgroundColor: Color, + isPageGapVisible: Boolean +): Color { + return if (isPageGapVisible) gapBackgroundColor else pageBackgroundColor +} + +internal fun desktopPdfViewportBackgroundColor( + displayMode: PdfDisplayMode, + pageBackgroundColor: Color, + appBackgroundColor: Color, + isVerticalPageGapVisible: Boolean +): Color { + return when (displayMode) { + PdfDisplayMode.VERTICAL_SCROLL -> desktopPdfVerticalViewportBackgroundColor( + pageBackgroundColor = pageBackgroundColor, + gapBackgroundColor = appBackgroundColor, + isPageGapVisible = isVerticalPageGapVisible + ) + PdfDisplayMode.PAGINATION -> appBackgroundColor + } +} + +internal fun desktopPdfSpreadPageGapDp( + isPageGapVisible: Boolean +): Dp = pdfVerticalPageGapDp( + isPageGapVisible = isPageGapVisible, + defaultGap = DesktopDefaultPdfSpreadPageGap +) diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfZoom.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfZoom.kt new file mode 100644 index 0000000..509c355 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfZoom.kt @@ -0,0 +1,644 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.gestures.calculateCentroid +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isCtrlPressed as isPointerCtrlPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.roundToInt + +private const val DesktopPdfZoomGestureFrameMillis = 16L +private const val DesktopPdfZoomPreviewTolerance = 0.0001f +internal const val DesktopPdfPaginationFastFirstRenderMaxScale = 2.0f + +internal fun desktopPdfScrollZoomFactor(scrollDelta: Float): Float { + if (!scrollDelta.isFinite() || abs(scrollDelta) < 0.01f) return 1f + val normalizedDelta = scrollDelta.coerceIn(-8f, 8f) + return exp((-normalizedDelta * 0.12f).toDouble()).toFloat() +} + +internal fun desktopPdfZoomTarget( + currentZoom: Float, + zoomSpec: PdfZoomSpec, + factor: Float +): Float { + val baseZoom = currentZoom.takeIf { it.isFinite() } ?: zoomSpec.default + val safeFactor = factor.takeIf { it.isFinite() && it > 0f } ?: 1f + return zoomSpec.clamp(baseZoom * safeFactor) +} + +internal fun desktopPdfAnchoredScrollTarget( + currentScroll: Int, + anchor: Float, + oldZoom: Float, + newZoom: Float +): Int { + if ( + !anchor.isFinite() || + !oldZoom.isFinite() || + !newZoom.isFinite() || + oldZoom <= 0f || + newZoom <= 0f + ) { + return currentScroll.coerceAtLeast(0) + } + val zoomRatio = newZoom / oldZoom + return (((currentScroll + anchor) * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) +} + +internal fun desktopPdfAnchoredLazyItemScrollOffset( + itemOffset: Int, + anchor: Float, + oldZoom: Float, + newZoom: Float +): Int { + if ( + !anchor.isFinite() || + !oldZoom.isFinite() || + !newZoom.isFinite() || + oldZoom <= 0f || + newZoom <= 0f + ) { + return (-itemOffset).coerceAtLeast(0) + } + val zoomRatio = newZoom / oldZoom + val offsetWithinItem = anchor - itemOffset + return ((offsetWithinItem * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) +} + +internal fun desktopPdfAnchoredPageScrollDelta( + viewportRootOffset: Offset, + oldPageRootOffset: Offset, + currentPageRootOffset: Offset, + anchor: Offset, + oldZoom: Float, + newZoom: Float +): IntOffset? { + if ( + !anchor.x.isFinite() || + !anchor.y.isFinite() || + !oldZoom.isFinite() || + !newZoom.isFinite() || + oldZoom <= 0f || + newZoom <= 0f + ) { + return null + } + val rootAnchor = viewportRootOffset + anchor + val oldPageLocal = rootAnchor - oldPageRootOffset + val zoomRatio = newZoom / oldZoom + val newPageLocal = Offset(oldPageLocal.x * zoomRatio, oldPageLocal.y * zoomRatio) + val desiredPageRoot = rootAnchor - newPageLocal + val delta = currentPageRootOffset - desiredPageRoot + return IntOffset(delta.x.roundToInt(), delta.y.roundToInt()) +} + +internal fun desktopPdfPaginationFirstRenderScale( + requestedScale: Float, + hasPageRender: Boolean, + isOpeningRender: Boolean = false +): Float { + if (hasPageRender || isOpeningRender || !requestedScale.isFinite() || requestedScale <= 0f) { + return requestedScale + } + return requestedScale.coerceAtMost(DesktopPdfPaginationFastFirstRenderMaxScale) +} + +internal fun desktopPdfRenderBelongsToPage( + renderedPageIndex: Int?, + requestedPageIndex: Int +): Boolean { + return renderedPageIndex == requestedPageIndex +} + +internal fun desktopPdfRenderScaleNeedsUpgrade( + renderedScale: Float?, + requestedScale: Float +): Boolean { + if (renderedScale == null) return true + if (!requestedScale.isFinite() || requestedScale <= 0f) return false + if (!renderedScale.isFinite() || renderedScale <= 0f) return true + return requestedScale - renderedScale > DesktopPdfRenderScaleTolerance +} + +internal fun desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset: Offset, + anchor: Offset?, + visiblePageIndices: List, + pageRootOffsets: Map, + pageSizes: Map, + fallbackPageIndex: Int +): Int { + if (anchor == null || visiblePageIndices.isEmpty()) return fallbackPageIndex + if (!anchor.x.isFinite() || !anchor.y.isFinite()) return fallbackPageIndex + val rootAnchor = viewportRootOffset + anchor + if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return fallbackPageIndex + val candidates = visiblePageIndices.mapNotNull { pageIndex -> + pageRootOffsets[pageIndex]?.let { root -> + pageIndex to root + } + } + if (candidates.isEmpty()) return fallbackPageIndex + candidates.firstOrNull { (pageIndex, root) -> + val size = pageSizes[pageIndex] ?: return@firstOrNull false + val width = size.width.toFloat() + val height = size.height.toFloat() + rootAnchor.x >= root.x && + rootAnchor.x <= root.x + width && + rootAnchor.y >= root.y && + rootAnchor.y <= root.y + height + }?.let { return it.first } + return candidates.minByOrNull { (pageIndex, root) -> + val size = pageSizes[pageIndex] + val dx: Float + val dy: Float + if (size == null) { + dx = rootAnchor.x - root.x + dy = rootAnchor.y - root.y + } else { + val right = root.x + size.width.toFloat() + val bottom = root.y + size.height.toFloat() + dx = when { + rootAnchor.x < root.x -> root.x - rootAnchor.x + rootAnchor.x > right -> rootAnchor.x - right + else -> 0f + } + dy = when { + rootAnchor.y < root.y -> root.y - rootAnchor.y + rootAnchor.y > bottom -> rootAnchor.y - bottom + else -> 0f + } + } + dx * dx + dy * dy + }?.first ?: fallbackPageIndex +} + +internal data class DesktopPdfZoomPreview( + val baseZoom: Float, + val zoom: Float, + val anchor: Offset?, + val displayMode: PdfDisplayMode, + val pageIndex: Int?, + val viewportRootOffset: Offset = Offset.Zero, + val pageRootOffset: Offset? = null, + val commitTargetHorizontalScroll: Int? = null, + val commitTargetVerticalScroll: Int? = null, + val diagnosticSequence: Int = 0 +) + +internal data class DesktopPdfZoomScrollBounds( + val currentHorizontalScroll: Int? = null, + val maxHorizontalScroll: Int? = null, + val currentVerticalScroll: Int? = null, + val maxVerticalScroll: Int? = null +) + +internal interface DesktopPdfLayoutScrollPrediction { + val maxHorizontalScroll: Int + val maxVerticalScroll: Int +} + +internal data class DesktopPdfSinglePageLayoutPrediction( + val rootOffset: Offset, + override val maxHorizontalScroll: Int, + override val maxVerticalScroll: Int +) : DesktopPdfLayoutScrollPrediction + +internal data class DesktopPdfSpreadLayoutPrediction( + val pageRootOffsets: Map, + override val maxHorizontalScroll: Int, + override val maxVerticalScroll: Int +) : DesktopPdfLayoutScrollPrediction + +internal data class DesktopPdfCachedPageRender( + val render: DesktopPdfPageRender, + val scale: Float +) + +internal data class DesktopPdfNavigationZoomSnapshot( + val zoom: Float, + val horizontalScroll: Int, + val verticalScroll: Int +) + +internal fun desktopPdfNavigationZoomSnapshot( + preview: DesktopPdfZoomPreview?, + currentHorizontalScroll: Int, + currentVerticalScroll: Int +): DesktopPdfNavigationZoomSnapshot? { + val activePreview = preview ?: return null + val baseZoom = activePreview.baseZoom.takeIf { it.isFinite() && it > 0f } ?: return null + val targetZoom = activePreview.zoom.takeIf { it.isFinite() && it > 0f } ?: return null + val anchor = activePreview.anchor + return DesktopPdfNavigationZoomSnapshot( + zoom = targetZoom, + horizontalScroll = anchor?.let { + desktopPdfAnchoredScrollTarget(currentHorizontalScroll, it.x, baseZoom, targetZoom) + } ?: currentHorizontalScroll.coerceAtLeast(0), + verticalScroll = anchor?.let { + desktopPdfAnchoredScrollTarget(currentVerticalScroll, it.y, baseZoom, targetZoom) + } ?: currentVerticalScroll.coerceAtLeast(0) + ) +} + +internal fun desktopPdfZoomPreviewMatchesScale( + preview: DesktopPdfZoomPreview, + scale: Float +): Boolean { + return abs(preview.baseZoom - scale) <= DesktopPdfZoomPreviewTolerance || + abs(preview.zoom - scale) <= DesktopPdfZoomPreviewTolerance +} + +internal fun desktopPdfReachableScrollDelta( + currentScroll: Int?, + maxScroll: Int?, + requestedDelta: Int +): Int { + if (currentScroll == null || maxScroll == null) return requestedDelta + val safeMax = maxScroll.coerceAtLeast(0) + val safeCurrent = currentScroll.coerceIn(0, safeMax) + val targetScroll = (safeCurrent + requestedDelta).coerceIn(0, safeMax) + return targetScroll - safeCurrent +} + +internal fun desktopPdfReachableScrollDelta( + requestedDelta: IntOffset, + scrollBounds: DesktopPdfZoomScrollBounds? +): IntOffset { + if (scrollBounds == null) return requestedDelta + return IntOffset( + x = desktopPdfReachableScrollDelta( + currentScroll = scrollBounds.currentHorizontalScroll, + maxScroll = scrollBounds.maxHorizontalScroll, + requestedDelta = requestedDelta.x + ), + y = desktopPdfReachableScrollDelta( + currentScroll = scrollBounds.currentVerticalScroll, + maxScroll = scrollBounds.maxVerticalScroll, + requestedDelta = requestedDelta.y + ) + ) +} + +internal fun desktopPdfZoomScrollBoundsWithCommitTargets( + preview: DesktopPdfZoomPreview?, + currentHorizontalScroll: Int, + maxHorizontalScroll: Int, + currentVerticalScroll: Int? = null, + maxVerticalScroll: Int? = null +): DesktopPdfZoomScrollBounds { + return DesktopPdfZoomScrollBounds( + currentHorizontalScroll = currentHorizontalScroll, + maxHorizontalScroll = maxOf(maxHorizontalScroll, preview?.commitTargetHorizontalScroll ?: 0), + currentVerticalScroll = currentVerticalScroll, + maxVerticalScroll = maxVerticalScroll?.let { + maxOf(it, preview?.commitTargetVerticalScroll ?: 0) + } + ) +} + +internal fun desktopPdfSinglePageLayoutPrediction( + viewportRootOffset: Offset, + viewportSize: IntSize, + pageCanvasSize: IntSize, + horizontalScroll: Int, + verticalScroll: Int, + paddingPx: Float +): DesktopPdfSinglePageLayoutPrediction? { + if (viewportSize.width <= 0 || viewportSize.height <= 0) return null + if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null + if (!viewportRootOffset.x.isFinite() || !viewportRootOffset.y.isFinite()) return null + if (!paddingPx.isFinite() || paddingPx < 0f) return null + val viewportWidth = viewportSize.width.toFloat() + val viewportHeight = viewportSize.height.toFloat() + val pageWidth = pageCanvasSize.width.toFloat() + val contentWidth = (viewportWidth - paddingPx * 2f).coerceAtLeast(0f) + val maxHorizontalScroll = (pageWidth + paddingPx * 2f - viewportWidth) + .roundToInt() + .coerceAtLeast(0) + val maxVerticalScroll = (pageCanvasSize.height.toFloat() + paddingPx * 2f - viewportHeight) + .roundToInt() + .coerceAtLeast(0) + val safeHorizontalScroll = horizontalScroll.coerceIn(0, maxHorizontalScroll) + val safeVerticalScroll = verticalScroll.coerceIn(0, maxVerticalScroll) + val pageX = if (pageWidth <= contentWidth) { + ((viewportWidth - pageWidth) / 2f) - safeHorizontalScroll.toFloat() + } else { + paddingPx - safeHorizontalScroll.toFloat() + } + val pageY = paddingPx - safeVerticalScroll.toFloat() + return DesktopPdfSinglePageLayoutPrediction( + rootOffset = Offset( + x = viewportRootOffset.x + pageX, + y = viewportRootOffset.y + pageY + ), + maxHorizontalScroll = maxHorizontalScroll, + maxVerticalScroll = maxVerticalScroll + ) +} + +internal fun desktopPdfSpreadLayoutPrediction( + viewportRootOffset: Offset, + viewportSize: IntSize, + visiblePageIndices: List, + pageCanvasSizes: Map, + horizontalScroll: Int, + verticalScroll: Int, + paddingPx: Float, + pageGapPx: Float +): DesktopPdfSpreadLayoutPrediction? { + if (viewportSize.width <= 0 || viewportSize.height <= 0) return null + if (visiblePageIndices.isEmpty()) return null + if (!viewportRootOffset.x.isFinite() || !viewportRootOffset.y.isFinite()) return null + if (!paddingPx.isFinite() || paddingPx < 0f) return null + if (!pageGapPx.isFinite() || pageGapPx < 0f) return null + val pageSizes = visiblePageIndices.map { pageIndex -> + val pageSize = pageCanvasSizes[pageIndex] ?: return null + if (pageSize.width <= 0 || pageSize.height <= 0) return null + pageSize + } + val viewportWidth = viewportSize.width.toFloat() + val viewportHeight = viewportSize.height.toFloat() + val rowWidth = pageSizes.sumOf { it.width }.toFloat() + + (pageGapPx * (pageSizes.size - 1).coerceAtLeast(0)) + val rowHeight = pageSizes.maxOf { it.height }.toFloat() + val contentWidth = (viewportWidth - paddingPx * 2f).coerceAtLeast(0f) + val maxHorizontalScroll = (rowWidth + paddingPx * 2f - viewportWidth) + .roundToInt() + .coerceAtLeast(0) + val maxVerticalScroll = (rowHeight + paddingPx * 2f - viewportHeight) + .roundToInt() + .coerceAtLeast(0) + val safeHorizontalScroll = horizontalScroll.coerceIn(0, maxHorizontalScroll) + val safeVerticalScroll = verticalScroll.coerceIn(0, maxVerticalScroll) + val rowX = if (rowWidth <= contentWidth) { + ((viewportWidth - rowWidth) / 2f) - safeHorizontalScroll.toFloat() + } else { + paddingPx - safeHorizontalScroll.toFloat() + } + val rowY = paddingPx - safeVerticalScroll.toFloat() + var pageX = viewportRootOffset.x + rowX + val pageY = viewportRootOffset.y + rowY + val roots = visiblePageIndices.mapIndexed { index, pageIndex -> + val root = Offset(pageX, pageY) + pageX += pageSizes[index].width.toFloat() + pageGapPx + pageIndex to root + }.toMap() + return DesktopPdfSpreadLayoutPrediction( + pageRootOffsets = roots, + maxHorizontalScroll = maxHorizontalScroll, + maxVerticalScroll = maxVerticalScroll + ) +} + +internal fun desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset: Offset, + oldPageRootOffset: Offset?, + currentAnchorPageRootOffset: Offset, + anchor: Offset?, + oldZoom: Float, + newZoom: Float, + currentZoom: Float, + scrollBounds: DesktopPdfZoomScrollBounds? = null +): Offset? { + if (oldPageRootOffset == null || anchor == null) return null + if (abs(currentZoom - newZoom) > DesktopPdfZoomPreviewTolerance) return null + val pageDelta = desktopPdfAnchoredPageScrollDelta( + viewportRootOffset = viewportRootOffset, + oldPageRootOffset = oldPageRootOffset, + currentPageRootOffset = currentAnchorPageRootOffset, + anchor = anchor, + oldZoom = oldZoom, + newZoom = newZoom + ) ?: return null + val reachableDelta = desktopPdfReachableScrollDelta(pageDelta, scrollBounds) + return Offset( + x = if (reachableDelta.x == 0) 0f else -reachableDelta.x.toFloat(), + y = if (reachableDelta.y == 0) 0f else -reachableDelta.y.toFloat() + ) +} + +internal fun desktopPdfZoomPreviewPivotFraction( + viewportRootOffset: Offset, + pageRootOffset: Offset, + anchor: Offset, + pageCanvasSize: IntSize +): Offset? { + if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null + if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null + val pageAnchor = viewportRootOffset + anchor - pageRootOffset + if (!pageAnchor.x.isFinite() || !pageAnchor.y.isFinite()) return null + return Offset( + x = (pageAnchor.x / pageCanvasSize.width).coerceIn(0f, 1f), + y = (pageAnchor.y / pageCanvasSize.height).coerceIn(0f, 1f) + ) +} + +internal fun desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset: Offset, + pageRootOffset: Offset, + anchor: Offset, + previewScale: Float +): Offset? { + if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null + if (!previewScale.isFinite() || previewScale <= 0f) return null + val rootAnchor = viewportRootOffset + anchor + if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return null + return Offset( + x = (pageRootOffset.x - rootAnchor.x) * (previewScale - 1f), + y = (pageRootOffset.y - rootAnchor.y) * (previewScale - 1f) + ) +} + +internal fun Modifier.desktopPdfZoomPreviewLayer( + preview: DesktopPdfZoomPreview?, + currentZoom: Float, + viewportRootOffset: Offset, + pageRootOffset: Offset, + pageCanvasSize: IntSize, + commitPageRootOffset: Offset? = null, + scrollBounds: DesktopPdfZoomScrollBounds? = null +): Modifier { + val activePreview = preview ?: return this + if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return this + if (!currentZoom.isFinite() || currentZoom <= 0f) return this + if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this + val previewScale = activePreview.zoom / currentZoom + val commitTranslation = desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = activePreview.viewportRootOffset, + oldPageRootOffset = activePreview.pageRootOffset, + currentAnchorPageRootOffset = commitPageRootOffset ?: pageRootOffset, + anchor = activePreview.anchor, + oldZoom = activePreview.baseZoom, + newZoom = activePreview.zoom, + currentZoom = currentZoom, + scrollBounds = scrollBounds + ) + if ( + !previewScale.isFinite() || + (abs(previewScale - 1f) < DesktopPdfZoomPreviewTolerance && commitTranslation == null) + ) { + return this + } + logPdfZoomSettle { + "preview_layer seq=${activePreview.diagnosticSequence} kind=page currentZoom=${currentZoom.formatLogFloat()} " + + "previewZoom=${activePreview.zoom.formatLogFloat()} scale=${previewScale.formatLogFloat()} " + + "pageRoot=${pageRootOffset.formatLogOffset()} commitRoot=${commitPageRootOffset.formatLogOffset()} " + + "commit=${commitTranslation.formatLogOffset()} " + + "h=${scrollBounds?.currentHorizontalScroll ?: "none"}/${scrollBounds?.maxHorizontalScroll ?: "none"} " + + "v=${scrollBounds?.currentVerticalScroll ?: "none"}/${scrollBounds?.maxVerticalScroll ?: "none"}" + } + val transformOrigin = activePreview.anchor?.let { anchor -> + desktopPdfZoomPreviewPivotFraction( + viewportRootOffset = viewportRootOffset, + pageRootOffset = pageRootOffset, + anchor = anchor, + pageCanvasSize = pageCanvasSize + )?.let { pivot -> + TransformOrigin(pivotFractionX = pivot.x, pivotFractionY = pivot.y) + } ?: TransformOrigin.Center + } ?: TransformOrigin.Center + return graphicsLayer { + scaleX = previewScale + scaleY = previewScale + translationX = commitTranslation?.x ?: 0f + translationY = commitTranslation?.y ?: 0f + this.transformOrigin = transformOrigin + } +} + +internal fun Modifier.desktopPdfDocumentZoomPreviewLayer( + preview: DesktopPdfZoomPreview?, + currentZoom: Float, + viewportRootOffset: Offset, + pageRootOffset: Offset, + anchorPageRootOffset: Offset? = null, + scrollBounds: DesktopPdfZoomScrollBounds? = null +): Modifier { + val activePreview = preview ?: return this + if (!currentZoom.isFinite() || currentZoom <= 0f) return this + if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this + val previewScale = activePreview.zoom / currentZoom + val commitTranslation = desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = activePreview.viewportRootOffset, + oldPageRootOffset = activePreview.pageRootOffset, + currentAnchorPageRootOffset = anchorPageRootOffset ?: pageRootOffset, + anchor = activePreview.anchor, + oldZoom = activePreview.baseZoom, + newZoom = activePreview.zoom, + currentZoom = currentZoom, + scrollBounds = scrollBounds + ) + if ( + !previewScale.isFinite() || + (abs(previewScale - 1f) < DesktopPdfZoomPreviewTolerance && commitTranslation == null) + ) { + return this + } + logPdfZoomSettle { + "preview_layer seq=${activePreview.diagnosticSequence} kind=document currentZoom=${currentZoom.formatLogFloat()} " + + "previewZoom=${activePreview.zoom.formatLogFloat()} scale=${previewScale.formatLogFloat()} " + + "pageRoot=${pageRootOffset.formatLogOffset()} anchorRoot=${(anchorPageRootOffset ?: pageRootOffset).formatLogOffset()} " + + "commit=${commitTranslation.formatLogOffset()} h=${scrollBounds?.currentHorizontalScroll ?: "none"}/" + + "${scrollBounds?.maxHorizontalScroll ?: "none"} v=${scrollBounds?.currentVerticalScroll ?: "none"}/" + + "${scrollBounds?.maxVerticalScroll ?: "none"}" + } + val translation = activePreview.anchor?.let { anchor -> + desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset = viewportRootOffset, + pageRootOffset = pageRootOffset, + anchor = anchor, + previewScale = previewScale + ) + } ?: Offset.Zero + return graphicsLayer { + scaleX = previewScale + scaleY = previewScale + translationX = translation.x + (commitTranslation?.x ?: 0f) + translationY = translation.y + (commitTranslation?.y ?: 0f) + transformOrigin = TransformOrigin(0f, 0f) + } +} + +@Composable +internal fun Modifier.desktopPdfZoomGestures( + currentZoom: Float, + zoomSpec: PdfZoomSpec, + onZoomChanged: (oldZoom: Float, newZoom: Float, anchor: Offset?) -> Unit +): Modifier { + val latestZoom by rememberUpdatedState(currentZoom) + val latestOnZoomChanged by rememberUpdatedState(onZoomChanged) + return this.pointerInput(zoomSpec) { + var gestureZoom = latestZoom + var appliedGestureZoom = latestZoom + var lastZoomEventAt = 0L + var lastAppliedZoomAt = 0L + fun applyZoomFactor(factor: Float, eventTime: Long, anchor: Offset?) { + if (lastZoomEventAt == 0L || eventTime - lastZoomEventAt > 180L) { + gestureZoom = latestZoom + appliedGestureZoom = latestZoom + lastAppliedZoomAt = 0L + } + val newZoom = desktopPdfZoomTarget(gestureZoom, zoomSpec, factor) + gestureZoom = newZoom + lastZoomEventAt = eventTime + val shouldApplyNow = lastAppliedZoomAt == 0L || + eventTime - lastAppliedZoomAt >= DesktopPdfZoomGestureFrameMillis || + newZoom == zoomSpec.min || + newZoom == zoomSpec.max + if (shouldApplyNow && newZoom != appliedGestureZoom) { + latestOnZoomChanged(appliedGestureZoom, newZoom, anchor) + appliedGestureZoom = newZoom + lastAppliedZoomAt = eventTime + } + } + + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val eventTime = event.changes.maxOfOrNull { it.uptimeMillis } ?: 0L + if (event.type == PointerEventType.Scroll && event.keyboardModifiers.isPointerCtrlPressed) { + val scrollDelta = event.changes.fold(Offset.Zero) { total, change -> + total + change.scrollDelta + } + val zoomDelta = if (abs(scrollDelta.y) >= abs(scrollDelta.x)) scrollDelta.y else scrollDelta.x + val factor = desktopPdfScrollZoomFactor(zoomDelta) + if (abs(factor - 1f) > 0.0001f) { + applyZoomFactor(factor, eventTime, event.changes.firstOrNull()?.position) + event.changes.forEach { it.consume() } + } + continue + } + + val pressedPointers = event.changes.count { it.pressed } + if (pressedPointers > 1) { + val zoomChange = event.calculateZoom() + if (zoomChange.isFinite() && abs(zoomChange - 1f) > 0.005f) { + val centroid = event.calculateCentroid(useCurrent = false) + val anchor = if (centroid == Offset.Unspecified) { + event.changes.firstOrNull { it.pressed }?.position + } else { + centroid + } + applyZoomFactor(zoomChange, eventTime, anchor) + } + event.changes.forEach { it.consume() } + } + } + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfium.kt similarity index 98% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfium.kt index 53127af..f71a86d 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfium.kt @@ -1,35 +1,35 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.PdfTocEntry -import com.aryan.reader.shared.opds.OpdsCatalog -import com.aryan.reader.shared.opds.OpdsStreamReference -import com.aryan.reader.shared.pdf.PdfInkTool -import com.aryan.reader.shared.pdf.PdfPageBounds -import com.aryan.reader.shared.pdf.PdfZoomSpec -import com.aryan.reader.shared.pdf.PdfiumAnnotationSubtype -import com.aryan.reader.shared.pdf.SharedPdfAnnotation -import com.aryan.reader.shared.pdf.SharedPdfAnnotationExportMapper -import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation -import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotationThreads -import com.aryan.reader.shared.pdf.SharedPdfHighlightAnnotationExport -import com.aryan.reader.shared.pdf.SharedPdfInkAnnotationExport -import com.aryan.reader.shared.pdf.SharedPdfIndexedPage -import com.aryan.reader.shared.pdf.SharedPdfReflowImageElement -import com.aryan.reader.shared.pdf.SharedPdfReflowPage -import com.aryan.reader.shared.pdf.SharedPdfReflowPageElement -import com.aryan.reader.shared.pdf.SharedPdfReflowTextElement -import com.aryan.reader.shared.pdf.SharedPdfReflowTextLine -import com.aryan.reader.shared.pdf.SharedPdfReflowTextSpan -import com.aryan.reader.shared.pdf.SharedPdfRichPageLayout -import com.aryan.reader.shared.pdf.SharedPdfSearchIndex -import com.aryan.reader.shared.pdf.SharedPdfSearchResult -import com.aryan.reader.shared.pdf.pdfInkAppearancePoints +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.PdfTocEntry +import org.dueattendant149.bookreader.shared.opds.OpdsCatalog +import org.dueattendant149.bookreader.shared.opds.OpdsStreamReference +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec +import org.dueattendant149.bookreader.shared.pdf.PdfiumAnnotationSubtype +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationExportMapper +import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotationThreads +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlightAnnotationExport +import org.dueattendant149.bookreader.shared.pdf.SharedPdfInkAnnotationExport +import org.dueattendant149.bookreader.shared.pdf.SharedPdfIndexedPage +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowImageElement +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowPage +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowPageElement +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowTextElement +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowTextLine +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowTextSpan +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichPageLayout +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchIndex +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult +import org.dueattendant149.bookreader.shared.pdf.pdfInkAppearancePoints import com.sun.jna.Callback import com.sun.jna.Library import com.sun.jna.Memory diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPlatformPaths.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPaths.kt similarity index 91% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPlatformPaths.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPaths.kt index 6cc96bd..fcb4b6f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPlatformPaths.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPaths.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import java.io.File import java.util.Locale @@ -24,14 +24,6 @@ internal data class DesktopPlatform( val isLinux: Boolean get() = os == DesktopOperatingSystem.LINUX val isWindows: Boolean get() = os == DesktopOperatingSystem.WINDOWS - val kcefBundleDirectoryName: String - get() = when (os) { - DesktopOperatingSystem.WINDOWS -> "kcef-bundle" - DesktopOperatingSystem.LINUX -> "kcef-bundle-linux-${architecture.resourceName}" - DesktopOperatingSystem.MACOS -> "kcef-bundle-macos-${architecture.resourceName}" - DesktopOperatingSystem.OTHER -> "kcef-bundle-${architecture.resourceName}" - } - val pdfiumDirectoryName: String get() = when (os) { DesktopOperatingSystem.WINDOWS -> "win-${architecture.resourceName}-v8" @@ -140,7 +132,7 @@ private fun xdgBase( ): File { return env(envName) ?.takeIf { it.isNotBlank() } + ?.takeIf { it.startsWith("/") } ?.let(::File) - ?.takeIf { it.isAbsolute } ?: File(userHome, fallbackRelativePath) } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPptxDocument.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocument.kt similarity index 94% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPptxDocument.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocument.kt index 013355d..14885d2 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPptxDocument.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocument.kt @@ -1,19 +1,19 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.pptx.SharedPptxCharBox as DesktopPptxCharBox -import com.aryan.reader.shared.pptx.SharedPptxDeck as DesktopPptxDeck -import com.aryan.reader.shared.pptx.SharedPptxDeckCache -import com.aryan.reader.shared.pptx.SharedPptxImageCrop as DesktopPptxImageCrop -import com.aryan.reader.shared.pptx.SharedPptxImageElement as DesktopPptxImageElement -import com.aryan.reader.shared.pptx.SharedPptxParagraph as DesktopPptxParagraph -import com.aryan.reader.shared.pptx.SharedPptxRect as DesktopPptxRect -import com.aryan.reader.shared.pptx.SharedPptxShapeElement as DesktopPptxShapeElement -import com.aryan.reader.shared.pptx.SharedPptxSlide as DesktopPptxSlide -import com.aryan.reader.shared.pptx.SharedPptxTableCell as DesktopPptxTableCell -import com.aryan.reader.shared.pptx.SharedPptxTableElement as DesktopPptxTableElement -import com.aryan.reader.shared.pptx.SharedPptxTextAlign as DesktopPptxTextAlign -import com.aryan.reader.shared.pptx.SharedPptxTextInsets as DesktopPptxTextInsets -import com.aryan.reader.shared.pptx.SharedPptxVerticalAnchor as DesktopPptxVerticalAnchor +import org.dueattendant149.bookreader.shared.pptx.SharedPptxCharBox as DesktopPptxCharBox +import org.dueattendant149.bookreader.shared.pptx.SharedPptxDeck as DesktopPptxDeck +import org.dueattendant149.bookreader.shared.pptx.SharedPptxDeckCache +import org.dueattendant149.bookreader.shared.pptx.SharedPptxImageCrop as DesktopPptxImageCrop +import org.dueattendant149.bookreader.shared.pptx.SharedPptxImageElement as DesktopPptxImageElement +import org.dueattendant149.bookreader.shared.pptx.SharedPptxParagraph as DesktopPptxParagraph +import org.dueattendant149.bookreader.shared.pptx.SharedPptxRect as DesktopPptxRect +import org.dueattendant149.bookreader.shared.pptx.SharedPptxShapeElement as DesktopPptxShapeElement +import org.dueattendant149.bookreader.shared.pptx.SharedPptxSlide as DesktopPptxSlide +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTableCell as DesktopPptxTableCell +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTableElement as DesktopPptxTableElement +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTextAlign as DesktopPptxTextAlign +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTextInsets as DesktopPptxTextInsets +import org.dueattendant149.bookreader.shared.pptx.SharedPptxVerticalAnchor as DesktopPptxVerticalAnchor import java.awt.AlphaComposite import java.awt.BasicStroke import java.awt.Color diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopProScreen.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProScreen.kt similarity index 68% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopProScreen.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProScreen.kt index dc2ed64..655ae2f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopProScreen.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProScreen.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Verified @@ -30,8 +29,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.UserData -import com.aryan.reader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.UserData +import org.dueattendant149.bookreader.shared.ui.readerString @Composable internal fun DesktopProScreen( @@ -55,7 +54,7 @@ internal fun DesktopProScreen( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { Icon(Icons.Default.Star, contentDescription = null, modifier = Modifier.size(30.dp), tint = MaterialTheme.colorScheme.primary) Column(Modifier.weight(1f)) { - Text(readerString("desktop_pro_and_credits", "Pro and credits"), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text(readerString("desktop_account_and_credits", "Account & credits"), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text( readerString("desktop_pro_sign_in_desc", "Sign in to check your account status on desktop."), style = MaterialTheme.typography.bodyMedium, @@ -73,7 +72,7 @@ internal fun DesktopProScreen( Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Icon(Icons.Default.Verified, contentDescription = null, tint = MaterialTheme.colorScheme.primary) - Text(readerString("desktop_account", "Account"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text(readerString("desktop_account_overview", "Account overview"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) } if (user == null) { Text( @@ -92,11 +91,13 @@ internal fun DesktopProScreen( Text(readerString("drawer_sign_in", "Sign in with Google")) } } else { - Text(user.displayName ?: user.email ?: readerString("desktop_signed_in", "Signed in"), style = MaterialTheme.typography.titleMedium) - user.email?.let { - Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(user.displayName ?: user.email ?: readerString("desktop_signed_in", "Signed in"), style = MaterialTheme.typography.titleMedium) + user.email?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } OutlinedButton(onClick = onRefresh, enabled = !isBusy) { Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.size(8.dp)) @@ -107,33 +108,26 @@ internal fun DesktopProScreen( } } } - statusMessage?.takeIf { it.isNotBlank() }?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - Surface( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceContainerLow, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Icon(Icons.Default.Cloud, contentDescription = null, tint = MaterialTheme.colorScheme.primary) - Text(readerString("desktop_access", "Desktop access"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - } - Text( - if (isProUser) { - readerString("desktop_pro_unlocked_account", "Pro is unlocked for this account.") - } else { - readerString("desktop_pro_not_unlocked_account", "Pro is not unlocked for this account.") - }, - style = MaterialTheme.typography.titleMedium - ) - Text(readerString("desktop_credits_available_format", "%1\$d credits available", credits), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) HorizontalDivider() + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(18.dp)) { + DesktopAccountValue( + label = readerString("desktop_plan", "Plan"), + value = if (isProUser) { + readerString("desktop_pro_unlocked_account", "Pro is unlocked for this account.") + } else { + readerString("desktop_pro_not_unlocked_account", "Pro is not unlocked for this account.") + }, + modifier = Modifier.weight(1f) + ) + DesktopAccountValue( + label = readerString("credits_tab", "Credits"), + value = readerString("desktop_credits_available_format", "%1\$d credits available", credits), + modifier = Modifier.weight(1f) + ) + } + Text( readerString( "desktop_pro_purchase_android_desc", @@ -142,9 +136,25 @@ internal fun DesktopProScreen( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) + + statusMessage?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } Spacer(Modifier.height(12.dp)) } } + +@Composable +private fun DesktopAccountValue( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProfileAvatar.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProfileAvatar.kt new file mode 100644 index 0000000..e60ad28 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProfileAvatar.kt @@ -0,0 +1,114 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.foundation.shape.CircleShape +import org.dueattendant149.bookreader.shared.UserData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Image as SkiaImage + +@Composable +internal fun DesktopProfileAvatar( + user: UserData, + modifier: Modifier = Modifier +) { + val photoUrl = user.photoUrl?.takeIf { it.isNotBlank() } + var bitmap by remember(photoUrl) { mutableStateOf(photoUrl?.let(DesktopProfileAvatarCache::peek)) } + + LaunchedEffect(photoUrl) { + bitmap = if (photoUrl == null) { + null + } else { + withContext(Dispatchers.IO) { + DesktopProfileAvatarCache.load(photoUrl) + } + } + } + + val imageBitmap = bitmap + if (imageBitmap != null) { + Image( + bitmap = imageBitmap, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier.clip(CircleShape) + ) + } else { + DesktopProfileAvatarFallback(user = user, modifier = modifier) + } +} + +@Composable +private fun DesktopProfileAvatarFallback( + user: UserData, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + val initial = (user.displayName ?: user.email) + ?.trim() + ?.firstOrNull() + ?.uppercase() + if (initial != null) { + Text(initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } else { + Icon(Icons.Default.AccountCircle, contentDescription = null) + } + } + } +} + +private object DesktopProfileAvatarCache { + private const val MaxEntries = 24 + + private val cache = object : LinkedHashMap(MaxEntries, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > MaxEntries + } + } + + fun peek(url: String): ImageBitmap? { + return synchronized(cache) { cache[url] } + } + + fun load(url: String): ImageBitmap? { + peek(url)?.let { return it } + val bitmap = runCatching { + DesktopOpdsHttp.fetchBytes(url, catalog = null).toImageBitmap() + }.getOrNull() ?: return null + + synchronized(cache) { + cache[url] = bitmap + } + return bitmap + } + + private fun ByteArray.toImageBitmap(): ImageBitmap? { + return runCatching { SkiaImage.makeFromEncoded(this).toComposeImageBitmap() }.getOrNull() + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaults.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaults.kt new file mode 100644 index 0000000..c0e0a73 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaults.kt @@ -0,0 +1,91 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderFeatureSurface +import org.dueattendant149.bookreader.shared.ReaderPlatform +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings + +internal const val DesktopReaderDefaultsVersion = 1 + +internal enum class DesktopReaderSettingsEngine { + TEXT, + PDF +} + +internal val DesktopDefaultTextReaderSettings = ReaderSettings( + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE +) + +internal val DesktopDefaultPdfReaderSettings = ReaderSettings( + themeId = "no_theme", + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE +) + +internal fun FileType.desktopReaderSettingsEngine(): DesktopReaderSettingsEngine? { + return when (SharedFileCapabilities.surfaceFor(this, ReaderPlatform.DESKTOP)) { + ReaderFeatureSurface.PDF_VIEWER -> DesktopReaderSettingsEngine.PDF + ReaderFeatureSurface.EPUB_READER, + ReaderFeatureSurface.TEXT_READER -> DesktopReaderSettingsEngine.TEXT + null -> null + } +} + +internal fun BookItem.usesDesktopReaderSettingsEngine(engine: DesktopReaderSettingsEngine): Boolean { + return type.desktopReaderSettingsEngine() == engine +} + +internal fun List.withDesktopReaderEngineSettings( + engine: DesktopReaderSettingsEngine, + settings: ReaderSettings +): List { + return map { book -> + if (book.usesDesktopReaderSettingsEngine(engine)) { + book.copy(readerSettings = settings) + } else { + book + } + } +} + +internal fun SharedReaderScreenState.withDesktopReaderEngineDefaultSettings( + engine: DesktopReaderSettingsEngine, + settings: ReaderSettings +): SharedReaderScreenState { + val engineSettings = if (engine == DesktopReaderSettingsEngine.PDF) { + settings.toDesktopPdfReaderSettings() + } else { + settings + } + return when (engine) { + DesktopReaderSettingsEngine.TEXT -> copy( + readerDefaultSettings = engineSettings, + rawLibraryBooks = rawLibraryBooks.withDesktopReaderEngineSettings(engine, engineSettings) + ) + DesktopReaderSettingsEngine.PDF -> copy( + pdfReaderDefaultSettings = engineSettings, + rawLibraryBooks = rawLibraryBooks.withDesktopReaderEngineSettings(engine, engineSettings) + ) + } +} + +internal fun ReaderSettings.toDesktopPdfDisplayMode(): PdfDisplayMode { + return when (readingMode) { + ReaderReadingMode.PAGINATED -> PdfDisplayMode.PAGINATION + ReaderReadingMode.VERTICAL -> PdfDisplayMode.VERTICAL_SCROLL + } +} + +internal fun PdfDisplayMode.toDesktopReaderReadingMode(): ReaderReadingMode { + return when (this) { + PdfDisplayMode.PAGINATION -> ReaderReadingMode.PAGINATED + PdfDisplayMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDiagnostics.kt new file mode 100644 index 0000000..8ee7aeb --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDiagnostics.kt @@ -0,0 +1,146 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import org.dueattendant149.bookreader.shared.ReaderLocator + +private const val PdfZoomPerfLogTag = "EpistemePdfZoomPerf" +private const val PdfZoomSettleLogTag = "EpistemePdfZoomSettle" +private const val PdfLinkLogTag = "EpistemePdfLink" +private const val PdfChromeTapLogTag = "EpistemePdfChromeTap" +private const val EpubLinkLogTag = "EpistemeEpubLink" +private const val EpubPaginationLogTag = "EpistemeEpubPagination" +private const val EpubCutoffLogTag = "EpistemeEpubCutoff" +private const val ReaderGapLogTag = "EpistemeReaderGap" +private const val EpubSelectionDebugLogTag = "EPUB_SELECTION_DEBUG" +private const val EpubHighlightFlowLogTag = "EpistemeEpubHighlightFlow" +private const val DesktopHighlightMapLogTag = "EpistemeDesktopHighlightMap" +private const val DesktopPositionTraceLogTag = "EpistemeDesktopPositionTrace" +private const val DesktopReaderCloseLogTag = "EpistemeDesktopReaderClose" +private const val DesktopNativeWebViewLogTag = "EpistemeNativeWebView" +private const val WebViewLayoutLogTag = "EpistemeWebViewLayout" +private const val ReaderModeSwitchLogTag = "EpistemeReaderModeSwitch" + +internal fun logPdfSelection(message: String) { +} + +internal fun logPdfZoomPerf(message: String) { + logDesktopDiagnostic(PdfZoomPerfLogTag) { message } +} + +internal fun logPdfZoomPerf(message: () -> String) { + logDesktopDiagnostic(PdfZoomPerfLogTag, message) +} + +internal fun logPdfZoomSettle(message: String) { + logDesktopDiagnostic(PdfZoomSettleLogTag) { message } +} + +internal fun logPdfZoomSettle(message: () -> String) { + logDesktopDiagnostic(PdfZoomSettleLogTag, message) +} + +internal fun logPdfLink(message: String) { + logDesktopDiagnostic(PdfLinkLogTag) { message } +} + +internal fun logPdfChromeTap(message: String) { + logDesktopDiagnostic(PdfChromeTapLogTag) { message } +} + +internal fun logPdfChromeTap(message: () -> String) { + logDesktopDiagnostic(PdfChromeTapLogTag, message) +} + +internal fun logEpubLink(message: String) { + logDesktopDiagnostic(EpubLinkLogTag) { message } +} + +internal fun logEpubPagination(message: String) { + logDesktopDiagnostic(EpubPaginationLogTag) { message } +} + +internal fun logEpubCutoff(message: String) { + logDesktopDiagnostic(EpubCutoffLogTag) { message } +} + +internal fun logReaderGap(message: String) { + logDesktopDiagnostic(ReaderGapLogTag) { message } +} + +internal fun logEpubSelectionDebug(message: String) { + logDesktopDiagnostic(EpubSelectionDebugLogTag) { message } +} + +internal fun logEpubHighlightFlow(message: String) { + logDesktopDiagnostic(EpubHighlightFlowLogTag) { message } +} + +internal fun logDesktopHighlightMap(message: String) { + logDesktopDiagnostic(DesktopHighlightMapLogTag) { message } +} + +internal fun logDesktopPositionTrace(message: String) { + logDesktopDiagnostic(DesktopPositionTraceLogTag) { message } +} + +internal fun logDesktopPositionTrace(message: () -> String) { + logDesktopDiagnostic(DesktopPositionTraceLogTag, message) +} + +internal fun logDesktopReaderClose(message: String) { + logDesktopDiagnostic(DesktopReaderCloseLogTag) { message } +} + +internal fun logDesktopWebView2(message: String) { + logDesktopDiagnostic(DesktopNativeWebViewLogTag) { message } +} + +internal fun logWebViewLayoutDiag(message: String) { + logDesktopDiagnostic(WebViewLayoutLogTag) { message } +} + +internal fun logReaderModeSwitch(message: String) { + logDesktopDiagnostic(ReaderModeSwitchLogTag) { message } +} + +internal fun DesktopPdfLinkTarget.formatLogTarget(): String { + return "dest=${destPageIndex?.let { it + 1 } ?: "null"} uri=\"${uri.orEmpty().logPreview()}\"" +} + +internal fun Float.formatLogFloat(): String { + return String.format("%.3f", this) +} + +internal fun Offset?.formatLogOffset(): String { + if (this == null) return "none" + return "${x.formatLogFloat()},${y.formatLogFloat()}" +} + +internal fun IntOffset?.formatLogIntOffset(): String { + if (this == null) return "none" + return "${this.x},${this.y}" +} + +internal fun IntSize.formatLogSize(): String { + return "${width}x${height}" +} + +internal fun DesktopPdfCharHit?.formatLogHit(prefix: String): String { + if (this == null) { + return "${prefix}Index=null ${prefix}Source=none ${prefix}X=null ${prefix}Y=null ${prefix}Nx=null ${prefix}Ny=null" + } + return "${prefix}Index=$index ${prefix}Source=$source " + + "${prefix}X=${point.x.formatLogFloat()} ${prefix}Y=${point.y.formatLogFloat()} " + + "${prefix}Nx=${normalized.x.formatLogFloat()} ${prefix}Ny=${normalized.y.formatLogFloat()}" +} + +internal fun ReaderLocator?.desktopPositionTraceSummary(maxTextLength: Int = 90): String { + if (this == null) return "null" + return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " + + "offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " + + "block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " + + "chapterId=\"${chapterId.orEmpty().logPreview(80)}\" href=\"${href.orEmpty().logPreview(120)}\" " + + "cfi=\"${cfi.orEmpty().logPreview(180)}\" text=\"${textQuote.orEmpty().logPreview(maxTextLength)}\"" +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpenTrace.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpenTrace.kt new file mode 100644 index 0000000..540e7db --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpenTrace.kt @@ -0,0 +1,29 @@ +package org.dueattendant149.bookreader.desktop + +internal const val DesktopReaderOpenTraceTag = "EpistemeDesktopOpenTrace" + +internal fun logDesktopReaderOpenTrace(message: () -> String) { + logDesktopDiagnostic(DesktopReaderOpenTraceTag, message) +} + +internal fun Long.elapsedOpenTraceMs(nowNanos: Long = System.nanoTime()): Long { + return ((nowNanos - this).coerceAtLeast(0L)) / 1_000_000L +} + +internal fun DesktopReaderOpening.elapsedOpenTraceMs(nowNanos: Long = System.nanoTime()): Long { + return startedAtNanos.elapsedOpenTraceMs(nowNanos) +} + +internal fun DesktopReaderOpening.openTracePrefix(event: String): String { + return "event=$event requestId=$requestId bookId=\"${bookId.logPreview(80)}\" " + + "title=\"${title.logPreview(120)}\" format=\"$formatLabel\" elapsedMs=${elapsedOpenTraceMs()}" +} + +internal fun DesktopReaderOpenResult.openTraceKind(): String { + return when (this) { + is DesktopReaderOpenResult.Failure -> "failure" + is DesktopReaderOpenResult.PasswordRequired -> "password_required" + is DesktopReaderOpenResult.Pdf -> "pdf" + is DesktopReaderOpenResult.Text -> "text" + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpening.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpening.kt similarity index 76% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpening.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpening.kt index c0ee262..909ac08 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpening.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpening.kt @@ -1,8 +1,8 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.reader.ReaderSessionState -import com.aryan.reader.shared.ui.SharedAppTab +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.reader.ReaderSessionState +import org.dueattendant149.bookreader.shared.ui.SharedAppTab internal data class DesktopReaderOpening( val requestId: Long, @@ -10,7 +10,8 @@ internal data class DesktopReaderOpening( val title: String, val formatLabel: String, val returnTab: SharedAppTab, - val password: String? = null + val password: String? = null, + val startedAtNanos: Long = System.nanoTime() ) internal sealed interface DesktopReaderOpenResult { diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderPanels.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderPanels.kt similarity index 72% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderPanels.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderPanels.kt index f5ee0ff..90474f9 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderPanels.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderPanels.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -26,7 +26,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -43,25 +42,22 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL -import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID -import com.aryan.reader.shared.ReaderAiByokSettings -import com.aryan.reader.shared.ReaderAiModelOption -import com.aryan.reader.shared.ReaderAiModelOptions -import com.aryan.reader.shared.ReaderAiResultState -import com.aryan.reader.shared.ReaderAutoScrollState -import com.aryan.reader.shared.ReaderCloudTtsVoices -import com.aryan.reader.shared.ReaderExtrasState -import com.aryan.reader.shared.ReaderExternalLookupAction -import com.aryan.reader.shared.ReaderTtsReadScope -import com.aryan.reader.shared.ReaderTtsReplacementPreferences -import com.aryan.reader.shared.maskedReaderAiKey -import com.aryan.reader.shared.ui.SharedMarkdownText -import com.aryan.reader.shared.ui.SharedReaderPopupLayer -import com.aryan.reader.shared.ui.SharedReaderTtsReplacementControls -import com.aryan.reader.shared.ui.SharedStableOutlinedTextField -import com.aryan.reader.shared.ui.readerString -import com.aryan.reader.shared.ui.sharedReaderPopupWidth +import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL +import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL_ID +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.ReaderAiModelOption +import org.dueattendant149.bookreader.shared.ReaderAiModelOptions +import org.dueattendant149.bookreader.shared.ReaderAiResultState +import org.dueattendant149.bookreader.shared.ReaderCloudTtsVoices +import org.dueattendant149.bookreader.shared.ReaderExtrasState +import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences +import org.dueattendant149.bookreader.shared.maskedReaderAiKey +import org.dueattendant149.bookreader.shared.ui.SharedMarkdownText +import org.dueattendant149.bookreader.shared.ui.SharedReaderPopupLayer +import org.dueattendant149.bookreader.shared.ui.SharedReaderTtsReplacementControls +import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField +import org.dueattendant149.bookreader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.sharedReaderPopupWidth @Composable internal fun DesktopReaderBottomSheet( @@ -197,7 +193,7 @@ internal fun DesktopAiByokSettingsDialog( DesktopSavedAiKeyRow( label = readerString("provider_gemini", "Gemini"), keyValue = sanitized.geminiKey, - onClear = { onSettingsChange(sanitized.copy(geminiKey = "", ttsModel = "")) } + onClear = { onSettingsChange(sanitized.copy(geminiKey = "")) } ) DesktopSavedAiKeyRow( label = readerString("provider_groq", "Groq"), @@ -250,26 +246,6 @@ internal fun DesktopAiByokSettingsDialog( HorizontalDivider() - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(readerString("options_show_ai_in_reader", "Show AI in reader"), style = MaterialTheme.typography.titleMedium) - Text( - readerString( - "desktop_show_ai_in_reader_desc", - "Matches the Android hide toggle for smart dictionary, summaries, and recaps." - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = !sanitized.hideReaderAiFeatures, - onCheckedChange = { enabled -> - onSettingsChange(sanitized.copy(hideReaderAiFeatures = !enabled)) - } - ) - } - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text(readerString("ai_settings_use_one_model", "Use one model for all features"), style = MaterialTheme.typography.titleMedium) @@ -320,31 +296,6 @@ internal fun DesktopAiByokSettingsDialog( options = listOf(ReaderAiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)), onSelected = { onSettingsChange(sanitized.copy(ttsModel = it)) } ) - Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - ReaderCloudTtsVoices.chunked(3).forEach { rowVoices -> - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - rowVoices.forEach { voice -> - FilterChip( - selected = sanitized.ttsSpeakerId == voice.id, - onClick = { onSettingsChange(sanitized.copy(ttsSpeakerId = voice.id)) }, - label = { - Column { - Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - voice.description, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - ) - } - } - } - } } }, confirmButton = { @@ -405,53 +356,21 @@ private fun DesktopAiModelSelector( } @Composable -internal fun DesktopPdfExtrasPanel( - pageText: String, +internal fun DesktopPdfTtsPanel( extrasState: ReaderExtrasState, aiByokSettings: ReaderAiByokSettings, - externalLookupAvailable: Boolean, cloudTtsFeatureAvailable: Boolean, - onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, - onOpenAiHub: (() -> Unit)? = null, - onCloudTtsStart: (ReaderTtsReadScope) -> Unit, - onCloudTtsPauseResume: () -> Unit, - onCloudTtsStop: () -> Unit, onCloudTtsClearCache: () -> Unit, - onAutoScrollChange: (ReaderAutoScrollState) -> Unit, + onCloudTtsVoiceChange: (String) -> Unit, ttsReplacementPreferences: ReaderTtsReplacementPreferences, ttsReplacementBookId: String, onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit ) { val settings = aiByokSettings.sanitized() - val autoScroll = extrasState.autoScroll.sanitized() Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text(readerString("desktop_extras", "Extras"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - if (externalLookupAvailable) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - ReaderExternalLookupAction.entries.forEach { action -> - FilterChip( - selected = false, - enabled = pageText.isNotBlank(), - onClick = { onExternalLookup(action, pageText) }, - label = { Text(action.title) } - ) - } - } - } - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(readerString("menu_auto_scroll", "Auto Scroll"), modifier = Modifier.weight(1f)) - Switch( - checked = autoScroll.enabled, - onCheckedChange = { onAutoScrollChange(autoScroll.copy(enabled = it)) } - ) - } - Slider( - value = autoScroll.speed, - onValueChange = { onAutoScrollChange(autoScroll.copy(speed = it).sanitized()) }, - valueRange = 12f..160f - ) + Text(readerString("menu_tts_settings", "TTS"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused if (cloudTtsFeatureAvailable) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { @@ -476,40 +395,38 @@ internal fun DesktopPdfExtrasPanel( Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } - TextButton( - enabled = settings.isCloudTtsAvailable || ttsBusy, - onClick = { - if (ttsBusy) { - onCloudTtsStop() - } else { - onCloudTtsStart(ReaderTtsReadScope.BOOK) - } - } - ) { - Text(if (ttsBusy) readerString("action_stop", "Stop") else readerString("action_read", "Read")) - } } - if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), fontWeight = FontWeight.SemiBold) + if (ttsBusy) { + Text( + readerString("desktop_stop_reading_change_voices", "Stop reading to change voices."), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - TextButton(onClick = onCloudTtsPauseResume) { - Text(if (extrasState.cloudTts.isPaused) readerString("tooltip_tts_resume", "Resume") else readerString("tooltip_tts_pause", "Pause")) + ReaderCloudTtsVoices.forEach { voice -> + FilterChip( + selected = settings.ttsSpeakerId == voice.id, + enabled = !ttsBusy, + onClick = { onCloudTtsVoiceChange(voice.id) }, + label = { + Column { + Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + voice.description, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + ) } } } - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - TextButton( - enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), - onClick = { onCloudTtsStart(ReaderTtsReadScope.PAGE) } - ) { - Text(readerString("desktop_page", "Page")) - } - TextButton( - enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), - onClick = { onCloudTtsStart(ReaderTtsReadScope.BOOK) } - ) { - Text(readerString("desktop_from_here", "From here")) - } - } val cacheSummary = extrasState.cloudTts.cacheSummary if (cacheSummary.hasCachedAudio) { Text( @@ -529,12 +446,5 @@ internal fun DesktopPdfExtrasPanel( bookId = ttsReplacementBookId, onPreferencesChange = onTtsReplacementPreferencesChange ) - if (settings.areReaderAiFeaturesAvailable && onOpenAiHub != null) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - TextButton(onClick = onOpenAiHub) { - Text(readerString("desktop_ai_hub", "AI hub")) - } - } - } } } diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderScreen.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderScreen.kt new file mode 100644 index 0000000..e3db9d4 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderScreen.kt @@ -0,0 +1,1088 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import org.dueattendant149.bookreader.paginatedreader.CssStyle +import org.dueattendant149.bookreader.paginatedreader.SemanticImage +import org.dueattendant149.bookreader.shared.CustomFontItem +import org.dueattendant149.bookreader.shared.ReaderAction +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.ReaderAiFeature +import org.dueattendant149.bookreader.shared.ReaderAutoScrollState +import org.dueattendant149.bookreader.shared.ReaderExtrasState +import org.dueattendant149.bookreader.shared.ReaderExternalLookupAction +import org.dueattendant149.bookreader.shared.ReaderHighlightPalette +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.ReaderToolbarPreferences +import org.dueattendant149.bookreader.shared.ReaderTtsChunk +import org.dueattendant149.bookreader.shared.ReaderTtsReadScope +import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences +import org.dueattendant149.bookreader.shared.ReaderTheme +import org.dueattendant149.bookreader.shared.reader.ReaderEngine +import org.dueattendant149.bookreader.shared.reader.ReaderImageReference +import org.dueattendant149.bookreader.shared.reader.ReaderLinkTarget +import org.dueattendant149.bookreader.shared.reader.ReaderPage +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.ReaderSessionState +import org.dueattendant149.bookreader.shared.reader.ReaderViewportSpec +import org.dueattendant149.bookreader.shared.reader.SharedEpubPaginationCache +import org.dueattendant149.bookreader.shared.reader.SharedMeasuredEpubPaginator +import org.dueattendant149.bookreader.shared.reader.isRightToLeftPaginationEnabled +import org.dueattendant149.bookreader.shared.reader.layoutSignature +import org.dueattendant149.bookreader.shared.reduce +import org.dueattendant149.bookreader.shared.ui.DesktopEpubNativeImage +import org.dueattendant149.bookreader.shared.ui.ReaderContentRenderPlan +import org.dueattendant149.bookreader.shared.ui.SharedNativePaginatedReader +import org.dueattendant149.bookreader.shared.ui.SharedNativeReaderSelectionAction +import org.dueattendant149.bookreader.shared.ui.SharedNativeVerticalReader +import org.dueattendant149.bookreader.shared.ui.SharedReaderScreen +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.awt.EventQueue +import java.awt.Window +import java.awt.event.KeyEvent as AwtKeyEvent + +@Composable +internal fun DesktopReaderScreen( + session: ReaderSessionState, + readerEngine: ReaderEngine, + onSessionChange: (ReaderSessionState) -> Unit, + onReturnToLibrary: (() -> Unit)? = null, + onFullscreenChange: (Boolean) -> Unit = {}, + readerAwtWindow: Window? = null, + toolbarPreferences: ReaderToolbarPreferences, + onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit, + appThemeControls: (@Composable () -> Unit)? = null, + customReaderThemes: List, + onCustomReaderThemesChange: (List) -> Unit, + highlightPalette: ReaderHighlightPalette, + onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + ttsReplacementBookId: String?, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, + onPickCustomFont: () -> String?, + customFonts: List, + readerExtrasState: ReaderExtrasState, + aiByokSettings: ReaderAiByokSettings, + externalLookupAvailable: Boolean, + cloudTtsControlsAvailable: Boolean, + onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, + onAiAction: (ReaderAiFeature, String) -> Unit, + onAiResultDismiss: () -> Unit, + onCloudTtsToggle: (String, ReaderLocator?) -> Unit, + onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit, + onCloudTtsPauseResume: () -> Unit, + onCloudTtsStop: () -> Unit, + onCloudTtsClearCache: () -> Unit, + onCloudTtsVoiceChange: (String) -> Unit, + onOpenAiHub: (() -> Unit)? = null, + onDownloadReaderImage: (ReaderImageReference) -> Unit, + readerTextureDataUri: (String) -> String?, + readerCustomTextureIds: List, + onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?, + bottomChromeExtraContent: @Composable ColumnScope.() -> Unit = {}, + webViewRuntimeState: DesktopWebViewRuntimeState, + webViewNetworkAccessEnabled: Boolean, + epubPaginationCache: SharedEpubPaginationCache, + epubPaginationCacheGeneration: Int, + useDetachedChromeLayer: Boolean = true, + useDetachedPanelLayer: Boolean = true +) { + val clipboardManager = LocalClipboardManager.current + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + val paginationCacheWriteScope = rememberCoroutineScope() + val measuredPaginator = remember( + textMeasurer, + density, + session.reader.settings.fontFamily, + session.reader.settings.customFontPath, + epubPaginationCache, + paginationCacheWriteScope + ) { + SharedMeasuredEpubPaginator( + textMeasurer = textMeasurer, + density = density, + fontFamily = session.reader.settings.toDesktopReaderFontFamily(), + pageCache = epubPaginationCache, + cacheWriteScope = paginationCacheWriteScope + ) + } + LaunchedEffect(session.reader.book.id) { + logDesktopReaderOpenTrace { + "event=desktop_text_reader_screen_composed bookId=\"${session.reader.book.id.logPreview(120)}\" " + + "title=\"${session.reader.book.title.logPreview(120)}\" mode=${session.reader.settings.readingMode} " + + "chapters=${session.reader.book.chapters.size} pages=${session.reader.pages.size} " + + "currentPage=${session.reader.currentPageIndex + 1} " + + "textChars=${session.reader.book.chapters.sumOf { it.plainText.length }} " + + "htmlChars=${session.reader.book.chapters.sumOf { it.htmlContent.length }} " + + "semanticBlocks=${session.reader.book.chapters.sumOf { it.semanticBlocks.size }} " + + "bookmarks=${session.bookmarks.size} highlights=${session.highlights.size}" + } + } + var readerViewport by remember(session.reader.book.id) { mutableStateOf(ReaderViewportSpec(0, 0)) } + val paginationLayoutSignature = session.reader.settings.layoutSignature() + val paginationContentSignature = remember(session.reader.book) { + session.reader.book.desktopPaginationContentSignature() + } + val paginationDensitySignature = DesktopEpubPaginationDensity( + density = density.density, + fontScale = density.fontScale + ) + val measuredPaginationRequest = remember( + session.reader.book.id, + paginationContentSignature, + paginationLayoutSignature, + readerViewport, + paginationDensitySignature, + epubPaginationCacheGeneration + ) { + if (session.reader.settings.readingMode == ReaderReadingMode.PAGINATED && readerViewport.isSpecified) { + DesktopEpubPaginationRequest( + bookId = session.reader.book.id, + chapterSignature = paginationContentSignature, + layoutSignature = paginationLayoutSignature, + viewport = readerViewport, + density = paginationDensitySignature, + cacheGeneration = epubPaginationCacheGeneration + ) + } else { + null + } + } + var completedMeasuredPaginationRequest by remember(session.reader.book.id) { + mutableStateOf(null) + } + var completedMeasuredPaginationPages by remember(session.reader.book.id) { + mutableStateOf(emptyList()) + } + var warmMeasuredPaginationRequest by remember(session.reader.book.id) { + mutableStateOf(null) + } + var warmMeasuredPaginationPages by remember(session.reader.book.id) { + mutableStateOf(emptyList()) + } + var runningMeasuredPaginationRequest by remember(session.reader.book.id) { + mutableStateOf(null) + } + val measuredPaginationPagesApplied = desktopMeasuredPaginationReady( + request = measuredPaginationRequest, + completedRequest = completedMeasuredPaginationRequest, + currentPages = session.reader.pages, + measuredPages = completedMeasuredPaginationPages + ) + val warmMeasuredPaginationPagesApplied = desktopMeasuredPaginationReady( + request = measuredPaginationRequest, + completedRequest = warmMeasuredPaginationRequest, + currentPages = session.reader.pages, + measuredPages = warmMeasuredPaginationPages + ) + val paginatedLayoutReady = desktopPaginatedLayoutReadyForDisplay( + readingMode = session.reader.settings.readingMode, + measuredPagesApplied = measuredPaginationPagesApplied + ) + val latestSession by rememberUpdatedState(session) + val latestOnSessionChange by rememberUpdatedState(onSessionChange) + var externalLinkDialogUrl by remember { mutableStateOf(null) } + var lastHandledLink by remember { mutableStateOf(null) } + var isFullscreen by remember(session.reader.book.id) { mutableStateOf(false) } + val desktopReaderExtrasState = readerExtrasState.copy(autoScroll = ReaderAutoScrollState()) + val currentReaderFullscreen by rememberUpdatedState(isFullscreen) + val currentOnReaderFullscreenChange by rememberUpdatedState(onFullscreenChange) + + fun setReaderFullscreen(enabled: Boolean) { + isFullscreen = enabled + onFullscreenChange(enabled) + } + + DesktopExternalLinkDialog( + url = externalLinkDialogUrl, + onDismiss = { externalLinkDialogUrl = null } + ) + + fun handleReaderAwtKeyEvent(event: AwtKeyEvent): Boolean { + val currentSession = latestSession + val action = event.desktopReaderKeyNavigationOrNull( + fullscreen = isFullscreen, + rightToLeftPagination = currentSession.reader.settings.isRightToLeftPaginationEnabled() + ) ?: return false + val nextSession = currentSession.reduceDesktopReaderKeyNavigation(action, readerEngine) + if (nextSession == null) { + if (action == DesktopReaderKeyNavigation.EXIT_FULLSCREEN && isFullscreen) { + setReaderFullscreen(false) + } + } else { + latestOnSessionChange(nextSession) + } + return true + } + + fun handleReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean { + if (latestSession.isSearchActive) { + if (event.id == AwtKeyEvent.KEY_PRESSED && isFullscreen && event.keyCode == AwtKeyEvent.VK_ESCAPE) { + setReaderFullscreen(false) + return true + } + return false + } + return handleReaderAwtKeyEvent(event) + } + + fun handleReaderGlobalShortcutAwtKeyEvent(event: AwtKeyEvent): Boolean { + if (event.id != AwtKeyEvent.KEY_PRESSED || !event.isControlDown) return false + val action = when (event.keyCode) { + AwtKeyEvent.VK_F -> DesktopReaderKeyNavigation.SEARCH + AwtKeyEvent.VK_G -> DesktopReaderKeyNavigation.NEXT_SEARCH + else -> return false + } + val nextSession = latestSession.reduceDesktopReaderKeyNavigation(action, readerEngine) ?: return false + latestOnSessionChange(nextSession) + return true + } + + DesktopReaderKeyDispatcherEffect( + enabled = externalLinkDialogUrl == null, + allowChromeModalWindows = true, + onKeyPressed = { event -> handleReaderGlobalShortcutAwtKeyEvent(event) } + ) + + DesktopReaderKeyDispatcherEffect( + enabled = externalLinkDialogUrl == null && !session.isSearchActive, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = false, + onKeyPressed = { event -> handleReaderAwtKeyEvent(event) } + ) + + DesktopReaderFullscreenKeyEffect( + enabled = isFullscreen && externalLinkDialogUrl == null, + onKeyPressed = { event -> handleReaderFullscreenAwtKeyEvent(event) } + ) + + LaunchedEffect(session.reader.settings.readingMode) { + if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) { + completedMeasuredPaginationRequest = null + completedMeasuredPaginationPages = emptyList() + warmMeasuredPaginationRequest = null + warmMeasuredPaginationPages = emptyList() + runningMeasuredPaginationRequest = null + } + } + + DisposableEffect(session.reader.book.id) { + onDispose { + if (currentReaderFullscreen) { + currentOnReaderFullscreenChange(false) + } + } + } + + LaunchedEffect( + measuredPaginationRequest, + measuredPaginator + ) { + val request = measuredPaginationRequest ?: return@LaunchedEffect + if (completedMeasuredPaginationRequest == request) { + logEpubPagination( + "reflow_skip reason=request_already_measured book=\"${session.reader.book.title.logPreview()}\" " + + "viewport=${request.viewport.widthPx}x${request.viewport.heightPx}" + ) + return@LaunchedEffect + } + runningMeasuredPaginationRequest = request + try { + val cacheProbeStartedAt = System.nanoTime() + val cacheProbeSettings = latestSession.reader.settings + val cachedPages = if ( + cacheProbeSettings.readingMode == ReaderReadingMode.PAGINATED && + cacheProbeSettings.layoutSignature() == request.layoutSignature + ) { + withContext(Dispatchers.Default) { + epubPaginationCache.loadMemory( + book = session.reader.book, + settings = cacheProbeSettings, + viewport = request.viewport, + density = request.density.density, + fontScale = request.density.fontScale + ) + } + } else { + null + } + val settingsAfterCacheProbe = latestSession.reader.settings + if (settingsAfterCacheProbe.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect + if (settingsAfterCacheProbe.layoutSignature() != request.layoutSignature) return@LaunchedEffect + if (cachedPages != null) { + val cacheLayoutChanged = !latestSession.reader.pages.samePageLayoutAs(cachedPages) + logEpubPagination( + "cache_warm_result book=\"${session.reader.book.title.logPreview()}\" pages=${cachedPages.size} " + + "layoutChanged=$cacheLayoutChanged viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "elapsedMs=${cacheProbeStartedAt.elapsedMillis()}" + ) + if (cacheLayoutChanged) { + val cacheApplySession = latestSession + latestOnSessionChange( + readerEngine.replacePages( + state = cacheApplySession, + pages = cachedPages, + reflowAnchor = readerEngine.reflowAnchorFor(cacheApplySession) + ) + ) + } + completedMeasuredPaginationPages = cachedPages + completedMeasuredPaginationRequest = request + return@LaunchedEffect + } + + val warmStartSession = latestSession + val warmAnchor = readerEngine.reflowAnchorFor(warmStartSession) + val warmChapterIndex = warmAnchor?.chapterIndex + ?: warmStartSession.reader.currentPage?.chapterIndex + ?: 0 + val warmFirstPageIndex = warmStartSession.reader.pages.firstPageIndexForChapter(warmChapterIndex) ?: 0 + val warmStartedAt = System.nanoTime() + logEpubPagination( + "chapter_warm_start book=\"${session.reader.book.title.logPreview()}\" chapter=$warmChapterIndex " + + "firstPage=${warmFirstPageIndex + 1} viewport=${request.viewport.widthPx}x${request.viewport.heightPx}" + ) + val cachedWarmChapterPages = epubPaginationCache.loadChapter( + book = session.reader.book, + settings = settingsAfterCacheProbe, + viewport = request.viewport, + chapterIndex = warmChapterIndex, + density = request.density.density, + fontScale = request.density.fontScale + ) + val warmChapterPages = cachedWarmChapterPages ?: withContext(Dispatchers.Default) { + measuredPaginator.paginateChapterWindow( + book = session.reader.book, + settings = settingsAfterCacheProbe, + viewport = request.viewport, + chapterIndex = warmChapterIndex, + firstPageIndex = warmFirstPageIndex + ) + } + val warmPages = desktopPagesWithMeasuredChapter( + currentPages = warmStartSession.reader.pages, + chapterIndex = warmChapterIndex, + measuredChapterPages = warmChapterPages + ) + val warmLayoutChanged = warmPages.isNotEmpty() && !warmStartSession.reader.pages.samePageLayoutAs(warmPages) + logEpubPagination( + "chapter_warm_result book=\"${session.reader.book.title.logPreview()}\" chapter=$warmChapterIndex " + + "source=${if (cachedWarmChapterPages != null) "cache" else "measured"} " + + "chapterPages=${warmChapterPages.size} pages=${warmPages.size} layoutChanged=$warmLayoutChanged " + + "elapsedMs=${warmStartedAt.elapsedMillis()}" + ) + if (warmLayoutChanged) { + logReaderModeSwitch( + "pagination_warm_apply_dispatch requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "chapter=$warmChapterIndex chapterPages=${warmChapterPages.size} currentPages=${warmStartSession.reader.pages.size}" + ) + latestOnSessionChange( + readerEngine.replacePages( + state = warmStartSession, + pages = warmPages, + reflowAnchor = warmAnchor + ) + ) + warmMeasuredPaginationPages = warmPages + warmMeasuredPaginationRequest = request + } + + val reflowStartSession = latestSession + val reflowStartRequestId = reflowStartSession.navigationRequestId + val reflowAnchor = readerEngine.reflowAnchorFor(reflowStartSession) + val settings = reflowStartSession.reader.settings + if (settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect + if (settings.layoutSignature() != request.layoutSignature) return@LaunchedEffect + logEpubPagination( + "reflow_start book=\"${session.reader.book.title.logPreview()}\" " + + "viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "spread=${settings.pageSpreadMode} font=${settings.fontSize} lineSpacing=${settings.lineSpacing} " + + "margins=${settings.resolvedHorizontalMargin}x${settings.resolvedVerticalMargin} " + + "pageWidthSetting=${settings.pageWidth} oldPages=${reflowStartSession.reader.pages.size} " + + "anchorPage=${reflowAnchor?.pageIndex} anchorOffsets=${reflowAnchor?.startOffset}..${reflowAnchor?.endOffset}" + ) + val pages = withContext(Dispatchers.Default) { + measuredPaginator.paginate( + book = session.reader.book, + settings = settings, + viewport = request.viewport, + readCache = true + ) + } + val layoutChanged = pages.isNotEmpty() && !latestSession.reader.pages.samePageLayoutAs(pages) + logEpubPagination( + "reflow_result book=\"${session.reader.book.title.logPreview()}\" pages=${pages.size} " + + "layoutChanged=$layoutChanged currentPages=${latestSession.reader.pages.size}" + ) + val currentVisiblePageDetails = latestSession.reader.visiblePages.map { page -> + "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" + } + val measuredCurrentPageDetails = pages.getOrNull(latestSession.reader.currentPageIndex) + ?.let { page -> "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" } + ?: "none" + logReaderModeSwitch( + "pagination_result requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "measuredPages=${pages.size} currentPages=${latestSession.reader.pages.size} layoutChanged=$layoutChanged " + + "currentVisible=$currentVisiblePageDetails measuredAtCurrent=$measuredCurrentPageDetails" + ) + if (layoutChanged) { + logReaderModeSwitch( + "pagination_apply_dispatch requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "measuredPages=${pages.size} currentPages=${latestSession.reader.pages.size}" + ) + latestOnSessionChange( + readerEngine.replacePages( + state = latestSession, + pages = pages, + reflowAnchor = reflowAnchor, + navigationRequestIdAtReflowStart = reflowStartRequestId + ) + ) + } + if (pages.isNotEmpty()) { + completedMeasuredPaginationPages = pages + completedMeasuredPaginationRequest = request + } + } catch (error: Throwable) { + if (error is CancellationException) throw error + logEpubPagination( + "reflow_failed book=\"${session.reader.book.title.logPreview()}\" " + + "viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "error=\"${error.message.orEmpty().logPreview(300)}\"" + ) + } finally { + if (runningMeasuredPaginationRequest == request) { + runningMeasuredPaginationRequest = null + } + } + } + + LaunchedEffect( + measuredPaginationRequest, + completedMeasuredPaginationRequest, + completedMeasuredPaginationPages, + session.reader.pages + ) { + val request = measuredPaginationRequest ?: return@LaunchedEffect + val measuredPages = completedMeasuredPaginationPages + if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect + if (completedMeasuredPaginationRequest != request || measuredPages.isEmpty()) return@LaunchedEffect + if (session.reader.pages.samePageLayoutAs(measuredPages)) return@LaunchedEffect + val currentVisiblePageDetails = session.reader.visiblePages.map { page -> + "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" + } + logReaderModeSwitch( + "pagination_apply_pending requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "currentPages=${session.reader.pages.size} measuredPages=${measuredPages.size} " + + "currentVisible=$currentVisiblePageDetails" + ) + onSessionChange( + readerEngine.replacePages( + state = session, + pages = measuredPages, + reflowAnchor = readerEngine.reflowAnchorFor(session) + ) + ) + } + + val handleDesktopSelectionAction: (DesktopReaderSelectionAction, String, ReaderLocator?) -> Unit = { action, text, locator -> + val settings = aiByokSettings.sanitized() + when (action) { + DesktopReaderSelectionAction.DEFINE -> { + if (settings.areReaderAiFeaturesAvailable) onAiAction(ReaderAiFeature.DEFINE, text) + } + DesktopReaderSelectionAction.SPEAK -> { + if (settings.isCloudTtsAvailable) onCloudTtsToggle(text, locator) + } + DesktopReaderSelectionAction.SEARCH -> onExternalLookup(ReaderExternalLookupAction.SEARCH, text) + DesktopReaderSelectionAction.PALETTE -> Unit + } + } + val nativeSelectionActions = buildSet { + val settings = aiByokSettings.sanitized() + if (settings.areReaderAiFeaturesAvailable) add(SharedNativeReaderSelectionAction.DEFINE) + if (externalLookupAvailable) add(SharedNativeReaderSelectionAction.SEARCH) + if (settings.isCloudTtsAvailable) add(SharedNativeReaderSelectionAction.SPEAK) + } + val handleNativeSelectionAction: (SharedNativeReaderSelectionAction, String, ReaderLocator?) -> Unit = { action, text, locator -> + when (action) { + SharedNativeReaderSelectionAction.DEFINE -> + handleDesktopSelectionAction(DesktopReaderSelectionAction.DEFINE, text, locator) + SharedNativeReaderSelectionAction.SPEAK -> + handleDesktopSelectionAction(DesktopReaderSelectionAction.SPEAK, text, locator) + SharedNativeReaderSelectionAction.SEARCH -> + handleDesktopSelectionAction(DesktopReaderSelectionAction.SEARCH, text, locator) + } + } + val handleDesktopEpubLinkClicked: (DesktopEpubLinkClick) -> Unit = { link -> + val now = System.currentTimeMillis() + val last = lastHandledLink + if (last != null && last.href == link.href && now - last.handledAtMs < 900L) { + logEpubLink( + "click_duplicate_ignored source=${link.source} href=\"${link.href.logPreview()}\" " + + "ageMs=${now - last.handledAtMs}" + ) + } else { + lastHandledLink = DesktopEpubHandledLink(link.href, now) + logEpubLink( + "click source=${link.source} href=\"${link.href.logPreview()}\" " + + "chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\" " + + "text=\"${link.text.orEmpty().logPreview()}\"" + ) + when (val target = readerEngine.resolveLink(session, link.href, link.chapterIndex)) { + is ReaderLinkTarget.External -> { + logEpubLink("resolved_external url=\"${target.url.logPreview()}\"") + if (externalLookupAvailable) { + externalLinkDialogUrl = target.url + } + } + is ReaderLinkTarget.Internal -> { + logEpubLink( + "resolved_internal chapter=${target.locator.chapterIndex} " + + "page=${target.locator.pageIndex} offset=${target.locator.startOffset}" + ) + onSessionChange(readerEngine.jumpToLocator(session, target.locator)) + } + ReaderLinkTarget.Ignored -> { + logEpubLink("resolved_ignored href=\"${link.href.logPreview()}\"") + } + } + } + } + + SharedReaderScreen( + session = session, + readerEngine = readerEngine, + onSessionChange = onSessionChange, + onReturnToLibrary = onReturnToLibrary, + isFullscreen = isFullscreen, + onFullscreenChange = ::setReaderFullscreen, + toolbarPreferences = toolbarPreferences, + onToolbarPreferencesChange = onToolbarPreferencesChange, + appThemeControls = appThemeControls, + customReaderThemes = customReaderThemes, + onCustomReaderThemesChange = onCustomReaderThemesChange, + highlightPalette = highlightPalette, + onHighlightPaletteChange = onHighlightPaletteChange, + ttsReplacementPreferences = ttsReplacementPreferences, + ttsReplacementBookId = ttsReplacementBookId, + onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange, + onPickCustomFont = onPickCustomFont, + customFonts = customFonts, + readerExtrasState = desktopReaderExtrasState, + aiByokSettings = aiByokSettings, + externalLookupAvailable = externalLookupAvailable, + cloudTtsControlsAvailable = cloudTtsControlsAvailable, + onExternalLookup = onExternalLookup, + onAiAction = onAiAction, + onAiResultDismiss = onAiResultDismiss, + onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) }, + onCloudTtsStart = onCloudTtsStart, + onCloudTtsPauseResume = onCloudTtsPauseResume, + onCloudTtsStop = onCloudTtsStop, + onCloudTtsClearCache = onCloudTtsClearCache, + onCloudTtsVoiceChange = onCloudTtsVoiceChange, + onOpenAiHub = onOpenAiHub, + onDownloadReaderImage = onDownloadReaderImage, + readerImagePreviewContent = { image, previewModifier -> + DesktopEpubNativeImage( + image = image.toDesktopPreviewSemanticImage(), + modifier = previewModifier.clip(RoundedCornerShape(3.dp)) + ) + }, + readerTextureDataUri = readerTextureDataUri, + readerTexturePreviewContent = { textureId, previewModifier -> + DesktopReaderTexturePreview(textureId = textureId, modifier = previewModifier) + }, + readerCustomTextureIds = readerCustomTextureIds, + onImportReaderTexture = onImportReaderTexture, + preferNativeVerticalReader = desktopShouldUseNativeVerticalEpubReader(), + bottomChromeExtraContent = bottomChromeExtraContent, + useDetachedChromeLayer = useDetachedChromeLayer, + useDetachedPanelLayer = useDetachedPanelLayer + ) { renderPlan, onVisiblePageChanged, onHighlightSelected, onOpenHighlightPaletteManager, onChromeActivity -> + val renderPlanModeKey = renderPlan.desktopReaderSurfaceModeKey() + val readerSurfaceKey = renderPlan.desktopReaderSurfaceContentKey(paginatedLayoutReady) + val readerModeSwitchLayoutModifier = + if (renderPlan.desktopReaderUsesNativeComposeSurface()) { + Modifier.onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + logReaderModeSwitch( + "native_surface_layout modeKey=$renderPlanModeKey surfaceKey=$readerSurfaceKey " + + "paginatedReady=$paginatedLayoutReady size=${coordinates.size.width}x${coordinates.size.height} " + + "windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " + + "${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()}" + ) + } + } else { + Modifier + } + val readerSurfaceModifier = Modifier + .fillMaxWidth() + .weight(1f) + .onSizeChanged { size -> + val next = ReaderViewportSpec(size.width, size.height) + logReaderGap( + "desktop_epub_reader_surface size=${size.width}x${size.height} " + + "mode=${session.reader.settings.readingMode} " + + "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}" + ) + logEpubCutoff( + "cutoff_probe layer=desktop_surface size=${size.width}x${size.height} " + + "mode=${session.reader.settings.readingMode} spread=${session.reader.settings.pageSpreadMode} " + + "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)} " + + "margins=${session.reader.settings.resolvedHorizontalMargin}x${session.reader.settings.resolvedVerticalMargin} " + + "pageWidthSetting=${session.reader.settings.pageWidth}" + ) + logWebViewLayoutDiag( + "compose_reader_surface size=${size.width}x${size.height} " + + "renderPlan=${if (renderPlan is ReaderContentRenderPlan.WebDocument) "web" else "native"} " + + "mode=${session.reader.settings.readingMode} " + + "fullscreen=$isFullscreen margins=${session.reader.settings.resolvedHorizontalMargin}x${session.reader.settings.resolvedVerticalMargin} " + + "pageWidth=${session.reader.settings.pageWidth} fontSize=${session.reader.settings.fontSize} " + + "lineSpacing=${session.reader.settings.lineSpacing} textAlign=${session.reader.settings.textAlign} " + + "paragraphSpacing=${session.reader.settings.paragraphSpacing} imageScale=${session.reader.settings.imageScale}" + ) + logDesktopReaderOpenTrace { + "event=desktop_reader_surface_size bookId=\"${session.reader.book.id.logPreview(120)}\" " + + "title=\"${session.reader.book.title.logPreview(120)}\" size=${size.width}x${size.height} " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} mode=${session.reader.settings.readingMode} " + + "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}" + } + if (next != readerViewport) { + logEpubPagination( + "viewport_changed width=${next.widthPx} height=${next.heightPx} " + + "previous=${readerViewport.widthPx}x${readerViewport.heightPx}" + ) + readerViewport = next + } + } + LaunchedEffect( + renderPlanModeKey, + session.reader.settings.readingMode, + paginatedLayoutReady + ) { + logReaderModeSwitch( + "surface_state modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " + + "paginatedReady=$paginatedLayoutReady runningPagination=${runningMeasuredPaginationRequest != null} " + + "completedPagination=${completedMeasuredPaginationRequest != null} measuredApplied=$measuredPaginationPagesApplied " + + "warmApplied=$warmMeasuredPaginationPagesApplied warmPageCount=${warmMeasuredPaginationPages.size} " + + "completedMatchesRequest=${completedMeasuredPaginationRequest == measuredPaginationRequest} " + + "measuredPageCount=${completedMeasuredPaginationPages.size} " + + "currentPage=${session.reader.currentPageIndex + 1} " + + "pageCount=${session.reader.pages.size} visiblePages=${session.reader.visiblePages.map { it.pageIndex + 1 }} " + + "fullscreen=$isFullscreen surfaceKey=$readerSurfaceKey" + ) + logDesktopReaderOpenTrace { + "event=desktop_render_plan_ready bookId=\"${session.reader.book.id.logPreview(120)}\" " + + "title=\"${session.reader.book.title.logPreview(120)}\" " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} mode=${session.reader.settings.readingMode} " + + "viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " + + "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)} " + + "htmlChars=${(renderPlan as? ReaderContentRenderPlan.WebDocument)?.html?.length ?: 0} " + + "paginatedReady=$paginatedLayoutReady" + } + if (renderPlan.desktopReaderUsesNativeComposeSurface()) { + cleanupRetiredDesktopWebView2InteropHosts( + readerAwtWindow, + "native_surface_state_ready_$paginatedLayoutReady" + ) + logDesktopWebView2ModeSwitchSnapshot("surface_state_${renderPlanModeKey}_after_sweep_request") + readerAwtWindow.requestDesktopReaderModeSwitchRepaint( + "native_surface_state_ready_$paginatedLayoutReady" + ) + DesktopReaderModeSwitchProbeDelaysMillis.forEach { delayMillis -> + delay(delayMillis) + cleanupRetiredDesktopWebView2InteropHosts( + readerAwtWindow, + "native_probe_after_${delayMillis}ms_ready_$paginatedLayoutReady" + ) + readerAwtWindow.requestDesktopReaderModeSwitchRepaint( + "native_probe_after_${delayMillis}ms_ready_$paginatedLayoutReady" + ) + logReaderModeSwitch( + "native_probe_after delayMs=$delayMillis modeKey=$renderPlanModeKey " + + "viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " + + "paginatedReady=$paginatedLayoutReady currentPage=${session.reader.currentPageIndex + 1} " + + "visiblePages=${session.reader.visiblePages.map { it.pageIndex + 1 }}" + ) + logDesktopWebView2ModeSwitchSnapshot("native_probe_after_${delayMillis}ms") + } + } else { + logDesktopWebView2ModeSwitchSnapshot("surface_state_$renderPlanModeKey") + } + } + DisposableEffect(renderPlanModeKey) { + logReaderModeSwitch( + "surface_enter modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()}" + ) + logDesktopWebView2ModeSwitchSnapshot("surface_enter_$renderPlanModeKey") + onDispose { + logReaderModeSwitch( + "surface_exit modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()}" + ) + logDesktopWebView2ModeSwitchSnapshot("surface_exit_$renderPlanModeKey") + } + } + @Composable + fun ReaderSurfaceContent() { + if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages && !paginatedLayoutReady) { + DesktopEpubPaginationPreparing( + active = runningMeasuredPaginationRequest != null, + modifier = Modifier.fillMaxSize() + ) + } else { + when (renderPlan) { + is ReaderContentRenderPlan.WebDocument -> { + val canRenderWebDocument = desktopEpubWebViewCanRender(webViewRuntimeState) + LaunchedEffect( + renderPlan.html, + canRenderWebDocument, + webViewRuntimeState, + webViewNetworkAccessEnabled + ) { + logDesktopWebView2( + "reader_screen_web_document canRender=$canRenderWebDocument " + + "backend=${desktopEpubWebViewBackend().logName} " + + "runtimeInitialized=${webViewRuntimeState.initialized} restart=${webViewRuntimeState.restartRequired} " + + "error=${webViewRuntimeState.errorMessage != null} network=$webViewNetworkAccessEnabled " + + "htmlChars=${renderPlan.html.length} htmlHash=${renderPlan.html.hashCode()}" + ) + } + if (canRenderWebDocument) { + DesktopEpubWebView( + html = renderPlan.html, + appearanceScript = renderPlan.appearanceScript, + highlightPaletteScript = renderPlan.highlightPaletteScript, + navigationTarget = renderPlan.navigationTarget, + highlights = renderPlan.highlights, + onHighlightCreated = { highlight -> + logEpubHighlightFlow( + "state_reduce_start id=${highlight.id} before=${session.highlights.size} " + + "color=${highlight.color.id} chapter=${highlight.chapterIndex} " + + "offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + + "page=${highlight.locator.pageIndex} textChars=${highlight.text.length}" + ) + val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine) + logEpubHighlightFlow( + "state_reduce_done id=${highlight.id} after=${nextSession.highlights.size} " + + "contains=${nextSession.highlights.any { it.id == highlight.id }}" + ) + onSessionChange(nextSession) + }, + onHighlightSelected = onHighlightSelected, + isFullscreen = isFullscreen, + onKeyboardNavigation = { action -> + val nextSession = session.reduceDesktopReaderKeyNavigation(action, readerEngine) + if (nextSession == null) { + if (action == DesktopReaderKeyNavigation.EXIT_FULLSCREEN && isFullscreen) { + setReaderFullscreen(false) + } + } else { + onSessionChange(nextSession) + } + }, + onSelectionAction = { payload -> + if (payload.action == DesktopReaderSelectionAction.PALETTE) { + onOpenHighlightPaletteManager() + } else { + handleDesktopSelectionAction(payload.action, payload.text, payload.locator) + } + }, + onLinkClicked = handleDesktopEpubLinkClicked, + onVisiblePageChanged = onVisiblePageChanged, + onPointerActivity = onChromeActivity, + networkAccessEnabled = webViewNetworkAccessEnabled, + backgroundColor = renderPlan.background, + modifier = Modifier.fillMaxSize() + ) + } else { + DesktopWebViewRuntimeIndicator( + state = webViewRuntimeState, + modifier = Modifier.fillMaxSize() + ) + } + } + is ReaderContentRenderPlan.NativePaginatedPages -> { + LaunchedEffect(renderPlan.visiblePages, paginatedLayoutReady) { + val pageDetails = renderPlan.visiblePages.joinToString(prefix = "[", postfix = "]") { page -> + "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" + } + logReaderModeSwitch( + "native_reader_render paginatedReady=$paginatedLayoutReady " + + "visiblePages=${renderPlan.visiblePages.map { it.pageIndex + 1 }} " + + "pageDetails=$pageDetails " + + "background=${renderPlan.background} foreground=${renderPlan.foreground}" + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + logReaderModeSwitch( + "native_reader_content_layout size=${coordinates.size.width}x${coordinates.size.height} " + + "windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " + + "${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()} " + + "visiblePages=${renderPlan.visiblePages.map { it.pageIndex + 1 }}" + ) + } + ) { + SharedNativePaginatedReader( + renderPlan = renderPlan, + readerFontFamily = renderPlan.settings.toDesktopReaderFontFamily(), + searchHighlight = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f), + onVisiblePageChanged = onVisiblePageChanged, + enabledSelectionActions = nativeSelectionActions, + onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) }, + onSelectionAction = handleNativeSelectionAction, + onOpenHighlightPaletteManager = onOpenHighlightPaletteManager, + onHighlightCreated = { highlight -> + logDesktopHighlightMap( + "native_state_reduce_start id=${highlight.id.logPreview(80)} before=${session.highlights.size} " + + "color=${highlight.color.id} chapter=${highlight.chapterIndex} " + + "page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + + "block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " + + "textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview(160)}\"" + ) + val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine) + logDesktopHighlightMap( + "native_state_reduce_done id=${highlight.id.logPreview(80)} after=${nextSession.highlights.size} " + + "contains=${nextSession.highlights.any { it.id == highlight.id }}" + ) + onSessionChange(nextSession) + }, + onHighlightSelected = onHighlightSelected, + onLinkClicked = { link -> + handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick()) + }, + onReaderTap = onChromeActivity, + imageContent = { image, imageModifier -> + DesktopEpubNativeImage( + image = image, + modifier = imageModifier + ) + }, + modifier = Modifier.fillMaxSize() + ) + } + } + is ReaderContentRenderPlan.NativeVerticalPages -> { + LaunchedEffect(renderPlan.book.id, renderPlan.pages, renderPlan.currentPageIndex) { + logReaderModeSwitch( + "native_vertical_reader_render currentPage=${renderPlan.currentPageIndex + 1} " + + "pages=${renderPlan.pages.size} chapters=${renderPlan.book.chapters.size} " + + "semanticBlocks=${renderPlan.book.chapters.sumOf { it.semanticBlocks.size }} " + + "background=${renderPlan.background} foreground=${renderPlan.foreground}" + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + logReaderModeSwitch( + "native_vertical_reader_content_layout size=${coordinates.size.width}x${coordinates.size.height} " + + "windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " + + "${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()} " + + "currentPage=${renderPlan.currentPageIndex + 1}" + ) + } + ) { + SharedNativeVerticalReader( + renderPlan = renderPlan, + readerFontFamily = renderPlan.settings.toDesktopReaderFontFamily(), + searchHighlight = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f), + onVisiblePageChanged = onVisiblePageChanged, + enabledSelectionActions = nativeSelectionActions, + onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) }, + onSelectionAction = handleNativeSelectionAction, + onOpenHighlightPaletteManager = onOpenHighlightPaletteManager, + onHighlightCreated = { highlight -> + logDesktopHighlightMap( + "native_vertical_state_reduce_start id=${highlight.id.logPreview(80)} before=${session.highlights.size} " + + "color=${highlight.color.id} chapter=${highlight.chapterIndex} " + + "page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + + "block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " + + "textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview(160)}\"" + ) + val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine) + logDesktopHighlightMap( + "native_vertical_state_reduce_done id=${highlight.id.logPreview(80)} after=${nextSession.highlights.size} " + + "contains=${nextSession.highlights.any { it.id == highlight.id }}" + ) + onSessionChange(nextSession) + }, + onHighlightSelected = onHighlightSelected, + onLinkClicked = { link -> + handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick()) + }, + onReaderTap = onChromeActivity, + imageContent = { image, imageModifier -> + DesktopEpubNativeImage( + image = image, + modifier = imageModifier + ) + }, + modifier = Modifier.fillMaxSize() + ) + } + } + } + } + } + + key(readerSurfaceKey) { + if (renderPlan is ReaderContentRenderPlan.WebDocument) { + Box( + modifier = readerSurfaceModifier + .fillMaxSize() + .background(renderPlan.background) + ) { + ReaderSurfaceContent() + } + } else { + Surface( + color = renderPlan.background, + shape = RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp), + modifier = readerSurfaceModifier + .fillMaxSize() + .clip(RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .then(readerModeSwitchLayoutModifier) + ) { + ReaderSurfaceContent() + } + } + } + } +} + +private fun ReaderContentRenderPlan.desktopReaderSurfaceModeKey(): String { + return when (this) { + is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web" + is ReaderContentRenderPlan.NativePaginatedPages -> "desktop-reader-native" + is ReaderContentRenderPlan.NativeVerticalPages -> "desktop-reader-native-vertical" + } +} + +private fun ReaderContentRenderPlan.desktopReaderSurfaceModeLabel(): String { + return when (this) { + is ReaderContentRenderPlan.WebDocument -> "web" + is ReaderContentRenderPlan.NativePaginatedPages -> "native" + is ReaderContentRenderPlan.NativeVerticalPages -> "native-vertical" + } +} + +private fun ReaderContentRenderPlan.desktopReaderSurfaceContentKey(paginatedLayoutReady: Boolean): String { + return when (this) { + is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web" + is ReaderContentRenderPlan.NativePaginatedPages -> + "desktop-reader-native-${if (paginatedLayoutReady) "ready" else "preparing"}" + is ReaderContentRenderPlan.NativeVerticalPages -> "desktop-reader-native-vertical" + } +} + +private fun ReaderContentRenderPlan.desktopReaderUsesNativeComposeSurface(): Boolean { + return this is ReaderContentRenderPlan.NativePaginatedPages || + this is ReaderContentRenderPlan.NativeVerticalPages +} + +private fun Window?.requestDesktopReaderModeSwitchRepaint(reason: String) { + val targetWindow = this + EventQueue.invokeLater { + if (targetWindow == null) { + logReaderModeSwitch("awt_repaint_skip reason=$reason window=null") + return@invokeLater + } + if (!targetWindow.isDisplayable) { + logReaderModeSwitch( + "awt_repaint_skip reason=$reason window=${targetWindow.javaClass.simpleName} " + + "displayable=false visible=${targetWindow.isVisible} showing=${targetWindow.isShowing} " + + "size=${targetWindow.width}x${targetWindow.height}" + ) + return@invokeLater + } + targetWindow.invalidate() + targetWindow.validate() + targetWindow.repaint() + (targetWindow as? javax.swing.RootPaneContainer)?.contentPane?.let { contentPane -> + contentPane.invalidate() + contentPane.validate() + contentPane.repaint() + } + logReaderModeSwitch( + "awt_repaint reason=$reason window=${targetWindow.javaClass.simpleName} " + + "visible=${targetWindow.isVisible} displayable=${targetWindow.isDisplayable} " + + "showing=${targetWindow.isShowing} size=${targetWindow.width}x${targetWindow.height}" + ) + } +} + +private val DesktopReaderModeSwitchProbeDelaysMillis = longArrayOf(120L, 350L, 900L) + +private fun Long.elapsedMillis(): Long { + return ((System.nanoTime() - this) / 1_000_000L).coerceAtLeast(0L) +} + +private fun ReaderImageReference.toDesktopPreviewSemanticImage(): SemanticImage { + return SemanticImage( + path = source, + altText = altText, + intrinsicWidth = intrinsicWidth, + intrinsicHeight = intrinsicHeight, + style = CssStyle(), + elementId = null, + cfi = cfi, + blockIndex = blockIndex + ) +} + +private fun ReaderSessionState.reduceDesktopReaderKeyNavigation( + action: DesktopReaderKeyNavigation, + readerEngine: ReaderEngine +): ReaderSessionState? { + return when (action) { + DesktopReaderKeyNavigation.NEXT -> reduce(ReaderAction.NextPage, readerEngine) + DesktopReaderKeyNavigation.PREVIOUS -> reduce(ReaderAction.PreviousPage, readerEngine) + DesktopReaderKeyNavigation.FIRST -> reduce(ReaderAction.JumpToPage(0), readerEngine) + DesktopReaderKeyNavigation.LAST -> reduce(ReaderAction.JumpToPage(reader.pages.lastIndex), readerEngine) + DesktopReaderKeyNavigation.SEARCH -> reduce(ReaderAction.SearchOpened, readerEngine) + DesktopReaderKeyNavigation.NEXT_SEARCH -> reduce(ReaderAction.JumpToNextSearchResult, readerEngine) + DesktopReaderKeyNavigation.EXIT_FULLSCREEN -> null + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTexturePreview.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTexturePreview.kt new file mode 100644 index 0000000..6789d5c --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTexturePreview.kt @@ -0,0 +1,43 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight + +@Composable +internal fun DesktopReaderTexturePreview( + textureId: String, + modifier: Modifier = Modifier +) { + val bitmap = remember(textureId) { DesktopReaderTextures.imageBitmapFor(textureId) } + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + ) + } else { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Text( + "Aa", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTextures.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTextures.kt similarity index 92% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTextures.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTextures.kt index 90ea59a..143aefa 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTextures.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTextures.kt @@ -1,11 +1,11 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap -import com.aryan.reader.shared.ReaderTexture -import com.aryan.reader.shared.ReaderTextureFilePrefix -import com.aryan.reader.shared.ReaderTextureImportExtensions -import com.aryan.reader.shared.readerTextureMimeTypeForExtension +import org.dueattendant149.bookreader.shared.ReaderTexture +import org.dueattendant149.bookreader.shared.ReaderTextureFilePrefix +import org.dueattendant149.bookreader.shared.ReaderTextureImportExtensions +import org.dueattendant149.bookreader.shared.readerTextureMimeTypeForExtension import java.io.ByteArrayInputStream import java.io.File import java.util.Base64 diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypography.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypography.kt new file mode 100644 index 0000000..c1301d3 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypography.kt @@ -0,0 +1,105 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.Font as DesktopFont +import org.dueattendant149.bookreader.shared.AppFontPreference +import org.dueattendant149.bookreader.shared.AppFontPreferenceKind +import org.dueattendant149.bookreader.shared.CustomFontItem +import org.dueattendant149.bookreader.shared.reader.ReaderPage +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.detectFontVariant +import org.dueattendant149.bookreader.shared.familyFilenameSignature +import org.dueattendant149.bookreader.shared.supportsVariableWeightAxis +import java.io.File + +internal fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily { + customFontPath?.takeIf { it.isNotBlank() }?.let { path -> + val baseFile = File(path) + val signature = baseFile.nameWithoutExtension.familyFilenameSignature() + val siblings = baseFile.parentFile?.listFiles()?.filter { + it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") && + it.nameWithoutExtension.familyFilenameSignature() == signature + } ?: listOf(baseFile) + + val seenVariants = mutableSetOf() + val fontList = siblings.flatMap { sibling -> + try { + val variant = sibling.nameWithoutExtension.detectFontVariant() + val weights = if (sibling.nameWithoutExtension.supportsVariableWeightAxis()) { + variableDesktopReaderFontWeights + } else { + listOf(variant?.weight ?: androidx.compose.ui.text.font.FontWeight.Normal) + } + weights.mapNotNull { weight -> + val style = variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal + if (seenVariants.add("${weight.weight}|$style")) { + DesktopFont(sibling, weight, style) + } else { + null + } + } + } catch (e: Exception) { + emptyList() + } + } + if (fontList.isNotEmpty()) { + return FontFamily(fontList) + } + } + return fontFamily.toComposeFontFamily() +} + +private fun String.toComposeFontFamily(): FontFamily { + return when (this) { + "Serif" -> FontFamily.Serif + "Sans" -> FontFamily.SansSerif + "Mono" -> FontFamily.Monospace + else -> FontFamily.Default + } +} + +private val variableDesktopReaderFontWeights = listOf( + androidx.compose.ui.text.font.FontWeight.Thin, + androidx.compose.ui.text.font.FontWeight.ExtraLight, + androidx.compose.ui.text.font.FontWeight.Light, + androidx.compose.ui.text.font.FontWeight.Normal, + androidx.compose.ui.text.font.FontWeight.Medium, + androidx.compose.ui.text.font.FontWeight.SemiBold, + androidx.compose.ui.text.font.FontWeight.Bold, + androidx.compose.ui.text.font.FontWeight.ExtraBold, + androidx.compose.ui.text.font.FontWeight.Black +) + +internal fun List.samePageLayoutAs(other: List): Boolean { + if (size != other.size) return false + return indices.all { index -> + val left = this[index] + val right = other[index] + left.pageIndex == right.pageIndex && + left.chapterIndex == right.chapterIndex && + left.startOffset == right.startOffset && + left.endOffset == right.endOffset && + left.text.length == right.text.length && + left.semanticBlocks == right.semanticBlocks + } +} + +internal fun CustomFontItem.toDesktopPreviewFontFamily(): FontFamily? { + val file = File(path).takeIf { it.isFile } ?: return null + return runCatching { FontFamily(DesktopFont(file)) }.getOrNull() +} + +internal fun AppFontPreference.toDesktopAppFontFamily(customFonts: List): FontFamily? { + val sanitized = sanitized() + return when (sanitized.kind) { + AppFontPreferenceKind.SYSTEM -> null + AppFontPreferenceKind.SERIF -> FontFamily.Serif + AppFontPreferenceKind.SANS_SERIF -> FontFamily.SansSerif + AppFontPreferenceKind.MONOSPACE -> FontFamily.Monospace + AppFontPreferenceKind.CUSTOM -> { + val fontId = sanitized.customFontId ?: return null + customFonts.firstOrNull { it.id == fontId && !it.isDeleted } + ?.toDesktopPreviewFontFamily() + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderWindowState.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowState.kt similarity index 74% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderWindowState.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowState.kt index b1a874e..99ccd30 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderWindowState.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowState.kt @@ -1,19 +1,53 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.ReaderCloudTtsState -import com.aryan.reader.shared.ReaderExtrasState -import com.aryan.reader.shared.RecapResult -import com.aryan.reader.shared.SummarizationResult -import com.aryan.reader.shared.reader.ReaderSessionState +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.ReaderCloudTtsState +import org.dueattendant149.bookreader.shared.ReaderExtrasState +import org.dueattendant149.bookreader.shared.RecapResult +import org.dueattendant149.bookreader.shared.SummarizationResult +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSessionState import kotlinx.coroutines.Job +internal const val DesktopReaderWindowDefaultWidthDp = 1120f +internal const val DesktopReaderWindowDefaultHeightDp = 760f +internal val DesktopReaderWindowDefaultSize = DpSize( + DesktopReaderWindowDefaultWidthDp.dp, + DesktopReaderWindowDefaultHeightDp.dp +) + +internal fun DesktopWindowStateSnapshot.toReaderWindowPlacement(): WindowPlacement { + return when (placement) { + DesktopSavedWindowPlacement.FULLSCREEN -> WindowPlacement.Floating + else -> toWindowPlacement() + } +} + +internal fun DesktopWindowStateSnapshot.toPersistableReaderWindowSnapshot(): DesktopWindowStateSnapshot? { + if (placement == DesktopSavedWindowPlacement.FULLSCREEN) return null + return sanitized() +} + +internal fun shouldResetDesktopTextReaderWindowSurface( + previousMode: ReaderReadingMode, + currentMode: ReaderReadingMode, + usesNativeWebView: Boolean +): Boolean { + return usesNativeWebView && + previousMode == ReaderReadingMode.VERTICAL && + currentMode == ReaderReadingMode.PAGINATED +} + internal data class DesktopReaderWindowState( val id: String, val opening: DesktopReaderOpening, val content: DesktopReaderWindowContent = DesktopReaderWindowContent.Opening, val focusRequestId: Long = 0L, - val fullscreen: Boolean = false + val fullscreen: Boolean = false, + val surfaceResetId: Long = 0L ) { val bookId: String get() = opening.bookId @@ -57,7 +91,6 @@ internal sealed interface DesktopReaderWindowContent { val isSummaryLoading: Boolean = false, val isRecapLoading: Boolean = false, val recapProgressMessage: String? = null, - val showCloudTtsSettings: Boolean = false, val ttsJob: Job? = null ) : DesktopReaderWindowContent } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopStartupSplash.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupSplash.kt similarity index 99% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopStartupSplash.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupSplash.kt index 3e15c05..11c49d9 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopStartupSplash.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupSplash.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import java.awt.BorderLayout import java.awt.Color diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopStringResources.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResources.kt similarity index 98% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopStringResources.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResources.kt index 8dd48e4..6432e60 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopStringResources.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResources.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.ui.SharedStringResolver +import org.dueattendant149.bookreader.shared.ui.SharedStringResolver import org.w3c.dom.Element import java.io.InputStream import java.util.Locale diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopSummaryCacheStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStore.kt similarity index 98% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopSummaryCacheStore.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStore.kt index a8591e9..f3f741f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopSummaryCacheStore.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStore.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import java.io.File import java.security.MessageDigest diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLog.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLog.kt new file mode 100644 index 0000000..ddd623d --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLog.kt @@ -0,0 +1,40 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.ReaderTtsChunk + +private const val DesktopTtsLogTag = "EpistemeDesktopTts" +private const val DesktopTtsStartTraceLogTag = "EpistemeDesktopTtsStartTrace" +private val DesktopTtsSensitiveQueryRegex = Regex("""(?i)([?&](?:key|token)=)[^&\s"]+""") +private val DesktopTtsSensitiveLabelRegex = Regex( + """(?i)\b((?:geminiKey|groqKey|api[_-]?key|authorization|token)\s*[:=]\s*)[^\s,;"]+""" +) + +internal fun logDesktopTts(message: String) { + logDesktopDiagnostic(DesktopTtsLogTag) { message } +} + +internal fun logDesktopTtsStartTrace(message: () -> String) { + logDesktopDiagnostic(DesktopTtsStartTraceLogTag, message) +} + +internal fun ReaderTtsChunk?.desktopTtsStartTraceSummary(maxTextLength: Int = 120): String { + if (this == null) return "null" + return "index=$index page=${pageIndex + 1} chapter=$chapterIndex " + + "offsets=$startOffset..$endOffset sourceCfi=\"${sourceCfi.orEmpty().logPreview(160)}\" " + + "textChars=${text.length} spokenChars=${spokenText.length} " + + "text=\"${text.logPreview(maxTextLength)}\" spoken=\"${spokenText.logPreview(maxTextLength)}\"" +} + +internal fun Throwable.desktopTtsSummary(): String { + val type = this::class.java.simpleName.ifBlank { "Throwable" } + return "$type: ${message.orEmpty().desktopTtsPreview(220)}" +} + +internal fun String.desktopTtsPreview(maxLength: Int = 120): String { + return replace(DesktopTtsSensitiveQueryRegex) { match -> match.groupValues[1] + "" } + .replace(DesktopTtsSensitiveLabelRegex) { match -> match.groupValues[1] + "" } + .replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowPolish.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolish.kt similarity index 99% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowPolish.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolish.kt index a949aa0..50aac69 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowPolish.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolish.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowStateStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStore.kt similarity index 97% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowStateStore.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStore.kt index fc79d31..3b5408b 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowStateStore.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStore.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -151,5 +151,9 @@ internal class DesktopWindowStateStore( fun defaultWindowStateFile(): File { return File(desktopUserConfigRoot(), "window_state.json") } + + fun defaultReaderWindowStateFile(): File { + return File(desktopUserConfigRoot(), "reader_window_state.json") + } } } diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowsWebView2EpubWebView.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowsWebView2EpubWebView.kt new file mode 100644 index 0000000..56e2ce9 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowsWebView2EpubWebView.kt @@ -0,0 +1,1910 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.UserHighlight +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.ui.ReaderContentNavigationTarget +import org.dueattendant149.bookreader.shared.ui.readerString +import kotlinx.coroutines.delay +import org.eclipse.swt.SWT +import org.eclipse.swt.awt.SWT_AWT +import org.eclipse.swt.browser.Browser +import org.eclipse.swt.browser.BrowserFunction +import org.eclipse.swt.browser.LocationEvent +import org.eclipse.swt.browser.LocationListener +import org.eclipse.swt.browser.ProgressAdapter +import org.eclipse.swt.browser.ProgressEvent +import org.eclipse.swt.widgets.Display +import org.eclipse.swt.widgets.Shell +import java.awt.Canvas +import java.awt.EventQueue +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import javax.swing.SwingUtilities + +@Composable +internal fun DesktopNativeSwtEpubWebView( + html: String, + appearanceScript: String, + highlightPaletteScript: String, + navigationTarget: ReaderContentNavigationTarget, + highlights: List, + onHighlightCreated: (UserHighlight) -> Unit, + onHighlightSelected: (String) -> Unit, + isFullscreen: Boolean, + onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, + onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit, + onLinkClicked: (DesktopEpubLinkClick) -> Unit, + onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, + onPointerActivity: () -> Unit = {}, + networkAccessEnabled: Boolean, + backgroundColor: Color, + modifier: Modifier = Modifier +) { + val backend = remember { desktopEpubWebViewBackend() } + if (backend == DesktopEpubWebViewBackend.UNSUPPORTED) { + DesktopNativeWebViewError( + backend = backend, + message = desktopNativeWebViewUnavailableMessage(backend), + modifier = modifier.fillMaxSize() + ) + return + } + val latestOnLinkClicked by rememberUpdatedState(onLinkClicked) + val bridgeHandlers = rememberDesktopEpubBridgeHandlers( + onHighlightCreated = onHighlightCreated, + onHighlightSelected = onHighlightSelected, + onKeyboardNavigation = onKeyboardNavigation, + onSelectionAction = onSelectionAction, + onLinkClicked = onLinkClicked, + onVisiblePageChanged = onVisiblePageChanged, + onPointerActivity = onPointerActivity + ) + val bridgeHandlersByMethod = remember(bridgeHandlers) { + bridgeHandlers.associateBy { it.methodName } + } + val hostBackground = remember(backgroundColor) { backgroundColor.toAwtColor() } + val panel = remember { DesktopWindowsWebView2Panel(hostBackground, backend) } + val composeDensity = LocalDensity.current + var loaded by remember { mutableStateOf(false) } + var loadProgress by remember { mutableFloatStateOf(-1f) } + var errorMessage by remember { mutableStateOf(null) } + val webViewHtml = remember(html, networkAccessEnabled) { + html.withDesktopWebView2Bootstrap(networkAccessEnabled = networkAccessEnabled) + } + + DisposableEffect(panel) { + onDispose { + logDesktopWebView2("compose_dispose panel=${panel.instanceId}") + panel.disposeWebView(waitForSwtDisposal = true) + } + } + + LaunchedEffect(hostBackground) { + panel.updateBackground(hostBackground) + } + + Box( + modifier = modifier.fillMaxSize().onSizeChanged { size -> + logWebViewLayoutDiag( + "compose_webview_box panel=${panel.instanceId} size=${size.width}x${size.height} " + + "loaded=$loaded network=$networkAccessEnabled navMode=${navigationTarget.readingMode} " + + "composeDensity=${composeDensity.density.formatLogFloat()}" + ) + } + ) { + SwingPanel( + background = backgroundColor, + factory = { panel }, + update = { currentPanel -> + currentPanel.configure( + bridgeHandlersByMethod = bridgeHandlersByMethod, + networkAccessEnabled = networkAccessEnabled, + onLinkIntercepted = { link -> latestOnLinkClicked(link) }, + onLoadStateChanged = { isLoaded, progress -> + loaded = isLoaded + loadProgress = progress + }, + onError = { message -> + errorMessage = message + loaded = false + loadProgress = -1f + } + ) + }, + modifier = Modifier + .matchParentSize() + .onSizeChanged { size -> + logWebViewLayoutDiag( + "compose_swing_panel panel=${panel.instanceId} size=${size.width}x${size.height} " + + "loaded=$loaded composeDensity=${composeDensity.density.formatLogFloat()}" + ) + } + ) + + LaunchedEffect(webViewHtml) { + loaded = false + loadProgress = -1f + errorMessage = null + logDesktopWebView2( + "compose_load_request panel=${panel.instanceId} rawHtmlChars=${html.length} " + + "wrappedHtmlChars=${webViewHtml.length} rawHash=${html.hashCode()} wrappedHash=${webViewHtml.hashCode()} " + + "network=$networkAccessEnabled" + ) + logWebViewLayoutDiag( + "compose_load_request panel=${panel.instanceId} rawHtmlChars=${html.length} " + + "wrappedHtmlChars=${webViewHtml.length} navMode=${navigationTarget.readingMode} " + + "background=${backgroundColor.toArgb()}" + ) + panel.loadHtml(webViewHtml) + } + + LaunchedEffect(loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2("compose_loaded panel=${panel.instanceId} action=install_key_navigation") + panel.executeJavaScript(DesktopEpubKeyNavigationScript) + } + + LaunchedEffect(isFullscreen, loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2("compose_script panel=${panel.instanceId} name=fullscreen value=$isFullscreen") + panel.executeJavaScript( + "window.readerDesktopFullscreen = ${if (isFullscreen) "true" else "false"};" + + "window.dispatchEvent(new Event('resize'));" + ) + panel.relayoutWebView("fullscreen_state_changed") + DesktopWebView2FullscreenRelayoutDelaysMillis.forEach { delayMillis -> + delay(delayMillis) + panel.relayoutWebView("fullscreen_state_changed_after_${delayMillis}ms") + panel.executeJavaScript("window.dispatchEvent(new Event('resize'));") + } + } + + LaunchedEffect(html, loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2("compose_script panel=${panel.instanceId} name=desktop_finished") + panel.executeJavaScript("window.readerPaginationLayoutLog && window.readerPaginationLayoutLog('desktop_finished');") + } + + LaunchedEffect(appearanceScript, loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2( + "compose_script panel=${panel.instanceId} name=appearance chars=${appearanceScript.length} hash=${appearanceScript.hashCode()}" + ) + panel.executeJavaScript(appearanceScript + "\n" + desktopWebView2DocumentProbeScript("appearance_applied")) + } + + LaunchedEffect(highlightPaletteScript, loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2( + "compose_script panel=${panel.instanceId} name=highlight_palette chars=${highlightPaletteScript.length} " + + "hash=${highlightPaletteScript.hashCode()}" + ) + panel.executeJavaScript(highlightPaletteScript) + } + + LaunchedEffect( + navigationTarget.requestId, + navigationTarget.readingMode, + loaded + ) { + if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect + if (!loaded) return@LaunchedEffect + val locator = navigationTarget.locator ?: return@LaunchedEffect + logDesktopWebView2( + "compose_script panel=${panel.instanceId} name=scroll_locator request=${navigationTarget.requestId} " + + "chapter=${locator.chapterIndex} page=${locator.pageIndex}" + ) + panel.executeJavaScript("window.readerScrollToLocator && window.readerScrollToLocator(${locator.toReaderLocatorJson()});") + } + + LaunchedEffect( + navigationTarget.ttsRequestId, + navigationTarget.ttsLocator, + navigationTarget.readingMode, + loaded + ) { + if (!loaded) return@LaunchedEffect + val locator = navigationTarget.ttsLocator + val command = if (locator == null) { + logDesktopTts( + "epub_highlight_command clear mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId}" + ) + "window.readerSetTtsLocator && window.readerSetTtsLocator(null, false);" + } else { + val follow = navigationTarget.readingMode == ReaderReadingMode.VERTICAL + logDesktopTts( + "epub_highlight_command set mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId} " + + "follow=$follow chapter=${locator.chapterIndex} page=${locator.pageIndex} " + + "offsets=${locator.startOffset}..${locator.endOffset} cfi=\"${locator.cfi.orEmpty().logPreview()}\" " + + "text=\"${locator.textQuote.orEmpty().logPreview()}\"" + ) + "window.readerSetTtsLocator && window.readerSetTtsLocator(${locator.toReaderLocatorJson()}, $follow);" + } + panel.executeJavaScript(command) + } + + LaunchedEffect(highlights, loaded) { + if (!loaded) return@LaunchedEffect + val highlightsJson = EpubAnnotationSerializer.highlightsToJson(highlights) + logDesktopWebView2( + "compose_script panel=${panel.instanceId} name=apply_highlights count=${highlights.size} chars=${highlightsJson.length}" + ) + panel.executeJavaScript("window.readerApplyHighlights && window.readerApplyHighlights($highlightsJson);") + } + + if (errorMessage != null) { + DesktopNativeWebViewError( + backend = backend, + message = errorMessage.orEmpty(), + modifier = Modifier.fillMaxSize() + ) + } else if (!loaded) { + if (loadProgress in 0f..1f) { + LinearProgressIndicator( + progress = { loadProgress }, + modifier = Modifier.fillMaxWidth() + ) + } else { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + } +} + +@Composable +private fun DesktopNativeWebViewError( + backend: DesktopEpubWebViewBackend, + message: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = readerString( + "desktop_native_webview_start_error", + "%1\$s could not start: %2\$s", + backend.displayName, + message + ), + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } +} + +private class DesktopWindowsWebView2Panel( + initialBackground: java.awt.Color, + private val backend: DesktopEpubWebViewBackend +) : Canvas() { + val instanceId: Int = nextDesktopWebView2InstanceId() + + @Volatile + private var bridgeHandlersByMethod: Map = emptyMap() + + @Volatile + private var networkAccessEnabled: Boolean = true + + @Volatile + private var onLinkIntercepted: (DesktopEpubLinkClick) -> Unit = {} + + @Volatile + private var onLoadStateChanged: (Boolean, Float) -> Unit = { _, _ -> } + + @Volatile + private var onError: (String) -> Unit = {} + + private var controller: DesktopWindowsWebView2Controller? = null + private var requestedHtml: String? = null + + @Volatile + private var lastLoadStartedAtNanos: Long = 0L + + val hasController: Boolean get() = controller != null + + @Volatile + private var disposeInProgress = false + + @Volatile + private var hostWindowClosing = false + + private var hostWindow: java.awt.Window? = null + private var hostWindowListener: WindowAdapter? = null + + init { + background = initialBackground + updateModeSwitchPanelState("init") + addComponentListener( + object : ComponentAdapter() { + override fun componentResized(event: ComponentEvent) { + logDesktopWebView2("panel_resized panel=$instanceId size=${width}x${height}") + logWebViewLayoutDiag( + "awt_canvas_resized panel=$instanceId size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()}" + ) + updateModeSwitchPanelState("component_resized") + controller?.resize(width, height, reason = "component_resized") + } + + override fun componentMoved(event: ComponentEvent) { + logWebViewLayoutDiag( + "awt_canvas_moved panel=$instanceId size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()}" + ) + updateModeSwitchPanelState("component_moved") + controller?.resize(width, height, reason = "component_moved") + } + + override fun componentShown(event: ComponentEvent) { + logWebViewLayoutDiag( + "awt_canvas_shown panel=$instanceId size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()}" + ) + updateModeSwitchPanelState("component_shown") + controller?.resize(width, height, reason = "component_shown") + } + } + ) + } + + fun relayoutWebView(reason: String) { + EventQueue.invokeLater { + updateModeSwitchPanelState("relayout_$reason") + logWebViewLayoutDiag( + "awt_canvas_relayout panel=$instanceId reason=$reason size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()} displayable=$isDisplayable" + ) + revalidate() + repaint() + controller?.resize(width, height, reason = reason) + } + } + + fun updateBackground(color: java.awt.Color) { + EventQueue.invokeLater { + if (background != color) { + background = color + repaint() + } + } + } + + fun configure( + bridgeHandlersByMethod: Map, + networkAccessEnabled: Boolean, + onLinkIntercepted: (DesktopEpubLinkClick) -> Unit, + onLoadStateChanged: (Boolean, Float) -> Unit, + onError: (String) -> Unit + ) { + updateHostWindowListener() + this.bridgeHandlersByMethod = bridgeHandlersByMethod + this.networkAccessEnabled = networkAccessEnabled + this.onLinkIntercepted = onLinkIntercepted + this.onLoadStateChanged = { isLoaded, progress -> + if (isLoaded) { + val startedAt = lastLoadStartedAtNanos + if (startedAt > 0L) { + logDesktopReaderOpenTrace { + "event=desktop_webview_panel_loaded panel=$instanceId " + + "durationMs=${startedAt.elapsedOpenTraceMs()} progress=$progress" + } + lastLoadStartedAtNanos = 0L + } + } + onLoadStateChanged(isLoaded, progress) + } + this.onError = { message -> + val startedAt = lastLoadStartedAtNanos + logDesktopReaderOpenTrace { + "event=desktop_webview_panel_error panel=$instanceId " + + "durationMs=${if (startedAt > 0L) startedAt.elapsedOpenTraceMs() else -1L} " + + "message=\"${message.logPreview(240)}\"" + } + lastLoadStartedAtNanos = 0L + onError(message) + } + logDesktopWebView2( + "panel_configure panel=$instanceId handlers=${bridgeHandlersByMethod.size} network=$networkAccessEnabled " + + "controller=${controller != null}" + ) + updateModeSwitchPanelState("configure") + } + + fun loadHtml(html: String) { + if (requestedHtml == html) { + logDesktopWebView2("panel_load_skip_duplicate panel=$instanceId htmlHash=${html.hashCode()}") + logDesktopReaderOpenTrace { + "event=desktop_webview_panel_load_skip_duplicate panel=$instanceId htmlHash=${html.hashCode()}" + } + return + } + lastLoadStartedAtNanos = System.nanoTime() + requestedHtml = html + logDesktopWebView2( + "panel_load_requested panel=$instanceId htmlChars=${html.length} htmlHash=${html.hashCode()} " + + "controller=${controller != null}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_panel_load_requested panel=$instanceId htmlChars=${html.length} " + + "htmlHash=${html.hashCode()} controller=${controller != null} canvas=${width}x${height}" + } + logWebViewLayoutDiag( + "panel_load_requested panel=$instanceId canvas=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} controller=${controller != null}" + ) + updateModeSwitchPanelState("load_requested") + ensureController(reason = "load_requested") + controller?.loadHtml(html) + } + + fun executeJavaScript(script: String) { + logDesktopWebView2( + "panel_execute panel=$instanceId scriptChars=${script.length} scriptHash=${script.hashCode()} controller=${controller != null}" + ) + controller?.executeJavaScript(script) + } + + fun disposeWebView( + waitForSwtDisposal: Boolean = false, + detachAwtCanvas: Boolean = true + ) { + if (disposeInProgress) { + logDesktopWebView2( + "panel_dispose_skip panel=$instanceId reason=in_progress controller=${controller != null}" + ) + updateModeSwitchPanelState("dispose_skip_in_progress") + return + } + disposeInProgress = true + logDesktopWebView2( + "panel_dispose panel=$instanceId controller=${controller != null} " + + "waitForSwtDisposal=$waitForSwtDisposal detachAwtCanvas=$detachAwtCanvas" + ) + try { + updateModeSwitchPanelState("dispose_begin") + if (detachAwtCanvas) { + retireAwtCanvasFromReaderSurface() + } + controller?.dispose(waitForCompletion = waitForSwtDisposal) + controller = null + updateModeSwitchPanelState("dispose_end") + } finally { + disposeInProgress = false + } + } + + override fun addNotify() { + super.addNotify() + updateHostWindowListener() + updateModeSwitchPanelState("add_notify") + logDesktopWebView2( + "panel_add_notify panel=$instanceId displayable=$isDisplayable showing=$isShowing " + + "canvas=${width}x${height} controller=${controller != null} hasHtml=${requestedHtml != null}" + ) + logWebViewLayoutDiag( + "awt_canvas_add_notify panel=$instanceId displayable=$isDisplayable showing=$isShowing " + + "size=${width}x${height} bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()} " + + "hasHtml=${requestedHtml != null}" + ) + ensureController(reason = "add_notify") + requestedHtml?.let { html -> controller?.loadHtml(html) } + controller?.resize(width, height, reason = "add_notify") + } + + override fun removeNotify() { + logDesktopWebView2("panel_remove_notify panel=$instanceId") + updateModeSwitchPanelState("remove_notify_begin") + updateHostWindowListener() + disposeWebView( + waitForSwtDisposal = true, + detachAwtCanvas = shouldRetireAwtCanvasFromReaderSurface() + ) + clearHostWindowListener() + super.removeNotify() + updateModeSwitchPanelState("remove_notify_end") + } + + private fun updateHostWindowListener() { + val window = SwingUtilities.getWindowAncestor(this) + if (hostWindow === window) return + clearHostWindowListener() + hostWindow = window + hostWindowClosing = false + if (window == null) return + val listener = object : WindowAdapter() { + override fun windowClosing(event: WindowEvent?) { + hostWindowClosing = true + logDesktopWebView2("panel_host_window_closing panel=$instanceId") + updateModeSwitchPanelState("host_window_closing") + } + + override fun windowClosed(event: WindowEvent?) { + hostWindowClosing = true + logDesktopWebView2("panel_host_window_closed panel=$instanceId") + updateModeSwitchPanelState("host_window_closed") + } + } + hostWindowListener = listener + window.addWindowListener(listener) + } + + private fun clearHostWindowListener() { + hostWindowListener?.let { listener -> + hostWindow?.removeWindowListener(listener) + } + hostWindowListener = null + hostWindow = null + } + + private fun shouldRetireAwtCanvasFromReaderSurface(): Boolean { + val window = hostWindow ?: SwingUtilities.getWindowAncestor(this) + val shouldRetire = desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing = hostWindowClosing, + hostWindowDisplayable = window?.isDisplayable == true + ) + if (!shouldRetire) { + logDesktopWebView2( + "panel_retire_skip panel=$instanceId reason=host_window_closing_or_disposed " + + "hostClosing=$hostWindowClosing host=${window.formatAwtComponentState()}" + ) + updateModeSwitchPanelState("retire_skip_host_window_closing_or_disposed") + } + return shouldRetire + } + + private fun retireAwtCanvasFromReaderSurface() { + if (!shouldRetireAwtCanvasFromReaderSurface()) return + runOnAwtEventThreadBlocking( + onError = { error -> + logDesktopWebView2( + "panel_retire_failed panel=$instanceId error=\"${error.message.orEmpty().logPreview(300)}\"" + ) + } + ) { + logWebViewLayoutDiag( + "awt_canvas_retire panel=$instanceId size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} displayable=$isDisplayable visible=$isVisible" + ) + updateModeSwitchPanelState("retire_begin") + val parentContainer = parent + val grandParent = parentContainer?.parent + logReaderModeSwitch( + "webview2_interop_retire_begin panel=$instanceId " + + "parent=${parentContainer.formatAwtComponentState()} grandParent=${grandParent.formatAwtComponentState()}" + ) + isVisible = false + setBounds(0, 0, 0, 0) + parentContainer?.isVisible = false + parentContainer?.setBounds(0, 0, 0, 0) + parentContainer?.revalidate() + parentContainer?.repaint() + grandParent?.revalidate() + grandParent?.repaint() + repaint() + scheduleRetiredInteropHostCleanup(parentContainer, grandParent, reason = "retire") + updateModeSwitchPanelState("retire_end") + logReaderModeSwitch( + "webview2_interop_retire_end panel=$instanceId " + + "parent=${parentContainer.formatAwtComponentState()} grandParent=${grandParent.formatAwtComponentState()}" + ) + } + } + + private fun scheduleRetiredInteropHostCleanup( + parentContainer: java.awt.Container?, + grandParent: java.awt.Container?, + reason: String + ) { + if (parentContainer == null || grandParent == null) return + EventQueue.invokeLater { + cleanupRetiredInteropHost(parentContainer, grandParent, "${reason}_next_event") + } + EventQueue.invokeLater { + DesktopWebView2InteropHostCleanupDelaysMillis.forEach { delayMillis -> + javax.swing.Timer(delayMillis.toInt()) { _ -> + cleanupRetiredInteropHost(parentContainer, grandParent, "${reason}_after_${delayMillis}ms") + }.apply { + isRepeats = false + start() + } + } + } + } + + private fun cleanupRetiredInteropHost( + parentContainer: java.awt.Container, + grandParent: java.awt.Container, + reason: String + ) { + val isInteropHost = parentContainer.javaClass.simpleName == DesktopSwingInteropHostClassName + val ownsOnlyRetiredPanel = parentContainer.components.all { component -> + component === this || !component.isDisplayable || !component.isShowing + } + if (!isInteropHost || !ownsOnlyRetiredPanel || parentContainer.parent !== grandParent) { + logReaderModeSwitch( + "webview2_interop_host_cleanup_skip panel=$instanceId reason=$reason " + + "isInteropHost=$isInteropHost ownsOnlyRetiredPanel=$ownsOnlyRetiredPanel " + + "parent=${parentContainer.formatAwtComponentState()} " + + "grandParent=${grandParent.formatAwtComponentState()} " + + "parentParent=${parentContainer.parent?.javaClass?.simpleName ?: "none"} " + + "children=${parentContainer.formatAwtChildrenState()}" + ) + return + } + logReaderModeSwitch( + "webview2_interop_host_cleanup_begin panel=$instanceId reason=$reason " + + "parent=${parentContainer.formatAwtComponentState()} " + + "grandParent=${grandParent.formatAwtComponentState()} " + + "children=${parentContainer.formatAwtChildrenState()}" + ) + if (parent === parentContainer) { + parentContainer.remove(this) + } + parentContainer.removeAll() + grandParent.remove(parentContainer) + parentContainer.invalidate() + grandParent.invalidate() + grandParent.validate() + grandParent.repaint() + updateModeSwitchPanelState("interop_host_cleanup_$reason") + logReaderModeSwitch( + "webview2_interop_host_cleanup_end panel=$instanceId reason=$reason " + + "parent=${parentContainer.formatAwtComponentState()} " + + "grandParent=${grandParent.formatAwtComponentState()} " + + "parentParent=${parentContainer.parent?.javaClass?.simpleName ?: "none"} " + + "grandParentChildren=${grandParent.componentCount}" + ) + } + + private fun updateModeSwitchPanelState(event: String) { + val snapshot = modeSwitchPanelSnapshot(event) + DesktopWebView2ModeSwitchPanelStates[instanceId] = snapshot + logReaderModeSwitch("webview2_panel $snapshot") + } + + fun modeSwitchPanelSnapshot(event: String): String { + val parentContainer = parent + val parentName = parentContainer?.javaClass?.simpleName ?: "none" + val parentDetails = parentContainer.formatAwtComponentState() + return "panel=$instanceId event=$event visible=$isVisible displayable=$isDisplayable " + + "showing=$isShowing size=${width}x${height} bounds=${bounds.formatAwtBounds()} " + + "parent=$parentName parentState=$parentDetails controller=${controller != null} hasHtml=${requestedHtml != null}" + } + + private fun ensureController(reason: String) { + if (controller != null) return + if (!isDisplayable) { + logDesktopWebView2("panel_controller_skip panel=$instanceId reason=$reason displayable=false") + return + } + logDesktopWebView2( + "panel_controller_create panel=$instanceId reason=$reason backend=${backend.logName} hasHtml=${requestedHtml != null}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_create panel=$instanceId reason=$reason " + + "backend=${backend.logName} hasHtml=${requestedHtml != null} canvas=${width}x${height}" + } + var createdController: DesktopWindowsWebView2Controller? = null + val newController = DesktopWindowsWebView2Controller( + instanceId = instanceId, + backend = backend, + canvas = this, + isNetworkAccessEnabled = { networkAccessEnabled }, + dispatchBridgeMessage = { method, params -> + EventQueue.invokeLater { + bridgeHandlersByMethod[method]?.onMessage(params) + } + }, + dispatchLinkClick = { link -> + EventQueue.invokeLater { + onLinkIntercepted(link) + } + }, + updateLoadState = { isLoaded, progress -> + EventQueue.invokeLater { + onLoadStateChanged(isLoaded, progress) + } + }, + reportError = { error -> + val message = error.desktopNativeWebViewMessage(backend) + EventQueue.invokeLater { + createdController?.let { failedController -> + if (controller === failedController) { + controller = null + } + } + onError(message) + } + } + ) + createdController = newController + controller = newController + } +} + +private class DesktopWindowsWebView2Controller( + private val instanceId: Int, + private val backend: DesktopEpubWebViewBackend, + private val canvas: Canvas, + private val isNetworkAccessEnabled: () -> Boolean, + private val dispatchBridgeMessage: (String, String) -> Unit, + private val dispatchLinkClick: (DesktopEpubLinkClick) -> Unit, + private val updateLoadState: (Boolean, Float) -> Unit, + private val reportError: (Throwable) -> Unit +) { + @Volatile + private var disposed = false + + private var shell: Shell? = null + private var browser: Browser? = null + private var bridgeFunction: BrowserFunction? = null + + @Volatile + private var pendingHtml: String? = null + + @Volatile + private var lastBrowserBoundsLog: String = "" + + init { + logDesktopWebView2("controller_init panel=$instanceId backend=${backend.logName} canvas=${canvas.width}x${canvas.height}") + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_init panel=$instanceId backend=${backend.logName} " + + "canvas=${canvas.width}x${canvas.height}" + } + logWebViewLayoutDiag( + "controller_init panel=$instanceId backend=${backend.logName} canvas=${canvas.width}x${canvas.height} " + + "canvasBounds=${canvas.bounds.formatAwtBounds()} screen=${canvas.safeScreenLocationLog()}" + ) + DesktopSwtWebView2EventLoop.asyncExec(reportError) { display -> + if (!disposed) createBrowser(display) + } + } + + fun loadHtml(html: String) { + logDesktopWebView2( + "controller_load_enqueue panel=$instanceId htmlChars=${html.length} htmlHash=${html.hashCode()} browser=${browser != null}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_load_enqueue panel=$instanceId htmlChars=${html.length} " + + "htmlHash=${html.hashCode()} browser=${browser != null}" + } + logWebViewLayoutDiag( + "controller_load_enqueue panel=$instanceId htmlChars=${html.length} browser=${browser != null} " + + "canvas=${canvas.width}x${canvas.height} browserBounds=$lastBrowserBoundsLog" + ) + DesktopSwtWebView2EventLoop.asyncExec(reportError) { + if (disposed) return@asyncExec + updateLoadState(false, -1f) + pendingHtml = html + val webView = browser + if (webView == null || webView.isDisposed) { + logDesktopWebView2("controller_load_pending panel=$instanceId reason=browser_not_ready") + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_load_pending panel=$instanceId reason=browser_not_ready" + } + } else { + pendingHtml = null + setBrowserText(webView, html, reason = "load") + } + } + } + + fun executeJavaScript(script: String) { + DesktopSwtWebView2EventLoop.asyncExec(reportError) { + if (disposed) return@asyncExec + val webView = browser + if (webView == null || webView.isDisposed) { + logDesktopWebView2( + "controller_execute_drop panel=$instanceId reason=browser_not_ready " + + "scriptChars=${script.length} scriptHash=${script.hashCode()}" + ) + } else { + val executed = webView.execute(script) + logDesktopWebView2( + "controller_execute panel=$instanceId executed=$executed scriptChars=${script.length} scriptHash=${script.hashCode()}" + ) + } + } + } + + fun resize(width: Int, height: Int, reason: String = "resize") { + DesktopSwtWebView2EventLoop.asyncExec(reportError) { + if (disposed) return@asyncExec + applyCanvasSizeToBrowser(width, height, reason = reason) + } + } + + fun dispose(waitForCompletion: Boolean = false) { + if (disposed) return + disposed = true + logDesktopWebView2("controller_dispose panel=$instanceId waitForCompletion=$waitForCompletion") + logReaderModeSwitch("webview2_controller_dispose panel=$instanceId waitForCompletion=$waitForCompletion") + if (waitForCompletion) { + DesktopSwtWebView2EventLoop.syncExec({}) { + disposeSwtWidgets() + } + } else { + DesktopSwtWebView2EventLoop.asyncExec({}) { + disposeSwtWidgets() + } + } + } + + private fun disposeSwtWidgets() { + logReaderModeSwitch( + "webview2_swt_dispose_begin panel=$instanceId shell=${shell?.isDisposed == false} browser=${browser?.isDisposed == false}" + ) + bridgeFunction?.takeUnless { it.isDisposed }?.dispose() + bridgeFunction = null + browser?.takeUnless { it.isDisposed }?.dispose() + browser = null + shell?.takeUnless { it.isDisposed }?.dispose() + shell = null + lastBrowserBoundsLog = "" + logReaderModeSwitch("webview2_swt_dispose_end panel=$instanceId") + } + + private fun createBrowser(display: Display) { + logDesktopWebView2("controller_create_start panel=$instanceId displayDisposed=${display.isDisposed}") + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_create_start panel=$instanceId displayDisposed=${display.isDisposed}" + } + runCatching { + shell = SWT_AWT.new_Shell(display, canvas) + logDesktopWebView2("controller_shell_created panel=$instanceId shellDisposed=${shell?.isDisposed == true}") + logWebViewLayoutDiag( + "swt_shell_created panel=$instanceId canvas=${canvas.width}x${canvas.height} " + + "canvasBounds=${canvas.bounds.formatAwtBounds()} shellBounds=${shell?.bounds?.formatSwtBounds().orEmpty()}" + ) + val webView = Browser(shell, backend.swtBrowserStyle()) + browser = webView + val swtBackground = org.eclipse.swt.graphics.Color( + display, + canvas.background.red, + canvas.background.green, + canvas.background.blue + ) + shell?.background = swtBackground + webView.background = swtBackground + shell?.addDisposeListener { + if (!swtBackground.isDisposed) swtBackground.dispose() + } + val browserType = webView.browserType.orEmpty() + logDesktopWebView2( + "controller_browser_created panel=$instanceId backend=${backend.logName} browserType=\"$browserType\"" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_browser_created panel=$instanceId backend=${backend.logName} " + + "browserType=\"${browserType.logPreview(120)}\"" + } + logWebViewLayoutDiag( + "swt_browser_created panel=$instanceId backend=${backend.logName} browserType=\"$browserType\" " + + "browserBounds=${webView.bounds.formatSwtBounds()} shellBounds=${shell?.bounds?.formatSwtBounds().orEmpty()}" + ) + check(backend.acceptsBrowserType(browserType)) { + "${backend.displayName} is not available; SWT opened '${browserType.ifBlank { "unknown" }}' instead." + } + val warmupHtml = desktopWebView2WarmupHtml(canvas.background) + val warmupAccepted = webView.setText(warmupHtml) + logDesktopReaderOpenTrace { + "event=desktop_webview_warmup_loaded panel=$instanceId accepted=$warmupAccepted " + + "background=\"${canvas.background.toCssHex()}\"" + } + run { + bridgeFunction = object : BrowserFunction(webView, DesktopWebView2NativeBridgeName) { + override fun function(arguments: Array): Any? { + val method = arguments.getOrNull(0)?.toString().orEmpty() + if (method.isBlank()) return null + val params = arguments.getOrNull(1)?.toString() ?: "{}" + if (method == DesktopWebView2DiagnosticMethodName) { + val preview = params.logPreview(6000) + logDesktopWebView2("bridge_diagnostic panel=$instanceId params=\"$preview\"") + logWebViewLayoutDiag("document_probe panel=$instanceId params=\"$preview\"") + } else { + logDesktopWebView2( + "bridge_message panel=$instanceId method=$method paramsChars=${params.length} params=\"${params.logPreview()}\"" + ) + dispatchBridgeMessage(method, params) + } + return null + } + } + webView.addLocationListener( + object : LocationListener { + override fun changing(event: LocationEvent) { + val location = event.location.orEmpty() + logDesktopWebView2( + "location_changing panel=$instanceId top=${event.top} doit=${event.doit} " + + "location=\"${location.logPreview()}\"" + ) + if (!isNetworkAccessEnabled() && location.isRemoteNetworkUrl()) { + logEpubLink("request_blocked_offline url=\"${location.logPreview()}\"") + event.doit = false + return + } + val link = location.readerLinkClickFromIntercept() ?: return + logEpubLink( + "request_intercept_webview2 url=\"${location.logPreview()}\" " + + "href=\"${link.href.logPreview()}\"" + ) + event.doit = false + dispatchLinkClick(link.copy(source = "request")) + } + + override fun changed(event: LocationEvent) = Unit + } + ) + webView.addProgressListener( + object : ProgressAdapter() { + private var lastLoggedProgressBucket = -1 + + override fun changed(event: ProgressEvent) { + val total = event.total + val progress = if (total > 0) { + event.current.coerceIn(0, total).toFloat() / total.toFloat() + } else { + -1f + } + val bucket = if (progress < 0f) { + -1 + } else { + (progress * 4).toInt().coerceIn(0, 4) + } + if (bucket != lastLoggedProgressBucket) { + lastLoggedProgressBucket = bucket + logDesktopWebView2( + "progress_changed panel=$instanceId current=${event.current} total=${event.total} " + + "progress=${if (progress < 0f) "unknown" else progress.formatLogFloat()}" + ) + } + updateLoadState(false, progress) + } + + override fun completed(event: ProgressEvent) { + val bridgeInjected = webView.execute(DesktopWebView2BridgeRuntimeScript) + applyCanvasSizeToBrowser(canvas.width, canvas.height, reason = "load_completed") + val probeInjected = webView.execute(desktopWebView2DocumentProbeScript("load_completed")) + logDesktopWebView2( + "progress_completed panel=$instanceId bridgeInjected=$bridgeInjected probeInjected=$probeInjected " + + "current=${event.current} total=${event.total}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_progress_completed panel=$instanceId " + + "bridgeInjected=$bridgeInjected probeInjected=$probeInjected " + + "current=${event.current} total=${event.total}" + } + logWebViewLayoutDiag( + "progress_completed panel=$instanceId bridgeInjected=$bridgeInjected " + + "current=${event.current} total=${event.total}" + ) + updateLoadState(true, 1f) + } + } + ) + pendingHtml?.let { html -> + pendingHtml = null + setBrowserText(webView, html, reason = "browser_ready") + } + } + applyCanvasSizeToBrowser(canvas.width, canvas.height, reason = "open") + shell?.open() + logDesktopWebView2( + "controller_open panel=$instanceId shellVisible=${shell?.isVisible == true} " + + "initial=${canvas.width}x${canvas.height} " + + "browserBounds=${browser?.bounds?.width ?: -1}x${browser?.bounds?.height ?: -1}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_open panel=$instanceId shellVisible=${shell?.isVisible == true} " + + "initial=${canvas.width}x${canvas.height} " + + "browserBounds=${browser?.bounds?.width ?: -1}x${browser?.bounds?.height ?: -1}" + } + logWebViewLayoutDiag( + "controller_open panel=$instanceId shellVisible=${shell?.isVisible == true} " + + "initial=${canvas.width}x${canvas.height} " + + "hostScale=${canvas.webView2HostScale().scaleX.formatLogFloat()}x${canvas.webView2HostScale().scaleY.formatLogFloat()} " + + "shellBounds=${shell?.bounds?.formatSwtBounds().orEmpty()} " + + "browserBounds=${browser?.bounds?.formatSwtBounds().orEmpty()} canvasBounds=${canvas.bounds.formatAwtBounds()}" + ) + }.onFailure { error -> + logDesktopWebView2( + "controller_create_failed panel=$instanceId error=\"${error.desktopNativeWebViewMessage(backend).logPreview(300)}\"" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_create_failed panel=$instanceId " + + "error=\"${error.desktopNativeWebViewMessage(backend).logPreview(300)}\"" + } + reportError(error) + dispose() + } + } + + private fun setBrowserText(webView: Browser, html: String, reason: String) { + val accepted = webView.setText(html) + logDesktopWebView2( + "controller_set_text panel=$instanceId reason=$reason accepted=$accepted " + + "htmlChars=${html.length} htmlHash=${html.hashCode()}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_set_text panel=$instanceId reason=$reason accepted=$accepted " + + "htmlChars=${html.length} htmlHash=${html.hashCode()}" + } + } + + private fun applyCanvasSizeToBrowser(width: Int, height: Int, reason: String) { + val webShell = shell ?: return + val webBrowser = browser + if (webShell.isDisposed || webBrowser?.isDisposed == true) return + val hostScale = canvas.webView2HostScale() + if (width <= 0 || height <= 0) { + logWebViewLayoutDiag( + "controller_resize_skip panel=$instanceId reason=$reason requested=${width}x${height} " + + "hostScale=${hostScale.scaleX.formatLogFloat()}x${hostScale.scaleY.formatLogFloat()} " + + "canvas=${canvas.width}x${canvas.height} shellBounds=${webShell.bounds.formatSwtBounds()} " + + "browserBounds=${webBrowser?.bounds?.formatSwtBounds().orEmpty()}" + ) + return + } + val targetBounds = desktopWebView2TargetBoundsForCanvas(width, height) ?: return + webShell.setBounds(targetBounds.x, targetBounds.y, targetBounds.width, targetBounds.height) + webBrowser?.setBounds(targetBounds.x, targetBounds.y, targetBounds.width, targetBounds.height) + lastBrowserBoundsLog = webBrowser?.bounds?.formatSwtBounds().orEmpty() + logDesktopWebView2( + "controller_resize panel=$instanceId reason=$reason requested=${width}x${height} " + + "target=${targetBounds.width}x${targetBounds.height} axisMode=logicalCanvas_zeroOrigin " + + "shellBounds=${webShell.bounds.x},${webShell.bounds.y} ${webShell.bounds.width}x${webShell.bounds.height} " + + "browserBounds=${webBrowser?.bounds?.width ?: -1}x${webBrowser?.bounds?.height ?: -1}" + ) + logWebViewLayoutDiag( + "controller_resize panel=$instanceId reason=$reason requested=${width}x${height} " + + "target=${targetBounds.width}x${targetBounds.height} axisMode=logicalCanvas_zeroOrigin " + + "hostScale=${hostScale.scaleX.formatLogFloat()}x${hostScale.scaleY.formatLogFloat()} " + + "canvas=${canvas.width}x${canvas.height} canvasBounds=${canvas.bounds.formatAwtBounds()} " + + "shellBounds=${webShell.bounds.formatSwtBounds()} " + + "browserBounds=${webBrowser?.bounds?.formatSwtBounds().orEmpty()}" + ) + } +} + +private object DesktopSwtWebView2EventLoop { + private val ready = CountDownLatch(1) + + @Volatile + private var display: Display? = null + + @Volatile + private var startupError: Throwable? = null + + init { + Thread( + { + runCatching { + logDesktopWebView2("swt_event_loop_start") + runCatching { Display.setAppName(EpistemeDesktopWindowTitle) } + if (desktopEpubWebViewUsesWebView2() && + System.getProperty(DesktopWebView2EdgeDataDirProperty).isNullOrBlank() + ) { + System.setProperty( + DesktopWebView2EdgeDataDirProperty, + File(desktopUserCacheRoot(), "webview2").absolutePath + ) + } + if (desktopEpubWebViewUsesWebView2()) { + logDesktopWebView2( + "swt_event_loop_user_data_dir path=\"${System.getProperty(DesktopWebView2EdgeDataDirProperty).orEmpty().logPreview(200)}\"" + ) + } + val swtDisplay = Display() + display = swtDisplay + ready.countDown() + logDesktopWebView2("swt_event_loop_ready") + while (!swtDisplay.isDisposed) { + if (!swtDisplay.readAndDispatch()) { + swtDisplay.sleep() + } + } + }.onFailure { error -> + startupError = error + ready.countDown() + logDesktopWebView2("swt_event_loop_failed error=\"${error.message.orEmpty().logPreview(300)}\"") + } + }, + "Episteme SWT Browser" + ).apply { + isDaemon = true + start() + } + } + + fun asyncExec( + onError: (Throwable) -> Unit, + block: (Display) -> Unit + ) { + val displayReady = runCatching { + ready.await(DesktopSwtReadyTimeoutSeconds, TimeUnit.SECONDS) + }.getOrElse { error -> + Thread.currentThread().interrupt() + onError(error) + return + } + if (!displayReady) { + logDesktopWebView2("swt_async_timeout") + onError(IllegalStateException("SWT display did not become ready.")) + return + } + startupError?.let { error -> + logDesktopWebView2("swt_async_startup_error error=\"${error.message.orEmpty().logPreview(300)}\"") + onError(error) + return + } + val swtDisplay = display + if (swtDisplay == null) { + logDesktopWebView2("swt_async_display_unavailable") + onError(IllegalStateException("SWT display is not available.")) + return + } + runCatching { + swtDisplay.asyncExec { + runCatching { + if (!swtDisplay.isDisposed) { + block(swtDisplay) + } + }.onFailure(onError) + } + }.onFailure { error -> + logDesktopWebView2("swt_async_enqueue_failed error=\"${error.message.orEmpty().logPreview(300)}\"") + onError(error) + } + } + + fun syncExec( + onError: (Throwable) -> Unit, + block: (Display) -> Unit + ) { + val displayReady = runCatching { + ready.await(DesktopSwtReadyTimeoutSeconds, TimeUnit.SECONDS) + }.getOrElse { error -> + Thread.currentThread().interrupt() + onError(error) + return + } + if (!displayReady) { + logDesktopWebView2("swt_sync_timeout") + onError(IllegalStateException("SWT display did not become ready.")) + return + } + startupError?.let { error -> + logDesktopWebView2("swt_sync_startup_error error=\"${error.message.orEmpty().logPreview(300)}\"") + onError(error) + return + } + val swtDisplay = display + if (swtDisplay == null) { + logDesktopWebView2("swt_sync_display_unavailable") + onError(IllegalStateException("SWT display is not available.")) + return + } + runCatching { + swtDisplay.syncExec { + runCatching { + if (!swtDisplay.isDisposed) { + block(swtDisplay) + } + }.onFailure(onError) + } + }.onFailure { error -> + logDesktopWebView2("swt_sync_enqueue_failed error=\"${error.message.orEmpty().logPreview(300)}\"") + onError(error) + } + } +} + +private fun Color.toAwtColor(): java.awt.Color = java.awt.Color(toArgb(), true) + +private fun desktopWebView2WarmupHtml(background: java.awt.Color): String { + val cssColor = background.toCssHex() + return """ + + + + + + + + + """.trimIndent() +} + +private fun java.awt.Color.toCssHex(): String { + return "#${red.toTwoDigitHex()}${green.toTwoDigitHex()}${blue.toTwoDigitHex()}" +} + +private fun Int.toTwoDigitHex(): String { + return coerceIn(0, 255).toString(16).padStart(2, '0') +} + +private fun runOnAwtEventThreadBlocking( + onError: (Throwable) -> Unit = {}, + block: () -> Unit +) { + if (EventQueue.isDispatchThread()) { + runCatching(block).onFailure(onError) + return + } + runCatching { + EventQueue.invokeAndWait { + runCatching(block).onFailure(onError) + } + }.onFailure(onError) +} + +private fun java.awt.Rectangle.formatAwtBounds(): String { + return "${x},${y} ${width}x$height" +} + +private fun java.awt.Component?.formatAwtComponentState(): String { + if (this == null) return "none" + return "${javaClass.simpleName}{visible=$isVisible displayable=$isDisplayable showing=$isShowing " + + "size=${width}x$height bounds=${bounds.formatAwtBounds()}}" +} + +private fun java.awt.Container.formatAwtChildrenState(): String { + if (componentCount == 0) return "none" + return components.joinToString(prefix = "[", postfix = "]") { component -> + component.formatAwtComponentState() + } +} + +private fun java.awt.Component.desktopWebView2Descendants(includeSelf: Boolean = false): List { + val descendants = mutableListOf() + if (includeSelf) descendants += this + fun collect(component: java.awt.Component) { + if (component is java.awt.Container) { + component.components.forEach { child -> + descendants += child + collect(child) + } + } + } + collect(this) + return descendants +} + +private fun org.eclipse.swt.graphics.Rectangle.formatSwtBounds(): String { + return "${x},${y} ${width}x$height" +} + +private fun Canvas.safeScreenLocationLog(): String { + return runCatching { + val point = locationOnScreen + "${point.x},${point.y}" + }.getOrDefault("unavailable") +} + +private data class DesktopWebView2HostScale( + val scaleX: Float, + val scaleY: Float +) + +internal data class DesktopWebView2TargetBounds( + val x: Int, + val y: Int, + val width: Int, + val height: Int +) + +internal fun desktopWebView2TargetBoundsForCanvas(width: Int, height: Int): DesktopWebView2TargetBounds? { + if (width <= 0 || height <= 0) return null + return DesktopWebView2TargetBounds( + x = 0, + y = 0, + width = width.coerceAtLeast(1), + height = height.coerceAtLeast(1) + ) +} + +internal fun desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing: Boolean, + hostWindowDisplayable: Boolean +): Boolean { + return !hostWindowClosing && hostWindowDisplayable +} + +private fun java.awt.Component.webView2HostScale(): DesktopWebView2HostScale { + val transform = graphicsConfiguration?.defaultTransform + return DesktopWebView2HostScale( + scaleX = transform?.scaleX?.takeIf { it.isFinite() && it > 0.0 }?.toFloat() ?: 1f, + scaleY = transform?.scaleY?.takeIf { it.isFinite() && it > 0.0 }?.toFloat() ?: 1f + ) +} + +private fun String.withDesktopWebView2Bootstrap(networkAccessEnabled: Boolean): String { + val injection = buildString { + if (!networkAccessEnabled) { + append(DesktopWebView2OfflineCspMetaTag) + append('\n') + } + append(DesktopWebView2ReaderSurfaceCssTag) + append('\n') + append(DesktopWebView2BridgeScriptTag) + } + val headStart = Regex("]*>", RegexOption.IGNORE_CASE).find(this) + if (headStart != null) { + val insertAt = headStart.range.last + 1 + return substring(0, insertAt) + "\n" + injection + "\n" + substring(insertAt) + } + return "$injection\n$this" +} + +internal fun desktopNativeWebViewUnavailableMessage( + backend: DesktopEpubWebViewBackend, + detail: String? = null +): String { + val base = when (backend) { + DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 -> + "Microsoft Edge WebView2 runtime is unavailable. Install or repair the WebView2 Runtime." + DesktopEpubWebViewBackend.WEBKIT -> + "WebKitGTK is unavailable. Install WebKitGTK from your Linux distribution packages." + DesktopEpubWebViewBackend.UNSUPPORTED -> + "Native webview is unavailable on this desktop platform." + } + val trimmedDetail = detail?.trim().orEmpty() + return if (trimmedDetail.isBlank()) base else "$base $trimmedDetail" +} + +private fun Throwable.desktopNativeWebViewMessage(backend: DesktopEpubWebViewBackend): String { + return desktopNativeWebViewUnavailableMessage( + backend = backend, + detail = message?.takeIf { it.isNotBlank() } ?: javaClass.simpleName + ) +} + +private fun desktopWebView2DocumentProbeScript(eventName: String): String { + return """ + (function () { + try { + var body = document.body; + var root = document.documentElement; + var firstChapter = document.querySelector('.chapter'); + var firstContent = document.querySelector('.reader-content'); + var blockSelector = 'p, div, h1, h2, h3, h4, h5, h6, li, blockquote, figure, table, pre'; + function round(value) { + return Math.round(Number(value || 0)); + } + function cssValue(element, name) { + if (!element) return ''; + var style = window.getComputedStyle(element); + return style ? (style.getPropertyValue(name) || '') : ''; + } + function cssVar(name) { + return cssValue(root, name).trim(); + } + function rectPayload(element) { + if (!element) return null; + var rect = element.getBoundingClientRect(); + var centerX = rect.left + (rect.width / 2); + var centerY = rect.top + (rect.height / 2); + var viewportHeight = window.innerHeight || 0; + return { + left: round(rect.left), + top: round(rect.top), + right: round(rect.right), + bottom: round(rect.bottom), + width: round(rect.width), + height: round(rect.height), + centerX: round(centerX), + centerDelta: round(centerX - ((window.innerWidth || 0) / 2)), + centerY: round(centerY), + viewportHeightDelta: round(rect.height - viewportHeight), + marginLeft: cssValue(element, 'margin-left').trim(), + marginRight: cssValue(element, 'margin-right').trim(), + paddingLeft: cssValue(element, 'padding-left').trim(), + paddingRight: cssValue(element, 'padding-right').trim(), + paddingTop: cssValue(element, 'padding-top').trim(), + paddingBottom: cssValue(element, 'padding-bottom').trim(), + textAlign: cssValue(element, 'text-align').trim(), + display: cssValue(element, 'display').trim(), + cssFloat: cssValue(element, 'float').trim(), + clear: cssValue(element, 'clear').trim(), + cssWidth: cssValue(element, 'width').trim(), + maxWidth: cssValue(element, 'max-width').trim(), + minHeight: cssValue(element, 'min-height').trim(), + boxSizing: cssValue(element, 'box-sizing').trim() + }; + } + function visibleChapter() { + var chapters = Array.prototype.slice.call(document.querySelectorAll('[data-reader-chapter-index]')); + var viewportTop = 0; + var viewportBottom = window.innerHeight || 0; + var best = null; + var bestVisibleHeight = -1; + chapters.forEach(function (candidate) { + var rect = candidate.getBoundingClientRect(); + var visibleHeight = Math.min(rect.bottom, viewportBottom) - Math.max(rect.top, viewportTop); + if (visibleHeight > bestVisibleHeight && rect.bottom >= viewportTop && rect.top <= viewportBottom) { + best = candidate; + bestVisibleHeight = visibleHeight; + } + }); + return best || firstChapter; + } + function visibleBlockIn(content) { + if (!content) return null; + var blocks = Array.prototype.slice.call(content.querySelectorAll(blockSelector)); + for (var i = 0; i < blocks.length; i++) { + var rect = blocks[i].getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.top <= (window.innerHeight || 0)) { + return blocks[i]; + } + } + return blocks[0] || null; + } + var chapter = visibleChapter(); + var content = chapter ? (chapter.querySelector('.reader-content') || chapter) : firstContent; + var firstBlock = firstContent ? firstContent.querySelector(blockSelector) : null; + var visibleBlock = visibleBlockIn(content); + var viewportCenterX = Math.max(0, Math.min((window.innerWidth || 0) - 1, Math.round((window.innerWidth || 0) / 2))); + var viewportTopY = Math.max(0, Math.min((window.innerHeight || 0) - 1, 8)); + var topElement = document.elementFromPoint(viewportCenterX, viewportTopY); + var topBlock = topElement && topElement.closest ? topElement.closest(blockSelector) : null; + var sampledElement = document.elementFromPoint( + viewportCenterX, + Math.max(0, Math.min((window.innerHeight || 0) - 1, Math.round((window.innerHeight || 0) / 2))) + ); + var sampledBlock = sampledElement && sampledElement.closest ? sampledElement.closest(blockSelector) : null; + var payload = { + event: '$eventName', + readyState: document.readyState || '', + title: document.title || '', + url: location.href || '', + devicePixelRatio: window.devicePixelRatio || 1, + bodyClass: body ? body.className : '', + rootClass: root ? root.className : '', + readerAlign: cssVar('--reader-align'), + readerMarginX: cssVar('--reader-margin-x'), + readerMarginY: cssVar('--reader-margin-y'), + readerVerticalMarginY: cssVar('--reader-vertical-margin-y'), + readerVerticalContentWidth: cssVar('--reader-vertical-content-width'), + readerVerticalPageWidth: cssVar('--reader-vertical-page-width'), + readerFontSize: cssVar('--reader-font-size'), + bodyZoom: cssValue(body, 'zoom').trim(), + bodyChildren: body ? body.children.length : -1, + bodyTextChars: body && body.innerText ? body.innerText.length : 0, + bodyHtmlChars: body && body.innerHTML ? body.innerHTML.length : 0, + bodyClientWidth: body ? body.clientWidth : -1, + bodyScrollWidth: body ? body.scrollWidth : -1, + rootClientWidth: root ? root.clientWidth : -1, + rootScrollWidth: root ? root.scrollWidth : -1, + scrollHeight: root ? root.scrollHeight : -1, + clientHeight: root ? root.clientHeight : -1, + viewportWidth: window.innerWidth || -1, + viewportHeight: window.innerHeight || -1, + visualViewportWidth: window.visualViewport ? round(window.visualViewport.width) : -1, + visualViewportHeight: window.visualViewport ? round(window.visualViewport.height) : -1, + visualViewportScale: window.visualViewport ? window.visualViewport.scale : -1, + scrollX: window.scrollX || 0, + topElementTag: topElement ? topElement.tagName : '', + topElementClass: topElement && topElement.className ? String(topElement.className) : '', + topBlockTag: topBlock ? topBlock.tagName : '', + topBlockRect: rectPayload(topBlock), + bodyRect: rectPayload(body), + rootRect: rectPayload(root), + firstChapterRect: rectPayload(firstChapter), + firstContentRect: rectPayload(firstContent), + visibleChapterIndex: chapter ? chapter.getAttribute('data-reader-chapter-index') : '', + chapterRect: rectPayload(chapter), + contentRect: rectPayload(content), + firstBlockTag: firstBlock ? firstBlock.tagName : '', + firstBlockRect: rectPayload(firstBlock), + visibleBlockTag: visibleBlock ? visibleBlock.tagName : '', + visibleBlockRect: rectPayload(visibleBlock), + sampledBlockTag: sampledBlock ? sampledBlock.tagName : '', + sampledBlockRect: rectPayload(sampledBlock) + }; + if (window.kmpJsBridge && window.kmpJsBridge.callNative) { + window.kmpJsBridge.callNative('$DesktopWebView2DiagnosticMethodName', JSON.stringify(payload)); + } + } catch (error) { + if (window.kmpJsBridge && window.kmpJsBridge.callNative) { + window.kmpJsBridge.callNative('$DesktopWebView2DiagnosticMethodName', JSON.stringify({ + event: '$eventName', + error: String(error && error.message ? error.message : error) + })); + } + } + })(); + """.trimIndent() +} + +private var DesktopWebView2InstanceSeed = 0 +private val DesktopWebView2ModeSwitchPanelStates = ConcurrentHashMap() + +@Synchronized +private fun nextDesktopWebView2InstanceId(): Int { + DesktopWebView2InstanceSeed += 1 + return DesktopWebView2InstanceSeed +} + +internal fun logDesktopWebView2ModeSwitchSnapshot(reason: String) { + val states = DesktopWebView2ModeSwitchPanelStates + .toSortedMap() + .values + .joinToString(separator = " | ") + .ifBlank { "none" } + logReaderModeSwitch( + "webview2_snapshot reason=$reason knownPanelCount=${DesktopWebView2ModeSwitchPanelStates.size} panels=$states" + ) +} + +internal fun cleanupRetiredDesktopWebView2InteropHosts(window: java.awt.Window?, reason: String) { + EventQueue.invokeLater { + if (window == null) { + logReaderModeSwitch("webview2_interop_host_sweep_skip reason=$reason window=null") + return@invokeLater + } + val interopHosts = window + .desktopWebView2Descendants() + .filterIsInstance() + .filter { component -> component.javaClass.simpleName == DesktopSwingInteropHostClassName } + if (interopHosts.isEmpty()) { + logReaderModeSwitch( + "webview2_interop_host_sweep reason=$reason window=${window.formatAwtComponentState()} hosts=none" + ) + return@invokeLater + } + interopHosts.forEach { host -> + cleanupRetiredDesktopWebView2InteropHost(window, host, reason) + } + } +} + +private fun cleanupRetiredDesktopWebView2InteropHost( + window: java.awt.Window, + host: java.awt.Container, + reason: String +) { + val panels = host + .desktopWebView2Descendants(includeSelf = true) + .filterIsInstance() + val hostRetired = !host.isShowing || !host.isVisible || host.width <= 0 || host.height <= 0 + val panelsRetired = panels.isNotEmpty() && panels.all { panel -> + !panel.isDisplayable || !panel.isShowing || panel.width <= 0 || panel.height <= 0 || !panel.hasController + } + val parent = host.parent + val removable = parent != null && hostRetired && panelsRetired + val panelStates = panels.joinToString(prefix = "[", postfix = "]") { panel -> + "panel=${panel.instanceId}{visible=${panel.isVisible} displayable=${panel.isDisplayable} " + + "showing=${panel.isShowing} size=${panel.width}x${panel.height} controller=${panel.hasController}}" + }.ifBlank { "none" } + logReaderModeSwitch( + "webview2_interop_host_sweep_candidate reason=$reason removable=$removable " + + "hostRetired=$hostRetired panelsRetired=$panelsRetired " + + "window=${window.formatAwtComponentState()} host=${host.formatAwtComponentState()} " + + "parent=${parent.formatAwtComponentState()} panels=$panelStates children=${host.formatAwtChildrenState()}" + ) + if (!removable) return + panels.forEach { panel -> + if (panel.parent === host) { + host.remove(panel) + } + } + host.removeAll() + parent?.remove(host) + host.invalidate() + parent?.invalidate() + parent?.validate() + parent?.repaint() + window.invalidate() + window.validate() + window.repaint() + panels.forEach { panel -> + DesktopWebView2ModeSwitchPanelStates[panel.instanceId] = + panel.modeSwitchPanelSnapshot("interop_host_sweep_removed_$reason") + logReaderModeSwitch("webview2_panel ${DesktopWebView2ModeSwitchPanelStates[panel.instanceId]}") + } + logReaderModeSwitch( + "webview2_interop_host_sweep_removed reason=$reason " + + "window=${window.formatAwtComponentState()} host=${host.formatAwtComponentState()} " + + "parent=${parent.formatAwtComponentState()} parentChildren=${parent?.componentCount ?: -1}" + ) +} + +private const val DesktopSwtReadyTimeoutSeconds = 10L +private val DesktopWebView2FullscreenRelayoutDelaysMillis = longArrayOf(180L, 260L, 420L) +private val DesktopWebView2InteropHostCleanupDelaysMillis = longArrayOf(80L, 220L) +private const val DesktopWebView2NativeBridgeName = "epistemeCallNative" +private const val DesktopWebView2DiagnosticMethodName = "readerWebView2Diagnostic" +private const val DesktopSwingInteropHostClassName = "SwingInteropViewGroup" +private const val DesktopWebView2EdgeDataDirProperty = "org.eclipse.swt.browser.EdgeDataDir" + +private fun DesktopEpubWebViewBackend.swtBrowserStyle(): Int { + return when (this) { + DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 -> SWT.EDGE + DesktopEpubWebViewBackend.WEBKIT -> SWT.WEBKIT + DesktopEpubWebViewBackend.UNSUPPORTED -> SWT.NONE + } +} + +private fun DesktopEpubWebViewBackend.acceptsBrowserType(browserType: String): Boolean { + return when (this) { + DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 -> browserType.equals("edge", ignoreCase = true) + DesktopEpubWebViewBackend.WEBKIT -> + browserType.contains("webkit", ignoreCase = true) || browserType.equals("safari", ignoreCase = true) + DesktopEpubWebViewBackend.UNSUPPORTED -> false + } +} + +private val DesktopWebView2BridgeRuntimeScript = """ + (function () { + window.kmpJsBridge = window.kmpJsBridge || {}; + window.kmpJsBridge.callNative = function (method, params) { + if (!window.$DesktopWebView2NativeBridgeName) return null; + var payload = '{}'; + if (typeof params === 'string') { + payload = params; + } else { + try { payload = JSON.stringify(params || {}); } catch (error) { payload = '{}'; } + } + return window.$DesktopWebView2NativeBridgeName(String(method || ''), payload); + }; + })(); +""".trimIndent() + +private val DesktopWebView2ReaderSurfaceCssTag = """ + +""".trimIndent() + +private val DesktopWebView2HorizontalClampScript = """ + (function () { + if (window.readerWebView2HorizontalClampInstalled) return; + window.readerWebView2HorizontalClampInstalled = true; + var clampQueued = false; + function clampHorizontalScroll() { + clampQueued = false; + var root = document.documentElement; + var body = document.body; + var changed = false; + if (window.scrollX) { + window.scrollTo({ top: window.scrollY || 0, left: 0, behavior: 'auto' }); + changed = true; + } + if (root && root.scrollLeft) { + root.scrollLeft = 0; + changed = true; + } + if (body && body.scrollLeft) { + body.scrollLeft = 0; + changed = true; + } + if (changed && window.kmpJsBridge && window.kmpJsBridge.callNative) { + try { + window.kmpJsBridge.callNative('$DesktopWebView2DiagnosticMethodName', JSON.stringify({ + event: 'horizontal_scroll_clamped' + })); + } catch (error) {} + } + } + function scheduleClamp() { + if (clampQueued) return; + clampQueued = true; + window.requestAnimationFrame(clampHorizontalScroll); + } + window.addEventListener('scroll', scheduleClamp, { passive: true }); + window.addEventListener('resize', scheduleClamp, { passive: true }); + document.addEventListener('DOMContentLoaded', scheduleClamp, { once: true }); + window.addEventListener('load', scheduleClamp, { once: true }); + scheduleClamp(); + })(); +""".trimIndent() + +private val DesktopWebView2BridgeScriptTag = """ + +""".trimIndent() + +private const val DesktopWebView2OfflineCspMetaTag = + "" diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Launcher.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Launcher.kt similarity index 71% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Launcher.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Launcher.kt index d03d1f1..6893a1e 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Launcher.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Launcher.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop fun main() { val startupSplash = DesktopStartupSplash.show() diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Main.kt similarity index 63% rename from desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt rename to desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Main.kt index b8a5f91..ef77e24 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Main.kt @@ -1,8 +1,9 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -23,92 +24,97 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState -import com.aryan.reader.shared.AppAction -import com.aryan.reader.shared.AppFontPreference -import com.aryan.reader.shared.BannerMessage -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.BookShelfRef -import com.aryan.reader.shared.CustomFontItem -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.ImportedBookFile -import com.aryan.reader.shared.LibraryAction -import com.aryan.reader.shared.ReaderAiByokSettings -import com.aryan.reader.shared.ReaderAiFeature -import com.aryan.reader.shared.ReaderAiResultState -import com.aryan.reader.shared.ReaderAutoScrollState -import com.aryan.reader.shared.ReaderCloudTtsState -import com.aryan.reader.shared.ReaderContextExtractor -import com.aryan.reader.shared.RecapResult -import com.aryan.reader.shared.ReaderExternalLookupAction -import com.aryan.reader.shared.ReaderExtrasState -import com.aryan.reader.shared.ReaderFeatureSurface -import com.aryan.reader.shared.ReaderPlatform -import com.aryan.reader.shared.ReaderTtsCacheSummary -import com.aryan.reader.shared.ReaderTtsChunk -import com.aryan.reader.shared.ReaderTtsPlanner -import com.aryan.reader.shared.ReaderTtsProgress -import com.aryan.reader.shared.ReaderTtsReadScope -import com.aryan.reader.shared.SharedFileCapabilities -import com.aryan.reader.shared.SharedImportOutcomeCounts -import com.aryan.reader.shared.SharedImportPlanner -import com.aryan.reader.shared.SharedLibraryEditor -import com.aryan.reader.shared.SharedLibraryStateProjector -import com.aryan.reader.shared.SharedReaderScreenState -import com.aryan.reader.shared.SharedSettingsAction -import com.aryan.reader.shared.SharedSettingsDestination -import com.aryan.reader.shared.SharedSettingsHubInput -import com.aryan.reader.shared.SharedSettingsPlatform -import com.aryan.reader.shared.Shelf -import com.aryan.reader.shared.ShelfRecord -import com.aryan.reader.shared.ShelfType -import com.aryan.reader.shared.SmartCollectionDefinition -import com.aryan.reader.shared.SummarizationResult -import com.aryan.reader.shared.externalLookupUrl -import com.aryan.reader.shared.opds.OpdsAcquisition -import com.aryan.reader.shared.opds.OpdsCatalog -import com.aryan.reader.shared.opds.OpdsEntry -import com.aryan.reader.shared.opds.OpdsStreamReference -import com.aryan.reader.shared.opds.SharedOpdsController -import com.aryan.reader.shared.opds.SharedOpdsDownloadState -import com.aryan.reader.shared.opds.SharedOpdsStreamUri -import com.aryan.reader.shared.pdf.SharedPdfReaderViewport -import com.aryan.reader.shared.reader.ReaderEngine -import com.aryan.reader.shared.reader.ReaderImageReference -import com.aryan.reader.shared.reader.ReaderReadingMode -import com.aryan.reader.shared.reader.ReaderSessionState -import com.aryan.reader.shared.reader.ReaderSettings -import com.aryan.reader.shared.reader.SharedEpubMetadataEditor -import com.aryan.reader.shared.reader.SharedEpubMetadataUpdate -import com.aryan.reader.shared.reader.SharedEpubPaginationCache -import com.aryan.reader.shared.reader.SharedJvmBookLoader -import com.aryan.reader.shared.reduce -import com.aryan.reader.shared.sharedSettingsHubModel -import com.aryan.reader.shared.ui.NonReaderLibraryTab -import com.aryan.reader.shared.ui.SharedAboutScreen -import com.aryan.reader.shared.ui.SharedAddToShelfDialog -import com.aryan.reader.shared.ui.SharedAppShell -import com.aryan.reader.shared.ui.SharedAppTab -import com.aryan.reader.shared.ui.SharedAppTheme -import com.aryan.reader.shared.ui.SharedAppThemeSettingsDialog -import com.aryan.reader.shared.ui.SharedBookInfoDialog -import com.aryan.reader.shared.ui.SharedConfirmDialog -import com.aryan.reader.shared.ui.SharedCustomFontsScreen -import com.aryan.reader.shared.ui.SharedHelpFeedbackScreen -import com.aryan.reader.shared.ui.LocalSharedStringResolver -import com.aryan.reader.shared.ui.SharedOpdsScreen -import com.aryan.reader.shared.ui.SharedReaderModalOwnerWindowProvider -import com.aryan.reader.shared.ui.SharedSettingsHub -import com.aryan.reader.shared.ui.SharedSupportProjectScreen -import com.aryan.reader.shared.ui.SharedTextInputDialog -import com.aryan.reader.shared.ui.readerString -import com.aryan.reader.shared.withTtsReplacements -import dev.datlag.kcef.KCEF +import org.dueattendant149.bookreader.shared.AppAction +import org.dueattendant149.bookreader.shared.AppFontPreference +import org.dueattendant149.bookreader.shared.BannerMessage +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.BookShelfRef +import org.dueattendant149.bookreader.shared.CustomFontItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.ImportedBookFile +import org.dueattendant149.bookreader.shared.LibraryAction +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.ReaderAiFeature +import org.dueattendant149.bookreader.shared.ReaderAiResultState +import org.dueattendant149.bookreader.shared.ReaderCloudTtsState +import org.dueattendant149.bookreader.shared.ReaderContextExtractor +import org.dueattendant149.bookreader.shared.RecapResult +import org.dueattendant149.bookreader.shared.ReaderExternalLookupAction +import org.dueattendant149.bookreader.shared.ReaderExtrasState +import org.dueattendant149.bookreader.shared.ReaderFeatureSurface +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.ReaderPlatform +import org.dueattendant149.bookreader.shared.ReaderTtsCacheSummary +import org.dueattendant149.bookreader.shared.ReaderTtsChunk +import org.dueattendant149.bookreader.shared.ReaderTtsPlanner +import org.dueattendant149.bookreader.shared.ReaderTtsProgress +import org.dueattendant149.bookreader.shared.ReaderTtsReadScope +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.SharedImportOutcomeCounts +import org.dueattendant149.bookreader.shared.SharedImportPlanner +import org.dueattendant149.bookreader.shared.SharedLibraryEditor +import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.SharedSettingsAction +import org.dueattendant149.bookreader.shared.SharedSettingsDestination +import org.dueattendant149.bookreader.shared.SharedSettingsHubInput +import org.dueattendant149.bookreader.shared.SharedSettingsPlatform +import org.dueattendant149.bookreader.shared.Shelf +import org.dueattendant149.bookreader.shared.ShelfRecord +import org.dueattendant149.bookreader.shared.ShelfType +import org.dueattendant149.bookreader.shared.SmartCollectionDefinition +import org.dueattendant149.bookreader.shared.SummarizationResult +import org.dueattendant149.bookreader.shared.externalLookupUrl +import org.dueattendant149.bookreader.shared.opds.OpdsAcquisition +import org.dueattendant149.bookreader.shared.opds.OpdsCatalog +import org.dueattendant149.bookreader.shared.opds.OpdsEntry +import org.dueattendant149.bookreader.shared.opds.OpdsStreamReference +import org.dueattendant149.bookreader.shared.opds.SharedOpdsController +import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadState +import org.dueattendant149.bookreader.shared.opds.SharedOpdsStreamUri +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport +import org.dueattendant149.bookreader.shared.reader.ReaderEngine +import org.dueattendant149.bookreader.shared.reader.ReaderImageReference +import org.dueattendant149.bookreader.shared.reader.ReaderSessionState +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataEditor +import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataUpdate +import org.dueattendant149.bookreader.shared.reader.SharedEpubPaginationCache +import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoadSemanticMode +import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoader +import org.dueattendant149.bookreader.shared.readerCloudTtsControlsModel +import org.dueattendant149.bookreader.shared.reduce +import org.dueattendant149.bookreader.shared.sharedSettingsHubModel +import org.dueattendant149.bookreader.shared.shouldApplyRemoteCloudBookMetadataUpdate +import org.dueattendant149.bookreader.shared.shouldUploadLocalCloudBookContent +import org.dueattendant149.bookreader.shared.shouldUploadLocalCloudBookMetadataUpdate +import org.dueattendant149.bookreader.shared.ui.NonReaderLibraryTab +import org.dueattendant149.bookreader.shared.ui.SharedAboutScreen +import org.dueattendant149.bookreader.shared.ui.SharedAddToShelfDialog +import org.dueattendant149.bookreader.shared.ui.SharedAppShell +import org.dueattendant149.bookreader.shared.ui.SharedAppTab +import org.dueattendant149.bookreader.shared.ui.SharedAppTheme +import org.dueattendant149.bookreader.shared.ui.SharedAppThemeControls +import org.dueattendant149.bookreader.shared.ui.SharedAppThemeSettingsDialog +import org.dueattendant149.bookreader.shared.ui.SharedBookInfoDialog +import org.dueattendant149.bookreader.shared.ui.SharedConfirmDialog +import org.dueattendant149.bookreader.shared.ui.SharedCustomFontsScreen +import org.dueattendant149.bookreader.shared.ui.SharedHelpFeedbackScreen +import org.dueattendant149.bookreader.shared.ui.LocalSharedStringResolver +import org.dueattendant149.bookreader.shared.ui.SharedManageShelfBooksDialog +import org.dueattendant149.bookreader.shared.ui.SharedOpdsScreen +import org.dueattendant149.bookreader.shared.ui.SharedReaderModalOwnerWindowProvider +import org.dueattendant149.bookreader.shared.ui.SharedReaderTtsOverlayControls +import org.dueattendant149.bookreader.shared.ui.SharedSettingsHub +import org.dueattendant149.bookreader.shared.ui.SharedSupportProjectScreen +import org.dueattendant149.bookreader.shared.ui.SharedTextInputDialog +import org.dueattendant149.bookreader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.withTtsReplacements import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -121,7 +127,15 @@ import java.io.File import java.net.URI import java.util.Base64 import java.util.UUID -import kotlin.math.max +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference + +private const val DesktopReaderCloseDisposeSyncDelayMillis = 350L +private const val DesktopVerticalInitialPreparedHtmlChapterRadius = 2 +private const val DesktopLibraryOpenPersistDebounceMillis = 300L +private const val DesktopCloudContentRetryDelayMillis = 10_000L +private const val DesktopReaderPositionPersistDebounceMillis = 650L +private const val DesktopProgressEpsilon = 0.001f private enum class DesktopFeatureNoticeAction { SIGN_IN, @@ -138,6 +152,11 @@ private data class DesktopFeatureNotice( val action: DesktopFeatureNoticeAction? = null ) +private data class DesktopFeatureNoticeState( + val notice: DesktopFeatureNotice, + val placement: DesktopFeatureNoticePlacement +) + private data class DesktopCloudSyncCredentials( val userId: String, val idToken: String, @@ -171,6 +190,7 @@ internal fun EpistemeDesktopApp( return desktopStringResolver.quantityString(name, quantity, fallbackOne, fallbackOther, *args) } val featurePolicy = desktopBuildProfile.featurePolicy + val desktopAiKeySettingsAvailable = desktopBuildProfile.aiKeySettingsAvailable val libraryProjector = remember { SharedLibraryStateProjector(DesktopFolderPathResolver) } val readerEngine = remember { ReaderEngine() } val libraryDatabase = remember { DesktopLibraryDatabase() } @@ -192,6 +212,16 @@ internal fun EpistemeDesktopApp( val desktopAccountProfileRepository = remember { DesktopAccountProfileRepository(desktopCloudConfig) } val desktopCloudSyncSettingsStore = remember { DesktopCloudSyncSettingsStore() } val initialDesktopCloudSyncSettings = remember { desktopCloudSyncSettingsStore.load() } + val initialDesktopAccountSession = remember(desktopBuildProfile, featurePolicy) { + if (featurePolicy.aiAndCloud && featurePolicy.networkAccess && !desktopBuildProfile.byokAiAvailable) { + desktopAuthRepository.currentSession() + } else { + null + } + } + val initialDesktopAccountProfile = remember(initialDesktopAccountSession?.user?.uid) { + initialDesktopAccountSession?.user?.uid?.let(desktopAccountProfileRepository::cachedProfile) + } val desktopInstallationIdStore = remember { DesktopInstallationIdStore() } val desktopFirestoreRepository = remember { DesktopFirestoreRepository(desktopCloudConfig) } val desktopGoogleDriveRepository = remember { DesktopGoogleDriveRepository() } @@ -207,10 +237,19 @@ internal fun EpistemeDesktopApp( var aiByokSettings by remember { mutableStateOf(aiByokStore.load()) } + val sanitizedAiByokSettings = aiByokSettings.toDesktopPersistableAiSettings() + val desktopByokCloudTtsAvailable = featurePolicy.aiAndCloud && + featurePolicy.networkAccess && + sanitizedAiByokSettings.isByokCloudTtsAvailable + val desktopCreditCloudTtsControlsAvailable = + desktopBuildProfile.creditBackedCloudTtsControlsAvailable && desktopCloudConfig.isTtsWorkerConfigured + val desktopCloudTtsControlsAvailable = desktopByokCloudTtsAvailable || desktopCreditCloudTtsControlsAvailable + val desktopCloudTtsUsesCredits = desktopCreditCloudTtsControlsAvailable && !desktopByokCloudTtsAvailable val initialLibrarySnapshot = remember { libraryDatabase.load().withDesktopDefaults() } val scope = rememberCoroutineScope() - var webViewRuntimeState by remember { mutableStateOf(DesktopWebViewRuntimeState()) } - var webViewRuntimeRequested by remember { mutableStateOf(false) } + val webViewRuntimeState = remember { + DesktopWebViewRuntimeState(initialized = desktopEpubWebViewUsesNativeSwtBrowser()) + } var readerCustomTextureIds by remember { mutableStateOf(DesktopReaderTextures.importedTextureIds()) } val appWindowFullscreen = appWindowPlacement == WindowPlacement.Fullscreen @@ -223,16 +262,13 @@ internal fun EpistemeDesktopApp( enabled = readerFullscreen && !appWindowFullscreen ) - DisposableEffect(Unit) { - onDispose { - KCEF.disposeBlocking() - } - } - var shelfRecords by remember { mutableStateOf(initialLibrarySnapshot.shelfRecords) } var shelfRefs by remember { mutableStateOf(initialLibrarySnapshot.shelfRefs) } var state by remember { val initialState = initialLibrarySnapshot.toDesktopReaderScreenState().copy( + currentUser = initialDesktopAccountSession?.user, + isProUser = initialDesktopAccountProfile?.isProUser == true, + credits = initialDesktopAccountProfile?.credits ?: 0, isSyncEnabled = initialDesktopCloudSyncSettings.isSyncEnabled, isFolderSyncEnabled = initialDesktopCloudSyncSettings.isFolderSyncEnabled ) @@ -247,20 +283,45 @@ internal fun EpistemeDesktopApp( var accountStatusMessage by remember { mutableStateOf(null) } var accountBusy by remember { mutableStateOf(false) } var accountRefreshRequestCount by remember { mutableStateOf(0) } + var desktopAccountProfileRefreshCompleted by remember { + mutableStateOf( + !featurePolicy.aiAndCloud || + desktopBuildProfile.byokAiAvailable || + initialDesktopAccountSession == null + ) + } + fun requestDesktopAccountRefreshAfterUsage(usage: DesktopPaidAiUsage = DesktopPaidAiUsage()) { + if (!featurePolicy.aiAndCloud || desktopBuildProfile.byokAiAvailable) return + scope.launch { + val nextCredits = desktopCreditsAfterPaidAiUsage(state.credits, usage.cost) + if (nextCredits != state.credits) { + state = libraryProjector.projectDesktopLibraryState( + state = state.copy(credits = nextCredits), + shelfRecords = shelfRecords, + shelfRefs = shelfRefs + ) + } + accountRefreshRequestCount++ + } + } fun effectiveAiSettings(): ReaderAiByokSettings { - val hidden = aiByokSettings.hideReaderAiFeatures + val sanitized = aiByokSettings.toDesktopPersistableAiSettings() + val byokCloudTtsAvailable = featurePolicy.aiAndCloud && + featurePolicy.networkAccess && + sanitized.isByokCloudTtsAvailable return if (desktopBuildProfile.byokAiAvailable) { aiByokSettings.withDesktopFeaturePolicy(featurePolicy) } else { ReaderAiByokSettings( - hideReaderAiFeatures = hidden, - ttsSpeakerId = aiByokSettings.sanitized().ttsSpeakerId, + geminiKey = if (featurePolicy.aiAndCloud && featurePolicy.networkAccess) sanitized.geminiKey else "", + hideReaderAiFeatures = false, + ttsModel = if (featurePolicy.aiAndCloud && featurePolicy.networkAccess) sanitized.ttsModel else "", + ttsSpeakerId = sanitized.ttsSpeakerId, serverBackedReaderAiFeatures = featurePolicy.aiAndCloud && featurePolicy.networkAccess, - serverBackedCloudTts = featurePolicy.aiAndCloud && - featurePolicy.networkAccess && + serverBackedCloudTts = !byokCloudTtsAvailable && + desktopCreditCloudTtsControlsAvailable && state.currentUser != null && - state.credits > 0 && - desktopCloudConfig.isTtsWorkerConfigured + state.credits > 0 ) } } @@ -279,10 +340,7 @@ internal fun EpistemeDesktopApp( currentSignedIn = { state.currentUser != null }, currentIsProUser = { state.isProUser }, currentCredits = { state.credits }, - onUsageCompleted = { - scope.launch { accountRefreshRequestCount++ } - Unit - } + onUsageReported = ::requestDesktopAccountRefreshAfterUsage ) } } @@ -292,15 +350,15 @@ internal fun EpistemeDesktopApp( networkAccess = { featurePolicy.networkAccess }, workerUrlProvider = { desktopCloudConfig.ttsWorkerUrl }, authTokenProvider = { desktopAuthRepository.freshIdToken() }, - useWorkerProvider = { !desktopBuildProfile.byokAiAvailable }, + useWorkerProvider = { true }, onWorkerUsageCompleted = { - scope.launch { accountRefreshRequestCount++ } + requestDesktopAccountRefreshAfterUsage() Unit } ) } val desktopSummaryCacheStore = remember { DesktopSummaryCacheStore() } - var selectedTab by remember { mutableStateOf(SharedAppTab.HOME) } + var selectedTab by remember { mutableStateOf(DesktopInitialAppTab) } var selectedLibraryTab by remember { mutableStateOf(NonReaderLibraryTab.BOOKS) } var customFonts by remember { mutableStateOf(initialLibrarySnapshot.customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }) @@ -309,75 +367,23 @@ internal fun EpistemeDesktopApp( var reflowingPdfBookIds by remember { mutableStateOf>(emptySet()) } val desktopEpubPaginationCache = remember { SharedEpubPaginationCache() } var epubPaginationCacheGeneration by remember { mutableStateOf(0) } - LaunchedEffect(webViewRuntimeRequested) { - if (!shouldStartDesktopWebViewRuntime(webViewRuntimeRequested, webViewRuntimeState)) { - return@LaunchedEffect - } - - val webViewBundleDir = withContext(Dispatchers.IO) { bundledDesktopWebViewDir() } - val webViewBundlePresent = withContext(Dispatchers.IO) { - isBundledDesktopWebViewPresent(webViewBundleDir) - } - if (!webViewBundlePresent) { - webViewRuntimeState = webViewRuntimeState.copy( - errorMessage = "Bundled embedded webview is missing from ${webViewBundleDir.absolutePath}." - ) - return@LaunchedEffect - } - - runCatching { - withContext(Dispatchers.IO) { - KCEF.init( - builder = { - installDir(webViewBundleDir) - progress { - onDownloading { - webViewRuntimeState = webViewRuntimeState.copy(downloadProgress = max(it, 0f)) - } - onInitialized { - webViewRuntimeState = webViewRuntimeState.copy(initialized = true, errorMessage = null) - } - } - settings { - cachePath = File(desktopUserCacheRoot(), "kcef").absolutePath - } - }, - onError = { error -> - webViewRuntimeState = webViewRuntimeState.copy(errorMessage = error?.message ?: error.toString()) - }, - onRestartRequired = { - webViewRuntimeState = webViewRuntimeState.copy(restartRequired = true) - } - ) - } - }.onFailure { error -> - webViewRuntimeState = webViewRuntimeState.copy(errorMessage = error.message ?: error.toString()) - } - } - LaunchedEffect(readerWindows) { - if (readerWindows.any { window -> - val content = window.content - content is DesktopReaderWindowContent.Text && - content.session.reader.book.chapters.isNotEmpty() && - content.session.reader.settings.readingMode == ReaderReadingMode.VERTICAL - } - ) { - webViewRuntimeRequested = true - } - } var nextReaderOpenRequestId by remember { mutableStateOf(0L) } var showCreateShelfDialog by remember { mutableStateOf(false) } + var createShelfBookIds by remember { mutableStateOf>(emptySet()) } + var createShelfClearsSelection by remember { mutableStateOf(false) } var showCreateSmartShelfDialog by remember { mutableStateOf(false) } var shelfToRename by remember { mutableStateOf(null) } var shelfToDelete by remember { mutableStateOf(null) } var folderToRemove by remember { mutableStateOf(null) } - var showAddToShelfDialog by remember { mutableStateOf(false) } + var addToShelfBookIds by remember { mutableStateOf>(emptySet()) } + var addToShelfClearsSelection by remember { mutableStateOf(false) } + var shelfToManageBooks by remember { mutableStateOf(null) } var showTagSelectionDialog by remember { mutableStateOf(false) } var showAiByokSettingsDialog by remember { mutableStateOf(false) } var showDesktopAppThemeSettingsDialog by remember { mutableStateOf(false) } var showDesktopLanguageDialog by remember { mutableStateOf(false) } var showClearBookCacheDialog by remember { mutableStateOf(false) } - var desktopFeatureNotice by remember { mutableStateOf(null) } + var desktopFeatureNoticeState by remember { mutableStateOf(null) } var settingsQuery by remember { mutableStateOf("") } var settingsDestination by remember { mutableStateOf(SharedSettingsDestination.ROOT) } var bookInfoDialogFor by remember { mutableStateOf(null) } @@ -386,10 +392,38 @@ internal fun EpistemeDesktopApp( var dropImportState by remember { mutableStateOf(DesktopDropImportState()) } var opdsState by remember { mutableStateOf(opdsController.state) } var desktopCloudSyncJob by remember { mutableStateOf(null) } + var desktopCloudContentRetryJob by remember { mutableStateOf(null) } var pendingDesktopCloudSyncAfterActive by remember { mutableStateOf(false) } val desktopBookCloudSyncJobs = remember { mutableMapOf() } + val pendingLibraryPersistJob = remember { AtomicReference(null) } + val desktopBookSidecarSaveJobs = remember { ConcurrentHashMap() } + var readerCloudDirtyBookIds by remember { mutableStateOf>(emptySet()) } + var readerCloudDirtyBaseTimestamps by remember { mutableStateOf>(emptyMap()) } + var readerCloudDirtySidecarBookIds by remember { mutableStateOf>(emptySet()) } + var readerCloudStalePositionGuards by remember { mutableStateOf>(emptyMap()) } + var closingReaderBookIds by remember { mutableStateOf>(emptySet()) } var initialDesktopCloudSyncDone by remember { mutableStateOf(false) } val readerWindowDefaults = remember(desktopBuildProfile) { epistemeDesktopWindowDefaults(desktopBuildProfile) } + val readerWindowStateStore = remember { + DesktopWindowStateStore(DesktopWindowStateStore.defaultReaderWindowStateFile()) + } + var savedReaderWindowState by remember { + mutableStateOf(readerWindowStateStore.load()?.toPersistableReaderWindowSnapshot()) + } + + fun showDesktopFeatureNotice( + notice: DesktopFeatureNotice, + readerWindowId: String? = null + ) { + desktopFeatureNoticeState = DesktopFeatureNoticeState( + notice = notice, + placement = desktopFeatureNoticePlacement(readerWindowId) + ) + } + + fun dismissDesktopFeatureNotice() { + desktopFeatureNoticeState = null + } fun projectState( next: SharedReaderScreenState, @@ -407,46 +441,126 @@ internal fun EpistemeDesktopApp( projected: SharedReaderScreenState, records: List = shelfRecords, refs: List = shelfRefs, - fonts: List = customFonts + fonts: List = customFonts, + persistDebounceMillis: Long = 0L ) { - scope.launch(Dispatchers.IO) { + val snapshot = projected.toDesktopLibrarySnapshot( + shelfRecords = records, + shelfRefs = refs, + customFonts = fonts + ) + pendingLibraryPersistJob.getAndSet(null)?.cancel() + val persistJob = scope.launch(Dispatchers.IO) { runCatching { - libraryDatabase.save( - projected.toDesktopLibrarySnapshot( - shelfRecords = records, - shelfRefs = refs, - customFonts = fonts - ) - ) + if (persistDebounceMillis > 0L) { + delay(persistDebounceMillis) + } + libraryDatabase.save(snapshot) } } + pendingLibraryPersistJob.set(persistJob) + persistJob.invokeOnCompletion { + pendingLibraryPersistJob.compareAndSet(persistJob, null) + } } fun replaceLibrary( next: SharedReaderScreenState, records: List = shelfRecords, refs: List = shelfRefs, - fonts: List = customFonts + fonts: List = customFonts, + persistDebounceMillis: Long = 0L ) { shelfRecords = records shelfRefs = refs val projected = projectState(next, records, refs) state = projected - persistSnapshot(projected, records, refs, fonts) + persistSnapshot(projected, records, refs, fonts, persistDebounceMillis) } - fun updateState(next: SharedReaderScreenState) { + fun updateState(next: SharedReaderScreenState, persistDebounceMillis: Long = 0L) { val projected = projectState(next) state = projected - persistSnapshot(projected) + persistSnapshot(projected, persistDebounceMillis = persistDebounceMillis) + } + + fun flushDesktopPersistenceBeforeDispose( + projected: SharedReaderScreenState, + records: List, + refs: List, + fonts: List + ) { + pendingLibraryPersistJob.getAndSet(null)?.cancel() + runCatching { + libraryDatabase.save( + projected.toDesktopLibrarySnapshot( + shelfRecords = records, + shelfRefs = refs, + customFonts = fonts + ) + ) + } + + val pendingSidecarBookIds = desktopBookSidecarSaveJobs.keys.toList() + if (pendingSidecarBookIds.isEmpty()) return + + desktopBookSidecarSaveJobs.values.forEach { it.cancel() } + desktopBookSidecarSaveJobs.clear() + val booksById = projected.rawLibraryBooks.associateBy { it.id } + pendingSidecarBookIds + .mapNotNull(booksById::get) + .filter { book -> + val sourceFolder = book.sourceFolder ?: return@filter false + projected.syncedFolders.firstOrNull { it.uriString == sourceFolder }?.localSyncEnabled ?: true + } + .forEach { book -> + runCatching { DesktopLocalFolderSync.saveBookSidecars(book) } + } + } + + fun DesktopReaderWindowState.cancelReaderWork() { + when (val content = content) { + DesktopReaderWindowContent.Opening, + is DesktopReaderWindowContent.PasswordRequired, + is DesktopReaderWindowContent.Pdf -> Unit + is DesktopReaderWindowContent.Text -> content.ttsJob?.cancel() + } + } + + fun DesktopReaderWindowState.readerCloseContentLabel(): String { + return when (content) { + DesktopReaderWindowContent.Opening -> "opening" + is DesktopReaderWindowContent.PasswordRequired -> "password_required" + is DesktopReaderWindowContent.Pdf -> "pdf" + is DesktopReaderWindowContent.Text -> "text" + } } fun DesktopReaderWindowState.closeReaderResources() { - when (val content = content) { - DesktopReaderWindowContent.Opening, - is DesktopReaderWindowContent.PasswordRequired -> Unit - is DesktopReaderWindowContent.Pdf -> content.document.close() - is DesktopReaderWindowContent.Text -> content.ttsJob?.cancel() + logDesktopReaderClose( + "close_resources_begin windowId=${id.logPreview(80)} bookId=${bookId.logPreview(80)} " + + "content=${readerCloseContentLabel()} fullscreen=$fullscreen" + ) + cancelReaderWork() + runCatching { + when (val content = content) { + DesktopReaderWindowContent.Opening, + is DesktopReaderWindowContent.PasswordRequired -> Unit + is DesktopReaderWindowContent.Pdf -> content.document.close() + is DesktopReaderWindowContent.Text -> Unit + } + }.onSuccess { + logDesktopReaderClose( + "close_resources_end windowId=${id.logPreview(80)} bookId=${bookId.logPreview(80)} " + + "content=${readerCloseContentLabel()}" + ) + }.onFailure { error -> + logDesktopReaderClose( + "close_resources_fail windowId=${id.logPreview(80)} bookId=${bookId.logPreview(80)} " + + "content=${readerCloseContentLabel()} error=\"${error.message.orEmpty().logPreview(240)}\" " + + "type=${error.javaClass.simpleName}" + ) + throw error } } @@ -470,46 +584,46 @@ internal fun EpistemeDesktopApp( return readerWindows.firstOrNull { it.id == windowId }?.content as? DesktopReaderWindowContent.Text } - fun closeReaderWindow(windowId: String) { - val closing = readerWindows.firstOrNull { it.id == windowId } ?: return - val shouldStopTts = (closing.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { - it.isLoading || it.isPlaying || it.isPaused - } == true - closing.closeReaderResources() - if (shouldStopTts) { - scope.launch { desktopTtsAdapter.stop() } + fun saveReaderWindowStateSnapshot(snapshot: DesktopWindowStateSnapshot?) { + val persistable = snapshot?.toPersistableReaderWindowSnapshot() ?: return + savedReaderWindowState = persistable + scope.launch(Dispatchers.IO) { + runCatching { readerWindowStateStore.save(persistable) } } - readerWindows = readerWindows.withoutDesktopReaderWindow(windowId) - updateState(state.reduce(AppAction.BookTabClosed(closing.bookId))) } - fun closeReaderWindowsForBookIds(bookIds: Set) { + fun markReaderCloudDirty( + bookId: String, + baseTimestamp: Long? = null, + sidecarsDirty: Boolean = false + ) { + if (bookId.isBlank()) return + if (bookId !in readerCloudDirtyBookIds) { + val resolvedBaseTimestamp = baseTimestamp + ?: state.rawLibraryBooks.firstOrNull { it.id == bookId }?.timestamp + ?: 0L + readerCloudDirtyBaseTimestamps = readerCloudDirtyBaseTimestamps + (bookId to resolvedBaseTimestamp) + logDesktopCloudSync { + "desktop.reader.dirty_start book=$bookId baseTs=$resolvedBaseTimestamp sidecarsDirty=$sidecarsDirty" + } + } + if (sidecarsDirty) { + readerCloudDirtySidecarBookIds = readerCloudDirtySidecarBookIds + bookId + } + readerCloudDirtyBookIds = readerCloudDirtyBookIds + bookId + } + + fun clearReaderCloudDirty(bookIds: Set) { if (bookIds.isEmpty()) return - val closing = readerWindows.filter { it.bookId in bookIds } - val shouldStopTts = closing.any { window -> - (window.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { - it.isLoading || it.isPlaying || it.isPaused - } == true - } - closing.forEach { it.closeReaderResources() } - if (shouldStopTts) { - scope.launch { desktopTtsAdapter.stop() } - } - readerWindows = readerWindows.withoutDesktopReaderBookIds(bookIds) + readerCloudDirtyBookIds = readerCloudDirtyBookIds - bookIds + readerCloudDirtyBaseTimestamps = readerCloudDirtyBaseTimestamps - bookIds + readerCloudDirtySidecarBookIds = readerCloudDirtySidecarBookIds - bookIds } - fun closeAllReaderWindows() { - val shouldStopTts = readerWindows.any { window -> - (window.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { - it.isLoading || it.isPlaying || it.isPaused - } == true - } - readerWindows.forEach { it.closeReaderResources() } - if (shouldStopTts) { - scope.launch { desktopTtsAdapter.stop() } - } - readerWindows = emptyList() - updateState(state.reduce(AppAction.AllTabsClosed)) + fun markReaderBooksClosing(bookIds: Set) { + if (bookIds.isEmpty()) return + closingReaderBookIds = closingReaderBookIds + bookIds + readerCloudStalePositionGuards = readerCloudStalePositionGuards - bookIds } fun downloadReaderImage(image: ReaderImageReference) { @@ -524,6 +638,23 @@ internal fun EpistemeDesktopApp( } } + fun saveDesktopOriginalFile(book: BookItem) { + val source = book.path?.let(::File) + if (source?.isFile != true) { + updateState(state.withBanner("Original file is not available.", isError = true)) + return + } + val target = chooseSaveBookFile(book.desktopSuggestedOriginalFileName()) ?: return + runCatching { + target.parentFile?.mkdirs() + source.copyTo(target, overwrite = true) + }.onSuccess { + updateState(state.withBanner("Saved ${target.name}.")) + }.onFailure { error -> + updateState(state.withBanner(error.message ?: "Could not save file.", isError = true)) + } + } + fun clearDesktopBookCache() { scope.launch { withContext(Dispatchers.IO) { @@ -542,6 +673,12 @@ internal fun EpistemeDesktopApp( desktopCloudConfig.isAuthConfigured } + fun desktopAccountAvailable(): Boolean { + return featurePolicy.aiAndCloud && + featurePolicy.networkAccess && + !desktopBuildProfile.byokAiAvailable + } + fun saveDesktopCloudSyncSettings( syncEnabled: Boolean = state.isSyncEnabled, folderSyncEnabled: Boolean = state.isFolderSyncEnabled @@ -555,44 +692,55 @@ internal fun EpistemeDesktopApp( } suspend fun refreshDesktopAccountProfile(showBanner: Boolean = false) { - if (!featurePolicy.aiAndCloud || desktopBuildProfile.byokAiAvailable) return + if (!featurePolicy.aiAndCloud || desktopBuildProfile.byokAiAvailable) { + desktopAccountProfileRefreshCompleted = true + return + } val session = desktopAuthRepository.restoreSavedSession() if (session == null) { if (state.isSyncEnabled) saveDesktopCloudSyncSettings(syncEnabled = false) updateState(state.copy(currentUser = null, isProUser = false, credits = 0, isSyncEnabled = false)) + desktopAccountProfileRefreshCompleted = true return } val token = desktopAuthRepository.freshIdToken() if (token.isNullOrBlank()) { if (state.isSyncEnabled) saveDesktopCloudSyncSettings(syncEnabled = false) updateState(state.copy(currentUser = null, isProUser = false, credits = 0, isSyncEnabled = false)) + desktopAccountProfileRefreshCompleted = true return } runCatching { desktopAccountProfileRepository.fetchProfile(session.user.uid, token) }.onSuccess { profile -> - val nextSyncEnabled = state.isSyncEnabled && profile.isProUser - if (!nextSyncEnabled && state.isSyncEnabled) { - saveDesktopCloudSyncSettings(syncEnabled = false) - } - updateState( - state.copy( - currentUser = session.user, - isProUser = profile.isProUser, - credits = profile.credits, - isSyncEnabled = nextSyncEnabled + if (desktopAuthRepository.currentSession()?.user?.uid == session.user.uid) { + desktopAccountProfileRepository.saveFetchedProfile(session.user.uid, profile) + val nextSyncEnabled = state.isSyncEnabled && profile.isProUser + if (!nextSyncEnabled && state.isSyncEnabled) { + saveDesktopCloudSyncSettings(syncEnabled = false) + } + updateState( + state.copy( + currentUser = session.user, + isProUser = profile.isProUser, + credits = profile.credits, + isSyncEnabled = nextSyncEnabled + ) ) - ) - accountStatusMessage = if (profile.isProUser) { - "Account checked. Pro is unlocked." - } else { - "Account checked. Pro is not unlocked." + accountStatusMessage = if (profile.isProUser) { + "Account checked. Pro is unlocked." + } else { + "Account checked. Pro is not unlocked." + } + if (showBanner) updateState(state.withBanner("Account status refreshed.")) } - if (showBanner) updateState(state.withBanner("Account status refreshed.")) }.onFailure { error -> - accountStatusMessage = error.message ?: "Could not check account status." - if (showBanner) updateState(state.withBanner(accountStatusMessage.orEmpty(), isError = true)) + if (desktopAuthRepository.currentSession()?.user?.uid == session.user.uid) { + accountStatusMessage = error.message ?: "Could not check account status." + if (showBanner) updateState(state.withBanner(accountStatusMessage.orEmpty(), isError = true)) + } } + desktopAccountProfileRefreshCompleted = true } fun signInDesktopAccount() { @@ -607,7 +755,8 @@ internal fun EpistemeDesktopApp( desktopAuthRepository.signIn(::openExternalUrl) }.onSuccess { session -> updateState(state.copy(currentUser = session.user, isProUser = false, credits = 0)) - accountStatusMessage = "Signed in. Checking Pro and credits..." + accountStatusMessage = "Signed in. Checking account and credits..." + desktopAccountProfileRefreshCompleted = false refreshDesktopAccountProfile() }.onFailure { error -> accountStatusMessage = error.message ?: "Google sign-in failed." @@ -663,6 +812,7 @@ internal fun EpistemeDesktopApp( val details = buildList { if (result.uploadedBooks > 0) add("Uploaded ${result.uploadedBooks}.") if (result.downloadedBooks > 0) add("Downloaded ${result.downloadedBooks}.") + if (result.pendingContentDownloads > 0) add("Waiting for ${result.pendingContentDownloads} upload(s) to finish.") } return if (details.isEmpty()) { "Cloud sync complete." @@ -672,14 +822,27 @@ internal fun EpistemeDesktopApp( } fun syncDesktopCloud(showBanner: Boolean = false): Job { - desktopCloudSyncJob?.takeIf { it.isActive }?.let { return it } + desktopCloudSyncJob?.takeIf { it.isActive }?.let { + logDesktopCloudSync { "desktop.full_sync.reuse_active showBanner=$showBanner" } + return it + } val job = scope.launch { - if (!state.isSyncEnabled) return@launch - val credentials = desktopCloudSyncCredentials(showBanner) ?: return@launch + if (!state.isSyncEnabled) { + logDesktopCloudSync { "desktop.full_sync.skip reason=sync_disabled showBanner=$showBanner" } + return@launch + } + val credentials = desktopCloudSyncCredentials(showBanner) ?: run { + logDesktopCloudSync { "desktop.full_sync.skip reason=missing_credentials showBanner=$showBanner" } + return@launch + } val snapshotState = state val snapshotShelfRecords = shelfRecords val snapshotShelfRefs = shelfRefs val snapshotFonts = customFonts + logDesktopCloudSync { + "desktop.full_sync.start user=${credentials.userId} device=${credentials.deviceId} showBanner=$showBanner " + + "books=${snapshotState.rawLibraryBooks.size} shelves=${snapshotShelfRecords.size} folderSync=${snapshotState.isFolderSyncEnabled}" + } if (showBanner) { updateState(state.copy(isRefreshing = true).withBanner("Cloud sync: checking library...")) @@ -702,6 +865,43 @@ internal fun EpistemeDesktopApp( ) } }.onSuccess { result -> + val openBookIds = readerWindows.mapTo(mutableSetOf()) { it.bookId } + val snapshotBooksById = snapshotState.rawLibraryBooks.associateBy { it.id } + val syncedBooksById = result.state.rawLibraryBooks.associateBy { it.id } + val staleGuards = openBookIds.mapNotNull { bookId -> + val before = snapshotBooksById[bookId] ?: return@mapNotNull null + val after = syncedBooksById[bookId] ?: return@mapNotNull null + if (after.timestamp > before.timestamp && !before.hasSameCloudReaderPosition(after)) { + bookId to before + } else { + null + } + }.toMap() + if (staleGuards.isNotEmpty()) { + readerCloudStalePositionGuards = readerCloudStalePositionGuards + staleGuards + clearReaderCloudDirty(staleGuards.keys) + logDesktopCloudSync { + "desktop.full_sync.open_reader_guard books=${staleGuards.keys.joinToString()} " + + "reason=remote_advanced_while_reader_open" + } + } + logDesktopCloudSync { + "desktop.full_sync.success user=${credentials.userId} uploaded=${result.uploadedBooks} " + + "downloaded=${result.downloadedBooks} pendingContent=${result.pendingContentDownloads} " + + "books=${result.state.rawLibraryBooks.size}" + } + if (result.pendingContentDownloads <= 0) { + desktopCloudContentRetryJob?.cancel() + desktopCloudContentRetryJob = null + } else if (desktopCloudContentRetryJob?.isActive != true) { + desktopCloudContentRetryJob = scope.launch { + delay(DesktopCloudContentRetryDelayMillis) + if (state.isSyncEnabled) { + logDesktopCloudSync { "desktop.full_sync.content_retry pending=${result.pendingContentDownloads}" } + syncDesktopCloud(showBanner = false).join() + } + } + } customFonts = result.customFonts val syncedState = result.state.copy( isSyncEnabled = state.isSyncEnabled, @@ -715,6 +915,7 @@ internal fun EpistemeDesktopApp( fonts = result.customFonts ) }.onFailure { error -> + logDesktopCloudSync { "desktop.full_sync.failed user=${credentials.userId} error=${error.message.orEmpty()}" } val failed = state.copy(isRefreshing = false) if (showBanner) { updateState(failed.withBanner(error.message ?: "Cloud sync failed.", isError = true)) @@ -761,26 +962,176 @@ internal fun EpistemeDesktopApp( } } - fun queueCloudBookMetadataSync(book: BookItem, uploadContent: Boolean = false) { + fun queueCloudBookMetadataSync( + book: BookItem, + uploadContent: Boolean = false, + debounce: Boolean = true, + dirtyBaseTimestamp: Long? = null, + forceUploadAnnotations: Boolean = false + ) { if (!state.isSyncEnabled) return if (isDesktopPdfReflowBookId(book.id)) return if (book.sourceFolder != null) return if (book.path?.startsWith("opds-pse") == true) return if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) return + logDesktopCloudSync { + "desktop.book_queue.request uploadContent=$uploadContent debounce=$debounce dirtyBaseTs=$dirtyBaseTimestamp " + + "forceAnnotations=$forceUploadAnnotations ${book.desktopCloudSyncSummary()}" + } desktopBookCloudSyncJobs.remove(book.id)?.cancel() val job = scope.launch { - if (!uploadContent) delay(1_200L) - val credentials = desktopCloudSyncCredentials(showBanner = false) ?: return@launch - val latestBook = state.rawLibraryBooks.firstOrNull { it.id == book.id } ?: return@launch + if (!uploadContent && debounce) delay(1_200L) + val credentials = desktopCloudSyncCredentials(showBanner = false) ?: run { + logDesktopCloudSync { "desktop.book_queue.skip reason=missing_credentials book=${book.id}" } + return@launch + } + val latestBook = state.rawLibraryBooks.firstOrNull { it.id == book.id } ?: run { + logDesktopCloudSync { "desktop.book_queue.skip reason=missing_local book=${book.id}" } + return@launch + } if (isDesktopPdfReflowBookId(latestBook.id)) return@launch if (latestBook.sourceFolder != null) return@launch + if (latestBook.path?.startsWith("opds-pse") == true) return@launch + if (SharedFileCapabilities.isManualOnlyReaderFileName(latestBook.displayName)) return@launch if (uploadContent) { updateState(state.copy(uploadingBookIds = state.uploadingBookIds + latestBook.id)) } try { + val remoteBook = withContext(Dispatchers.IO) { + desktopFirestoreRepository.getBookMetadata( + userId = credentials.userId, + bookId = latestBook.id, + idToken = credentials.idToken + ) + } + val localSidecarTimestamp = withContext(Dispatchers.IO) { + DesktopCloudSidecarSync.localAnnotationTimestamp(latestBook) + } + val hasLocalAnnotations = withContext(Dispatchers.IO) { + DesktopCloudSidecarSync.hasLocalAnnotationData(latestBook) + } + val remoteAnnotationDriveTimestamp = if (remoteBook?.hasAnnotations == true) { + withContext(Dispatchers.IO) { + desktopGoogleDriveRepository.getFileByName( + credentials.driveAccessToken, + desktopCloudAnnotationDriveFileName(latestBook.id) + )?.modifiedTimeMillis ?: 0L + } + } else { + 0L + } + val localReadingTimestamp = latestBook.effectiveCloudReadingPositionModifiedTimestamp() + val remoteReadingTimestamp = remoteBook?.effectiveCloudReadingPositionModifiedTimestamp() ?: 0L + val remoteAnnotationTimestamp = remoteBook?.effectiveCloudAnnotationModifiedTimestamp( + remoteAnnotationDriveTimestamp + ) ?: 0L + val latestBookForMetadata = if (remoteBook != null && remoteReadingTimestamp > localReadingTimestamp) { + latestBook.withCloudReadingPosition(remoteBook) + } else { + latestBook + } + val localFile = latestBook.path?.let(::File) + val localFileAvailable = localFile?.isFile == true + val localContentTimestamp = latestBook.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: localFile?.takeIf { it.isFile }?.lastModified() + ?: 0L + val remoteChangedSinceDirtyStart = dirtyBaseTimestamp != null && + remoteBook != null && + remoteBook.lastModifiedTimestamp != dirtyBaseTimestamp + logDesktopCloudSync { + "desktop.book_queue.preflight book=${latestBook.id} dirtyBaseTs=$dirtyBaseTimestamp " + + "remoteChangedSinceDirtyStart=$remoteChangedSinceDirtyStart " + + latestBook.desktopCloudSyncSummary() + " " + + (remoteBook?.desktopCloudSyncSummary() ?: "remote=null") + + " localSidecarTs=$localSidecarTimestamp localContentTs=$localContentTimestamp" + } + logDesktopCloudAnnotations { + "desktop.queue.inspect book=${latestBook.id} dirtyBaseTs=$dirtyBaseTimestamp " + + "remoteChangedSinceDirtyStart=$remoteChangedSinceDirtyStart " + + "remoteHas=${remoteBook?.hasAnnotations} remoteTs=${remoteBook?.lastModifiedTimestamp ?: 0L} " + + "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + + "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " + + "localHas=$hasLocalAnnotations localSidecarTs=$localSidecarTimestamp " + + DesktopCloudSidecarSync.localAnnotationDebugSummary(latestBook) + } + if (remoteChangedSinceDirtyStart && !(forceUploadAnnotations && hasLocalAnnotations)) { + logDesktopCloudAnnotations { + "desktop.queue.skip_upload book=${latestBook.id} reason=remote_changed_since_dirty " + + "dirtyBaseTs=$dirtyBaseTimestamp remoteTs=${remoteBook?.lastModifiedTimestamp ?: 0L}" + } + logDesktopCloudSync { "desktop.book_queue.decision action=pull_remote_changed_since_dirty book=${latestBook.id}" } + syncDesktopCloud(showBanner = false).join() + return@launch + } + val canUploadMetadata = remoteBook == null || shouldUploadLocalCloudBookMetadataUpdate( + localModifiedTimestamp = latestBook.timestamp, + remoteModifiedTimestamp = remoteBook.lastModifiedTimestamp + ) + val canUploadContent = when { + remoteBook?.isDeleted == true && canUploadMetadata -> localFileAvailable + uploadContent -> shouldUploadLocalCloudBookContent( + localFileAvailable = localFileAvailable, + localContentModifiedTimestamp = localContentTimestamp, + remoteContentModifiedTimestamp = remoteBook?.fileContentModifiedTimestamp + ) + else -> false + } + val canUploadAnnotations = (forceUploadAnnotations && hasLocalAnnotations) || + (hasLocalAnnotations && + (remoteBook == null || + !remoteBook.hasAnnotations || + localSidecarTimestamp > remoteAnnotationTimestamp)) + val shouldApplyRemote = remoteBook != null && shouldApplyRemoteCloudBookMetadataUpdate( + localModifiedTimestamp = latestBook.timestamp, + remoteModifiedTimestamp = remoteBook.lastModifiedTimestamp + ) + + if (remoteBook != null && !canUploadMetadata && !canUploadContent && !canUploadAnnotations) { + logDesktopCloudAnnotations { + "desktop.queue.no_upload book=${latestBook.id} canUploadAnnotations=$canUploadAnnotations " + + "canUploadMetadata=$canUploadMetadata shouldApplyRemote=$shouldApplyRemote " + + "remoteHas=${remoteBook.hasAnnotations} remoteTs=${remoteBook.lastModifiedTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + + "localSidecarTs=$localSidecarTimestamp" + } + logDesktopCloudSync { + "desktop.book_queue.decision action=${if (remoteBook.isDeleted || shouldApplyRemote) "pull_remote" else "noop"} " + + "book=${latestBook.id} canUploadMetadata=$canUploadMetadata canUploadContent=$canUploadContent " + + "canUploadAnnotations=$canUploadAnnotations shouldApplyRemote=$shouldApplyRemote" + } + if (remoteBook.isDeleted || shouldApplyRemote) { + syncDesktopCloud(showBanner = false).join() + } + return@launch + } + + val usesRemoteMetadataForUpload = !canUploadMetadata && shouldApplyRemote && remoteBook != null + val bookForUpload = if (usesRemoteMetadataForUpload && remoteBook != null) { + remoteBook.toDesktopBookItem(existing = latestBook).let { remoteMetadataBook -> + if (canUploadContent) { + remoteMetadataBook.copy(fileContentModifiedTimestamp = localContentTimestamp) + } else { + remoteMetadataBook + } + } + } else { + latestBookForMetadata + } + logDesktopCloudSync { + "desktop.book_queue.decision action=${if (usesRemoteMetadataForUpload) "upload_annotations_with_remote_metadata" else "upload_local"} " + + "book=${latestBook.id} canUploadMetadata=$canUploadMetadata canUploadContent=$canUploadContent " + + "canUploadAnnotations=$canUploadAnnotations shouldApplyRemote=$shouldApplyRemote" + } + logDesktopCloudAnnotations { + "desktop.queue.upload book=${latestBook.id} action=${if (usesRemoteMetadataForUpload) "upload_annotations_with_remote_metadata" else "upload_local"} " + + "canUploadAnnotations=$canUploadAnnotations canUploadMetadata=$canUploadMetadata " + + "remoteHas=${remoteBook?.hasAnnotations} remoteTs=${remoteBook?.lastModifiedTimestamp ?: 0L} " + + "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + + "localSidecarTs=$localSidecarTimestamp" + } val syncedBook = withContext(Dispatchers.IO) { desktopCloudSync.uploadBookAndMetadata( input = DesktopCloudSyncInput( @@ -794,16 +1145,24 @@ internal fun EpistemeDesktopApp( customFonts = customFonts, includeFolderBooks = state.isFolderSyncEnabled ), - book = latestBook, - uploadContent = uploadContent + book = bookForUpload, + uploadContent = canUploadContent, + uploadAnnotations = canUploadAnnotations, + remoteHasAnnotations = remoteBook?.hasAnnotations == true, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, + remoteContentModifiedTimestamp = remoteBook?.fileContentModifiedTimestamp ) } ?: return@launch + logDesktopCloudSync { + "desktop.book_queue.upload_success oldTs=${latestBook.timestamp} newTs=${syncedBook.timestamp} " + + syncedBook.desktopCloudSyncSummary("synced") + } updateState( state.copy( rawLibraryBooks = state.rawLibraryBooks.map { current -> if (current.id == syncedBook.id && current.timestamp == latestBook.timestamp) { - current.copy(timestamp = syncedBook.timestamp) + syncedBook } else { current } @@ -824,6 +1183,128 @@ internal fun EpistemeDesktopApp( } } + fun syncClosedReaderBooksIfDirty(bookIds: Set) { + val dirtyBookIds = bookIds.intersect(readerCloudDirtyBookIds) + if (dirtyBookIds.isEmpty()) return + logDesktopCloudSync { "desktop.reader.close_dirty books=${dirtyBookIds.joinToString()} requested=${bookIds.joinToString()}" } + val dirtyBooks = dirtyBookIds.mapNotNull { bookId -> + state.rawLibraryBooks.firstOrNull { it.id == bookId } + ?.let { book -> + Triple( + book, + readerCloudDirtyBaseTimestamps[bookId], + bookId in readerCloudDirtySidecarBookIds + ) + } + } + clearReaderCloudDirty(dirtyBookIds) + dirtyBooks.forEach { (book, baseTimestamp, sidecarsDirty) -> + queueCloudBookMetadataSync( + book = book, + debounce = false, + dirtyBaseTimestamp = baseTimestamp, + forceUploadAnnotations = sidecarsDirty + ) + } + } + + fun syncClosedReaderBooksAfterDispose(bookIds: Set) { + if (bookIds.isEmpty()) return + scope.launch { + delay(DesktopReaderCloseDisposeSyncDelayMillis) + val stillClosedBookIds = bookIds + .filter { bookId -> readerWindows.none { it.bookId == bookId } } + .toSet() + closingReaderBookIds = closingReaderBookIds - bookIds + readerCloudStalePositionGuards = readerCloudStalePositionGuards - stillClosedBookIds + syncClosedReaderBooksIfDirty(stillClosedBookIds) + clearReaderCloudDirty(stillClosedBookIds) + } + } + + fun closeReaderWindow(windowId: String) { + logDesktopReaderClose("close_window_request windowId=${windowId.logPreview(80)} openWindows=${readerWindows.size}") + val closing = readerWindows.firstOrNull { it.id == windowId } ?: run { + logDesktopReaderClose("close_window_missing windowId=${windowId.logPreview(80)} openWindows=${readerWindows.size}") + return + } + val closingBookIds = setOf(closing.bookId) + val shouldStopTts = (closing.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { + it.isLoading || it.isPlaying || it.isPaused + } == true + logDesktopReaderClose( + "close_window_begin windowId=${closing.id.logPreview(80)} bookId=${closing.bookId.logPreview(80)} " + + "content=${closing.readerCloseContentLabel()} fullscreen=${closing.fullscreen} shouldStopTts=$shouldStopTts" + ) + if (desktopFeatureNoticeState?.placement?.readerWindowId == windowId) { + dismissDesktopFeatureNotice() + } + markReaderBooksClosing(closingBookIds) + closing.cancelReaderWork() + readerWindows = readerWindows.withoutDesktopReaderWindow(windowId) + logDesktopReaderClose( + "close_window_removed windowId=${closing.id.logPreview(80)} bookId=${closing.bookId.logPreview(80)} " + + "remainingWindows=${readerWindows.size}" + ) + updateState(state.reduce(AppAction.BookTabClosed(closing.bookId))) + if (shouldStopTts) { + scope.launch { desktopTtsAdapter.stop() } + } + syncClosedReaderBooksAfterDispose(closingBookIds) + } + + fun closeReaderWindowsForBookIds(bookIds: Set) { + if (bookIds.isEmpty()) return + val closing = readerWindows.filter { it.bookId in bookIds } + val closingBookIds = closing.mapTo(mutableSetOf()) { it.bookId } + val closingWindowIds = closing.mapTo(mutableSetOf()) { it.id } + val shouldStopTts = closing.any { window -> + (window.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { + it.isLoading || it.isPlaying || it.isPaused + } == true + } + val targetNoticeWindowId = desktopFeatureNoticeState?.placement?.readerWindowId + if (targetNoticeWindowId != null && targetNoticeWindowId in closingWindowIds) { + dismissDesktopFeatureNotice() + } + markReaderBooksClosing(closingBookIds) + closing.forEach { it.cancelReaderWork() } + readerWindows = readerWindows.withoutDesktopReaderBookIds(bookIds) + if (closingBookIds.isNotEmpty()) { + var nextState = state + closingBookIds.forEach { bookId -> + nextState = nextState.reduce(AppAction.BookTabClosed(bookId)) + } + updateState(nextState) + } + if (shouldStopTts) { + scope.launch { desktopTtsAdapter.stop() } + } + closingReaderBookIds = closingReaderBookIds - closingBookIds + readerCloudStalePositionGuards = readerCloudStalePositionGuards - closingBookIds + clearReaderCloudDirty(closingBookIds) + } + + fun closeAllReaderWindows() { + val closingBookIds = readerWindows.mapTo(mutableSetOf()) { it.bookId } + val shouldStopTts = readerWindows.any { window -> + (window.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { + it.isLoading || it.isPlaying || it.isPaused + } == true + } + markReaderBooksClosing(closingBookIds) + readerWindows.forEach { it.cancelReaderWork() } + readerWindows = emptyList() + if (desktopFeatureNoticeState?.placement?.readerWindowId != null) { + dismissDesktopFeatureNotice() + } + updateState(state.reduce(AppAction.AllTabsClosed)) + if (shouldStopTts) { + scope.launch { desktopTtsAdapter.stop() } + } + syncClosedReaderBooksAfterDispose(closingBookIds) + } + fun syncCloudShelfChange(record: ShelfRecord, refs: List, isDeleted: Boolean = false) { if (!state.isSyncEnabled || record.isSmart) return scope.launch { @@ -891,20 +1372,21 @@ internal fun EpistemeDesktopApp( } fun updateAiByokSettings(next: ReaderAiByokSettings) { - val sanitized = next.sanitized() - val settingsToSave = if (!desktopBuildProfile.byokAiAvailable) { - aiByokSettings.sanitized().copy( - hideReaderAiFeatures = sanitized.hideReaderAiFeatures, - ttsSpeakerId = sanitized.ttsSpeakerId - ) - } else { - logDesktopTts( - "settings_update keyPresent=${sanitized.geminiKey.isNotBlank()} " + - "ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\" speaker=\"${sanitized.ttsSpeakerId.desktopTtsPreview()}\" " + - "cloudAvailable=${sanitized.isCloudTtsAvailable}" - ) - sanitized + val sanitized = next.toDesktopPersistableAiSettings() + if (sanitized.ttsSpeakerId != aiByokSettings.toDesktopPersistableAiSettings().ttsSpeakerId && desktopTtsAdapter.isPlaybackActive) { + scope.launch { + snackbarHostState.showSnackbar( + desktopString("desktop_stop_reading_change_voices", "Stop reading to change voices.") + ) + } + return } + val settingsToSave = sanitized + logDesktopTts( + "settings_update keyPresent=${sanitized.geminiKey.isNotBlank()} " + + "ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\" speaker=\"${sanitized.ttsSpeakerId.desktopTtsPreview()}\" " + + "cloudAvailable=${sanitized.isCloudTtsAvailable}" + ) aiByokSettings = settingsToSave readerWindows = readerWindows.replaceAllDesktopTextReaderContent { content -> @@ -930,12 +1412,6 @@ internal fun EpistemeDesktopApp( } } - fun updateReaderAutoScroll(windowId: String, autoScroll: ReaderAutoScrollState) { - updateTextReaderWindow(windowId) { content -> - content.copy(extrasState = content.extrasState.copy(autoScroll = autoScroll.sanitized())) - } - } - fun textReaderTtsCacheSummary(content: DesktopReaderWindowContent.Text): ReaderTtsCacheSummary { return desktopTtsAdapter.cacheSummary( content.session.reader.book.title, @@ -955,10 +1431,15 @@ internal fun EpistemeDesktopApp( ) fun cloudTtsUnavailableMessage(): String { - return if (desktopBuildProfile.byokAiAvailable) { + return if (!featurePolicy.aiAndCloud || !featurePolicy.networkAccess) { desktopString( - "desktop_cloud_tts_needs_gemini_key_desc", - "Add a Gemini key and select Gemini cloud TTS in AI keys and models." + "desktop_cloud_tts_unavailable", + "Cloud TTS unavailable" + ) + } else if (!desktopByokCloudTtsAvailable && !desktopCreditCloudTtsControlsAvailable) { + desktopString( + "desktop_cloud_tts_not_configured_desc", + "Cloud TTS is not configured for this desktop build." ) } else if (state.currentUser == null) { desktopString("desktop_cloud_tts_sign_in_required_desc", "Sign in with Google to use cloud TTS.") @@ -991,12 +1472,6 @@ internal fun EpistemeDesktopApp( messageFallback = "Desktop AI is not configured for this build." ) } - if (effectiveAiSettings().hideReaderAiFeatures) { - return desktopFeatureUnavailableNotice( - messageKey = "desktop_reader_ai_hidden_desc", - messageFallback = "Reader AI features are hidden." - ) - } if (feature == ReaderAiFeature.DEFINE && desktopReaderWordCount(text) > 1 && state.currentUser == null) { return desktopSignInRequiredNotice( messageKey = "desktop_sign_in_required_multi_word_dictionary_desc", @@ -1037,8 +1512,14 @@ internal fun EpistemeDesktopApp( } fun desktopFeatureNoticeForCloudTts(): DesktopFeatureNotice? { - if (desktopBuildProfile.byokAiAvailable) return null - if (!featurePolicy.networkAccess || !desktopCloudConfig.isTtsWorkerConfigured) { + if (!featurePolicy.aiAndCloud || !featurePolicy.networkAccess) { + return desktopFeatureUnavailableNotice( + messageKey = "desktop_cloud_tts_not_configured_desc", + messageFallback = "Cloud TTS is not configured for this desktop build." + ) + } + if (desktopByokCloudTtsAvailable) return null + if (!desktopCreditCloudTtsControlsAvailable) { return desktopFeatureUnavailableNotice( messageKey = "desktop_cloud_tts_not_configured_desc", messageFallback = "Cloud TTS is not configured for this desktop build." @@ -1147,7 +1628,7 @@ internal fun EpistemeDesktopApp( } } desktopFeatureNoticeForReaderAi(ReaderAiFeature.SUMMARIZE, text)?.let { notice -> - desktopFeatureNotice = notice + showDesktopFeatureNotice(notice, readerWindowId = windowId) return } updateTextReaderWindow(windowId) { it.copy(isSummaryLoading = true, summaryResult = null) } @@ -1189,7 +1670,7 @@ internal fun EpistemeDesktopApp( isSummaryLoading = false ) } - desktopFeatureNoticeForError(result.error)?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForError(result.error)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } } } @@ -1201,7 +1682,7 @@ internal fun EpistemeDesktopApp( return } desktopFeatureNoticeForReaderAi(ReaderAiFeature.RECAP, currentText)?.let { notice -> - desktopFeatureNotice = notice + showDesktopFeatureNotice(notice, readerWindowId = windowId) return } val book = content.session.reader.book @@ -1233,7 +1714,7 @@ internal fun EpistemeDesktopApp( pastSummaries += generated } if (summary.error != null) { - desktopFeatureNoticeForError(summary.error)?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForError(summary.error)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } } delay(500) } @@ -1258,7 +1739,7 @@ internal fun EpistemeDesktopApp( recapProgressMessage = null ) } - desktopFeatureNoticeForError(recap.error)?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForError(recap.error)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } } } @@ -1281,7 +1762,7 @@ internal fun EpistemeDesktopApp( if (normalizedText.isBlank()) return if (!effectiveAiSettings().areReaderAiFeaturesAvailable) return desktopFeatureNoticeForReaderAi(feature, normalizedText)?.let { notice -> - desktopFeatureNotice = notice + showDesktopFeatureNotice(notice, readerWindowId = windowId) return } val aiResultRequestId = content.readerAiResultRequestId + 1 @@ -1392,22 +1873,133 @@ internal fun EpistemeDesktopApp( ) val latest = textReaderWindowContent(windowId) if (latest != null && isReaderAiResultVisible(latest, aiResultRequestId)) { - desktopFeatureNoticeForError(result.second)?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForError(result.second)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } } } } - fun syncBookSidecars(book: BookItem) { + fun isDesktopFolderLocalSyncEnabled(sourceFolder: String?): Boolean { + if (sourceFolder.isNullOrBlank()) return false + return state.syncedFolders.firstOrNull { it.uriString == sourceFolder }?.localSyncEnabled ?: true + } + + fun syncBookSidecars(book: BookItem, debounceMillis: Long = 0L) { if (book.sourceFolder.isNullOrBlank()) { logDesktopFolderSync("bookSidecars.skipNoFolder book=${book.id}") return } + if (!isDesktopFolderLocalSyncEnabled(book.sourceFolder)) { + logDesktopFolderSync( + "bookSidecars.skipDisabled book=${book.id} " + + "sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\"" + ) + return + } logDesktopFolderSync( "bookSidecars.request book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\"" ) - scope.launch(Dispatchers.IO) { + desktopBookSidecarSaveJobs.remove(book.id)?.cancel() + val saveJob = scope.launch(Dispatchers.IO) { + if (debounceMillis > 0L) { + delay(debounceMillis) + } DesktopLocalFolderSync.saveBookSidecars(book) } + desktopBookSidecarSaveJobs[book.id] = saveJob + saveJob.invokeOnCompletion { + desktopBookSidecarSaveJobs.remove(book.id, saveJob) + } + } + + fun scheduleFolderMetadataExtraction(sourceFolders: Set) { + val enabledSourceFolders = sourceFolders.filterTo(mutableSetOf()) { isDesktopFolderLocalSyncEnabled(it) } + if (enabledSourceFolders.isEmpty()) return + val snapshotBooks = state.rawLibraryBooks + val originalBooksById = snapshotBooks + .filter { it.sourceFolder in enabledSourceFolders } + .associateBy { it.id } + if (originalBooksById.isEmpty()) return + + scope.launch { + val metadataResult = withContext(Dispatchers.IO) { + DesktopFolderMetadataExtractor.enrichFolderBooks( + books = snapshotBooks, + sourceFolders = enabledSourceFolders + ) + } + if (metadataResult.stats.updatedBooks <= 0) return@launch + + val enrichedBooksById = metadataResult.books + .filter { it.sourceFolder in enabledSourceFolders } + .associateBy { it.id } + val booksToSave = mutableListOf() + val mergedBooks = state.rawLibraryBooks.map { current -> + val enriched = enrichedBooksById[current.id] + ?.takeIf { current.sourceFolder in enabledSourceFolders } + ?: return@map current + val merged = current.withDesktopImportMetadata( + enriched = enriched, + original = originalBooksById[current.id] + ) + if (merged != current) booksToSave += merged + merged + } + if (booksToSave.isEmpty()) return@launch + + updateState(state.copy(rawLibraryBooks = mergedBooks)) + withContext(Dispatchers.IO) { + booksToSave.forEach { syncBook -> + DesktopLocalFolderSync.saveBookSidecars(syncBook) + } + } + } + } + + fun BookItem.matchesIncomingReaderPosition( + pageIndex: Int, + progress: Float, + session: ReaderSessionState? + ): Boolean { + val savedPage = if (type == FileType.PDF || type == FileType.PPTX || SharedFileCapabilities.isComicArchive(type)) { + lastPageIndex + } else { + readerPosition?.pageIndex ?: lastPageIndex + } + val savedProgress = progressPercentage + val progressMatches = savedProgress != null && kotlin.math.abs(savedProgress - progress) < 0.001f + val locatorMatches = session == null || readerPosition == session.navigationLocator + return savedPage == pageIndex && progressMatches && locatorMatches + } + + fun shouldIgnoreStaleReaderEcho( + bookId: String, + pageIndex: Int, + progress: Float, + session: ReaderSessionState? + ): Boolean { + val guard = readerCloudStalePositionGuards[bookId] ?: return false + if (guard.matchesIncomingReaderPosition(pageIndex, progress, session)) { + logDesktopPositionTrace { + "event=persist_skip_stale_echo bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress " + + "incomingLocator=${session?.navigationLocator.desktopPositionTraceSummary()} " + + "guardLocator=${guard.readerPosition.desktopPositionTraceSummary()}" + } + logDesktopCloudSync { + "desktop.reader.position_skip_stale_echo book=$bookId page=$pageIndex progress=$progress " + + guard.desktopCloudSyncSummary("guard") + } + return true + } + readerCloudStalePositionGuards = readerCloudStalePositionGuards - bookId + logDesktopPositionTrace { + "event=persist_stale_guard_cleared bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress " + + "incomingLocator=${session?.navigationLocator.desktopPositionTraceSummary()} " + + "guardLocator=${guard.readerPosition.desktopPositionTraceSummary()}" + } + logDesktopCloudSync { + "desktop.reader.position_guard_cleared book=$bookId page=$pageIndex progress=$progress" + } + return false } fun updateBookReadingState( @@ -1417,48 +2009,154 @@ internal fun EpistemeDesktopApp( session: ReaderSessionState? = null, pdfViewport: SharedPdfReaderViewport? = null ) { + val hasOpenReaderWindow = readerWindows.any { it.bookId == bookId } + val previousBook = state.rawLibraryBooks.firstOrNull { it.id == bookId } + logDesktopPositionTrace { + "event=persist_request bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress " + + "hasSession=${session != null} mode=${session?.reader?.settings?.readingMode ?: "none"} " + + "openWindow=$hasOpenReaderWindow closing=${bookId in closingReaderBookIds} " + + "incomingLocator=${session?.navigationLocator.desktopPositionTraceSummary()} " + + "previousPage=${previousBook?.lastPageIndex ?: "null"} " + + "previousProgress=${previousBook?.progressPercentage ?: "null"} " + + "previousLocator=${previousBook?.readerPosition.desktopPositionTraceSummary()}" + } + if (!hasOpenReaderWindow && bookId !in closingReaderBookIds) { + logDesktopPositionTrace { + "event=persist_skip_closed bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress" + } + logDesktopCloudSync { + "desktop.reader.position_skip_closed book=$bookId page=$pageIndex progress=$progress" + } + return + } + if (shouldIgnoreStaleReaderEcho(bookId, pageIndex, progress, session)) return + var updatedBook: BookItem? = null var shouldSyncSidecars = false - val next = state.copy( - rawLibraryBooks = state.rawLibraryBooks.map { book -> + var dirtyBaseTimestamp: Long? = null + if (previousBook == null) { + logDesktopPositionTrace { + "event=persist_skip_missing_book bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress" + } + return + } + val textReaderSettings = session?.reader?.settings + val updatedTextReaderDefaults = textReaderSettings + ?.takeIf { it != state.readerDefaultSettings } + val readerPosition = session?.navigationLocator + val nextReaderSettings = textReaderSettings ?: previousBook.readerSettings + val nextBookmarks = session?.bookmarks ?: previousBook.readerBookmarks + val nextHighlights = session?.highlights ?: previousBook.readerHighlights + val nextPdfViewport = pdfViewport ?: previousBook.pdfReaderViewport + val progressChanged = previousBook.progressPercentage + ?.let { kotlin.math.abs(it - progress) >= DesktopProgressEpsilon } + ?: true + val isReaderDirty = + previousBook.lastPageIndex != pageIndex || + progressChanged || + previousBook.readerPosition != readerPosition || + previousBook.readerSettings != nextReaderSettings || + previousBook.readerBookmarks != nextBookmarks || + previousBook.readerHighlights != nextHighlights || + previousBook.pdfReaderViewport != nextPdfViewport + if (!isReaderDirty && updatedTextReaderDefaults == null) { + logDesktopPositionTrace { + "event=persist_skip_unchanged bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress" + } + return + } + val stateWithReaderDefaults = if (updatedTextReaderDefaults != null) { + state.withDesktopReaderEngineDefaultSettings( + DesktopReaderSettingsEngine.TEXT, + updatedTextReaderDefaults + ) + } else { + state + } + val next = stateWithReaderDefaults.copy( + readerDefaultSettings = textReaderSettings ?: state.readerDefaultSettings, + rawLibraryBooks = stateWithReaderDefaults.rawLibraryBooks.map { book -> if (book.id == bookId) { - val readerPosition = session?.navigationLocator ?: book.readerPosition - shouldSyncSidecars = session != null || - book.lastPageIndex != pageIndex || - book.progressPercentage != progress || - book.readerPosition != readerPosition - book.copy( - progressPercentage = progress, - timestamp = System.currentTimeMillis(), - isRecent = true, - lastPageIndex = pageIndex, - readerPosition = readerPosition, - readerSettings = session?.reader?.settings ?: book.readerSettings, - readerBookmarks = session?.bookmarks ?: book.readerBookmarks, - readerHighlights = session?.highlights ?: book.readerHighlights, - pdfReaderViewport = pdfViewport ?: book.pdfReaderViewport - ).also { updatedBook = it } + shouldSyncSidecars = isReaderDirty + if (isReaderDirty && book.id !in readerCloudDirtyBookIds) { + dirtyBaseTimestamp = book.timestamp + } + if (isReaderDirty) { + val now = System.currentTimeMillis() + book.copy( + progressPercentage = progress, + timestamp = now, + isRecent = true, + lastPageIndex = pageIndex, + readerPosition = readerPosition, + readerSettings = nextReaderSettings, + readerBookmarks = nextBookmarks, + readerHighlights = nextHighlights, + pdfReaderViewport = nextPdfViewport, + readingPositionModifiedTimestamp = now + ).also { updatedBook = it } + } else { + book + } } else { book } } ) - updateState(next) - if (shouldSyncSidecars) { - updatedBook?.let(::syncBookSidecars) + updateState( + next, + persistDebounceMillis = if (isReaderDirty) DesktopReaderPositionPersistDebounceMillis else 0L + ) + logDesktopPositionTrace { + val saved = updatedBook + "event=persist_done bookId=\"${bookId.logPreview(80)}\" updated=${saved != null} " + + "requestedPage=$pageIndex requestedProgress=$progress " + + "savedPage=${saved?.lastPageIndex ?: "null"} savedProgress=${saved?.progressPercentage ?: "null"} " + + "savedLocator=${saved?.readerPosition.desktopPositionTraceSummary()} " + + "shouldSyncSidecars=$shouldSyncSidecars dirtyBaseTimestamp=${dirtyBaseTimestamp ?: "null"}" + } + if (updatedTextReaderDefaults != null) { + readerWindows = readerWindows.map { windowState -> + val content = windowState.content + if (content is DesktopReaderWindowContent.Text && + content.session.reader.settings != updatedTextReaderDefaults + ) { + windowState.copy( + content = content.copy( + session = readerEngine.updateSettings(content.session, updatedTextReaderDefaults) + ) + ) + } else { + windowState + } + } + } + if (shouldSyncSidecars) { + updatedBook?.let { book -> + syncBookSidecars(book, debounceMillis = DesktopReaderPositionPersistDebounceMillis) + } + markReaderCloudDirty(bookId, baseTimestamp = dirtyBaseTimestamp) } - updatedBook?.let { queueCloudBookMetadataSync(it) } } fun updateBookReaderSettings(bookId: String, settings: ReaderSettings) { + val pdfSettings = settings.toDesktopPdfReaderSettings() var updatedBook: BookItem? = null - val next = state.copy( - rawLibraryBooks = state.rawLibraryBooks.map { book -> + var dirtyBaseTimestamp: Long? = null + val stateWithPdfDefaults = state.withDesktopReaderEngineDefaultSettings( + DesktopReaderSettingsEngine.PDF, + pdfSettings + ) + val next = stateWithPdfDefaults.copy( + rawLibraryBooks = stateWithPdfDefaults.rawLibraryBooks.map { book -> if (book.id == bookId) { + if (book.id !in readerCloudDirtyBookIds) { + dirtyBaseTimestamp = book.timestamp + } book.copy( timestamp = System.currentTimeMillis(), isRecent = true, - readerSettings = settings + readerSettings = pdfSettings ).also { updatedBook = it } } else { book @@ -1467,7 +2165,7 @@ internal fun EpistemeDesktopApp( ) updateState(next) updatedBook?.let(::syncBookSidecars) - updatedBook?.let { queueCloudBookMetadataSync(it) } + markReaderCloudDirty(bookId, baseTimestamp = dirtyBaseTimestamp) } fun importDesktopReaderTexture(settings: ReaderSettings): ReaderSettings? { @@ -1512,6 +2210,8 @@ internal fun EpistemeDesktopApp( fun signOutDesktopAccount() { desktopAuthRepository.signOut() + desktopAccountProfileRepository.clearCachedProfiles() + desktopAccountProfileRefreshCompleted = true stopReaderCloudTts() saveDesktopCloudSyncSettings(syncEnabled = false) updateState(state.copy(currentUser = null, isProUser = false, credits = 0, isSyncEnabled = false)) @@ -1570,12 +2270,36 @@ internal fun EpistemeDesktopApp( } } - fun startReaderCloudTts(windowId: String, readScope: ReaderTtsReadScope, chunks: List) { + fun startReaderCloudTts( + windowId: String, + readScope: ReaderTtsReadScope, + chunks: List, + startChunkIndex: Int = 0, + restartActive: Boolean = false, + applyReplacements: Boolean = true + ) { val content = textReaderWindowContent(windowId) ?: return val replacementBookId = content.book.id.ifBlank { content.session.reader.book.title } - val ttsChunks = chunks - .filter { it.text.isNotBlank() } - .withTtsReplacements(state.readerTtsReplacementPreferences, replacementBookId) + val sourceChunks = chunks.filter { it.text.isNotBlank() } + logDesktopTtsStartTrace { + "event=desktop_start_request windowId=\"${windowId.logPreview(80)}\" scope=${readScope.name} " + + "incomingChunks=${chunks.size} sourceChunks=${sourceChunks.size} startChunkIndex=$startChunkIndex " + + "restartActive=$restartActive applyReplacements=$applyReplacements " + + "currentPage=${content.session.reader.currentPageIndex} sessionLocator=${content.session.navigationLocator.desktopPositionTraceSummary(160)} " + + "incomingFirst=${chunks.firstOrNull().desktopTtsStartTraceSummary(160)} " + + "sourceFirst=${sourceChunks.firstOrNull().desktopTtsStartTraceSummary(160)}" + } + val ttsChunks = if (applyReplacements) { + sourceChunks.withTtsReplacements(state.readerTtsReplacementPreferences, replacementBookId) + } else { + sourceChunks + } + logDesktopTtsStartTrace { + "event=desktop_start_prepared windowId=\"${windowId.logPreview(80)}\" scope=${readScope.name} " + + "ttsChunks=${ttsChunks.size} boundedStart=${startChunkIndex.coerceIn(0, ttsChunks.lastIndex.coerceAtLeast(0))} " + + "first=${ttsChunks.firstOrNull().desktopTtsStartTraceSummary(160)} " + + "second=${ttsChunks.getOrNull(1).desktopTtsStartTraceSummary(160)}" + } val settings = aiByokSettings.sanitized() val currentCloudTts = content.extrasState.cloudTts logDesktopTts( @@ -1584,10 +2308,14 @@ internal fun EpistemeDesktopApp( "keyPresent=${settings.geminiKey.isNotBlank()} ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" " + "available=${desktopTtsAdapter.isAvailable}" ) - if (currentCloudTts.isPlaying || currentCloudTts.isLoading || currentCloudTts.isPaused) { + val ttsActive = currentCloudTts.isPlaying || currentCloudTts.isLoading || currentCloudTts.isPaused + if (ttsActive && !restartActive) { stopReaderCloudTts(windowId) return } + if (ttsActive) { + content.ttsJob?.cancel() + } if (ttsChunks.isEmpty()) { logDesktopTts("reader_sequence_ignored reason=blank_text scope=${readScope.name}") updateTextReaderWindow(windowId) { latest -> @@ -1604,7 +2332,7 @@ internal fun EpistemeDesktopApp( } if (!desktopTtsAdapter.isAvailable) { logDesktopTts("reader_sequence_blocked reason=adapter_unavailable") - desktopFeatureNoticeForCloudTts()?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForCloudTts()?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } updateTextReaderWindow(windowId) { latest -> latest.copy( extrasState = latest.extrasState.copy( @@ -1638,11 +2366,18 @@ internal fun EpistemeDesktopApp( } } val ttsSessionId = System.currentTimeMillis() + val boundedStartChunkIndex = startChunkIndex.coerceIn(0, ttsChunks.lastIndex) + val playbackChunks = ttsChunks.drop(boundedStartChunkIndex) + logDesktopTtsStartTrace { + "event=desktop_playback_window windowId=\"${windowId.logPreview(80)}\" scope=${readScope.name} " + + "boundedStart=$boundedStartChunkIndex playbackChunks=${playbackChunks.size} " + + "playbackFirst=${playbackChunks.firstOrNull().desktopTtsStartTraceSummary(160)}" + } val initialProgress = ReaderTtsProgress( sessionId = ttsSessionId, scope = readScope, chunks = ttsChunks, - currentChunkIndex = -1 + currentChunkIndex = boundedStartChunkIndex - 1 ) updateTextReaderWindow(windowId) { latest -> latest.copy( @@ -1661,11 +2396,24 @@ internal fun EpistemeDesktopApp( ) ) } + fun updateTextReaderTtsSession(transform: (DesktopReaderWindowContent.Text) -> DesktopReaderWindowContent.Text) { + updateTextReaderWindow(windowId) { latest -> + if (latest.extrasState.cloudTts.progress.sessionId == ttsSessionId) { + transform(latest) + } else { + latest + } + } + } val ttsJob = scope.launch { runCatching { - logDesktopTts("reader_sequence_start scope=${readScope.name} chunks=${ttsChunks.size}") - desktopTtsAdapter.speakChunks(content.session.reader.book.title, readScope, ttsChunks) { index -> + logDesktopTts( + "reader_sequence_start scope=${readScope.name} chunks=${ttsChunks.size} " + + "startChunk=${boundedStartChunkIndex + 1}" + ) + desktopTtsAdapter.speakChunks(content.session.reader.book.title, readScope, playbackChunks) { relativeIndex -> if (!isActive) throw kotlinx.coroutines.CancellationException("Reader cloud TTS stopped") + val index = boundedStartChunkIndex + relativeIndex val chunk = ttsChunks[index] val progress = initialProgress.copy(currentChunkIndex = index) val latest = textReaderWindowContent(windowId) @@ -1680,7 +2428,7 @@ internal fun EpistemeDesktopApp( session = updatedSession ) } - updateTextReaderWindow(windowId) { current -> + updateTextReaderTtsSession { current -> current.copy( extrasState = current.extrasState.copy( cloudTts = ReaderCloudTtsState( @@ -1700,11 +2448,14 @@ internal fun EpistemeDesktopApp( "sourceCfi=\"${chunk.sourceCfi.orEmpty().logPreview()}\" chars=${chunk.text.length} " + "text=\"${chunk.text.logPreview()}\"" ) + logDesktopTtsStartTrace { + "event=desktop_chunk_start scope=${readScope.name} index=${index + 1}/${ttsChunks.size} " + + "chunk=${chunk.desktopTtsStartTraceSummary(180)}" + } } }.onFailure { error -> logDesktopTts("reader_sequence_failed error=\"${error.desktopTtsSummary()}\"") - if (error !is kotlinx.coroutines.CancellationException) error.printStackTrace() - updateTextReaderWindow(windowId) { latest -> + updateTextReaderTtsSession { latest -> if (error is kotlinx.coroutines.CancellationException) { latest.copy( ttsJob = null, @@ -1716,7 +2467,7 @@ internal fun EpistemeDesktopApp( ) ) } else { - desktopFeatureNoticeForError(error.message)?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForError(error.message)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } latest.copy( ttsJob = null, extrasState = latest.extrasState.copy( @@ -1731,7 +2482,7 @@ internal fun EpistemeDesktopApp( } }.onSuccess { logDesktopTts("reader_sequence_success chunks=${ttsChunks.size}") - updateTextReaderWindow(windowId) { latest -> + updateTextReaderTtsSession { latest -> latest.copy( ttsJob = null, extrasState = latest.extrasState.copy( @@ -1747,7 +2498,37 @@ internal fun EpistemeDesktopApp( updateTextReaderWindow(windowId) { latest -> latest.copy(ttsJob = ttsJob) } } - fun toggleReaderCloudTts(windowId: String, text: String) { + fun skipReaderCloudTtsChunk(windowId: String, delta: Int) { + val content = textReaderWindowContent(windowId) ?: return + val progress = content.extrasState.cloudTts.progress + if (progress.chunks.isEmpty()) return + val currentIndex = progress.currentChunkIndex.takeIf { it >= 0 } ?: return + val targetIndex = (currentIndex + delta).coerceIn(0, progress.chunks.lastIndex) + if (targetIndex == currentIndex) return + startReaderCloudTts( + windowId = windowId, + readScope = progress.scope, + chunks = progress.chunks, + startChunkIndex = targetIndex, + restartActive = true, + applyReplacements = false + ) + } + + fun locateReaderCloudTtsChunk(windowId: String) { + val content = textReaderWindowContent(windowId) ?: return + val chunk = content.extrasState.cloudTts.progress.currentChunk ?: return + val updatedSession = readerEngine.goToPage(content.session, chunk.pageIndex) + updateTextReaderWindow(windowId) { current -> current.copy(session = updatedSession) } + updateBookReadingState( + bookId = content.book.id, + pageIndex = updatedSession.reader.currentPageIndex, + progress = updatedSession.reader.progress, + session = updatedSession + ) + } + + fun toggleReaderCloudTts(windowId: String, text: String, locator: ReaderLocator? = null) { val content = textReaderWindowContent(windowId) ?: return val normalizedText = text.trim() val settings = aiByokSettings.sanitized() @@ -1780,7 +2561,7 @@ internal fun EpistemeDesktopApp( } if (!desktopTtsAdapter.isAvailable) { logDesktopTts("reader_toggle_blocked reason=adapter_unavailable") - desktopFeatureNoticeForCloudTts()?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForCloudTts()?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } updateTextReaderWindow(windowId) { latest -> latest.copy( extrasState = latest.extrasState.copy( @@ -1794,14 +2575,24 @@ internal fun EpistemeDesktopApp( } return } - val page = content.session.reader.currentPage - val selectionChunks = if (page != null) { + val locatorChunks = locator + ?.takeIf { it.startOffset != null || !it.cfi.isNullOrBlank() } + ?.let { selectionLocator -> + ReaderTtsPlanner.chunksFromCurrentLocation( + content.session.copy(navigationLocator = selectionLocator) + ).takeIf { it.isNotEmpty() } + } + val page = locator + ?.pageIndex + ?.let { content.session.reader.pages.getOrNull(it) } + ?: content.session.reader.currentPage + val selectionChunks = locatorChunks ?: if (page != null) { ReaderTtsPlanner.chunksForText( text = normalizedText, - pageIndex = page.pageIndex, - chapterIndex = page.chapterIndex, + pageIndex = locator?.pageIndex ?: page.pageIndex, + chapterIndex = locator?.chapterIndex ?: page.chapterIndex, chapterTitle = page.chapterTitle, - sourceStartOffset = page.startOffset + sourceStartOffset = locator?.startOffset ?: page.startOffset ) } else { ReaderTtsPlanner.chunksForText( @@ -1811,7 +2602,11 @@ internal fun EpistemeDesktopApp( chapterTitle = desktopString("desktop_selection", "Selection") ) } - startReaderCloudTts(windowId, ReaderTtsReadScope.PAGE, selectionChunks) + startReaderCloudTts( + windowId = windowId, + readScope = if (locatorChunks != null) ReaderTtsReadScope.BOOK else ReaderTtsReadScope.PAGE, + chunks = selectionChunks + ) } fun finishImportFiles( @@ -1938,7 +2733,7 @@ internal fun EpistemeDesktopApp( book.withDesktopImportMetadata( enriched = enriched, original = originalTargetBooksById[book.id] - ) + ).copy(timestamp = System.currentTimeMillis()) } ) ) @@ -1989,6 +2784,11 @@ internal fun EpistemeDesktopApp( updateState(state.withBanner("No local folders are linked yet.", isError = true)) return } + if (targetFolder == null && state.syncedFolders.none { it.localSyncEnabled }) { + logDesktopFolderSync("ui.sync.skipNoEnabledFolders mode=$mode") + updateState(state.withBanner("No local folders have sync enabled.", isError = true)) + return + } val snapshotState = state val snapshotShelfRefs = shelfRefs @@ -2002,14 +2802,22 @@ internal fun EpistemeDesktopApp( } scope.launch { - val result = withContext(Dispatchers.IO) { - DesktopLocalFolderSync.sync( - state = snapshotState, - shelfRefs = snapshotShelfRefs, - targetFolder = targetFolder, - metadataOnly = metadataOnly - ) - } + val result = runCatching { + withContext(Dispatchers.IO) { + DesktopLocalFolderSync.sync( + state = snapshotState, + shelfRefs = snapshotShelfRefs, + targetFolder = targetFolder, + metadataOnly = metadataOnly, + extractMetadata = false + ) + } + }.onFailure { error -> + logDesktopFolderSync("ui.sync.failed mode=$mode error=${error.folderSyncSummary()}") + if (showBanner) { + updateState(state.withBanner(error.message ?: "Folder sync failed.", isError = true)) + } + }.getOrNull() ?: return@launch val failedCount = result.failedFolders.size val stats = result.stats val metadataStats = result.metadataStats @@ -2040,20 +2848,24 @@ internal fun EpistemeDesktopApp( "new=${stats.newBooks} updated=${stats.updatedBooks} remoteUpdates=${stats.remoteMetadataUpdates} " + "removed=${stats.removedBooks} metadataExtracted=${metadataStats.updatedBooks}" ) - val completedState = if (showBanner || failedCount > 0) { - result.state.withBanner(message, isError = failedCount > 0) - } else { - result.state - } + val completedState = desktopFolderSyncCompletedState( + state = result.state, + message = message, + failedFolderCount = failedCount, + showBanner = showBanner + ) replaceLibrary( completedState, refs = result.shelfRefs ) + if (!metadataOnly) { + scheduleFolderMetadataExtraction(result.processedFolderUris.toSet()) + } val existingBookIds = completedState.rawLibraryBooks.mapTo(mutableSetOf()) { it.id } readerWindows = readerWindows.mapNotNull { window -> val migratedBookId = result.idMigrations[window.bookId] ?: window.bookId if (migratedBookId !in existingBookIds) { - window.closeReaderResources() + window.cancelReaderWork() null } else if (migratedBookId != window.bookId) { val migratedContent = when (val content = window.content) { @@ -2091,7 +2903,7 @@ internal fun EpistemeDesktopApp( fun syncDesktopLibrary(showBanner: Boolean = true) { val hasCloud = state.isSyncEnabled - val hasFolders = state.syncedFolders.isNotEmpty() + val hasFolders = state.syncedFolders.any { it.localSyncEnabled } if (!hasCloud && !hasFolders) { updateState(state.withBanner("No sync methods are active.", isError = true)) return @@ -2203,6 +3015,22 @@ internal fun EpistemeDesktopApp( } } + fun createShelfWithBooks(name: String, bookIds: Set, clearSelection: Boolean = true) { + SharedLibraryEditor.createShelfWithBooks( + state = state, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + name = name, + bookIds = bookIds, + clearSelection = clearSelection, + nowMillis = System.currentTimeMillis() + )?.let { result -> + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + result.shelfRecords.lastOrNull { record -> record.name == name.trim() } + ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } + } + } + fun createSmartShelf(name: String, definition: SmartCollectionDefinition) { SharedLibraryEditor.createSmartShelf(state, shelfRecords, shelfRefs, name, definition, System.currentTimeMillis())?.let { replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) @@ -2238,6 +3066,33 @@ internal fun EpistemeDesktopApp( } } + fun addBooksToShelves(bookIds: Set, shelfIds: Set, clearSelection: Boolean) { + val targetShelfIds = shelfIds.filterTo(linkedSetOf()) { SharedLibraryEditor.canMutateShelf(it) } + SharedLibraryEditor.addBooksToShelves( + state = state, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + bookIds = bookIds, + shelfIds = targetShelfIds, + clearSelection = clearSelection, + nowMillis = System.currentTimeMillis() + )?.let { result -> + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + targetShelfIds.forEach { shelfId -> + result.shelfRecords.firstOrNull { record -> record.id == shelfId } + ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } + } + } + } + + fun replaceShelfBooks(shelf: Shelf, bookIds: Set) { + SharedLibraryEditor.replaceShelfBooks(state, shelfRecords, shelfRefs, shelf.id, bookIds, System.currentTimeMillis())?.let { result -> + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + result.shelfRecords.firstOrNull { record -> record.id == shelf.id } + ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } + } + } + fun tagSelectedBooks(tagName: String) { SharedLibraryEditor.tagSelectedBooks(state, shelfRecords, shelfRefs, tagName, System.currentTimeMillis())?.let { replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) @@ -2298,7 +3153,9 @@ internal fun EpistemeDesktopApp( } rewritten.onSuccess(::applyBookMetadataUpdate) .onFailure { error -> - println("Failed to update EPUB metadata for ${updated.displayName}: ${error.message}") + logDesktopDiagnostic("EpistemeDesktopMetadata") { + "epub_metadata_update_failed book=${updated.id} error=\"${error.message.orEmpty().logPreview()}\"" + } updateState(state.copy(bannerMessage = BannerMessage("Could not update EPUB metadata."))) } } @@ -2312,10 +3169,9 @@ internal fun EpistemeDesktopApp( val now = System.currentTimeMillis() val next = SharedLibraryEditor.markBookOpened(state, bookId, now) val openedState = next.reduce(AppAction.BookTabOpened(bookId)) - updateState(openedState) + updateState(openedState, persistDebounceMillis = DesktopLibraryOpenPersistDebounceMillis) openedState.rawLibraryBooks.firstOrNull { it.id == bookId }?.let { book -> - syncBookSidecars(book) - queueCloudBookMetadataSync(book) + syncBookSidecars(book, debounceMillis = DesktopLibraryOpenPersistDebounceMillis) } } @@ -2330,13 +3186,16 @@ internal fun EpistemeDesktopApp( rawLibraryBooks = state.rawLibraryBooks.map { current -> if (current.id == book.id) { current.withDesktopImportMetadata(enriched = enriched, original = book) + .copy(timestamp = System.currentTimeMillis()) } else { current } } ) ) - state.rawLibraryBooks.firstOrNull { it.id == book.id }?.let { queueCloudBookMetadataSync(it) } + state.rawLibraryBooks.firstOrNull { it.id == book.id }?.let { + markReaderCloudDirty(it.id, baseTimestamp = book.timestamp) + } } } @@ -2362,14 +3221,23 @@ internal fun EpistemeDesktopApp( } fun exitReaderTo(tab: SharedAppTab) { - selectedTab = tab.takeUnless { it == SharedAppTab.READER } ?: SharedAppTab.LIBRARY + if (tab == SharedAppTab.SHELVES) { + selectedLibraryTab = NonReaderLibraryTab.SHELVES + selectedTab = SharedAppTab.LIBRARY + } else { + selectedTab = tab.takeUnless { it == SharedAppTab.READER } ?: SharedAppTab.LIBRARY + } } fun selectAppTab(tab: SharedAppTab) { - val nextTab = if (tab == SharedAppTab.CATALOGS && !featurePolicy.opdsCatalogs) { - SharedAppTab.HOME - } else { - tab + val nextTab = when { + tab == SharedAppTab.CATALOGS && !featurePolicy.opdsCatalogs -> SharedAppTab.LIBRARY + tab == SharedAppTab.PRO && !desktopAccountAvailable() -> SharedAppTab.LIBRARY + tab == SharedAppTab.SHELVES -> { + selectedLibraryTab = NonReaderLibraryTab.SHELVES + SharedAppTab.LIBRARY + } + else -> tab } if (nextTab == SharedAppTab.SETTINGS) { settingsQuery = "" @@ -2385,12 +3253,48 @@ internal fun EpistemeDesktopApp( } } + fun focusDesktopAppWindow() { + val ownerWindow = (window as? java.awt.Window) + ?: window?.let { javax.swing.SwingUtilities.getWindowAncestor(it) } + ?: return + EventQueue.invokeLater { + if (!ownerWindow.isDisplayable || !ownerWindow.isShowing) return@invokeLater + if (ownerWindow is java.awt.Frame && ownerWindow.extendedState and java.awt.Frame.ICONIFIED != 0) { + ownerWindow.extendedState = ownerWindow.extendedState and java.awt.Frame.ICONIFIED.inv() + } + ownerWindow.toFront() + ownerWindow.requestFocus() + ownerWindow.requestFocusInWindow() + } + } + + fun confirmDesktopFeatureNotice(notice: DesktopFeatureNotice) { + dismissDesktopFeatureNotice() + when (notice.action) { + DesktopFeatureNoticeAction.SIGN_IN -> signInDesktopAccount() + DesktopFeatureNoticeAction.OPEN_PRO -> { + selectAppTab(SharedAppTab.PRO) + focusDesktopAppWindow() + } + null -> Unit + } + } + fun applyReaderOpenResult(result: DesktopReaderOpenResult) { + val applyStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + result.opening.openTracePrefix("desktop_apply_start") + + " result=${result.openTraceKind()}" + } val window = readerWindows.firstOrNull { it.opening.requestId == result.opening.requestId } if (window == null) { if (result is DesktopReaderOpenResult.Pdf) { result.document.close() } + logDesktopReaderOpenTrace { + result.opening.openTracePrefix("desktop_apply_missing_window") + + " result=${result.openTraceKind()} durationMs=${applyStartedAt.elapsedOpenTraceMs()}" + } return } @@ -2411,10 +3315,6 @@ internal fun EpistemeDesktopApp( } is DesktopReaderOpenResult.Pdf -> { - (window.content as? DesktopReaderWindowContent.Pdf) - ?.document - ?.takeIf { it.handleId != result.document.handleId } - ?.close() readerWindows = readerWindows.withDesktopReaderWindowContent( requestId = result.opening.requestId, content = DesktopReaderWindowContent.Pdf( @@ -2447,6 +3347,11 @@ internal fun EpistemeDesktopApp( recordBookOpened(result.book.id) } } + logDesktopReaderOpenTrace { + result.opening.openTracePrefix("desktop_apply_done") + + " result=${result.openTraceKind()} windowId=\"${window.id.logPreview(80)}\" " + + "durationMs=${applyStartedAt.elapsedOpenTraceMs()} openWindows=${readerWindows.size}" + } } fun openReader( @@ -2456,10 +3361,6 @@ internal fun EpistemeDesktopApp( returnTabOverride: SharedAppTab? = null ) { val desktopReaderSurface = SharedFileCapabilities.surfaceFor(book.type, ReaderPlatform.DESKTOP) - if (shouldRequestDesktopWebViewRuntime(desktopReaderSurface)) { - webViewRuntimeRequested = true - } - if (desktopReaderSurface == ReaderFeatureSurface.PDF_VIEWER) { val path = book.path if (path.isNullOrBlank()) { @@ -2502,23 +3403,57 @@ internal fun EpistemeDesktopApp( ?: SharedAppTab.LIBRARY, password = password ) + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_request") + + " type=${book.type} surface=$desktopReaderSurface force=$force " + + "path=\"${book.path.orEmpty().logPreview(180)}\"" + } val readerDefaultSettings = state.readerDefaultSettings + val previousWindowCount = readerWindows.size if (force) { - readerWindows.firstOrNull { it.bookId == book.id }?.closeReaderResources() + readerWindows.firstOrNull { it.bookId == book.id }?.let { existingWindow -> + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_force_cancel_existing") + + " windowId=\"${existingWindow.id.logPreview(80)}\"" + } + existingWindow.cancelReaderWork() + } } val openDecision = readerWindows.openOrFocusDesktopReaderWindow(opening, force) readerWindows = openDecision.windows + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_window_decision") + + " shouldStart=${openDecision.shouldStartOpen} force=$force " + + "previousWindows=$previousWindowCount nextWindows=${openDecision.windows.size}" + } if (!openDecision.shouldStartOpen) { recordBookOpened(book.id) + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_focus_existing_done") + } return } scope.launch { + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_coroutine_start") + } val result = withContext(Dispatchers.IO) { + val ioStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_io_start") + + " surface=$desktopReaderSurface" + } runCatching { when (desktopReaderSurface) { ReaderFeatureSurface.PDF_VIEWER -> { + val pdfStartedAt = System.nanoTime() val path = book.path.orEmpty() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_pdf_load_start") + + " type=${book.type} path=\"${path.logPreview(180)}\" " + + "passwordSupplied=${!opening.password.isNullOrEmpty()}" + } val streamReference = SharedOpdsStreamUri.parse(path) val document = if (streamReference != null) { DesktopPdfium.loadOpdsStream( @@ -2539,19 +3474,73 @@ internal fun EpistemeDesktopApp( else -> DesktopPdfium.loadComic(readerFile, book.type) } } + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_pdf_load_done") + + " type=${book.type} durationMs=${pdfStartedAt.elapsedOpenTraceMs()} " + + "pages=${document.pageCount}" + } DesktopReaderOpenResult.Pdf(opening, book, document) } ReaderFeatureSurface.EPUB_READER, ReaderFeatureSurface.TEXT_READER -> { val path = book.path?.takeIf { it.isNotBlank() } ?: error("Book path is missing.") + val readerFile = File(path) + val settingsStartedAt = System.nanoTime() + val restoredSettings = resolvedDesktopReaderSettings(book, readerDefaultSettings) + val semanticMode = desktopEpubBookLoadSemanticMode(restoredSettings) + val preparedHtmlChapterRange = if (semanticMode == SharedJvmBookLoadSemanticMode.SKIP) { + val initialChapter = book.readerPosition?.chapterIndex?.takeIf { it >= 0 } ?: 0 + (initialChapter - DesktopVerticalInitialPreparedHtmlChapterRadius).coerceAtLeast(0).. + (initialChapter + DesktopVerticalInitialPreparedHtmlChapterRadius) + } else { + null + } + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_settings_restored") + + " durationMs=${settingsStartedAt.elapsedOpenTraceMs()} " + + "mode=${restoredSettings.readingMode} semanticMode=${semanticMode.name} " + + "preparedHtmlChapters=${preparedHtmlChapterRange?.let { "${it.first}..${it.last}" } ?: "all"} " + + "fontSize=${restoredSettings.fontSize} textAlign=${restoredSettings.textAlign} " + + "pageWidth=${restoredSettings.pageWidth}" + } + val loadStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_text_load_start") + + " type=${book.type} semanticMode=${semanticMode.name} fileBytes=${readerFile.length()} " + + "path=\"${path.logPreview(180)}\"" + } val loadedBook = SharedJvmBookLoader.load( - file = File(path), + file = readerFile, type = book.type, titleOverride = book.title?.takeIf { it.isNotBlank() }, - authorOverride = book.author?.takeIf { it.isNotBlank() } + authorOverride = book.author?.takeIf { it.isNotBlank() }, + semanticMode = semanticMode, + preparedHtmlChapterRange = preparedHtmlChapterRange ) - val restoredSettings = resolvedDesktopReaderSettings(book, readerDefaultSettings) + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_text_load_done") + + " durationMs=${loadStartedAt.elapsedOpenTraceMs()} " + + "loadedTitle=\"${loadedBook.title.logPreview(120)}\" " + + "chapters=${loadedBook.chapters.size} pagesBeforeSession=n/a " + + "textChars=${loadedBook.chapters.sumOf { it.plainText.length }} " + + "htmlChars=${loadedBook.chapters.sumOf { it.htmlContent.length }} " + + "semanticBlocks=${loadedBook.chapters.sumOf { it.semanticBlocks.size }} " + + "cssFiles=${loadedBook.css.size} cssChars=${loadedBook.css.values.sumOf { it.length }}" + } + val sessionStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_session_create_start") + + " initialPage=${book.lastPageIndex ?: 0} hasLocator=${book.readerPosition != null} " + + "locator=${book.readerPosition.desktopPositionTraceSummary(70)} " + + "bookmarks=${book.readerBookmarks.size} highlights=${book.readerHighlights.size}" + } + logDesktopPositionTrace { + opening.openTracePrefix("desktop_position_restore_start") + + " initialPage=${book.lastPageIndex ?: 0} storedProgress=${book.progressPercentage ?: "null"} " + + "storedLocator=${book.readerPosition.desktopPositionTraceSummary()} " + + "mode=${restoredSettings.readingMode}" + } val restoredSession = readerEngine.createSession( book = loadedBook, settings = restoredSettings, @@ -2560,9 +3549,42 @@ internal fun EpistemeDesktopApp( bookmarks = book.readerBookmarks, highlights = book.readerHighlights ) + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_session_create_done") + + " durationMs=${sessionStartedAt.elapsedOpenTraceMs()} " + + "pages=${restoredSession.reader.pages.size} " + + "currentPage=${restoredSession.reader.currentPageIndex + 1} " + + "navigationLocator=${restoredSession.navigationLocator.desktopPositionTraceSummary(70)} " + + "visiblePages=${restoredSession.reader.visiblePages.map { it.pageIndex + 1 }}" + } + logDesktopPositionTrace { + opening.openTracePrefix("desktop_position_restore_session_done") + + " durationMs=${sessionStartedAt.elapsedOpenTraceMs()} " + + "page=${restoredSession.reader.currentPageIndex} pages=${restoredSession.reader.pages.size} " + + "navigationLocator=${restoredSession.navigationLocator.desktopPositionTraceSummary()}" + } val restoredProgress = book.progressPercentage val session = if (book.readerPosition == null && book.lastPageIndex == null && restoredProgress != null) { + val progressStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_progress_restore_start") + + " progress=$restoredProgress" + } readerEngine.goToProgress(restoredSession, restoredProgress.coerceIn(0f, 100f) / 100f) + .also { restored -> + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_progress_restore_done") + + " durationMs=${progressStartedAt.elapsedOpenTraceMs()} " + + "currentPage=${restored.reader.currentPageIndex + 1} " + + "navigationLocator=${restored.navigationLocator.desktopPositionTraceSummary(70)}" + } + logDesktopPositionTrace { + opening.openTracePrefix("desktop_position_restore_progress_done") + + " durationMs=${progressStartedAt.elapsedOpenTraceMs()} " + + "page=${restored.reader.currentPageIndex} " + + "navigationLocator=${restored.navigationLocator.desktopPositionTraceSummary()}" + } + } } else { restoredSession } @@ -2572,6 +3594,11 @@ internal fun EpistemeDesktopApp( else -> error("${SharedFileCapabilities.displayNameFor(book.type)} reader support comes later.") } }.getOrElse { error -> + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_io_exception") + + " durationMs=${ioStartedAt.elapsedOpenTraceMs()} " + + "error=\"${error.message.orEmpty().logPreview(240)}\"" + } if (desktopReaderSurface == ReaderFeatureSurface.PDF_VIEWER && book.type == FileType.PDF && error.isDesktopPdfPasswordException() @@ -2589,8 +3616,17 @@ internal fun EpistemeDesktopApp( (error.message ?: "unknown error") ) } + }.also { loadedResult -> + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_io_done") + + " result=${loadedResult.openTraceKind()} durationMs=${ioStartedAt.elapsedOpenTraceMs()}" + } } } + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_result_ready") + + " result=${result.openTraceKind()}" + } applyReaderOpenResult(result) } } @@ -2713,7 +3749,7 @@ internal fun EpistemeDesktopApp( } } - fun emitOpds(next: com.aryan.reader.shared.opds.SharedOpdsScreenState) { + fun emitOpds(next: org.dueattendant149.bookreader.shared.opds.SharedOpdsScreenState) { opdsState = next } @@ -2844,8 +3880,18 @@ internal fun EpistemeDesktopApp( } val latestReaderWindows by rememberUpdatedState(readerWindows) + val latestStateForDispose by rememberUpdatedState(state) + val latestShelfRecordsForDispose by rememberUpdatedState(shelfRecords) + val latestShelfRefsForDispose by rememberUpdatedState(shelfRefs) + val latestCustomFontsForDispose by rememberUpdatedState(customFonts) DisposableEffect(Unit) { onDispose { + flushDesktopPersistenceBeforeDispose( + projected = latestStateForDispose, + records = latestShelfRecordsForDispose, + refs = latestShelfRefsForDispose, + fonts = latestCustomFontsForDispose + ) latestReaderWindows.forEach { it.closeReaderResources() } } } @@ -2868,9 +3914,10 @@ internal fun EpistemeDesktopApp( } } - LaunchedEffect(state.isSyncEnabled, state.currentUser?.uid, state.isProUser) { + LaunchedEffect(state.isSyncEnabled, state.currentUser?.uid, state.isProUser, desktopAccountProfileRefreshCompleted) { if ( !initialDesktopCloudSyncDone && + desktopAccountProfileRefreshCompleted && state.isSyncEnabled && state.currentUser != null && state.isProUser @@ -2881,7 +3928,7 @@ internal fun EpistemeDesktopApp( } LaunchedEffect(Unit) { - if (state.syncedFolders.isNotEmpty()) { + if (state.syncedFolders.any { it.localSyncEnabled }) { scanSyncedFolders(showBanner = false) } } @@ -2920,18 +3967,35 @@ internal fun EpistemeDesktopApp( appSeedColor = state.appSeedColor, appFontFamily = desktopAppFontFamily ) { - EpistemeDesktopWindowChromeEffect( - window = window, - captionColor = MaterialTheme.colorScheme.surface, - textColor = MaterialTheme.colorScheme.onSurface, - borderColor = MaterialTheme.colorScheme.background - ) - Box( - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - ) { - SharedAppShell( + val appThemeControls: @Composable () -> Unit = { + SharedAppThemeControls( + appThemeMode = state.appThemeMode, + appContrastOption = state.appContrastOption, + appTextDimFactorLight = state.appTextDimFactorLight, + appTextDimFactorDark = state.appTextDimFactorDark, + appSeedColor = state.appSeedColor, + customAppThemes = state.customAppThemes, + onThemeModeChanged = { mode -> updateState(state.reduce(AppAction.AppThemeChanged(mode))) }, + onContrastOptionChanged = { option -> updateState(state.reduce(AppAction.AppContrastChanged(option))) }, + onTextDimFactorLightChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorLightChanged(factor))) }, + onTextDimFactorDarkChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorDarkChanged(factor))) }, + onSeedColorChanged = { color -> updateState(state.reduce(AppAction.AppSeedColorChanged(color))) }, + onCustomThemeAdded = { theme -> updateState(state.reduce(AppAction.CustomAppThemeAdded(theme))) }, + onCustomThemeDeleted = { themeId -> updateState(state.reduce(AppAction.CustomAppThemeDeleted(themeId))) } + ) + } + EpistemeDesktopWindowChromeEffect( + window = window, + captionColor = MaterialTheme.colorScheme.surface, + textColor = MaterialTheme.colorScheme.onSurface, + borderColor = MaterialTheme.colorScheme.background + ) + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + SharedAppShell( selectedTab = selectedTab, snackbarHostState = snackbarHostState, appThemeMode = state.appThemeMode, @@ -2942,6 +4006,23 @@ internal fun EpistemeDesktopApp( customAppThemes = state.customAppThemes, isTabsEnabled = state.isTabsEnabled, featurePolicy = featurePolicy, + currentUser = if (desktopAccountAvailable()) state.currentUser else null, + accountAvailable = desktopAccountAvailable(), + isOssBuild = desktopBuildProfile.isOssOffline, + isProUser = state.isProUser, + isSyncEnabled = state.isSyncEnabled, + syncAvailable = desktopCloudSyncAvailable(), + onSignInRequested = if (desktopAccountAvailable()) { + ::signInDesktopAccount + } else { + null + }, + accountAvatar = { user, modifier -> + DesktopProfileAvatar(user = user, modifier = modifier) + }, + onSyncEnabledChange = { enabled -> + setDesktopCloudSyncEnabled(enabled) + }, onTabSelected = { tab -> selectAppTab(tab) }, @@ -2960,46 +4041,13 @@ internal fun EpistemeDesktopApp( if (!enabled) closeAllReaderWindows() updateState(state.reduce(AppAction.TabsEnabledChanged(enabled))) }, - onAiSettingsRequested = if (desktopBuildProfile.byokAiAvailable) { + onAiSettingsRequested = if (desktopAiKeySettingsAvailable) { { showAiByokSettingsDialog = true } } else { null } ) { tab -> when (tab) { - SharedAppTab.HOME -> HomeScreen( - state = state, - selectedLibraryTab = selectedLibraryTab, - onLibraryTabChange = { selectedLibraryTab = it }, - onStateChange = ::updateState, - onImportBooks = { - importFiles(chooseFiles()) - }, - onImportFolder = { chooseFolder()?.let(::importFolder) }, - onRead = ::openReader, - onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, - onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) }, - onRemoveSelected = ::removeSelectedBooks, - onShowBookInfo = { - bookInfoInitiallyEditing = false - bookInfoDialogFor = it - }, - onEditBook = { - bookInfoInitiallyEditing = true - bookInfoDialogFor = it - }, - onCreateShelf = { showCreateShelfDialog = true }, - onCreateSmartShelf = { showCreateSmartShelfDialog = true }, - onRenameShelf = { shelfToRename = it }, - onDeleteShelf = { shelfToDelete = it }, - onRemoveFolder = { folderToRemove = it }, - onTagSelectedBooks = { showTagSelectionDialog = true }, - onAddSelectedBooksToShelf = { showAddToShelfDialog = true }, - onSyncFolderMetadata = { syncFolderMetadata() }, - onScanFolders = { scanSyncedFolders() }, - onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) } - ) - SharedAppTab.SETTINGS -> SharedSettingsHub( model = sharedSettingsHubModel( SharedSettingsHubInput( @@ -3009,19 +4057,20 @@ internal fun EpistemeDesktopApp( isSignedIn = state.currentUser != null, isProUser = state.isProUser, accountAvailable = featurePolicy.aiAndCloud && !desktopBuildProfile.byokAiAvailable, + includeAccountAuthActions = false, syncAvailable = desktopCloudSyncAvailable(), folderSyncAvailable = true, - aiSettingsAvailable = desktopBuildProfile.byokAiAvailable, + aiSettingsAvailable = desktopAiKeySettingsAvailable, includeLanguage = true, includeScreenCaptureProtection = false, includeExternalFileBehavior = false, includeStrictFileFilter = false, includeReaderTabs = false, - includeHideReaderAi = featurePolicy.aiAndCloud, + includeHideReaderAi = false, isTabsEnabled = state.isTabsEnabled, isSyncEnabled = state.isSyncEnabled, isFolderSyncEnabled = state.isFolderSyncEnabled, - hideReaderAi = effectiveAiSettings().hideReaderAiFeatures, + hideReaderAi = false, languageTitle = desktopString("options_language", "Language"), languageSummary = selectedDesktopLanguageOption(desktopLanguageTag).let { option -> desktopString(option.labelKey, option.fallbackLabel) @@ -3034,11 +4083,21 @@ internal fun EpistemeDesktopApp( onDestinationChange = { settingsDestination = it }, readerDefaultSettings = state.readerDefaultSettings, onReaderDefaultSettingsChange = { settings -> - updateState(state.reduce(AppAction.ReaderDefaultSettingsChanged(settings))) + updateState( + state.withDesktopReaderEngineDefaultSettings( + DesktopReaderSettingsEngine.TEXT, + settings + ) + ) }, pdfReaderDefaultSettings = state.pdfReaderDefaultSettings, onPdfReaderDefaultSettingsChange = { settings -> - updateState(state.reduce(AppAction.PdfReaderDefaultSettingsChanged(settings))) + updateState( + state.withDesktopReaderEngineDefaultSettings( + DesktopReaderSettingsEngine.PDF, + settings + ) + ) }, readerToolbarPreferences = state.readerToolbarPreferences, onReaderToolbarPreferencesChange = { preferences -> @@ -3050,6 +4109,10 @@ internal fun EpistemeDesktopApp( }, customFonts = customFonts, onPickCustomFont = { importCustomFont(chooseFontFile())?.path }, + customReaderThemes = state.customReaderThemes, + onCustomReaderThemesChange = { themes -> + updateState(state.reduce(AppAction.CustomReaderThemesChanged(themes))) + }, readerCustomTextureIds = readerCustomTextureIds, onImportReaderTexture = ::importDesktopReaderTexture, onAction = { action -> @@ -3060,14 +4123,10 @@ internal fun EpistemeDesktopApp( updateState(state.reduce(AppAction.TabsEnabledChanged(!state.isTabsEnabled))) } SharedSettingsAction.FOLDER_SYNC -> setDesktopFolderSyncEnabled(!state.isFolderSyncEnabled) - SharedSettingsAction.AI_SETTINGS -> if (desktopBuildProfile.byokAiAvailable) showAiByokSettingsDialog = true + SharedSettingsAction.AI_SETTINGS -> if (desktopAiKeySettingsAvailable) showAiByokSettingsDialog = true SharedSettingsAction.SIGN_IN -> signInDesktopAccount() SharedSettingsAction.SIGN_OUT -> signOutDesktopAccount() - SharedSettingsAction.HIDE_READER_AI -> { - val next = aiByokSettings.copy(hideReaderAiFeatures = !effectiveAiSettings().hideReaderAiFeatures) - aiByokSettings = next - runCatching { aiByokStore.save(next.sanitized()) } - } + SharedSettingsAction.HIDE_READER_AI -> Unit SharedSettingsAction.CUSTOM_FONTS -> selectAppTab(SharedAppTab.CUSTOM_FONTS) SharedSettingsAction.HELP_FEEDBACK -> selectAppTab(SharedAppTab.FEEDBACK) SharedSettingsAction.SUPPORT -> selectAppTab(SharedAppTab.SUPPORT) @@ -3136,24 +4195,48 @@ internal fun EpistemeDesktopApp( bookInfoInitiallyEditing = true bookInfoDialogFor = it }, - onCreateShelf = { showCreateShelfDialog = true }, + onCreateShelf = { + createShelfBookIds = emptySet() + createShelfClearsSelection = false + showCreateShelfDialog = true + }, + onCreateShelfWithBooks = { name, bookIds -> createShelfWithBooks(name, bookIds) }, onCreateSmartShelf = { showCreateSmartShelfDialog = true }, onRenameShelf = { shelfToRename = it }, onDeleteShelf = { shelfToDelete = it }, onRemoveFolder = { folderToRemove = it }, onTagSelectedBooks = { showTagSelectionDialog = true }, - onAddSelectedBooksToShelf = { showAddToShelfDialog = true }, + onAddSelectedBooksToShelf = { + addToShelfBookIds = state.selectedBookIds + addToShelfClearsSelection = true + }, + onAddBooksToShelf = { bookIds -> + addToShelfBookIds = bookIds + addToShelfClearsSelection = false + }, + onManageShelfBooks = { shelfToManageBooks = it }, onSyncFolderMetadata = { syncFolderMetadata() }, onScanFolders = { scanSyncedFolders() }, - onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) } + onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }, + onSaveOriginalFile = ::saveDesktopOriginalFile ) - SharedAppTab.SHELVES -> ShelvesScreen( - shelves = state.shelves, + SharedAppTab.SHELVES -> LibraryScreen( + state = state, + selectedLibraryTab = NonReaderLibraryTab.SHELVES, + onLibraryTabChange = { + selectedLibraryTab = it + selectedTab = SharedAppTab.LIBRARY + }, + onStateChange = ::updateState, + onImportBooks = { + importFiles(chooseFiles()) + }, + onImportFolder = { chooseFolder()?.let(::importFolder) }, onRead = ::openReader, onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, - selectedBookIds = state.selectedBookIds, - pinnedBookIds = state.pinnedLibraryBookIds, + onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) }, + onRemoveSelected = ::removeSelectedBooks, onShowBookInfo = { bookInfoInitiallyEditing = false bookInfoDialogFor = it @@ -3162,12 +4245,30 @@ internal fun EpistemeDesktopApp( bookInfoInitiallyEditing = true bookInfoDialogFor = it }, - onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }, - onCreateShelf = { showCreateShelfDialog = true }, + onCreateShelf = { + createShelfBookIds = emptySet() + createShelfClearsSelection = false + showCreateShelfDialog = true + }, + onCreateShelfWithBooks = { name, bookIds -> createShelfWithBooks(name, bookIds) }, onCreateSmartShelf = { showCreateSmartShelfDialog = true }, onRenameShelf = { shelfToRename = it }, onDeleteShelf = { shelfToDelete = it }, - onRemoveFolder = { folderToRemove = it } + onRemoveFolder = { folderToRemove = it }, + onTagSelectedBooks = { showTagSelectionDialog = true }, + onAddSelectedBooksToShelf = { + addToShelfBookIds = state.selectedBookIds + addToShelfClearsSelection = true + }, + onAddBooksToShelf = { bookIds -> + addToShelfBookIds = bookIds + addToShelfClearsSelection = false + }, + onManageShelfBooks = { shelfToManageBooks = it }, + onSyncFolderMetadata = { syncFolderMetadata() }, + onScanFolders = { scanSyncedFolders() }, + onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }, + onSaveOriginalFile = ::saveDesktopOriginalFile ) SharedAppTab.CATALOGS -> { @@ -3243,6 +4344,21 @@ internal fun EpistemeDesktopApp( { openExternalUrl(EpistemeIssuesUrl) } } else { null + }, + onOpenPrivacyPolicy = if (featurePolicy.projectLinks) { + { openExternalUrl(desktopBuildProfile.legalLinks.privacyPolicyUrl) } + } else { + null + }, + onOpenTerms = if (featurePolicy.projectLinks) { + { openExternalUrl(desktopBuildProfile.legalLinks.termsUrl) } + } else { + null + }, + onOpenLicenses = if (featurePolicy.projectLinks) { + { openExternalUrl(desktopBuildProfile.legalLinks.licensesUrl) } + } else { + null } ) @@ -3253,18 +4369,48 @@ internal fun EpistemeDesktopApp( } readerWindows.forEach { readerWindow -> - key(readerWindow.id) { + key(readerWindow.id, readerWindow.surfaceResetId) { + val restoredReaderWindowState = savedReaderWindowState val windowState = rememberWindowState( + placement = restoredReaderWindowState?.toReaderWindowPlacement() ?: WindowPlacement.Floating, position = WindowPosition(Alignment.Center), - size = DpSize(1120.dp, 760.dp) + size = restoredReaderWindowState?.toWindowSize(DesktopReaderWindowDefaultSize) + ?: DesktopReaderWindowDefaultSize ) Window( - onCloseRequest = { closeReaderWindow(readerWindow.id) }, + onCloseRequest = { + logDesktopReaderClose( + "window_on_close_request windowId=${readerWindow.id.logPreview(80)} " + + "bookId=${readerWindow.bookId.logPreview(80)} fullscreen=${readerWindow.fullscreen}" + ) + if (!readerWindow.fullscreen) { + saveReaderWindowStateSnapshot(DesktopWindowStateSnapshot.fromWindowState(windowState)) + } + closeReaderWindow(readerWindow.id) + }, title = desktopString("desktop_label_pair_format", "%1\$s - %2\$s", readerWindow.title, readerWindowDefaults.title), state = windowState, icon = painterResource(readerWindowDefaults.iconResourcePath) ) { val readerAwtWindow = this.window + val latestReaderWindowForDispose by rememberUpdatedState(readerWindow) + DisposableEffect(readerWindow.id) { + onDispose { + logDesktopReaderClose( + "window_dispose_effect windowId=${latestReaderWindowForDispose.id.logPreview(80)} " + + "bookId=${latestReaderWindowForDispose.bookId.logPreview(80)} " + + "content=${latestReaderWindowForDispose.readerCloseContentLabel()}" + ) + latestReaderWindowForDispose.closeReaderResources() + } + } + DesktopWindowStatePersistenceEffect( + windowState = windowState, + store = readerWindowStateStore, + enabled = !readerWindow.fullscreen, + transformSnapshot = { it.toPersistableReaderWindowSnapshot() }, + onSnapshotSaved = { savedReaderWindowState = it } + ) DisposableEffect(readerAwtWindow, readerWindowDefaults.minimumSize) { readerAwtWindow.minimumSize = readerWindowDefaults.minimumSize onDispose {} @@ -3310,11 +4456,18 @@ internal fun EpistemeDesktopApp( ) { when (val content = readerWindow.content) { DesktopReaderWindowContent.Opening -> { - DesktopReaderOpeningScreen(opening = readerWindow.opening) + val openingBook = state.rawLibraryBooks.firstOrNull { it.id == readerWindow.bookId } + DesktopReaderOpeningScreen( + opening = readerWindow.opening, + readerSettings = openingBook?.let { resolvedDesktopReaderSettings(it, state.readerDefaultSettings) } + ) } is DesktopReaderWindowContent.PasswordRequired -> { - DesktopReaderOpeningScreen(opening = readerWindow.opening) + DesktopReaderOpeningScreen( + opening = readerWindow.opening, + readerSettings = resolvedDesktopReaderSettings(content.book, state.readerDefaultSettings) + ) DesktopPdfPasswordDialog( title = content.book.displayName, isError = content.attemptedPassword, @@ -3350,6 +4503,7 @@ internal fun EpistemeDesktopApp( onFullscreenChange = { enabled -> updateReaderWindow(readerWindow.id) { it.copy(fullscreen = enabled) } }, + appThemeControls = appThemeControls, onPageStateChange = { page, progress, viewport -> updateBookReadingState( bookId = content.book.id, @@ -3365,12 +4519,26 @@ internal fun EpistemeDesktopApp( onPdfHighlighterPaletteChange = { palette -> updateState(state.reduce(AppAction.PdfHighlighterPaletteChanged(palette))) }, + customReaderThemes = state.customReaderThemes, + onCustomReaderThemesChange = { themes -> + updateState(state.reduce(AppAction.CustomReaderThemesChanged(themes))) + }, customTextureIds = readerCustomTextureIds, onImportTexture = ::importDesktopReaderTexture, onLocalSidecarsChanged = { state.rawLibraryBooks.firstOrNull { it.id == content.book.id }?.let { book -> syncBookSidecars(book) - queueCloudBookMetadataSync(book) + markReaderCloudDirty( + bookId = book.id, + baseTimestamp = book.timestamp, + sidecarsDirty = true + ) + queueCloudBookMetadataSync( + book = book, + debounce = true, + dirtyBaseTimestamp = book.timestamp, + forceUploadAnnotations = true + ) } }, aiByokSettings = effectiveAiSettings(), @@ -3382,23 +4550,26 @@ internal fun EpistemeDesktopApp( }, summaryCacheStore = desktopSummaryCacheStore, credits = state.credits, - showPaidCredits = !desktopBuildProfile.byokAiAvailable, + showPaidCredits = desktopCloudTtsUsesCredits, onAiByokSettingsChange = ::updateAiByokSettings, featurePolicy = featurePolicy, + cloudTtsControlsAvailable = desktopCloudTtsControlsAvailable, onReaderAiEntitlementRequired = { feature, text -> desktopFeatureNoticeForReaderAi(feature, text)?.let { notice -> - desktopFeatureNotice = notice + showDesktopFeatureNotice(notice, readerWindowId = readerWindow.id) true } ?: false }, onCloudTtsEntitlementRequired = { desktopFeatureNoticeForCloudTts()?.let { notice -> - desktopFeatureNotice = notice + showDesktopFeatureNotice(notice, readerWindowId = readerWindow.id) true } ?: false }, onPaidFeatureError = { errorMessage -> - desktopFeatureNoticeForError(errorMessage)?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForError(errorMessage)?.let { + showDesktopFeatureNotice(it, readerWindowId = readerWindow.id) + } }, hasReflowFile = activePdfHasReflowFile, isReflowingThisBook = activePdfBook.id in reflowingPdfBookIds, @@ -3411,6 +4582,40 @@ internal fun EpistemeDesktopApp( } is DesktopReaderWindowContent.Text -> { + var previousTextReaderMode by remember(readerWindow.id) { + mutableStateOf(content.session.reader.settings.readingMode) + } + LaunchedEffect(content.session.reader.settings.readingMode) { + val previousMode = previousTextReaderMode + val currentMode = content.session.reader.settings.readingMode + previousTextReaderMode = currentMode + if ( + shouldResetDesktopTextReaderWindowSurface( + previousMode = previousMode, + currentMode = currentMode, + usesNativeWebView = desktopEpubWebViewUsesNativeSwtBrowser() + ) + ) { + if (!readerWindow.fullscreen) { + saveReaderWindowStateSnapshot( + DesktopWindowStateSnapshot.fromWindowState(windowState) + ) + } + logReaderModeSwitch( + "window_surface_reset_request windowId=${readerWindow.id.logPreview()} " + + "bookId=${readerWindow.bookId.logPreview()} previousMode=$previousMode currentMode=$currentMode " + + "surfaceResetId=${readerWindow.surfaceResetId} fullscreen=${readerWindow.fullscreen} " + + "windowState=${windowState.size.width.value.formatLogFloat()}x" + + "${windowState.size.height.value.formatLogFloat()} placement=${windowState.placement}" + ) + updateReaderWindow(readerWindow.id) { currentWindow -> + currentWindow.copy( + surfaceResetId = currentWindow.surfaceResetId + 1, + focusRequestId = currentWindow.focusRequestId + 1 + ) + } + } + } LaunchedEffect( readerWindow.id, content.session.reader.book.id, @@ -3437,10 +4642,16 @@ internal fun EpistemeDesktopApp( onFullscreenChange = { enabled -> updateReaderWindow(readerWindow.id) { it.copy(fullscreen = enabled) } }, + readerAwtWindow = readerAwtWindow, toolbarPreferences = state.readerToolbarPreferences, onToolbarPreferencesChange = { preferences -> updateState(state.reduce(AppAction.ReaderToolbarPreferencesChanged(preferences))) }, + appThemeControls = appThemeControls, + customReaderThemes = state.customReaderThemes, + onCustomReaderThemesChange = { themes -> + updateState(state.reduce(AppAction.CustomReaderThemesChanged(themes))) + }, highlightPalette = state.readerHighlightPalette, onHighlightPaletteChange = { palette -> updateState(state.reduce(AppAction.ReaderHighlightPaletteChanged(palette))) @@ -3457,7 +4668,7 @@ internal fun EpistemeDesktopApp( readerExtrasState = content.extrasState, aiByokSettings = effectiveAiSettings(), externalLookupAvailable = featurePolicy.externalLookup, - cloudTtsControlsAvailable = featurePolicy.aiAndCloud, + cloudTtsControlsAvailable = desktopCloudTtsControlsAvailable, onExternalLookup = ::openReaderExternalLookup, onAiAction = { feature, text -> runReaderAiAction(readerWindow.id, feature, text) @@ -3470,68 +4681,46 @@ internal fun EpistemeDesktopApp( ) } }, - onCloudTtsToggle = { text -> toggleReaderCloudTts(readerWindow.id, text) }, + onCloudTtsToggle = { text, locator -> toggleReaderCloudTts(readerWindow.id, text, locator) }, onCloudTtsStart = { readScope, chunks -> startReaderCloudTts(readerWindow.id, readScope, chunks) }, onCloudTtsPauseResume = { pauseResumeReaderCloudTts(readerWindow.id) }, onCloudTtsStop = { stopReaderCloudTts(readerWindow.id) }, onCloudTtsClearCache = { clearReaderCloudTtsCache(readerWindow.id) }, + onCloudTtsVoiceChange = { voiceId -> + updateAiByokSettings(effectiveAiSettings().copy(ttsSpeakerId = voiceId)) + }, onOpenAiHub = { updateTextReaderWindow(readerWindow.id) { current -> current.copy(showAiHub = true) } }, - onAutoScrollChange = { autoScroll -> - updateReaderAutoScroll(readerWindow.id, autoScroll) - }, onDownloadReaderImage = ::downloadReaderImage, readerTextureDataUri = DesktopReaderTextures::dataUriFor, readerCustomTextureIds = readerCustomTextureIds, onImportReaderTexture = ::importDesktopReaderTexture, bottomChromeExtraContent = { - if (featurePolicy.aiAndCloud) { + if (desktopCloudTtsControlsAvailable) { val settings = effectiveAiSettings() - val ttsActive = content.extrasState.cloudTts.isLoading || - content.extrasState.cloudTts.isPlaying || - content.extrasState.cloudTts.isPaused - if (content.showCloudTtsSettings) { - DesktopCloudTtsSettingsOverlay( + var isTtsOverlayCollapsed by remember(readerWindow.id) { mutableStateOf(false) } + val ttsControls = readerCloudTtsControlsModel(content.extrasState.cloudTts) + if (ttsControls.isVisible) { + SharedReaderTtsOverlayControls( settings = settings, - isTtsActive = ttsActive, - showCredits = !desktopBuildProfile.byokAiAvailable, + cloudTts = content.extrasState.cloudTts, credits = state.credits, - cacheSummary = content.extrasState.cloudTts.cacheSummary, - onClearCache = { clearReaderCloudTtsCache(readerWindow.id) }, - onSettingsChange = { next -> - updateAiByokSettings( - aiByokSettings.sanitized().copy( - ttsSpeakerId = next.sanitized().ttsSpeakerId - ) - ) - } + showCredits = desktopCloudTtsUsesCredits, + isCollapsed = isTtsOverlayCollapsed, + onCollapseChange = { isTtsOverlayCollapsed = it }, + onPauseResume = { pauseResumeReaderCloudTts(readerWindow.id) }, + onSkipPrevious = { skipReaderCloudTtsChunk(readerWindow.id, -1) }, + onSkipNext = { skipReaderCloudTtsChunk(readerWindow.id, 1) }, + onLocateCurrentChunk = { locateReaderCloudTtsChunk(readerWindow.id) }, + onClose = { stopReaderCloudTts(readerWindow.id) }, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 4.dp) ) } - DesktopCloudTtsChromeControls( - settings = settings, - cloudTts = content.extrasState.cloudTts, - credits = state.credits, - showCredits = !desktopBuildProfile.byokAiAvailable, - onRead = { - startReaderCloudTts( - readerWindow.id, - ReaderTtsReadScope.BOOK, - ReaderTtsPlanner.chunksFromCurrentLocation(content.session) - ) - }, - onPauseResume = { pauseResumeReaderCloudTts(readerWindow.id) }, - onStop = { stopReaderCloudTts(readerWindow.id) }, - onOpenSettings = { - updateTextReaderWindow(readerWindow.id) { current -> - current.copy(showCloudTtsSettings = !current.showCloudTtsSettings) - } - } - ) } }, webViewRuntimeState = webViewRuntimeState, @@ -3568,11 +4757,20 @@ internal fun EpistemeDesktopApp( } }, credits = state.credits, - showCredits = !desktopBuildProfile.byokAiAvailable + showCredits = desktopCloudTtsUsesCredits ) } } } + desktopFeatureNoticeState + ?.takeIf { it.placement.rendersInReaderWindow(readerWindow.id) } + ?.let { noticeState -> + DesktopFeatureNoticeDialog( + notice = noticeState.notice, + onDismiss = ::dismissDesktopFeatureNotice, + onConfirm = { confirmDesktopFeatureNotice(noticeState.notice) } + ) + } } } } @@ -3580,38 +4778,17 @@ internal fun EpistemeDesktopApp( } } - desktopFeatureNotice?.let { notice -> - AlertDialog( - onDismissRequest = { desktopFeatureNotice = null }, - title = { Text(readerString(notice.titleKey, notice.titleFallback)) }, - text = { Text(readerString(notice.messageKey, notice.messageFallback)) }, - confirmButton = { - TextButton( - onClick = { - desktopFeatureNotice = null - when (notice.action) { - DesktopFeatureNoticeAction.SIGN_IN -> signInDesktopAccount() - DesktopFeatureNoticeAction.OPEN_PRO -> selectAppTab(SharedAppTab.PRO) - null -> Unit - } - } - ) { - Text(readerString(notice.confirmKey, notice.confirmFallback)) - } - }, - dismissButton = if (notice.action != null) { - { - TextButton(onClick = { desktopFeatureNotice = null }) { - Text(readerString("action_not_now", "Not now")) - } - } - } else { - null - } - ) - } + desktopFeatureNoticeState + ?.takeIf { it.placement.rendersInMainWindow() } + ?.let { noticeState -> + DesktopFeatureNoticeDialog( + notice = noticeState.notice, + onDismiss = ::dismissDesktopFeatureNotice, + onConfirm = { confirmDesktopFeatureNotice(noticeState.notice) } + ) + } - if (showAiByokSettingsDialog && desktopBuildProfile.byokAiAvailable) { + if (showAiByokSettingsDialog && desktopAiKeySettingsAvailable) { DesktopAiByokSettingsDialog( settings = aiByokSettings, secureStorageAvailable = aiByokStore.isSecureStorageAvailable, @@ -3672,10 +4849,20 @@ internal fun EpistemeDesktopApp( label = readerString("shelf_name_hint", "Shelf name"), initialValue = "", confirmLabel = readerString("action_create", "Create"), - onDismiss = { showCreateShelfDialog = false }, - onConfirm = { name -> - createShelf(name) + onDismiss = { showCreateShelfDialog = false + createShelfBookIds = emptySet() + createShelfClearsSelection = false + }, + onConfirm = { name -> + if (createShelfBookIds.isEmpty()) { + createShelf(name) + } else { + createShelfWithBooks(name, createShelfBookIds, clearSelection = createShelfClearsSelection) + } + showCreateShelfDialog = false + createShelfBookIds = emptySet() + createShelfClearsSelection = false } ) } @@ -3737,17 +4924,36 @@ internal fun EpistemeDesktopApp( ) } - if (showAddToShelfDialog) { + if (addToShelfBookIds.isNotEmpty()) { SharedAddToShelfDialog( shelves = state.shelves.filter { it.type == ShelfType.MANUAL && it.id != "unshelved" }, - onDismiss = { showAddToShelfDialog = false }, + onDismiss = { + addToShelfBookIds = emptySet() + addToShelfClearsSelection = false + }, onCreateShelf = { - showAddToShelfDialog = false + createShelfBookIds = addToShelfBookIds + createShelfClearsSelection = addToShelfClearsSelection + addToShelfBookIds = emptySet() + addToShelfClearsSelection = false showCreateShelfDialog = true }, - onShelfSelected = { shelf -> - addSelectedBooksToShelf(shelf.id) - showAddToShelfDialog = false + onShelvesSelected = { shelfIds -> + addBooksToShelves(addToShelfBookIds, shelfIds, clearSelection = addToShelfClearsSelection) + addToShelfBookIds = emptySet() + addToShelfClearsSelection = false + } + ) + } + + shelfToManageBooks?.let { shelf -> + SharedManageShelfBooksDialog( + shelf = shelf, + books = state.rawLibraryBooks, + onDismiss = { shelfToManageBooks = null }, + onSave = { bookIds -> + replaceShelfBooks(shelf, bookIds) + shelfToManageBooks = null } ) } @@ -3797,6 +5003,33 @@ internal fun EpistemeDesktopApp( } } +@Composable +private fun DesktopFeatureNoticeDialog( + notice: DesktopFeatureNotice, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(readerString(notice.titleKey, notice.titleFallback)) }, + text = { Text(readerString(notice.messageKey, notice.messageFallback)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(readerString(notice.confirmKey, notice.confirmFallback)) + } + }, + dismissButton = if (notice.action != null) { + { + TextButton(onClick = onDismiss) { + Text(readerString("action_not_now", "Not now")) + } + } + } else { + null + } + ) +} + private fun desktopSignInRequiredNotice( messageKey: String, messageFallback: String @@ -3822,7 +5055,7 @@ private fun desktopOutOfCreditsNotice( messageKey = messageKey, messageFallback = messageFallback, confirmKey = "desktop_view_pro_and_credits", - confirmFallback = "View Pro and credits", + confirmFallback = "View account & credits", action = DesktopFeatureNoticeAction.OPEN_PRO ) } @@ -3837,7 +5070,7 @@ private fun desktopProRequiredNotice( messageKey = messageKey, messageFallback = messageFallback, confirmKey = "desktop_view_pro_and_credits", - confirmFallback = "View Pro and credits", + confirmFallback = "View account & credits", action = DesktopFeatureNoticeAction.OPEN_PRO ) } diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBuildProfileTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBuildProfileTest.kt deleted file mode 100644 index 403c10f..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBuildProfileTest.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.aryan.reader.desktop - -import com.aryan.reader.shared.ReaderAiByokSettings -import com.aryan.reader.shared.SharedFeaturePolicy -import java.io.File -import java.nio.file.Files -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class DesktopBuildProfileTest { - @Test - fun `standard desktop flavor keeps online features available`() { - val profile = desktopBuildProfileForFlavor("standard") - - assertEquals(DesktopFlavorStandard, profile.flavor) - assertEquals(EpistemeDesktopStandardAppName, profile.appName) - assertEquals("Standard edition", profile.buildLabel) - assertEquals(SharedFeaturePolicy.Standard, profile.featurePolicy) - assertTrue(profile.featurePolicy.networkAccess) - assertFalse(profile.featurePolicy.byokAi) - assertFalse(profile.byokAiAvailable) - } - - @Test - fun `oss offline desktop flavor disables network backed features`() { - val profile = desktopBuildProfileForFlavor("oss-offline") - - assertEquals(DesktopFlavorOssOffline, profile.flavor) - assertEquals(EpistemeDesktopOssAppName, profile.appName) - assertEquals("Offline OSS edition", profile.buildLabel) - assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy) - assertFalse(profile.featurePolicy.networkAccess) - assertFalse(profile.featurePolicy.aiAndCloud) - assertTrue(profile.featurePolicy.byokAi) - assertFalse(profile.byokAiAvailable) - assertFalse(profile.featurePolicy.opdsCatalogs) - assertFalse(profile.featurePolicy.googleFontsDownload) - } - - @Test - fun `oss desktop flavor aliases resolve to offline oss profile`() { - val profile = desktopBuildProfileForFlavor("oss") - - assertEquals(DesktopFlavorOssOffline, profile.flavor) - assertEquals(EpistemeDesktopOssAppName, profile.appName) - assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy) - } - - @Test - fun `desktop BYOK settings are only exposed by an online OSS-style policy`() { - val settings = ReaderAiByokSettings( - geminiKey = "gemini_secret", - modelForAll = "gemini:gemini-flash-lite-latest" - ) - val onlineOssPolicy = SharedFeaturePolicy( - networkAccess = true, - aiAndCloud = true, - byokAi = true - ) - - assertTrue( - DesktopBuildProfile( - flavor = "oss-online", - appName = "Episteme oss", - buildLabel = "OSS edition", - featurePolicy = onlineOssPolicy - ).byokAiAvailable - ) - assertEquals(settings, settings.withDesktopFeaturePolicy(onlineOssPolicy)) - assertTrue(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.Standard).hideReaderAiFeatures) - assertTrue(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.OssOffline).hideReaderAiFeatures) - } - - @Test - fun `desktop diagnostics are disabled unless explicitly enabled`() { - assertFalse(desktopDiagnosticsFlag(null)) - assertFalse(desktopDiagnosticsFlag("")) - assertFalse(desktopDiagnosticsFlag("false")) - assertFalse(desktopDiagnosticsFlag("1")) - - assertTrue(desktopDiagnosticsFlag("true")) - assertTrue(desktopDiagnosticsFlag(" TRUE ")) - } - - @Test - fun `bundled webview detection requires cef binaries`() { - val dir = Files.createTempDirectory("episteme-kcef-test").toFile() - try { - val windowsX64 = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) - assertFalse(isBundledDesktopWebViewPresent(dir, windowsX64)) - File(dir, "jcef.dll").writeText("jcef") - File(dir, "libcef.dll").writeText("cef") - - assertTrue(isBundledDesktopWebViewPresent(dir, windowsX64)) - } finally { - dir.deleteRecursively() - } - } - - @Test - fun `linux bundled webview detection requires cef shared library and resources`() { - val dir = Files.createTempDirectory("episteme-linux-kcef-test").toFile() - try { - val linuxX64 = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) - assertFalse(isBundledDesktopWebViewPresent(dir, linuxX64)) - - File(dir, "libcef.so").writeText("cef") - File(dir, "chrome-sandbox").writeText("sandbox") - File(dir, "icudtl.dat").writeText("icu") - File(dir, "locales").mkdir() - - assertTrue(isBundledDesktopWebViewPresent(dir, linuxX64)) - } finally { - dir.deleteRecursively() - } - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCloudSyncMappingTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCloudSyncMappingTest.kt deleted file mode 100644 index 351bb6f..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCloudSyncMappingTest.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.aryan.reader.desktop - -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.HighlightColor -import com.aryan.reader.shared.ReaderLocator -import com.aryan.reader.shared.UserHighlight -import com.aryan.reader.shared.reader.ReaderBookmark -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class DesktopCloudSyncMappingTest { - @Test - fun `book metadata encodes desktop reader state for cloud sync`() { - val bookmarkLocator = ReaderLocator( - chapterIndex = 2, - pageIndex = 4, - startOffset = 30, - endOffset = 44, - textQuote = "marked passage" - ) - val highlightLocator = ReaderLocator( - chapterIndex = 2, - startOffset = 50, - endOffset = 64, - textQuote = "highlighted text" - ) - val book = BookItem( - id = "book-1", - path = null, - type = FileType.EPUB, - displayName = "Book.epub", - timestamp = 1_000L, - title = "Book", - author = "Author", - progressPercentage = 42f, - lastPageIndex = 4, - readerPosition = ReaderLocator( - chapterIndex = 2, - pageIndex = 4, - startOffset = 10, - endOffset = 20 - ), - readerBookmarks = listOf( - ReaderBookmark( - id = "bookmark-1", - pageIndex = 4, - chapterTitle = "Chapter", - preview = "marked passage", - locator = bookmarkLocator - ) - ), - readerHighlights = listOf( - UserHighlight( - id = "highlight-1", - cfi = "desktop:2:50:64", - text = "highlighted text", - color = HighlightColor.YELLOW, - chapterIndex = 2, - locator = highlightLocator - ) - ) - ) - - val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false, timestamp = 2_000L) - val restored = metadata.toDesktopBookItem() - - assertEquals("desktop:2:10:20", metadata.lastPositionCfi) - assertEquals(2, metadata.lastChapterIndex) - assertEquals(4, metadata.lastPage) - assertEquals(42f, metadata.progressPercentage) - assertTrue(assertNotNull(metadata.bookmarksJson).contains("desktop:2:30:44")) - assertTrue(assertNotNull(metadata.highlightsJson).contains("highlighted text")) - assertEquals(book.id, restored.id) - assertEquals(2, restored.readerPosition?.chapterIndex) - assertEquals(10, restored.readerPosition?.startOffset) - assertEquals(1, restored.readerBookmarks.size) - assertEquals(2, restored.readerBookmarks.single().locator.chapterIndex) - assertEquals(30, restored.readerBookmarks.single().locator.startOffset) - assertEquals(44, restored.readerBookmarks.single().locator.endOffset) - assertEquals("desktop:2:30:44", restored.readerBookmarks.single().locator.cfi) - assertEquals(1, restored.readerHighlights.size) - assertEquals(2, restored.readerHighlights.single().locator.chapterIndex) - assertEquals(50, restored.readerHighlights.single().locator.startOffset) - assertEquals(64, restored.readerHighlights.single().locator.endOffset) - assertEquals("desktop:2:50:64", restored.readerHighlights.single().locator.cfi) - } - - @Test - fun `remote metadata without annotation json preserves existing desktop annotations`() { - val existingBookmark = ReaderBookmark( - id = "bookmark-1", - pageIndex = 1, - chapterTitle = "Chapter", - preview = "local bookmark" - ) - val existingHighlight = UserHighlight( - id = "highlight-1", - cfi = "desktop:0:12:18", - text = "local highlight", - color = HighlightColor.BLUE, - chapterIndex = 0 - ) - val existing = BookItem( - id = "book-1", - path = "C:/books/Book.epub", - type = FileType.EPUB, - displayName = "Book.epub", - timestamp = 1_000L, - readerBookmarks = listOf(existingBookmark), - readerHighlights = listOf(existingHighlight) - ) - val remote = DesktopCloudBookMetadata( - bookId = existing.id, - displayName = existing.displayName, - type = FileType.EPUB.name, - lastModifiedTimestamp = 2_000L, - bookmarksJson = null, - highlightsJson = null - ) - - val restored = remote.toDesktopBookItem(existing = existing) - - assertEquals(listOf(existingBookmark), restored.readerBookmarks) - assertEquals(listOf(existingHighlight), restored.readerHighlights) - assertEquals(existing.path, restored.path) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComicArchiveTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComicArchiveTest.kt deleted file mode 100644 index f465330..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComicArchiveTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.aryan.reader.desktop - -import com.aryan.reader.shared.FileType -import java.io.File -import java.nio.file.Files -import java.util.Base64 -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class DesktopComicArchiveTest { - @Test - fun `cbz archive loads image pages for pdf reader surface`() = withTempDir { dir -> - val cbz = File(dir, "comic.cbz") - ZipOutputStream(cbz.outputStream()).use { zip -> - zip.putNextEntry(ZipEntry("pages/001.png")) - zip.write(onePixelPngBytes()) - zip.closeEntry() - } - - val document = DesktopPdfium.loadComic(cbz, FileType.CBZ) - try { - assertEquals(1, document.pageCount) - assertEquals(1f, document.pageSizes.single().width) - assertEquals(1f, document.pageSizes.single().height) - - val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex = 0, scale = 8f) - - assertEquals(8, image.width) - assertEquals(8, image.height) - } finally { - document.close() - } - } - - @Test - fun `desktop comic types are routed through shared reader capability map`() { - assertTrue(DesktopComicArchive.canLoad(FileType.CBZ)) - assertTrue(DesktopComicArchive.canLoad(FileType.CBR)) - assertTrue(DesktopComicArchive.canLoad(FileType.CB7)) - } - - private fun withTempDir(block: (File) -> Unit) { - val dir = Files.createTempDirectory("reader-desktop-comic").toFile() - try { - block(dir) - } finally { - dir.deleteRecursively() - } - } - - private fun onePixelPngBytes(): ByteArray { - return Base64.getDecoder().decode( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderDefaultsTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderDefaultsTest.kt deleted file mode 100644 index 68fcdae..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderDefaultsTest.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.aryan.reader.desktop - -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.ReaderPlatform -import com.aryan.reader.shared.SharedFileCapabilities -import com.aryan.reader.shared.pdf.PdfZoomSpec -import com.aryan.reader.shared.reader.ReaderReadingMode -import com.aryan.reader.shared.reader.ReaderSettings -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class DesktopReaderDefaultsTest { - - @Test - fun `desktop open book dialog accepts every shared desktop readable format`() { - assertEquals( - SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP), - desktopBookFileTypesForDialog() - ) - assertTrue(FileType.PDF in desktopBookFileTypesForDialog()) - } - - @Test - fun `desktop uses global reader defaults when book has no local settings`() { - val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL) - val book = bookItem("without-local") - - assertEquals(defaults, resolvedDesktopReaderSettings(book, defaults)) - } - - @Test - fun `desktop keeps local book reader settings ahead of global defaults`() { - val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL) - val local = ReaderSettings(fontSize = 17, readingMode = ReaderReadingMode.PAGINATED, themeId = "sepia") - val book = bookItem("with-local").copy(readerSettings = local) - - assertEquals(local, resolvedDesktopReaderSettings(book, defaults)) - } - - @Test - fun `desktop pdf zoom allows deeper page magnification`() { - val sharedDefaultMax = PdfZoomSpec().max - val letterPageScale = DesktopPdfZoomSpec.safeRenderScale( - pageWidth = 612f, - pageHeight = 792f, - requestedScale = 6f - ) - - assertEquals(8f, DesktopPdfZoomSpec.max) - assertTrue(letterPageScale > sharedDefaultMax) - } - - @Test - fun `desktop pdf touchpad zoom factors zoom in and out`() { - val zoomSpec = PdfZoomSpec(min = 0.5f, max = 8f, default = 1f) - - assertTrue(desktopPdfScrollZoomFactor(-1f) > 1.1f) - assertTrue(desktopPdfScrollZoomFactor(1f) < 0.9f) - assertEquals(8f, desktopPdfZoomTarget(currentZoom = 7.8f, zoomSpec = zoomSpec, factor = 2f)) - assertEquals(0.5f, desktopPdfZoomTarget(currentZoom = 0.6f, zoomSpec = zoomSpec, factor = 0.1f)) - } - - @Test - fun `desktop paginated pdf page changes avoid high resolution first render`() { - assertEquals( - DesktopPdfPaginationFastFirstRenderMaxScale, - desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = false) - ) - assertEquals( - 6f, - desktopPdfPaginationFirstRenderScale( - requestedScale = 6f, - hasPageRender = false, - isOpeningRender = true - ) - ) - assertEquals( - 6f, - desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = true) - ) - assertEquals( - 1.25f, - desktopPdfPaginationFirstRenderScale(requestedScale = 1.25f, hasPageRender = false) - ) - assertEquals( - 0.75f, - desktopPdfPaginationFirstRenderScale(requestedScale = 0.75f, hasPageRender = false) - ) - } - - @Test - fun `desktop pdf anchored zoom keeps cursor content stable`() { - assertEquals( - 300, - desktopPdfAnchoredScrollTarget(currentScroll = 100, anchor = 100f, oldZoom = 1f, newZoom = 2f) - ) - assertEquals( - 25, - desktopPdfAnchoredScrollTarget(currentScroll = 150, anchor = 100f, oldZoom = 2f, newZoom = 1f) - ) - assertEquals( - 100, - desktopPdfAnchoredLazyItemScrollOffset(itemOffset = 0, anchor = 100f, oldZoom = 1f, newZoom = 2f) - ) - assertEquals( - 200, - desktopPdfAnchoredLazyItemScrollOffset(itemOffset = -50, anchor = 100f, oldZoom = 1f, newZoom = 2f) - ) - assertEquals( - IntOffset(100, 100), - desktopPdfAnchoredPageScrollDelta( - viewportRootOffset = Offset.Zero, - oldPageRootOffset = Offset.Zero, - currentPageRootOffset = Offset.Zero, - anchor = Offset(100f, 100f), - oldZoom = 1f, - newZoom = 2f - ) - ) - assertEquals( - IntOffset(0, 0), - desktopPdfAnchoredPageScrollDelta( - viewportRootOffset = Offset.Zero, - oldPageRootOffset = Offset.Zero, - currentPageRootOffset = Offset(-100f, -100f), - anchor = Offset(100f, 100f), - oldZoom = 1f, - newZoom = 2f - ) - ) - val offCenterPivot = desktopPdfZoomPreviewPivotFraction( - viewportRootOffset = Offset(20f, 30f), - pageRootOffset = Offset(120f, 230f), - anchor = Offset(250f, 450f), - pageCanvasSize = IntSize(500, 1000) - ) ?: error("Expected off-center pivot") - assertEquals(0.3f, offCenterPivot.x, 0.0001f) - assertEquals(0.25f, offCenterPivot.y, 0.0001f) - - val clampedPivot = desktopPdfZoomPreviewPivotFraction( - viewportRootOffset = Offset.Zero, - pageRootOffset = Offset.Zero, - anchor = Offset(900f, -20f), - pageCanvasSize = IntSize(500, 1000) - ) ?: error("Expected clamped pivot") - assertEquals(1f, clampedPivot.x, 0.0001f) - assertEquals(0f, clampedPivot.y, 0.0001f) - - val firstPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation( - viewportRootOffset = Offset.Zero, - pageRootOffset = Offset(0f, 0f), - anchor = Offset(100f, 200f), - previewScale = 2f - ) ?: error("Expected first page document translation") - assertEquals(-100f, firstPageDocumentTranslation.x, 0.0001f) - assertEquals(-200f, firstPageDocumentTranslation.y, 0.0001f) - - val secondPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation( - viewportRootOffset = Offset.Zero, - pageRootOffset = Offset(0f, 900f), - anchor = Offset(100f, 200f), - previewScale = 2f - ) ?: error("Expected second page document translation") - assertEquals(-100f, secondPageDocumentTranslation.x, 0.0001f) - assertEquals(700f, secondPageDocumentTranslation.y, 0.0001f) - } - - private fun bookItem(id: String): BookItem { - return BookItem( - id = id, - path = "C:/Books/$id.epub", - type = FileType.EPUB, - displayName = "$id.epub", - timestamp = 1L - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderWindowStateTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderWindowStateTest.kt deleted file mode 100644 index 1aa6792..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderWindowStateTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.aryan.reader.desktop - -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.ui.SharedAppTab -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class DesktopReaderWindowStateTest { - - @Test - fun `opening a new reader creates a window`() { - val opening = readerOpening("book-1", requestId = 1) - - val decision = emptyList().openOrFocusDesktopReaderWindow( - opening = opening, - force = false - ) - - assertTrue(decision.shouldStartOpen) - assertEquals(listOf("book-1"), decision.windows.map { it.bookId }) - assertEquals(1L, decision.windows.single().focusRequestId) - } - - @Test - fun `opening an already open reader focuses the existing window`() { - val opening = readerOpening("book-1", requestId = 1) - val first = emptyList() - .openOrFocusDesktopReaderWindow(opening, force = false) - .windows - - val decision = first.openOrFocusDesktopReaderWindow( - opening = readerOpening("book-1", requestId = 2), - force = false - ) - - assertFalse(decision.shouldStartOpen) - assertEquals(1, decision.windows.size) - assertEquals(2L, decision.windows.single().focusRequestId) - assertEquals(1L, decision.windows.single().opening.requestId) - } - - @Test - fun `forcing an already open reader replaces the opening request`() { - val opening = readerOpening("book-1", requestId = 1) - val first = emptyList() - .openOrFocusDesktopReaderWindow(opening, force = false) - .windows - - val decision = first.openOrFocusDesktopReaderWindow( - opening = readerOpening("book-1", requestId = 2), - force = true - ) - - assertTrue(decision.shouldStartOpen) - assertEquals(1, decision.windows.size) - assertEquals(2L, decision.windows.single().opening.requestId) - assertEquals(2L, decision.windows.single().focusRequestId) - } - - private fun readerOpening(bookId: String, requestId: Long): DesktopReaderOpening { - return DesktopReaderOpening( - requestId = requestId, - bookId = bookId, - title = "Book $bookId", - formatLabel = FileType.EPUB.name, - returnTab = SharedAppTab.LIBRARY - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStartupTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStartupTest.kt deleted file mode 100644 index 23d59ad..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStartupTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.aryan.reader.desktop - -import com.aryan.reader.shared.ReaderFeatureSurface -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class DesktopStartupTest { - @Test - fun `startup splash uses compact branded feedback`() { - val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("standard")) - - assertEquals(EpistemeDesktopWindowTitle, spec.title) - assertTrue(spec.message.isNotBlank()) - assertTrue(spec.width in 320..480) - assertTrue(spec.height in 180..280) - } - - @Test - fun `oss startup splash uses oss branding`() { - val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("oss-offline")) - - assertEquals(EpistemeDesktopOssAppName, spec.title) - } - - @Test - fun `embedded webview starts only for epub backed reader surfaces`() { - assertTrue(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.EPUB_READER)) - assertTrue(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.TEXT_READER)) - assertFalse(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.PDF_VIEWER)) - assertFalse(shouldRequestDesktopWebViewRuntime(null)) - } - - @Test - fun `embedded webview startup skips terminal runtime states`() { - assertFalse(shouldStartDesktopWebViewRuntime(requested = false, state = DesktopWebViewRuntimeState())) - assertTrue(shouldStartDesktopWebViewRuntime(requested = true, state = DesktopWebViewRuntimeState())) - assertFalse( - shouldStartDesktopWebViewRuntime( - requested = true, - state = DesktopWebViewRuntimeState(initialized = true) - ) - ) - assertFalse( - shouldStartDesktopWebViewRuntime( - requested = true, - state = DesktopWebViewRuntimeState(restartRequired = true) - ) - ) - assertFalse( - shouldStartDesktopWebViewRuntime( - requested = true, - state = DesktopWebViewRuntimeState(errorMessage = "missing bundle") - ) - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAiByokStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStoreTest.kt similarity index 70% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAiByokStoreTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStoreTest.kt index c559812..737dfb4 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAiByokStoreTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStoreTest.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID -import com.aryan.reader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL_ID +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings import java.nio.file.Files import kotlin.io.path.readText import kotlin.io.path.writeText @@ -86,6 +86,54 @@ class DesktopAiByokStoreTest { assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel) } + @Test + fun `save with blank key clears protected secret entry`() { + val settingsFile = Files.createTempDirectory("reader-ai-store-clear").resolve("ai-byok.properties") + val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec) + + store.save( + ReaderAiByokSettings( + geminiKey = "gemini_secret", + groqKey = "groq_secret", + modelForAll = "groq:qwen/qwen3-32b" + ) + ) + store.save( + ReaderAiByokSettings( + groqKey = "groq_secret", + modelForAll = "groq:qwen/qwen3-32b" + ) + ) + + val raw = settingsFile.readText() + assertFalse(raw.contains("geminiKeyProtected=")) + assertTrue(raw.contains("groqKeyProtected=")) + + val loaded = store.load() + assertEquals("", loaded.geminiKey) + assertEquals("groq_secret", loaded.groqKey) + assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll) + } + + @Test + fun `load ignores legacy hidden reader ai preference on desktop`() { + val settingsFile = Files.createTempDirectory("reader-ai-store-visible").resolve("ai-byok.properties") + settingsFile.writeText( + """ + hideReaderAiFeatures=true + modelForAll=groq:qwen/qwen3-32b + useOneModel=true + """.trimIndent() + ) + val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec) + + val loaded = store.load() + + assertFalse(loaded.hideReaderAiFeatures) + assertTrue(loaded.useOneModel) + assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll) + } + @Test fun `load does not probe secure storage when settings file is missing`() { val settingsFile = Files.createTempDirectory("reader-ai-store-missing").resolve("ai-byok.properties") diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAurPackagingMetadataTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAurPackagingMetadataTest.kt new file mode 100644 index 0000000..b72f737 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAurPackagingMetadataTest.kt @@ -0,0 +1,32 @@ +package org.dueattendant149.bookreader.desktop + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class DesktopAurPackagingMetadataTest { + @Test + fun `aur metadata declares arch runtime dependencies license and desktop mime support`() { + val buildScript = desktopBuildScriptText() + + assertTrue(buildScript.contains("\"libarchive\"")) + assertTrue(buildScript.contains("license=('AGPL-3.0-only')")) + assertTrue(buildScript.contains("license = AGPL-3.0-only")) + assertTrue(buildScript.contains("/usr/share/licenses/${'$'}pkgname/LICENSE")) + assertTrue(buildScript.contains("application/epub+zip")) + assertTrue(buildScript.contains("application/vnd.comicbook+zip")) + assertTrue(buildScript.contains("application/vnd.openxmlformats-officedocument.wordprocessingml.document")) + } + + private fun desktopBuildScriptText(): String { + val candidates = listOf( + File("build.gradle.kts"), + File("desktopApp/build.gradle.kts") + ) + val buildFile = candidates.firstOrNull { file -> + file.isFile && file.readText().contains("PrepareDesktopAurPackageTask") + } + requireNotNull(buildFile) { "Could not locate desktopApp/build.gradle.kts" } + return buildFile.readText() + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAuthStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAuthStoreTest.kt new file mode 100644 index 0000000..0cf8176 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAuthStoreTest.kt @@ -0,0 +1,108 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.UserData +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopAuthStoreTest { + @Test + fun `save protects refresh tokens and load restores the account`() { + val settingsFile = Files.createTempDirectory("reader-auth-store") + .resolve("auth.properties") + .toFile() + val store = DesktopAuthStore(settingsFile, ReversibleSecretCodec) + + store.save(testSession()) + + val raw = settingsFile.readText() + assertFalse(raw.contains("firebase_refresh")) + assertFalse(raw.contains("google_refresh")) + assertTrue(raw.contains("firebaseRefreshTokenProtected=")) + assertTrue(raw.contains("googleRefreshTokenProtected=")) + + val loaded = DesktopAuthStore(settingsFile, ReversibleSecretCodec).load() + assertEquals("user-1", loaded?.user?.uid) + assertEquals("reader@example.com", loaded?.user?.email) + assertEquals("firebase_refresh", loaded?.refreshToken) + assertEquals("google_refresh", loaded?.googleRefreshToken) + } + + @Test + fun `save falls back to session only without leaving a partial account file when secure storage is unavailable`() { + val settingsFile = Files.createTempDirectory("reader-auth-store-unavailable") + .resolve("auth.properties") + .toFile() + val store = DesktopAuthStore(settingsFile, ThrowingSecretCodec) + + store.save(testSession()) + + assertFalse(settingsFile.exists()) + assertEquals(null, store.load()) + } + + @Test + fun `save still surfaces secure storage write failures when storage is available`() { + val settingsFile = Files.createTempDirectory("reader-auth-store-available-write-failure") + .resolve("auth.properties") + .toFile() + val store = DesktopAuthStore(settingsFile, AvailableThrowingSecretCodec) + + assertFailsWith { + store.save(testSession()) + } + assertFalse(settingsFile.exists()) + } + + private fun testSession(): DesktopAuthSession { + return DesktopAuthSession( + user = UserData( + uid = "user-1", + displayName = "Reader", + photoUrl = null, + email = "reader@example.com" + ), + idToken = "id_token", + refreshToken = "firebase_refresh", + expiresAtEpochMillis = 123L, + googleAccessToken = "google_access", + googleRefreshToken = "google_refresh", + googleAccessTokenExpiresAtEpochMillis = 456L + ) + } + + private object ReversibleSecretCodec : DesktopSecretCodec { + override val isAvailable: Boolean = true + + override fun protect(value: String): String { + return "test:" + value.reversed() + } + + override fun unprotect(value: String): String { + return value.removePrefix("test:").reversed() + } + } + + private object ThrowingSecretCodec : DesktopSecretCodec { + override val isAvailable: Boolean = false + + override fun protect(value: String): String { + throw IllegalStateException("Secure storage unavailable") + } + + override fun unprotect(value: String): String = "" + } + + private object AvailableThrowingSecretCodec : DesktopSecretCodec { + override val isAvailable: Boolean = true + + override fun protect(value: String): String { + throw IllegalStateException("Secure storage write failed") + } + + override fun unprotect(value: String): String = "" + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBookImporterTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporterTest.kt similarity index 96% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBookImporterTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporterTest.kt index 952614f..08bc0ed 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBookImporterTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporterTest.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.ImportedBookFile +import org.dueattendant149.bookreader.shared.ImportedBookFile import java.io.File import java.nio.file.Files import java.security.MessageDigest diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfileTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfileTest.kt new file mode 100644 index 0000000..9280be1 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfileTest.kt @@ -0,0 +1,175 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL_ID +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.SharedFeaturePolicy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopBuildProfileTest { + @Test + fun `standard desktop flavor keeps online features available`() { + val profile = desktopBuildProfileForFlavor("standard") + + assertEquals(DesktopFlavorStandard, profile.flavor) + assertEquals(EpistemeDesktopStandardAppName, profile.appName) + assertEquals("Standard edition", profile.buildLabel) + assertEquals(SharedFeaturePolicy.Standard, profile.featurePolicy) + assertTrue(profile.legalLinks.privacyPolicyUrl.endsWith("/privacy-policy.html")) + assertTrue(profile.legalLinks.termsUrl.endsWith("/terms-and-conditions.html")) + assertTrue(profile.featurePolicy.networkAccess) + assertFalse(profile.featurePolicy.byokAi) + assertFalse(profile.byokAiAvailable) + assertTrue(profile.aiKeySettingsAvailable) + assertTrue(profile.creditBackedCloudTtsControlsAvailable) + } + + @Test + fun `oss offline desktop flavor disables network backed features`() { + val profile = desktopBuildProfileForFlavor("oss-offline") + + assertEquals(DesktopFlavorOssOffline, profile.flavor) + assertEquals(EpistemeDesktopOssAppName, profile.appName) + assertEquals("Offline OSS edition", profile.buildLabel) + assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy) + assertTrue(profile.legalLinks.privacyPolicyUrl.endsWith("/oss-privacy-policy.html")) + assertTrue(profile.legalLinks.termsUrl.endsWith("/oss-terms-of-service.html")) + assertFalse(profile.featurePolicy.networkAccess) + assertFalse(profile.featurePolicy.aiAndCloud) + assertTrue(profile.featurePolicy.byokAi) + assertFalse(profile.byokAiAvailable) + assertFalse(profile.aiKeySettingsAvailable) + assertFalse(profile.featurePolicy.opdsCatalogs) + assertFalse(profile.featurePolicy.googleFontsDownload) + assertFalse(profile.creditBackedCloudTtsControlsAvailable) + } + + @Test + fun `oss desktop flavor aliases resolve to offline oss profile`() { + val profile = desktopBuildProfileForFlavor("oss") + + assertEquals(DesktopFlavorOssOffline, profile.flavor) + assertEquals(EpistemeDesktopOssAppName, profile.appName) + assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy) + } + + @Test + fun `desktop BYOK settings are only exposed by an online OSS-style policy`() { + val settings = ReaderAiByokSettings( + geminiKey = "gemini_secret", + modelForAll = "gemini:gemini-flash-lite-latest" + ) + val onlineOssPolicy = SharedFeaturePolicy.OssOnline + + val onlineOssProfile = DesktopBuildProfile( + flavor = "oss-online", + appName = "Episteme oss", + buildLabel = "OSS edition", + featurePolicy = onlineOssPolicy + ) + + assertTrue(onlineOssProfile.byokAiAvailable) + assertFalse(onlineOssProfile.aiKeySettingsAvailable) + assertEquals(settings, settings.withDesktopFeaturePolicy(onlineOssPolicy)) + assertFalse(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.Standard).hideReaderAiFeatures) + assertFalse(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.OssOffline).hideReaderAiFeatures) + + val byokCloudTtsSettings = settings.copy( + geminiKey = "gemini_secret", + ttsModel = GEMINI_CLOUD_TTS_MODEL_ID + ) + val desktopByokSettings = byokCloudTtsSettings.withDesktopFeaturePolicy(onlineOssPolicy) + assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, desktopByokSettings.ttsModel) + assertTrue(desktopByokSettings.isCloudTtsAvailable) + assertFalse( + DesktopBuildProfile( + flavor = "oss-online", + appName = "Episteme oss", + buildLabel = "OSS edition", + featurePolicy = onlineOssPolicy + ).creditBackedCloudTtsControlsAvailable + ) + } + + @Test + fun `desktop tts worker requires its own configured endpoint`() { + val config = DesktopCloudConfig( + aiWorkerUrl = "https://example.com/ai", + ttsWorkerUrl = "", + firebaseWebApiKey = "", + firebaseProjectId = "", + googleOAuthClientId = "", + googleOAuthClientSecret = "" + ) + + assertTrue(config.isAiWorkerConfigured) + assertFalse(config.isTtsWorkerConfigured) + } + + @Test + fun `desktop cloud tts adapter allows byok before credit worker`() { + val byokAdapter = DesktopGeminiCloudTtsAdapter( + settingsProvider = { + ReaderAiByokSettings( + geminiKey = "gemini_secret", + ttsModel = GEMINI_CLOUD_TTS_MODEL_ID + ) + }, + networkAccess = { true }, + workerUrlProvider = { "" } + ) + val workerAdapter = DesktopGeminiCloudTtsAdapter( + settingsProvider = { ReaderAiByokSettings(serverBackedCloudTts = true) }, + networkAccess = { true }, + workerUrlProvider = { "https://example.com/tts" } + ) + val unavailableAdapter = DesktopGeminiCloudTtsAdapter( + settingsProvider = { ReaderAiByokSettings() }, + networkAccess = { true }, + workerUrlProvider = { "https://example.com/tts" } + ) + + assertTrue(byokAdapter.isAvailable) + assertTrue(workerAdapter.isAvailable) + assertFalse(unavailableAdapter.isAvailable) + } + + @Test + fun `desktop persisted AI settings keep Android model controls and force visibility`() { + val settings = ReaderAiByokSettings( + geminiKey = " gemini_secret ", + groqKey = " groq_secret ", + useOneModel = true, + modelForAll = "groq:qwen/qwen3-32b", + defineModel = "gemini:gemini-flash-lite-latest", + summarizeModel = "groq:llama-3.3-70b-versatile", + recapModel = "gemini:gemini-2.5-flash-lite", + hideReaderAiFeatures = true + ) + + val persisted = settings.toDesktopPersistableAiSettings() + + assertEquals("gemini_secret", persisted.geminiKey) + assertEquals("groq_secret", persisted.groqKey) + assertTrue(persisted.useOneModel) + assertEquals("groq:qwen/qwen3-32b", persisted.modelForAll) + assertEquals("gemini:gemini-flash-lite-latest", persisted.defineModel) + assertEquals("groq:llama-3.3-70b-versatile", persisted.summarizeModel) + assertEquals("gemini:gemini-2.5-flash-lite", persisted.recapModel) + assertFalse(persisted.hideReaderAiFeatures) + } + + @Test + fun `desktop diagnostics are disabled unless explicitly enabled`() { + assertFalse(desktopDiagnosticsFlag(null)) + assertFalse(desktopDiagnosticsFlag("")) + assertFalse(desktopDiagnosticsFlag("false")) + assertFalse(desktopDiagnosticsFlag("1")) + + assertTrue(desktopDiagnosticsFlag("true")) + assertTrue(desktopDiagnosticsFlag(" TRUE ")) + } + +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfigTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfigTest.kt new file mode 100644 index 0000000..e2ef8cf --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfigTest.kt @@ -0,0 +1,60 @@ +package org.dueattendant149.bookreader.desktop + +import java.util.Properties +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopCloudConfigTest { + @Test + fun `packaged resource config can enable desktop Google sign in`() { + val config = desktopCloudConfigFromProperties( + resourceProperties = properties( + "FIREBASE_WEB_API_KEY" to "firebase-key", + "FIREBASE_PROJECT_ID" to "reader-project", + "GOOGLE_OAUTH_CLIENT_ID" to "oauth-client", + "GOOGLE_OAUTH_CLIENT_SECRET" to "oauth-secret" + ), + systemProperty = { null }, + environment = { null } + ) + + assertTrue(config.isAuthConfigured) + assertEquals("firebase-key", config.firebaseWebApiKey) + assertEquals("reader-project", config.firebaseProjectId) + assertEquals("oauth-client", config.googleOAuthClientId) + assertEquals("oauth-secret", config.googleOAuthClientSecret) + } + + @Test + fun `local desktop keys override packaged resource config`() { + val config = desktopCloudConfigFromProperties( + resourceProperties = properties( + "FIREBASE_WEB_API_KEY" to "packaged-firebase-key", + "FIREBASE_PROJECT_ID" to "packaged-project", + "GOOGLE_OAUTH_CLIENT_ID" to "packaged-oauth-client", + "GOOGLE_OAUTH_CLIENT_SECRET" to "packaged-oauth-secret" + ), + localProperties = properties( + "DESKTOP_FIREBASE_WEB_API_KEY" to "local-firebase-key", + "DESKTOP_FIREBASE_PROJECT_ID" to "local-project", + "DESKTOP_GOOGLE_OAUTH_CLIENT_ID" to "local-oauth-client", + "DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET" to "local-oauth-secret" + ), + systemProperty = { null }, + environment = { null } + ) + + assertTrue(config.isAuthConfigured) + assertEquals("local-firebase-key", config.firebaseWebApiKey) + assertEquals("local-project", config.firebaseProjectId) + assertEquals("local-oauth-client", config.googleOAuthClientId) + assertEquals("local-oauth-secret", config.googleOAuthClientSecret) + } +} + +private fun properties(vararg values: Pair): Properties { + return Properties().apply { + values.forEach { (key, value) -> setProperty(key, value) } + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncMappingTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncMappingTest.kt new file mode 100644 index 0000000..de3454c --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncMappingTest.kt @@ -0,0 +1,391 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.HighlightColor +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.UserHighlight +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmark +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichDocument +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer +import org.dueattendant149.bookreader.shared.reader.ReaderBookmark +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopCloudSyncMappingTest { + @Test + fun `book metadata encodes desktop reader state for cloud sync`() { + val bookmarkLocator = ReaderLocator( + chapterIndex = 2, + pageIndex = 4, + startOffset = 30, + endOffset = 44, + textQuote = "marked passage" + ) + val highlightLocator = ReaderLocator( + chapterIndex = 2, + startOffset = 50, + endOffset = 64, + textQuote = "highlighted text" + ) + val book = BookItem( + id = "book-1", + path = null, + type = FileType.EPUB, + displayName = "Book.epub", + timestamp = 1_000L, + title = "Book", + author = "Author", + progressPercentage = 42f, + lastPageIndex = 4, + readerPosition = ReaderLocator( + chapterIndex = 2, + pageIndex = 4, + startOffset = 10, + endOffset = 20 + ), + readerBookmarks = listOf( + ReaderBookmark( + id = "bookmark-1", + pageIndex = 4, + chapterTitle = "Chapter", + preview = "marked passage", + locator = bookmarkLocator + ) + ), + readerHighlights = listOf( + UserHighlight( + id = "highlight-1", + cfi = "desktop:2:50:64", + text = "highlighted text", + color = HighlightColor.YELLOW, + chapterIndex = 2, + locator = highlightLocator + ) + ) + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false, timestamp = 2_000L) + val restored = metadata.toDesktopBookItem() + + assertEquals("desktop:2:10:20", metadata.lastPositionCfi) + assertEquals(2, metadata.lastChapterIndex) + assertEquals(4, metadata.lastPage) + assertEquals(42f, metadata.progressPercentage) + assertEquals(1_000L, metadata.readingPositionModifiedTimestamp) + assertTrue(assertNotNull(metadata.bookmarksJson).contains("desktop:2:30:44")) + assertTrue(assertNotNull(metadata.highlightsJson).contains("highlighted text")) + assertEquals(book.id, restored.id) + assertEquals(2, restored.readerPosition?.chapterIndex) + assertEquals(10, restored.readerPosition?.startOffset) + assertEquals(1, restored.readerBookmarks.size) + assertEquals(2, restored.readerBookmarks.single().locator.chapterIndex) + assertEquals(30, restored.readerBookmarks.single().locator.startOffset) + assertEquals(44, restored.readerBookmarks.single().locator.endOffset) + assertEquals("desktop:2:30:44", restored.readerBookmarks.single().locator.cfi) + assertEquals(1, restored.readerHighlights.size) + assertEquals(2, restored.readerHighlights.single().locator.chapterIndex) + assertEquals(50, restored.readerHighlights.single().locator.startOffset) + assertEquals(64, restored.readerHighlights.single().locator.endOffset) + assertEquals("desktop:2:50:64", restored.readerHighlights.single().locator.cfi) + } + + @Test + fun `metadata only upload can preserve remote content timestamp`() { + val book = BookItem( + id = "book-1", + path = null, + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L, + fileContentModifiedTimestamp = 111L + ) + + val metadata = book.toDesktopCloudBookMetadata( + hasAnnotations = false, + timestamp = 2_000L, + contentTimestampOverride = 999L + ) + + assertEquals(999L, metadata.fileContentModifiedTimestamp) + } + + @Test + fun `metadata upload keeps reading position timestamp separate from upload timestamp`() { + val book = BookItem( + id = "book-1", + path = null, + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L, + lastPageIndex = 12, + progressPercentage = 20f, + readingPositionModifiedTimestamp = 1_500L + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = true, timestamp = 3_000L) + + assertEquals(3_000L, metadata.lastModifiedTimestamp) + assertEquals(1_500L, metadata.readingPositionModifiedTimestamp) + assertEquals(0L, metadata.annotationModifiedTimestamp) + assertEquals(12, metadata.lastPage) + } + + @Test + fun `metadata upload keeps annotation timestamp separate from upload timestamp`() { + val book = BookItem( + id = "book-1", + path = null, + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L + ) + + val metadata = book.toDesktopCloudBookMetadata( + hasAnnotations = true, + timestamp = 3_000L, + annotationModifiedTimestamp = 2_250L + ) + + assertEquals(3_000L, metadata.lastModifiedTimestamp) + assertEquals(2_250L, metadata.annotationModifiedTimestamp) + assertEquals(2_250L, metadata.effectiveCloudAnnotationModifiedTimestamp()) + } + + @Test + fun `annotation freshness does not fall back to book metadata timestamp`() { + val metadata = DesktopCloudBookMetadata( + bookId = "book-1", + type = FileType.PDF.name, + lastModifiedTimestamp = 5_000L, + hasAnnotations = true + ) + + assertEquals(0L, metadata.effectiveCloudAnnotationModifiedTimestamp()) + assertEquals(3_000L, metadata.effectiveCloudAnnotationModifiedTimestamp(sidecarModifiedTimestamp = 3_000L)) + } + + @Test + fun `desktop drive file names use shared cloud content extension`() { + assertEquals("book-1.epub", desktopCloudBookDriveFileName("book-1", FileType.EPUB)) + assertEquals("book-1.md", desktopCloudBookDriveFileName("book-1", FileType.MD)) + assertEquals("book-1.mobi", desktopCloudBookDriveFileName("book-1", FileType.MOBI)) + assertNull(desktopCloudBookDriveFileName("book-1", FileType.UNKNOWN)) + } + + @Test + fun `empty epub annotations upload as empty arrays`() { + val book = BookItem( + id = "book-1", + path = "C:/books/Book.epub", + type = FileType.EPUB, + displayName = "Book.epub", + timestamp = 1_000L + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false) + + assertEquals("[]", metadata.bookmarksJson) + assertEquals("[]", metadata.highlightsJson) + } + + @Test + fun `remote pdf metadata moves stale desktop viewport to remote page`() { + val existing = BookItem( + id = "book-1", + path = "C:/books/Book.pdf", + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L, + lastPageIndex = 264, + progressPercentage = 33.125f, + readerPosition = ReaderLocator(pageIndex = 264), + pdfReaderViewport = SharedPdfReaderViewport( + pageIndex = 264, + verticalFirstPageIndex = 264, + verticalFirstPageScrollOffset = 120 + ) + ) + val remote = DesktopCloudBookMetadata( + bookId = "book-1", + displayName = "Book.pdf", + type = FileType.PDF.name, + lastModifiedTimestamp = 2_000L, + lastPage = 69, + progressPercentage = 8.75f + ) + + val restored = remote.toDesktopBookItem(existing = existing) + + assertEquals(69, restored.lastPageIndex) + assertEquals(8.75f, restored.progressPercentage) + assertNull(restored.readerPosition) + assertEquals(69, restored.pdfReaderViewport?.pageIndex) + assertEquals(69, restored.pdfReaderViewport?.verticalFirstPageIndex) + assertEquals(0, restored.pdfReaderViewport?.verticalFirstPageScrollOffset) + } + + @Test + fun `remote metadata with older reading timestamp preserves newer local pdf position`() { + val existing = BookItem( + id = "book-1", + path = "C:/books/Book.pdf", + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 4_000L, + lastPageIndex = 88, + progressPercentage = 44f, + pdfReaderViewport = SharedPdfReaderViewport(pageIndex = 88, verticalFirstPageIndex = 88), + readingPositionModifiedTimestamp = 4_000L + ) + val remote = DesktopCloudBookMetadata( + bookId = "book-1", + displayName = "Book.pdf", + type = FileType.PDF.name, + lastModifiedTimestamp = 6_000L, + readingPositionModifiedTimestamp = 3_000L, + lastPage = 12, + progressPercentage = 6f, + hasAnnotations = true + ) + + val restored = remote.toDesktopBookItem(existing = existing) + + assertEquals(6_000L, restored.timestamp) + assertEquals(88, restored.lastPageIndex) + assertEquals(44f, restored.progressPercentage) + assertEquals(88, restored.pdfReaderViewport?.pageIndex) + assertEquals(4_000L, restored.readingPositionModifiedTimestamp) + } + + @Test + fun `pdf metadata upload ignores stale text locator page`() { + val book = BookItem( + id = "book-1", + path = "C:/books/Book.pdf", + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L, + lastPageIndex = 264, + progressPercentage = 33.125f, + readerPosition = ReaderLocator(pageIndex = 69) + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false) + + assertEquals(264, metadata.lastPage) + assertNull(metadata.lastPositionCfi) + } + + @Test + fun `comic metadata upload ignores stale text locator page`() { + val book = BookItem( + id = "book-1", + path = "C:/books/Book.cbt", + type = FileType.CBT, + displayName = "Book.cbt", + timestamp = 1_000L, + lastPageIndex = 42, + progressPercentage = 33.125f, + readerPosition = ReaderLocator(pageIndex = 12) + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false) + + assertEquals(42, metadata.lastPage) + assertNull(metadata.lastPositionCfi) + } + + @Test + fun `remote metadata without annotation json preserves existing desktop annotations`() { + val existingBookmark = ReaderBookmark( + id = "bookmark-1", + pageIndex = 1, + chapterTitle = "Chapter", + preview = "local bookmark" + ) + val existingHighlight = UserHighlight( + id = "highlight-1", + cfi = "desktop:0:12:18", + text = "local highlight", + color = HighlightColor.BLUE, + chapterIndex = 0 + ) + val existing = BookItem( + id = "book-1", + path = "C:/books/Book.epub", + type = FileType.EPUB, + displayName = "Book.epub", + timestamp = 1_000L, + readerBookmarks = listOf(existingBookmark), + readerHighlights = listOf(existingHighlight) + ) + val remote = DesktopCloudBookMetadata( + bookId = existing.id, + displayName = existing.displayName, + type = FileType.EPUB.name, + lastModifiedTimestamp = 2_000L, + bookmarksJson = null, + highlightsJson = null + ) + + val restored = remote.toDesktopBookItem(existing = existing) + + assertEquals(listOf(existingBookmark), restored.readerBookmarks) + assertEquals(listOf(existingHighlight), restored.readerHighlights) + assertEquals(existing.path, restored.path) + } + + @Test + fun `desktop pdf bookmarks map to android metadata json`() { + val metadataJson = desktopPdfBookmarksMetadataJson( + bookmarks = listOf( + SharedPdfBookmark( + pageIndex = 3, + label = "Important page", + createdAt = 1_234L + ) + ), + lastPageIndex = 9 + ) + + val restored = desktopPdfBookmarksFromMetadataJson(metadataJson) + + assertTrue(metadataJson.contains("\"pageIndex\"")) + assertTrue(metadataJson.contains("\"title\"")) + assertTrue(metadataJson.contains("\"totalPages\"")) + assertEquals(1, restored.size) + assertEquals(3, restored.single().pageIndex) + assertEquals("Important page", restored.single().label) + } + + @Test + fun `android pdf bookmark metadata keeps titles on desktop`() { + val restored = desktopPdfBookmarksFromMetadataJson( + """[{"pageIndex":2,"title":"Android bookmark","totalPages":8}]""" + ) + + assertEquals(1, restored.size) + assertEquals(2, restored.single().pageIndex) + assertEquals("Android bookmark", restored.single().label) + } + + @Test + fun `empty desktop pdf annotations are not exported as cloud annotation data`() { + val emptyAnnotationsJson = SharedPdfAnnotationSerializer.encode(emptyList()) + + assertNull(desktopPdfAnnotationElementForSync(emptyAnnotationsJson)) + } + + @Test + fun `empty desktop pdf rich text is not exported as cloud annotation data`() { + val emptyRichTextJson = SharedPdfRichTextSerializer.encode(SharedPdfRichDocument()) + + assertNull(desktopPdfRichTextElementForSync(emptyRichTextJson)) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchiveTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchiveTest.kt new file mode 100644 index 0000000..829b126 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchiveTest.kt @@ -0,0 +1,115 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.FileType +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import java.io.File +import java.nio.file.Files +import java.util.Base64 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopComicArchiveTest { + @Test + fun `cbz archive loads image pages for pdf reader surface`() = withTempDir { dir -> + val cbz = File(dir, "comic.cbz") + ZipOutputStream(cbz.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("pages/001.png")) + zip.write(onePixelPngBytes()) + zip.closeEntry() + } + + val document = DesktopPdfium.loadComic(cbz, FileType.CBZ) + try { + assertEquals(1, document.pageCount) + assertEquals(1f, document.pageSizes.single().width) + assertEquals(1f, document.pageSizes.single().height) + + val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex = 0, scale = 8f) + + assertEquals(8, image.width) + assertEquals(8, image.height) + } finally { + document.close() + } + } + + @Test + fun `closing stale comic document does not close replacement with same path`() = withTempDir { dir -> + val cbz = File(dir, "comic.cbz") + ZipOutputStream(cbz.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("pages/001.png")) + zip.write(onePixelPngBytes()) + zip.closeEntry() + } + + val staleDocument = DesktopPdfium.loadComic(cbz, FileType.CBZ) + val activeDocument = DesktopPdfium.loadComic(cbz, FileType.CBZ) + try { + staleDocument.close() + + val image = DesktopPdfium.renderPageBufferedImage(activeDocument, pageIndex = 0, scale = 4f) + + assertEquals(4, image.width) + assertEquals(4, image.height) + } finally { + staleDocument.close() + activeDocument.close() + } + } + + @Test + fun `cbt archive loads image pages for pdf reader surface`() = withTempDir { dir -> + val cbt = File(dir, "comic.cbt") + TarArchiveOutputStream(cbt.outputStream()).use { tar -> + val bytes = onePixelPngBytes() + val entry = TarArchiveEntry("pages/001.png").apply { + size = bytes.size.toLong() + } + tar.putArchiveEntry(entry) + tar.write(bytes) + tar.closeArchiveEntry() + tar.finish() + } + + val document = DesktopPdfium.loadComic(cbt, FileType.CBT) + try { + assertEquals(1, document.pageCount) + assertEquals(1f, document.pageSizes.single().width) + assertEquals(1f, document.pageSizes.single().height) + + val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex = 0, scale = 8f) + + assertEquals(8, image.width) + assertEquals(8, image.height) + } finally { + document.close() + } + } + + @Test + fun `desktop comic types are routed through shared reader capability map`() { + assertTrue(DesktopComicArchive.canLoad(FileType.CBZ)) + assertTrue(DesktopComicArchive.canLoad(FileType.CBR)) + assertTrue(DesktopComicArchive.canLoad(FileType.CB7)) + assertTrue(DesktopComicArchive.canLoad(FileType.CBT)) + } + + private fun withTempDir(block: (File) -> Unit) { + val dir = Files.createTempDirectory("reader-desktop-comic").toFile() + try { + block(dir) + } finally { + dir.deleteRecursively() + } + } + + private fun onePixelPngBytes(): ByteArray { + return Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComposeInteropTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComposeInteropTest.kt similarity index 76% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComposeInteropTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComposeInteropTest.kt index 2b5a4b2..5d3700f 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComposeInteropTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComposeInteropTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import kotlin.test.Test import kotlin.test.assertEquals @@ -7,7 +7,7 @@ class DesktopComposeInteropTest { @Test fun `desktop enables Compose interop blending before app startup`() { withSystemProperty(ComposeInteropBlendingProperty, null) { - configureComposeSwingInterop() + configureComposeSwingInterop(nonNativeWebViewPlatform) assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty)) } @@ -16,7 +16,7 @@ class DesktopComposeInteropTest { @Test fun `desktop treats blank Compose interop blending value as unset`() { withSystemProperty(ComposeInteropBlendingProperty, " ") { - configureComposeSwingInterop() + configureComposeSwingInterop(nonNativeWebViewPlatform) assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty)) } @@ -25,7 +25,7 @@ class DesktopComposeInteropTest { @Test fun `desktop preserves explicit Compose interop blending override`() { withSystemProperty(ComposeInteropBlendingProperty, "false") { - configureComposeSwingInterop() + configureComposeSwingInterop(nonNativeWebViewPlatform) assertEquals("false", System.getProperty(ComposeInteropBlendingProperty)) } @@ -52,4 +52,11 @@ class DesktopComposeInteropTest { } } } + + private companion object { + val nonNativeWebViewPlatform = DesktopPlatform( + os = DesktopOperatingSystem.OTHER, + architecture = DesktopArchitecture.X64 + ) + } } diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCustomFontStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStoreTest.kt similarity index 97% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCustomFontStoreTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStoreTest.kt index 365d922..a755d8d 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCustomFontStoreTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStoreTest.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.CustomFontItem +import org.dueattendant149.bookreader.shared.CustomFontItem import java.io.File import java.nio.file.Files import kotlin.test.Test diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsingTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsingTest.kt new file mode 100644 index 0000000..257652e --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsingTest.kt @@ -0,0 +1,112 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.ReaderLocator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopEpubBridgeParsingTest { + @Test + fun `reader position bridge keeps semantic locator fields`() { + val position = """ + { + "pageIndex": 12, + "chapterIndex": 2, + "chapterId": "chap-2", + "href": "text/chapter2.xhtml", + "startOffset": 140, + "endOffset": 140, + "blockIndex": 9, + "charOffset": 140, + "textQuote": "quoted text", + "cfi": "desktop-scroll:10:100:/4/2:3" + } + """.trimIndent().readerPositionOrNull() + + assertEquals(12, position?.pageIndex) + assertEquals(2, position?.locator?.chapterIndex) + assertEquals("chap-2", position?.locator?.chapterId) + assertEquals("text/chapter2.xhtml", position?.locator?.href) + assertEquals(9, position?.locator?.blockIndex) + assertEquals(140, position?.locator?.charOffset) + assertEquals("quoted text", position?.locator?.textQuote) + assertEquals("/4/2:3", position?.locator?.cfi) + } + + @Test + fun `locator json sent to web view includes semantic position fields`() { + val json = ReaderLocator( + chapterIndex = 2, + chapterId = "chap-2", + href = "text/chapter2.xhtml", + pageIndex = 12, + startOffset = 140, + endOffset = 155, + blockIndex = 9, + charOffset = 140, + textQuote = "quoted text", + cfi = "/4/2:3" + ).toReaderLocatorJson() + + assertTrue(json.contains("\"chapterId\":\"chap-2\"")) + assertTrue(json.contains("\"href\":\"text/chapter2.xhtml\"")) + assertTrue(json.contains("\"blockIndex\":9")) + assertTrue(json.contains("\"charOffset\":140")) + } + + @Test + fun `selection action bridge keeps locator fields for selected tts`() { + val payload = """ + { + "action": "speak", + "text": "selected text", + "locator": { + "chapterIndex": 3, + "chapterId": "chap-3", + "href": "text/chapter3.xhtml", + "pageIndex": 41, + "startOffset": 900, + "endOffset": 913, + "blockIndex": 7, + "charOffset": 900, + "textQuote": "selected text", + "cfi": "desktop-scroll:10:20:/4/8:12|/4/8:25" + } + } + """.trimIndent().readerSelectionActionOrNull() + + assertEquals(DesktopReaderSelectionAction.SPEAK, payload?.action) + assertEquals("selected text", payload?.text) + assertEquals(3, payload?.locator?.chapterIndex) + assertEquals("chap-3", payload?.locator?.chapterId) + assertEquals("text/chapter3.xhtml", payload?.locator?.href) + assertEquals(41, payload?.locator?.pageIndex) + assertEquals(900, payload?.locator?.startOffset) + assertEquals(913, payload?.locator?.endOffset) + assertEquals(7, payload?.locator?.blockIndex) + assertEquals(900, payload?.locator?.charOffset) + assertEquals("selected text", payload?.locator?.textQuote) + assertEquals("/4/8:12|/4/8:25", payload?.locator?.cfi) + } + + @Test + fun `selection action bridge parses highlight palette manager action`() { + val payload = """ + { + "action": "palette", + "text": "selected text" + } + """.trimIndent().readerSelectionActionOrNull() + + assertEquals(DesktopReaderSelectionAction.PALETTE, payload?.action) + assertEquals("selected text", payload?.text) + } + + @Test + fun `desktop epub chrome tap script keeps click fallback for pointer-capable webviews`() { + assertTrue(DesktopEpubKeyNavigationScript.contains("var lastChromeTapNotifiedAt = 0;")) + assertTrue(DesktopEpubKeyNavigationScript.contains("function maybeNotifyChromeTapFromClick(event)")) + assertTrue(DesktopEpubKeyNavigationScript.contains("if (window.PointerEvent) {")) + assertTrue(DesktopEpubKeyNavigationScript.contains("maybeNotifyChromeTapFromClick(event);")) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPaginationTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPaginationTest.kt new file mode 100644 index 0000000..cde4361 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPaginationTest.kt @@ -0,0 +1,119 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.reader.ReaderPage +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.ReaderViewportSpec +import org.dueattendant149.bookreader.shared.reader.layoutSignature +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopEpubPaginationTest { + @Test + fun `measured pagination is not ready until measured pages are applied`() { + val request = desktopPaginationRequest() + val currentPages = listOf(readerPage(text = "old page")) + val measuredPages = listOf(readerPage(text = "measured page")) + + assertFalse( + desktopMeasuredPaginationReady( + request = request, + completedRequest = request, + currentPages = currentPages, + measuredPages = measuredPages + ) + ) + } + + @Test + fun `measured pagination is ready when current pages match measured pages`() { + val request = desktopPaginationRequest() + val measuredPages = listOf(readerPage(text = "measured page")) + + assertTrue( + desktopMeasuredPaginationReady( + request = request, + completedRequest = request, + currentPages = measuredPages, + measuredPages = measuredPages + ) + ) + } + + @Test + fun `paginated display waits for completed measured pages`() { + assertFalse( + desktopPaginatedLayoutReadyForDisplay( + readingMode = ReaderReadingMode.PAGINATED, + measuredPagesApplied = false + ) + ) + assertTrue( + desktopPaginatedLayoutReadyForDisplay( + readingMode = ReaderReadingMode.PAGINATED, + measuredPagesApplied = true + ) + ) + assertTrue( + desktopPaginatedLayoutReadyForDisplay( + readingMode = ReaderReadingMode.VERTICAL, + measuredPagesApplied = false + ) + ) + } + + @Test + fun `measured chapter warm start replaces only that chapter and renumbers pages`() { + val currentPages = listOf( + readerPage(text = "chapter 0 page", chapterIndex = 0, pageIndex = 0), + readerPage(text = "chapter 1 old a", chapterIndex = 1, pageIndex = 1), + readerPage(text = "chapter 1 old b", chapterIndex = 1, pageIndex = 2), + readerPage(text = "chapter 2 page", chapterIndex = 2, pageIndex = 3) + ) + val measuredChapter = listOf( + readerPage(text = "chapter 1 measured", chapterIndex = 1, pageIndex = 1) + ) + + val pages = desktopPagesWithMeasuredChapter( + currentPages = currentPages, + chapterIndex = 1, + measuredChapterPages = measuredChapter + ) + + assertEquals(listOf(0, 1, 2), pages.map { it.pageIndex }) + assertEquals(listOf(0, 1, 2), pages.map { it.chapterIndex }) + assertEquals("chapter 1 measured", pages[1].text) + } + + private fun desktopPaginationRequest(): DesktopEpubPaginationRequest { + return DesktopEpubPaginationRequest( + bookId = "book", + chapterSignature = 1, + layoutSignature = ReaderSettings( + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.SINGLE + ).layoutSignature(), + viewport = ReaderViewportSpec(widthPx = 1200, heightPx = 900), + density = DesktopEpubPaginationDensity(density = 1f, fontScale = 1f), + cacheGeneration = 0 + ) + } + + private fun readerPage( + text: String, + chapterIndex: Int = 0, + pageIndex: Int = 0 + ): ReaderPage { + return ReaderPage( + pageIndex = pageIndex, + chapterIndex = chapterIndex, + chapterTitle = "Chapter", + text = text, + startOffset = 0, + endOffset = text.length + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacementTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacementTest.kt new file mode 100644 index 0000000..50eee68 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacementTest.kt @@ -0,0 +1,24 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopFeatureNoticePlacementTest { + @Test + fun `main notice renders only in the main window`() { + val placement = desktopFeatureNoticePlacement(readerWindowId = null) + + assertTrue(placement.rendersInMainWindow()) + assertFalse(placement.rendersInReaderWindow("reader-1")) + } + + @Test + fun `reader notice renders only in the matching reader window`() { + val placement = desktopFeatureNoticePlacement(readerWindowId = "reader-1") + + assertFalse(placement.rendersInMainWindow()) + assertTrue(placement.rendersInReaderWindow("reader-1")) + assertFalse(placement.rendersInReaderWindow("reader-2")) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractorTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractorTest.kt similarity index 98% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractorTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractorTest.kt index d54e44a..50412cc 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractorTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractorTest.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.FileType +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType import java.io.File import java.nio.file.Files import java.util.Base64 diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabaseTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabaseTest.kt new file mode 100644 index 0000000..8b2d450 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabaseTest.kt @@ -0,0 +1,55 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot +import org.dueattendant149.bookreader.shared.SharedLibrarySnapshotJson +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopLibraryDatabaseTest { + @Test + fun `save writes readable library and backup snapshots`() { + val databaseFile = Files.createTempDirectory("reader-library-db") + .resolve("library.json") + .toFile() + val database = DesktopLibraryDatabase(databaseFile) + val snapshot = SharedLibrarySnapshot( + recentFilesLimit = 37, + openTabIds = listOf("book-a"), + activeTabBookId = "book-a" + ) + + database.save(snapshot) + + val loaded = database.load() + assertEquals(37, loaded.recentFilesLimit) + assertEquals(listOf("book-a"), loaded.openTabIds) + assertEquals("book-a", loaded.activeTabBookId) + assertTrue(databaseFile.isFile) + assertTrue(databaseFile.parentFile.resolve("library.json.bak").isFile) + } + + @Test + fun `load falls back to backup when primary library is corrupt`() { + val databaseFile = Files.createTempDirectory("reader-library-db-corrupt") + .resolve("library.json") + .toFile() + val backupSnapshot = SharedLibrarySnapshot( + recentFilesLimit = 19, + openTabIds = listOf("backup-book"), + activeTabBookId = "backup-book" + ) + databaseFile.parentFile.mkdirs() + databaseFile.writeText("""{"books":[""") + databaseFile.parentFile + .resolve("library.json.bak") + .writeText(SharedLibrarySnapshotJson.encode(backupSnapshot)) + + val loaded = DesktopLibraryDatabase(databaseFile).load() + + assertEquals(19, loaded.recentFilesLimit) + assertEquals(listOf("backup-book"), loaded.openTabIds) + assertEquals("backup-book", loaded.activeTabBookId) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSyncTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSyncTest.kt similarity index 54% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSyncTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSyncTest.kt index ade9952..dd60247 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSyncTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSyncTest.kt @@ -1,18 +1,47 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR -import com.aryan.reader.shared.SharedFolderBookMetadata -import com.aryan.reader.shared.SharedReaderScreenState -import com.aryan.reader.shared.SyncedFolder +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR +import org.dueattendant149.bookreader.shared.SharedFolderBookMetadata +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.SyncedFolder import java.io.File import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue class DesktopLocalFolderSyncTest { + @Test + fun `target folder sync imports files before desktop metadata extraction`() { + val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile() + try { + val bookFile = File(root, "Notes.txt").apply { writeText("Notes") } + + val result = DesktopLocalFolderSync.sync( + state = SharedReaderScreenState(), + shelfRefs = emptyList(), + targetFolder = root, + nowMillis = 3_000L, + extractMetadata = false + ) + + val syncedBook = result.state.rawLibraryBooks.single() + assertEquals("local_Notes.txt", syncedBook.id) + assertEquals(bookFile.absolutePath, syncedBook.path) + assertEquals(root.absolutePath, syncedBook.sourceFolder) + assertEquals(listOf(root.absolutePath), result.processedFolderUris) + assertEquals(1, result.state.syncedFolders.size) + assertEquals(1, result.stats.newBooks) + assertEquals(0, result.metadataStats.updatedBooks) + assertNull(syncedBook.coverImagePath) + } finally { + root.deleteRecursively() + } + } + @Test fun `metadata-only sync imports sidecar metadata without scanning physical files`() { val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile() @@ -61,6 +90,40 @@ class DesktopLocalFolderSyncTest { assertEquals(0, result.stats.newBooks) assertEquals(0, result.stats.removedBooks) assertEquals(1, result.stats.remoteMetadataUpdates) + assertTrue(result.processedFolderUris.contains(root.absolutePath)) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `disabled folder is not scanned or written`() { + val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile() + try { + File(root, "Notes.txt").writeText("Notes") + val existingBook = BookItem( + id = "local_Existing.pdf", + path = File(root, "Existing.pdf").absolutePath, + type = FileType.PDF, + displayName = "Existing.pdf", + timestamp = 100L, + progressPercentage = 50f, + sourceFolder = root.absolutePath + ) + + val result = DesktopLocalFolderSync.sync( + state = SharedReaderScreenState( + rawLibraryBooks = listOf(existingBook), + syncedFolders = listOf(syncedFolder(root).copy(localSyncEnabled = false)) + ), + shelfRefs = emptyList(), + nowMillis = 3_000L + ) + + assertEquals(listOf(existingBook), result.state.rawLibraryBooks) + assertTrue(result.processedFolderUris.isEmpty()) + assertEquals(0, result.stats.newBooks) + assertTrue(!File(root, LOCAL_FOLDER_SYNC_DATA_DIR).exists()) } finally { root.deleteRecursively() } diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopOpdsRepositoryTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepositoryTest.kt similarity index 96% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopOpdsRepositoryTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepositoryTest.kt index 3a7f0e3..4ea13be 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopOpdsRepositoryTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepositoryTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import java.io.File import java.nio.file.Files @@ -74,7 +74,7 @@ class DesktopOpdsRepositoryTest { password: String? ) { saveCatalogs( - com.aryan.reader.shared.opds.SharedOpdsCatalogs.addCatalog( + org.dueattendant149.bookreader.shared.opds.SharedOpdsCatalogs.addCatalog( catalogs = loadCatalogs(), title = title, url = url, diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiUsageTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiUsageTest.kt new file mode 100644 index 0000000..28541ab --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiUsageTest.kt @@ -0,0 +1,15 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopPaidAiUsageTest { + @Test + fun `desktop paid AI usage applies an optimistic integer credit decrement`() { + assertEquals(9, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 1.0)) + assertEquals(7, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 2.2)) + assertEquals(10, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 0.0)) + assertEquals(10, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = null)) + assertEquals(0, desktopCreditsAfterPaidAiUsage(currentCredits = 1, cost = 4.0)) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfFileActionsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActionsTest.kt similarity index 90% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfFileActionsTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActionsTest.kt index d4a052c..cc7376e 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfFileActionsTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActionsTest.kt @@ -1,12 +1,12 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.ui.text.AnnotatedString -import com.aryan.reader.shared.pdf.PdfAnnotationKind -import com.aryan.reader.shared.pdf.PdfInkTool -import com.aryan.reader.shared.pdf.PdfPageBounds -import com.aryan.reader.shared.pdf.PdfPagePoint -import com.aryan.reader.shared.pdf.SharedPdfAnnotation -import com.aryan.reader.shared.pdf.SharedPdfRichPageLayout +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichPageLayout import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationSidebarTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationSidebarTest.kt new file mode 100644 index 0000000..8d7dc06 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationSidebarTest.kt @@ -0,0 +1,56 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopPdfNavigationSidebarTest { + @Test + fun `sidebar highlights exclude ink and text annotations`() { + val result = desktopPdfSidebarHighlights( + listOf( + annotation(id = "ink", pageIndex = 0, kind = PdfAnnotationKind.INK, createdAt = 1L), + annotation(id = "later-highlight", pageIndex = 2, kind = PdfAnnotationKind.HIGHLIGHT, createdAt = 4L), + annotation(id = "text", pageIndex = 1, kind = PdfAnnotationKind.TEXT, createdAt = 1L), + annotation( + id = "first-same-page-highlight", + pageIndex = 1, + kind = PdfAnnotationKind.HIGHLIGHT, + createdAt = 3L + ), + annotation( + id = "second-same-page-highlight", + pageIndex = 1, + kind = PdfAnnotationKind.HIGHLIGHT, + createdAt = 2L + ) + ) + ) + + assertEquals( + listOf("first-same-page-highlight", "second-same-page-highlight", "later-highlight"), + result.map { it.id } + ) + assertTrue(result.all { it.kind == PdfAnnotationKind.HIGHLIGHT }) + } + + private fun annotation( + id: String, + pageIndex: Int, + kind: PdfAnnotationKind, + createdAt: Long + ): SharedPdfAnnotation { + return SharedPdfAnnotation( + id = id, + pageIndex = pageIndex, + kind = kind, + tool = if (kind == PdfAnnotationKind.TEXT) PdfInkTool.TEXT else PdfInkTool.HIGHLIGHTER, + text = "selected text", + colorArgb = 0x55FFEB3B, + createdAt = createdAt + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfReflowTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflowTest.kt similarity index 87% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfReflowTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflowTest.kt index 18f472d..7339f3d 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfReflowTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflowTest.kt @@ -1,9 +1,10 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.SharedLibraryStateProjector -import com.aryan.reader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.ui.toNonReaderLibraryOrganizationModel import java.io.File import kotlin.test.Test import kotlin.test.assertEquals @@ -85,6 +86,7 @@ class DesktopPdfReflowTest { assertEquals(listOf(source.id), projected.libraryBooks.map { it.id }) assertTrue(projected.rawLibraryBooks.any { it.id == reflow.id }) assertTrue(projected.recentBooks.none { it.id == reflow.id }) + assertEquals(1, projected.toNonReaderLibraryOrganizationModel().allBooksCount) assertEquals(listOf(reflow.id), projected.openTabIds) assertEquals(reflow.id, projected.activeTabBookId) } diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbingTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbingTest.kt new file mode 100644 index 0000000..333f4bf --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbingTest.kt @@ -0,0 +1,60 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopPdfScrubbingTest { + @Test + fun `scrub target clamps to valid page range`() { + val settings = ReaderSettings() + + assertEquals( + 0, + desktopPdfPageScrubTarget( + value = -10f, + pageCount = 6, + displayMode = PdfDisplayMode.VERTICAL_SCROLL, + settings = settings + ) + ) + assertEquals( + 5, + desktopPdfPageScrubTarget( + value = 99f, + pageCount = 6, + displayMode = PdfDisplayMode.VERTICAL_SCROLL, + settings = settings + ) + ) + } + + @Test + fun `paginated scrub target normalizes to spread start`() { + val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE) + + assertEquals( + 2, + desktopPdfPageScrubTarget( + value = 3f, + pageCount = 8, + displayMode = PdfDisplayMode.PAGINATION, + settings = settings + ) + ) + } + + @Test + fun `scrub commit prefers preview before page state catches up`() { + assertEquals( + 7, + desktopPdfPageScrubCommitTarget( + previewPage = 7, + currentPage = 2, + pageCount = 10 + ) + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarsTest.kt new file mode 100644 index 0000000..c47a802 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarsTest.kt @@ -0,0 +1,16 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class DesktopPdfSidecarsTest { + @Test + fun `pdf sidecar keys avoid String hashCode collisions`() { + val first = desktopPdfDocumentKey("C:/Books/Aa.pdf") + val second = desktopPdfDocumentKey("C:/Books/BB.pdf") + + assertTrue("C:/Books/Aa.pdf".hashCode() == "C:/Books/BB.pdf".hashCode()) + assertNotEquals(first, second) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTextHighlightStateTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTextHighlightStateTest.kt new file mode 100644 index 0000000..19562e0 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTextHighlightStateTest.kt @@ -0,0 +1,69 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderAction +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderState +import org.dueattendant149.bookreader.shared.pdf.reduce +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopPdfTextHighlightStateTest { + @Test + fun `text selection highlight keeps chosen text selection mode after creation`() { + val annotation = textSelectionHighlight() + val state = SharedPdfReaderState.initial(pageCount = 1) + .reduce(SharedPdfReaderAction.TextSelectionModeChanged(true)) + + val next = state.withDesktopPdfTextSelectionHighlightAdded(annotation) + + assertEquals(listOf(annotation), next.annotations) + assertTrue(next.isTextSelectionMode) + assertEquals(PdfInkTool.NONE, next.selectedTool) + assertNull(next.selectedAnnotationId) + } + + @Test + fun `dismissing selected text highlight sheet keeps chosen text selection mode`() { + val annotation = textSelectionHighlight() + val state = SharedPdfReaderState.initial(pageCount = 1) + .reduce(SharedPdfReaderAction.TextSelectionModeChanged(true)) + .copy(annotations = listOf(annotation), selectedAnnotationId = annotation.id) + + val next = state.withDesktopPdfTextHighlightSheetDismissed() + + assertTrue(next.isTextSelectionMode) + assertEquals(PdfInkTool.NONE, next.selectedTool) + assertNull(next.selectedAnnotationId) + } + + @Test + fun `dismissing non text highlight annotation keeps text selection mode unchanged`() { + val annotation = textSelectionHighlight().copy(rangeStartIndex = null, rangeEndIndex = null) + val state = SharedPdfReaderState.initial(pageCount = 1) + .reduce(SharedPdfReaderAction.TextSelectionModeChanged(true)) + .copy(annotations = listOf(annotation), selectedAnnotationId = annotation.id) + + val next = state.withDesktopPdfTextHighlightSheetDismissed() + + assertTrue(next.isTextSelectionMode) + assertNull(next.selectedAnnotationId) + } + + private fun textSelectionHighlight(): SharedPdfAnnotation { + return SharedPdfAnnotation( + id = "highlight-1", + pageIndex = 0, + kind = PdfAnnotationKind.HIGHLIGHT, + tool = PdfInkTool.HIGHLIGHTER, + text = "selected text", + colorArgb = 0x55FFEB3B, + rangeStartIndex = 1, + rangeEndIndex = 12, + createdAt = 1L + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfThemeTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfThemeTest.kt similarity index 56% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfThemeTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfThemeTest.kt index fd47373..09648a7 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfThemeTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfThemeTest.kt @@ -1,17 +1,18 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.PdfDisplayMode -import com.aryan.reader.shared.ReaderTheme +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderTheme import kotlin.test.Test import kotlin.test.assertEquals class DesktopPdfThemeTest { @Test - fun `desktop pdf defaults to vertical display mode`() { - assertEquals(PdfDisplayMode.VERTICAL_SCROLL, DesktopDefaultPdfDisplayMode) + fun `desktop pdf defaults to paginated display mode`() { + assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfDisplayMode) assertEquals(8.dp, DesktopDefaultPdfVerticalPageGap) + assertEquals(18.dp, DesktopDefaultPdfSpreadPageGap) } @Test @@ -49,4 +50,35 @@ class DesktopPdfThemeTest { ) ) } + + @Test + fun `pagination viewport uses app theme color outside pages`() { + val pageBackground = Color.Black + val appBackground = Color(0xFFE2E2E2) + + assertEquals( + appBackground, + desktopPdfViewportBackgroundColor( + displayMode = PdfDisplayMode.PAGINATION, + pageBackgroundColor = pageBackground, + appBackgroundColor = appBackground, + isVerticalPageGapVisible = false + ) + ) + assertEquals( + appBackground, + desktopPdfViewportBackgroundColor( + displayMode = PdfDisplayMode.PAGINATION, + pageBackgroundColor = pageBackground, + appBackgroundColor = appBackground, + isVerticalPageGapVisible = true + ) + ) + } + + @Test + fun `spread page gap follows pdf page gap visibility setting`() { + assertEquals(18.dp, desktopPdfSpreadPageGapDp(isPageGapVisible = true)) + assertEquals(0.dp, desktopPdfSpreadPageGapDp(isPageGapVisible = false)) + } } diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPlatformPathsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPathsTest.kt similarity index 94% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPlatformPathsTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPathsTest.kt index 170cedc..95f55de 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPlatformPathsTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPathsTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import kotlin.test.Test import kotlin.test.assertEquals @@ -10,7 +10,6 @@ class DesktopPlatformPathsTest { assertEquals(DesktopOperatingSystem.LINUX, platform.os) assertEquals(DesktopArchitecture.X64, platform.architecture) - assertEquals("kcef-bundle-linux-x64", platform.kcefBundleDirectoryName) assertEquals("linux-x64-v8", platform.pdfiumDirectoryName) assertEquals("lib", platform.pdfiumLibraryDirectoryName) assertEquals("libpdfium.so", platform.pdfiumLibraryFileName) @@ -22,7 +21,6 @@ class DesktopPlatformPathsTest { assertEquals(DesktopOperatingSystem.WINDOWS, platform.os) assertEquals(DesktopArchitecture.X64, platform.architecture) - assertEquals("kcef-bundle", platform.kcefBundleDirectoryName) assertEquals("win-x64-v8", platform.pdfiumDirectoryName) assertEquals("bin", platform.pdfiumLibraryDirectoryName) assertEquals("pdfium.dll", platform.pdfiumLibraryFileName) diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPptxDocumentTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocumentTest.kt similarity index 98% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPptxDocumentTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocumentTest.kt index c746843..387ce4b 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPptxDocumentTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocumentTest.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop -import com.aryan.reader.shared.pptx.SharedPptxDeckCache +import org.dueattendant149.bookreader.shared.pptx.SharedPptxDeckCache import java.io.File import java.nio.file.Files import java.util.zip.ZipEntry diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaultsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaultsTest.kt new file mode 100644 index 0000000..6e81891 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaultsTest.kt @@ -0,0 +1,537 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderPlatform +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot +import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopReaderDefaultsTest { + + @Test + fun `desktop open book dialog accepts every shared desktop readable format`() { + assertEquals( + SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP), + desktopBookFileTypesForDialog() + ) + assertTrue(FileType.PDF in desktopBookFileTypesForDialog()) + } + + @Test + fun `desktop uses global reader defaults when book has no local settings`() { + val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL) + val book = bookItem("without-local") + + assertEquals(defaults, resolvedDesktopReaderSettings(book, defaults)) + } + + @Test + fun `desktop keeps local book reader settings ahead of global defaults`() { + val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL) + val local = ReaderSettings(fontSize = 17, readingMode = ReaderReadingMode.PAGINATED, themeId = "sepia") + val book = bookItem("with-local").copy(readerSettings = local) + + assertEquals(local, resolvedDesktopReaderSettings(book, defaults)) + } + + @Test + fun `desktop library defaults migrate untouched reader defaults to two page pagination`() { + val migrated = SharedLibrarySnapshot().withDesktopDefaults() + + assertEquals(DesktopReaderDefaultsVersion, migrated.desktopReaderDefaultsVersion) + assertEquals(ReaderReadingMode.PAGINATED, migrated.readerDefaultSettings.readingMode) + assertEquals(ReaderPageSpreadMode.TWO_PAGE, migrated.readerDefaultSettings.pageSpreadMode) + assertEquals(ReaderReadingMode.PAGINATED, migrated.pdfReaderDefaultSettings.readingMode) + assertEquals(ReaderPageSpreadMode.TWO_PAGE, migrated.pdfReaderDefaultSettings.pageSpreadMode) + assertEquals("no_theme", migrated.pdfReaderDefaultSettings.themeId) + } + + @Test + fun `desktop reader settings engines are separated by shared reader surface`() { + assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.EPUB.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.MOBI.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.DOCX.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.PDF, FileType.PDF.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.PDF, FileType.CBZ.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.PDF, FileType.CBT.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.PDF, FileType.PPTX.desktopReaderSettingsEngine()) + } + + @Test + fun `desktop engine settings update only matching reader family books`() { + val textSettings = ReaderSettings(themeId = "sepia", readingMode = ReaderReadingMode.PAGINATED) + val pdfSettings = ReaderSettings(themeId = "reverse", readingMode = ReaderReadingMode.PAGINATED) + val books = listOf( + bookItem("epub"), + bookItem("mobi").copy(path = "C:/Books/mobi.mobi", type = FileType.MOBI, displayName = "mobi.mobi"), + bookItem("pdf").copy(path = "C:/Books/pdf.pdf", type = FileType.PDF, displayName = "pdf.pdf") + ) + + val withTextDefaults = books.withDesktopReaderEngineSettings(DesktopReaderSettingsEngine.TEXT, textSettings) + assertEquals(textSettings, withTextDefaults[0].readerSettings) + assertEquals(textSettings, withTextDefaults[1].readerSettings) + assertEquals(null, withTextDefaults[2].readerSettings) + + val withPdfDefaults = withTextDefaults.withDesktopReaderEngineSettings(DesktopReaderSettingsEngine.PDF, pdfSettings) + assertEquals(textSettings, withPdfDefaults[0].readerSettings) + assertEquals(textSettings, withPdfDefaults[1].readerSettings) + assertEquals(pdfSettings, withPdfDefaults[2].readerSettings) + } + + @Test + fun `desktop pdf display mode is carried by pdf reader settings`() { + assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfDisplayMode) + assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfReaderSettings.toDesktopPdfDisplayMode()) + assertEquals( + PdfDisplayMode.VERTICAL_SCROLL, + ReaderSettings(readingMode = ReaderReadingMode.VERTICAL).toDesktopPdfDisplayMode() + ) + } + + @Test + fun `desktop pdf initial page is normalized before paginated spread display`() { + val spreadSettings = ReaderSettings( + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE + ) + + assertEquals( + 2, + desktopPdfInitialPageIndex( + requestedPageIndex = 3, + pageCount = 10, + displayMode = PdfDisplayMode.PAGINATION, + settings = spreadSettings + ) + ) + assertEquals( + 3, + desktopPdfInitialPageIndex( + requestedPageIndex = 3, + pageCount = 10, + displayMode = PdfDisplayMode.VERTICAL_SCROLL, + settings = spreadSettings + ) + ) + assertEquals( + 3, + desktopPdfInitialPageIndex( + requestedPageIndex = 3, + pageCount = 10, + displayMode = PdfDisplayMode.PAGINATION, + settings = spreadSettings.copy(pageSpreadMode = ReaderPageSpreadMode.SINGLE) + ) + ) + } + + @Test + fun `desktop pdf zoom allows deeper page magnification`() { + val sharedDefaultMax = PdfZoomSpec().max + val letterPageScale = DesktopPdfZoomSpec.safeRenderScale( + pageWidth = 612f, + pageHeight = 792f, + requestedScale = 6f + ) + + assertEquals(8f, DesktopPdfZoomSpec.max) + assertTrue(letterPageScale > sharedDefaultMax) + } + + @Test + fun `desktop pdf touchpad zoom factors zoom in and out`() { + val zoomSpec = PdfZoomSpec(min = 0.5f, max = 8f, default = 1f) + + assertTrue(desktopPdfScrollZoomFactor(-1f) > 1.1f) + assertTrue(desktopPdfScrollZoomFactor(1f) < 0.9f) + assertEquals(8f, desktopPdfZoomTarget(currentZoom = 7.8f, zoomSpec = zoomSpec, factor = 2f)) + assertEquals(0.5f, desktopPdfZoomTarget(currentZoom = 0.6f, zoomSpec = zoomSpec, factor = 0.1f)) + } + + @Test + fun `desktop pdf page navigation commits pending zoom preview position`() { + val preview = DesktopPdfZoomPreview( + baseZoom = 1f, + zoom = 2f, + anchor = Offset(100f, 80f), + displayMode = PdfDisplayMode.PAGINATION, + pageIndex = 0 + ) + val snapshot = desktopPdfNavigationZoomSnapshot( + preview = preview, + currentHorizontalScroll = 40, + currentVerticalScroll = 20 + ) ?: error("Expected navigation zoom snapshot") + + assertEquals(2f, snapshot.zoom) + assertEquals(180, snapshot.horizontalScroll) + assertEquals(120, snapshot.verticalScroll) + } + + @Test + fun `desktop paginated pdf page changes avoid high resolution first render`() { + assertEquals( + DesktopPdfPaginationFastFirstRenderMaxScale, + desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = false) + ) + assertEquals( + 6f, + desktopPdfPaginationFirstRenderScale( + requestedScale = 6f, + hasPageRender = false, + isOpeningRender = true + ) + ) + assertEquals( + 6f, + desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = true) + ) + assertEquals( + 1.25f, + desktopPdfPaginationFirstRenderScale(requestedScale = 1.25f, hasPageRender = false) + ) + assertEquals( + 0.75f, + desktopPdfPaginationFirstRenderScale(requestedScale = 0.75f, hasPageRender = false) + ) + } + + @Test + fun `desktop pdf only displays renders for the requested page`() { + assertTrue(desktopPdfRenderBelongsToPage(renderedPageIndex = 0, requestedPageIndex = 0)) + assertFalse(desktopPdfRenderBelongsToPage(renderedPageIndex = null, requestedPageIndex = 0)) + assertFalse(desktopPdfRenderBelongsToPage(renderedPageIndex = 1, requestedPageIndex = 0)) + } + + @Test + fun `desktop pdf render scale rerenders only for missing or lower quality renders`() { + assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = null, requestedScale = 1f)) + assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = 1.02f)) + assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = Float.NaN, requestedScale = 1f)) + assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = 1.005f)) + assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 2f, requestedScale = 1f)) + assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = Float.NaN)) + } + + @Test + fun `desktop pdf spread zoom anchors to page under cursor`() { + val visiblePages = listOf(199, 200) + val pageRoots = mapOf( + 199 to Offset(424f, 30f), + 200 to Offset(972f, 30f) + ) + val pageSizes = mapOf( + 199 to IntSize(525, 693), + 200 to IntSize(525, 693) + ) + + assertEquals( + 200, + desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset = Offset.Zero, + anchor = Offset(1048.75f, 465f), + visiblePageIndices = visiblePages, + pageRootOffsets = pageRoots, + pageSizes = pageSizes, + fallbackPageIndex = 199 + ) + ) + assertEquals( + 199, + desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset = Offset.Zero, + anchor = Offset(500f, 465f), + visiblePageIndices = visiblePages, + pageRootOffsets = pageRoots, + pageSizes = pageSizes, + fallbackPageIndex = 199 + ) + ) + assertEquals( + 199, + desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset = Offset.Zero, + anchor = null, + visiblePageIndices = visiblePages, + pageRootOffsets = pageRoots, + pageSizes = pageSizes, + fallbackPageIndex = 199 + ) + ) + val fittedSpread = desktopPdfSpreadLayoutPrediction( + viewportRootOffset = Offset.Zero, + viewportSize = IntSize(1920, 991), + visiblePageIndices = visiblePages, + pageCanvasSizes = mapOf( + 199 to IntSize(667, 881), + 200 to IntSize(667, 881) + ), + horizontalScroll = 0, + verticalScroll = 112, + paddingPx = 30f, + pageGapPx = 22.5f + ) ?: error("Expected fitted spread prediction") + assertEquals(0, fittedSpread.maxHorizontalScroll) + assertEquals(0, fittedSpread.maxVerticalScroll) + assertEquals(282f, fittedSpread.pageRootOffsets[199]?.x ?: -1f, 0.5f) + assertEquals(972f, fittedSpread.pageRootOffsets[200]?.x ?: -1f, 0.5f) + assertEquals(30f, fittedSpread.pageRootOffsets[200]?.y ?: -1f, 0.0001f) + + val scrollableSpread = desktopPdfSpreadLayoutPrediction( + viewportRootOffset = Offset.Zero, + viewportSize = IntSize(1920, 991), + visiblePageIndices = visiblePages, + pageCanvasSizes = mapOf( + 199 to IntSize(1371, 1810), + 200 to IntSize(1371, 1810) + ), + horizontalScroll = 696, + verticalScroll = 575, + paddingPx = 30f, + pageGapPx = 22.5f + ) ?: error("Expected scrollable spread prediction") + assertEquals(905, scrollableSpread.maxHorizontalScroll) + assertEquals(879, scrollableSpread.maxVerticalScroll) + assertEquals(-666f, scrollableSpread.pageRootOffsets[199]?.x ?: 0f, 0.5f) + assertEquals(728f, scrollableSpread.pageRootOffsets[200]?.x ?: 0f, 0.5f) + assertEquals(-545f, scrollableSpread.pageRootOffsets[200]?.y ?: 0f, 0.0001f) + } + + @Test + fun `desktop pdf zoom preview bridges committed anchored zoom`() { + val preview = DesktopPdfZoomPreview( + baseZoom = 1f, + zoom = 2f, + anchor = Offset(100f, 100f), + displayMode = PdfDisplayMode.PAGINATION, + pageIndex = 0, + viewportRootOffset = Offset.Zero, + pageRootOffset = Offset.Zero + ) + + assertTrue(desktopPdfZoomPreviewMatchesScale(preview, 1f)) + assertTrue(desktopPdfZoomPreviewMatchesScale(preview, 2f)) + assertFalse(desktopPdfZoomPreviewMatchesScale(preview, 1.5f)) + assertEquals( + Offset(-100f, -100f), + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f + ) + ) + assertEquals( + Offset.Zero, + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset(-100f, -100f), + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f + ) + ) + assertEquals( + null, + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 1f + ) + ) + assertEquals(0, desktopPdfReachableScrollDelta(currentScroll = 0, maxScroll = 0, requestedDelta = 100)) + assertEquals(0, desktopPdfReachableScrollDelta(currentScroll = 0, maxScroll = 200, requestedDelta = -40)) + assertEquals(-40, desktopPdfReachableScrollDelta(currentScroll = 80, maxScroll = 200, requestedDelta = -40)) + assertEquals( + Offset.Zero, + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f, + scrollBounds = DesktopPdfZoomScrollBounds( + currentHorizontalScroll = 0, + maxHorizontalScroll = 0, + currentVerticalScroll = 0, + maxVerticalScroll = 0 + ) + ) + ) + assertEquals( + Offset(-50f, -25f), + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f, + scrollBounds = DesktopPdfZoomScrollBounds( + currentHorizontalScroll = 0, + maxHorizontalScroll = 50, + currentVerticalScroll = 0, + maxVerticalScroll = 25 + ) + ) + ) + val pendingCommitBounds = desktopPdfZoomScrollBoundsWithCommitTargets( + preview = preview.copy( + commitTargetHorizontalScroll = 300, + commitTargetVerticalScroll = 300 + ), + currentHorizontalScroll = 0, + maxHorizontalScroll = 0, + currentVerticalScroll = 0, + maxVerticalScroll = 0 + ) + assertEquals(300, pendingCommitBounds.maxHorizontalScroll) + assertEquals(300, pendingCommitBounds.maxVerticalScroll) + assertEquals( + Offset(-100f, -100f), + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f, + scrollBounds = pendingCommitBounds + ) + ) + val fittingPagePrediction = desktopPdfSinglePageLayoutPrediction( + viewportRootOffset = Offset.Zero, + viewportSize = IntSize(1920, 991), + pageCanvasSize = IntSize(1216, 1605), + horizontalScroll = 0, + verticalScroll = 0, + paddingPx = 30f + ) ?: error("Expected fitting page prediction") + assertEquals(Offset(352f, 30f), fittingPagePrediction.rootOffset) + assertEquals(0, fittingPagePrediction.maxHorizontalScroll) + assertEquals(674, fittingPagePrediction.maxVerticalScroll) + + val oversizedPagePrediction = desktopPdfSinglePageLayoutPrediction( + viewportRootOffset = Offset.Zero, + viewportSize = IntSize(1920, 991), + pageCanvasSize = IntSize(2498, 3298), + horizontalScroll = 409, + verticalScroll = 1122, + paddingPx = 30f + ) ?: error("Expected oversized page prediction") + assertEquals(Offset(-379f, -1092f), oversizedPagePrediction.rootOffset) + assertEquals(638, oversizedPagePrediction.maxHorizontalScroll) + assertEquals(2367, oversizedPagePrediction.maxVerticalScroll) + } + + @Test + fun `desktop pdf anchored zoom keeps cursor content stable`() { + assertEquals( + 300, + desktopPdfAnchoredScrollTarget(currentScroll = 100, anchor = 100f, oldZoom = 1f, newZoom = 2f) + ) + assertEquals( + 25, + desktopPdfAnchoredScrollTarget(currentScroll = 150, anchor = 100f, oldZoom = 2f, newZoom = 1f) + ) + assertEquals( + 100, + desktopPdfAnchoredLazyItemScrollOffset(itemOffset = 0, anchor = 100f, oldZoom = 1f, newZoom = 2f) + ) + assertEquals( + 200, + desktopPdfAnchoredLazyItemScrollOffset(itemOffset = -50, anchor = 100f, oldZoom = 1f, newZoom = 2f) + ) + assertEquals( + IntOffset(100, 100), + desktopPdfAnchoredPageScrollDelta( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f + ) + ) + assertEquals( + IntOffset(0, 0), + desktopPdfAnchoredPageScrollDelta( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentPageRootOffset = Offset(-100f, -100f), + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f + ) + ) + val offCenterPivot = desktopPdfZoomPreviewPivotFraction( + viewportRootOffset = Offset(20f, 30f), + pageRootOffset = Offset(120f, 230f), + anchor = Offset(250f, 450f), + pageCanvasSize = IntSize(500, 1000) + ) ?: error("Expected off-center pivot") + assertEquals(0.3f, offCenterPivot.x, 0.0001f) + assertEquals(0.25f, offCenterPivot.y, 0.0001f) + + val clampedPivot = desktopPdfZoomPreviewPivotFraction( + viewportRootOffset = Offset.Zero, + pageRootOffset = Offset.Zero, + anchor = Offset(900f, -20f), + pageCanvasSize = IntSize(500, 1000) + ) ?: error("Expected clamped pivot") + assertEquals(1f, clampedPivot.x, 0.0001f) + assertEquals(0f, clampedPivot.y, 0.0001f) + + val firstPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset = Offset.Zero, + pageRootOffset = Offset(0f, 0f), + anchor = Offset(100f, 200f), + previewScale = 2f + ) ?: error("Expected first page document translation") + assertEquals(-100f, firstPageDocumentTranslation.x, 0.0001f) + assertEquals(-200f, firstPageDocumentTranslation.y, 0.0001f) + + val secondPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset = Offset.Zero, + pageRootOffset = Offset(0f, 900f), + anchor = Offset(100f, 200f), + previewScale = 2f + ) ?: error("Expected second page document translation") + assertEquals(-100f, secondPageDocumentTranslation.x, 0.0001f) + assertEquals(700f, secondPageDocumentTranslation.y, 0.0001f) + } + + private fun bookItem(id: String): BookItem { + return BookItem( + id = id, + path = "C:/Books/$id.epub", + type = FileType.EPUB, + displayName = "$id.epub", + timestamp = 1L + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderKeyCommandsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderKeyCommandsTest.kt new file mode 100644 index 0000000..d383823 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderKeyCommandsTest.kt @@ -0,0 +1,174 @@ +package org.dueattendant149.bookreader.desktop + +import java.awt.Canvas +import java.awt.event.KeyEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopReaderKeyCommandsTest { + + @Test + fun `ctrl f opens epub reader search`() { + assertEquals( + DesktopReaderKeyNavigation.SEARCH, + awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F') + .desktopReaderKeyNavigationOrNull(fullscreen = false) + ) + } + + @Test + fun `epub right to left pagination swaps physical arrow navigation`() { + assertEquals( + DesktopReaderKeyNavigation.PREVIOUS, + awtKeyEvent(KeyEvent.VK_RIGHT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true) + ) + assertEquals( + DesktopReaderKeyNavigation.NEXT, + awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true) + ) + assertEquals( + DesktopReaderKeyNavigation.NEXT, + awtKeyEvent(KeyEvent.VK_PAGE_DOWN, 0, KeyEvent.CHAR_UNDEFINED) + .desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true) + ) + } + + @Test + fun `ctrl f opens pdf reader search while reading`() { + assertEquals( + DesktopPdfKeyCommand.SEARCH, + awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F') + .desktopPdfKeyCommandOrNull(fullscreen = false, editingText = false) + ) + } + + @Test + fun `ctrl f opens pdf reader search while text editing`() { + assertEquals( + DesktopPdfKeyCommand.SEARCH, + awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F') + .desktopPdfKeyCommandOrNull(fullscreen = false, editingText = true) + ) + } + + @Test + fun `pdf text editing keeps unmodified arrows for the editor`() { + assertNull( + awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopPdfKeyCommandOrNull(fullscreen = false, editingText = true) + ) + } + + @Test + fun `pdf right to left pagination swaps physical arrow navigation`() { + assertEquals( + DesktopPdfKeyCommand.PREVIOUS_PAGE, + awtKeyEvent(KeyEvent.VK_RIGHT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopPdfKeyCommandOrNull( + fullscreen = false, + editingText = false, + rightToLeftPagination = true + ) + ) + assertEquals( + DesktopPdfKeyCommand.NEXT_PAGE, + awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopPdfKeyCommandOrNull( + fullscreen = false, + editingText = false, + rightToLeftPagination = true + ) + ) + assertEquals( + DesktopPdfKeyCommand.NEXT_PAGE, + awtKeyEvent(KeyEvent.VK_PAGE_DOWN, 0, KeyEvent.CHAR_UNDEFINED) + .desktopPdfKeyCommandOrNull( + fullscreen = false, + editingText = false, + rightToLeftPagination = true + ) + ) + } + + @Test + fun `reader side panels can opt into global key dispatch without enabling popups`() { + assertEquals( + DesktopReaderModalWindowKind.PANEL, + desktopReaderModalWindowKind( + windowName = "${DesktopReaderModalWindowNamePrefix}PanelLeft", + windowTitle = "Reader Navigation" + ) + ) + assertTrue( + desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = DesktopReaderModalWindowKind.PANEL, + allowChromeModalWindows = false, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = false + ) + ) + assertFalse( + desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = DesktopReaderModalWindowKind.POPUP, + allowChromeModalWindows = true, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = true + ) + ) + } + + @Test + fun `reader chrome and owner window dispatch remain separately gated`() { + assertEquals( + DesktopReaderModalWindowKind.CHROME, + desktopReaderModalWindowKind( + windowName = "${DesktopReaderModalWindowNamePrefix}ChromeTop", + windowTitle = "Reader Chrome Top" + ) + ) + assertEquals( + DesktopReaderModalWindowKind.POPUP, + desktopReaderModalWindowKind( + windowName = "${DesktopReaderModalWindowNamePrefix}Popup", + windowTitle = "Reader Popup" + ) + ) + assertNull(desktopReaderModalWindowKind(windowName = "", windowTitle = "Episteme")) + assertFalse( + desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = null, + allowChromeModalWindows = false, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = false + ) + ) + assertTrue( + desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = DesktopReaderModalWindowKind.CHROME, + allowChromeModalWindows = true, + allowPanelModalWindows = false, + dispatchWhenOwnerWindowActive = false + ) + ) + } + + private fun awtKeyEvent( + keyCode: Int, + modifiers: Int, + keyChar: Char + ): KeyEvent { + return KeyEvent( + Canvas(), + KeyEvent.KEY_PRESSED, + 0L, + modifiers, + keyCode, + keyChar + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypographyTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypographyTest.kt new file mode 100644 index 0000000..b3c8c3b --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypographyTest.kt @@ -0,0 +1,69 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.unit.sp +import org.dueattendant149.bookreader.paginatedreader.CssStyle +import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph +import org.dueattendant149.bookreader.shared.reader.ReaderPage +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopReaderTypographyTest { + + @Test + fun `same page layout includes semantic styling`() { + val plain = pageWith( + SemanticParagraph( + text = "Styled text", + spans = emptyList(), + style = CssStyle(), + elementId = null, + cfi = null, + startCharOffsetInSource = 0, + blockIndex = 0 + ) + ) + val styled = pageWith( + SemanticParagraph( + text = "Styled text", + spans = emptyList(), + style = CssStyle(fontSize = 24.sp), + elementId = null, + cfi = null, + startCharOffsetInSource = 0, + blockIndex = 0 + ) + ) + + assertFalse(listOf(plain).samePageLayoutAs(listOf(styled))) + } + + @Test + fun `same page layout still matches identical semantic pages`() { + val page = pageWith( + SemanticParagraph( + text = "Styled text", + spans = emptyList(), + style = CssStyle(fontSize = 24.sp), + elementId = null, + cfi = null, + startCharOffsetInSource = 0, + blockIndex = 0 + ) + ) + + assertTrue(listOf(page).samePageLayoutAs(listOf(page.copy()))) + } + + private fun pageWith(block: SemanticParagraph): ReaderPage { + return ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = block.text, + startOffset = 0, + endOffset = block.text.length, + semanticBlocks = listOf(block) + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowStateTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowStateTest.kt new file mode 100644 index 0000000..99e93cf --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowStateTest.kt @@ -0,0 +1,141 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.ui.SharedAppTab +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopReaderWindowStateTest { + + @Test + fun `desktop starts on library instead of home`() { + assertEquals(SharedAppTab.LIBRARY, DesktopInitialAppTab) + } + + @Test + fun `opening a new reader creates a window`() { + val opening = readerOpening("book-1", requestId = 1) + + val decision = emptyList().openOrFocusDesktopReaderWindow( + opening = opening, + force = false + ) + + assertTrue(decision.shouldStartOpen) + assertEquals(listOf("book-1"), decision.windows.map { it.bookId }) + assertEquals(1L, decision.windows.single().focusRequestId) + } + + @Test + fun `opening an already open reader focuses the existing window`() { + val opening = readerOpening("book-1", requestId = 1) + val first = emptyList() + .openOrFocusDesktopReaderWindow(opening, force = false) + .windows + + val decision = first.openOrFocusDesktopReaderWindow( + opening = readerOpening("book-1", requestId = 2), + force = false + ) + + assertFalse(decision.shouldStartOpen) + assertEquals(1, decision.windows.size) + assertEquals(2L, decision.windows.single().focusRequestId) + assertEquals(1L, decision.windows.single().opening.requestId) + } + + @Test + fun `forcing an already open reader replaces the opening request`() { + val opening = readerOpening("book-1", requestId = 1) + val first = emptyList() + .openOrFocusDesktopReaderWindow(opening, force = false) + .windows + + val decision = first.openOrFocusDesktopReaderWindow( + opening = readerOpening("book-1", requestId = 2), + force = true + ) + + assertTrue(decision.shouldStartOpen) + assertEquals(1, decision.windows.size) + assertEquals(2L, decision.windows.single().opening.requestId) + assertEquals(2L, decision.windows.single().focusRequestId) + } + + @Test + fun `reader window uses persisted size instead of hardcoded fallback`() { + val snapshot = DesktopWindowStateSnapshot( + placement = DesktopSavedWindowPlacement.FLOATING, + widthDp = 1340f, + heightDp = 840f + ) + + val size = snapshot.toWindowSize(DesktopReaderWindowDefaultSize) + + assertEquals(1340.dp, size.width) + assertEquals(840.dp, size.height) + } + + @Test + fun `reader window defaults preserve previous detached reader size`() { + assertEquals(1120.dp, DesktopReaderWindowDefaultSize.width) + assertEquals(760.dp, DesktopReaderWindowDefaultSize.height) + } + + @Test + fun `reader window persistence ignores fullscreen snapshots`() { + val snapshot = DesktopWindowStateSnapshot( + placement = DesktopSavedWindowPlacement.FULLSCREEN, + widthDp = 1920f, + heightDp = 1080f + ) + + assertEquals(WindowPlacement.Floating, snapshot.toReaderWindowPlacement()) + assertNull(snapshot.toPersistableReaderWindowSnapshot()) + } + + @Test + fun `native webview text reader resets surface when switching from vertical to paginated`() { + assertTrue( + shouldResetDesktopTextReaderWindowSurface( + previousMode = ReaderReadingMode.VERTICAL, + currentMode = ReaderReadingMode.PAGINATED, + usesNativeWebView = true + ) + ) + } + + @Test + fun `text reader surface reset is limited to native webview vertical to paginated switches`() { + assertFalse( + shouldResetDesktopTextReaderWindowSurface( + previousMode = ReaderReadingMode.PAGINATED, + currentMode = ReaderReadingMode.VERTICAL, + usesNativeWebView = true + ) + ) + assertFalse( + shouldResetDesktopTextReaderWindowSurface( + previousMode = ReaderReadingMode.VERTICAL, + currentMode = ReaderReadingMode.PAGINATED, + usesNativeWebView = false + ) + ) + } + + private fun readerOpening(bookId: String, requestId: Long): DesktopReaderOpening { + return DesktopReaderOpening( + requestId = requestId, + bookId = bookId, + title = "Book $bookId", + formatLabel = FileType.EPUB.name, + returnTab = SharedAppTab.LIBRARY + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupTest.kt new file mode 100644 index 0000000..3538120 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupTest.kt @@ -0,0 +1,231 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoadSemanticMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import java.io.File +import java.nio.file.Files + +class DesktopStartupTest { + @Test + fun `startup splash uses compact branded feedback`() { + val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("standard")) + + assertEquals(EpistemeDesktopWindowTitle, spec.title) + assertTrue(spec.message.isNotBlank()) + assertTrue(spec.width in 320..480) + assertTrue(spec.height in 180..280) + } + + @Test + fun `oss startup splash uses oss branding`() { + val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("oss-offline")) + + assertEquals(EpistemeDesktopOssAppName, spec.title) + } + + @Test + fun `desktop epub webview uses native browser backends without bundled runtime`() { + val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) + val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) + + assertEquals(DesktopEpubWebViewBackend.WEBKIT, desktopEpubWebViewBackend(linux)) + assertEquals(DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2, desktopEpubWebViewBackend(windows)) + assertEquals(DesktopEpubWebViewBackend.WEBKIT, desktopEpubWebViewBackend(macos)) + assertEquals(DesktopEpubWebViewBackend.UNSUPPORTED, desktopEpubWebViewBackend(other)) + + assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(linux)) + assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(windows)) + assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(macos)) + assertFalse(desktopEpubWebViewUsesNativeSwtBrowser(other)) + } + + @Test + fun `native webviews can render without bundled runtime state`() { + val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) + val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) + + assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), windows)) + assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), linux)) + assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), macos)) + assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(initialized = true), linux)) + assertFalse(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(initialized = true), other)) + } + + @Test + fun `desktop vertical epub native reader is Linux only`() { + val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) + val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) + + assertTrue(desktopShouldUseNativeVerticalEpubReader(linux)) + assertFalse(desktopShouldUseNativeVerticalEpubReader(windows)) + assertFalse(desktopShouldUseNativeVerticalEpubReader(macos)) + assertFalse(desktopShouldUseNativeVerticalEpubReader(other)) + } + + @Test + fun `desktop vertical epub load keeps semantic blocks for Linux native reader`() { + val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) + val verticalSettings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL) + val paginatedSettings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED) + + assertEquals(SharedJvmBookLoadSemanticMode.FULL, desktopEpubBookLoadSemanticMode(verticalSettings, linux)) + assertEquals(SharedJvmBookLoadSemanticMode.SKIP, desktopEpubBookLoadSemanticMode(verticalSettings, windows)) + assertEquals(SharedJvmBookLoadSemanticMode.SKIP, desktopEpubBookLoadSemanticMode(verticalSettings, macos)) + assertEquals(SharedJvmBookLoadSemanticMode.FULL, desktopEpubBookLoadSemanticMode(paginatedSettings, windows)) + } + + @Test + fun `native webview unavailable messages point to the platform runtime`() { + val windowsMessage = desktopNativeWebViewUnavailableMessage( + backend = DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2, + detail = "missing runtime" + ) + val linuxMessage = desktopNativeWebViewUnavailableMessage( + backend = DesktopEpubWebViewBackend.WEBKIT, + detail = "missing library" + ) + + assertTrue(windowsMessage.contains("WebView2 Runtime")) + assertTrue(windowsMessage.contains("missing runtime")) + assertTrue(linuxMessage.contains("WebKitGTK")) + assertTrue(linuxMessage.contains("Linux distribution packages")) + assertTrue(linuxMessage.contains("missing library")) + } + + @Test + fun `compose interop blending stays off by default for native swt webviews`() { + val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) + + assertNull(composeInteropBlendingDefault(windows)) + assertNull(composeInteropBlendingDefault(linux)) + assertEquals(ComposeInteropBlendingEnabled, composeInteropBlendingDefault(other)) + } + + @Test + fun `silent startup folder sync does not surface missing folder banner`() { + val completed = desktopFolderSyncCompletedState( + state = SharedReaderScreenState(), + message = "Folder sync failed for 1 folder.", + failedFolderCount = 1, + showBanner = false + ) + + assertNull(completed.bannerMessage) + } + + @Test + fun `manual folder sync still surfaces missing folder banner`() { + val completed = desktopFolderSyncCompletedState( + state = SharedReaderScreenState(), + message = "Folder sync failed for 1 folder.", + failedFolderCount = 1, + showBanner = true + ) + + assertEquals("Folder sync failed for 1 folder.", completed.bannerMessage?.message) + assertTrue(completed.bannerMessage?.isError == true) + } + + @Test + fun `desktop account profile store restores cached profile for matching user`() { + val directory = Files.createTempDirectory("episteme-account-profile-test").toFile() + try { + val store = DesktopAccountProfileStore(File(directory, "account_profile.properties")) + store.save("user-1", DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 123L)) + + assertEquals( + DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 123L), + store.load("user-1") + ) + assertNull(store.load("user-2")) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `desktop account profile freshness uses fetched timestamp`() { + val now = 10_000L + val ttl = 1_000L + + assertTrue(DesktopAccountProfile(fetchedAtEpochMillis = now - ttl).isFresh(now, ttl)) + assertTrue(DesktopAccountProfile(fetchedAtEpochMillis = now - 1L).isFresh(now, ttl)) + assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = now - ttl - 1L).isFresh(now, ttl)) + assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = now + 1L).isFresh(now, ttl)) + assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = 0L).isFresh(now, ttl)) + } + + @Test + fun `desktop account profile repository ignores stale startup cache`() { + val directory = Files.createTempDirectory("episteme-account-profile-policy-test").toFile() + try { + val store = DesktopAccountProfileStore(File(directory, "account_profile.properties")) + val repository = DesktopAccountProfileRepository(testDesktopCloudConfig(), store) + val now = DesktopAccountProfileCacheTtlMillis + 10_000L + val freshProfile = DesktopAccountProfile( + isProUser = true, + credits = 42, + fetchedAtEpochMillis = now - DesktopAccountProfileCacheTtlMillis + 1L + ) + + repository.saveFetchedProfile("user-1", freshProfile) + assertEquals(freshProfile, repository.cachedProfile("user-1", now)) + + repository.saveFetchedProfile( + "user-1", + freshProfile.copy(fetchedAtEpochMillis = now - DesktopAccountProfileCacheTtlMillis - 1L) + ) + assertNull(repository.cachedProfile("user-1", now)) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `desktop account profile repository clear removes sign out cache`() { + val directory = Files.createTempDirectory("episteme-account-profile-clear-test").toFile() + try { + val store = DesktopAccountProfileStore(File(directory, "account_profile.properties")) + val repository = DesktopAccountProfileRepository(testDesktopCloudConfig(), store) + + repository.saveFetchedProfile( + "user-1", + DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 10_000L) + ) + repository.clearCachedProfiles() + + assertNull(repository.cachedProfile("user-1", 10_001L)) + assertNull(store.load("user-1")) + } finally { + directory.deleteRecursively() + } + } + + private fun testDesktopCloudConfig(): DesktopCloudConfig { + return DesktopCloudConfig( + aiWorkerUrl = "", + ttsWorkerUrl = "", + firebaseWebApiKey = "", + firebaseProjectId = "reader-test", + googleOAuthClientId = "", + googleOAuthClientSecret = "" + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStringResourcesTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResourcesTest.kt similarity index 84% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStringResourcesTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResourcesTest.kt index 5fb6234..9c16d69 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStringResourcesTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResourcesTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import kotlin.test.Test import kotlin.test.assertEquals @@ -72,6 +72,24 @@ class DesktopStringResourcesTest { assertEquals("Don't skip %1${'$'}d file", parsed["quoted_count"]?.get("one")) } + @Test + fun loadsAndroidToolbarTooltipDescriptionsForDesktop() { + val resources = DesktopAndroidStringResources.load( + locale = Locale.ENGLISH, + classLoader = Thread.currentThread().contextClassLoader + ?: DesktopStringResourcesTest::class.java.classLoader + ) + + assertEquals( + "Exit search and go back to the reader", + resources.stringOrNull("tooltip_close_search_desc") + ) + assertEquals( + "Jump to the next search match in the document", + resources.stringOrNull("tooltip_next_result_desc") + ) + } + @Test fun choosesDesktopPluralQuantityForSupportedLanguages() { val slavicQuantities = setOf("one", "few", "many", "other") @@ -109,6 +127,14 @@ class DesktopStringResourcesTest { assertEquals("language_portuguese_brazilian", option.labelKey) } + @Test + fun resolvesSelectedDesktopLanguageOptionForEstonian() { + val option = selectedDesktopLanguageOption("et") + + assertEquals("et", option.normalizedTag) + assertEquals("language_estonian", option.labelKey) + } + @Test fun desktopLanguageSettingsStorePersistsLanguageAcrossInstances() { val tempDirectory = Files.createTempDirectory("episteme-desktop-language-test") diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopSummaryCacheStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStoreTest.kt similarity index 97% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopSummaryCacheStoreTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStoreTest.kt index 5c826fa..0775a3f 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopSummaryCacheStoreTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStoreTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import java.nio.file.Files import kotlin.test.Test diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLogTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLogTest.kt new file mode 100644 index 0000000..24e4728 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLogTest.kt @@ -0,0 +1,18 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopTtsLogTest { + @Test + fun `desktop tts preview redacts key and token query values`() { + val preview = "wss://example.test/live?key=gemini_secret&token=firebase_secret" + .desktopTtsPreview(300) + + assertFalse(preview.contains("gemini_secret")) + assertFalse(preview.contains("firebase_secret")) + assertTrue(preview.contains("key=")) + assertTrue(preview.contains("token=")) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWebView2LayoutTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWebView2LayoutTest.kt new file mode 100644 index 0000000..1ff5168 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWebView2LayoutTest.kt @@ -0,0 +1,44 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopWebView2LayoutTest { + @Test + fun `webview2 host bounds match the awt canvas logical size`() { + val bounds = desktopWebView2TargetBoundsForCanvas(width = 1440, height = 900) + + assertEquals(DesktopWebView2TargetBounds(x = 0, y = 0, width = 1440, height = 900), bounds) + } + + @Test + fun `webview2 host bounds are unavailable before the canvas has size`() { + assertNull(desktopWebView2TargetBoundsForCanvas(width = 0, height = 900)) + assertNull(desktopWebView2TargetBoundsForCanvas(width = 1440, height = 0)) + } + + @Test + fun `webview2 awt canvas is not retired while host window is closing`() { + assertFalse( + desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing = true, + hostWindowDisplayable = true + ) + ) + assertFalse( + desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing = false, + hostWindowDisplayable = false + ) + ) + assertTrue( + desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing = false, + hostWindowDisplayable = true + ) + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowPolishTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolishTest.kt similarity index 97% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowPolishTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolishTest.kt index 354ecfa..c250c25 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowPolishTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolishTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import androidx.compose.ui.graphics.Color import kotlin.test.Test diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowStateStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStoreTest.kt similarity index 76% rename from desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowStateStoreTest.kt rename to desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStoreTest.kt index 24a7ba6..23c27e6 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowStateStoreTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStoreTest.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.desktop +package org.dueattendant149.bookreader.desktop import kotlin.io.path.createTempFile import kotlin.test.Test @@ -34,4 +34,10 @@ class DesktopWindowStateStoreTest { assertEquals(EpistemeDesktopWindowMinimumWidthPx.toFloat(), snapshot.widthDp) assertEquals(EpistemeDesktopWindowMinimumHeightPx.toFloat(), snapshot.heightDp) } + + @Test + fun `reader window state uses a separate config file`() { + assertEquals("window_state.json", DesktopWindowStateStore.defaultWindowStateFile().name) + assertEquals("reader_window_state.json", DesktopWindowStateStore.defaultReaderWindowStateFile().name) + } } diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/LinuxSecretToolCodecTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/LinuxSecretToolCodecTest.kt new file mode 100644 index 0000000..4ae7a89 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/LinuxSecretToolCodecTest.kt @@ -0,0 +1,115 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LinuxSecretToolCodecTest { + @Test + fun `libsecret codec stores looks up legacy secret tool references and clears secrets by key`() { + val client = FakeLinuxSecretServiceClient() + val codec = LinuxLibsecretCodec(client) + + assertTrue(codec.isAvailable) + val reference = codec.protect("geminiKeyProtected", "linux_gemini_key") + + assertTrue(reference.startsWith("linux-libsecret:")) + assertEquals("linux_gemini_key", codec.unprotect("geminiKeyProtected", reference)) + assertEquals( + "linux_gemini_key", + codec.unprotect("geminiKeyProtected", "secret-tool:Episteme.Reader.geminiKeyProtected") + ) + codec.delete("geminiKeyProtected") + assertTrue(client.storedSecrets.isEmpty()) + } + + @Test + fun `linux secret service codec falls back to secret tool when libsecret is unavailable`() { + val runner = FakeSecretCommandRunner() + val codec = LinuxSecretServiceCodec( + libsecretCodec = LinuxLibsecretCodec(FakeLinuxSecretServiceClient(available = false)), + secretToolCodec = LinuxSecretToolCodec(runner) + ) + + assertTrue(codec.isAvailable) + val reference = codec.protect("geminiKeyProtected", "linux_gemini_key") + + assertTrue(reference.startsWith("secret-tool:")) + assertEquals("linux_gemini_key", codec.unprotect("geminiKeyProtected", reference)) + assertFalse(runner.storedSecrets.isEmpty()) + } + + @Test + fun `secret tool codec stores looks up and clears secrets by key`() { + val runner = FakeSecretCommandRunner() + val codec = LinuxSecretToolCodec(runner) + + assertTrue(codec.isAvailable) + val reference = codec.protect("firebaseRefreshTokenProtected", "linux_refresh") + + assertEquals("linux_refresh", codec.unprotect("firebaseRefreshTokenProtected", reference)) + codec.delete("firebaseRefreshTokenProtected") + assertTrue(runner.storedSecrets.isEmpty()) + } + + private class FakeLinuxSecretServiceClient( + private val available: Boolean = true + ) : LinuxSecretServiceClient { + val storedSecrets = linkedMapOf() + + override val isAvailable: Boolean + get() = available + + override fun store(key: String, label: String, password: String) { + check(available) { "libsecret unavailable" } + storedSecrets[key] = password + } + + override fun lookup(key: String): String? { + check(available) { "libsecret unavailable" } + return storedSecrets[key] + } + + override fun clear(key: String) { + if (available) { + storedSecrets.remove(key) + } + } + } + + private class FakeSecretCommandRunner : DesktopSecretCommandRunner { + val storedSecrets = linkedMapOf() + + override fun isExecutableAvailable(command: String): Boolean { + return command == "secret-tool" + } + + override fun run( + command: List, + input: String?, + timeoutMillis: Long + ): DesktopSecretCommandResult { + return when (command.getOrNull(1)) { + "--help" -> DesktopSecretCommandResult(0, "usage", "") + "store" -> { + storedSecrets[command.last()] = input.orEmpty() + DesktopSecretCommandResult(0, "", "") + } + "lookup" -> { + val secret = storedSecrets[command.last()] + if (secret == null) { + DesktopSecretCommandResult(1, "", "not found") + } else { + DesktopSecretCommandResult(0, "$secret\n", "") + } + } + "clear" -> { + storedSecrets.remove(command.last()) + DesktopSecretCommandResult(0, "", "") + } + else -> DesktopSecretCommandResult(1, "", "unexpected command") + } + } + } +} diff --git a/docs/EPISTEME.png b/docs/EPISTEME.png index d154bdd..2cce3ca 100644 Binary files a/docs/EPISTEME.png and b/docs/EPISTEME.png differ diff --git a/docs/EPISTEME_desktop.png b/docs/EPISTEME_desktop.png new file mode 100644 index 0000000..d27a66c Binary files /dev/null and b/docs/EPISTEME_desktop.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c991cf3..3dd99a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ androidxTestRunner = "1.6.2" material3WindowSizeClassAndroid = "1.3.2" credentials = "1.5.0" composeMultiplatform = "1.8.2" +ksp = "2.2.10-2.0.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -61,7 +62,8 @@ android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlin-ksp = { id = "com.google.devtools.ksp", version = "2.3.2" } +kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } # Add plugins required by pdfiumandroid diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/scripts/desktop/download-pdfium.ps1 b/scripts/desktop/download-pdfium.ps1 new file mode 100644 index 0000000..4a040e4 --- /dev/null +++ b/scripts/desktop/download-pdfium.ps1 @@ -0,0 +1,52 @@ +param( + [string]$Repository = $env:GITHUB_REPOSITORY, + [string]$Tag = "pdfium-desktop-v1", + [string]$Destination = "third_party/pdfium" +) + +if ([string]::IsNullOrWhiteSpace($Repository)) { + throw "Repository is required. Pass -Repository owner/repo or set GITHUB_REPOSITORY." +} + +$ErrorActionPreference = "Stop" + +$assets = @( + @{ Name = "pdfium-linux-x64-v8.zip"; Directory = "linux-x64-v8" }, + @{ Name = "pdfium-win-x64-v8.zip"; Directory = "win-x64-v8" } +) + +New-Item -ItemType Directory -Force -Path $Destination | Out-Null +$workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("reader-pdfium-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Force -Path $workDir | Out-Null + +try { + foreach ($asset in $assets) { + $zipPath = Join-Path $workDir $asset.Name + gh release download $Tag --repo $Repository --pattern $asset.Name --dir $workDir --clobber + + if (-not (Test-Path $zipPath)) { + throw "Pdfium release asset was not downloaded: $($asset.Name)" + } + + $targetDir = Join-Path $Destination $asset.Directory + if (Test-Path $targetDir) { + Remove-Item -Recurse -Force $targetDir + } + + $extractDir = Join-Path $workDir $asset.Directory + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + Expand-Archive -Force -Path $zipPath -DestinationPath $extractDir + + $rootedDir = Join-Path $extractDir $asset.Directory + if (Test-Path $rootedDir) { + Move-Item -Path $rootedDir -Destination $targetDir + } else { + New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + Move-Item -Path (Join-Path $extractDir "*") -Destination $targetDir + } + } +} finally { + if (Test-Path $workDir) { + Remove-Item -Recurse -Force $workDir + } +} diff --git a/scripts/desktop/publish-pdfium-release.ps1 b/scripts/desktop/publish-pdfium-release.ps1 new file mode 100644 index 0000000..7fa30ce --- /dev/null +++ b/scripts/desktop/publish-pdfium-release.ps1 @@ -0,0 +1,51 @@ +param( + [string]$Repository = $env:GITHUB_REPOSITORY, + [string]$Tag = "pdfium-desktop-v1", + [string]$Title = "Desktop Pdfium binaries", + [string]$PdfiumRoot = "third_party/pdfium" +) + +if ([string]::IsNullOrWhiteSpace($Repository)) { + throw "Repository is required. Pass -Repository owner/repo or set GITHUB_REPOSITORY." +} + +$ErrorActionPreference = "Stop" + +$folders = @( + @{ Directory = "linux-x64-v8"; Asset = "pdfium-linux-x64-v8.zip" }, + @{ Directory = "win-x64-v8"; Asset = "pdfium-win-x64-v8.zip" } +) + +$workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("reader-pdfium-release-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Force -Path $workDir | Out-Null + +try { + foreach ($folder in $folders) { + $source = Join-Path $PdfiumRoot $folder.Directory + if (-not (Test-Path $source)) { + throw "Missing Pdfium folder: $source" + } + + $assetPath = Join-Path $workDir $folder.Asset + Compress-Archive -Force -Path $source -DestinationPath $assetPath + } + + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + gh release view $Tag --repo $Repository *> $null + $releaseExists = $LASTEXITCODE -eq 0 + $ErrorActionPreference = $previousErrorActionPreference + + if (-not $releaseExists) { + gh release create $Tag --repo $Repository --title $Title --notes "Pdfium binaries used by desktop CI packaging." + } + + foreach ($folder in $folders) { + $assetPath = Join-Path $workDir $folder.Asset + gh release upload $Tag $assetPath --repo $Repository --clobber + } +} finally { + if (Test-Path $workDir) { + Remove-Item -Recurse -Force $workDir + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 37f309d..093a81c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,11 +18,25 @@ dependencyResolutionManagement { mavenCentral() maven("https://jitpack.io") maven("https://jogamp.org/deployment/maven") - maven("https.jitpack.io") + maven("https://jitpack.io") } } rootProject.name = "Reader" -include(":app") + +fun isDesktopOnlyBuild(): Boolean { + providers.gradleProperty("desktopOnly").orNull + ?.let { return it.equals("true", ignoreCase = true) } + + val requestedTasks = gradle.startParameter.taskNames + return requestedTasks.isNotEmpty() && requestedTasks.all { taskName -> + val normalized = taskName.removePrefix(":") + normalized.startsWith("desktopApp:") + } +} + +if (!isDesktopOnlyBuild()) { + include(":app") +} include(":shared") include(":desktopApp") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index d2a7964..20ae666 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,20 +1,40 @@ +import com.android.build.api.dsl.LibraryExtension + plugins { alias(libs.plugins.kotlin.multiplatform) - id("com.android.library") + alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) alias(libs.plugins.compose.multiplatform) - id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20" + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kover) } +fun isDesktopOnlyBuild(): Boolean { + providers.gradleProperty("desktopOnly").orNull + ?.let { return it.equals("true", ignoreCase = true) } + + val requestedTasks = gradle.startParameter.taskNames + return requestedTasks.isNotEmpty() && requestedTasks.all { taskName -> + val normalized = taskName.removePrefix(":") + normalized.startsWith("desktopApp:") + } +} + +val desktopOnlyBuild = isDesktopOnlyBuild() + +if (!desktopOnlyBuild) { + apply(plugin = "com.android.library") +} + kotlin { - androidTarget() + if (!desktopOnlyBuild) { + androidTarget() + } jvm("desktop") jvmToolchain(21) sourceSets { val commonMain by getting - val androidMain by getting val desktopMain by getting val readerJvmMain by creating { dependsOn(commonMain) @@ -22,13 +42,15 @@ kotlin { implementation("org.jsoup:jsoup:1.17.2") } } - androidMain.dependsOn(readerJvmMain) + if (!desktopOnlyBuild) { + val androidMain by getting + androidMain.dependsOn(readerJvmMain) + } desktopMain.dependsOn(readerJvmMain) commonMain.dependencies { implementation(compose.foundation) implementation(compose.material3) - implementation(compose.materialIconsExtended) implementation(compose.ui) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") @@ -41,11 +63,17 @@ kotlin { } } -android { - namespace = "com.aryan.reader.shared" - compileSdk = 36 +if (!desktopOnlyBuild) { + extensions.configure("android") { + namespace = "org.dueattendant149.bookreader.shared" + compileSdk = 36 - defaultConfig { - minSdk = 26 + defaultConfig { + minSdk = 26 + } + + buildFeatures { + buildConfig = true + } } } diff --git a/shared/src/androidMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.android.kt b/shared/src/androidMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.android.kt deleted file mode 100644 index b3a15a9..0000000 --- a/shared/src/androidMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.android.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.aryan.reader.shared.reader - -internal actual val SharedReaderDiagnosticsEnabled: Boolean = false - -internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean = false diff --git a/shared/src/androidMain/kotlin/com/aryan/reader/shared/LocalFolderSync.android.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.android.kt similarity index 84% rename from shared/src/androidMain/kotlin/com/aryan/reader/shared/LocalFolderSync.android.kt rename to shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.android.kt index 700e05e..cabc21b 100644 --- a/shared/src/androidMain/kotlin/com/aryan/reader/shared/LocalFolderSync.android.kt +++ b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.android.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared import java.security.MessageDigest diff --git a/shared/src/androidMain/kotlin/com/aryan/reader/shared/Platform.android.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.android.kt similarity index 58% rename from shared/src/androidMain/kotlin/com/aryan/reader/shared/Platform.android.kt rename to shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.android.kt index d1f14a5..92ed9f2 100644 --- a/shared/src/androidMain/kotlin/com/aryan/reader/shared/Platform.android.kt +++ b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.android.kt @@ -1,3 +1,3 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared actual fun currentTimestamp(): Long = System.currentTimeMillis() diff --git a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.android.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.android.kt new file mode 100644 index 0000000..4555979 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.android.kt @@ -0,0 +1,17 @@ +package org.dueattendant149.bookreader.shared.reader + +import android.util.Log +import org.dueattendant149.bookreader.shared.BuildConfig + +internal actual val SharedReaderDiagnosticsEnabled: Boolean = BuildConfig.DEBUG + +internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean { + if (!BuildConfig.DEBUG) return false + return tag == SharedEpubCutoffDiagnosticsTag || + runCatching { Log.isLoggable(tag, Log.DEBUG) }.getOrDefault(false) +} + +internal actual fun writeSharedReaderDiagnostic(tag: String, message: String) { + if (!BuildConfig.DEBUG) return + Log.d(tag, message) +} diff --git a/shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.android.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.android.kt similarity index 94% rename from shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.android.kt rename to shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.android.kt index 6282dc2..bea2290 100644 --- a/shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.android.kt +++ b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.android.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.ui +package org.dueattendant149.bookreader.shared.ui import android.graphics.BitmapFactory import androidx.compose.foundation.Image diff --git a/shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.android.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.android.kt similarity index 93% rename from shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.android.kt rename to shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.android.kt index a1dec4b..b092083 100644 --- a/shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.android.kt +++ b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.android.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.ui +package org.dueattendant149.bookreader.shared.ui import androidx.compose.runtime.Composable import androidx.compose.ui.window.Dialog diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/Icons.kt b/shared/src/commonMain/kotlin/androidx/compose/material/icons/Icons.kt new file mode 100644 index 0000000..2624e69 --- /dev/null +++ b/shared/src/commonMain/kotlin/androidx/compose/material/icons/Icons.kt @@ -0,0 +1,12 @@ +package androidx.compose.material.icons + +object Icons { + object Filled + val Default: Filled get() = Filled + + object Outlined + + object AutoMirrored { + object Filled + } +} diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt b/shared/src/commonMain/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt new file mode 100644 index 0000000..5398bb8 --- /dev/null +++ b/shared/src/commonMain/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt @@ -0,0 +1,261 @@ +@file:Suppress("ObjectPropertyName", "unused") + +package androidx.compose.material.icons.automirrored.filled + +import androidx.compose.material.icons.Icons +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.dp + +val Icons.AutoMirrored.Filled.ArrowBack: ImageVector + get() = EpistemeAutoMirroredFilledIcons.arrowBack + +val Icons.AutoMirrored.Filled.ArrowForward: ImageVector + get() = EpistemeAutoMirroredFilledIcons.arrowForward + +val Icons.AutoMirrored.Filled.KeyboardArrowRight: ImageVector + get() = EpistemeAutoMirroredFilledIcons.keyboardArrowRight + +val Icons.AutoMirrored.Filled.LibraryBooks: ImageVector + get() = EpistemeAutoMirroredFilledIcons.libraryBooks + +val Icons.AutoMirrored.Filled.List: ImageVector + get() = EpistemeAutoMirroredFilledIcons.list + +val Icons.AutoMirrored.Filled.MenuBook: ImageVector + get() = EpistemeAutoMirroredFilledIcons.menuBook + +val Icons.AutoMirrored.Filled.NavigateBefore: ImageVector + get() = EpistemeAutoMirroredFilledIcons.navigateBefore + +val Icons.AutoMirrored.Filled.NavigateNext: ImageVector + get() = EpistemeAutoMirroredFilledIcons.navigateNext + +val Icons.AutoMirrored.Filled.OpenInNew: ImageVector + get() = EpistemeAutoMirroredFilledIcons.openInNew + +val Icons.AutoMirrored.Filled.Redo: ImageVector + get() = EpistemeAutoMirroredFilledIcons.redo + +val Icons.AutoMirrored.Filled.Sort: ImageVector + get() = EpistemeAutoMirroredFilledIcons.sort + +val Icons.AutoMirrored.Filled.Undo: ImageVector + get() = EpistemeAutoMirroredFilledIcons.undo + +val Icons.AutoMirrored.Filled.VolumeUp: ImageVector + get() = EpistemeAutoMirroredFilledIcons.volumeUp + +private object EpistemeAutoMirroredFilledIcons { + val arrowBack: ImageVector by lazy { + materialIcon( + name = "ArrowBack", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z""" + ) + ) + } + + val arrowForward: ImageVector by lazy { + materialIcon( + name = "ArrowForward", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M647,520L160,520L160,440L647,440L423,216L480,160L800,480L480,800L423,744L647,520Z""" + ) + ) + } + + val keyboardArrowRight: ImageVector by lazy { + materialIcon( + name = "KeyboardArrowRight", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z""" + ) + ) + } + + val libraryBooks: ImageVector by lazy { + materialIcon( + name = "LibraryBooks", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M400,560L560,560L560,480L400,480L400,560ZM400,440L720,440L720,360L400,360L400,440ZM400,320L720,320L720,240L400,240L400,320ZM320,720Q287,720 263.5,696.5Q240,673 240,640L240,160Q240,127 263.5,103.5Q287,80 320,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L320,720ZM320,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640ZM160,880Q127,880 103.5,856.5Q80,833 80,800L80,240L160,240L160,800Q160,800 160,800Q160,800 160,800L720,800L720,880L160,880ZM320,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640L320,640Q320,640 320,640Q320,640 320,640L320,160Q320,160 320,160Q320,160 320,160Z""" + ) + ) + } + + val list: ImageVector by lazy { + materialIcon( + name = "List", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M280,360L280,280L840,280L840,360L280,360ZM280,520L280,440L840,440L840,520L280,520ZM280,680L280,600L840,600L840,680L280,680ZM160,360Q143,360 131.5,348.5Q120,337 120,320Q120,303 131.5,291.5Q143,280 160,280Q177,280 188.5,291.5Q200,303 200,320Q200,337 188.5,348.5Q177,360 160,360ZM160,520Q143,520 131.5,508.5Q120,497 120,480Q120,463 131.5,451.5Q143,440 160,440Q177,440 188.5,451.5Q200,463 200,480Q200,497 188.5,508.5Q177,520 160,520ZM160,680Q143,680 131.5,668.5Q120,657 120,640Q120,623 131.5,611.5Q143,600 160,600Q177,600 188.5,611.5Q200,623 200,640Q200,657 188.5,668.5Q177,680 160,680Z""" + ) + ) + } + + val menuBook: ImageVector by lazy { + materialIcon( + name = "MenuBook", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M560,396L560,328Q593,314 627.5,307Q662,300 700,300Q726,300 751,304Q776,308 800,314L800,378Q776,369 751.5,364.5Q727,360 700,360Q662,360 627,369.5Q592,379 560,396ZM560,616L560,548Q593,534 627.5,527Q662,520 700,520Q726,520 751,524Q776,528 800,534L800,598Q776,589 751.5,584.5Q727,580 700,580Q662,580 627,589Q592,598 560,616ZM560,506L560,438Q593,424 627.5,417Q662,410 700,410Q726,410 751,414Q776,418 800,424L800,488Q776,479 751.5,474.5Q727,470 700,470Q662,470 627,479.5Q592,489 560,506ZM260,640Q307,640 351.5,650.5Q396,661 440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268Q120,268 120,268Q120,268 120,268L120,664Q120,664 120,664Q120,664 120,664Q155,652 189.5,646Q224,640 260,640ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664Q840,664 840,664Q840,664 840,664L840,268Q840,268 840,268Q840,268 840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM280,466Q280,466 280,466Q280,466 280,466L280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466L280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Z""" + ) + ) + } + + val navigateBefore: ImageVector by lazy { + materialIcon( + name = "NavigateBefore", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M560,720L320,480L560,240L616,296L432,480L616,664L560,720Z""" + ) + ) + } + + val navigateNext: ImageVector by lazy { + materialIcon( + name = "NavigateNext", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z""" + ) + ) + } + + val openInNew: ImageVector by lazy { + materialIcon( + name = "OpenInNew", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L480,120L480,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,480L840,480L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM388,628L332,572L704,200L560,200L560,120L840,120L840,400L760,400L760,256L388,628Z""" + ) + ) + } + + val redo: ImageVector by lazy { + materialIcon( + name = "Redo", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M396,760Q299,760 229.5,697Q160,634 160,540Q160,446 229.5,383Q299,320 396,320L648,320L544,216L600,160L800,360L600,560L544,504L648,400L396,400Q333,400 286.5,440Q240,480 240,540Q240,600 286.5,640Q333,680 396,680L680,680L680,760L396,760Z""" + ) + ) + } + + val sort: ImageVector by lazy { + materialIcon( + name = "Sort", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M120,720L120,640L360,640L360,720L120,720ZM120,520L120,440L600,440L600,520L120,520ZM120,320L120,240L840,240L840,320L120,320Z""" + ) + ) + } + + val undo: ImageVector by lazy { + materialIcon( + name = "Undo", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M280,760L280,680L564,680Q627,680 673.5,640Q720,600 720,540Q720,480 673.5,440Q627,400 564,400L312,400L416,504L360,560L160,360L360,160L416,216L312,320L564,320Q661,320 730.5,383Q800,446 800,540Q800,634 730.5,697Q661,760 564,760L280,760Z""" + ) + ) + } + + val volumeUp: ImageVector by lazy { + materialIcon( + name = "VolumeUp", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M560,829L560,747Q650,721 705,647Q760,573 760,479Q760,385 705,311Q650,237 560,211L560,129Q684,157 762,254.5Q840,352 840,479Q840,606 762,703.5Q684,801 560,829ZM120,600L120,360L280,360L480,160L480,800L280,600L120,600ZM560,640L560,318Q607,340 633.5,384Q660,428 660,480Q660,531 633.5,574.5Q607,618 560,640ZM400,354L314,440L200,440L200,520L314,520L400,606L400,354ZM300,480L300,480L300,480L300,480L300,480L300,480Z""" + ) + ) + } + +} + +private fun materialIcon( + name: String, + defaultWidth: Float, + defaultHeight: Float, + viewportWidth: Float, + viewportHeight: Float, + autoMirror: Boolean, + paths: List +): ImageVector { + return ImageVector.Builder( + name = name, + defaultWidth = defaultWidth.dp, + defaultHeight = defaultHeight.dp, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + autoMirror = autoMirror + ).apply { + paths.forEach { pathData -> + addPath( + pathData = PathParser().parsePathString(pathData).toNodes(), + fill = SolidColor(Color.Black) + ) + } + }.build() +} + diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt b/shared/src/commonMain/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt new file mode 100644 index 0000000..8f5fe46 --- /dev/null +++ b/shared/src/commonMain/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt @@ -0,0 +1,1519 @@ +@file:Suppress("ObjectPropertyName", "unused") + +package androidx.compose.material.icons.filled + +import androidx.compose.material.icons.Icons +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.dp + +val Icons.Filled.AccountCircle: ImageVector + get() = EpistemeFilledIcons.accountCircle + +val Icons.Filled.Add: ImageVector + get() = EpistemeFilledIcons.add + +val Icons.Filled.ArrowDownward: ImageVector + get() = EpistemeFilledIcons.arrowDownward + +val Icons.Filled.ArrowDropDown: ImageVector + get() = EpistemeFilledIcons.arrowDropDown + +val Icons.Filled.ArrowDropUp: ImageVector + get() = EpistemeFilledIcons.arrowDropUp + +val Icons.Filled.ArrowUpward: ImageVector + get() = EpistemeFilledIcons.arrowUpward + +val Icons.Filled.Book: ImageVector + get() = EpistemeFilledIcons.book + +val Icons.Filled.Bookmark: ImageVector + get() = EpistemeFilledIcons.bookmark + +val Icons.Filled.BookmarkBorder: ImageVector + get() = EpistemeFilledIcons.bookmarkBorder + +val Icons.Filled.Brush: ImageVector + get() = EpistemeFilledIcons.brush + +val Icons.Filled.BugReport: ImageVector + get() = EpistemeFilledIcons.bugReport + +val Icons.Filled.Check: ImageVector + get() = EpistemeFilledIcons.check + +val Icons.Filled.ChevronLeft: ImageVector + get() = EpistemeFilledIcons.chevronLeft + +val Icons.Filled.ChevronRight: ImageVector + get() = EpistemeFilledIcons.chevronRight + +val Icons.Filled.Close: ImageVector + get() = EpistemeFilledIcons.close + +val Icons.Filled.Cloud: ImageVector + get() = EpistemeFilledIcons.cloud + +val Icons.Filled.CloudDownload: ImageVector + get() = EpistemeFilledIcons.cloudDownload + +val Icons.Filled.Code: ImageVector + get() = EpistemeFilledIcons.code + +val Icons.Filled.ContentCopy: ImageVector + get() = EpistemeFilledIcons.contentCopy + +val Icons.Filled.CopyAll: ImageVector + get() = EpistemeFilledIcons.copyAll + +val Icons.Filled.CreateNewFolder: ImageVector + get() = EpistemeFilledIcons.createNewFolder + +val Icons.Filled.Delete: ImageVector + get() = EpistemeFilledIcons.delete + +val Icons.Filled.Description: ImageVector + get() = EpistemeFilledIcons.description + +val Icons.Filled.DoNotTouch: ImageVector + get() = EpistemeFilledIcons.doNotTouch + +val Icons.Filled.Download: ImageVector + get() = EpistemeFilledIcons.download + +val Icons.Filled.Edit: ImageVector + get() = EpistemeFilledIcons.edit + +val Icons.Filled.Email: ImageVector + get() = EpistemeFilledIcons.email + +val Icons.Filled.ExpandLess: ImageVector + get() = EpistemeFilledIcons.expandLess + +val Icons.Filled.ExpandMore: ImageVector + get() = EpistemeFilledIcons.expandMore + +val Icons.Filled.Favorite: ImageVector + get() = EpistemeFilledIcons.favorite + +val Icons.Filled.Feedback: ImageVector + get() = EpistemeFilledIcons.feedback + +val Icons.Filled.FileOpen: ImageVector + get() = EpistemeFilledIcons.fileOpen + +val Icons.Filled.FilterList: ImageVector + get() = EpistemeFilledIcons.filterList + +val Icons.Filled.Folder: ImageVector + get() = EpistemeFilledIcons.folder + +val Icons.Filled.FolderSpecial: ImageVector + get() = EpistemeFilledIcons.folderSpecial + +val Icons.Filled.FormatListNumbered: ImageVector + get() = EpistemeFilledIcons.formatListNumbered + +val Icons.Filled.Fullscreen: ImageVector + get() = EpistemeFilledIcons.fullscreen + +val Icons.Filled.FullscreenExit: ImageVector + get() = EpistemeFilledIcons.fullscreenExit + +val Icons.Filled.Gavel: ImageVector + get() = EpistemeFilledIcons.gavel + +val Icons.Filled.GraphicEq: ImageVector + get() = EpistemeFilledIcons.graphicEq + +val Icons.Filled.ImportExport: ImageVector + get() = EpistemeFilledIcons.importExport + +val Icons.Filled.Info: ImageVector + get() = EpistemeFilledIcons.info + +val Icons.Filled.KeyboardArrowDown: ImageVector + get() = EpistemeFilledIcons.keyboardArrowDown + +val Icons.Filled.KeyboardArrowLeft: ImageVector + get() = EpistemeFilledIcons.keyboardArrowLeft + +val Icons.Filled.KeyboardArrowRight: ImageVector + get() = EpistemeFilledIcons.keyboardArrowRight + +val Icons.Filled.KeyboardArrowUp: ImageVector + get() = EpistemeFilledIcons.keyboardArrowUp + +val Icons.Filled.Lock: ImageVector + get() = EpistemeFilledIcons.lock + +val Icons.Filled.LockOpen: ImageVector + get() = EpistemeFilledIcons.lockOpen + +val Icons.Filled.Menu: ImageVector + get() = EpistemeFilledIcons.menu + +val Icons.Filled.MoreVert: ImageVector + get() = EpistemeFilledIcons.moreVert + +val Icons.Filled.MyLocation: ImageVector + get() = EpistemeFilledIcons.myLocation + +val Icons.Filled.OpenInNew: ImageVector + get() = EpistemeFilledIcons.openInNew + +val Icons.Filled.Palette: ImageVector + get() = EpistemeFilledIcons.palette + +val Icons.Filled.Pause: ImageVector + get() = EpistemeFilledIcons.pause + +val Icons.Filled.PhoneAndroid: ImageVector + get() = EpistemeFilledIcons.phoneAndroid + +val Icons.Filled.PlayArrow: ImageVector + get() = EpistemeFilledIcons.playArrow + +val Icons.Filled.PlayCircle: ImageVector + get() = EpistemeFilledIcons.playCircle + +val Icons.Filled.Policy: ImageVector + get() = EpistemeFilledIcons.policy + +val Icons.Filled.Print: ImageVector + get() = EpistemeFilledIcons.print + +val Icons.Filled.Psychology: ImageVector + get() = EpistemeFilledIcons.psychology + +val Icons.Filled.PushPin: ImageVector + get() = EpistemeFilledIcons.pushPin + +val Icons.Filled.Refresh: ImageVector + get() = EpistemeFilledIcons.refresh + +val Icons.Filled.Remove: ImageVector + get() = EpistemeFilledIcons.remove + +val Icons.Filled.Restore: ImageVector + get() = EpistemeFilledIcons.restore + +val Icons.Filled.Save: ImageVector + get() = EpistemeFilledIcons.save + +val Icons.Filled.ScreenRotation: ImageVector + get() = EpistemeFilledIcons.screenRotation + +val Icons.Filled.Search: ImageVector + get() = EpistemeFilledIcons.search + +val Icons.Filled.SelectAll: ImageVector + get() = EpistemeFilledIcons.selectAll + +val Icons.Filled.Settings: ImageVector + get() = EpistemeFilledIcons.settings + +val Icons.Filled.Share: ImageVector + get() = EpistemeFilledIcons.share + +val Icons.Filled.SkipNext: ImageVector + get() = EpistemeFilledIcons.skipNext + +val Icons.Filled.SkipPrevious: ImageVector + get() = EpistemeFilledIcons.skipPrevious + +val Icons.Filled.Smartphone: ImageVector + get() = EpistemeFilledIcons.smartphone + +val Icons.Filled.Star: ImageVector + get() = EpistemeFilledIcons.star + +val Icons.Filled.Stop: ImageVector + get() = EpistemeFilledIcons.stop + +val Icons.Filled.SwapHoriz: ImageVector + get() = EpistemeFilledIcons.swapHoriz + +val Icons.Filled.Sync: ImageVector + get() = EpistemeFilledIcons.sync + +val Icons.Filled.Tag: ImageVector + get() = EpistemeFilledIcons.tag + +val Icons.Filled.TextFields: ImageVector + get() = EpistemeFilledIcons.textFields + +val Icons.Filled.TouchApp: ImageVector + get() = EpistemeFilledIcons.touchApp + +val Icons.Filled.Translate: ImageVector + get() = EpistemeFilledIcons.translate + +val Icons.Filled.Tune: ImageVector + get() = EpistemeFilledIcons.tune + +val Icons.Filled.Verified: ImageVector + get() = EpistemeFilledIcons.verified + +val Icons.Filled.VerifiedUser: ImageVector + get() = EpistemeFilledIcons.verifiedUser + +val Icons.Filled.Visibility: ImageVector + get() = EpistemeFilledIcons.visibility + +val Icons.Filled.VisibilityOff: ImageVector + get() = EpistemeFilledIcons.visibilityOff + +val Icons.Filled.ZoomOut: ImageVector + get() = EpistemeFilledIcons.zoomOut + +private object EpistemeFilledIcons { + val accountCircle: ImageVector by lazy { + materialIcon( + name = "AccountCircle", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520Q421,520 380.5,479.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM580,784.5Q627,769 666,740Q627,711 580,695.5Q533,680 480,680Q427,680 380,695.5Q333,711 294,740Q333,769 380,784.5Q427,800 480,800Q533,800 580,784.5ZM523,423Q540,406 540,380Q540,354 523,337Q506,320 480,320Q454,320 437,337Q420,354 420,380Q420,406 437,423Q454,440 480,440Q506,440 523,423ZM480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380ZM480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Z""" + ) + ) + } + + val add: ImageVector by lazy { + materialIcon( + name = "Add", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z""" + ) + ) + } + + val arrowDownward: ImageVector by lazy { + materialIcon( + name = "ArrowDownward", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,160L440,647L216,423L160,480L480,800L800,480L744,423L520,647L520,160L440,160Z""" + ) + ) + } + + val arrowDropDown: ImageVector by lazy { + materialIcon( + name = "ArrowDropDown", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,600L280,400L680,400L480,600Z""" + ) + ) + } + + val arrowDropUp: ImageVector by lazy { + materialIcon( + name = "ArrowDropUp", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,560L480,360L680,560L280,560Z""" + ) + ) + } + + val arrowUpward: ImageVector by lazy { + materialIcon( + name = "ArrowUpward", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,800L440,313L216,537L160,480L480,160L800,480L744,537L520,313L520,800L440,800Z""" + ) + ) + } + + val book: ImageVector by lazy { + materialIcon( + name = "Book", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M300,880Q242,880 201,839Q160,798 160,740L160,220Q160,162 201,121Q242,80 300,80L800,80L800,680Q775,680 757.5,697.5Q740,715 740,740Q740,765 757.5,782.5Q775,800 800,800L800,880L300,880ZM240,613Q254,606 269,603Q284,600 300,600L320,600L320,160L300,160Q275,160 257.5,177.5Q240,195 240,220L240,613ZM400,600L720,600L720,160L400,160L400,600ZM240,613Q240,613 240,613Q240,613 240,613L240,613L240,160L240,160Q240,160 240,160Q240,160 240,160L240,613ZM300,800L673,800Q667,786 663.5,771.5Q660,757 660,740Q660,724 663,709Q666,694 673,680L300,680Q274,680 257,697.5Q240,715 240,740Q240,766 257,783Q274,800 300,800Z""" + ) + ) + } + + val bookmark: ImageVector by lazy { + materialIcon( + name = "Bookmark", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M200,840L200,200Q200,167 223.5,143.5Q247,120 280,120L680,120Q713,120 736.5,143.5Q760,167 760,200L760,840L480,720L200,840ZM280,718L480,632L680,718L680,200Q680,200 680,200Q680,200 680,200L280,200Q280,200 280,200Q280,200 280,200L280,718ZM280,200L280,200Q280,200 280,200Q280,200 280,200L680,200Q680,200 680,200Q680,200 680,200L680,200L480,200L280,200Z""" + ) + ) + } + + val bookmarkBorder: ImageVector by lazy { + materialIcon( + name = "BookmarkBorder", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M200,840L200,200Q200,167 223.5,143.5Q247,120 280,120L680,120Q713,120 736.5,143.5Q760,167 760,200L760,840L480,720L200,840ZM280,718L480,632L680,718L680,200Q680,200 680,200Q680,200 680,200L280,200Q280,200 280,200Q280,200 280,200L280,718ZM280,200L280,200Q280,200 280,200Q280,200 280,200L680,200Q680,200 680,200Q680,200 680,200L680,200L480,200L280,200Z""" + ) + ) + } + + val brush: ImageVector by lazy { + materialIcon( + name = "Brush", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,840Q195,840 151,818Q107,796 80,760Q106,760 133,739.5Q160,719 160,680Q160,630 195,595Q230,560 280,560Q330,560 365,595Q400,630 400,680Q400,746 353,793Q306,840 240,840ZM240,760Q273,760 296.5,736.5Q320,713 320,680Q320,663 308.5,651.5Q297,640 280,640Q263,640 251.5,651.5Q240,663 240,680Q240,703 234.5,722Q229,741 220,758Q225,760 230,760Q235,760 240,760ZM470,600L360,490L718,132Q729,121 745.5,120.5Q762,120 774,132L828,186Q840,198 840,214Q840,230 828,242L470,600ZM280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Z""" + ) + ) + } + + val bugReport: ImageVector by lazy { + materialIcon( + name = "BugReport", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,760Q546,760 593,713Q640,666 640,600L640,440Q640,374 593,327Q546,280 480,280Q414,280 367,327Q320,374 320,440L320,600Q320,666 367,713Q414,760 480,760ZM400,640L560,640L560,560L400,560L400,640ZM400,480L560,480L560,400L400,400L400,480ZM480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520ZM480,840Q415,840 359.5,808Q304,776 272,720L160,720L160,640L244,640Q241,620 240.5,600Q240,580 240,560L160,560L160,480L240,480Q240,460 240.5,440Q241,420 244,400L160,400L160,320L272,320Q286,297 303.5,277Q321,257 344,242L280,176L336,120L422,206Q450,197 479,197Q508,197 536,206L624,120L680,176L614,242Q637,257 655.5,276.5Q674,296 688,320L800,320L800,400L716,400Q719,420 719.5,440Q720,460 720,480L800,480L800,560L720,560Q720,580 719.5,600Q719,620 716,640L800,640L800,720L688,720Q656,776 600.5,808Q545,840 480,840Z""" + ) + ) + } + + val check: ImageVector by lazy { + materialIcon( + name = "Check", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z""" + ) + ) + } + + val chevronLeft: ImageVector by lazy { + materialIcon( + name = "ChevronLeft", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M560,720L320,480L560,240L616,296L432,480L616,664L560,720Z""" + ) + ) + } + + val chevronRight: ImageVector by lazy { + materialIcon( + name = "ChevronRight", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z""" + ) + ) + } + + val close: ImageVector by lazy { + materialIcon( + name = "Close", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z""" + ) + ) + } + + val cloud: ImageVector by lazy { + materialIcon( + name = "Cloud", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q235,274 310,217Q385,160 480,160Q597,160 678.5,241.5Q760,323 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L260,800ZM260,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,357 621.5,298.5Q563,240 480,240Q397,240 338.5,298.5Q280,357 280,440L280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z""" + ) + ) + } + + val cloudDownload: ImageVector by lazy { + materialIcon( + name = "CloudDownload", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q227,294 295,229Q363,164 440,164Q473,164 496.5,187.5Q520,211 520,244L520,486L584,424L640,480L480,640L320,480L376,424L440,486L440,244Q364,258 322,317.5Q280,377 280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,392 658,350.5Q636,309 600,280L600,187Q674,222 717,290.5Q760,359 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L260,800ZM480,442Q480,442 480,442Q480,442 480,442L480,442Q480,442 480,442Q480,442 480,442L480,442Q480,442 480,442Q480,442 480,442L480,442Q480,442 480,442Q480,442 480,442Q480,442 480,442Q480,442 480,442L480,442Q480,442 480,442Q480,442 480,442Q480,442 480,442Q480,442 480,442L480,442L480,442Q480,442 480,442Q480,442 480,442Z""" + ) + ) + } + + val code: ImageVector by lazy { + materialIcon( + name = "Code", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M320,720L80,480L320,240L377,297L193,481L376,664L320,720ZM640,720L583,663L767,479L584,296L640,240L880,480L640,720Z""" + ) + ) + } + + val contentCopy: ImageVector by lazy { + materialIcon( + name = "ContentCopy", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640Z""" + ) + ) + } + + val copyAll: ImageVector by lazy { + materialIcon( + name = "CopyAll", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M120,740L120,660L200,660L200,740L120,740ZM120,600L120,520L200,520L200,600L120,600ZM120,460L120,380L200,380L200,460L120,460ZM260,880L260,800L340,800L340,880L260,880ZM360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM400,880L400,800L480,800L480,880L400,880ZM200,880Q167,880 143.5,856.5Q120,833 120,800L200,800L200,880ZM540,880L540,800L620,800Q620,833 596.5,856.5Q573,880 540,880ZM120,320Q120,287 143.5,263.5Q167,240 200,240L200,320L120,320ZM540,400Q540,400 540,400Q540,400 540,400L540,400Q540,400 540,400Q540,400 540,400L540,400Q540,400 540,400Q540,400 540,400L540,400Q540,400 540,400Q540,400 540,400Z""" + ) + ) + } + + val createNewFolder: ImageVector by lazy { + materialIcon( + name = "CreateNewFolder", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M560,640L640,640L640,560L720,560L720,480L640,480L640,400L560,400L560,480L480,480L480,560L560,560L560,640ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240L160,320L160,320Q160,320 160,320Q160,320 160,320L160,720Q160,720 160,720Q160,720 160,720Z""" + ) + ) + } + + val delete: ImageVector by lazy { + materialIcon( + name = "Delete", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z""" + ) + ) + } + + val description: ImageVector by lazy { + materialIcon( + name = "Description", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M320,720L640,720L640,640L320,640L320,720ZM320,560L640,560L640,480L320,480L320,560ZM240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L720,800Q720,800 720,800Q720,800 720,800L720,360L520,360ZM240,160L240,160L240,360L240,360L240,160L240,360L240,360L240,800Q240,800 240,800Q240,800 240,800L240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160Z""" + ) + ) + } + + val doNotTouch: ImageVector by lazy { + materialIcon( + name = "DoNotTouch", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M840,726L760,646L760,200Q760,183 771.5,171.5Q783,160 800,160Q817,160 828.5,171.5Q840,183 840,200L840,726ZM360,246L280,166L280,160Q280,143 291.5,131.5Q303,120 320,120Q337,120 348.5,131.5Q360,143 360,160L360,246ZM520,406L440,326L440,80Q440,63 451.5,51.5Q463,40 480,40Q497,40 508.5,51.5Q520,63 520,80L520,406ZM680,487L600,487L600,487L600,120Q600,103 611.5,91.5Q623,80 640,80Q657,80 668.5,91.5Q680,103 680,120L680,487ZM717,830L360,473L360,697L212,593L369,822Q374,830 383,835Q392,840 402,840L680,840Q690,840 699.5,837.5Q709,835 717,830ZM402,920Q372,920 346,906.5Q320,893 303,868L48,495L72,472Q91,453 117,450Q143,447 164,462L280,543L280,393L27,140L84,83L876,875L819,932L775,888Q755,903 731,911.5Q707,920 680,920L402,920ZM539,652Q539,652 539,652Q539,652 539,652L539,652Q539,652 539,652Q539,652 539,652L539,652L539,652L539,652ZM600,487L600,487L600,487Z""" + ) + ) + } + + val download: ImageVector by lazy { + materialIcon( + name = "Download", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,640L280,440L336,382L440,486L440,160L520,160L520,486L624,382L680,440L480,640ZM240,800Q207,800 183.5,776.5Q160,753 160,720L160,600L240,600L240,720Q240,720 240,720Q240,720 240,720L720,720Q720,720 720,720Q720,720 720,720L720,600L800,600L800,720Q800,753 776.5,776.5Q753,800 720,800L240,800Z""" + ) + ) + } + + val edit: ImageVector by lazy { + materialIcon( + name = "Edit", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M200,760L257,760L648,369L591,312L200,703L200,760ZM120,840L120,670L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L290,840L120,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z""" + ) + ) + } + + val email: ImageVector by lazy { + materialIcon( + name = "Email", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM480,520L160,320L160,720Q160,720 160,720Q160,720 160,720L800,720Q800,720 800,720Q800,720 800,720L800,320L480,520ZM480,440L800,240L160,240L480,440ZM160,320L160,240L160,240L160,320L160,720Q160,720 160,720Q160,720 160,720L160,720Q160,720 160,720Q160,720 160,720L160,320Z""" + ) + ) + } + + val expandLess: ImageVector by lazy { + materialIcon( + name = "ExpandLess", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M296,615L240,559L480,319L720,559L664,615L480,431L296,615Z""" + ) + ) + } + + val expandMore: ImageVector by lazy { + materialIcon( + name = "ExpandMore", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,615L240,375L296,319L480,503L664,319L720,375L480,615Z""" + ) + ) + } + + val favorite: ImageVector by lazy { + materialIcon( + name = "Favorite", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,840L422,788Q321,697 255,631Q189,565 150,512.5Q111,460 95.5,416Q80,372 80,326Q80,232 143,169Q206,106 300,106Q352,106 399,128Q446,150 480,190Q514,150 561,128Q608,106 660,106Q754,106 817,169Q880,232 880,326Q880,372 864.5,416Q849,460 810,512.5Q771,565 705,631Q639,697 538,788L480,840ZM480,732Q576,646 638,584.5Q700,523 736,477.5Q772,432 786,396.5Q800,361 800,326Q800,266 760,226Q720,186 660,186Q613,186 573,212.5Q533,239 518,280L518,280L442,280L442,280Q427,239 387,212.5Q347,186 300,186Q240,186 200,226Q160,266 160,326Q160,361 174,396.5Q188,432 224,477.5Q260,523 322,584.5Q384,646 480,732ZM480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459L480,459L480,459L480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Z""" + ) + ) + } + + val feedback: ImageVector by lazy { + materialIcon( + name = "Feedback", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,600Q497,600 508.5,588.5Q520,577 520,560Q520,543 508.5,531.5Q497,520 480,520Q463,520 451.5,531.5Q440,543 440,560Q440,577 451.5,588.5Q463,600 480,600ZM440,440L520,440L520,200L440,200L440,440ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z""" + ) + ) + } + + val fileOpen: ImageVector by lazy { + materialIcon( + name = "FileOpen", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,560L720,560L720,360L520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L600,800L600,880L240,880ZM878,895L760,777L760,866L680,866L680,640L906,640L906,720L816,720L934,838L878,895ZM240,800L240,560L240,560L240,360L240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800Z""" + ) + ) + } + + val filterList: ImageVector by lazy { + materialIcon( + name = "FilterList", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M400,720L400,640L560,640L560,720L400,720ZM240,520L240,440L720,440L720,520L240,520ZM120,320L120,240L840,240L840,320L120,320Z""" + ) + ) + } + + val folder: ImageVector by lazy { + materialIcon( + name = "Folder", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240L160,320L160,320Q160,320 160,320Q160,320 160,320L160,720Q160,720 160,720Q160,720 160,720Z""" + ) + ) + } + + val folderSpecial: ImageVector by lazy { + materialIcon( + name = "FolderSpecial", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M504,668L596,598L688,668L654,554L746,480L632,480L596,368L560,480L446,480L538,554L504,668ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240L160,320L160,320Q160,320 160,320Q160,320 160,320L160,720Q160,720 160,720Q160,720 160,720Z""" + ) + ) + } + + val formatListNumbered: ImageVector by lazy { + materialIcon( + name = "FormatListNumbered", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M120,880L120,820L220,820L220,790L160,790L160,730L220,730L220,700L120,700L120,640L240,640Q257,640 268.5,651.5Q280,663 280,680L280,720Q280,737 268.5,748.5Q257,760 240,760Q257,760 268.5,771.5Q280,783 280,800L280,840Q280,857 268.5,868.5Q257,880 240,880L120,880ZM120,600L120,490Q120,473 131.5,461.5Q143,450 160,450L220,450L220,420L120,420L120,360L240,360Q257,360 268.5,371.5Q280,383 280,400L280,470Q280,487 268.5,498.5Q257,510 240,510L180,510L180,540L280,540L280,600L120,600ZM180,320L180,140L120,140L120,80L240,80L240,320L180,320ZM360,760L360,680L840,680L840,760L360,760ZM360,520L360,440L840,440L840,520L360,520ZM360,280L360,200L840,200L840,280L360,280Z""" + ) + ) + } + + val fullscreen: ImageVector by lazy { + materialIcon( + name = "Fullscreen", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M120,840L120,640L200,640L200,760L320,760L320,840L120,840ZM640,840L640,760L760,760L760,640L840,640L840,840L640,840ZM120,320L120,120L320,120L320,200L200,200L200,320L120,320ZM760,320L760,200L640,200L640,120L840,120L840,320L760,320Z""" + ) + ) + } + + val fullscreenExit: ImageVector by lazy { + materialIcon( + name = "FullscreenExit", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,840L240,720L120,720L120,640L320,640L320,840L240,840ZM640,840L640,640L840,640L840,720L720,720L720,840L640,840ZM120,320L120,240L240,240L240,120L320,120L320,320L120,320ZM640,320L640,120L720,120L720,240L840,240L840,320L640,320Z""" + ) + ) + } + + val gavel: ImageVector by lazy { + materialIcon( + name = "Gavel", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M160,840L160,760L640,760L640,840L160,840ZM386,646L160,420L244,334L472,560L386,646ZM640,392L414,164L500,80L726,306L640,392ZM824,800L302,278L358,222L880,744L824,800Z""" + ) + ) + } + + val graphicEq: ImageVector by lazy { + materialIcon( + name = "GraphicEq", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,720L280,240L360,240L360,720L280,720ZM440,880L440,80L520,80L520,880L440,880ZM120,560L120,400L200,400L200,560L120,560ZM600,720L600,240L680,240L680,720L600,720ZM760,560L760,400L840,400L840,560L760,560Z""" + ) + ) + } + + val importExport: ImageVector by lazy { + materialIcon( + name = "ImportExport", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,760L520,760L520,593L584,657L640,600L480,440L320,600L377,656L440,593L440,760ZM240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L720,800Q720,800 720,800Q720,800 720,800L720,360L520,360ZM240,160L240,160L240,360L240,360L240,160L240,360L240,360L240,800Q240,800 240,800Q240,800 240,800L240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160Z""" + ) + ) + } + + val info: ImageVector by lazy { + materialIcon( + name = "Info", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,680L520,680L520,440L440,440L440,680ZM508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360Q497,360 508.5,348.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z""" + ) + ) + } + + val keyboardArrowDown: ImageVector by lazy { + materialIcon( + name = "KeyboardArrowDown", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,616L240,376L296,320L480,504L664,320L720,376L480,616Z""" + ) + ) + } + + val keyboardArrowLeft: ImageVector by lazy { + materialIcon( + name = "KeyboardArrowLeft", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M560,720L320,480L560,240L616,296L432,480L616,664L560,720Z""" + ) + ) + } + + val keyboardArrowRight: ImageVector by lazy { + materialIcon( + name = "KeyboardArrowRight", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z""" + ) + ) + } + + val keyboardArrowUp: ImageVector by lazy { + materialIcon( + name = "KeyboardArrowUp", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,432L296,616L240,560L480,320L720,560L664,616L480,432Z""" + ) + ) + } + + val lock: ImageVector by lazy { + materialIcon( + name = "Lock", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,880Q207,880 183.5,856.5Q160,833 160,800L160,400Q160,367 183.5,343.5Q207,320 240,320L280,320L280,240Q280,157 338.5,98.5Q397,40 480,40Q563,40 621.5,98.5Q680,157 680,240L680,320L720,320Q753,320 776.5,343.5Q800,367 800,400L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,400Q720,400 720,400Q720,400 720,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800ZM536.5,656.5Q560,633 560,600Q560,567 536.5,543.5Q513,520 480,520Q447,520 423.5,543.5Q400,567 400,600Q400,633 423.5,656.5Q447,680 480,680Q513,680 536.5,656.5ZM360,320L600,320L600,240Q600,190 565,155Q530,120 480,120Q430,120 395,155Q360,190 360,240L360,320ZM240,800Q240,800 240,800Q240,800 240,800L240,400Q240,400 240,400Q240,400 240,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800Z""" + ) + ) + } + + val lockOpen: ImageVector by lazy { + materialIcon( + name = "LockOpen", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,320L600,320L600,240Q600,190 565,155Q530,120 480,120Q430,120 395,155Q360,190 360,240L280,240Q280,157 338.5,98.5Q397,40 480,40Q563,40 621.5,98.5Q680,157 680,240L680,320L720,320Q753,320 776.5,343.5Q800,367 800,400L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880Q207,880 183.5,856.5Q160,833 160,800L160,400Q160,367 183.5,343.5Q207,320 240,320ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,400Q720,400 720,400Q720,400 720,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800ZM536.5,656.5Q560,633 560,600Q560,567 536.5,543.5Q513,520 480,520Q447,520 423.5,543.5Q400,567 400,600Q400,633 423.5,656.5Q447,680 480,680Q513,680 536.5,656.5ZM240,800Q240,800 240,800Q240,800 240,800L240,400Q240,400 240,400Q240,400 240,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800Z""" + ) + ) + } + + val menu: ImageVector by lazy { + materialIcon( + name = "Menu", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M120,720L120,640L840,640L840,720L120,720ZM120,520L120,440L840,440L840,520L120,520ZM120,320L120,240L840,240L840,320L120,320Z""" + ) + ) + } + + val moreVert: ImageVector by lazy { + materialIcon( + name = "MoreVert", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z""" + ) + ) + } + + val myLocation: ImageVector by lazy { + materialIcon( + name = "MyLocation", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,918L440,838Q315,824 225.5,734.5Q136,645 122,520L42,520L42,440L122,440Q136,315 225.5,225.5Q315,136 440,122L440,42L520,42L520,122Q645,136 734.5,225.5Q824,315 838,440L918,440L918,520L838,520Q824,645 734.5,734.5Q645,824 520,838L520,918L440,918ZM678,678Q760,596 760,480Q760,364 678,282Q596,200 480,200Q364,200 282,282Q200,364 200,480Q200,596 282,678Q364,760 480,760Q596,760 678,678ZM367,593Q320,546 320,480Q320,414 367,367Q414,320 480,320Q546,320 593,367Q640,414 640,480Q640,546 593,593Q546,640 480,640Q414,640 367,593ZM536.5,536.5Q560,513 560,480Q560,447 536.5,423.5Q513,400 480,400Q447,400 423.5,423.5Q400,447 400,480Q400,513 423.5,536.5Q447,560 480,560Q513,560 536.5,536.5ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z""" + ) + ) + } + + val openInNew: ImageVector by lazy { + materialIcon( + name = "OpenInNew", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L480,120L480,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,480L840,480L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM388,628L332,572L704,200L560,200L560,120L840,120L840,400L760,400L760,256L388,628Z""" + ) + ) + } + + val palette: ImageVector by lazy { + materialIcon( + name = "Palette", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,880Q398,880 325,848.5Q252,817 197.5,762.5Q143,708 111.5,635Q80,562 80,480Q80,397 112.5,324Q145,251 200.5,197Q256,143 330,111.5Q404,80 488,80Q568,80 639,107.5Q710,135 763.5,183.5Q817,232 848.5,298.5Q880,365 880,442Q880,557 810,618.5Q740,680 640,680L566,680Q557,680 553.5,685Q550,690 550,696Q550,708 565,730.5Q580,753 580,782Q580,832 552.5,856Q525,880 480,880ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM303,503Q320,486 320,460Q320,434 303,417Q286,400 260,400Q234,400 217,417Q200,434 200,460Q200,486 217,503Q234,520 260,520Q286,520 303,503ZM423,343Q440,326 440,300Q440,274 423,257Q406,240 380,240Q354,240 337,257Q320,274 320,300Q320,326 337,343Q354,360 380,360Q406,360 423,343ZM623,343Q640,326 640,300Q640,274 623,257Q606,240 580,240Q554,240 537,257Q520,274 520,300Q520,326 537,343Q554,360 580,360Q606,360 623,343ZM743,503Q760,486 760,460Q760,434 743,417Q726,400 700,400Q674,400 657,417Q640,434 640,460Q640,486 657,503Q674,520 700,520Q726,520 743,503ZM480,800Q489,800 494.5,795Q500,790 500,782Q500,768 485,749Q470,730 470,692Q470,650 499,625Q528,600 570,600L640,600Q706,600 753,561.5Q800,523 800,442Q800,321 707.5,240.5Q615,160 488,160Q352,160 256,253Q160,346 160,480Q160,613 253.5,706.5Q347,800 480,800Z""" + ) + ) + } + + val pause: ImageVector by lazy { + materialIcon( + name = "Pause", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M520,760L520,200L760,200L760,760L520,760ZM200,760L200,200L440,200L440,760L200,760ZM600,680L680,680L680,280L600,280L600,680ZM280,680L360,680L360,280L280,280L280,680ZM280,280L280,280L280,680L280,680L280,280ZM600,280L600,280L600,680L600,680L600,280Z""" + ) + ) + } + + val phoneAndroid: ImageVector by lazy { + materialIcon( + name = "PhoneAndroid", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,920Q247,920 223.5,896.5Q200,873 200,840L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,244Q778,251 789,266Q800,281 800,300L800,380Q800,399 789,414Q778,429 760,436L760,840Q760,873 736.5,896.5Q713,920 680,920L280,920ZM280,840L680,840Q680,840 680,840Q680,840 680,840L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840ZM280,840Q280,840 280,840Q280,840 280,840L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840ZM508.5,788.5Q520,777 520,760Q520,743 508.5,731.5Q497,720 480,720Q463,720 451.5,731.5Q440,743 440,760Q440,777 451.5,788.5Q463,800 480,800Q497,800 508.5,788.5Z""" + ) + ) + } + + val playArrow: ImageVector by lazy { + materialIcon( + name = "PlayArrow", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z""" + ) + ) + } + + val playCircle: ImageVector by lazy { + materialIcon( + name = "PlayCircle", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M380,660L660,480L380,300L380,660ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z""" + ) + ) + } + + val policy: ImageVector by lazy { + materialIcon( + name = "Policy", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,529 771,607.5Q742,686 688,746L560,618Q542,629 521.5,634.5Q501,640 480,640Q414,640 367,593Q320,546 320,480Q320,414 367,367Q414,320 480,320Q546,320 593,367Q640,414 640,480Q640,502 634.5,522.5Q629,543 618,562L678,622Q698,581 709,536Q720,491 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796Q506,788 529.5,775.5Q553,763 576,746L632,802Q599,829 560.5,849Q522,869 480,880ZM536.5,536.5Q560,513 560,480Q560,447 536.5,423.5Q513,400 480,400Q447,400 423.5,423.5Q400,447 400,480Q400,513 423.5,536.5Q447,560 480,560Q513,560 536.5,536.5ZM488,483L488,483Q488,483 488,483Q488,483 488,483L488,483Q488,483 488,483Q488,483 488,483L488,483L488,483L488,483L488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Z""" + ) + ) + } + + val print: ImageVector by lazy { + materialIcon( + name = "Print", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M640,320L640,200L320,200L320,320L240,320L240,120L720,120L720,320L640,320ZM160,400L160,400Q160,400 171.5,400Q183,400 200,400L760,400Q777,400 788.5,400Q800,400 800,400L800,400L720,400L720,400L240,400L240,400L160,400ZM720,500Q737,500 748.5,488.5Q760,477 760,460Q760,443 748.5,431.5Q737,420 720,420Q703,420 691.5,431.5Q680,443 680,460Q680,477 691.5,488.5Q703,500 720,500ZM640,760L640,600L320,600L320,760L640,760ZM720,840L240,840L240,680L80,680L80,440Q80,389 115,354.5Q150,320 200,320L760,320Q811,320 845.5,354.5Q880,389 880,440L880,680L720,680L720,840ZM800,600L800,440Q800,423 788.5,411.5Q777,400 760,400L200,400Q183,400 171.5,411.5Q160,423 160,440L160,600L240,600L240,520L720,520L720,600L800,600Z""" + ) + ) + } + + val psychology: ImageVector by lazy { + materialIcon( + name = "Psychology", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,880L240,708Q183,656 151.5,586.5Q120,517 120,440Q120,290 225,185Q330,80 480,80Q605,80 701.5,153.5Q798,227 827,345L879,550Q884,569 872,584.5Q860,600 840,600L760,600L760,720Q760,753 736.5,776.5Q713,800 680,800L600,800L600,880L520,880L520,720L680,720Q680,720 680,720Q680,720 680,720L680,520L788,520L750,365Q727,274 652,217Q577,160 480,160Q364,160 282,241Q200,322 200,438Q200,498 224.5,552Q249,606 294,648L320,672L320,880L240,880ZM494,520L494,520L494,520Q494,520 494,520Q494,520 494,520Q494,520 494,520Q494,520 494,520Q494,520 494,520Q494,520 494,520L494,520L494,520L494,520Q494,520 494,520Q494,520 494,520L494,520L494,520ZM440,600L520,600L526,550Q534,547 540.5,543Q547,539 552,534L598,554L638,486L598,456Q600,448 600,440Q600,432 598,424L638,394L598,326L552,346Q547,341 540.5,337Q534,333 526,330L520,280L440,280L434,330Q426,333 419.5,337Q413,341 408,346L362,326L322,394L362,424Q360,432 360,440Q360,448 362,456L322,486L362,554L408,534Q413,539 419.5,543Q426,547 434,550L440,600ZM437.5,482.5Q420,465 420,440Q420,415 437.5,397.5Q455,380 480,380Q505,380 522.5,397.5Q540,415 540,440Q540,465 522.5,482.5Q505,500 480,500Q455,500 437.5,482.5Z""" + ) + ) + } + + val pushPin: ImageVector by lazy { + materialIcon( + name = "PushPin", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M640,480L720,560L720,640L520,640L520,880L480,920L440,880L440,640L240,640L240,560L320,480L320,200L280,200L280,120L680,120L680,200L640,200L640,480ZM354,560L606,560L560,514L560,200L400,200L400,514L354,560ZM480,560L480,560L480,560L480,560L480,560L480,560Z""" + ) + ) + } + + val refresh: ImageVector by lazy { + materialIcon( + name = "Refresh", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,800Q346,800 253,707Q160,614 160,480Q160,346 253,253Q346,160 480,160Q549,160 612,188.5Q675,217 720,270L720,160L800,160L800,440L520,440L520,360L688,360Q656,304 600.5,272Q545,240 480,240Q380,240 310,310Q240,380 240,480Q240,580 310,650Q380,720 480,720Q557,720 619,676Q681,632 706,560L790,560Q762,666 676,733Q590,800 480,800Z""" + ) + ) + } + + val remove: ImageVector by lazy { + materialIcon( + name = "Remove", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M200,520L200,440L760,440L760,520L200,520Z""" + ) + ) + } + + val restore: ImageVector by lazy { + materialIcon( + name = "Restore", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,840Q342,840 239.5,748.5Q137,657 122,520L204,520Q218,624 296.5,692Q375,760 480,760Q597,760 678.5,678.5Q760,597 760,480Q760,363 678.5,281.5Q597,200 480,200Q411,200 351,232Q291,264 250,320L360,320L360,400L120,400L120,160L200,160L200,254Q251,190 324.5,155Q398,120 480,120Q555,120 620.5,148.5Q686,177 734.5,225.5Q783,274 811.5,339.5Q840,405 840,480Q840,555 811.5,620.5Q783,686 734.5,734.5Q686,783 620.5,811.5Q555,840 480,840ZM592,648L440,496L440,280L520,280L520,464L648,592L592,648Z""" + ) + ) + } + + val save: ImageVector by lazy { + materialIcon( + name = "Save", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M840,280L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120L840,280ZM760,314L646,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,314ZM565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720Q530,720 565,685ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200L200,314Z""" + ) + ) + } + + val screenRotation: ImageVector by lazy { + materialIcon( + name = "ScreenRotation", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M496,778L183,464Q172,453 166,439Q160,425 160,410Q160,395 166,381Q172,367 183,356L356,183Q367,172 381,166.5Q395,161 410,161Q425,161 439,166.5Q453,172 464,183L777,496Q788,507 794,521Q800,535 800,550Q800,565 794,579Q788,593 777,604L604,778Q593,789 579,794.5Q565,800 550,800Q535,800 521,794.5Q507,789 496,778ZM550,720Q550,720 550,720Q550,720 550,720L720,550Q720,550 720,550Q720,550 720,550L410,240Q410,240 410,240Q410,240 410,240L240,410Q240,410 240,410Q240,410 240,410L550,720ZM480,960Q381,960 293.5,922.5Q206,885 140.5,819.5Q75,754 37.5,666.5Q0,579 0,480L80,480Q80,551 104,616Q128,681 170.5,733Q213,785 272,821.5Q331,858 401,873L296,768L352,712L588,948Q562,954 534.5,957Q507,960 480,960ZM880,480Q880,409 856,344Q832,279 789.5,227Q747,175 688,138.5Q629,102 559,87L664,192L608,248L372,12Q398,6 425.5,3Q453,0 480,0Q579,0 666.5,37.5Q754,75 819.5,140.5Q885,206 922.5,293.5Q960,381 960,480L880,480ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480ZM373,404Q386,404 394.5,395Q403,386 403,374Q403,361 394.5,352.5Q386,344 373,344Q361,344 352,352.5Q343,361 343,374Q343,386 352,395Q361,404 373,404Z""" + ) + ) + } + + val search: ImageVector by lazy { + materialIcon( + name = "Search", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z""" + ) + ) + } + + val selectAll: ImageVector by lazy { + materialIcon( + name = "SelectAll", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,680L280,280L680,280L680,680L280,680ZM360,600L600,600L600,360L360,360L360,600ZM200,760L200,840Q167,840 143.5,816.5Q120,793 120,760L200,760ZM120,680L120,600L200,600L200,680L120,680ZM120,520L120,440L200,440L200,520L120,520ZM120,360L120,280L200,280L200,360L120,360ZM200,200L120,200Q120,167 143.5,143.5Q167,120 200,120L200,200ZM280,840L280,760L360,760L360,840L280,840ZM280,200L280,120L360,120L360,200L280,200ZM440,840L440,760L520,760L520,840L440,840ZM440,200L440,120L520,120L520,200L440,200ZM600,840L600,760L680,760L680,840L600,840ZM600,200L600,120L680,120L680,200L600,200ZM760,840L760,760L840,760Q840,793 816.5,816.5Q793,840 760,840ZM760,680L760,600L840,600L840,680L760,680ZM760,520L760,440L840,440L840,520L760,520ZM760,360L760,280L840,280L840,360L760,360ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200Z""" + ) + ) + } + + val settings: ImageVector by lazy { + materialIcon( + name = "Settings", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M370,880L354,752Q341,747 329.5,740Q318,733 307,725L188,775L78,585L181,507Q180,500 180,493.5Q180,487 180,480Q180,473 180,466.5Q180,460 181,453L78,375L188,185L307,235Q318,227 330,220Q342,213 354,208L370,80L590,80L606,208Q619,213 630.5,220Q642,227 653,235L772,185L882,375L779,453Q780,460 780,466.5Q780,473 780,480Q780,487 780,493.5Q780,500 778,507L881,585L771,775L653,725Q642,733 630,740Q618,747 606,752L590,880L370,880ZM440,800L519,800L533,694Q564,686 590.5,670.5Q617,655 639,633L738,674L777,606L691,541Q696,527 698,511.5Q700,496 700,480Q700,464 698,448.5Q696,433 691,419L777,354L738,286L639,328Q617,305 590.5,289.5Q564,274 533,266L520,160L441,160L427,266Q396,274 369.5,289.5Q343,305 321,327L222,286L183,354L269,418Q264,433 262,448Q260,463 260,480Q260,496 262,511Q264,526 269,541L183,606L222,674L321,632Q343,655 369.5,670.5Q396,686 427,694L440,800ZM482,620Q540,620 581,579Q622,538 622,480Q622,422 581,381Q540,340 482,340Q423,340 382.5,381Q342,422 342,480Q342,538 382.5,579Q423,620 482,620ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Z""" + ) + ) + } + + val share: ImageVector by lazy { + materialIcon( + name = "Share", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240Q697,240 708.5,228.5ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z""" + ) + ) + } + + val skipNext: ImageVector by lazy { + materialIcon( + name = "SkipNext", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M660,720L660,240L740,240L740,720L660,720ZM220,720L220,240L580,480L220,720ZM300,480L300,480L300,480ZM300,570L436,480L300,390L300,570Z""" + ) + ) + } + + val skipPrevious: ImageVector by lazy { + materialIcon( + name = "SkipPrevious", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M220,720L220,240L300,240L300,720L220,720ZM740,720L380,480L740,240L740,720ZM660,480L660,480L660,480ZM660,570L660,390L524,480L660,570Z""" + ) + ) + } + + val smartphone: ImageVector by lazy { + materialIcon( + name = "Smartphone", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,920Q247,920 223.5,896.5Q200,873 200,840L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,244Q778,251 789,266Q800,281 800,300L800,380Q800,399 789,414Q778,429 760,436L760,840Q760,873 736.5,896.5Q713,920 680,920L280,920ZM280,840L680,840Q680,840 680,840Q680,840 680,840L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840ZM280,840Q280,840 280,840Q280,840 280,840L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840ZM508.5,228.5Q520,217 520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200Q440,217 451.5,228.5Q463,240 480,240Q497,240 508.5,228.5Z""" + ) + ) + } + + val star: ImageVector by lazy { + materialIcon( + name = "Star", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M354,673L480,597L606,674L573,530L684,434L538,421L480,285L422,420L276,433L387,530L354,673ZM233,840L298,559L80,370L368,345L480,80L592,345L880,370L662,559L727,840L480,691L233,840ZM480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490Z""" + ) + ) + } + + val stop: ImageVector by lazy { + materialIcon( + name = "Stop", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M320,320L320,320L320,640L320,640L320,320ZM240,720L240,240L720,240L720,720L240,720ZM320,640L640,640L640,320L320,320L320,640Z""" + ) + ) + } + + val swapHoriz: ImageVector by lazy { + materialIcon( + name = "SwapHoriz", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,800L80,600L280,400L336,457L233,560L520,560L520,640L233,640L336,743L280,800ZM680,560L624,503L727,400L440,400L440,320L727,320L624,217L680,160L880,360L680,560Z""" + ) + ) + } + + val sync: ImageVector by lazy { + materialIcon( + name = "Sync", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M160,800L160,720L270,720L254,706Q202,660 181,601Q160,542 160,482Q160,371 226.5,284.5Q293,198 400,170L400,254Q328,280 284,342.5Q240,405 240,482Q240,527 257,569.5Q274,612 310,648L320,658L320,560L400,560L400,800L160,800ZM560,790L560,706Q632,680 676,617.5Q720,555 720,478Q720,433 703,390.5Q686,348 650,312L640,302L640,400L560,400L560,160L800,160L800,240L690,240L706,254Q755,303 777.5,360.5Q800,418 800,478Q800,589 733.5,675.5Q667,762 560,790Z""" + ) + ) + } + + val tag: ImageVector by lazy { + materialIcon( + name = "Tag", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z""" + ) + ) + } + + val textFields: ImageVector by lazy { + materialIcon( + name = "TextFields", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,800L280,280L80,280L80,160L600,160L600,280L400,280L400,800L280,800ZM640,800L640,480L520,480L520,360L880,360L880,480L760,480L760,800L640,800Z""" + ) + ) + } + + val touchApp: ImageVector by lazy { + materialIcon( + name = "TouchApp", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M419,880Q391,880 366.5,868Q342,856 325,834L107,557L126,537Q146,516 174,512Q202,508 226,523L300,568L300,240Q300,223 311.5,211.5Q323,200 340,200Q357,200 369,211.5Q381,223 381,240L381,712L284,652L388,785Q394,792 402,796Q410,800 419,800L640,800Q673,800 696.5,776.5Q720,753 720,720L720,560Q720,543 708.5,531.5Q697,520 680,520L461,520L461,440L680,440Q730,440 765,475Q800,510 800,560L800,720Q800,786 753,833Q706,880 640,880L419,880ZM167,340Q154,318 147,292.5Q140,267 140,240Q140,157 198.5,98.5Q257,40 340,40Q423,40 481.5,98.5Q540,157 540,240Q540,267 533,292.5Q526,318 513,340L444,300Q452,286 456,271.5Q460,257 460,240Q460,190 425,155Q390,120 340,120Q290,120 255,155Q220,190 220,240Q220,257 224,271.5Q228,286 236,300L167,340ZM502,620L502,620L502,620L502,620Q502,620 502,620Q502,620 502,620L502,620Q502,620 502,620Q502,620 502,620L502,620Q502,620 502,620Q502,620 502,620L502,620L502,620Z""" + ) + ) + } + + val translate: ImageVector by lazy { + materialIcon( + name = "Translate", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M476,880L658,400L742,400L924,880L840,880L797,758L603,758L560,880L476,880ZM160,760L104,704L306,502Q271,467 242.5,422Q214,377 190,320L274,320Q294,359 314,388Q334,417 362,446Q395,413 430.5,353.5Q466,294 484,240L40,240L40,160L320,160L320,80L400,80L400,160L680,160L680,240L564,240Q543,312 501,388Q459,464 418,504L514,602L484,684L362,559L160,760ZM628,688L772,688L700,484L628,688Z""" + ) + ) + } + + val tune: ImageVector by lazy { + materialIcon( + name = "Tune", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,840L440,600L520,600L520,680L840,680L840,760L520,760L520,840L440,840ZM120,760L120,680L360,680L360,760L120,760ZM280,600L280,520L120,520L120,440L280,440L280,360L360,360L360,600L280,600ZM440,520L440,440L840,440L840,520L440,520ZM600,360L600,120L680,120L680,200L840,200L840,280L680,280L680,360L600,360ZM120,280L120,200L520,200L520,280L120,280Z""" + ) + ) + } + + val verified: ImageVector by lazy { + materialIcon( + name = "Verified", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M344,900L268,772L124,740L138,592L40,480L138,368L124,220L268,188L344,60L480,118L616,60L692,188L836,220L822,368L920,480L822,592L836,740L692,772L616,900L480,842L344,900ZM378,798L480,754L584,798L640,702L750,676L740,564L814,480L740,394L750,282L640,258L582,162L480,206L376,162L320,258L210,282L220,394L146,480L220,564L210,678L320,702L378,798ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480ZM438,622L664,396L608,338L438,508L352,424L296,480L438,622Z""" + ) + ) + } + + val verifiedUser: ImageVector by lazy { + materialIcon( + name = "VerifiedUser", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M438,622L664,396L607,339L438,508L354,424L297,481L438,622ZM480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,596 709.5,720.5Q619,845 480,880ZM480,796Q584,763 652,664Q720,565 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796ZM480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Z""" + ) + ) + } + + val visibility: ImageVector by lazy { + materialIcon( + name = "Visibility", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M607.5,587.5Q660,535 660,460Q660,385 607.5,332.5Q555,280 480,280Q405,280 352.5,332.5Q300,385 300,460Q300,535 352.5,587.5Q405,640 480,640Q555,640 607.5,587.5ZM403.5,536.5Q372,505 372,460Q372,415 403.5,383.5Q435,352 480,352Q525,352 556.5,383.5Q588,415 588,460Q588,505 556.5,536.5Q525,568 480,568Q435,568 403.5,536.5ZM214,678.5Q94,597 40,460Q94,323 214,241.5Q334,160 480,160Q626,160 746,241.5Q866,323 920,460Q866,597 746,678.5Q626,760 480,760Q334,760 214,678.5ZM480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM687.5,620.5Q782,561 832,460Q782,359 687.5,299.5Q593,240 480,240Q367,240 272.5,299.5Q178,359 128,460Q178,561 272.5,620.5Q367,680 480,680Q593,680 687.5,620.5Z""" + ) + ) + } + + val visibilityOff: ImageVector by lazy { + materialIcon( + name = "VisibilityOff", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M644,532L586,474Q595,427 559,386Q523,345 466,354L408,296Q425,288 442.5,284Q460,280 480,280Q555,280 607.5,332.5Q660,385 660,460Q660,480 656,497.5Q652,515 644,532ZM772,658L714,602Q752,573 781.5,538.5Q811,504 832,460Q782,359 688.5,299.5Q595,240 480,240Q451,240 423,244Q395,248 368,256L306,194Q347,177 390,168.5Q433,160 480,160Q631,160 749,243.5Q867,327 920,460Q897,519 859.5,569.5Q822,620 772,658ZM792,904L624,738Q589,749 553.5,754.5Q518,760 480,760Q329,760 211,676.5Q93,593 40,460Q61,407 93,361.5Q125,316 166,280L56,168L112,112L848,848L792,904ZM222,336Q193,362 169,393Q145,424 128,460Q178,561 271.5,620.5Q365,680 480,680Q500,680 519,677.5Q538,675 558,672L522,634Q511,637 501,638.5Q491,640 480,640Q405,640 352.5,587.5Q300,535 300,460Q300,449 301.5,439Q303,429 306,418L222,336ZM541,429L541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429ZM390,504Q390,504 390,504Q390,504 390,504L390,504Q390,504 390,504Q390,504 390,504Q390,504 390,504Q390,504 390,504Z""" + ) + ) + } + + val zoomOut: ImageVector by lazy { + materialIcon( + name = "ZoomOut", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M120,840L120,600L200,600L200,704L324,580L380,636L256,760L360,760L360,840L120,840ZM600,840L600,760L704,760L580,636L636,580L760,704L760,600L840,600L840,840L600,840ZM324,380L200,256L200,360L120,360L120,120L360,120L360,200L256,200L380,324L324,380ZM636,380L580,324L704,200L600,200L600,120L840,120L840,360L760,360L760,256L636,380Z""" + ) + ) + } + +} + +private fun materialIcon( + name: String, + defaultWidth: Float, + defaultHeight: Float, + viewportWidth: Float, + viewportHeight: Float, + autoMirror: Boolean, + paths: List +): ImageVector { + return ImageVector.Builder( + name = name, + defaultWidth = defaultWidth.dp, + defaultHeight = defaultHeight.dp, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + autoMirror = autoMirror + ).apply { + paths.forEach { pathData -> + addPath( + pathData = PathParser().parsePathString(pathData).toNodes(), + fill = SolidColor(Color.Black) + ) + } + }.build() +} + diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt b/shared/src/commonMain/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt new file mode 100644 index 0000000..7eaa644 --- /dev/null +++ b/shared/src/commonMain/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt @@ -0,0 +1,159 @@ +@file:Suppress("ObjectPropertyName", "unused") + +package androidx.compose.material.icons.outlined + +import androidx.compose.material.icons.Icons +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.dp + +val Icons.Outlined.AccountCircle: ImageVector + get() = EpistemeOutlinedIcons.accountCircle + +val Icons.Outlined.Email: ImageVector + get() = EpistemeOutlinedIcons.email + +val Icons.Outlined.FavoriteBorder: ImageVector + get() = EpistemeOutlinedIcons.favoriteBorder + +val Icons.Outlined.Feedback: ImageVector + get() = EpistemeOutlinedIcons.feedback + +val Icons.Outlined.FileOpen: ImageVector + get() = EpistemeOutlinedIcons.fileOpen + +val Icons.Outlined.Gavel: ImageVector + get() = EpistemeOutlinedIcons.gavel + +val Icons.Outlined.Policy: ImageVector + get() = EpistemeOutlinedIcons.policy + +private object EpistemeOutlinedIcons { + val accountCircle: ImageVector by lazy { + materialIcon( + name = "AccountCircle", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520Q421,520 380.5,479.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM580,784.5Q627,769 666,740Q627,711 580,695.5Q533,680 480,680Q427,680 380,695.5Q333,711 294,740Q333,769 380,784.5Q427,800 480,800Q533,800 580,784.5ZM523,423Q540,406 540,380Q540,354 523,337Q506,320 480,320Q454,320 437,337Q420,354 420,380Q420,406 437,423Q454,440 480,440Q506,440 523,423ZM480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380ZM480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Z""" + ) + ) + } + + val email: ImageVector by lazy { + materialIcon( + name = "Email", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM480,520L160,320L160,720Q160,720 160,720Q160,720 160,720L800,720Q800,720 800,720Q800,720 800,720L800,320L480,520ZM480,440L800,240L160,240L480,440ZM160,320L160,240L160,240L160,320L160,720Q160,720 160,720Q160,720 160,720L160,720Q160,720 160,720Q160,720 160,720L160,320Z""" + ) + ) + } + + val favoriteBorder: ImageVector by lazy { + materialIcon( + name = "FavoriteBorder", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,840L422,788Q321,697 255,631Q189,565 150,512.5Q111,460 95.5,416Q80,372 80,326Q80,232 143,169Q206,106 300,106Q352,106 399,128Q446,150 480,190Q514,150 561,128Q608,106 660,106Q754,106 817,169Q880,232 880,326Q880,372 864.5,416Q849,460 810,512.5Q771,565 705,631Q639,697 538,788L480,840ZM480,732Q576,646 638,584.5Q700,523 736,477.5Q772,432 786,396.5Q800,361 800,326Q800,266 760,226Q720,186 660,186Q613,186 573,212.5Q533,239 518,280L518,280L442,280L442,280Q427,239 387,212.5Q347,186 300,186Q240,186 200,226Q160,266 160,326Q160,361 174,396.5Q188,432 224,477.5Q260,523 322,584.5Q384,646 480,732ZM480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459L480,459L480,459L480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Z""" + ) + ) + } + + val feedback: ImageVector by lazy { + materialIcon( + name = "Feedback", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,600Q497,600 508.5,588.5Q520,577 520,560Q520,543 508.5,531.5Q497,520 480,520Q463,520 451.5,531.5Q440,543 440,560Q440,577 451.5,588.5Q463,600 480,600ZM440,440L520,440L520,200L440,200L440,440ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z""" + ) + ) + } + + val fileOpen: ImageVector by lazy { + materialIcon( + name = "FileOpen", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,560L720,560L720,360L520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L600,800L600,880L240,880ZM878,895L760,777L760,866L680,866L680,640L906,640L906,720L816,720L934,838L878,895ZM240,800L240,560L240,560L240,360L240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800Z""" + ) + ) + } + + val gavel: ImageVector by lazy { + materialIcon( + name = "Gavel", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M160,840L160,760L640,760L640,840L160,840ZM386,646L160,420L244,334L472,560L386,646ZM640,392L414,164L500,80L726,306L640,392ZM824,800L302,278L358,222L880,744L824,800Z""" + ) + ) + } + + val policy: ImageVector by lazy { + materialIcon( + name = "Policy", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,529 771,607.5Q742,686 688,746L560,618Q542,629 521.5,634.5Q501,640 480,640Q414,640 367,593Q320,546 320,480Q320,414 367,367Q414,320 480,320Q546,320 593,367Q640,414 640,480Q640,502 634.5,522.5Q629,543 618,562L678,622Q698,581 709,536Q720,491 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796Q506,788 529.5,775.5Q553,763 576,746L632,802Q599,829 560.5,849Q522,869 480,880ZM536.5,536.5Q560,513 560,480Q560,447 536.5,423.5Q513,400 480,400Q447,400 423.5,423.5Q400,447 400,480Q400,513 423.5,536.5Q447,560 480,560Q513,560 536.5,536.5ZM488,483L488,483Q488,483 488,483Q488,483 488,483L488,483Q488,483 488,483Q488,483 488,483L488,483L488,483L488,483L488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Z""" + ) + ) + } + +} + +private fun materialIcon( + name: String, + defaultWidth: Float, + defaultHeight: Float, + viewportWidth: Float, + viewportHeight: Float, + autoMirror: Boolean, + paths: List +): ImageVector { + return ImageVector.Builder( + name = name, + defaultWidth = defaultWidth.dp, + defaultHeight = defaultHeight.dp, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + autoMirror = autoMirror + ).apply { + paths.forEach { pathData -> + addPath( + pathData = PathParser().parsePathString(pathData).toNodes(), + fill = SolidColor(Color.Black) + ) + } + }.build() +} + diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/CustomFontModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/CustomFontModels.kt deleted file mode 100644 index 95d1551..0000000 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/CustomFontModels.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.aryan.reader.shared - -data class CustomFontItem( - val id: String, - val displayName: String, - val fileName: String, - val fileExtension: String, - val path: String, - val timestamp: Long, - val isDeleted: Boolean = false -) - diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppShell.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppShell.kt deleted file mode 100644 index a4190af..0000000 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppShell.kt +++ /dev/null @@ -1,580 +0,0 @@ -package com.aryan.reader.shared.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.LibraryBooks -import androidx.compose.material.icons.automirrored.filled.MenuBook -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Cloud -import androidx.compose.material.icons.filled.CreateNewFolder -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.Feedback -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.ImportExport -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.Sync -import androidx.compose.material.icons.filled.TextFields -import androidx.compose.material3.Button -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationRail -import androidx.compose.material3.NavigationRailItem -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -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.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.aryan.reader.shared.AppContrastOption -import com.aryan.reader.shared.AppThemeMode -import com.aryan.reader.shared.CustomAppTheme -import com.aryan.reader.shared.SharedFeaturePolicy - -enum class SharedAppTab { - HOME, - LIBRARY, - SHELVES, - CATALOGS, - READER, - SETTINGS, - PRO, - CUSTOM_FONTS, - SUPPORT, - FEEDBACK, - ABOUT -} - -@Composable -fun SharedAppShell( - selectedTab: SharedAppTab, - snackbarHostState: SnackbarHostState, - appThemeMode: AppThemeMode = AppThemeMode.SYSTEM, - appContrastOption: AppContrastOption = AppContrastOption.STANDARD, - appTextDimFactorLight: Float = 1.0f, - appTextDimFactorDark: Float = 1.0f, - appSeedColor: Color? = null, - customAppThemes: List = emptyList(), - isTabsEnabled: Boolean = true, - featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard, - onTabSelected: (SharedAppTab) -> Unit, - onImportFiles: () -> Unit, - onImportFolder: () -> Unit = {}, - onSyncRequested: () -> Unit, - onFolderMetadataSyncRequested: (() -> Unit)? = null, - onAppThemeModeChange: (AppThemeMode) -> Unit = {}, - onAppContrastOptionChange: (AppContrastOption) -> Unit = {}, - onAppTextDimFactorLightChange: (Float) -> Unit = {}, - onAppTextDimFactorDarkChange: (Float) -> Unit = {}, - onAppSeedColorChange: (Color?) -> Unit = {}, - onCustomAppThemeAdded: (CustomAppTheme) -> Unit = {}, - onCustomAppThemeDeleted: (String) -> Unit = {}, - onTabsEnabledChange: (Boolean) -> Unit = {}, - onAiSettingsRequested: (() -> Unit)? = null, - content: @Composable (SharedAppTab) -> Unit -) { - val aiSettingsAvailable = onAiSettingsRequested != null && featurePolicy.aiAndCloud - val shellModel = remember(selectedTab, aiSettingsAvailable, featurePolicy) { - sharedAppShellModel( - selectedTab = selectedTab, - aiSettingsAvailable = aiSettingsAvailable, - featurePolicy = featurePolicy - ) - } - var showToolsPanel by remember { mutableStateOf(false) } - var showAppThemeSettings by remember { mutableStateOf(false) } - - Scaffold( - containerColor = MaterialTheme.colorScheme.background, - snackbarHost = { SnackbarHost(snackbarHostState) } - ) { padding -> - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .padding(padding) - ) { - val useSidebar = maxWidth >= 900.dp - Row(Modifier.fillMaxSize()) { - if (shellModel.showPrimaryNavigation) { - if (useSidebar) { - SharedAppSidebar( - selectedTab = shellModel.selectedPrimaryTab, - primaryTabs = shellModel.primaryTabs, - onTabSelected = onTabSelected, - onToolsClick = { showToolsPanel = true } - ) - } else { - SharedAppCompactRail( - selectedTab = shellModel.selectedPrimaryTab, - primaryTabs = shellModel.primaryTabs, - onTabSelected = onTabSelected, - onToolsClick = { showToolsPanel = true } - ) - } - } - - Box( - Modifier - .weight(1f) - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - ) { - content(selectedTab) - } - } - - if (showToolsPanel) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.24f)) - .clickable { showToolsPanel = false } - ) - SharedToolsPanel( - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight() - .widthIn(max = 390.dp), - isTabsEnabled = isTabsEnabled, - toolActions = shellModel.toolActions, - onClose = { showToolsPanel = false }, - onImportFiles = { - showToolsPanel = false - onImportFiles() - }, - onImportFolder = { - showToolsPanel = false - onImportFolder() - }, - onSyncRequested = { - showToolsPanel = false - onSyncRequested() - }, - onFolderMetadataSyncRequested = onFolderMetadataSyncRequested?.let { syncMetadata -> - { - showToolsPanel = false - syncMetadata() - } - }, - onAppThemeRequested = { - showToolsPanel = false - showAppThemeSettings = true - }, - onAiSettingsRequested = { - showToolsPanel = false - onAiSettingsRequested?.invoke() - }, - onOpenTab = { tab -> - showToolsPanel = false - onTabSelected(tab) - }, - onTabsEnabledChange = onTabsEnabledChange - ) - } - } - } - - if (showAppThemeSettings) { - SharedAppThemeSettingsDialog( - appThemeMode = appThemeMode, - appContrastOption = appContrastOption, - appTextDimFactorLight = appTextDimFactorLight, - appTextDimFactorDark = appTextDimFactorDark, - appSeedColor = appSeedColor, - customAppThemes = customAppThemes, - onThemeModeChanged = onAppThemeModeChange, - onContrastOptionChanged = onAppContrastOptionChange, - onTextDimFactorLightChanged = onAppTextDimFactorLightChange, - onTextDimFactorDarkChanged = onAppTextDimFactorDarkChange, - onSeedColorChanged = onAppSeedColorChange, - onCustomThemeAdded = onCustomAppThemeAdded, - onCustomThemeDeleted = onCustomAppThemeDeleted, - onDismiss = { showAppThemeSettings = false } - ) - } -} - -@Composable -private fun SharedAppSidebar( - selectedTab: SharedAppTab, - primaryTabs: List, - onTabSelected: (SharedAppTab) -> Unit, - onToolsClick: () -> Unit -) { - Surface( - modifier = Modifier - .width(SharedUiTokens.sidebarWidth) - .fillMaxHeight(), - color = MaterialTheme.colorScheme.surfaceContainerLow, - tonalElevation = 0.dp - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(SharedUiTokens.compactGap) - ) { - Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) { - Text(readerString("app_name", "Episteme"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - Text(readerString("desktop_library_and_reader", "Library and reader"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - primaryTabs.forEach { tab -> - SharedSidebarNavItem( - tab = tab, - selected = selectedTab == tab, - onClick = { onTabSelected(tab) } - ) - } - Spacer(Modifier.weight(1f)) - HorizontalDivider() - SharedSidebarButton( - label = readerString("desktop_tools", "Tools"), - icon = Icons.Default.Settings, - onClick = onToolsClick - ) - } - } -} - -@Composable -private fun SharedAppCompactRail( - selectedTab: SharedAppTab, - primaryTabs: List, - onTabSelected: (SharedAppTab) -> Unit, - onToolsClick: () -> Unit -) { - NavigationRail(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) { - primaryTabs.forEach { tab -> - NavigationRailItem( - selected = selectedTab == tab, - onClick = { onTabSelected(tab) }, - icon = { Icon(tab.icon, contentDescription = null) }, - label = { Text(tab.localizedLabel()) } - ) - } - Spacer(Modifier.weight(1f)) - IconButton(onClick = onToolsClick) { - Icon(Icons.Default.Settings, contentDescription = readerString("desktop_tools", "Tools")) - } - } -} - -@Composable -private fun SharedSidebarNavItem( - tab: SharedAppTab, - selected: Boolean, - onClick: () -> Unit -) { - val containerColor = if (selected) { - MaterialTheme.colorScheme.secondaryContainer - } else { - Color.Transparent - } - val contentColor = if (selected) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - color = containerColor, - contentColor = contentColor, - onClick = onClick - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon(tab.icon, contentDescription = null, modifier = Modifier.size(21.dp)) - Text(tab.localizedLabel(), style = MaterialTheme.typography.bodyMedium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal) - } - } -} - -@Composable -private fun SharedSidebarButton( - label: String, - icon: ImageVector, - onClick: () -> Unit -) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - color = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - onClick = onClick - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon(icon, contentDescription = null, modifier = Modifier.size(21.dp)) - Text(label, style = MaterialTheme.typography.bodyMedium) - } - } -} - -@Composable -private fun SharedToolsPanel( - modifier: Modifier, - isTabsEnabled: Boolean, - toolActions: List, - onClose: () -> Unit, - onImportFiles: () -> Unit, - onImportFolder: () -> Unit, - onSyncRequested: () -> Unit, - onFolderMetadataSyncRequested: (() -> Unit)?, - onAppThemeRequested: () -> Unit, - onAiSettingsRequested: () -> Unit, - onOpenTab: (SharedAppTab) -> Unit, - onTabsEnabledChange: (Boolean) -> Unit -) { - val hasWorkspaceActions = SharedAppToolAction.SETTINGS in toolActions || - SharedAppToolAction.APP_THEME in toolActions || - SharedAppToolAction.TABS_TOGGLE in toolActions - val hasLibraryActions = SharedAppToolAction.IMPORT_FILES in toolActions || - SharedAppToolAction.IMPORT_FOLDER in toolActions || - SharedAppToolAction.SYNC in toolActions - val hasSettingsActions = SharedAppToolAction.PRO in toolActions || - SharedAppToolAction.AI_SETTINGS in toolActions || - SharedAppToolAction.CUSTOM_FONTS in toolActions - val hasProjectActions = SharedAppToolAction.HELP_FEEDBACK in toolActions || - SharedAppToolAction.SUPPORT in toolActions || - SharedAppToolAction.ABOUT in toolActions - - Surface( - modifier = modifier, - color = MaterialTheme.colorScheme.surface, - tonalElevation = 8.dp, - shadowElevation = 8.dp - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(readerString("desktop_tools", "Tools"), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(readerString("desktop_tools_desc", "Import, sync, and app settings"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - IconButton(onClick = onClose) { - Icon(Icons.Default.Close, contentDescription = readerString("desktop_close_tools", "Close tools")) - } - } - - if (hasWorkspaceActions) { - SharedToolsSection(readerString("desktop_workspace", "Workspace")) { - if (SharedAppToolAction.SETTINGS in toolActions) { - SharedToolRow(Icons.Default.Settings, readerString("desktop_settings_hub", "Settings hub")) { onOpenTab(SharedAppTab.SETTINGS) } - } - if (SharedAppToolAction.APP_THEME in toolActions) { - SharedToolRow( - icon = Icons.Default.Palette, - title = readerString("app_theme_title", "App theme"), - onClick = onAppThemeRequested - ) - } - if (SharedAppToolAction.TABS_TOGGLE in toolActions) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 2.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text(readerString("desktop_open_readers", "Open readers"), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - Text( - if (isTabsEnabled) readerString("content_desc_enabled", "Enabled") else readerString("desktop_disabled", "Disabled"), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = isTabsEnabled, - onCheckedChange = onTabsEnabledChange - ) - } - } - } - } - - if (hasLibraryActions) { - SharedToolsSection(readerString("library_title", "Library")) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - if (SharedAppToolAction.IMPORT_FILES in toolActions) { - Button(onClick = onImportFiles, modifier = Modifier.weight(1f)) { - Icon(Icons.Default.ImportExport, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text(readerString("desktop_import_files", "Import files")) - } - } - if (SharedAppToolAction.IMPORT_FOLDER in toolActions) { - OutlinedButton(onClick = onImportFolder, modifier = Modifier.weight(1f)) { - Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text(readerString("fab_add_folder", "Add folder")) - } - } - } - if (SharedAppToolAction.SYNC in toolActions) { - if (onFolderMetadataSyncRequested == null) { - FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) { - Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text(readerString("desktop_sync_folders", "Sync folders")) - } - } else { - SharedToolRow(Icons.Default.Sync, readerString("desktop_sync_metadata", "Sync metadata"), onFolderMetadataSyncRequested) - SharedToolRow(Icons.Default.Search, readerString("desktop_full_scan", "Full scan")) { - onSyncRequested() - } - } - } - } - } - - if (hasSettingsActions) { - SharedToolsSection(readerString("settings", "Settings")) { - if (SharedAppToolAction.PRO in toolActions) { - SharedToolRow(Icons.Default.Star, readerString("desktop_pro_and_credits", "Pro and credits")) { onOpenTab(SharedAppTab.PRO) } - } - if (SharedAppToolAction.AI_SETTINGS in toolActions) { - SharedToolRow(Icons.Default.Settings, readerString("ai_settings_title", "AI keys and models"), onAiSettingsRequested) - } - if (SharedAppToolAction.CUSTOM_FONTS in toolActions) { - SharedToolRow(Icons.Default.TextFields, readerString("custom_fonts", "Custom fonts")) { onOpenTab(SharedAppTab.CUSTOM_FONTS) } - } - } - } - - if (hasProjectActions) { - SharedToolsSection(readerString("desktop_project", "Project")) { - if (SharedAppToolAction.HELP_FEEDBACK in toolActions) { - SharedToolRow(Icons.Default.Feedback, readerString("drawer_help_feedback", "Help & feedback")) { onOpenTab(SharedAppTab.FEEDBACK) } - } - if (SharedAppToolAction.SUPPORT in toolActions) { - SharedToolRow(Icons.Default.Favorite, readerString("drawer_support_project", "Support project")) { onOpenTab(SharedAppTab.SUPPORT) } - } - if (SharedAppToolAction.ABOUT in toolActions) { - SharedToolRow(Icons.Default.Info, readerString("about_title", "About Episteme")) { onOpenTab(SharedAppTab.ABOUT) } - } - } - } - - Spacer(Modifier.height(12.dp)) - } - } -} - -@Composable -private fun SharedToolsSection( - title: String, - content: @Composable ColumnScope.() -> Unit -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) - content() - } -} - -@Composable -private fun SharedToolRow( - icon: ImageVector, - title: String, - onClick: () -> Unit -) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, - onClick = onClick - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary) - Text(title, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } -} - -@Composable -private fun SharedAppTab.localizedLabel(): String { - return when (this) { - SharedAppTab.HOME -> readerString("nav_home", "Home") - SharedAppTab.LIBRARY -> readerString("library_title", "Library") - SharedAppTab.SHELVES -> readerString("tab_shelves", "Shelves") - SharedAppTab.CATALOGS -> readerString("opds_stream", "OPDS") - SharedAppTab.READER -> readerString("desktop_reader", "Reader") - SharedAppTab.SETTINGS -> readerString("settings", "Settings") - SharedAppTab.PRO -> readerString("desktop_pro", "Pro") - SharedAppTab.CUSTOM_FONTS -> readerString("custom_fonts", "Custom fonts") - SharedAppTab.SUPPORT -> readerString("desktop_support", "Support") - SharedAppTab.FEEDBACK -> readerString("desktop_feedback", "Feedback") - SharedAppTab.ABOUT -> readerString("desktop_about", "About") - } -} - -private val SharedAppTab.icon: ImageVector - get() = when (this) { - SharedAppTab.HOME -> Icons.Default.Home - SharedAppTab.LIBRARY -> Icons.AutoMirrored.Filled.LibraryBooks - SharedAppTab.SHELVES -> Icons.Default.Folder - SharedAppTab.CATALOGS -> Icons.Default.Cloud - SharedAppTab.READER -> Icons.AutoMirrored.Filled.MenuBook - SharedAppTab.SETTINGS -> Icons.Default.Settings - SharedAppTab.PRO -> Icons.Default.Star - SharedAppTab.CUSTOM_FONTS -> Icons.Default.TextFields - SharedAppTab.SUPPORT -> Icons.Default.Favorite - SharedAppTab.FEEDBACK -> Icons.Default.Feedback - SharedAppTab.ABOUT -> Icons.Default.Info - } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt deleted file mode 100644 index 62b9a7c..0000000 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt +++ /dev/null @@ -1,2438 +0,0 @@ -package com.aryan.reader.shared.ui - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.gestures.detectTapGestures -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.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.rememberScrollState -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -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.draw.clip -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.isSpecified -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.PathParser -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInRoot -import androidx.compose.ui.platform.LocalViewConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.LineBreak -import androidx.compose.ui.text.style.LineHeightStyle -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.isSpecified -import androidx.compose.ui.unit.sp -import com.aryan.reader.paginatedreader.CssStyle -import com.aryan.reader.paginatedreader.SemanticBlock -import com.aryan.reader.paginatedreader.SemanticFlexContainer -import com.aryan.reader.paginatedreader.SemanticHeader -import com.aryan.reader.paginatedreader.SemanticImage -import com.aryan.reader.paginatedreader.SemanticList -import com.aryan.reader.paginatedreader.SemanticListItem -import com.aryan.reader.paginatedreader.SemanticMath -import com.aryan.reader.paginatedreader.SemanticParagraph -import com.aryan.reader.paginatedreader.SemanticSpacer -import com.aryan.reader.paginatedreader.SemanticTable -import com.aryan.reader.paginatedreader.SemanticTextBlock -import com.aryan.reader.paginatedreader.SemanticWrappingBlock -import com.aryan.reader.shared.HighlightColor -import com.aryan.reader.shared.ReaderLocator -import com.aryan.reader.shared.UserHighlight -import com.aryan.reader.shared.reader.ReaderPage -import com.aryan.reader.shared.reader.ReaderSettings -import com.aryan.reader.shared.reader.SharedReaderTextAlign -import com.aryan.reader.shared.reader.logSharedReaderDiagnostic -import kotlin.math.roundToInt - -enum class SharedNativeReaderSelectionAction { - DEFINE, - SEARCH, - SPEAK -} - -data class SharedNativeReaderLinkClick( - val href: String, - val chapterIndex: Int?, - val text: String? -) - -internal data class SharedNativeReaderTextSelection( - val chapterIndex: Int, - val pageIndex: Int, - val startOffset: Int, - val endOffset: Int, - val text: String, - val startPageIndex: Int = pageIndex, - val endPageIndex: Int = pageIndex, - val startBlockIndex: Int = -1, - val endBlockIndex: Int = -1, - val startBlockCharOffset: Int = startOffset, - val endBlockCharOffset: Int = endOffset, - val startLocalOffset: Int = 0, - val endLocalOffset: Int = endOffset - startOffset, - val startBaseCfi: String? = null, - val endBaseCfi: String? = null, - val rect: Rect = Rect.Zero, - val textPerBlock: Map = emptyMap() -) { - val cfi: String - get() = if (!startBaseCfi.isNullOrBlank() && !endBaseCfi.isNullOrBlank()) { - "${startBaseCfi}:${startLocalOffset}|${endBaseCfi}:${endLocalOffset}" - } else { - "desktop:$chapterIndex:$startOffset:$endOffset" - } -} - -private data class SharedNativeSelectionBlockKey( - val pageIndex: Int, - val blockIndex: Int, - val blockCharOffset: Int -) { - val stableKey: String get() = "$pageIndex:$blockIndex:$blockCharOffset" -} - -private data class SharedNativeTextBlockDescriptor( - val chapterIndex: Int, - val pageIndex: Int, - val blockIndex: Int, - val blockCharOffset: Int, - val baseCfi: String?, - val textStartOffset: Int, - val text: String -) { - val key: SharedNativeSelectionBlockKey - get() = SharedNativeSelectionBlockKey(pageIndex, blockIndex, blockCharOffset) -} - -private data class SharedNativeTextLayoutInfo( - val descriptor: SharedNativeTextBlockDescriptor, - val layout: TextLayoutResult, - val coordinates: LayoutCoordinates -) - -private data class SharedNativeTextPosition( - val descriptor: SharedNativeTextBlockDescriptor, - val localOffset: Int -) - -private enum class SharedNativeSelectionHandle { - START, - END -} - -private object SharedNativeSelectionVectorIcons { - val Copy: ImageVector = vector( - name = "SharedNativeSelectionCopy", - pathData = "M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640L720,160L360,160L360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800L640,800L640,880L200,880Z" - ) - val Define: ImageVector = vector( - name = "SharedNativeSelectionDefine", - pathData = "M480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664L840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268L120,664Q155,652 189.5,646Q224,640 260,640Q307,640 351.5,650.5Q396,661 440,682Z" - ) - val Speak: ImageVector = vector( - name = "SharedNativeSelectionSpeak", - pathData = "M560,828L560,746Q653,719 706.5,642Q760,565 760,466Q760,367 706.5,290Q653,213 560,186L560,104Q687,133 763.5,234Q840,335 840,466Q840,597 763.5,698Q687,799 560,828ZM120,600L120,360L280,360L480,160L480,800L280,600L120,600ZM560,640L560,292Q612,317 646,364.5Q680,412 680,466Q680,520 646,567.5Q612,615 560,640Z" - ) - val Search: ImageVector = vector( - name = "SharedNativeSelectionSearch", - pathData = "M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z" - ) - val Clear: ImageVector = vector( - name = "SharedNativeSelectionClear", - pathData = "M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z" - ) - val Teardrop: ImageVector = vector( - name = "SharedNativeSelectionTeardrop", - pathData = "M480,860Q347,860 253.5,768Q160,676 160,544Q160,481 184.5,423.5Q209,366 254,322L480,100L706,322Q751,366 775.5,423.5Q800,481 800,544Q800,676 706.5,768Q613,860 480,860Z" - ) - - private fun vector(name: String, pathData: String): ImageVector { - return ImageVector.Builder( - name = name, - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f - ).apply { - addPath( - pathData = PathParser().parsePathString(pathData).toNodes(), - fill = SolidColor(Color.Black) - ) - }.build() - } -} - -@Composable -fun SharedNativePaginatedReader( - renderPlan: ReaderContentRenderPlan.NativePaginatedPages, - readerFontFamily: FontFamily, - searchHighlight: Color, - onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, - modifier: Modifier = Modifier, - enabledSelectionActions: Set = emptySet(), - onCopyText: (String) -> Unit = {}, - onSelectionAction: (SharedNativeReaderSelectionAction, String) -> Unit = { _, _ -> }, - onHighlightCreated: (UserHighlight) -> Unit = {}, - onHighlightSelected: (String) -> Unit = {}, - onLinkClicked: (SharedNativeReaderLinkClick) -> Unit = {}, - imageContent: (@Composable (SemanticImage, Modifier) -> Unit)? = null -) { - val visiblePages = renderPlan.visiblePages - val firstPage = visiblePages.firstOrNull() - var activeSelection by remember(renderPlan.navigationTarget.requestId) { - mutableStateOf(null) - } - var selectionGestureActive by remember(renderPlan.navigationTarget.requestId) { - mutableStateOf(false) - } - var selectionHandleDragging by remember(renderPlan.navigationTarget.requestId) { - mutableStateOf(false) - } - fun updateActiveSelection(selection: SharedNativeReaderTextSelection?) { - activeSelection = selection - if (selection == null) { - selectionGestureActive = false - selectionHandleDragging = false - } - } - val visiblePageIndices = remember(visiblePages) { visiblePages.map { it.pageIndex } } - val selectionLayouts = remember(renderPlan.navigationTarget.requestId, visiblePageIndices) { - mutableStateMapOf() - } - var readerCoordinates by remember(renderPlan.navigationTarget.requestId) { - mutableStateOf(null) - } - val density = LocalDensity.current - LaunchedEffect(visiblePageIndices) { - val selection = activeSelection - if (selection != null && selection.pageIndex !in visiblePageIndices) { - updateActiveSelection(null) - } - } - LaunchedEffect(firstPage?.pageIndex, renderPlan.navigationTarget.requestId) { - firstPage?.let { page -> - onVisiblePageChanged( - page.pageIndex, - renderPlan.navigationTarget.locator ?: page.toNativeReaderLocator() - ) - } - } - - if (visiblePages.isEmpty()) { - Box(modifier = modifier, contentAlignment = Alignment.Center) { - Text(readerString("desktop_no_page_content", "No page content"), color = renderPlan.foreground.copy(alpha = 0.68f)) - } - return - } - - val selectionHighlight = MaterialTheme.colorScheme.primary.copy(alpha = 0.28f) - Box( - modifier = modifier.onGloballyPositioned { readerCoordinates = it } - ) { - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background(renderPlan.background), - contentAlignment = Alignment.Center - ) { - val pageGap = 28.dp - val horizontalMargin = renderPlan.settings.resolvedHorizontalMargin.dp - val configuredContentWidth = renderPlan.settings.pageWidth.dp - val pageOuterWidth = if (visiblePages.size > 1) { - val availablePageOuterWidth = ((maxWidth - pageGap).coerceAtLeast(1.dp)) / 2f - val availableContentWidth = (availablePageOuterWidth - (horizontalMargin * 2f)).coerceAtLeast(1.dp) - minOf(availableContentWidth, configuredContentWidth) + (horizontalMargin * 2f) - } else { - val availableContentWidth = (maxWidth - (horizontalMargin * 2f)).coerceAtLeast(1.dp) - minOf(availableContentWidth, configuredContentWidth) + (horizontalMargin * 2f) - } - Row( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(pageGap, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically - ) { - visiblePages.forEach { page -> - SharedNativePaginatedPage( - page = page, - renderPlan = renderPlan, - readerFontFamily = readerFontFamily, - searchHighlight = searchHighlight, - selectionHighlight = selectionHighlight, - activeSelection = activeSelection, - onSelectionChange = ::updateActiveSelection, - onSelectionGestureActiveChange = { selectionGestureActive = it }, - onHighlightSelected = onHighlightSelected, - onLinkClicked = onLinkClicked, - selectionLayouts = selectionLayouts, - imageContent = imageContent, - modifier = Modifier - .width(pageOuterWidth) - .fillMaxHeight() - ) - } - } - } - activeSelection?.let { selection -> - arrayOf(SharedNativeSelectionHandle.START, SharedNativeSelectionHandle.END).forEach { handle -> - SharedNativeSelectionHandleView( - selection = selection, - handle = handle, - selectionLayouts = selectionLayouts.values, - readerCoordinates = readerCoordinates, - onDragActiveChange = { selectionHandleDragging = it }, - onDrag = { windowPosition -> - val currentSelection = activeSelection - if (currentSelection != null) { - sharedNativeSelectionWithHandleMoved( - selection = currentSelection, - handle = handle, - windowPosition = windowPosition, - layouts = selectionLayouts.values - )?.let(::updateActiveSelection) - } - }, - modifier = Modifier.align(Alignment.TopStart) - ) - } - if (!selectionGestureActive && !selectionHandleDragging) { - val highlightPalette = renderPlan.highlightPalette.sanitized().colors - SharedNativeSelectionMenu( - selection = selection, - highlightPalette = highlightPalette, - enabledSelectionActions = enabledSelectionActions, - background = renderPlan.background, - foreground = renderPlan.foreground, - onCopy = { - onCopyText(selection.text) - updateActiveSelection(null) - }, - onSelectionAction = { action -> - onSelectionAction(action, selection.text) - updateActiveSelection(null) - }, - onHighlight = { color -> - onHighlightCreated(sharedNativeReaderHighlightForSelection(selection, color)) - updateActiveSelection(null) - }, - onDismiss = { updateActiveSelection(null) }, - modifier = Modifier - .align(Alignment.TopStart) - .offset { - sharedNativeSelectionMenuOffset( - selection = selection, - readerCoordinates = readerCoordinates, - density = density, - highlightPaletteSize = highlightPalette.size, - actionCount = enabledSelectionActions.size + 2 - ) - } - ) - } - } - } -} - -@Composable -private fun SharedNativePaginatedPage( - page: ReaderPage, - renderPlan: ReaderContentRenderPlan.NativePaginatedPages, - readerFontFamily: FontFamily, - searchHighlight: Color, - selectionHighlight: Color, - activeSelection: SharedNativeReaderTextSelection?, - onSelectionChange: (SharedNativeReaderTextSelection?) -> Unit, - onSelectionGestureActiveChange: (Boolean) -> Unit, - onHighlightSelected: (String) -> Unit, - onLinkClicked: (SharedNativeReaderLinkClick) -> Unit, - selectionLayouts: MutableMap, - imageContent: (@Composable (SemanticImage, Modifier) -> Unit)?, - modifier: Modifier = Modifier -) { - val settings = renderPlan.settings - val fallbackTextAlign = settings.textAlign.toComposeTextAlign() - val visibleHighlights = renderPlan.highlights.visibleInPage(page) - val blocks = page.semanticBlocks - var contentFit by remember(page.pageIndex, blocks) { mutableStateOf(null) } - val blockLayouts = remember(page.pageIndex, blocks) { mutableStateMapOf() } - var layoutVersion by remember(page.pageIndex, blocks) { mutableStateOf(0) } - var lastPageFitLogSignature by remember(page.pageIndex, blocks) { mutableStateOf(null) } - - LaunchedEffect( - contentFit, - layoutVersion, - blocks.size, - page.pageIndex, - page.chapterIndex, - settings.fontSize, - settings.lineSpacing, - settings.paragraphSpacing - ) { - val content = contentFit ?: return@LaunchedEffect - if (blocks.isEmpty() || blockLayouts.size < blocks.size) return@LaunchedEffect - val contentTopPx = content.rootTopPx - val contentHeightPx = content.heightPx - val orderedFits = blocks.indices.mapNotNull { index -> blockLayouts[index] } - if (orderedFits.size < blocks.size) return@LaunchedEffect - - val usedPx = orderedFits.maxOfOrNull { fit -> - fit.relativeBottomPx(contentTopPx) - } ?: return@LaunchedEffect - val remainingPx = contentHeightPx - usedPx - if (remainingPx >= 0) return@LaunchedEffect - - val signature = buildString { - append(page.pageIndex) - append(':') - append(contentHeightPx) - append(':') - append(usedPx) - orderedFits.forEach { fit -> - append(':') - append(fit.index) - append(',') - append(fit.relativeTopPx(contentTopPx)) - append(',') - append(fit.heightPx) - } - } - if (signature != lastPageFitLogSignature) { - lastPageFitLogSignature = signature - logSharedReaderDiagnostic(EpubPageFitLogTag) { - "page_fit layer=rendered_overflow page=${page.pageIndex + 1} chapter=${page.chapterIndex} " + - "usedPx=$usedPx contentPx=$contentHeightPx remainingPx=$remainingPx " + - "overflowPx=${(-remainingPx).coerceAtLeast(0)} blocks=${blocks.size} " + - "range=${page.startOffset}..${page.endOffset} textChars=${page.text.length} " + - "tail=\"${orderedFits.renderedPageFitTail(contentTopPx)}\"" - } - } - } - - Surface( - modifier = modifier, - shape = RoundedCornerShape(4.dp), - color = renderPlan.background, - contentColor = renderPlan.foreground, - tonalElevation = 0.dp, - shadowElevation = 1.dp, - border = BorderStroke(1.dp, renderPlan.foreground.copy(alpha = 0.14f)) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding( - horizontal = settings.resolvedHorizontalMargin.dp, - vertical = settings.resolvedVerticalMargin.dp - ) - .onGloballyPositioned { coordinates -> - val nextFit = SharedNativeContentFit( - rootTopPx = coordinates.positionInRoot().y.roundToInt(), - heightPx = coordinates.size.height - ) - if (contentFit != nextFit) { - contentFit = nextFit - } - }, - verticalArrangement = Arrangement.Top - ) { - if (blocks.isEmpty()) { - SharedNativeInteractiveText( - text = page.text.toReaderAnnotatedString( - searchQuery = renderPlan.searchQuery, - searchHighlight = searchHighlight, - absoluteStartOffset = page.startOffset, - highlights = visibleHighlights, - activeSelection = activeSelection, - selectionHighlight = selectionHighlight - ), - page = page, - textBlock = SharedNativeTextBlockDescriptor( - chapterIndex = page.chapterIndex, - pageIndex = page.pageIndex, - blockIndex = -1, - blockCharOffset = page.startOffset, - baseCfi = null, - textStartOffset = page.startOffset, - text = page.text - ), - textStartOffset = page.startOffset, - color = renderPlan.foreground, - textAlign = fallbackTextAlign, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = settings.fontSize.sp, - lineHeight = (settings.fontSize * settings.lineSpacing).sp, - fontFamily = readerFontFamily - ).withAndroidPaginationTextMetrics(), - onSelectionChange = onSelectionChange, - onSelectionGestureActiveChange = onSelectionGestureActiveChange, - onHighlightSelected = onHighlightSelected, - onLinkClicked = onLinkClicked, - selectionLayouts = selectionLayouts, - fitLabel = SharedNativeTextFitLabel( - page = page, - blockIndex = -1, - kind = "plain", - sourceRange = "${page.startOffset}..${page.endOffset}", - textChars = page.text.length - ) - ) - } else { - SharedSemanticBlockStack( - blocks = blocks, - page = page, - foreground = renderPlan.foreground, - searchQuery = renderPlan.searchQuery, - searchHighlight = searchHighlight, - highlights = visibleHighlights, - activeSelection = activeSelection, - selectionHighlight = selectionHighlight, - fallbackTextAlign = fallbackTextAlign, - fallbackFontFamily = readerFontFamily, - settings = settings, - includeTrailingBottomMargin = false, - onSelectionChange = onSelectionChange, - onSelectionGestureActiveChange = onSelectionGestureActiveChange, - onHighlightSelected = onHighlightSelected, - onLinkClicked = onLinkClicked, - selectionLayouts = selectionLayouts, - imageContent = imageContent, - onBlockLaidOut = { fit -> - if (blockLayouts[fit.index] != fit) { - blockLayouts[fit.index] = fit - layoutVersion += 1 - } - } - ) - } - } - } -} - -@Composable -private fun SharedNativeSelectionMenu( - @Suppress("UNUSED_PARAMETER") - selection: SharedNativeReaderTextSelection, - highlightPalette: List, - enabledSelectionActions: Set, - background: Color, - foreground: Color, - onCopy: () -> Unit, - onSelectionAction: (SharedNativeReaderSelectionAction) -> Unit, - onHighlight: (HighlightColor) -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier -) { - val menuBackground = background.blendWith(foreground, foregroundWeight = 0.08f) - val borderColor = foreground.copy(alpha = 0.18f) - val hoverIconBackground = foreground.copy(alpha = 0.09f) - val iconColor = foreground.copy(alpha = 0.86f) - val actions = buildList { - add(SharedNativeSelectionMenuAction("Copy", SharedNativeSelectionVectorIcons.Copy, onCopy)) - if (SharedNativeReaderSelectionAction.DEFINE in enabledSelectionActions) { - add( - SharedNativeSelectionMenuAction( - "Define", - SharedNativeSelectionVectorIcons.Define, - { onSelectionAction(SharedNativeReaderSelectionAction.DEFINE) } - ) - ) - } - if (SharedNativeReaderSelectionAction.SPEAK in enabledSelectionActions) { - add( - SharedNativeSelectionMenuAction( - "Speak", - SharedNativeSelectionVectorIcons.Speak, - { onSelectionAction(SharedNativeReaderSelectionAction.SPEAK) } - ) - ) - } - if (SharedNativeReaderSelectionAction.SEARCH in enabledSelectionActions) { - add( - SharedNativeSelectionMenuAction( - "Search", - SharedNativeSelectionVectorIcons.Search, - { onSelectionAction(SharedNativeReaderSelectionAction.SEARCH) } - ) - ) - } - add(SharedNativeSelectionMenuAction("Clear", SharedNativeSelectionVectorIcons.Clear, onDismiss)) - } - Surface( - modifier = modifier, - shape = RoundedCornerShape(14.dp), - color = menuBackground, - contentColor = foreground, - tonalElevation = 0.dp, - shadowElevation = 18.dp, - border = BorderStroke(1.dp, borderColor) - ) { - Column( - modifier = Modifier - .width(IntrinsicSize.Max) - .widthIn(max = 280.dp) - .padding(bottom = 6.dp) - ) { - if (highlightPalette.isNotEmpty()) { - Row( - modifier = Modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically - ) { - highlightPalette.forEach { color -> - Box( - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .background(color.color) - .border( - width = 1.dp, - color = borderColor, - shape = CircleShape - ) - .clickable { onHighlight(color) } - ) - } - } - HorizontalDivider(color = foreground.copy(alpha = 0.12f)) - } - Column( - modifier = Modifier - .padding(start = 6.dp, top = 5.dp, end = 6.dp, bottom = 2.dp), - verticalArrangement = Arrangement.spacedBy(3.dp) - ) { - actions.chunked(3).forEach { rowActions -> - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - rowActions.forEach { action -> - SharedNativeSelectionIconButton( - action = action, - iconColor = iconColor, - iconBackground = hoverIconBackground, - foreground = foreground - ) - } - } - } - } - } - } -} - -private data class SharedNativeSelectionMenuAction( - val label: String, - val icon: ImageVector, - val onClick: () -> Unit -) - -@Composable -private fun SharedNativeSelectionIconButton( - action: SharedNativeSelectionMenuAction, - iconColor: Color, - iconBackground: Color, - foreground: Color -) { - Column( - modifier = Modifier - .width(70.dp) - .height(52.dp) - .clip(RoundedCornerShape(10.dp)) - .clickable { action.onClick() } - .padding(horizontal = 4.dp, vertical = 6.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterVertically) - ) { - Box( - modifier = Modifier - .size(22.dp) - .clip(CircleShape) - .background(iconBackground), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = action.icon, - contentDescription = action.label, - tint = iconColor, - modifier = Modifier.size(16.dp) - ) - } - Text( - text = action.label, - color = foreground, - style = MaterialTheme.typography.labelSmall.copy( - fontSize = 12.sp, - lineHeight = 12.sp, - fontWeight = FontWeight.SemiBold - ) - ) - } -} - -@Composable -private fun SharedNativeSelectionHandleView( - selection: SharedNativeReaderTextSelection, - handle: SharedNativeSelectionHandle, - selectionLayouts: Collection, - readerCoordinates: LayoutCoordinates?, - onDragActiveChange: (Boolean) -> Unit, - onDrag: (Offset) -> Unit, - modifier: Modifier = Modifier -) { - val density = LocalDensity.current - val handleOffset = sharedNativeSelectionHandleOffset( - selection = selection, - handle = handle, - layouts = selectionLayouts, - readerCoordinates = readerCoordinates, - density = density - ) ?: return - val handleColor = MaterialTheme.colorScheme.primary - var handleCoordinates by remember(handle) { mutableStateOf(null) } - Box( - modifier = modifier - .offset { handleOffset } - .size(28.dp) - .onGloballyPositioned { handleCoordinates = it } - .pointerInput(handle) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - down.consume() - onDragActiveChange(true) - try { - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == down.id } ?: break - if (!change.pressed) { - change.consume() - break - } - handleCoordinates - ?.takeIf { it.isAttached } - ?.let { coordinates -> onDrag(coordinates.localToWindow(change.position)) } - change.consume() - } - } finally { - onDragActiveChange(false) - } - } - }, - contentAlignment = Alignment.TopCenter - ) { - Icon( - imageVector = SharedNativeSelectionVectorIcons.Teardrop, - contentDescription = if (handle == SharedNativeSelectionHandle.START) { - "Adjust selection start" - } else { - "Adjust selection end" - }, - tint = handleColor, - modifier = Modifier - .size(22.dp) - .graphicsLayer { - rotationZ = if (handle == SharedNativeSelectionHandle.START) 28f else -28f - transformOrigin = TransformOrigin(0.5f, 0f) - } - ) - } -} - -@Composable -private fun SharedNativeInteractiveText( - text: AnnotatedString, - page: ReaderPage, - textBlock: SharedNativeTextBlockDescriptor, - textStartOffset: Int, - color: Color, - textAlign: TextAlign, - style: TextStyle, - onSelectionChange: (SharedNativeReaderTextSelection?) -> Unit, - onSelectionGestureActiveChange: (Boolean) -> Unit, - onHighlightSelected: (String) -> Unit, - onLinkClicked: (SharedNativeReaderLinkClick) -> Unit, - selectionLayouts: MutableMap, - modifier: Modifier = Modifier, - fitLabel: SharedNativeTextFitLabel? = null -) { - var textLayoutResult by remember(text) { mutableStateOf(null) } - var textCoordinates by remember(text) { mutableStateOf(null) } - var lastTextClipLogSignature by remember(text) { mutableStateOf(null) } - var dragAnchorOffset by remember(text) { mutableStateOf(null) } - val viewConfiguration = LocalViewConfiguration.current - val textBlockKey = textBlock.key.stableKey - DisposableEffect(textBlockKey, selectionLayouts) { - onDispose { - selectionLayouts.remove(textBlockKey) - } - } - LaunchedEffect(textLayoutResult, textCoordinates, textBlock, textBlockKey) { - val layout = textLayoutResult ?: return@LaunchedEffect - val coordinates = textCoordinates ?: return@LaunchedEffect - selectionLayouts[textBlockKey] = SharedNativeTextLayoutInfo( - descriptor = textBlock, - layout = layout, - coordinates = coordinates - ) - } - LaunchedEffect(textLayoutResult, textCoordinates, fitLabel) { - val layout = textLayoutResult ?: return@LaunchedEffect - val coordinates = textCoordinates ?: return@LaunchedEffect - val label = fitLabel ?: return@LaunchedEffect - val boxHeightPx = coordinates.size.height - val layoutHeightPx = layout.size.height - val lastLineBottomPx = if (layout.lineCount > 0) { - layout.getLineBottom(layout.lineCount - 1).roundToInt() - } else { - layoutHeightPx - } - val clipPx = maxOf(layoutHeightPx, lastLineBottomPx) - boxHeightPx - if (clipPx <= 1) return@LaunchedEffect - val signature = "${label.page.pageIndex}:${label.blockIndex}:$boxHeightPx:$layoutHeightPx:$lastLineBottomPx" - if (signature == lastTextClipLogSignature) return@LaunchedEffect - lastTextClipLogSignature = signature - logSharedReaderDiagnostic(EpubPageFitLogTag) { - "page_fit layer=text_clip page=${label.page.pageIndex + 1} chapter=${label.page.chapterIndex} " + - "block=${label.blockIndex} kind=${label.kind} boxPx=$boxHeightPx layoutPx=$layoutHeightPx " + - "lastLineBottomPx=$lastLineBottomPx clipPx=$clipPx lines=${layout.lineCount} " + - "range=${label.sourceRange} textChars=${label.textChars}" - } - } - Text( - text = text, - color = color, - modifier = modifier - .fillMaxWidth() - .onGloballyPositioned { textCoordinates = it } - .pointerInput(text) { - detectTapGestures( - onPress = { - onSelectionGestureActiveChange(true) - try { - tryAwaitRelease() - } finally { - onSelectionGestureActiveChange(false) - } - }, - onLongPress = { offset -> - val layout = textLayoutResult ?: return@detectTapGestures - val charOffset = layout.getOffsetForPosition(offset) - .coerceIn(0, text.text.length) - val boundary = layout.getWordBoundary(charOffset) - val range = sharedNativeReaderTrimmedWordRange( - text = text.text, - start = boundary.start, - end = boundary.end - ) ?: return@detectTapGestures - onSelectionChange( - sharedNativeReaderSelectionBetween( - start = SharedNativeTextPosition(textBlock, range.start), - end = SharedNativeTextPosition(textBlock, range.end), - layouts = selectionLayouts.values - ) - ) - }, - onTap = { offset -> - val layout = textLayoutResult ?: return@detectTapGestures - val charOffset = layout.getOffsetForPosition(offset) - .coerceIn(0, text.text.length) - text.stringAnnotationAt(ReaderNativeAnnotationUrl, charOffset)?.let { href -> - onSelectionChange(null) - onLinkClicked( - SharedNativeReaderLinkClick( - href = href, - chapterIndex = page.chapterIndex, - text = text.text - ) - ) - return@detectTapGestures - } - text.stringAnnotationAt(ReaderNativeAnnotationHighlight, charOffset)?.let { highlightId -> - onSelectionChange(null) - onHighlightSelected(highlightId) - return@detectTapGestures - } - onSelectionChange(null) - } - ) - } - .pointerInput(text) { - detectDragGesturesAfterLongPress( - onDragStart = { offset -> - onSelectionGestureActiveChange(true) - val layout = textLayoutResult - if (layout != null) { - val charOffset = layout.getOffsetForPosition(offset) - .coerceIn(0, text.text.length) - val boundary = layout.getWordBoundary(charOffset) - val range = sharedNativeReaderTrimmedWordRange( - text = text.text, - start = boundary.start, - end = boundary.end - ) - if (range != null) { - dragAnchorOffset = range.start - onSelectionChange( - sharedNativeReaderSelectionBetween( - start = SharedNativeTextPosition(textBlock, range.start), - end = SharedNativeTextPosition(textBlock, range.end), - layouts = selectionLayouts.values - ) - ) - } - } - }, - onDrag = { change, _ -> - val layout = textLayoutResult - val anchor = dragAnchorOffset - if (layout != null && anchor != null) { - val current = textCoordinates?.let { coordinates -> - sharedNativeReaderTextPositionAtWindow( - windowPosition = coordinates.localToWindow(change.position), - layouts = selectionLayouts.values - ) - } ?: SharedNativeTextPosition( - descriptor = textBlock, - localOffset = layout.getOffsetForPosition(change.position) - .coerceIn(0, text.text.length) - ) - onSelectionChange( - sharedNativeReaderSelectionBetween( - start = SharedNativeTextPosition(textBlock, anchor), - end = current, - layouts = selectionLayouts.values - ) - ) - } - change.consume() - }, - onDragEnd = { - dragAnchorOffset = null - onSelectionGestureActiveChange(false) - }, - onDragCancel = { - dragAnchorOffset = null - onSelectionGestureActiveChange(false) - } - ) - } - .pointerInput(textBlockKey, text) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - val layout = textLayoutResult ?: return@awaitEachGesture - val coordinates = textCoordinates ?: return@awaitEachGesture - val anchorOffset = layout.getOffsetForPosition(down.position) - .coerceIn(0, text.text.length) - val anchor = SharedNativeTextPosition(textBlock, anchorOffset) - val touchSlopSquared = viewConfiguration.touchSlop * viewConfiguration.touchSlop - var selecting = false - try { - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == down.id } ?: break - if (!change.pressed) break - val dx = change.position.x - down.position.x - val dy = change.position.y - down.position.y - if (!selecting && dx * dx + dy * dy >= touchSlopSquared) { - selecting = true - onSelectionGestureActiveChange(true) - } - if (selecting) { - val latestCoordinates = textCoordinates ?: coordinates - val windowPosition = latestCoordinates.localToWindow(change.position) - val current = sharedNativeReaderTextPositionAtWindow( - windowPosition = windowPosition, - layouts = selectionLayouts.values - ) ?: SharedNativeTextPosition( - descriptor = textBlock, - localOffset = layout.getOffsetForPosition(change.position) - .coerceIn(0, text.text.length) - ) - onSelectionChange( - sharedNativeReaderSelectionBetween( - start = anchor, - end = current, - layouts = selectionLayouts.values - ) - ) - change.consume() - } - } - } finally { - if (selecting) { - onSelectionGestureActiveChange(false) - } - } - } - }, - textAlign = textAlign, - style = style, - onTextLayout = { textLayoutResult = it } - ) -} - -private fun ReaderPage.toNativeReaderLocator(): ReaderLocator { - return ReaderLocator( - chapterIndex = chapterIndex, - pageIndex = pageIndex, - startOffset = startOffset, - endOffset = endOffset, - textQuote = text.replace(Regex("\\s+"), " ").trim().take(160), - cfi = "desktop:$chapterIndex:$startOffset:$endOffset" - ) -} - -private fun String.toReaderAnnotatedString( - searchQuery: String, - searchHighlight: Color, - absoluteStartOffset: Int, - highlights: List, - activeSelection: SharedNativeReaderTextSelection?, - selectionHighlight: Color -): AnnotatedString { - val normalized = searchQuery.trim() - return buildAnnotatedString { - append(this@toReaderAnnotatedString) - highlights.forEach { highlight -> - applyHighlightToTextRange( - highlight = highlight, - textStartOffset = absoluteStartOffset, - textLength = this@toReaderAnnotatedString.length - ) - } - applySelectionToTextRange( - selection = activeSelection, - textStartOffset = absoluteStartOffset, - textLength = this@toReaderAnnotatedString.length, - color = selectionHighlight - ) - if (normalized.length >= 2) { - var startIndex = 0 - while (startIndex < this@toReaderAnnotatedString.length) { - val index = this@toReaderAnnotatedString.indexOf(normalized, startIndex, ignoreCase = true) - if (index < 0) break - addStyle( - style = SpanStyle(background = searchHighlight), - start = index, - end = index + normalized.length - ) - startIndex = index + normalized.length - } - } - } -} - -@Composable -private fun SharedSemanticBlockStack( - blocks: List, - page: ReaderPage, - foreground: Color, - searchQuery: String, - searchHighlight: Color, - highlights: List, - activeSelection: SharedNativeReaderTextSelection?, - selectionHighlight: Color, - fallbackTextAlign: TextAlign, - fallbackFontFamily: FontFamily, - settings: ReaderSettings, - includeTrailingBottomMargin: Boolean, - onSelectionChange: (SharedNativeReaderTextSelection?) -> Unit, - onSelectionGestureActiveChange: (Boolean) -> Unit, - onHighlightSelected: (String) -> Unit, - onLinkClicked: (SharedNativeReaderLinkClick) -> Unit, - selectionLayouts: MutableMap, - imageContent: (@Composable (SemanticImage, Modifier) -> Unit)?, - onBlockLaidOut: ((SharedNativeBlockFit) -> Unit)? = null -) { - var previous: SemanticBlock? = null - blocks.forEachIndexed { index, block -> - SharedSemanticBlockView( - block = block, - page = page, - foreground = foreground, - searchQuery = searchQuery, - searchHighlight = searchHighlight, - highlights = highlights, - activeSelection = activeSelection, - selectionHighlight = selectionHighlight, - fallbackTextAlign = fallbackTextAlign, - fallbackFontFamily = fallbackFontFamily, - settings = settings, - marginTop = block.collapsedTopMarginDp(previous, settings), - marginBottom = if (includeTrailingBottomMargin && index == blocks.lastIndex) { - block.effectiveBottomMarginDp(settings) - } else { - 0.dp - }, - onSelectionChange = onSelectionChange, - onSelectionGestureActiveChange = onSelectionGestureActiveChange, - onHighlightSelected = onHighlightSelected, - onLinkClicked = onLinkClicked, - selectionLayouts = selectionLayouts, - imageContent = imageContent, - layoutIndex = index, - onBlockLaidOut = onBlockLaidOut - ) - previous = block - } -} - -@Composable -private fun SharedSemanticBlockView( - block: SemanticBlock, - page: ReaderPage, - foreground: Color, - searchQuery: String, - searchHighlight: Color, - highlights: List, - activeSelection: SharedNativeReaderTextSelection?, - selectionHighlight: Color, - fallbackTextAlign: TextAlign, - fallbackFontFamily: FontFamily, - settings: ReaderSettings, - marginTop: Dp, - marginBottom: Dp, - onSelectionChange: (SharedNativeReaderTextSelection?) -> Unit, - onSelectionGestureActiveChange: (Boolean) -> Unit, - onHighlightSelected: (String) -> Unit, - onLinkClicked: (SharedNativeReaderLinkClick) -> Unit, - selectionLayouts: MutableMap, - imageContent: (@Composable (SemanticImage, Modifier) -> Unit)?, - layoutIndex: Int? = null, - onBlockLaidOut: ((SharedNativeBlockFit) -> Unit)? = null -) { - val modifier = Modifier - .fillMaxWidth() - .padding( - start = block.style.blockStyle.margin.left.safeDp(), - top = marginTop, - end = block.style.blockStyle.margin.right.safeDp(), - bottom = marginBottom - ) - .then( - if (block.style.blockStyle.backgroundColor.isSpecified) { - Modifier.background(block.style.blockStyle.backgroundColor, RoundedCornerShape(4.dp)) - } else { - Modifier - } - ) - .padding( - start = block.style.blockStyle.padding.left.safeDp(), - top = block.style.blockStyle.padding.top.safeDp(), - end = block.style.blockStyle.padding.right.safeDp(), - bottom = block.style.blockStyle.padding.bottom.safeDp() - ) - val measuredModifier = if (layoutIndex != null && onBlockLaidOut != null) { - Modifier - .onGloballyPositioned { coordinates -> - onBlockLaidOut(block.toSharedNativeBlockFit(layoutIndex, coordinates)) - } - .then(modifier) - } else { - modifier - } - - when (block) { - is SemanticHeader -> { - SharedSemanticTextView( - block = block, - page = page, - modifier = measuredModifier, - foreground = foreground, - searchQuery = searchQuery, - searchHighlight = searchHighlight, - highlights = highlights, - activeSelection = activeSelection, - selectionHighlight = selectionHighlight, - fallbackTextAlign = block.style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: fallbackTextAlign, - fallbackFontFamily = fallbackFontFamily, - settings = settings, - fontWeight = FontWeight.Bold, - onSelectionChange = onSelectionChange, - onSelectionGestureActiveChange = onSelectionGestureActiveChange, - onHighlightSelected = onHighlightSelected, - onLinkClicked = onLinkClicked, - selectionLayouts = selectionLayouts - ) - } - - is SemanticParagraph -> SharedSemanticTextView(block, page, measuredModifier, foreground, searchQuery, searchHighlight, highlights, activeSelection, selectionHighlight, fallbackTextAlign, fallbackFontFamily, settings, onSelectionChange = onSelectionChange, onSelectionGestureActiveChange = onSelectionGestureActiveChange, onHighlightSelected = onHighlightSelected, onLinkClicked = onLinkClicked, selectionLayouts = selectionLayouts) - is SemanticListItem -> SharedSemanticTextView(block, page, measuredModifier, foreground, searchQuery, searchHighlight, highlights, activeSelection, selectionHighlight, fallbackTextAlign, fallbackFontFamily, settings, onSelectionChange = onSelectionChange, onSelectionGestureActiveChange = onSelectionGestureActiveChange, onHighlightSelected = onHighlightSelected, onLinkClicked = onLinkClicked, selectionLayouts = selectionLayouts) - is SemanticTextBlock -> SharedSemanticTextView(block, page, measuredModifier, foreground, searchQuery, searchHighlight, highlights, activeSelection, selectionHighlight, fallbackTextAlign, fallbackFontFamily, settings, onSelectionChange = onSelectionChange, onSelectionGestureActiveChange = onSelectionGestureActiveChange, onHighlightSelected = onHighlightSelected, onLinkClicked = onLinkClicked, selectionLayouts = selectionLayouts) - - is SemanticList -> { - Column(modifier = measuredModifier, verticalArrangement = Arrangement.Top) { - var previous: SemanticBlock? = null - block.items.forEachIndexed { index, item -> - Row( - modifier = Modifier.padding( - top = item.collapsedTopMarginDp(previous, settings), - bottom = if (index == block.items.lastIndex) item.effectiveBottomMarginDp(settings) else 0.dp - ), - verticalAlignment = Alignment.Top - ) { - val markerModifier = Modifier - .width(SharedNativeListItemMarkerAreaWidthDp.dp) - .padding(end = SharedNativeListItemMarkerEndPaddingDp.dp) - Text( - text = if (block.isOrdered) "${index + 1}." else "\u2022", - color = foreground, - modifier = markerModifier, - textAlign = TextAlign.End, - style = item.renderedTextStyle( - settings = settings, - fallbackFontFamily = fallbackFontFamily, - fallbackTextAlign = TextAlign.End - ) - ) - SharedSemanticTextView( - block = item, - page = page, - modifier = Modifier.weight(1f), - foreground = foreground, - searchQuery = searchQuery, - searchHighlight = searchHighlight, - highlights = highlights, - activeSelection = activeSelection, - selectionHighlight = selectionHighlight, - fallbackTextAlign = fallbackTextAlign, - fallbackFontFamily = fallbackFontFamily, - settings = settings, - onSelectionChange = onSelectionChange, - onSelectionGestureActiveChange = onSelectionGestureActiveChange, - onHighlightSelected = onHighlightSelected, - onLinkClicked = onLinkClicked, - selectionLayouts = selectionLayouts - ) - } - previous = item - } - } - } - - is SemanticFlexContainer -> { - Column(modifier = measuredModifier, verticalArrangement = Arrangement.Top) { - SharedSemanticBlockStack( - blocks = block.children, - page = page, - foreground = foreground, - searchQuery = searchQuery, - searchHighlight = searchHighlight, - highlights = highlights, - activeSelection = activeSelection, - selectionHighlight = selectionHighlight, - fallbackTextAlign = fallbackTextAlign, - fallbackFontFamily = fallbackFontFamily, - settings = settings, - includeTrailingBottomMargin = true, - onSelectionChange = onSelectionChange, - onSelectionGestureActiveChange = onSelectionGestureActiveChange, - onHighlightSelected = onHighlightSelected, - onLinkClicked = onLinkClicked, - selectionLayouts = selectionLayouts, - imageContent = imageContent - ) - } - } - - is SemanticWrappingBlock -> { - Column(modifier = measuredModifier, verticalArrangement = Arrangement.Top) { - SharedSemanticBlockStack( - blocks = listOf(block.floatedImage) + block.paragraphsToWrap, - page = page, - foreground = foreground, - searchQuery = searchQuery, - searchHighlight = searchHighlight, - highlights = highlights, - activeSelection = activeSelection, - selectionHighlight = selectionHighlight, - fallbackTextAlign = fallbackTextAlign, - fallbackFontFamily = fallbackFontFamily, - settings = settings, - includeTrailingBottomMargin = true, - onSelectionChange = onSelectionChange, - onSelectionGestureActiveChange = onSelectionGestureActiveChange, - onHighlightSelected = onHighlightSelected, - onLinkClicked = onLinkClicked, - selectionLayouts = selectionLayouts, - imageContent = imageContent - ) - } - } - - is SemanticTable -> { - Column(modifier = measuredModifier, verticalArrangement = Arrangement.Top) { - block.rows.forEach { row -> - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - row.forEach { cell -> - Column(modifier = Modifier.weight(cell.colspan.toFloat().coerceAtLeast(1f))) { - SharedSemanticBlockStack( - blocks = cell.content, - page = page, - foreground = foreground, - searchQuery = searchQuery, - searchHighlight = searchHighlight, - highlights = highlights, - activeSelection = activeSelection, - selectionHighlight = selectionHighlight, - fallbackTextAlign = fallbackTextAlign, - fallbackFontFamily = fallbackFontFamily, - settings = settings, - includeTrailingBottomMargin = true, - onSelectionChange = onSelectionChange, - onSelectionGestureActiveChange = onSelectionGestureActiveChange, - onHighlightSelected = onHighlightSelected, - onLinkClicked = onLinkClicked, - selectionLayouts = selectionLayouts, - imageContent = imageContent - ) - } - } - } - } - } - } - - is SemanticImage -> { - SharedNativeImageBlock( - block = block, - foreground = foreground, - settings = settings, - imageContent = imageContent, - modifier = measuredModifier - ) - } - - is SemanticMath -> { - Text( - text = block.altText ?: "Equation", - color = foreground, - modifier = measuredModifier, - style = MaterialTheme.typography.bodyMedium - ) - } - - is SemanticSpacer -> Spacer(measuredModifier.height(if (block.isExplicitLineBreak) 8.dp else 16.dp)) - } -} - -@Composable -private fun SharedNativeImageBlock( - block: SemanticImage, - foreground: Color, - settings: ReaderSettings, - imageContent: (@Composable (SemanticImage, Modifier) -> Unit)?, - modifier: Modifier = Modifier -) { - BoxWithConstraints( - modifier = modifier, - contentAlignment = block.imageContentAlignment() - ) { - val imageModifier = Modifier.sharedNativeImageSize(block, settings, maxWidth) - if (imageContent != null) { - imageContent(block, imageModifier) - } else { - Text( - text = block.altText?.takeIf { it.isNotBlank() } ?: block.path.substringAfterLast('/').substringAfterLast('\\'), - color = foreground.copy(alpha = 0.7f), - modifier = imageModifier, - style = MaterialTheme.typography.bodySmall - ) - } - } -} - -private fun SemanticImage.imageContentAlignment(): Alignment { - val style = style.blockStyle - return when { - style.float == "right" || style.horizontalAlign == "right" || style.horizontalAlign == "end" -> Alignment.CenterEnd - style.float == "left" || style.horizontalAlign == "left" || style.horizontalAlign == "start" -> Alignment.CenterStart - else -> Alignment.Center - } -} - -@Composable -private fun Modifier.sharedNativeImageSize( - block: SemanticImage, - settings: ReaderSettings, - maxWidth: Dp -): Modifier { - val density = LocalDensity.current - val style = block.style.blockStyle - val imageScale = settings.imageScale.coerceIn(0.5f, 2f) - val scaledSize = sharedNativeImageRenderSizeDp( - block = block, - density = density, - maxWidth = maxWidth, - imageScale = imageScale - ) - - return this - .then( - if (scaledSize != null) { - Modifier - .width(scaledSize.first) - .height(scaledSize.second) - } else if (style.width.isPositiveSpecified()) { - Modifier.width(style.width) - } else { - Modifier.fillMaxWidth() - } - ) - .then( - if (scaledSize == null && style.maxWidth.isPositiveSpecified()) { - Modifier.widthIn(max = style.maxWidth) - } else { - Modifier - } - ) - .then( - if (scaledSize == null) { - val fallbackHeight = style.height.takeIfPositiveSpecified() - ?: with(density) { (settings.fontSize * 8f).sp.toDp() } - Modifier.height(fallbackHeight) - } else { - Modifier - } - ) -} - -private fun sharedNativeImageRenderSizeDp( - block: SemanticImage, - density: Density, - maxWidth: Dp, - imageScale: Float -): Pair? { - val intrinsicWidth = block.intrinsicWidth - val intrinsicHeight = block.intrinsicHeight - if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f || intrinsicHeight <= 0f) { - return null - } - - val style = block.style.blockStyle - val aspectRatio = intrinsicHeight / intrinsicWidth - val maxWidthPx = with(density) { maxWidth.toPx() } - val baseWidthPx = with(density) { - if (style.width.isPositiveSpecified()) style.width.toPx() else maxWidth.toPx() - } - - var scaledWidthPx = baseWidthPx * imageScale - if (style.maxWidth.isPositiveSpecified()) { - scaledWidthPx = scaledWidthPx.coerceAtMost(with(density) { style.maxWidth.toPx() } * imageScale) - } - scaledWidthPx = scaledWidthPx.coerceAtMost(maxWidthPx) - - return with(density) { - scaledWidthPx.toDp() to (scaledWidthPx * aspectRatio).toDp() - } -} - -private data class SharedNativeContentFit( - val rootTopPx: Int, - val heightPx: Int -) - -private data class SharedNativeTextFitLabel( - val page: ReaderPage, - val blockIndex: Int, - val kind: String, - val sourceRange: String, - val textChars: Int -) - -private data class SharedNativeBlockFit( - val index: Int, - val kind: String, - val blockIndex: Int, - val sourceRange: String, - val rootTopPx: Int, - val heightPx: Int -) { - fun relativeTopPx(contentTopPx: Int): Int = rootTopPx - contentTopPx - - fun relativeBottomPx(contentTopPx: Int): Int = relativeTopPx(contentTopPx) + heightPx - - fun format(contentTopPx: Int): String { - val topPx = relativeTopPx(contentTopPx) - val bottomPx = topPx + heightPx - return "#$index:$kind(block=$blockIndex,top=$topPx,height=$heightPx,bottom=$bottomPx,range=$sourceRange)" - } -} - -private fun SemanticBlock.toSharedNativeBlockFit( - index: Int, - coordinates: LayoutCoordinates -): SharedNativeBlockFit { - return SharedNativeBlockFit( - index = index, - kind = sharedNativeKindName(), - blockIndex = blockIndex, - sourceRange = sharedNativeSourceRangeLabel(), - rootTopPx = coordinates.positionInRoot().y.roundToInt(), - heightPx = coordinates.size.height - ) -} - -private fun List.renderedPageFitTail(contentTopPx: Int): String { - return takeLast(EpubPageFitTailBlockCount).joinToString("|") { it.format(contentTopPx) } -} - -private fun SemanticBlock.sharedNativeKindName(): String { - return when (this) { - is SemanticTextBlock -> when (this) { - is SemanticHeader -> "header" - is SemanticParagraph -> "paragraph" - is SemanticListItem -> "list_item" - else -> "text" - } - is SemanticList -> "list" - is SemanticTable -> "table" - is SemanticFlexContainer -> "flex" - is SemanticWrappingBlock -> "wrapping" - is SemanticImage -> "image" - is SemanticMath -> "math" - is SemanticSpacer -> "spacer" - } -} - -private fun SemanticBlock.sharedNativeSourceRangeLabel(): String { - return when (this) { - is SemanticTextBlock -> { - val start = startCharOffsetInSource - "$start..${start + text.length}" - } - else -> cfi?.takeIf { it.isNotBlank() } - ?: elementId?.takeIf { it.isNotBlank() } - ?: "-" - }.sharedNativeLogPreview(maxLength = 80) -} - -private fun String.sharedNativeLogPreview(maxLength: Int = 96): String { - return replace(Regex("\\s+"), " ") - .trim() - .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } - .replace("\"", "\\\"") -} - -@Composable -private fun SharedSemanticTextView( - block: SemanticTextBlock, - page: ReaderPage, - modifier: Modifier, - foreground: Color, - searchQuery: String, - searchHighlight: Color, - highlights: List, - activeSelection: SharedNativeReaderTextSelection?, - selectionHighlight: Color, - fallbackTextAlign: TextAlign, - fallbackFontFamily: FontFamily, - settings: ReaderSettings, - fontWeight: FontWeight? = null, - onSelectionChange: (SharedNativeReaderTextSelection?) -> Unit, - onSelectionGestureActiveChange: (Boolean) -> Unit, - onHighlightSelected: (String) -> Unit, - onLinkClicked: (SharedNativeReaderLinkClick) -> Unit, - selectionLayouts: MutableMap -) { - val textStyle = block.renderedTextStyle( - settings = settings, - fallbackFontFamily = fallbackFontFamily, - fallbackTextAlign = fallbackTextAlign, - fontWeight = fontWeight - ) - SharedNativeInteractiveText( - text = block.toAnnotatedString( - query = searchQuery, - highlightColor = searchHighlight, - highlights = highlights, - activeSelection = activeSelection, - selectionHighlight = selectionHighlight, - blockFontSizeSp = textStyle.fontSize.value, - pageIndex = page.pageIndex, - blockCfi = block.cfi, - blockIndex = block.blockIndex, - blockCharOffset = block.startCharOffsetInSource - ), - page = page, - textBlock = SharedNativeTextBlockDescriptor( - chapterIndex = page.chapterIndex, - pageIndex = page.pageIndex, - blockIndex = block.blockIndex, - blockCharOffset = block.startCharOffsetInSource, - baseCfi = block.cfi, - textStartOffset = block.startCharOffsetInSource, - text = block.text - ), - textStartOffset = block.startCharOffsetInSource, - color = foreground, - modifier = modifier, - textAlign = block.style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: fallbackTextAlign, - style = textStyle, - onSelectionChange = onSelectionChange, - onSelectionGestureActiveChange = onSelectionGestureActiveChange, - onHighlightSelected = onHighlightSelected, - onLinkClicked = onLinkClicked, - selectionLayouts = selectionLayouts, - fitLabel = SharedNativeTextFitLabel( - page = page, - blockIndex = block.blockIndex, - kind = block.sharedNativeKindName(), - sourceRange = block.sharedNativeSourceRangeLabel(), - textChars = block.text.length - ) - ) -} - -@Composable -private fun SemanticTextBlock.renderedTextStyle( - settings: ReaderSettings, - fallbackFontFamily: FontFamily, - fallbackTextAlign: TextAlign, - fontWeight: FontWeight? = null -): TextStyle { - val fontSize = (style.fontSize.takeIfSpecified() - ?: style.spanStyle.fontSize.takeIfSpecified()) - ?.resolveFontSizeSp(settings.fontSize.toFloat()) - ?: when (this) { - is SemanticHeader -> (settings.fontSize * headerScale(level)).sp - else -> settings.fontSize.sp - } - val lineHeight = style.paragraphStyle.lineHeight.takeIfSpecified() - ?.resolveLineHeightSp(fontSize.value) - ?: (fontSize.value * settings.lineSpacing).sp - return MaterialTheme.typography.bodyLarge.copy( - fontSize = fontSize, - lineHeight = lineHeight, - fontFamily = fallbackFontFamily, - fontWeight = fontWeight ?: if (this is SemanticHeader) FontWeight.Bold else MaterialTheme.typography.bodyLarge.fontWeight, - textAlign = style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: fallbackTextAlign - ).withAndroidPaginationTextMetrics() -} - -private fun TextStyle.withAndroidPaginationTextMetrics(): TextStyle { - return copy( - lineBreak = LineBreak.Paragraph, - letterSpacing = TextUnit.Unspecified, - lineHeightStyle = LineHeightStyle( - alignment = LineHeightStyle.Alignment.Proportional, - trim = LineHeightStyle.Trim.None - ) - ) -} - -private fun SemanticTextBlock.toAnnotatedString( - query: String, - highlightColor: Color, - highlights: List, - activeSelection: SharedNativeReaderTextSelection?, - selectionHighlight: Color, - blockFontSizeSp: Float, - pageIndex: Int, - blockCfi: String?, - blockIndex: Int, - blockCharOffset: Int -): AnnotatedString { - val normalized = query.trim() - return buildAnnotatedString { - append(text) - spans.forEach { span -> - val start = span.start.coerceIn(0, text.length) - val end = span.end.coerceIn(start, text.length) - if (start < end) { - addStyle(span.style.toRenderedSpanStyle(blockFontSizeSp), start, end) - span.linkHref?.takeIf { it.isNotBlank() }?.let { href -> - addStringAnnotation(ReaderNativeAnnotationUrl, href, start, end) - } - } - } - highlights.forEach { highlight -> - applyHighlightToTextRange( - highlight = highlight, - blockCfi = blockCfi, - textStartOffset = startCharOffsetInSource, - textLength = text.length, - text = text - ) - } - applySelectionToTextRange( - selection = activeSelection, - pageIndex = pageIndex, - blockIndex = blockIndex, - blockCharOffset = blockCharOffset, - textStartOffset = startCharOffsetInSource, - textLength = text.length, - color = selectionHighlight - ) - if (normalized.length >= 2) { - var startIndex = 0 - while (startIndex < text.length) { - val index = text.indexOf(normalized, startIndex, ignoreCase = true) - if (index < 0) break - addStyle(SpanStyle(background = highlightColor), index, index + normalized.length) - startIndex = index + normalized.length - } - } - } -} - -private fun TextUnit.takeIfSpecified(): TextUnit? = if (isSpecified) this else null - -private fun Color.blendWith(other: Color, foregroundWeight: Float): Color { - val weight = foregroundWeight.coerceIn(0f, 1f) - val baseWeight = 1f - weight - return Color( - red * baseWeight + other.red * weight, - green * baseWeight + other.green * weight, - blue * baseWeight + other.blue * weight, - alpha - ) -} - -private fun TextUnit.resolveFontSizeSp(baseFontSizeSp: Float): TextUnit { - return when { - isEm -> (baseFontSizeSp * value).sp - else -> value.sp - } -} - -private fun TextUnit.resolveLineHeightSp(fontSizeSp: Float): TextUnit { - return when { - isEm -> (fontSizeSp * value).sp - else -> value.sp - } -} - -private fun CssStyle.toRenderedSpanStyle(parentFontSizeSp: Float): SpanStyle { - val resolvedFontSize = (spanStyle.fontSize.takeIfSpecified() ?: fontSize.takeIfSpecified()) - ?.resolveFontSizeSp(parentFontSizeSp) - return if (resolvedFontSize == null) { - spanStyle - } else { - spanStyle.copy(fontSize = resolvedFontSize) - } -} - -private fun List.visibleInPage(page: ReaderPage): List { - return filter { highlight -> - val locator = highlight.locator - val chapterIndex = locator.chapterIndex ?: highlight.chapterIndex - val start = locator.startOffset - val end = locator.endOffset - val pageMatch = locator.pageIndex == page.pageIndex - val offsetMatch = start != null && - end != null && - start < page.endOffset && - end > page.startOffset - chapterIndex == page.chapterIndex && (pageMatch || offsetMatch) - } -} - -private fun AnnotatedString.Builder.applyHighlightToTextRange( - highlight: UserHighlight, - blockCfi: String? = null, - textStartOffset: Int, - textLength: Int, - text: String? = null -) { - val cfiRange = sharedNativeHighlightRangeInBlock( - highlight = highlight, - blockCfi = blockCfi, - textLength = textLength, - text = text - ) - if (cfiRange != null) { - addStyle( - style = SpanStyle(background = highlight.color.color.copy(alpha = 0.38f)), - start = cfiRange.start, - end = cfiRange.end - ) - addStringAnnotation(ReaderNativeAnnotationHighlight, highlight.id, cfiRange.start, cfiRange.end) - return - } - if (highlight.cfi.contains('|') || highlight.cfi.startsWith("/")) return - val start = highlight.locator.startOffset ?: return - val end = highlight.locator.endOffset ?: return - val localStart = (start - textStartOffset).coerceIn(0, textLength) - val localEnd = (end - textStartOffset).coerceIn(localStart, textLength) - if (localStart < localEnd) { - addStyle( - style = SpanStyle(background = highlight.color.color.copy(alpha = 0.38f)), - start = localStart, - end = localEnd - ) - addStringAnnotation(ReaderNativeAnnotationHighlight, highlight.id, localStart, localEnd) - } -} - -private fun AnnotatedString.Builder.applySelectionToTextRange( - selection: SharedNativeReaderTextSelection?, - pageIndex: Int? = null, - blockIndex: Int? = null, - blockCharOffset: Int? = null, - textStartOffset: Int, - textLength: Int, - color: Color -) { - if (selection == null) return - val blockLocalRange = if (pageIndex != null && blockIndex != null && blockCharOffset != null) { - sharedNativeSelectionRangeInBlock( - selection = selection, - pageIndex = pageIndex, - blockIndex = blockIndex, - blockCharOffset = blockCharOffset, - textLength = textLength - ) - } else { - null - } - val localStart: Int - val localEnd: Int - if (blockLocalRange != null) { - localStart = blockLocalRange.start - localEnd = blockLocalRange.end - } else { - if (selection.startBlockIndex >= 0 || selection.endBlockIndex >= 0) return - localStart = (selection.startOffset - textStartOffset).coerceIn(0, textLength) - localEnd = (selection.endOffset - textStartOffset).coerceIn(localStart, textLength) - } - if (localStart < localEnd) { - addStyle( - style = SpanStyle(background = color), - start = localStart, - end = localEnd - ) - } -} - -private fun AnnotatedString.stringAnnotationAt(tag: String, offset: Int): String? { - if (isEmpty()) return null - val start = offset.coerceIn(0, (length - 1).coerceAtLeast(0)) - val end = (start + 1).coerceAtMost(length) - return getStringAnnotations(tag, start, end).firstOrNull()?.item -} - -private data class SharedNativeSelectedTextRange( - val info: SharedNativeTextLayoutInfo, - val start: Int, - val end: Int -) - -private data class SharedNativeSelectionEndpoint( - val info: SharedNativeTextLayoutInfo, - val localOffset: Int -) - -private fun sharedNativeSelectionMenuOffset( - selection: SharedNativeReaderTextSelection, - readerCoordinates: LayoutCoordinates?, - density: Density, - highlightPaletteSize: Int, - actionCount: Int -): IntOffset { - val coordinates = readerCoordinates?.takeIf { it.isAttached } ?: return IntOffset(16, 16) - if (selection.rect == Rect.Zero) return IntOffset(16, 16) - val leftTopLocal = coordinates.windowToLocal(Offset(selection.rect.left, selection.rect.top)) - val rightBottomLocal = coordinates.windowToLocal(Offset(selection.rect.right, selection.rect.bottom)) - val paddingPx = with(density) { 16.dp.toPx() } - val estimatedWidthPx = with(density) { 280.dp.toPx() } - val estimatedHeightPx = sharedNativeSelectionMenuEstimatedHeightPx( - density = density, - highlightPaletteSize = highlightPaletteSize, - actionCount = actionCount - ) - val selectionRect = SharedSelectionMenuRect( - left = leftTopLocal.x, - top = leftTopLocal.y, - right = rightBottomLocal.x, - bottom = rightBottomLocal.y - ) - val placement = sharedSelectionMenuPlacement( - viewport = SharedSelectionMenuViewport(coordinates.size.width, coordinates.size.height), - popup = SharedSelectionMenuSize( - width = estimatedWidthPx.roundToInt(), - height = estimatedHeightPx.roundToInt() - ), - selection = selectionRect, - marginPx = paddingPx, - gapPx = paddingPx - ) - return IntOffset(placement.x, placement.y) -} - -private fun sharedNativeSelectionMenuEstimatedHeightPx( - density: Density, - highlightPaletteSize: Int, - actionCount: Int -): Float { - val actionRows = ((actionCount.coerceAtLeast(1) + 2) / 3).coerceAtLeast(1) - return with(density) { - val paletteHeight = if (highlightPaletteSize > 0) 41.dp.toPx() else 0f - val actionsHeight = 7.dp.toPx() + - (actionRows * 52).dp.toPx() + - ((actionRows - 1).coerceAtLeast(0) * 3).dp.toPx() - paletteHeight + actionsHeight - } -} - -private fun sharedNativeSelectionHandleOffset( - selection: SharedNativeReaderTextSelection, - handle: SharedNativeSelectionHandle, - layouts: Collection, - readerCoordinates: LayoutCoordinates?, - density: Density -): IntOffset? { - val reader = readerCoordinates?.takeIf { it.isAttached } ?: return null - val endpoint = sharedNativeSelectionEndpoint(selection, handle, layouts) ?: return null - val textLength = endpoint.info.descriptor.text.length - if (textLength <= 0) return null - val safeOffset = endpoint.localOffset.coerceIn(0, textLength) - val probeStart = when (handle) { - SharedNativeSelectionHandle.START -> safeOffset.coerceIn(0, textLength - 1) - SharedNativeSelectionHandle.END -> (safeOffset - 1).coerceIn(0, textLength - 1) - } - val probeEnd = (probeStart + 1).coerceAtMost(textLength) - val localRect = runCatching { - endpoint.info.layout.getPathForRange(probeStart, probeEnd).getBounds() - }.getOrNull() ?: return null - val localX = when (handle) { - SharedNativeSelectionHandle.START -> if (safeOffset >= textLength) localRect.right else localRect.left - SharedNativeSelectionHandle.END -> if (safeOffset <= probeStart) localRect.left else localRect.right - } - val windowPosition = endpoint.info.coordinates.localToWindow(Offset(localX, localRect.bottom)) - val readerPosition = reader.windowToLocal(windowPosition) - val halfHandlePx = with(density) { 14.dp.toPx() } - return IntOffset( - x = (readerPosition.x - halfHandlePx).roundToInt(), - y = readerPosition.y.roundToInt() - ) -} - -private fun sharedNativeSelectionWithHandleMoved( - selection: SharedNativeReaderTextSelection, - handle: SharedNativeSelectionHandle, - windowPosition: Offset, - layouts: Collection -): SharedNativeReaderTextSelection? { - val moved = sharedNativeReaderTextPositionAtWindow(windowPosition, layouts) ?: return null - val opposite = sharedNativeSelectionEndpointPosition( - selection = selection, - handle = if (handle == SharedNativeSelectionHandle.START) SharedNativeSelectionHandle.END else SharedNativeSelectionHandle.START, - layouts = layouts - ) ?: return null - return if (handle == SharedNativeSelectionHandle.START) { - sharedNativeReaderSelectionBetween(moved, opposite, layouts) - } else { - sharedNativeReaderSelectionBetween(opposite, moved, layouts) - } -} - -private fun sharedNativeSelectionEndpointPosition( - selection: SharedNativeReaderTextSelection, - handle: SharedNativeSelectionHandle, - layouts: Collection -): SharedNativeTextPosition? { - val endpoint = sharedNativeSelectionEndpoint(selection, handle, layouts) ?: return null - return SharedNativeTextPosition( - descriptor = endpoint.info.descriptor, - localOffset = endpoint.localOffset.coerceIn(0, endpoint.info.descriptor.text.length) - ) -} - -private fun sharedNativeSelectionEndpoint( - selection: SharedNativeReaderTextSelection, - handle: SharedNativeSelectionHandle, - layouts: Collection -): SharedNativeSelectionEndpoint? { - val pageIndex = if (handle == SharedNativeSelectionHandle.START) { - selection.startPageIndex - } else { - selection.endPageIndex - } - val blockIndex = if (handle == SharedNativeSelectionHandle.START) { - selection.startBlockIndex - } else { - selection.endBlockIndex - } - val blockCharOffset = if (handle == SharedNativeSelectionHandle.START) { - selection.startBlockCharOffset - } else { - selection.endBlockCharOffset - } - val localOffset = if (handle == SharedNativeSelectionHandle.START) { - selection.startLocalOffset - } else { - selection.endLocalOffset - } - val key = SharedNativeSelectionBlockKey(pageIndex, blockIndex, blockCharOffset) - val info = layouts.firstOrNull { it.coordinates.isAttached && it.descriptor.key == key } ?: return null - return SharedNativeSelectionEndpoint(info, localOffset) -} - -private fun sharedNativeReaderTextPositionAtWindow( - windowPosition: Offset, - layouts: Collection -): SharedNativeTextPosition? { - val target = layouts - .asSequence() - .filter { it.coordinates.isAttached && it.descriptor.text.isNotEmpty() } - .minByOrNull { info -> - val rect = info.coordinates.boundsInWindow() - val dx = maxOf(rect.left - windowPosition.x, 0f, windowPosition.x - rect.right) - val dy = maxOf(rect.top - windowPosition.y, 0f, windowPosition.y - rect.bottom) - dx * dx + dy * dy - } ?: return null - val localPosition = target.coordinates.windowToLocal(windowPosition) - return SharedNativeTextPosition( - descriptor = target.descriptor, - localOffset = target.layout.getOffsetForPosition(localPosition) - .coerceIn(0, target.descriptor.text.length) - ) -} - -private fun sharedNativeReaderSelectionBetween( - start: SharedNativeTextPosition, - end: SharedNativeTextPosition, - layouts: Collection -): SharedNativeReaderTextSelection? { - if (start.descriptor.chapterIndex != end.descriptor.chapterIndex) return null - val (orderedStart, orderedEnd) = if (sharedNativeCompareTextPositions(start, end) <= 0) { - start to end - } else { - end to start - } - val selectedRanges = layouts - .asSequence() - .filter { it.coordinates.isAttached } - .filter { info -> - sharedNativeSelectionRangeInBlock( - start = orderedStart, - end = orderedEnd, - block = info.descriptor, - textLength = info.descriptor.text.length - ) != null - } - .sortedWith( - compareBy { it.descriptor.pageIndex } - .thenBy { it.descriptor.blockIndex } - .thenBy { it.descriptor.blockCharOffset } - ) - .mapNotNull { info -> - val range = sharedNativeSelectionRangeInBlock( - start = orderedStart, - end = orderedEnd, - block = info.descriptor, - textLength = info.descriptor.text.length - ) ?: return@mapNotNull null - SharedNativeSelectedTextRange(info, range.start, range.end) - } - .toMutableList() - sharedNativeTrimSelectedRanges(selectedRanges) - if (selectedRanges.isEmpty()) return null - val selectedText = selectedRanges.joinToString(" ") { range -> - range.info.descriptor.text.substring(range.start, range.end) - }.trim() - if (selectedText.isBlank()) return null - val first = selectedRanges.first() - val last = selectedRanges.last() - val startAbsoluteOffset = first.info.descriptor.blockCharOffset + first.start - val endAbsoluteOffset = last.info.descriptor.blockCharOffset + last.end - return SharedNativeReaderTextSelection( - chapterIndex = first.info.descriptor.chapterIndex, - pageIndex = first.info.descriptor.pageIndex, - startOffset = startAbsoluteOffset, - endOffset = endAbsoluteOffset, - text = selectedText, - startPageIndex = first.info.descriptor.pageIndex, - endPageIndex = last.info.descriptor.pageIndex, - startBlockIndex = first.info.descriptor.blockIndex, - endBlockIndex = last.info.descriptor.blockIndex, - startBlockCharOffset = first.info.descriptor.blockCharOffset, - endBlockCharOffset = last.info.descriptor.blockCharOffset, - startLocalOffset = first.start, - endLocalOffset = last.end, - startBaseCfi = first.info.descriptor.baseCfi, - endBaseCfi = last.info.descriptor.baseCfi, - rect = sharedNativeSelectionRect(selectedRanges), - textPerBlock = selectedRanges.associate { range -> - range.info.descriptor.key.stableKey to range.info.descriptor.text.substring(range.start, range.end) - } - ) -} - -private fun sharedNativeSelectionRangeInBlock( - start: SharedNativeTextPosition, - end: SharedNativeTextPosition, - block: SharedNativeTextBlockDescriptor, - textLength: Int -): SharedNativeReaderTextRange? { - if (sharedNativeCompareBlockToPosition(block, start) < 0) return null - if (sharedNativeCompareBlockToPosition(block, end) > 0) return null - val isStart = block.key == start.descriptor.key - val isEnd = block.key == end.descriptor.key - val localStart = if (isStart) start.localOffset else 0 - val localEnd = if (isEnd) end.localOffset else textLength - val safeStart = localStart.coerceIn(0, textLength) - val safeEnd = localEnd.coerceIn(safeStart, textLength) - return if (safeStart < safeEnd) SharedNativeReaderTextRange(safeStart, safeEnd) else null -} - -private fun sharedNativeSelectionRangeInBlock( - selection: SharedNativeReaderTextSelection, - pageIndex: Int, - blockIndex: Int, - blockCharOffset: Int, - textLength: Int -): SharedNativeReaderTextRange? { - if (selection.startBlockIndex < 0 || selection.endBlockIndex < 0) return null - val blockPosition = SharedNativeSelectionBlockKey(pageIndex, blockIndex, blockCharOffset) - val startPosition = SharedNativeSelectionBlockKey( - selection.startPageIndex, - selection.startBlockIndex, - selection.startBlockCharOffset - ) - val endPosition = SharedNativeSelectionBlockKey( - selection.endPageIndex, - selection.endBlockIndex, - selection.endBlockCharOffset - ) - if (sharedNativeCompareBlockKeys(blockPosition, startPosition) < 0) return null - if (sharedNativeCompareBlockKeys(blockPosition, endPosition) > 0) return null - val isStart = blockPosition == startPosition - val isEnd = blockPosition == endPosition - val localStart = if (isStart) selection.startLocalOffset else 0 - val localEnd = if (isEnd) selection.endLocalOffset else textLength - val safeStart = localStart.coerceIn(0, textLength) - val safeEnd = localEnd.coerceIn(safeStart, textLength) - return if (safeStart < safeEnd) SharedNativeReaderTextRange(safeStart, safeEnd) else null -} - -private fun sharedNativeCompareTextPositions( - first: SharedNativeTextPosition, - second: SharedNativeTextPosition -): Int { - val blockCompare = sharedNativeCompareBlockKeys(first.descriptor.key, second.descriptor.key) - return if (blockCompare != 0) blockCompare else first.localOffset.compareTo(second.localOffset) -} - -private fun sharedNativeCompareBlockToPosition( - block: SharedNativeTextBlockDescriptor, - position: SharedNativeTextPosition -): Int = sharedNativeCompareBlockKeys(block.key, position.descriptor.key) - -private fun sharedNativeCompareBlockKeys( - first: SharedNativeSelectionBlockKey, - second: SharedNativeSelectionBlockKey -): Int { - if (first.pageIndex != second.pageIndex) return first.pageIndex.compareTo(second.pageIndex) - if (first.blockIndex != second.blockIndex) return first.blockIndex.compareTo(second.blockIndex) - return first.blockCharOffset.compareTo(second.blockCharOffset) -} - -private fun sharedNativeTrimSelectedRanges(ranges: MutableList) { - while (ranges.isNotEmpty()) { - val first = ranges.first() - val text = first.info.descriptor.text - var start = first.start - while (start < first.end && text[start].isWhitespace()) start++ - if (start < first.end) { - if (start != first.start) ranges[0] = first.copy(start = start) - break - } - ranges.removeAt(0) - } - while (ranges.isNotEmpty()) { - val lastIndex = ranges.lastIndex - val last = ranges[lastIndex] - val text = last.info.descriptor.text - var end = last.end - while (end > last.start && text[end - 1].isWhitespace()) end-- - if (end > last.start) { - if (end != last.end) ranges[lastIndex] = last.copy(end = end) - break - } - ranges.removeAt(lastIndex) - } -} - -private fun sharedNativeSelectionRect(ranges: List): Rect { - var left = Float.POSITIVE_INFINITY - var top = Float.POSITIVE_INFINITY - var right = Float.NEGATIVE_INFINITY - var bottom = Float.NEGATIVE_INFINITY - ranges.forEach { range -> - val coordinates = range.info.coordinates - val windowRect = runCatching { - val localRect = range.info.layout.getPathForRange(range.start, range.end).getBounds() - Rect( - coordinates.localToWindow(localRect.topLeft), - coordinates.localToWindow(localRect.bottomRight) - ) - }.getOrElse { - coordinates.boundsInWindow() - } - left = minOf(left, windowRect.left, windowRect.right) - top = minOf(top, windowRect.top, windowRect.bottom) - right = maxOf(right, windowRect.left, windowRect.right) - bottom = maxOf(bottom, windowRect.top, windowRect.bottom) - } - return if (left.isFinite() && top.isFinite() && right.isFinite() && bottom.isFinite()) { - Rect(left, top, right, bottom) - } else { - Rect.Zero - } -} - -internal data class SharedNativeReaderTextRange( - val start: Int, - val end: Int -) - -internal fun sharedNativeReaderTrimmedWordRange( - text: String, - start: Int, - end: Int -): SharedNativeReaderTextRange? { - var normalizedStart = start.coerceIn(0, text.length) - var normalizedEnd = end.coerceIn(normalizedStart, text.length) - while (normalizedStart < normalizedEnd && !text[normalizedStart].isLetterOrDigit()) { - normalizedStart++ - } - while (normalizedEnd > normalizedStart && !text[normalizedEnd - 1].isLetterOrDigit()) { - normalizedEnd-- - } - return if (normalizedStart < normalizedEnd) { - SharedNativeReaderTextRange(normalizedStart, normalizedEnd) - } else { - null - } -} - -private data class SharedNativeCfiPoint( - val path: String, - val offset: Int -) - -private fun sharedNativeHighlightRangeInBlock( - highlight: UserHighlight, - blockCfi: String?, - textLength: Int, - text: String? -): SharedNativeReaderTextRange? { - val cfi = highlight.cfi.takeIf { it.contains('|') || it.startsWith("/") } ?: return null - val blockPath = blockCfi?.takeIf { it.startsWith("/") } ?: return null - val parts = cfi.split('|') - val start = parts.firstOrNull()?.sharedNativeCfiPointOrNull() ?: return null - val end = parts.lastOrNull()?.sharedNativeCfiPointOrNull() ?: start - val startMatches = sharedNativeCfiPathsEquivalent(start.path, blockPath) - val endMatches = sharedNativeCfiPathsEquivalent(end.path, blockPath) - val isIntermediate = !startMatches && !endMatches && - parts.size > 1 && - sharedNativeCfiPathStrictlyBetween(blockPath, start.path, end.path) - if (!startMatches && !endMatches && !isIntermediate) return null - - var localStart = if (startMatches) start.offset else 0 - var localEnd = if (endMatches) end.offset else textLength - if (startMatches && endMatches && localEnd < localStart) { - localStart = localEnd.also { localEnd = localStart } - } - localStart = localStart.coerceIn(0, textLength) - localEnd = localEnd.coerceIn(localStart, textLength) - if (localStart < localEnd) { - return SharedNativeReaderTextRange(localStart, localEnd) - } - - val quote = highlight.text.takeIf { it.isNotBlank() } - val blockText = text - if (quote != null && blockText != null) { - val exact = blockText.indexOf(quote, ignoreCase = false) - if (exact >= 0) return SharedNativeReaderTextRange(exact, (exact + quote.length).coerceAtMost(textLength)) - val relaxed = blockText.indexOf(quote, ignoreCase = true) - if (relaxed >= 0) return SharedNativeReaderTextRange(relaxed, (relaxed + quote.length).coerceAtMost(textLength)) - } - return null -} - -private fun String.sharedNativeCfiPointOrNull(): SharedNativeCfiPoint? { - val separator = lastIndexOf(':') - if (separator <= 0 || separator == lastIndex) return null - val path = substring(0, separator).takeIf { it.startsWith("/") } ?: return null - val offset = substring(separator + 1).toIntOrNull() ?: return null - return SharedNativeCfiPoint(path, offset) -} - -private fun sharedNativeCfiPathsEquivalent(first: String, second: String): Boolean { - val firstParts = first.split('/').filter { it.isNotEmpty() } - val secondParts = second.split('/').filter { it.isNotEmpty() } - if (firstParts == secondParts) return true - return firstParts.size == secondParts.size && - firstParts.isNotEmpty() && - firstParts.drop(1) == secondParts.drop(1) -} - -private fun sharedNativeCfiPathStrictlyBetween(candidate: String, start: String, end: String): Boolean { - val candidateParts = candidate.sharedNativeCfiNumericPathParts() ?: return false - val startParts = start.sharedNativeCfiNumericPathParts() ?: return false - val endParts = end.sharedNativeCfiNumericPathParts() ?: return false - return sharedNativeCompareCfiPathParts(candidateParts, startParts) > 0 && - sharedNativeCompareCfiPathParts(candidateParts, endParts) < 0 -} - -private fun String.sharedNativeCfiNumericPathParts(): List? { - val parts = split('/').filter { it.isNotEmpty() } - if (parts.isEmpty()) return null - return parts.map { it.toIntOrNull() ?: return null } -} - -private fun sharedNativeCompareCfiPathParts(first: List, second: List): Int { - val length = minOf(first.size, second.size) - for (index in 0 until length) { - val comparison = first[index].compareTo(second[index]) - if (comparison != 0) return comparison - } - return first.size.compareTo(second.size) -} - -internal fun sharedNativeReaderHighlightForSelection( - selection: SharedNativeReaderTextSelection, - color: HighlightColor -): UserHighlight { - val locator = ReaderLocator( - chapterIndex = selection.chapterIndex, - pageIndex = selection.pageIndex, - startOffset = selection.startOffset, - endOffset = selection.endOffset, - textQuote = selection.text, - cfi = selection.cfi - ) - return UserHighlight( - id = "native-${selection.chapterIndex}-${selection.startPageIndex}-${selection.startBlockIndex}-${selection.startLocalOffset}-${selection.endPageIndex}-${selection.endBlockIndex}-${selection.endLocalOffset}-${color.id}", - cfi = selection.cfi, - text = selection.text, - color = color, - chapterIndex = selection.chapterIndex, - locator = locator - ) -} - -private fun headerScale(level: Int): Float { - return when (level) { - 1 -> 1.5f - 2 -> 1.35f - 3 -> 1.2f - 4 -> 1.1f - else -> 1f - } -} - -private fun Dp.safeDp(): Dp = if (isSpecified) this else 0.dp - -private fun Dp.isPositiveSpecified(): Boolean = isSpecified && this > 0.dp - -private fun Dp.takeIfPositiveSpecified(): Dp? = takeIf { it.isPositiveSpecified() } - -@Composable -private fun SemanticBlock.collapsedTopMarginDp( - previous: SemanticBlock?, - settings: ReaderSettings -): Dp { - val top = style.blockStyle.margin.top.safeDp() - return previous?.let { maxOf(it.effectiveBottomMarginDp(settings), top) } ?: top -} - -@Composable -private fun SemanticBlock.effectiveBottomMarginDp(settings: ReaderSettings): Dp { - val explicit = style.blockStyle.margin.bottom.safeDp() - if (explicit != 0.dp) return explicit - return renderedDefaultBottomSpacingDp(settings) -} - -@Composable -private fun SemanticBlock.renderedDefaultBottomSpacingDp(settings: ReaderSettings): Dp { - return when (this) { - is SemanticParagraph, - is SemanticHeader, - is SemanticList, - is SemanticTable, - is SemanticImage -> settings.renderedDefaultBlockSpacingDp() - is SemanticMath -> if (svgContent == null) settings.renderedDefaultBlockSpacingDp() else 0.dp - else -> 0.dp - } -} - -@Composable -private fun ReaderSettings.renderedDefaultBlockSpacingDp(): Dp { - val density = LocalDensity.current - return with(density) { (fontSize * paragraphSpacing).sp.toDp() } -} - -private fun SharedReaderTextAlign.toComposeTextAlign(): TextAlign { - return when (this) { - SharedReaderTextAlign.START -> TextAlign.Start - SharedReaderTextAlign.RIGHT -> TextAlign.Right - SharedReaderTextAlign.JUSTIFY -> TextAlign.Justify - SharedReaderTextAlign.CENTER -> TextAlign.Center - } -} - -private const val ReaderNativeAnnotationUrl = "URL" -private const val ReaderNativeAnnotationHighlight = "HIGHLIGHT" -private const val EpubPageFitLogTag = "EpistemeEpubPageFit" -private const val EpubPageFitTailBlockCount = 4 -private const val SharedNativeListItemMarkerAreaWidthDp = 32 -private const val SharedNativeListItemMarkerEndPaddingDp = 8 diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt similarity index 59% rename from shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt index bfd18c3..60559ae 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified @@ -138,30 +138,340 @@ object CssParser { } } - private fun splitDeclarations(declarations: String): List { - val parts = declarations.split(';').toMutableList() - if (parts.size <= 1) return parts + private data class CssBlock(val header: String, val body: String, val sourceOrder: Int) + private data class ParsedSelector(val selector: String, val pseudoElement: String?) + private fun splitDeclarations(declarations: String): List { val result = mutableListOf() - val iterator = parts.listIterator() - while(iterator.hasNext()) { - var current = iterator.next() - val originalCurrent = current - var reassembled = false - while (current.count { it == '(' } > current.count { it == ')' }) { - if (!iterator.hasNext()) break - val nextPart = iterator.next() - current += ";$nextPart" - reassembled = true + val current = StringBuilder() + var quote: Char? = null + var escaped = false + var parenDepth = 0 + var bracketDepth = 0 + + declarations.forEach { ch -> + when { + escaped -> { + current.append(ch) + escaped = false + } + ch == '\\' -> { + current.append(ch) + escaped = true + } + quote != null -> { + current.append(ch) + if (ch == quote) quote = null + } + ch == '"' || ch == '\'' -> { + current.append(ch) + quote = ch + } + ch == '(' -> { + current.append(ch) + parenDepth++ + } + ch == ')' -> { + current.append(ch) + parenDepth = (parenDepth - 1).coerceAtLeast(0) + } + ch == '[' -> { + current.append(ch) + bracketDepth++ + } + ch == ']' -> { + current.append(ch) + bracketDepth = (bracketDepth - 1).coerceAtLeast(0) + } + ch == ';' && parenDepth == 0 && bracketDepth == 0 -> { + result += current.toString() + current.clear() + } + else -> current.append(ch) } - if (reassembled) { - ReaderCssLog.d("Reassembled declaration. Original: '$originalCurrent'. Final: '$current'") - } - result.add(current) } + if (current.isNotBlank()) result += current.toString() return result } + private fun stripCssComments(css: String): String { + val result = StringBuilder(css.length) + var index = 0 + var quote: Char? = null + var escaped = false + while (index < css.length) { + val ch = css[index] + if (escaped) { + result.append(ch) + escaped = false + index++ + continue + } + if (ch == '\\') { + result.append(ch) + escaped = true + index++ + continue + } + if (quote != null) { + result.append(ch) + if (ch == quote) quote = null + index++ + continue + } + if (ch == '"' || ch == '\'') { + quote = ch + result.append(ch) + index++ + continue + } + if (ch == '/' && index + 1 < css.length && css[index + 1] == '*') { + index += 2 + while (index + 1 < css.length && !(css[index] == '*' && css[index + 1] == '/')) { + index++ + } + index = (index + 2).coerceAtMost(css.length) + continue + } + result.append(ch) + index++ + } + return result.toString() + } + + private fun parseCssBlocks( + css: String, + constraints: Constraints, + isDarkTheme: Boolean, + adaptThemeColors: Boolean, + sourceCounter: IntArray = intArrayOf(0) + ): Pair, List> { + val blocks = mutableListOf() + val fontFaceBlocks = mutableListOf() + var index = 0 + + fun skipWhitespace() { + while (index < css.length && css[index].isWhitespace()) index++ + } + + fun findMatchingBrace(openBrace: Int): Int { + var depth = 1 + var i = openBrace + 1 + var quote: Char? = null + var escaped = false + while (i < css.length) { + val ch = css[i] + when { + escaped -> escaped = false + ch == '\\' -> escaped = true + quote != null -> if (ch == quote) quote = null + ch == '"' || ch == '\'' -> quote = ch + ch == '{' -> depth++ + ch == '}' -> { + depth-- + if (depth == 0) return i + } + } + i++ + } + return css.lastIndex + } + + while (index < css.length) { + skipWhitespace() + if (index >= css.length) break + val headerStart = index + var quote: Char? = null + var escaped = false + var parenDepth = 0 + var bracketDepth = 0 + while (index < css.length) { + val ch = css[index] + when { + escaped -> escaped = false + ch == '\\' -> escaped = true + quote != null -> if (ch == quote) quote = null + ch == '"' || ch == '\'' -> quote = ch + ch == '(' -> parenDepth++ + ch == ')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0) + ch == '[' -> bracketDepth++ + ch == ']' -> bracketDepth = (bracketDepth - 1).coerceAtLeast(0) + ch == ';' && parenDepth == 0 && bracketDepth == 0 -> { + index++ + break + } + ch == '{' && parenDepth == 0 && bracketDepth == 0 -> break + } + index++ + } + if (index >= css.length || css[index] != '{') continue + + val header = css.substring(headerStart, index).trim() + val close = findMatchingBrace(index) + val body = css.substring(index + 1, close.coerceAtMost(css.length)) + index = close + 1 + + when { + header.startsWith("@media", ignoreCase = true) -> { + if (mediaQueryApplies(header, constraints, isDarkTheme, adaptThemeColors)) { + val nested = parseCssBlocks(body, constraints, isDarkTheme, adaptThemeColors, sourceCounter) + blocks += nested.first + fontFaceBlocks += nested.second + } + } + header.startsWith("@supports", ignoreCase = true) -> { + val nested = parseCssBlocks(body, constraints, isDarkTheme, adaptThemeColors, sourceCounter) + blocks += nested.first + fontFaceBlocks += nested.second + } + header.startsWith("@font-face", ignoreCase = true) -> fontFaceBlocks += body + header.startsWith("@") -> Unit + header.isNotBlank() -> blocks += CssBlock(header, body, sourceCounter[0]++) + } + } + + return blocks to fontFaceBlocks + } + + private fun mediaQueryApplies( + header: String, + constraints: Constraints, + isDarkTheme: Boolean, + adaptThemeColors: Boolean + ): Boolean { + val query = header.removePrefix("@media").trim().lowercase() + if (query.isBlank() || query == "all" || query == "screen") return true + if (query.contains("print")) return false + if (query.contains("prefers-color-scheme")) { + val wantsDark = query.contains("prefers-color-scheme") && query.contains("dark") + val wantsLight = query.contains("prefers-color-scheme") && query.contains("light") + if (!adaptThemeColors) return !wantsDark + if (wantsDark && !isDarkTheme) return false + if (wantsLight && isDarkTheme) return false + } + Regex("""min-width\s*:\s*([^)]+)""").findAll(query).forEach { match -> + val minWidth = parseCssDimension(match.groupValues[1], 16f, 1f, constraints.maxWidth) + if (minWidth.isSpecified && minWidth.value > constraints.maxWidth) return false + } + Regex("""max-width\s*:\s*([^)]+)""").findAll(query).forEach { match -> + val maxWidth = parseCssDimension(match.groupValues[1], 16f, 1f, constraints.maxWidth) + if (maxWidth.isSpecified && maxWidth.value < constraints.maxWidth) return false + } + return true + } + + private fun splitCssList(value: String, delimiter: Char): List { + val result = mutableListOf() + val current = StringBuilder() + var quote: Char? = null + var escaped = false + var parenDepth = 0 + var bracketDepth = 0 + value.forEach { ch -> + when { + escaped -> { + current.append(ch) + escaped = false + } + ch == '\\' -> { + current.append(ch) + escaped = true + } + quote != null -> { + current.append(ch) + if (ch == quote) quote = null + } + ch == '"' || ch == '\'' -> { + current.append(ch) + quote = ch + } + ch == '(' -> { + current.append(ch) + parenDepth++ + } + ch == ')' -> { + current.append(ch) + parenDepth = (parenDepth - 1).coerceAtLeast(0) + } + ch == '[' -> { + current.append(ch) + bracketDepth++ + } + ch == ']' -> { + current.append(ch) + bracketDepth = (bracketDepth - 1).coerceAtLeast(0) + } + ch == delimiter && parenDepth == 0 && bracketDepth == 0 -> { + result += current.toString() + current.clear() + } + else -> current.append(ch) + } + } + if (current.isNotBlank()) result += current.toString() + return result + } + + private fun splitCssTokens(value: String): List { + val result = mutableListOf() + val current = StringBuilder() + var quote: Char? = null + var escaped = false + var parenDepth = 0 + value.forEach { ch -> + when { + escaped -> { + current.append(ch) + escaped = false + } + ch == '\\' -> { + current.append(ch) + escaped = true + } + quote != null -> { + current.append(ch) + if (ch == quote) quote = null + } + ch == '"' || ch == '\'' -> { + current.append(ch) + quote = ch + } + ch == '(' -> { + current.append(ch) + parenDepth++ + } + ch == ')' -> { + current.append(ch) + parenDepth = (parenDepth - 1).coerceAtLeast(0) + } + ch.isWhitespace() && parenDepth == 0 -> { + if (current.isNotBlank()) { + result += current.toString() + current.clear() + } + } + else -> current.append(ch) + } + } + if (current.isNotBlank()) result += current.toString() + return result + } + + private fun parseSelector(selector: String): ParsedSelector { + var pseudoElement: String? = null + var sanitized = selector + Regex("::?(before|after)\\b", RegexOption.IGNORE_CASE).find(sanitized)?.let { match -> + pseudoElement = match.groupValues[1].lowercase() + sanitized = sanitized.removeRange(match.range) + } + sanitized = sanitized + .replace(Regex(":(link|visited|hover|active|focus)\\b", RegexOption.IGNORE_CASE), "") + .replace(Regex("::?(first-letter|first-line|marker|selection)\\b", RegexOption.IGNORE_CASE), "") + .replace(Regex(":root\\b", RegexOption.IGNORE_CASE), "html") + .trim() + return ParsedSelector(sanitized, pseudoElement) + } + private fun calculateSpecificity(selector: String): Int { val ids = ID_SELECTOR_REGEX.findAll(selector).count() val classesAndAttributes = CLASS_ATTRIBUTE_SELECTOR_REGEX.findAll(selector).count() @@ -187,46 +497,38 @@ object CssParser { val otherComplex = mutableListOf() val fontFaces = mutableListOf() - val blockRegex = "([^{}]+)\\s*\\{([^}]+)\\}".toRegex() - - var cleanedCss = cssContent.replace(Regex("/\\*.*?\\*/", RegexOption.DOT_MATCHES_ALL), "") - - val mediaQueryRegex = Regex("@media[^{]+\\{((?>[^{}]+|\\{[^{}]*\\})*)\\}") - mediaQueryRegex.findAll(cleanedCss).forEach { match -> - val condition = match.groups[0]?.value?.trim() ?: "" - if (adaptThemeColors && isDarkTheme && condition.contains("prefers-color-scheme: dark")) { - val darkCss = match.groups[1]?.value ?: "" - cleanedCss += "\n$darkCss" - } - } - cleanedCss = mediaQueryRegex.replace(cleanedCss, "") - - ReaderCssLog.d("CssParser: Checking for @font-face rules...") - val fontFaceMatches = FONT_FACE_REGEX.findAll(cleanedCss) - if (!fontFaceMatches.any()) { - ReaderCssLog.d("CssParser: No @font-face rules found by regex.") - } - fontFaceMatches.forEach { match -> - ReaderCssLog.d("CssParser: Found a @font-face block. Parsing its properties.") - val properties = match.groupValues[1] + val cleanedCss = stripCssComments(cssContent) + val (styleBlocks, fontFaceBlocks) = parseCssBlocks( + css = cleanedCss, + constraints = constraints, + isDarkTheme = isDarkTheme, + adaptThemeColors = adaptThemeColors + ) + fontFaceBlocks.forEach { properties -> parseFontFace(properties, cssPath)?.let { fontFaces.add(it) } } - cleanedCss = FONT_FACE_REGEX.replace(cleanedCss, "") - blockRegex.findAll(cleanedCss).forEach { matchResult -> - val selectorGroup = matchResult.groups[1]?.value?.trim() ?: "" - val propertiesGroup = matchResult.groups[2]?.value?.trim() ?: "" + val rootCustomProperties = styleBlocks + .filter { block -> + splitCssList(block.header, ',').any { selector -> + val normalized = selector.trim().lowercase() + normalized == ":root" || normalized == "html" || normalized == "body" + } + } + .fold(emptyMap()) { acc, block -> acc + extractCustomProperties(block.body, acc) } - val selectors = selectorGroup.split(',').map { it.trim() } + val allRules = mutableListOf() + styleBlocks.forEach { block -> + val selectorGroup = block.header.trim() + val propertiesGroup = block.body.trim() + val selectors = splitCssList(selectorGroup, ',').map { it.trim() } for (originalSelector in selectors) { - if (originalSelector.isBlank() || originalSelector.startsWith("@")) { + if (originalSelector.isBlank()) { continue } - val sanitizedSelector = originalSelector.replace( - Regex(":(link|visited|hover|active|focus)\\b|::(first-letter|first-line|marker)\\b", RegexOption.IGNORE_CASE), - "" - ).trim() + val parsedSelector = parseSelector(originalSelector) + val sanitizedSelector = parsedSelector.selector if (sanitizedSelector.isBlank()) { continue } @@ -240,7 +542,8 @@ object CssParser { isDarkTheme = isDarkTheme, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor, - adaptThemeColors = adaptThemeColors + adaptThemeColors = adaptThemeColors, + inheritedCustomProperties = rootCustomProperties ) val importantStyle = parseProperties( properties = propertiesGroup, @@ -251,18 +554,25 @@ object CssParser { isDarkTheme = isDarkTheme, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor, - adaptThemeColors = adaptThemeColors + adaptThemeColors = adaptThemeColors, + inheritedCustomProperties = rootCustomProperties ) fun addRule(style: CssStyle, spec: Int) { if (style == CssStyle()) return - val rule = CssRule(CssSelector(sanitizedSelector, spec), style) + val rule = CssRule( + selector = CssSelector(sanitizedSelector, spec), + style = style, + pseudoElement = parsedSelector.pseudoElement, + sourceOrder = block.sourceOrder + ) + allRules += rule when { - SIMPLE_ID_SELECTOR.matches(sanitizedSelector) -> + parsedSelector.pseudoElement == null && SIMPLE_ID_SELECTOR.matches(sanitizedSelector) -> byId.getOrPut(sanitizedSelector.substring(1)) { mutableListOf() }.add(rule) - SIMPLE_CLASS_SELECTOR.matches(sanitizedSelector) -> + parsedSelector.pseudoElement == null && SIMPLE_CLASS_SELECTOR.matches(sanitizedSelector) -> byClass.getOrPut(sanitizedSelector.substring(1)) { mutableListOf() }.add(rule) - SIMPLE_TAG_SELECTOR.matches(sanitizedSelector) -> + parsedSelector.pseudoElement == null && SIMPLE_TAG_SELECTOR.matches(sanitizedSelector) -> byTag.getOrPut(sanitizedSelector) { mutableListOf() }.add(rule) else -> otherComplex.add(rule) } @@ -272,7 +582,7 @@ object CssParser { addRule(importantStyle, specificity + IMPORTANT_SPECIFICITY_BOOST) } } - val optimizedRules = OptimizedCssRules(byTag, byClass, byId, otherComplex) + val optimizedRules = OptimizedCssRules(byTag, byClass, byId, otherComplex, allRules) return OptimizedCssParseResult(optimizedRules, fontFaces) } @@ -392,14 +702,21 @@ object CssParser { isDarkTheme: Boolean, themeBackgroundColor: Color = Color.Unspecified, themeTextColor: Color = Color.Unspecified, - adaptThemeColors: Boolean = true + adaptThemeColors: Boolean = true, + inheritedCustomProperties: Map = emptyMap() ): CssStyle { + val localCustomProperties = if (!onlyImportant) extractCustomProperties(properties, inheritedCustomProperties) else emptyMap() + val customProperties = inheritedCustomProperties + localCustomProperties + var hasApplicableDeclaration = false var spanStyle = SpanStyle() var paragraphStyle = ParagraphStyle() var padding = BoxBorders() var width: Dp = Dp.Unspecified var maxWidth: Dp = Dp.Unspecified var height: Dp = Dp.Unspecified + var minWidth: Dp = Dp.Unspecified + var minHeight: Dp = Dp.Unspecified + var maxHeight: Dp = Dp.Unspecified var backgroundColor: Color = Color.Unspecified // Changed: Track the max width found to prioritize visible borders @@ -432,6 +749,18 @@ object CssParser { var borderCollapse: String? = null var borderSpacing: Dp = 0.dp var borderRadius: Dp = 0.dp + var overflow: String? = null + var breakBefore: String? = null + var breakAfter: String? = null + var breakInside: String? = null + var widows: Int = 2 + var orphans: Int = 2 + var visibility: String? = null + var objectFit: String? = null + var objectPosition: String? = null + var backgroundImage: String? = null + var whiteSpace: String? = null + var verticalAlign: String? = null var hyphens: String? = null var fontVariantNumeric: String? = null var textEmphasisStyleString: String? = null @@ -481,15 +810,21 @@ object CssParser { val key = parts[0].lowercase() val valueWithImportant = parts[1] val isImportant = valueWithImportant.contains("!important", ignoreCase = true) + if (key.startsWith("--")) { + return@forEach + } if (isImportant != onlyImportant) { return@forEach } - val value = if (isImportant) { + hasApplicableDeclaration = true + val rawValue = if (isImportant) { valueWithImportant.replace(Regex("\\s*!important", RegexOption.IGNORE_CASE), "").trim() } else { - valueWithImportant + valueWithImportant.trim() } + val value = resolveCssVariables(rawValue, customProperties) + val valueLower = value.trim().lowercase() fun updateUnifiedBorder( widthStr: String?, @@ -497,7 +832,7 @@ object CssParser { styleStr: String? ) { val parsedWidth = widthStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp - val parsedColor = colorStr?.let { parseColor(it) }?.let { maybeAdaptColor(it, isBackground = false) } + val parsedColor = colorStr?.let { parseColor(it, spanStyle.color) }?.let { maybeAdaptColor(it, isBackground = false) } val isExplicitWidth = widthStr != null @@ -516,7 +851,7 @@ object CssParser { when (key) { "font-family" -> { - fontFamilies = value.split(',') + fontFamilies = splitCssList(value, ',') .map { it.trim().removeSurrounding("\"").removeSurrounding("'").lowercase() } } "font-size" -> { @@ -533,7 +868,7 @@ object CssParser { } } "font-weight" -> { - spanStyle = spanStyle.copy(fontWeight = when (value) { + spanStyle = spanStyle.copy(fontWeight = when (valueLower) { "bold" -> FontWeight.Bold "700" -> FontWeight.Bold "600" -> FontWeight.SemiBold @@ -543,30 +878,31 @@ object CssParser { "100" -> FontWeight.Thin "normal" -> FontWeight.Normal "400" -> FontWeight.Normal - else -> value.toIntOrNull()?.let { FontWeight(it) } ?: spanStyle.fontWeight + else -> valueLower.toIntOrNull()?.let { FontWeight(it) } ?: spanStyle.fontWeight }) } "font-style" -> { - if (value == "italic" || value == "oblique") spanStyle = spanStyle.copy(fontStyle = FontStyle.Italic) - else if (value == "normal") spanStyle = spanStyle.copy(fontStyle = FontStyle.Normal) + if (valueLower == "italic" || valueLower == "oblique") spanStyle = spanStyle.copy(fontStyle = FontStyle.Italic) + else if (valueLower == "normal") spanStyle = spanStyle.copy(fontStyle = FontStyle.Normal) } "color" -> { - parseColor(value)?.let { + parseColor(value, spanStyle.color)?.let { spanStyle = spanStyle.copy(color = maybeAdaptColor(it, isBackground = false)) } } "text-align" -> { - val align = when (value) { + val align = when (valueLower) { "center" -> TextAlign.Center - "right" -> TextAlign.End + "right", "end" -> TextAlign.End "justify" -> TextAlign.Justify + "left", "start" -> TextAlign.Start else -> TextAlign.Start } paragraphStyle = paragraphStyle.copy(textAlign = align) } "line-height" -> { val trimmedValue = value.trim() - var lineHeight = when { + val lineHeight = when { trimmedValue.endsWith("%") -> { val percentage = trimmedValue.removeSuffix("%").toFloatOrNull() if (percentage != null) { @@ -580,9 +916,6 @@ object CssParser { } else -> parseCssDimensionToTextUnit(trimmedValue, containerWidthPx, density) } - if (lineHeight.isEm && lineHeight.value < 1.2f && lineHeight.value > 0) { - lineHeight = 2f.em - } if (lineHeight != TextUnit.Unspecified) { paragraphStyle = paragraphStyle.copy(lineHeight = lineHeight) } @@ -594,7 +927,7 @@ object CssParser { } } "text-decoration" -> { - val parts = value.split(" ") + val parts = splitCssTokens(valueLower) val decos = mutableListOf() if (parts.contains("underline")) decos.add(TextDecoration.Underline) @@ -608,7 +941,7 @@ object CssParser { val styles = listOf("solid", "double", "dotted", "dashed", "wavy") parts.firstOrNull { it in styles }?.let { textDecorationStyle = it } - parts.firstNotNullOfOrNull { parseColor(it) }?.let { color -> + parts.firstNotNullOfOrNull { parseColor(it, spanStyle.color) }?.let { color -> textDecorationColor = maybeAdaptColor(color, isBackground = false) } } @@ -621,10 +954,10 @@ object CssParser { } } "text-decoration-style" -> { - textDecorationStyle = value + textDecorationStyle = valueLower } "text-decoration-color" -> { - parseColor(value)?.let { + parseColor(value, spanStyle.color)?.let { textDecorationColor = maybeAdaptColor(it, isBackground = false) } } @@ -638,8 +971,8 @@ object CssParser { } } "text-transform" -> { - textTransform = when (value) { - "uppercase", "lowercase", "capitalize", "none" -> value + textTransform = when (valueLower) { + "uppercase", "lowercase", "capitalize", "none" -> valueLower else -> null } } @@ -649,7 +982,7 @@ object CssParser { } } "margin" -> { - val marginParts = value.split(' ').filter { it.isNotBlank() } + val marginParts = splitCssTokens(value) when (marginParts.size) { 1 -> { marginTopStr = marginParts[0]; marginRightStr = marginParts[0]; marginBottomStr = marginParts[0]; marginLeftStr = marginParts[0] @@ -682,11 +1015,25 @@ object CssParser { "width" -> width = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) "max-width" -> maxWidth = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) "height" -> height = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) + "min-width" -> minWidth = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) + "min-height" -> minHeight = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) + "max-height" -> maxHeight = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) "background-color" -> { - val originalColor = parseColor(value) ?: Color.Unspecified + val originalColor = parseColor(value, spanStyle.color) ?: Color.Unspecified backgroundColor = maybeAdaptColor(originalColor, isBackground = true) } + "background-image" -> backgroundImage = value.takeIf { valueLower != "none" } + "background" -> { + URL_REGEX.find(value)?.groupValues?.getOrNull(2)?.takeIf { it.isNotBlank() }?.let { + backgroundImage = it + } + splitCssTokens(value).firstNotNullOfOrNull { token -> + parseColor(token, spanStyle.color) + }?.let { color -> + backgroundColor = maybeAdaptColor(color, isBackground = true) + } + } // Border Properties "border-width" -> { @@ -707,15 +1054,15 @@ object CssParser { "border-bottom-width" -> borderBottomWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "border-left-width" -> borderLeftWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) - "border-top-style" -> borderTopStyle = value - "border-right-style" -> borderRightStyle = value - "border-bottom-style" -> borderBottomStyle = value - "border-left-style" -> borderLeftStyle = value + "border-top-style" -> borderTopStyle = valueLower + "border-right-style" -> borderRightStyle = valueLower + "border-bottom-style" -> borderBottomStyle = valueLower + "border-left-style" -> borderLeftStyle = valueLower - "border-top-color" -> borderTopColor = parseColor(value) - "border-right-color" -> borderRightColor = parseColor(value) - "border-bottom-color" -> borderBottomColor = parseColor(value) - "border-left-color" -> borderLeftColor = parseColor(value) + "border-top-color" -> borderTopColor = parseColor(value, spanStyle.color) + "border-right-color" -> borderRightColor = parseColor(value, spanStyle.color) + "border-bottom-color" -> borderBottomColor = parseColor(value, spanStyle.color) + "border-left-color" -> borderLeftColor = parseColor(value, spanStyle.color) "border-top" -> { val (w, s, c) = parseBorderShorthand(value, baseFontSizeSp, density, containerWidthPx) @@ -762,8 +1109,8 @@ object CssParser { "border-bottom-left-radius" -> borderBottomLeftRadius = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "border-collapse" -> { - if (value in listOf("collapse", "separate")) { - borderCollapse = value + if (valueLower in listOf("collapse", "separate")) { + borderCollapse = valueLower } } "border-spacing" -> { @@ -771,51 +1118,82 @@ object CssParser { } "list-style-type" -> { - listStyleType = value + listStyleType = valueLower } "list-style-image" -> { URL_REGEX.find(value)?.groupValues?.get(2)?.let { listStyleImage = it } } + "list-style" -> { + URL_REGEX.find(value)?.groupValues?.get(2)?.takeIf { it.isNotBlank() }?.let { + listStyleImage = it + } + val positions = setOf("inside", "outside") + splitCssTokens(valueLower) + .firstOrNull { token -> token !in positions && !token.startsWith("url(") } + ?.let { listStyleType = it } + } "page-break-inside" -> { - if (value == "avoid") { + if (valueLower == "avoid") { pageBreakInsideAvoid = true } + breakInside = normalizeBreakValue(valueLower) } "page-break-after" -> { - if (value == "avoid") { + if (valueLower == "avoid") { pageBreakAfterAvoid = true } + breakAfter = normalizeBreakValue(valueLower) } - "display" -> display = value - "flex-direction" -> flexDirection = value - "justify-content" -> justifyContent = value - "align-items" -> alignItems = value - "filter" -> filter = value - "box-sizing" -> boxSizing = value - "content" -> content = value.removeSurrounding("\"").removeSurrounding("'") - "position" -> position = value + "page-break-before" -> breakBefore = normalizeBreakValue(valueLower) + "break-before" -> breakBefore = normalizeBreakValue(valueLower) + "break-after" -> breakAfter = normalizeBreakValue(valueLower) + "break-inside" -> breakInside = normalizeBreakValue(valueLower) + "display" -> display = valueLower + "flex-direction" -> flexDirection = valueLower + "justify-content" -> justifyContent = valueLower + "align-items" -> alignItems = valueLower + "filter" -> filter = valueLower + "box-sizing" -> boxSizing = valueLower + "content" -> content = value + "position" -> position = valueLower "left" -> left = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "right" -> right = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "top" -> top = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "bottom" -> bottom = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "float" -> { - if (value in listOf("left", "right", "none")) { - float = value + if (valueLower in listOf("left", "right", "none")) { + float = valueLower } } "hyphens", "-webkit-hyphens", "-moz-hyphens", "-epub-hyphens", "adobe-hyphenate" -> { - if (value in listOf("auto", "manual", "none")) { - hyphens = value + if (valueLower in listOf("auto", "manual", "none")) { + hyphens = valueLower } } - "font-variant-numeric" -> { - fontVariantNumeric = value + "white-space" -> { + if (valueLower in listOf("normal", "nowrap", "pre", "pre-wrap", "pre-line", "break-spaces")) { + whiteSpace = valueLower + } } + "visibility" -> { + if (valueLower in listOf("visible", "hidden", "collapse")) visibility = valueLower + } + "overflow" -> { + if (valueLower in listOf("visible", "hidden", "clip", "scroll", "auto")) overflow = valueLower + } + "font-variant-numeric" -> { + fontVariantNumeric = valueLower + } + "widows" -> widows = valueLower.toIntOrNull()?.coerceAtLeast(1) ?: widows + "orphans" -> orphans = valueLower.toIntOrNull()?.coerceAtLeast(1) ?: orphans + "vertical-align" -> verticalAlign = valueLower + "object-fit" -> objectFit = valueLower + "object-position" -> objectPosition = rawValue "clear" -> { - if (value in listOf("left", "right", "both", "none")) { - clear = value + if (valueLower in listOf("left", "right", "both", "none")) { + clear = valueLower } } "text-emphasis", "-epub-text-emphasis" -> { @@ -825,16 +1203,19 @@ object CssParser { textEmphasisStyleString = value } "text-emphasis-color", "-epub-text-emphasis-color" -> { - textEmphasisColor = parseColor(value)?.let { maybeAdaptColor(it, isBackground = false) } + textEmphasisColor = parseColor(value, spanStyle.color)?.let { maybeAdaptColor(it, isBackground = false) } } "text-emphasis-position", "-epub-text-emphasis-position" -> { - if (value in listOf("over", "under")) { - textEmphasisPositionString = value + if (valueLower in listOf("over", "under")) { + textEmphasisPositionString = valueLower } } } } } + if (!hasApplicableDeclaration && localCustomProperties.isEmpty()) { + return CssStyle() + } val finalHorizontalAlign = if (marginLeftStr == "auto" && marginRightStr == "auto") "center" else null val margin = BoxBorders( @@ -926,16 +1307,29 @@ object CssParser { horizontalAlign = finalHorizontalAlign, filter = filter, borderCollapse = borderCollapse, - borderSpacing = borderSpacing + borderSpacing = borderSpacing, + minWidth = minWidth, + minHeight = minHeight, + maxHeight = maxHeight, + overflow = overflow, + breakBefore = breakBefore, + breakAfter = breakAfter, + breakInside = breakInside, + widows = widows, + orphans = orphans, + visibility = visibility, + objectFit = objectFit, + objectPosition = objectPosition, + backgroundImage = backgroundImage ) return CssStyle( spanStyle, paragraphStyle, blockStyle, fontFamilies, display, fontSize, textTransform, boxSizing, content, hyphens, fontVariantNumeric, textEmphasis, - wordSpacing, textDecorationStyle, textDecorationColor, textUnderlineOffset + wordSpacing, textDecorationStyle, textDecorationColor, textUnderlineOffset, whiteSpace, verticalAlign, customProperties ) } private fun parseShorthand4(value: String, baseFontSize: Float, density: Float, containerWidth: Int): List { - val parts = value.split(' ').filter { it.isNotBlank() } + val parts = splitCssTokens(value) val dps = parts.map { parseCssSizeToDp(it, baseFontSize, density, containerWidth) } return when (dps.size) { 1 -> listOf(dps[0], dps[0], dps[0], dps[0]) @@ -947,7 +1341,7 @@ object CssParser { } private fun parseShorthand4Strings(value: String): List { - val parts = value.split(' ').filter { it.isNotBlank() } + val parts = splitCssTokens(value).map { it.lowercase() } return when (parts.size) { 1 -> listOf(parts[0], parts[0], parts[0], parts[0]) 2 -> listOf(parts[0], parts[1], parts[0], parts[1]) @@ -962,7 +1356,7 @@ object CssParser { val c = parseColor(value) return listOf(c, c, c, c) } - val parts = value.split(' ').filter { it.isNotBlank() } + val parts = splitCssTokens(value) val colors = parts.map { parseColor(it) } return when (colors.size) { 1 -> listOf(colors[0], colors[0], colors[0], colors[0]) @@ -974,7 +1368,7 @@ object CssParser { } private fun parseBorderShorthand(value: String, baseFontSize: Float, density: Float, containerWidth: Int): Triple { - val parts = value.split(" ").filter { it.isNotBlank() } + val parts = splitCssTokens(value) var w: Dp? = null var s: String? = null var c: Color? = null @@ -998,6 +1392,42 @@ object CssParser { return Triple(w, s, c) } + private fun extractCustomProperties( + properties: String, + inheritedCustomProperties: Map + ): Map { + val result = linkedMapOf() + splitDeclarations(properties).forEach { declaration -> + val parts = declaration.split(':', limit = 2).map { it.trim() } + if (parts.size != 2 || !parts[0].startsWith("--")) return@forEach + val rawValue = parts[1].replace(Regex("\\s*!important", RegexOption.IGNORE_CASE), "").trim() + result[parts[0]] = resolveCssVariables(rawValue, inheritedCustomProperties + result) + } + return result + } + + private fun resolveCssVariables(value: String, customProperties: Map): String { + if (!value.contains("var(")) return value + var resolved = value + repeat(8) { + val next = Regex("""var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,\s*([^()]*))?\)""").replace(resolved) { match -> + customProperties[match.groupValues[1]] ?: match.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() } ?: "" + } + if (next == resolved) return resolved + resolved = next + } + return resolved + } + + private fun normalizeBreakValue(value: String): String? { + return when (value.lowercase()) { + "always" -> "page" + "avoid", "avoid-page", "page", "left", "right", "recto", "verso" -> value.lowercase() + "auto" -> null + else -> null + } + } + internal fun parseCssDimension( size: String, baseFontSizeSp: Float, @@ -1005,10 +1435,19 @@ object CssParser { containerWidthPx: Int ): Dp { val trimmed = size.trim().lowercase() - if (trimmed in listOf("auto", "none", "max-content", "min-content", "fit-content", "inherit", "initial")) { + if (trimmed in listOf("auto", "none", "max-content", "min-content", "fit-content", "inherit", "initial", "unset", "revert")) { return Dp.Unspecified } if (trimmed == "0" || trimmed == "0px") return 0.dp + if (trimmed.startsWith("calc(") && trimmed.endsWith(")")) { + val px = evaluateCssLengthExpression( + expression = trimmed.removePrefix("calc(").removeSuffix(")"), + baseFontSizeSp = baseFontSizeSp, + density = density, + containerWidthPx = containerWidthPx + ) + if (px != null) return (px / density).dp + } return when { trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.let { (it / density).dp } ?: Dp.Unspecified @@ -1038,6 +1477,86 @@ object CssParser { } } + private fun evaluateCssLengthExpression( + expression: String, + baseFontSizeSp: Float, + density: Float, + containerWidthPx: Int + ): Float? { + val tokens = Regex("""(\d*\.?\d+(?:px|dp|em|rem|pt|%)?)|([+\-*/()])""") + .findAll(expression.replace("\\s+".toRegex(), "")) + .map { it.value } + .toList() + if (tokens.isEmpty()) return null + + var index = 0 + var parseExpression: (() -> Float?)? = null + fun parseNumber(token: String): Float? { + return when { + token.endsWith("px") -> token.removeSuffix("px").toFloatOrNull() + token.endsWith("dp") -> token.removeSuffix("dp").toFloatOrNull()?.let { it * density } + token.endsWith("em") -> token.removeSuffix("em").toFloatOrNull()?.let { it * baseFontSizeSp * density } + token.endsWith("rem") -> token.removeSuffix("rem").toFloatOrNull()?.let { it * baseFontSizeSp * density } + token.endsWith("pt") -> token.removeSuffix("pt").toFloatOrNull()?.let { it * 1.333f * density } + token.endsWith("%") -> token.removeSuffix("%").toFloatOrNull()?.let { (it / 100f) * containerWidthPx } + else -> token.toFloatOrNull() + } + } + + fun parseFactor(): Float? { + val token = tokens.getOrNull(index++) ?: return null + return when (token) { + "+" -> parseFactor() + "-" -> parseFactor()?.let { -it } + "(" -> { + val value = parseExpression?.invoke() ?: return null + if (tokens.getOrNull(index) == ")") index++ + value + } + else -> parseNumber(token) + } + } + + fun parseTerm(): Float? { + var value = parseFactor() ?: return null + while (true) { + when (tokens.getOrNull(index)) { + "*" -> { + index++ + value *= parseFactor() ?: return null + } + "/" -> { + index++ + val divisor = parseFactor() ?: return null + if (divisor == 0f) return null + value /= divisor + } + else -> return value + } + } + } + + parseExpression = fun(): Float? { + var value = parseTerm() ?: return null + while (true) { + when (tokens.getOrNull(index)) { + "+" -> { + index++ + value += parseTerm() ?: return null + } + "-" -> { + index++ + value -= parseTerm() ?: return null + } + else -> return value + } + } + } + + val result = parseExpression?.invoke() + return if (result != null && index == tokens.size) result else null + } + internal fun parseCssSizeToDp( size: String, baseFontSizeSp: Float, @@ -1050,9 +1569,10 @@ object CssParser { return if (dim.isSpecified) dim else 0.dp } - internal fun parseColor(colorString: String): Color? { + internal fun parseColor(colorString: String, currentColor: Color = Color.Unspecified): Color? { val sanitized = colorString.trim().lowercase() return when { + sanitized == "currentcolor" -> currentColor.takeIf { it.isSpecified } sanitized.startsWith("#") -> { val hex = sanitized.substring(1) val colorLong = hex.toLongOrNull(16) ?: return null @@ -1063,6 +1583,13 @@ object CssParser { val b = colorLong and 0x00F Color((r * 17).toInt(), (g * 17).toInt(), (b * 17).toInt(), 255) } + 4 -> { + val r = (colorLong and 0xF000) shr 12 + val g = (colorLong and 0x0F00) shr 8 + val b = (colorLong and 0x00F0) shr 4 + val a = colorLong and 0x000F + Color((r * 17).toInt(), (g * 17).toInt(), (b * 17).toInt(), (a * 17).toInt()) + } 6 -> Color( ((colorLong shr 16) and 0xFF).toInt(), ((colorLong shr 8) and 0xFF).toInt(), @@ -1070,36 +1597,69 @@ object CssParser { 255 ) // #RRGGBB 8 -> Color( + ((colorLong shr 24) and 0xFF).toInt(), ((colorLong shr 16) and 0xFF).toInt(), ((colorLong shr 8) and 0xFF).toInt(), - (colorLong and 0xFF).toInt(), - ((colorLong shr 24) and 0xFF).toInt() - ) // #AARRGGBB + (colorLong and 0xFF).toInt() + ) // CSS #RRGGBBAA else -> null } } sanitized.startsWith("rgb") -> { - val isRgba = sanitized.startsWith("rgba") val valuesString = sanitized.substringAfter('(').substringBefore(')') - val values = valuesString.split(',').map { it.trim() } + val alphaParts = valuesString.split('/').map { it.trim() } + val values = if (alphaParts.first().contains(',')) { + alphaParts.first().split(',').map { it.trim() } + } else { + splitCssTokens(alphaParts.first()) + } if (values.size < 3) return null - val r = values[0].toIntOrNull() ?: 0 - val g = values[1].toIntOrNull() ?: 0 - val b = values[2].toIntOrNull() ?: 0 - val a = if (isRgba && values.size == 4) (values[3].toFloatOrNull() ?: 1f) else 1f + fun channel(part: String): Int { + return if (part.endsWith("%")) { + (((part.removeSuffix("%").toFloatOrNull() ?: 0f) / 100f) * 255f).roundToInt() + } else { + part.toFloatOrNull()?.roundToInt() ?: 0 + }.coerceIn(0, 255) + } + fun alpha(part: String?): Float { + if (part.isNullOrBlank()) return 1f + return if (part.endsWith("%")) { + ((part.removeSuffix("%").toFloatOrNull() ?: 100f) / 100f) + } else { + part.toFloatOrNull() ?: 1f + }.coerceIn(0f, 1f) + } + + val r = channel(values[0]) + val g = channel(values[1]) + val b = channel(values[2]) + val a = alpha(alphaParts.getOrNull(1) ?: values.getOrNull(3)) Color(r, g, b, (a * 255).roundToInt()) } + sanitized.startsWith("hsl") -> parseHslColor(sanitized) else -> when(sanitized) { "black" -> Color.Black "white" -> Color.White "red" -> Color.Red - "green" -> Color.Green + "green" -> Color(0, 128, 0) + "lime" -> Color.Green "blue" -> Color.Blue "gray", "grey" -> Color.Gray + "silver" -> Color(192, 192, 192) + "maroon" -> Color(128, 0, 0) + "olive" -> Color(128, 128, 0) + "purple" -> Color(128, 0, 128) + "teal" -> Color(0, 128, 128) + "navy" -> Color(0, 0, 128) + "orange" -> Color(255, 165, 0) + "brown" -> Color(165, 42, 42) + "pink" -> Color(255, 192, 203) "cyan" -> Color.Cyan + "aqua" -> Color.Cyan + "fuchsia" -> Color.Magenta "magenta" -> Color.Magenta "yellow" -> Color.Yellow "transparent" -> Color.Transparent @@ -1108,8 +1668,46 @@ object CssParser { } } + private fun parseHslColor(value: String): Color? { + val valuesString = value.substringAfter('(').substringBefore(')') + val alphaParts = valuesString.split('/').map { it.trim() } + val values = if (alphaParts.first().contains(',')) { + alphaParts.first().split(',').map { it.trim() } + } else { + splitCssTokens(alphaParts.first()) + } + if (values.size < 3) return null + val hue = values[0].removeSuffix("deg").toFloatOrNull() ?: return null + val saturation = values[1].removeSuffix("%").toFloatOrNull()?.div(100f) ?: return null + val lightness = values[2].removeSuffix("%").toFloatOrNull()?.div(100f) ?: return null + val alpha = alphaParts.getOrNull(1)?.let { + if (it.endsWith("%")) (it.removeSuffix("%").toFloatOrNull() ?: 100f) / 100f else it.toFloatOrNull() ?: 1f + } ?: values.getOrNull(3)?.let { + if (it.endsWith("%")) (it.removeSuffix("%").toFloatOrNull() ?: 100f) / 100f else it.toFloatOrNull() ?: 1f + } ?: 1f + + val c = (1f - kotlin.math.abs(2f * lightness - 1f)) * saturation + val x = c * (1f - kotlin.math.abs((hue / 60f) % 2f - 1f)) + val m = lightness - c / 2f + val normalizedHue = ((hue % 360f) + 360f) % 360f + val (r1, g1, b1) = when { + normalizedHue < 60f -> Triple(c, x, 0f) + normalizedHue < 120f -> Triple(x, c, 0f) + normalizedHue < 180f -> Triple(0f, c, x) + normalizedHue < 240f -> Triple(0f, x, c) + normalizedHue < 300f -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) + } + return Color( + ((r1 + m) * 255f).roundToInt().coerceIn(0, 255), + ((g1 + m) * 255f).roundToInt().coerceIn(0, 255), + ((b1 + m) * 255f).roundToInt().coerceIn(0, 255), + (alpha.coerceIn(0f, 1f) * 255f).roundToInt() + ) + } + private fun parseBoxBorders(value: String, baseFontSizeSp: Float, density: Float, containerWidthPx: Int): BoxBorders { - val parts = value.split(' ').map { it.trim() }.filter { it.isNotEmpty() } + val parts = splitCssTokens(value) val dps = parts.map { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } return when (dps.size) { 1 -> BoxBorders(top = dps[0], right = dps[0], bottom = dps[0], left = dps[0]) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/FontFamilyMapper.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt similarity index 95% rename from shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/FontFamilyMapper.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt index 63e6d13..3c6dd99 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/FontFamilyMapper.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import androidx.compose.ui.text.font.FontFamily diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/PaginatedReaderData.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt similarity index 83% rename from shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/PaginatedReaderData.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt index b2142fc..fd5e478 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/PaginatedReaderData.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt @@ -18,7 +18,7 @@ * mail: epistemereader@gmail.com */ @file:OptIn(ExperimentalSerializationApi::class) -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified @@ -32,13 +32,13 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified -import com.aryan.reader.paginatedreader.serialization.AnnotatedStringSerializer -import com.aryan.reader.paginatedreader.serialization.ColorSerializer -import com.aryan.reader.paginatedreader.serialization.DpSerializer -import com.aryan.reader.paginatedreader.serialization.ParagraphStyleSerializer -import com.aryan.reader.paginatedreader.serialization.SpanStyleSerializer -import com.aryan.reader.paginatedreader.serialization.TextAlignSerializer -import com.aryan.reader.paginatedreader.serialization.TextUnitSerializer +import org.dueattendant149.bookreader.paginatedreader.serialization.AnnotatedStringSerializer +import org.dueattendant149.bookreader.paginatedreader.serialization.ColorSerializer +import org.dueattendant149.bookreader.paginatedreader.serialization.DpSerializer +import org.dueattendant149.bookreader.paginatedreader.serialization.ParagraphStyleSerializer +import org.dueattendant149.bookreader.paginatedreader.serialization.SpanStyleSerializer +import org.dueattendant149.bookreader.paginatedreader.serialization.TextAlignSerializer +import org.dueattendant149.bookreader.paginatedreader.serialization.TextUnitSerializer import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.protobuf.ProtoNumber @@ -78,7 +78,20 @@ data class BlockStyle( @ProtoNumber(31) @Serializable(with = DpSerializer::class) val borderTopRightRadius: Dp = 0.dp, @ProtoNumber(32) @Serializable(with = DpSerializer::class) val borderBottomRightRadius: Dp = 0.dp, @ProtoNumber(33) @Serializable(with = DpSerializer::class) val borderBottomLeftRadius: Dp = 0.dp, - @ProtoNumber(34) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp + @ProtoNumber(34) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp, + @ProtoNumber(35) @Serializable(with = DpSerializer::class) val minWidth: Dp = Dp.Unspecified, + @ProtoNumber(36) @Serializable(with = DpSerializer::class) val minHeight: Dp = Dp.Unspecified, + @ProtoNumber(37) @Serializable(with = DpSerializer::class) val maxHeight: Dp = Dp.Unspecified, + @ProtoNumber(38) val overflow: String? = null, + @ProtoNumber(39) val breakBefore: String? = null, + @ProtoNumber(40) val breakAfter: String? = null, + @ProtoNumber(41) val breakInside: String? = null, + @ProtoNumber(42) val widows: Int = 2, + @ProtoNumber(43) val orphans: Int = 2, + @ProtoNumber(44) val visibility: String? = null, + @ProtoNumber(45) val objectFit: String? = null, + @ProtoNumber(46) val objectPosition: String? = null, + @ProtoNumber(47) val backgroundImage: String? = null ) { fun merge(other: BlockStyle): BlockStyle { return BlockStyle( @@ -125,7 +138,20 @@ data class BlockStyle( horizontalAlign = other.horizontalAlign ?: this.horizontalAlign, filter = other.filter ?: this.filter, borderCollapse = other.borderCollapse ?: this.borderCollapse, - borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing + borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing, + minWidth = if (other.minWidth.isSpecified) other.minWidth else this.minWidth, + minHeight = if (other.minHeight.isSpecified) other.minHeight else this.minHeight, + maxHeight = if (other.maxHeight.isSpecified) other.maxHeight else this.maxHeight, + overflow = other.overflow ?: this.overflow, + breakBefore = other.breakBefore ?: this.breakBefore, + breakAfter = other.breakAfter ?: this.breakAfter, + breakInside = other.breakInside ?: this.breakInside, + widows = if (other.widows != 2) other.widows else this.widows, + orphans = if (other.orphans != 2) other.orphans else this.orphans, + visibility = other.visibility ?: this.visibility, + objectFit = other.objectFit ?: this.objectFit, + objectPosition = other.objectPosition ?: this.objectPosition, + backgroundImage = other.backgroundImage ?: this.backgroundImage ) } } @@ -307,7 +333,10 @@ data class CssStyle( @ProtoNumber(13) @Serializable(with = TextUnitSerializer::class) val wordSpacing: TextUnit = TextUnit.Unspecified, @ProtoNumber(14) val textDecorationStyle: String? = null, @ProtoNumber(15) @Serializable(with = ColorSerializer::class) val textDecorationColor: Color = Color.Unspecified, - @ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified + @ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified, + @ProtoNumber(17) val whiteSpace: String? = null, + @ProtoNumber(18) val verticalAlign: String? = null, + @ProtoNumber(19) val customProperties: Map = emptyMap() ) { fun merge(other: CssStyle): CssStyle { return CssStyle( @@ -326,7 +355,10 @@ data class CssStyle( wordSpacing = if (other.wordSpacing.isSpecified) other.wordSpacing else this.wordSpacing, textDecorationStyle = other.textDecorationStyle ?: this.textDecorationStyle, textDecorationColor = if (other.textDecorationColor.isSpecified) other.textDecorationColor else this.textDecorationColor, - textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset + textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset, + whiteSpace = other.whiteSpace ?: this.whiteSpace, + verticalAlign = other.verticalAlign ?: this.verticalAlign, + customProperties = this.customProperties + other.customProperties ) } } @@ -340,15 +372,17 @@ data class CssSelector( @Serializable data class CssRule( @ProtoNumber(1) val selector: CssSelector, - @ProtoNumber(2) val style: CssStyle + @ProtoNumber(2) val style: CssStyle, + @ProtoNumber(3) val pseudoElement: String? = null, + @ProtoNumber(4) val sourceOrder: Int = 0 ) @Serializable data class FontFaceInfo( @ProtoNumber(1) val fontFamily: String, @ProtoNumber(2) val src: String, - @ProtoNumber(3) @Serializable(with = com.aryan.reader.paginatedreader.serialization.FontWeightSerializer::class) val fontWeight: FontWeight?, - @ProtoNumber(4) @Serializable(with = com.aryan.reader.paginatedreader.serialization.FontStyleSerializer::class) val fontStyle: FontStyle? + @ProtoNumber(3) @Serializable(with = org.dueattendant149.bookreader.paginatedreader.serialization.FontWeightSerializer::class) val fontWeight: FontWeight?, + @ProtoNumber(4) @Serializable(with = org.dueattendant149.bookreader.paginatedreader.serialization.FontStyleSerializer::class) val fontStyle: FontStyle? ) @Serializable @@ -371,7 +405,8 @@ data class OptimizedCssRules( @ProtoNumber(1) val byTag: Map> = emptyMap(), @ProtoNumber(2) val byClass: Map> = emptyMap(), @ProtoNumber(3) val byId: Map> = emptyMap(), - @ProtoNumber(4) val otherComplex: List = emptyList() + @ProtoNumber(4) val otherComplex: List = emptyList(), + @ProtoNumber(5) val allRules: List = emptyList() ) { fun merge(other: OptimizedCssRules): OptimizedCssRules { fun mergeMap( @@ -397,16 +432,17 @@ data class OptimizedCssRules( byTag = mergeMap(this.byTag, other.byTag), byClass = mergeMap(this.byClass, other.byClass), byId = mergeMap(this.byId, other.byId), - otherComplex = this.otherComplex + other.otherComplex + otherComplex = this.otherComplex + other.otherComplex, + allRules = this.toFlatList() + other.toFlatList() ) } fun toFlatList(): List { - return byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex + return allRules.ifEmpty { byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex } } } data class OptimizedCssParseResult( val rules: OptimizedCssRules, val fontFaces: List -) \ No newline at end of file +) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/SemanticModel.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/SemanticModel.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt index 4c757eb..e68cb89 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/SemanticModel.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt @@ -19,7 +19,7 @@ */ @file:OptIn(ExperimentalSerializationApi::class) -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/StyleUtils.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt similarity index 98% rename from shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/StyleUtils.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt index 9347350..d425019 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/StyleUtils.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/UserAgentStylesheet.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt similarity index 98% rename from shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/UserAgentStylesheet.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt index 0b79a05..73f4d9f 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/UserAgentStylesheet.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt @@ -17,7 +17,7 @@ * * mail: epistemereader@gmail.com */ -package com.aryan.reader.paginatedreader +package org.dueattendant149.bookreader.paginatedreader object UserAgentStylesheet { diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/serialization/ComposeTypeSerializers.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/serialization/ComposeTypeSerializers.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt index f44955f..123956f 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/serialization/ComposeTypeSerializers.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt @@ -18,7 +18,7 @@ * mail: epistemereader@gmail.com */ @file:OptIn(ExperimentalSerializationApi::class) -package com.aryan.reader.paginatedreader.serialization +package org.dueattendant149.bookreader.paginatedreader.serialization import androidx.compose.ui.geometry.Offset import androidx.compose.ui.text.font.FontFamily @@ -43,7 +43,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified -import com.aryan.reader.paginatedreader.FontFamilyMapper +import org.dueattendant149.bookreader.paginatedreader.FontFamilyMapper import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt similarity index 93% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt index 6aa87d2..71f7080 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt @@ -1,9 +1,9 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared import androidx.compose.ui.graphics.Color -import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette -import com.aryan.reader.shared.reader.ReaderSettings -import com.aryan.reader.shared.reader.ReaderSearchOptions +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.ReaderSearchOptions sealed interface LibraryAction { data class SearchChanged(val query: String) : LibraryAction @@ -76,6 +76,7 @@ sealed interface AppAction { data class AppFontPreferenceChanged(val preference: AppFontPreference) : AppAction data class CustomAppThemeAdded(val theme: CustomAppTheme) : AppAction data class CustomAppThemeDeleted(val themeId: String) : AppAction + data class CustomReaderThemesChanged(val themes: List) : AppAction data class SyncEnabledChanged(val enabled: Boolean) : AppAction data class FolderSyncEnabledChanged(val enabled: Boolean) : AppAction data class TabsEnabledChanged(val enabled: Boolean) : AppAction diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt similarity index 97% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt index f844c98..5a3de22 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt @@ -1,8 +1,8 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared import androidx.compose.ui.graphics.Color -import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette -import com.aryan.reader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.shared.reader.ReaderSettings data class SharedText( val name: String, @@ -272,6 +272,7 @@ data class SharedReaderScreenState( val appSeedColor: Color? = null, val appFontPreference: AppFontPreference = AppFontPreference.System, val customAppThemes: List = emptyList(), + val customReaderThemes: List = emptyList(), val readerDefaultSettings: ReaderSettings = ReaderSettings(), val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"), val allTags: List = emptyList(), diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt new file mode 100644 index 0000000..f81c834 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt @@ -0,0 +1,102 @@ +package org.dueattendant149.bookreader.shared + +enum class SharedCloudBookMetadataWinner { + LOCAL, + REMOTE, + SAME +} + +fun sharedCloudBookReadingMetadataWinner( + localModifiedTimestamp: Long?, + remoteModifiedTimestamp: Long +): SharedCloudBookMetadataWinner { + val localTimestamp = localModifiedTimestamp ?: Long.MIN_VALUE + return when { + localTimestamp > remoteModifiedTimestamp -> SharedCloudBookMetadataWinner.LOCAL + remoteModifiedTimestamp > localTimestamp -> SharedCloudBookMetadataWinner.REMOTE + else -> SharedCloudBookMetadataWinner.SAME + } +} + +fun sharedCloudBookMetadataWinner( + localModifiedTimestamp: Long?, + remoteModifiedTimestamp: Long, + localSidecarModifiedTimestamp: Long = 0L +): SharedCloudBookMetadataWinner { + val localTimestamp = maxOf(localModifiedTimestamp ?: Long.MIN_VALUE, localSidecarModifiedTimestamp) + return when { + localTimestamp > remoteModifiedTimestamp -> SharedCloudBookMetadataWinner.LOCAL + remoteModifiedTimestamp > localTimestamp -> SharedCloudBookMetadataWinner.REMOTE + else -> SharedCloudBookMetadataWinner.SAME + } +} + +fun shouldApplyRemoteCloudBookMetadataUpdate( + localModifiedTimestamp: Long?, + remoteModifiedTimestamp: Long +): Boolean { + return sharedCloudBookReadingMetadataWinner( + localModifiedTimestamp = localModifiedTimestamp, + remoteModifiedTimestamp = remoteModifiedTimestamp + ) == SharedCloudBookMetadataWinner.REMOTE +} + +fun shouldUploadLocalCloudBookMetadataUpdate( + localModifiedTimestamp: Long, + remoteModifiedTimestamp: Long +): Boolean { + return sharedCloudBookReadingMetadataWinner( + localModifiedTimestamp = localModifiedTimestamp, + remoteModifiedTimestamp = remoteModifiedTimestamp + ) == SharedCloudBookMetadataWinner.LOCAL +} + +fun shouldApplyRemoteCloudBookUpdate( + localModifiedTimestamp: Long?, + remoteModifiedTimestamp: Long, + localSidecarModifiedTimestamp: Long = 0L +): Boolean { + return sharedCloudBookMetadataWinner( + localModifiedTimestamp = localModifiedTimestamp, + remoteModifiedTimestamp = remoteModifiedTimestamp, + localSidecarModifiedTimestamp = localSidecarModifiedTimestamp + ) == SharedCloudBookMetadataWinner.REMOTE +} + +fun shouldUploadLocalCloudBookUpdate( + localModifiedTimestamp: Long, + remoteModifiedTimestamp: Long, + localSidecarModifiedTimestamp: Long = 0L +): Boolean { + return sharedCloudBookMetadataWinner( + localModifiedTimestamp = localModifiedTimestamp, + remoteModifiedTimestamp = remoteModifiedTimestamp, + localSidecarModifiedTimestamp = localSidecarModifiedTimestamp + ) == SharedCloudBookMetadataWinner.LOCAL +} + +fun shouldDownloadRemoteCloudBookContent( + localFileAvailable: Boolean, + localContentModifiedTimestamp: Long, + remoteContentModifiedTimestamp: Long, + remoteDeleted: Boolean = false +): Boolean { + return !remoteDeleted && + remoteContentModifiedTimestamp > 0L && + (!localFileAvailable || remoteContentModifiedTimestamp > localContentModifiedTimestamp) +} + +fun shouldUploadLocalCloudBookContent( + localFileAvailable: Boolean, + localContentModifiedTimestamp: Long, + remoteContentModifiedTimestamp: Long? +): Boolean { + return localFileAvailable && + localContentModifiedTimestamp > 0L && + localContentModifiedTimestamp > (remoteContentModifiedTimestamp ?: 0L) +} + +fun sharedCloudBookContentFileName(bookId: String, type: FileType): String? { + val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null + return "$bookId.$extension" +} diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt new file mode 100644 index 0000000..c6ed956 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt @@ -0,0 +1,88 @@ +package org.dueattendant149.bookreader.shared + +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight + +data class CustomFontItem( + val id: String, + val displayName: String, + val fileName: String, + val fileExtension: String, + val path: String, + val timestamp: Long, + val isDeleted: Boolean = false +) + +data class CustomFontVariantItem( + val font: CustomFontItem, + val variant: FontVariant? +) + +data class CustomFontFamilyItem( + val familyName: String, + val variants: List +) + +fun List.groupByFamily(): List { + val families = this.groupBy { + it.displayName.familyFilenameSignature().takeIf { s -> s.isNotBlank() } ?: it.displayName + } + + return families.map { (familyName, fonts) -> + val variants = fonts.map { font -> + CustomFontVariantItem( + font = font, + variant = font.displayName.detectFontVariant() + ) + } + CustomFontFamilyItem( + familyName = familyName.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }, + variants = variants + ) + }.sortedBy { it.familyName } +} + +fun CustomFontVariantItem.fontFaceLabel(): String { + val variant = variant ?: return "Regular" + return when { + variant.weight.weight >= FontWeight.Bold.weight && variant.style == FontStyle.Italic -> "Bold Italic" + variant.weight.weight >= FontWeight.Bold.weight -> "Bold" + variant.style == FontStyle.Italic -> "Italic" + variant.weight == FontWeight.Normal -> "Regular" + variant.weight.weight < FontWeight.Normal.weight -> variant.weight.fontWeightLabel() + else -> variant.weight.fontWeightLabel() + } +} + +fun CustomFontFamilyItem.fontFaceSummary(): String { + return variants + .sortedWith(compareBy { + it.variant?.style == FontStyle.Italic + }.thenBy { + it.variant?.weight?.weight ?: FontWeight.Normal.weight + }) + .map { it.fontFaceLabel() } + .distinct() + .joinToString() +} + +fun CustomFontFamilyItem.hasVariableWeightFace(): Boolean { + return variants.any { variant -> + variant.font.displayName.supportsVariableWeightAxis() || variant.font.fileName.supportsVariableWeightAxis() + } +} + +private fun FontWeight.fontWeightLabel(): String { + return when (this) { + FontWeight.Thin -> "Thin" + FontWeight.ExtraLight -> "Extra Light" + FontWeight.Light -> "Light" + FontWeight.Normal -> "Regular" + FontWeight.Medium -> "Medium" + FontWeight.SemiBold -> "Semi Bold" + FontWeight.Bold -> "Bold" + FontWeight.ExtraBold -> "Extra Bold" + FontWeight.Black -> "Black" + else -> weight.toString() + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt similarity index 94% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt index 967e276..8146139 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared enum class ReaderPlatform { ANDROID, @@ -89,6 +89,10 @@ object SharedFileCapabilities { "application/x-rar-compressed", "application/x-cb7", "application/x-7z-compressed", + "application/vnd.comicbook+tar", + "application/x-cbt", + "application/x-tar", + "application/tar", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.text", @@ -166,6 +170,13 @@ object SharedFileCapabilities { androidSurface = ReaderFeatureSurface.PDF_VIEWER, desktopSurface = ReaderFeatureSurface.PDF_VIEWER ), + FileTypeCapability( + type = FileType.CBT, + displayName = "CBT", + extensions = setOf("cbt"), + androidSurface = ReaderFeatureSurface.PDF_VIEWER, + desktopSurface = ReaderFeatureSurface.PDF_VIEWER + ), FileTypeCapability( type = FileType.DOCX, displayName = "DOCX", @@ -211,11 +222,13 @@ object SharedFileCapabilities { FileType.CBZ to "application/zip", FileType.CBR to "application/zip", FileType.CB7 to "application/zip", + FileType.CBT to "application/x-tar", FileType.DOCX to "application/vnd.openxmlformats-officedocument.wordprocessingml.document", FileType.PPTX to "application/vnd.openxmlformats-officedocument.presentationml.presentation", FileType.ODT to "application/vnd.oasis.opendocument.text", FileType.FODT to "application/x-vnd.oasis.opendocument.text-flat-xml" ) + val comicArchiveTypes: Set = setOf(FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT) val knownFileTypes: Set = all.mapTo(mutableSetOf()) { it.type } fun capabilityFor(type: FileType): FileTypeCapability? { @@ -234,6 +247,10 @@ object SharedFileCapabilities { return mimeTypesByType[type] } + fun isComicArchive(type: FileType): Boolean { + return type in comicArchiveTypes + } + fun fileTypeForName(fileName: String): FileType { return resolveFileTypeForName(fileName) ?: FileType.UNKNOWN } @@ -267,6 +284,9 @@ object SharedFileCapabilities { "application/x-cb7", "application/x-7z-compressed" -> { if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null } + "application/vnd.comicbook+tar", "application/x-cbt", "application/x-tar", "application/tar" -> { + if (fileName?.endsWith(".cbt", ignoreCase = true) == true) FileType.CBT else null + } "application/pdf" -> FileType.PDF "application/epub+zip" -> FileType.EPUB "application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2 diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt new file mode 100644 index 0000000..1eace98 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt @@ -0,0 +1,149 @@ +package org.dueattendant149.bookreader.shared + +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight + +data class FontVariant( + val weight: FontWeight, + val style: FontStyle +) + +private val filenameSeparatorsRegex = Regex("""[\s._,-]+""") + +fun String.familyFilenameSignature(): String { + val tokens = filenameTokens() + val variantIndexes = tokens.variantTokenIndexes() + val signatureTokens = tokens.filterIndexed { index, token -> + token.isNotBlank() && index !in variantIndexes + } + return signatureTokens.joinToString(separator = " ") +} + +fun String.supportsVariableWeightAxis(): Boolean { + return filenameTokens().any { it == "wght" } +} + +fun String.fontWeightCssDescriptor(fallbackWeight: FontWeight = FontWeight.Normal): String { + return if (supportsVariableWeightAxis()) { + "100 900" + } else { + fallbackWeight.weight.toString() + } +} + +private fun List.variantTokenIndexes(): Set { + val indexes = mutableSetOf() + forEachIndexed { index, token -> + if (token in singleVariantTokens || token.toIntOrNull()?.isCssFontWeight() == true) { + indexes += index + } + val next = getOrNull(index + 1) ?: return@forEachIndexed + if ("$token$next" in compoundVariantTokens) { + indexes += index + indexes += index + 1 + } + } + return indexes +} + +private fun Int.isCssFontWeight(): Boolean = this in 100..900 && this % 100 == 0 + +private fun List.containsCompoundToken(compoundTokens: Set): Boolean { + return windowed(size = 2, step = 1, partialWindows = false) + .any { (first, second) -> "$first$second" in compoundTokens } +} + +private fun List.detectedWeight(): FontWeight { + val compactTokens = buildList { + addAll(this@detectedWeight) + this@detectedWeight.windowed(size = 2, step = 1, partialWindows = false) + .forEach { (first, second) -> add("$first$second") } + } + return compactTokens.asSequence() + .mapNotNull { token -> + tokenWeightMap[token] + ?: compoundTokenWeightMap[token] + ?: token.toIntOrNull()?.takeIf { it.isCssFontWeight() }?.let(::FontWeight) + } + .maxByOrNull { it.weight } + ?: FontWeight.Normal +} + +private fun List.detectedStyle(): FontStyle { + return if (any { it in italicTokens } || containsCompoundToken(compoundItalicTokens)) { + FontStyle.Italic + } else { + FontStyle.Normal + } +} + +fun String.detectFontVariant(): FontVariant? { + val tokens = filenameTokens() + .filter { it.isNotBlank() } + if (tokens.isEmpty()) return null + + return FontVariant(weight = tokens.detectedWeight(), style = tokens.detectedStyle()) +} + +private fun String.filenameTokens(): List { + return this + .replace(Regex("""(?i)variablefont"""), " variablefont ") + .replace(Regex("""(?<=[a-z])(?=[A-Z])"""), "-") + .lowercase() + .split(filenameSeparatorsRegex) +} + +private val italicTokens = setOf("italic", "ital", "oblique", "obliq", "it", "itallic", "italics", "slanted", "slant") + +private val tokenWeightMap = mapOf( + "thin" to FontWeight.Thin, + "hairline" to FontWeight.Thin, + "extralight" to FontWeight.ExtraLight, + "ultralight" to FontWeight.ExtraLight, + "light" to FontWeight.Light, + "regular" to FontWeight.Normal, + "normal" to FontWeight.Normal, + "roman" to FontWeight.Normal, + "book" to FontWeight.Normal, + "medium" to FontWeight.Medium, + "semibold" to FontWeight.SemiBold, + "demibold" to FontWeight.SemiBold, + "bold" to FontWeight.Bold, + "extrabold" to FontWeight.ExtraBold, + "ultrabold" to FontWeight.ExtraBold, + "black" to FontWeight.Black, + "heavy" to FontWeight.Black +) + +private val variableFontTokens = setOf( + "variablefont", + "vf", + "variable", + "wght", + "wdth", + "opsz", + "slnt", + "grad", + "xtra", + "xopq", + "yopq", + "ytlc", + "ytuc", + "ytas", + "ytde" +) + +private val compoundItalicTokens = setOf("bolditalic", "boldital", "boldoblique", "boldobliq") +private val compoundWeightTokens = mapOf( + "extralight" to FontWeight.ExtraLight, + "ultralight" to FontWeight.ExtraLight, + "semibold" to FontWeight.SemiBold, + "demibold" to FontWeight.SemiBold, + "extrabold" to FontWeight.ExtraBold, + "ultrabold" to FontWeight.ExtraBold +) +private val compoundVariableFontTokens = setOf("variablefont") +private val compoundTokenWeightMap = compoundItalicTokens.associateWith { FontWeight.Bold } + compoundWeightTokens + +private val singleVariantTokens = italicTokens + tokenWeightMap.keys + variableFontTokens +private val compoundVariantTokens = compoundItalicTokens + compoundWeightTokens.keys + compoundVariableFontTokens diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ImportContracts.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt similarity index 98% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/ImportContracts.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt index 1ab914a..a768765 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ImportContracts.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared enum class SharedImportDecisionStatus { IMPORTABLE, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt similarity index 88% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt index 9535879..2d18fa9 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt @@ -1,11 +1,11 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared -import com.aryan.reader.shared.pdf.SharedPdfReaderViewport -import com.aryan.reader.shared.reader.ReaderBookmark -import com.aryan.reader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport +import org.dueattendant149.bookreader.shared.reader.ReaderBookmark +import org.dueattendant149.bookreader.shared.reader.ReaderSettings enum class FileType { - PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, PPTX, UNKNOWN + PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, CBT, DOCX, ODT, FODT, PPTX, UNKNOWN } val PDF_VIEWER_FILE_TYPES: Set @@ -67,7 +67,8 @@ data class SyncedFolder( val uriString: String, val name: String, val lastScanTime: Long, - val allowedFileTypes: Set = SharedFileCapabilities.knownFileTypes + val allowedFileTypes: Set = SharedFileCapabilities.knownFileTypes, + val localSyncEnabled: Boolean = true ) data class BookItem( @@ -99,7 +100,8 @@ data class BookItem( val readerSettings: ReaderSettings? = null, val readerBookmarks: List = emptyList(), val readerHighlights: List = emptyList(), - val pdfReaderViewport: SharedPdfReaderViewport? = null + val pdfReaderViewport: SharedPdfReaderViewport? = null, + val readingPositionModifiedTimestamp: Long = 0L ) data class Shelf( diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryMutations.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt similarity index 69% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryMutations.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt index 3bae388..f22767a 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryMutations.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared data class SharedLibraryMutationResult( val state: SharedReaderScreenState, @@ -99,6 +99,46 @@ object SharedLibraryEditor { ) } + fun createShelfWithBooks( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + name: String, + bookIds: Iterable, + clearSelection: Boolean = true, + nowMillis: Long = currentTimestamp() + ): SharedLibraryMutationResult? { + val trimmed = cleanShelfName(name) ?: return null + val selectedBooks = cleanBookIds(bookIds) + val shelfId = "shelf_$nowMillis" + val newRefs = selectedBooks.map { bookId -> + BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis) + } + return SharedLibraryMutationResult( + state = state.copy( + selectedBookIds = if (clearSelection && selectedBooks.isNotEmpty()) emptySet() else state.selectedBookIds, + bannerMessage = if (selectedBooks.isEmpty()) { + BannerMessage.string( + "banner_shelf_created", + "Created shelf \"%1\$s\".", + trimmed + ) + } else { + BannerMessage.quantity( + "banner_shelf_created_with_books", + selectedBooks.size, + "Created shelf \"%1\$s\" with %2\$d book.", + "Created shelf \"%1\$s\" with %2\$d books.", + trimmed, + selectedBooks.size + ) + } + ), + shelfRecords = shelfRecords + ShelfRecord(id = shelfId, name = trimmed), + shelfRefs = shelfRefs + newRefs + ) + } + fun createSmartShelf( state: SharedReaderScreenState, shelfRecords: List, @@ -239,25 +279,73 @@ object SharedLibraryEditor { shelfId: String, nowMillis: Long = currentTimestamp() ): SharedLibraryMutationResult? { - val selected = state.selectedBookIds - if (selected.isEmpty()) return null - val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId } - val additions = selected.mapNotNull { bookId -> - if (!existing.add(bookId to shelfId)) null else BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis) - } + return addBooksToShelves( + state = state, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + bookIds = state.selectedBookIds, + shelfIds = listOf(shelfId), + clearSelection = true, + nowMillis = nowMillis, + bannerName = "banner_books_added_to_shelf", + singularMessage = "%1\$d book added to shelf.", + pluralMessage = "%1\$d books added to shelf." + ) + } + + fun addBooksToShelves( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + bookIds: Iterable, + shelfIds: Iterable, + clearSelection: Boolean = true, + nowMillis: Long = currentTimestamp() + ): SharedLibraryMutationResult? { + return addBooksToShelves( + state = state, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + bookIds = bookIds, + shelfIds = shelfIds, + clearSelection = clearSelection, + nowMillis = nowMillis, + bannerName = "banner_books_added_to_shelves", + singularMessage = "%1\$d shelf entry added.", + pluralMessage = "%1\$d shelf entries added." + ) + } + + fun replaceShelfBooks( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + shelfId: String, + bookIds: Iterable, + nowMillis: Long = currentTimestamp() + ): SharedLibraryMutationResult? { + val cleanShelfId = shelfId.trim() + if (!canMutateShelf(cleanShelfId)) return null + val selectedBooks = cleanBookIds(bookIds) + val shelfName = state.shelves.firstOrNull { it.id == cleanShelfId }?.name + ?: shelfRecords.firstOrNull { it.id == cleanShelfId }?.name + ?: cleanShelfId return SharedLibraryMutationResult( state = state.copy( - selectedBookIds = emptySet(), bannerMessage = BannerMessage.quantity( - "banner_books_added_to_shelf", - additions.size, - "%1\$d book added to shelf.", - "%1\$d books added to shelf.", - additions.size + "banner_shelf_books_updated", + selectedBooks.size, + "Updated \"%1\$s\" with %2\$d book.", + "Updated \"%1\$s\" with %2\$d books.", + shelfName, + selectedBooks.size ) ), shelfRecords = shelfRecords, - shelfRefs = shelfRefs + additions + shelfRefs = shelfRefs.filterNot { it.shelfId == cleanShelfId } + + selectedBooks.map { bookId -> + BookShelfRef(bookId = bookId, shelfId = cleanShelfId, addedAt = nowMillis) + } ) } @@ -325,6 +413,51 @@ object SharedLibraryEditor { shelfRefs = shelfRefs ) } + + private fun addBooksToShelves( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + bookIds: Iterable, + shelfIds: Iterable, + clearSelection: Boolean, + nowMillis: Long, + bannerName: String, + singularMessage: String, + pluralMessage: String + ): SharedLibraryMutationResult? { + val selectedBooks = cleanBookIds(bookIds) + val targetShelfIds = shelfIds + .map { it.trim() } + .filter { canMutateShelf(it) } + .distinct() + if (selectedBooks.isEmpty() || targetShelfIds.isEmpty()) return null + + val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId } + val additions = targetShelfIds.flatMap { shelfId -> + selectedBooks.mapNotNull { bookId -> + if (!existing.add(bookId to shelfId)) { + null + } else { + BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis) + } + } + } + return SharedLibraryMutationResult( + state = state.copy( + selectedBookIds = if (clearSelection) emptySet() else state.selectedBookIds, + bannerMessage = BannerMessage.quantity( + bannerName, + additions.size, + singularMessage, + pluralMessage, + additions.size + ) + ), + shelfRecords = shelfRecords, + shelfRefs = shelfRefs + additions + ) + } } fun parseTagList(input: String, knownTags: List, nowMillis: Long = currentTimestamp()): List { diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt index e7f3789..ddff77e 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared class LibraryProjector { fun home(state: LibraryState): HomeScreenModel { diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt index 2e3aaaf..64e3c77 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared data class ShelfRecord( val id: String, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt similarity index 96% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt index ccb7976..3cceb68 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared -import com.aryan.reader.shared.reader.ReaderBookmark +import org.dueattendant149.bookreader.shared.reader.ReaderBookmark import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -141,6 +141,13 @@ data class SharedFolderBookMetadata( seriesIndex = existing?.seriesIndex, lastPageIndex = lastPage, readerPosition = parsedReaderPosition ?: existing?.readerPosition, + readingPositionModifiedTimestamp = if ( + parsedReaderPosition != null || lastPage != null || progressPercentage > 0f + ) { + metadataTimestamp + } else { + existing?.readingPositionModifiedTimestamp ?: 0L + }, readerBookmarks = parsedBookmarks ?: existing?.readerBookmarks.orEmpty(), readerHighlights = parsedHighlights ?: existing?.readerHighlights.orEmpty() ) @@ -152,6 +159,9 @@ data class SharedFolderBookMetadata( chapterIndex = lastChapterIndex, cfi = lastPositionCfi, pageIndex = lastPage + ).withFallbacks( + blockIndex = locatorBlockIndex, + charOffset = locatorCharOffset ) } @@ -272,6 +282,15 @@ object LocalFolderSyncEngine { nowMillis: Long = currentTimestamp(), metadataOnly: Boolean = false ): LocalFolderSyncResult { + if (!folder.localSyncEnabled) { + return LocalFolderSyncResult( + state = state, + idMigrations = emptyMap(), + removedBookIds = emptySet(), + stats = LocalFolderSyncStats() + ) + } + val folderRoot = folder.uriString val allowedTypes = folder.allowedFileTypes val booksById = linkedMapOf() @@ -439,16 +458,7 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? { !bookmarksJson.isNullOrBlank() || !highlightsJson.isNullOrBlank() if (!isDirty) return null - val positionCfi = position?.cfi ?: position?.let { locator -> - val chapterIndex = locator.chapterIndex - val startOffset = locator.startOffset - val endOffset = locator.endOffset ?: startOffset - if (chapterIndex != null && startOffset != null && endOffset != null) { - "desktop:$chapterIndex:$startOffset:$endOffset" - } else { - null - } - } + val positionCfi = position?.toStablePositionCfi() return SharedFolderBookMetadata( bookId = id, @@ -463,8 +473,8 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? { isRecent = isRecent, lastModifiedTimestamp = localFolderModifiedTimestamp(), bookmarksJson = bookmarksJson, - locatorBlockIndex = null, - locatorCharOffset = null, + locatorBlockIndex = position?.blockIndex, + locatorCharOffset = position?.charOffset, customName = null, highlightsJson = highlightsJson, seriesName = null, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/PdfReaderModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt similarity index 97% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/PdfReaderModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt index 36e226f..29ea9af 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/PdfReaderModels.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared enum class SaveMode { ORIGINAL, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt similarity index 65% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt index 9b30bc4..6942569 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared import androidx.compose.ui.graphics.Color @@ -42,11 +42,15 @@ data class ReaderLocator( val pageIndex: Int? = null, val startOffset: Int? = null, val endOffset: Int? = null, + val blockIndex: Int? = null, + val charOffset: Int? = null, val textQuote: String? = null, val cfi: String? = null ) { val hasTextRange: Boolean get() = startOffset != null && endOffset != null && endOffset >= startOffset + val hasBlockPosition: Boolean + get() = blockIndex != null && charOffset != null fun withFallbacks( chapterIndex: Int? = null, @@ -55,6 +59,8 @@ data class ReaderLocator( pageIndex: Int? = null, startOffset: Int? = null, endOffset: Int? = null, + blockIndex: Int? = null, + charOffset: Int? = null, textQuote: String? = null, cfi: String? = null ): ReaderLocator { @@ -65,6 +71,8 @@ data class ReaderLocator( pageIndex = this.pageIndex ?: pageIndex, startOffset = this.startOffset ?: startOffset, endOffset = this.endOffset ?: endOffset, + blockIndex = this.blockIndex ?: blockIndex, + charOffset = this.charOffset ?: charOffset, textQuote = this.textQuote ?: textQuote, cfi = this.cfi ?: cfi ) @@ -74,6 +82,10 @@ data class ReaderLocator( val sameChapter = chapterIndex == null || other.chapterIndex == null || chapterIndex == other.chapterIndex if (!sameChapter) return false + if (hasBlockPosition && other.hasBlockPosition) { + return blockIndex == other.blockIndex && charOffset == other.charOffset + } + if (hasTextRange && other.hasTextRange) { return startOffset == other.startOffset && endOffset == other.endOffset } @@ -92,21 +104,40 @@ data class ReaderLocator( pageIndex: Int? = null, textQuote: String? = null ): ReaderLocator { - val desktopParts = cfi + val stableCfi = cfi?.toStableReaderPositionCfi() + val desktopParts = stableCfi ?.takeIf { it.startsWith("desktop:") } ?.split(':') .orEmpty() val parsedChapterIndex = desktopParts.getOrNull(1)?.toIntOrNull() val possibleStartOffset = desktopParts.getOrNull(2)?.toIntOrNull() val possibleEndOffset = desktopParts.getOrNull(3)?.toIntOrNull() + val androidLocatorParts = stableCfi + ?.takeIf { it.startsWith("android-locator:") } + ?.split(':') + .orEmpty() + val parsedAndroidChapterIndex = androidLocatorParts.getOrNull(1)?.toIntOrNull() + val parsedBlockIndex = androidLocatorParts.getOrNull(2)?.toIntOrNull() + val parsedCharOffset = androidLocatorParts.getOrNull(3)?.toIntOrNull() + ?.takeIf { it >= 0 } + val parsedAndroidEndOffset = parsedCharOffset + ?.let { start -> textQuote?.takeIf { it.isNotBlank() }?.let { start + it.length } } val hasOffsetRange = desktopParts.size == 4 && possibleStartOffset != null && possibleEndOffset != null && possibleStartOffset >= 0 && possibleEndOffset >= possibleStartOffset && possibleEndOffset - possibleStartOffset <= 100_000 - val parsedStartOffset = if (hasOffsetRange) possibleStartOffset else null - val parsedEndOffset = if (hasOffsetRange) possibleEndOffset else null + val parsedStartOffset = when { + hasOffsetRange -> possibleStartOffset + parsedBlockIndex != null && parsedAndroidEndOffset != null -> parsedCharOffset + else -> null + } + val parsedEndOffset = when { + hasOffsetRange -> possibleEndOffset + parsedBlockIndex != null -> parsedAndroidEndOffset + else -> null + } val parsedPageIndex = when { pageIndex != null -> pageIndex desktopParts.size == 3 || desktopParts.size >= 5 || (desktopParts.size == 4 && !hasOffsetRange) -> @@ -114,23 +145,56 @@ data class ReaderLocator( else -> null } return ReaderLocator( - chapterIndex = chapterIndex ?: parsedChapterIndex, + chapterIndex = chapterIndex ?: parsedChapterIndex ?: parsedAndroidChapterIndex, pageIndex = parsedPageIndex, startOffset = parsedStartOffset, endOffset = parsedEndOffset, + blockIndex = parsedBlockIndex, + charOffset = parsedCharOffset, textQuote = textQuote, - cfi = cfi + cfi = stableCfi ?: cfi ) } } } +fun String.toStableReaderPositionCfi(): String { + val trimmed = trim() + if (!trimmed.startsWith("desktop-scroll:")) return trimmed + return trimmed + .split(':', limit = 4) + .getOrNull(3) + ?.takeIf { it.isNotBlank() } + ?: trimmed +} + +fun ReaderLocator.toStablePositionCfi(): String? { + cfi + ?.toStableReaderPositionCfi() + ?.takeIf { it.isNotBlank() } + ?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") } + ?.let { return it } + + val chapter = chapterIndex + val start = startOffset + val end = endOffset ?: start + return when { + chapter != null && blockIndex != null && charOffset != null -> + "android-locator:$chapter:$blockIndex:$charOffset" + chapter != null && start != null && end != null -> + "desktop:$chapter:$start:$end" + chapter != null && pageIndex != null -> + "desktop:$chapter:$pageIndex" + else -> null + } +} + data class ReaderHighlightPalette( val colors: List = defaultColors ) { fun sanitized(): ReaderHighlightPalette { - val distinct = colors.distinct().filter { it in HighlightColor.entries } - return copy(colors = distinct.ifEmpty { defaultColors }) + val knownColors = colors.filter { it in HighlightColor.entries } + return copy(colors = knownColors.takeIf { it.size == PaletteSize } ?: defaultColors) } fun contains(color: HighlightColor): Boolean { @@ -147,14 +211,13 @@ data class ReaderHighlightPalette( } companion object { + const val PaletteSize: Int = 4 val defaultColors: List get() = listOf( HighlightColor.YELLOW, HighlightColor.GREEN, HighlightColor.BLUE, - HighlightColor.RED, - HighlightColor.PURPLE, - HighlightColor.ORANGE + HighlightColor.RED ) } } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt similarity index 97% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt index 2fb43be..6c75319 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -238,6 +238,8 @@ object EpubAnnotationSerializer { pageIndex?.let { put("pageIndex", JsonPrimitive(it)) } startOffset?.let { put("startOffset", JsonPrimitive(it)) } endOffset?.let { put("endOffset", JsonPrimitive(it)) } + blockIndex?.let { put("blockIndex", JsonPrimitive(it)) } + charOffset?.let { put("charOffset", JsonPrimitive(it)) } textQuote?.let { put("textQuote", JsonPrimitive(it)) } cfi?.let { put("cfi", JsonPrimitive(it)) } } @@ -253,6 +255,8 @@ object EpubAnnotationSerializer { pageIndex = obj.int("pageIndex"), startOffset = obj.int("startOffset"), endOffset = obj.int("endOffset"), + blockIndex = obj.int("blockIndex"), + charOffset = obj.int("charOffset"), textQuote = obj.string("textQuote"), cfi = obj.string("cfi") ) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt similarity index 75% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt index 578ec37..6496bda 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt @@ -1,11 +1,11 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.toArgb -import com.aryan.reader.shared.reader.ReaderReadingMode -import com.aryan.reader.shared.reader.ReaderSettings -import com.aryan.reader.shared.reader.SharedReaderTextAlign +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.SharedReaderTextAlign import kotlin.math.max import kotlin.math.roundToInt @@ -101,13 +101,29 @@ data class ReaderTheme( val isCustom: Boolean = false ) -val BuiltInReaderThemes = listOf( - ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false), +fun List.sanitizeCustomReaderThemes(): List { + val seenIds = mutableSetOf() + return asReversed() + .filter { theme -> + theme.isCustom && + theme.id.isNotBlank() && + theme.name.isNotBlank() && + theme.backgroundColor.isSpecified && + theme.textColor.isSpecified && + seenIds.add(theme.id) + } + .asReversed() +} + +private val StandardReaderSolidThemes = listOf( ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false), ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true), ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false), ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true), - ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true), + ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true) +) + +private val StandardReaderTexturedThemes = listOf( ReaderTheme("natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id), ReaderTheme("retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id), ReaderTheme("veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id), @@ -116,21 +132,16 @@ val BuiltInReaderThemes = listOf( ReaderTheme("retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id) ) +val BuiltInReaderThemes = listOf( + ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false) +) + StandardReaderSolidThemes + StandardReaderTexturedThemes + val BuiltInPdfReaderThemes = listOf( ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false), - ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true), - ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false), - ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true), - ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false), - ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true), - ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true), - ReaderTheme("pdf_natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id), - ReaderTheme("pdf_retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id), - ReaderTheme("pdf_veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id), - ReaderTheme("pdf_grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id), - ReaderTheme("pdf_fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id), - ReaderTheme("pdf_retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id) -) + ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true) +) + StandardReaderSolidThemes + StandardReaderTexturedThemes.map { theme -> + theme.copy(id = "pdf_${theme.id}") +} fun FormatSettings.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings { val horizontalMarginPx = (ReaderAppearanceDefaults.marginPx * horizontalMargin).roundToInt() @@ -169,6 +180,59 @@ fun ReaderTheme.toReaderSettings(base: ReaderSettings = ReaderSettings()): Reade ) } +fun ReaderSettings.resetReaderFormatSettings(): ReaderSettings { + val defaults = ReaderSettings() + return copy( + fontSize = defaults.fontSize, + lineSpacing = defaults.lineSpacing, + margin = defaults.margin, + horizontalMargin = defaults.horizontalMargin, + verticalMargin = defaults.verticalMargin, + textAlign = defaults.textAlign, + pageWidth = defaults.pageWidth, + fontFamily = defaults.fontFamily, + paragraphSpacing = defaults.paragraphSpacing, + imageScale = defaults.imageScale, + customFontPath = defaults.customFontPath + ) +} + +fun ReaderSettings.withHorizontalReaderMargin(horizontalMarginPx: Int): ReaderSettings { + val nextHorizontal = horizontalMarginPx.coerceIn( + ReaderAppearanceDefaults.minMarginPx, + ReaderAppearanceDefaults.maxMarginPx + ) + val currentVertical = resolvedVerticalMargin.coerceIn( + ReaderAppearanceDefaults.minMarginPx, + ReaderAppearanceDefaults.maxMarginPx + ) + return copy( + margin = max(nextHorizontal, currentVertical), + horizontalMargin = nextHorizontal, + verticalMargin = currentVertical + ) +} + +fun ReaderSettings.withVerticalReaderMargin(verticalMarginPx: Int): ReaderSettings { + val currentHorizontal = resolvedHorizontalMargin.coerceIn( + ReaderAppearanceDefaults.minMarginPx, + ReaderAppearanceDefaults.maxMarginPx + ) + val nextVertical = verticalMarginPx.coerceIn( + ReaderAppearanceDefaults.minMarginPx, + ReaderAppearanceDefaults.maxMarginPx + ) + return copy( + margin = max(currentHorizontal, nextVertical), + horizontalMargin = currentHorizontal, + verticalMargin = nextVertical + ) +} + +fun ReaderSettings.shouldShowPageWidthFormatControl(): Boolean { + return readingMode == ReaderReadingMode.PAGINATED +} + fun readerThemeById(themeId: String?): ReaderTheme? { return BuiltInReaderThemes.firstOrNull { it.id == themeId } } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt similarity index 68% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt index be64849..ddac244 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt @@ -1,20 +1,22 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared -import com.aryan.reader.paginatedreader.SemanticBlock -import com.aryan.reader.paginatedreader.SemanticFlexContainer -import com.aryan.reader.paginatedreader.SemanticList -import com.aryan.reader.paginatedreader.SemanticTable -import com.aryan.reader.paginatedreader.SemanticTextBlock -import com.aryan.reader.paginatedreader.SemanticWrappingBlock -import com.aryan.reader.shared.reader.ReaderPage -import com.aryan.reader.shared.reader.ReaderSessionState -import com.aryan.reader.shared.reader.SharedEpubBook -import com.aryan.reader.shared.reader.SharedEpubChapter +import org.dueattendant149.bookreader.paginatedreader.SemanticBlock +import org.dueattendant149.bookreader.paginatedreader.SemanticFlexContainer +import org.dueattendant149.bookreader.paginatedreader.SemanticList +import org.dueattendant149.bookreader.paginatedreader.SemanticTable +import org.dueattendant149.bookreader.paginatedreader.SemanticTextBlock +import org.dueattendant149.bookreader.paginatedreader.SemanticWrappingBlock +import org.dueattendant149.bookreader.shared.reader.ReaderPage +import org.dueattendant149.bookreader.shared.reader.ReaderSessionState +import org.dueattendant149.bookreader.shared.reader.SharedEpubBook +import org.dueattendant149.bookreader.shared.reader.SharedEpubChapter +import org.dueattendant149.bookreader.shared.reader.logSharedReaderDiagnostic const val GEMINI_CLOUD_TTS_MODEL = "gemini-3.1-flash-live-preview" const val GEMINI_CLOUD_TTS_MODEL_ID = "gemini:$GEMINI_CLOUD_TTS_MODEL" const val DEFAULT_CLOUD_TTS_SPEAKER_ID = "Aoede" const val READER_TTS_CHUNK_MAX_LENGTH = 250 +private const val ReaderTtsStartTraceLogTag = "EpistemeDesktopTtsStartTrace" data class ReaderCloudTtsVoice( val id: String, @@ -325,15 +327,42 @@ object ReaderTtsPlanner { val anchor = session.navigationLocator val pageIndex = anchor?.pageIndex ?: session.reader.currentPageIndex val pages = session.reader.pages.dropWhile { it.pageIndex < pageIndex.coerceAtLeast(0) } + val syntheticDesktopAnchor = anchor?.isSyntheticDesktopTtsAnchor() == true val chunks = chunksForPages(session.reader.book, pages) val chapterIndex = anchor?.chapterIndex val startOffset = anchor?.startOffset - if (chapterIndex == null && startOffset == null) return chunks - var nextIndex = 0 - return chunks.mapNotNull { chunk -> - chunk.afterLocator(chapterIndex = chapterIndex, startOffset = startOffset) - ?.copy(index = nextIndex++) + logReaderTtsStartTrace { + "event=planner_from_here_start pageIndex=$pageIndex pages=${pages.size} chunks=${chunks.size} " + + "syntheticDesktop=$syntheticDesktopAnchor " + + "anchor=${anchor.readerTtsLocatorSummary()} first=${chunks.firstOrNull().readerTtsChunkSummary()} " + + "second=${chunks.getOrNull(1).readerTtsChunkSummary()}" } + if (chapterIndex == null && startOffset == null) return chunks + val target = anchor?.toTtsChunkTarget() + val startChunkIndex = findReaderTtsChunkStartIndex(chunks, target) + ?: chunks.indexOfFirst { it.isOnOrAfterLocator(chapterIndex, startOffset) }.takeIf { it >= 0 } + ?: run { + logReaderTtsStartTrace { + "event=planner_from_here_empty reason=no_start_chunk target=${target.readerTtsTargetSummary()} " + + "anchor=${anchor.readerTtsLocatorSummary()} chunks=${chunks.size}" + } + return emptyList() + } + val initialChunk = anchor?.let { chunks[startChunkIndex].sliceFromLocator(it) } + val sessionChunks = if (initialChunk == null) { + chunks.drop(startChunkIndex + 1) + } else { + chunks.withInitialChunkOverride(startChunkIndex, initialChunk).drop(startChunkIndex) + } + logReaderTtsStartTrace { + "event=planner_from_here_result target=${target.readerTtsTargetSummary()} startChunkIndex=$startChunkIndex " + + "sourceChunk=${chunks.getOrNull(startChunkIndex).readerTtsChunkSummary()} " + + "initialChunk=${initialChunk.readerTtsChunkSummary()} resultChunks=${sessionChunks.size} " + + "resultFirst=${sessionChunks.firstOrNull().readerTtsChunkSummary()}" + } + return sessionChunks + .filter { it.text.isNotBlank() } + .mapIndexed { index, chunk -> chunk.copy(index = index) } } fun chunksForText( @@ -401,28 +430,51 @@ object ReaderTtsPlanner { } } - private fun ReaderTtsChunk.afterLocator(chapterIndex: Int?, startOffset: Int?): ReaderTtsChunk? { + private fun ReaderTtsChunk.isOnOrAfterLocator(chapterIndex: Int?, startOffset: Int?): Boolean { if (chapterIndex != null) { - if (this.chapterIndex < chapterIndex) return null - if (this.chapterIndex > chapterIndex) return this + if (this.chapterIndex < chapterIndex) return false + if (this.chapterIndex > chapterIndex) return true } - val anchorOffset = startOffset ?: return this - if (endOffset <= anchorOffset) return null - if (anchorOffset <= this.startOffset) return this - return trimStartTo(anchorOffset) + val anchorOffset = startOffset ?: return true + return endOffset > anchorOffset } - private fun ReaderTtsChunk.trimStartTo(sourceOffset: Int): ReaderTtsChunk? { - val boundedOffset = sourceOffset.coerceIn(startOffset, endOffset) - if (boundedOffset <= startOffset) return this - if (boundedOffset >= endOffset) return null - val rawDrop = (boundedOffset - startOffset).coerceIn(0, text.length) - val remaining = text.drop(rawDrop) + private fun ReaderTtsChunk.sliceFromLocator(locator: ReaderLocator): ReaderTtsChunk? { + if (locator.chapterIndex != null && locator.chapterIndex != chapterIndex) return this + val sourceOffset = locator.startOffset ?: return this + val rawDrop = (sourceOffset - startOffset).coerceIn(0, text.length) + val drop = rawDrop + if (drop <= 0) { + logReaderTtsStartTrace { + "event=planner_slice_keep reason=drop_at_start rawDrop=$rawDrop " + + "locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}" + } + return this + } + if (drop >= text.length) { + logReaderTtsStartTrace { + "event=planner_slice_skip reason=drop_past_end rawDrop=$rawDrop " + + "chosenDrop=$drop locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}" + } + return null + } + val remaining = text.drop(drop) val leadingWhitespace = remaining.indexOfFirst { !it.isWhitespace() } - if (leadingWhitespace < 0) return null + if (leadingWhitespace < 0) { + logReaderTtsStartTrace { + "event=planner_slice_skip reason=blank_after_drop rawDrop=$rawDrop " + + "chosenDrop=$drop locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}" + } + return null + } val nextText = remaining.drop(leadingWhitespace) if (nextText.isBlank()) return null - val nextStartOffset = (boundedOffset + leadingWhitespace).coerceAtMost(endOffset) + val nextStartOffset = (sourceOffset + leadingWhitespace).coerceAtMost(endOffset) + logReaderTtsStartTrace { + "event=planner_slice_result rawDrop=$rawDrop chosenDrop=$drop " + + "leadingWhitespace=$leadingWhitespace nextStart=$nextStartOffset locator=${locator.readerTtsLocatorSummary()} " + + "chunk=${readerTtsChunkSummary()} nextText=\"${nextText.readerTtsLogPreview()}\"" + } return copy( text = nextText, spokenText = nextText, @@ -545,7 +597,7 @@ object ReaderTtsPlanner { chunks += ReaderTtsTextRange( text = currentText.toString(), start = currentStart, - end = currentStart + currentText.length + end = currentEnd ) } currentText = StringBuilder() @@ -554,19 +606,25 @@ object ReaderTtsPlanner { } for (sentence in sentenceRanges) { + val appendText = if (currentText.isEmpty()) { + sentence.text + } else { + val gapStart = (currentEnd - sourceStart).coerceIn(0, source.length) + val gapEnd = (sentence.end - sourceStart).coerceIn(gapStart, source.length) + source.substring(gapStart, gapEnd) + } if (sentence.text.length > maxLength) { flushCurrent() chunks += sentence continue } - if (currentText.isNotEmpty() && currentText.length + sentence.text.length + 1 > maxLength) { + if (currentText.isNotEmpty() && currentText.length + appendText.length > maxLength) { flushCurrent() currentText.append(sentence.text) currentStart = sentence.start currentEnd = sentence.end } else { - if (currentText.isNotEmpty()) currentText.append(" ") - currentText.append(sentence.text) + currentText.append(appendText) if (currentStart < 0) currentStart = sentence.start currentEnd = sentence.end } @@ -612,6 +670,162 @@ object ReaderTtsPlanner { val start: Int, val end: Int ) + + private data class ReaderTtsChunkTarget( + val text: String, + val sourceCfi: String?, + val startOffset: Int + ) + + private fun ReaderLocator.toTtsChunkTarget(): ReaderTtsChunkTarget? { + val offset = startOffset ?: return null + val sourceCfi = cfi + ?.readerTtsSourceCfiBase() + ?.takeIf { it.startsWith("/") } + return ReaderTtsChunkTarget( + text = textQuote.orEmpty(), + sourceCfi = sourceCfi, + startOffset = offset + ) + } + + private fun ReaderLocator.isSyntheticDesktopTtsAnchor(): Boolean { + val value = cfi.orEmpty() + return value.startsWith("desktop:") || + value.startsWith("desktop-scroll:") || + value.startsWith("desktop-scroll-page:") + } + + private fun findReaderTtsChunkStartIndex( + chunks: List, + target: ReaderTtsChunkTarget? + ): Int? { + if (target == null) return null + + val exactIndex = chunks.indexOfFirst { + readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) && + it.startOffset == target.startOffset && + it.text.normalizedReaderTtsText() == target.text.normalizedReaderTtsText() + } + if (exactIndex >= 0) return exactIndex + + val sourceAndOffsetIndex = chunks.indexOfFirst { + readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) && + target.startOffset >= it.startOffset && + target.startOffset < it.endOffset + } + if (sourceAndOffsetIndex >= 0) return sourceAndOffsetIndex + + val sourceAndTextIndex = chunks.indexOfFirst { + readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) && + readerTtsTextMatches(it.text, target.text) + } + if (sourceAndTextIndex >= 0) return sourceAndTextIndex + + val sourceNearestOffsetIndex = chunks + .mapIndexedNotNull { index, chunk -> + if (readerSameTtsChunkSource(chunk.sourceCfi, target.sourceCfi)) { + index to kotlin.math.abs(chunk.startOffset - target.startOffset) + } else { + null + } + } + .minByOrNull { it.second } + ?.first + if (sourceNearestOffsetIndex != null) return sourceNearestOffsetIndex + + return findUniqueReaderTtsTextMatch(chunks, target.text) + } + + private fun List.withInitialChunkOverride( + startChunkIndex: Int, + initialChunk: ReaderTtsChunk? + ): List { + if (initialChunk == null || startChunkIndex !in indices) return this + val existing = this[startChunkIndex] + if ( + existing.text == initialChunk.text && + existing.sourceCfi == initialChunk.sourceCfi && + existing.startOffset == initialChunk.startOffset + ) { + return this + } + return toMutableList().also { it[startChunkIndex] = initialChunk } + } + + private fun readerSameTtsChunkSource(first: String?, second: String?): Boolean { + val firstSource = first.orEmpty() + val secondSource = second.orEmpty() + if (firstSource.isBlank() || secondSource.isBlank()) return firstSource == secondSource + val firstPath = firstSource.readerTtsSourceCfiBase() + val secondPath = secondSource.readerTtsSourceCfiBase() + return firstPath == secondPath || + readerTtsCfiPathContains(firstPath, secondPath) || + readerTtsCfiPathContains(secondPath, firstPath) + } + + private fun readerTtsCfiPathContains(parentPath: String, childPath: String): Boolean { + if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false + val parentParts = parentPath.split('/').filter { it.isNotEmpty() } + val childParts = childPath.split('/').filter { it.isNotEmpty() } + return parentParts.size < childParts.size && childParts.take(parentParts.size) == parentParts + } + + private fun readerTtsTextMatches(first: String, second: String): Boolean { + val firstNormalized = first.normalizedReaderTtsText() + val secondNormalized = second.normalizedReaderTtsText() + if (firstNormalized.isBlank() || secondNormalized.isBlank()) return false + return firstNormalized == secondNormalized || + firstNormalized.startsWith(secondNormalized) || + secondNormalized.startsWith(firstNormalized) + } + + private fun findUniqueReaderTtsTextMatch(chunks: List, text: String): Int? { + val matches = chunks.mapIndexedNotNull { index, chunk -> + index.takeIf { readerTtsTextMatches(chunk.text, text) } + } + return matches.singleOrNull() + } + + private fun String.readerTtsSourceCfiBase(): String { + return substringBefore('|').substringBefore(':') + } + + private fun String.normalizedReaderTtsText(): String { + return replace(Regex("\\s+"), " ").trim() + } + + private inline fun logReaderTtsStartTrace(message: () -> String) { + logSharedReaderDiagnostic(ReaderTtsStartTraceLogTag, message) + } + + private fun ReaderLocator?.readerTtsLocatorSummary(maxTextLength: Int = 120): String { + if (this == null) return "null" + return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " + + "offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " + + "block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " + + "cfi=\"${cfi.orEmpty().readerTtsLogPreview(180)}\" text=\"${textQuote.orEmpty().readerTtsLogPreview(maxTextLength)}\"" + } + + private fun ReaderTtsChunk?.readerTtsChunkSummary(maxTextLength: Int = 120): String { + if (this == null) return "null" + return "index=$index page=$pageIndex chapter=$chapterIndex offsets=$startOffset..$endOffset " + + "sourceCfi=\"${sourceCfi.orEmpty().readerTtsLogPreview(160)}\" textChars=${text.length} " + + "text=\"${text.readerTtsLogPreview(maxTextLength)}\" spoken=\"${spokenText.readerTtsLogPreview(maxTextLength)}\"" + } + + private fun ReaderTtsChunkTarget?.readerTtsTargetSummary(maxTextLength: Int = 120): String { + if (this == null) return "null" + return "offset=$startOffset sourceCfi=\"${sourceCfi.orEmpty().readerTtsLogPreview(160)}\" " + + "text=\"${text.readerTtsLogPreview(maxTextLength)}\"" + } + + private fun String.readerTtsLogPreview(maxLength: Int = 120): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") + } } data class ReaderCloudTtsState( @@ -625,6 +839,31 @@ data class ReaderCloudTtsState( val cacheSummary: ReaderTtsCacheSummary = ReaderTtsCacheSummary() ) +data class ReaderCloudTtsControlsModel( + val isVisible: Boolean, + val canPauseResume: Boolean, + val canSkipPrevious: Boolean, + val canSkipNext: Boolean, + val canLocateCurrentChunk: Boolean +) + +fun readerCloudTtsControlsModel(cloudTts: ReaderCloudTtsState): ReaderCloudTtsControlsModel { + val progress = cloudTts.progress + val visible = cloudTts.isLoading || cloudTts.isPlaying || cloudTts.isPaused + val hasCurrentChunk = progress.currentChunk != null + return ReaderCloudTtsControlsModel( + isVisible = visible, + canPauseResume = cloudTts.isPlaying || cloudTts.isPaused, + canSkipPrevious = !cloudTts.isLoading && + progress.currentChunkIndex > 0 && + progress.chunks.isNotEmpty(), + canSkipNext = !cloudTts.isLoading && + progress.currentChunkIndex >= 0 && + progress.currentChunkIndex < progress.chunks.lastIndex, + canLocateCurrentChunk = hasCurrentChunk + ) +} + data class ReaderAiResultState( val title: String? = null, val text: String = "", diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderMarkdownModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt similarity index 98% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderMarkdownModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt index bafd853..adc5387 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderMarkdownModels.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared data class ReaderMarkdownDocument( val blocks: List diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderSearchModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt similarity index 93% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderSearchModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt index ef5fdd0..3e1ed28 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderSearchModels.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared data class SearchResult( val locationInSource: Int, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderToolbarModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt similarity index 88% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderToolbarModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt index 424ec75..ca2e976 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderToolbarModels.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt @@ -1,13 +1,11 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared private val DefaultReaderBottomToolIds: Set get() = setOf( ReaderTool.SLIDER.id, ReaderTool.TOC.id, ReaderTool.FORMAT.id, - ReaderTool.SEARCH.id, - ReaderTool.AI_FEATURES.id, - ReaderTool.TTS_CONTROLS.id + ReaderTool.SEARCH.id ) enum class ReaderTool( @@ -22,8 +20,8 @@ enum class ReaderTool( TOC("toc", "Sidebar", "Bottom Bar"), FORMAT("format", "Text Formatting", "Bottom Bar"), SEARCH("search", "Search", "Bottom Bar", supportsDesktopQuickAction = true), - AI_FEATURES("ai_features", "AI Features", "Bottom Bar", supportsDesktopQuickAction = true), - TTS_CONTROLS("tts_controls", "TTS Controls", "Bottom Bar", supportsDesktopQuickAction = true), + AI_FEATURES("ai_features", "AI Features", "Bottom Bar"), + TTS_CONTROLS("tts_controls", "TTS Controls", "Bottom Bar"), READING_MODE("reading_mode", "Reading Mode", "Overflow Menu"), BOOKMARK("bookmark", "Bookmark", "Overflow Menu", supportsDesktopQuickAction = true), TAP_TO_TURN("tap_to_turn", "Tap to Turn Pages", "Overflow Menu"), @@ -31,7 +29,6 @@ enum class ReaderTool( PAGE_TURN_ANIM("page_turn_anim", "Realistic Page Turns", "Overflow Menu"), KEEP_SCREEN_ON("keep_screen_on", "Keep Screen On", "Overflow Menu"), VISUAL_OPTIONS("visual_options", "Visual Options", "Overflow Menu"), - AUTO_SCROLL("auto_scroll", "Auto Scroll", "Overflow Menu", supportsDesktopQuickAction = true), TTS_SETTINGS("tts_settings", "TTS Voice Settings", "Overflow Menu"), TTS_REPLACEMENTS("tts_replacements", "TTS Word Replacements", "Overflow Menu"); diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderTtsReplacements.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt similarity index 81% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderTtsReplacements.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt index d2a30b1..587245f 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderTtsReplacements.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -90,22 +90,11 @@ data class ReaderTtsReplacementApplyResult( object ReaderTtsReplacementEngine { fun validate(rule: ReaderTtsReplacementRule): ReaderTtsReplacementValidation { - if (rule.from.isBlank()) { - return ReaderTtsReplacementValidation(isValid = false, message = "Enter text to replace.") - } - if (!rule.isRegex) { - return ReaderTtsReplacementValidation(isValid = true) - } - return runCatching { rule.toRegex() } - .fold( - onSuccess = { ReaderTtsReplacementValidation(isValid = true) }, - onFailure = { - ReaderTtsReplacementValidation( - isValid = false, - message = it.message ?: "This regex is not valid.", - ) - }, - ) + val validation = ReaderWordReplacementEngine.validate(rule.toWordReplacementRule()) + return ReaderTtsReplacementValidation( + isValid = validation.isValid, + message = validation.message, + ) } fun apply( @@ -117,52 +106,31 @@ object ReaderTtsReplacementEngine { return ReaderTtsReplacementApplyResult(text = text) } - var current = text - val applied = mutableListOf() - val errors = mutableListOf() - - preferences.activeRulesForBook(bookId).forEach { rule -> - if (!rule.enabled || rule.from.isBlank()) return@forEach - val regex = runCatching { rule.toRegex() } - .onFailure { - errors += ReaderTtsReplacementError( - ruleId = rule.id, - message = it.message ?: "Invalid regex.", - ) - } - .getOrNull() ?: return@forEach - val replacement = if (rule.isRegex) rule.to else Regex.escapeReplacement(rule.to) - val next = runCatching { regex.replace(current, replacement) } - .onFailure { - errors += ReaderTtsReplacementError( - ruleId = rule.id, - message = it.message ?: "Invalid replacement.", - ) - } - .getOrNull() ?: return@forEach - if (next != current) { - applied += rule.id - current = next - } - } + val result = ReaderWordReplacementEngine.apply( + text = text, + rules = preferences.activeRulesForBook(bookId).map { it.toWordReplacementRule() }, + ) return ReaderTtsReplacementApplyResult( - text = current, - appliedRuleIds = applied, - errors = errors, + text = result.text, + appliedRuleIds = result.appliedRuleIds, + errors = result.errors.map { + ReaderTtsReplacementError(ruleId = it.ruleId, message = it.message) + }, ) } +} - private fun ReaderTtsReplacementRule.toRegex(): Regex { - val source = if (isRegex) from else Regex.escape(from) - val boundedSource = if (wholeWord) { - """(? = emptyList(), + val errors: List = emptyList(), +) + +object ReaderWordReplacementEngine { + fun validate(rule: ReaderWordReplacementRule): ReaderWordReplacementValidation { + if (rule.from.isBlank()) { + return ReaderWordReplacementValidation(isValid = false, message = "Enter text to replace.") + } + if (!rule.isRegex) { + return ReaderWordReplacementValidation(isValid = true) + } + return runCatching { rule.toRegex() } + .fold( + onSuccess = { ReaderWordReplacementValidation(isValid = true) }, + onFailure = { + ReaderWordReplacementValidation( + isValid = false, + message = it.message ?: "This regex is not valid.", + ) + }, + ) + } + + fun apply( + text: String, + rules: List, + ): ReaderWordReplacementApplyResult { + if (text.isEmpty() || rules.isEmpty()) { + return ReaderWordReplacementApplyResult(text = text) + } + + var current = text + val applied = mutableListOf() + val errors = mutableListOf() + + rules.forEach { rule -> + if (!rule.enabled || rule.from.isBlank()) return@forEach + val regex = runCatching { rule.toRegex() } + .onFailure { + errors += ReaderWordReplacementError( + ruleId = rule.id, + message = it.message ?: "Invalid regex.", + ) + } + .getOrNull() ?: return@forEach + val replacement = if (rule.isRegex) rule.to else Regex.escapeReplacement(rule.to) + val next = runCatching { regex.replace(current, replacement) } + .onFailure { + errors += ReaderWordReplacementError( + ruleId = rule.id, + message = it.message ?: "Invalid replacement.", + ) + } + .getOrNull() ?: return@forEach + if (next != current) { + applied += rule.id + current = next + } + } + + return ReaderWordReplacementApplyResult( + text = current, + appliedRuleIds = applied, + errors = errors, + ) + } + + private fun ReaderWordReplacementRule.toRegex(): Regex { + val source = if (isRegex) from else Regex.escape(from) + val boundedSource = if (wholeWord) { + """(?> = emptyMap(), +) { + fun rulesForFile(fileId: String?): List { + return fileRules[fileId.orEmpty()].orEmpty() + } + + fun activeRulesForFile(fileId: String?): List { + return rulesForFile(fileId).filter { it.enabled && it.from.isNotBlank() } + } + + fun withFileRules( + fileId: String?, + rules: List, + ): ReaderBookReplacementPreferences { + val key = fileId.orEmpty() + val nextRules = if (rules.isEmpty()) { + fileRules - key + } else { + fileRules + (key to rules) + } + return copy(fileRules = nextRules) + } + + fun scopedToFile(fileId: String?): ReaderBookReplacementPreferences { + val key = fileId.orEmpty() + val rules = rulesForFile(key) + return if (rules.isEmpty()) { + ReaderBookReplacementPreferences() + } else { + ReaderBookReplacementPreferences(fileRules = mapOf(key to rules)) + } + } + + fun signatureForFile(fileId: String?): String { + return activeRulesForFile(fileId).joinToString(separator = "|") { rule -> + listOf( + rule.id, + rule.from, + rule.to, + rule.enabled.toString(), + rule.isRegex.toString(), + rule.matchCase.toString(), + rule.wholeWord.toString(), + ).joinToString(separator = "\u001F") + } + } +} + +object ReaderBookReplacementEngine { + fun validate(rule: ReaderWordReplacementRule): ReaderWordReplacementValidation { + return ReaderWordReplacementEngine.validate(rule) + } + + fun apply( + text: String, + preferences: ReaderBookReplacementPreferences, + fileId: String?, + ): ReaderWordReplacementApplyResult { + return ReaderWordReplacementEngine.apply( + text = text, + rules = preferences.activeRulesForFile(fileId), + ) + } +} + +object ReaderBookReplacementPreferencesJson { + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = false + encodeDefaults = true + } + + fun encode(preferences: ReaderBookReplacementPreferences): String { + return json.encodeToString(preferences) + } + + fun decodeOrEmpty(raw: String?): ReaderBookReplacementPreferences { + if (raw.isNullOrBlank()) return ReaderBookReplacementPreferences() + return runCatching { + json.decodeFromString(raw) + }.getOrNull() ?: ReaderBookReplacementPreferences() + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt similarity index 98% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt index 671137d..3cdfcb3 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared import kotlinx.coroutines.flow.Flow diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SampleLibrary.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt similarity index 98% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/SampleLibrary.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt index 5a847c6..b5c676f 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SampleLibrary.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared fun sampleLibraryState(): LibraryState { val reference = Tag("reference", "Reference", 0xFF9575CD.toInt()) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ScreenProjectors.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt similarity index 97% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/ScreenProjectors.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt index eda6178..68b9010 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ScreenProjectors.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared data class SharedHomeScreenModel( val recentBooks: List, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SettingsHubModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/SettingsHubModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt index a2b46ba..df0ed2f 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SettingsHubModels.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared enum class SharedSettingsPlatform { ANDROID, @@ -646,6 +646,7 @@ data class SharedSettingsHubInput( val isSignedIn: Boolean = false, val isProUser: Boolean = false, val accountAvailable: Boolean = true, + val includeAccountAuthActions: Boolean = true, val syncAvailable: Boolean = true, val folderSyncAvailable: Boolean = true, val aiSettingsAvailable: Boolean = true, @@ -750,7 +751,7 @@ fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubMode SharedSettingsSectionModel( section = SharedSettingsSection.SYNC_ACCOUNTS, items = buildList { - if (input.accountAvailable && input.featurePolicy.aiAndCloud) { + if (input.includeAccountAuthActions && input.accountAvailable && input.featurePolicy.aiAndCloud) { if (input.isSignedIn) { add( SharedSettingsItemModel( diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFeaturePolicy.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt similarity index 78% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFeaturePolicy.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt index a102c63..ef4746e 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFeaturePolicy.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared data class SharedFeaturePolicy( val networkAccess: Boolean = true, @@ -11,6 +11,11 @@ data class SharedFeaturePolicy( ) { companion object { val Standard = SharedFeaturePolicy() + val OssOnline = SharedFeaturePolicy( + networkAccess = true, + aiAndCloud = true, + byokAi = true + ) val OssOffline = SharedFeaturePolicy( networkAccess = false, opdsCatalogs = false, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFormatters.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt similarity index 97% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFormatters.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt index f51770f..2038dc4 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFormatters.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared import kotlin.math.log10 import kotlin.math.pow diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt new file mode 100644 index 0000000..9c0a20f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt @@ -0,0 +1,34 @@ +package org.dueattendant149.bookreader.shared + +const val EPISTEME_POLICY_BASE_URL = "https://aryan-raj3112.github.io/reader-policy" + +enum class SharedLegalProfile { + STANDARD, + OSS +} + +data class SharedLegalLinks( + val privacyPolicyUrl: String, + val termsUrl: String, + val licensesUrl: String +) + +fun sharedLegalLinksForProfile(profile: SharedLegalProfile): SharedLegalLinks { + val privacyPath: String + val termsPath: String + when (profile) { + SharedLegalProfile.STANDARD -> { + privacyPath = "privacy-policy.html" + termsPath = "terms-and-conditions.html" + } + SharedLegalProfile.OSS -> { + privacyPath = "oss-privacy-policy.html" + termsPath = "oss-terms-of-service.html" + } + } + return SharedLegalLinks( + privacyPolicyUrl = "$EPISTEME_POLICY_BASE_URL/$privacyPath", + termsUrl = "$EPISTEME_POLICY_BASE_URL/$termsPath", + licensesUrl = "$EPISTEME_POLICY_BASE_URL/licenses.html" + ) +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt similarity index 91% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt index 5589377..6f6561c 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -16,13 +16,13 @@ import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette -import com.aryan.reader.shared.pdf.SharedPdfReaderViewport -import com.aryan.reader.shared.reader.ReaderBookmark -import com.aryan.reader.shared.reader.ReaderPageSpreadMode -import com.aryan.reader.shared.reader.ReaderReadingMode -import com.aryan.reader.shared.reader.ReaderSettings -import com.aryan.reader.shared.reader.SharedReaderTextAlign +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport +import org.dueattendant149.bookreader.shared.reader.ReaderBookmark +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.SharedReaderTextAlign data class SharedLibrarySnapshot( val books: List = emptyList(), @@ -45,8 +45,10 @@ data class SharedLibrarySnapshot( val appSeedColor: Color? = null, val appFontPreference: AppFontPreference = AppFontPreference.System, val customAppThemes: List = emptyList(), + val customReaderThemes: List = emptyList(), val readerDefaultSettings: ReaderSettings = ReaderSettings(), val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"), + val desktopReaderDefaultsVersion: Int = 0, val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(), val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(), val pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(), @@ -54,7 +56,7 @@ data class SharedLibrarySnapshot( ) object SharedLibrarySnapshotJson { - private const val SCHEMA_VERSION = 20 + private const val SCHEMA_VERSION = 22 private val json = Json { prettyPrint = true @@ -106,11 +108,15 @@ object SharedLibrarySnapshotJson { ?.asAppFontPreferenceOrNull() ?: AppFontPreference.System, customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() }, + customReaderThemes = root.array("customReaderThemes") + .mapNotNull { it.asReaderThemeOrNull() } + .sanitizeCustomReaderThemes(), readerDefaultSettings = readerDefaultSettings.migrateLegacyDefaultReadingMode(schemaVersion), pdfReaderDefaultSettings = root["pdfReaderDefaultSettings"] ?.takeUnless { it is JsonNull } ?.asReaderSettingsOrNull() ?: ReaderSettings(themeId = "no_theme"), + desktopReaderDefaultsVersion = root.int("desktopReaderDefaultsVersion", 0), readerToolbarPreferences = root["readerToolbarPreferences"] ?.takeUnless { it is JsonNull } ?.asReaderToolbarPreferencesOrNull() @@ -154,8 +160,12 @@ object SharedLibrarySnapshotJson { "appSeedColor" to snapshot.appSeedColor.asJson(), "appFontPreference" to snapshot.appFontPreference.sanitized().toJsonObject(), "customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }), + "customReaderThemes" to JsonArray( + snapshot.customReaderThemes.sanitizeCustomReaderThemes().map { it.toJsonObject() } + ), "readerDefaultSettings" to snapshot.readerDefaultSettings.asJson(), "pdfReaderDefaultSettings" to snapshot.pdfReaderDefaultSettings.asJson(), + "desktopReaderDefaultsVersion" to JsonPrimitive(snapshot.desktopReaderDefaultsVersion), "readerToolbarPreferences" to snapshot.readerToolbarPreferences.sanitized().toJsonObject(), "readerHighlightPalette" to snapshot.readerHighlightPalette.sanitized().toJsonObject(), "pdfHighlighterPalette" to snapshot.pdfHighlighterPalette.sanitized().toJsonObject(), @@ -279,7 +289,8 @@ private fun JsonElement.asBookItemOrNull(): BookItem? { readerSettings = obj["readerSettings"]?.takeUnless { it is JsonNull }?.asReaderSettingsOrNull(), readerBookmarks = obj.array("readerBookmarks").mapNotNull { it.asReaderBookmarkOrNull() }, readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() }, - pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull() + pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull(), + readingPositionModifiedTimestamp = obj.long("readingPositionModifiedTimestamp") ) } @@ -336,7 +347,8 @@ private fun JsonElement.asSyncedFolderOrNull(): SyncedFolder? { .mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() } .filter { it in SharedFileCapabilities.knownFileTypes } .toSet() - .ifEmpty { SharedFileCapabilities.knownFileTypes } + .ifEmpty { SharedFileCapabilities.knownFileTypes }, + localSyncEnabled = obj.boolean("localSyncEnabled", true) ) } @@ -349,6 +361,19 @@ private fun JsonElement.asCustomAppThemeOrNull(): CustomAppTheme? { ) } +private fun JsonElement.asReaderThemeOrNull(): ReaderTheme? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + return ReaderTheme( + id = obj.string("id") ?: return null, + name = obj.string("name") ?: return null, + backgroundColor = obj.int("bgColor")?.let { Color(it) } ?: return null, + textColor = obj.int("textColor")?.let { Color(it) } ?: return null, + isDark = obj.boolean("isDark", false), + textureId = obj.string("textureId")?.takeIf { it.isNotBlank() }, + isCustom = true + ) +} + private fun JsonElement.asAppFontPreferenceOrNull(): AppFontPreference? { val obj = runCatching { jsonObject }.getOrNull() ?: return null val kind = obj.string("kind") @@ -391,7 +416,8 @@ private fun BookItem.toJsonObject(): JsonObject { "readerSettings" to readerSettings.asJson(), "readerBookmarks" to JsonArray(readerBookmarks.map { it.toJsonObject() }), "readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() }), - "pdfReaderViewport" to pdfReaderViewport.asJson() + "pdfReaderViewport" to pdfReaderViewport.asJson(), + "readingPositionModifiedTimestamp" to JsonPrimitive(readingPositionModifiedTimestamp) ) ) } @@ -451,7 +477,8 @@ private fun SyncedFolder.toJsonObject(): JsonObject { .filter { it in SharedFileCapabilities.knownFileTypes } .map { it.name } .sorted() - .asJsonArray() + .asJsonArray(), + "localSyncEnabled" to JsonPrimitive(localSyncEnabled) ) ) } @@ -466,6 +493,18 @@ private fun CustomAppTheme.toJsonObject(): JsonObject { ) } +private fun ReaderTheme.toJsonObject(): JsonObject { + val values = mutableMapOf( + "id" to JsonPrimitive(id), + "name" to JsonPrimitive(name), + "bgColor" to JsonPrimitive(backgroundColor.toArgb()), + "textColor" to JsonPrimitive(textColor.toArgb()), + "isDark" to JsonPrimitive(isDark) + ) + textureId?.let { values["textureId"] = JsonPrimitive(it) } + return JsonObject(values) +} + private fun AppFontPreference.toJsonObject(): JsonObject { val sanitized = sanitized() return JsonObject( @@ -529,6 +568,7 @@ private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? { pageSpreadMode = obj.string("pageSpreadMode") ?.let { runCatching { ReaderPageSpreadMode.valueOf(it) }.getOrNull() } ?: defaults.pageSpreadMode, + rightToLeftPagination = obj.boolean("rightToLeftPagination", defaults.rightToLeftPagination), pdfVerticalPageGapVisible = obj.boolean( "pdfVerticalPageGapVisible", defaults.pdfVerticalPageGapVisible @@ -650,6 +690,8 @@ private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? { pageIndex = obj.int("pageIndex"), startOffset = obj.int("startOffset"), endOffset = obj.int("endOffset"), + blockIndex = obj.int("blockIndex"), + charOffset = obj.int("charOffset"), textQuote = obj.string("textQuote"), cfi = obj.string("cfi") ) @@ -681,6 +723,7 @@ private fun ReaderSettings?.asJson(): JsonElement { "pageInfoMode" to JsonPrimitive(settings.pageInfoMode.name), "pageInfoPosition" to JsonPrimitive(settings.pageInfoPosition.name), "pageSpreadMode" to JsonPrimitive(settings.pageSpreadMode.name), + "rightToLeftPagination" to JsonPrimitive(settings.rightToLeftPagination), "pdfVerticalPageGapVisible" to JsonPrimitive(settings.pdfVerticalPageGapVisible), "pdfPageNumberOverlayVisible" to JsonPrimitive(settings.pdfPageNumberOverlayVisible), "pdfFirstPageStandaloneInSpread" to JsonPrimitive(settings.pdfFirstPageStandaloneInSpread), @@ -767,6 +810,8 @@ private fun ReaderLocator.toJsonObject(): JsonObject { pageIndex?.let { put("pageIndex", JsonPrimitive(it)) } startOffset?.let { put("startOffset", JsonPrimitive(it)) } endOffset?.let { put("endOffset", JsonPrimitive(it)) } + blockIndex?.let { put("blockIndex", JsonPrimitive(it)) } + charOffset?.let { put("charOffset", JsonPrimitive(it)) } textQuote?.let { put("textQuote", JsonPrimitive(it)) } cfi?.let { put("cfi", JsonPrimitive(it)) } } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt similarity index 95% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt index 0f79da6..94f94e0 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared -import com.aryan.reader.shared.reader.ReaderEngine -import com.aryan.reader.shared.reader.ReaderSessionState +import org.dueattendant149.bookreader.shared.reader.ReaderEngine +import org.dueattendant149.bookreader.shared.reader.ReaderSessionState fun LibraryState.reduce(action: LibraryAction): LibraryState { return when (action) { @@ -89,6 +89,9 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState { appSeedColor = if (shouldClearSeed) null else appSeedColor ) } + is AppAction.CustomReaderThemesChanged -> copy( + customReaderThemes = action.themes.sanitizeCustomReaderThemes() + ) is AppAction.SyncEnabledChanged -> copy(isSyncEnabled = action.enabled) is AppAction.FolderSyncEnabledChanged -> copy(isFolderSyncEnabled = action.enabled) is AppAction.TabsEnabledChanged -> copy( @@ -101,9 +104,11 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState { if (bookId.isBlank()) { this } else { + val currentTabIds = openTabIds.distinct() + val nextTabIds = if (bookId in currentTabIds) currentTabIds else currentTabIds + bookId copy( isTabsEnabled = true, - openTabIds = (openTabIds - bookId) + bookId, + openTabIds = nextTabIds, activeTabBookId = bookId ) } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SmartCollectionEngine.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt similarity index 98% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/SmartCollectionEngine.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt index 67e35e0..de1f8a8 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SmartCollectionEngine.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared +package org.dueattendant149.bookreader.shared import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogs.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt similarity index 98% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogs.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt index 6bea4a2..80fd208 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogs.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.opds +package org.dueattendant149.bookreader.shared.opds import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsController.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsController.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt index 151596c..d36b737 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsController.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.opds +package org.dueattendant149.bookreader.shared.opds class SharedOpdsController( private val repository: SharedOpdsRepository, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt similarity index 86% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt index 9eabf11..3e34222 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsModels.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.opds +package org.dueattendant149.bookreader.shared.opds data class OpdsCatalog( val id: String, @@ -47,10 +47,18 @@ data class OpdsAcquisition( mimeType.contains("x-mobipocket-ebook", ignoreCase = true) -> "MOBI" mimeType.contains("fictionbook", ignoreCase = true) || mimeType.contains("fb2", ignoreCase = true) -> "FB2" - mimeType.contains("cbz", ignoreCase = true) || - mimeType.contains("comicbook", ignoreCase = true) -> "CBZ" + mimeType.contains("cbt", ignoreCase = true) || + mimeType.contains("comicbook+tar", ignoreCase = true) || + mimeType.contains("x-tar", ignoreCase = true) || + mimeType.equals("application/tar", ignoreCase = true) -> "CBT" mimeType.contains("cbr", ignoreCase = true) || + mimeType.contains("comicbook-rar", ignoreCase = true) || mimeType.contains("rar", ignoreCase = true) -> "CBR" + mimeType.contains("cb7", ignoreCase = true) || + mimeType.contains("7z", ignoreCase = true) -> "CB7" + mimeType.contains("cbz", ignoreCase = true) || + mimeType.contains("comicbook+zip", ignoreCase = true) || + mimeType.contains("comicbook", ignoreCase = true) -> "CBZ" mimeType.contains("txt", ignoreCase = true) || mimeType.contains("text/plain", ignoreCase = true) -> "TXT" else -> mimeType.substringAfterLast("/").uppercase() @@ -63,7 +71,7 @@ data class OpdsAcquisition( "PPTX" -> 4 "MOBI" -> 3 "FB2", "MD", "HTML" -> 2 - "CBZ", "CBR", "CB7" -> 1 + "CBZ", "CBR", "CB7", "CBT" -> 1 "TXT" -> 0 else -> -1 } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsUtilities.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt similarity index 63% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsUtilities.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt index 97bdc46..8ab4b2b 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsUtilities.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt @@ -1,6 +1,7 @@ -package com.aryan.reader.shared.opds +package org.dueattendant149.bookreader.shared.opds -import com.aryan.reader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.SharedFileCapabilities object SharedOpdsSearch { suspend fun buildSearchUrl( @@ -18,7 +19,14 @@ object SharedOpdsSearch { fun expandSearchTemplate(template: String, query: String): String { val encoded = query.percentEncode() - val expandedSearchTerms = template.replace("{searchTerms}", encoded) + val expandedSearchTerms = template + .replace("{searchTerms}", encoded) + .replace("{count}", DefaultSearchCount) + .replace("{startPage}", DefaultSearchStartPage) + .replace("{startIndex}", DefaultSearchStartIndex) + .replace("{language}", DefaultSearchLanguage) + .replace("{inputEncoding}", DefaultSearchEncoding) + .replace("{outputEncoding}", DefaultSearchEncoding) if (expandedSearchTerms != template) return expandedSearchTerms val queryTemplate = Regex("""\{([?&])([^}]+)\}""").find(template) @@ -56,6 +64,12 @@ object SharedOpdsSearch { contains("{query}") || contains("{keyword}") } + + private const val DefaultSearchCount = "12" + private const val DefaultSearchStartPage = "1" + private const val DefaultSearchStartIndex = "1" + private const val DefaultSearchLanguage = "*" + private const val DefaultSearchEncoding = "UTF-8" } object SharedOpdsDownloadNamer { @@ -82,6 +96,7 @@ object SharedOpdsDownloadNamer { "CBZ" -> ".cbz" "CBR" -> ".cbr" "CB7" -> ".cb7" + "CBT" -> ".cbt" "MD" -> ".md" "HTML" -> ".html" "TXT" -> ".txt" @@ -120,11 +135,106 @@ object SharedOpdsDownloadNamer { .lowercase() .takeIf { it.isNotBlank() } ?: return null - if (SharedFileCapabilities.fileTypeForName(cleanName) == com.aryan.reader.shared.FileType.UNKNOWN) return null + if (SharedFileCapabilities.fileTypeForName(cleanName) == org.dueattendant149.bookreader.shared.FileType.UNKNOWN) return null return ".$extension" } } +object SharedOpdsLocalBookMatcher { + fun findBook(entry: OpdsEntry, books: List): BookItem? { + return find( + entry = entry, + books = books, + title = { it.title }, + displayName = { it.displayName }, + path = { it.path } + ) + } + + fun find( + entry: OpdsEntry, + books: List, + title: (T) -> String?, + displayName: (T) -> String?, + path: (T) -> String? + ): T? { + val entryKeys = entry.matchKeys() + return books.firstOrNull { book -> + book.matchKeys(title, displayName, path).any { it in entryKeys } + } + } + + private fun OpdsEntry.matchKeys(): Set { + return buildSet { + addNormalized(title) + val safeTitle = SharedOpdsDownloadNamer.safeFileStem(title) + addNormalized(safeTitle) + addNormalized(safeTitle.take(50)) + acquisitions.forEach { acquisition -> + addFileNameKeys(acquisition.url) + } + } + } + + private fun T.matchKeys( + title: (T) -> String?, + displayName: (T) -> String?, + path: (T) -> String? + ): Set { + return buildSet { + addNormalized(title(this@matchKeys)) + addFileNameKeys(displayName(this@matchKeys)) + addFileNameKeys(path(this@matchKeys)) + } + } + + private fun MutableSet.addFileNameKeys(value: String?) { + val decodedName = value + ?.substringBefore('?') + ?.substringBefore('#') + ?.substringAfterLast('/') + ?.substringAfterLast('\\') + ?.percentDecode() + ?.takeIf { it.isNotBlank() } + ?: return + addNormalized(decodedName) + addNormalized(decodedName.withoutKnownExtension()) + addNormalized(decodedName.withoutKnownExtension().withoutOpdsDownloadPrefix()) + } + + private fun MutableSet.addNormalized(value: String?) { + val normalized = value?.normalizedMatchKey() ?: return + if (normalized.isNotBlank()) add(normalized) + } + + private fun String.withoutKnownExtension(): String { + val knownSuffix = SharedFileCapabilities.fileExtensionSuffixForName(this) + if (knownSuffix != null && endsWith(knownSuffix, ignoreCase = true)) { + return dropLast(knownSuffix.length) + } + val extension = substringAfterLast('.', missingDelimiterValue = "") + return if (extension.length in 1..8 && extension.all { it.isLetterOrDigit() }) { + substringBeforeLast('.') + } else { + this + } + } + + private fun String.normalizedMatchKey(): String { + return percentDecode() + .withoutOpdsDownloadPrefix() + .replace(Regex("""[^\p{L}\p{N}]+"""), " ") + .trim() + .lowercase() + .replace(Regex("""\s+"""), " ") + .removePrefix("opds dl ") + } + + private fun String.withoutOpdsDownloadPrefix(): String { + return replace(Regex("""^opds[_\-\s]+dl[_\-\s]+""", RegexOption.IGNORE_CASE), "") + } +} + object SharedOpdsStreamUri { private const val SCHEME_PREFIX = "opds-pse://stream" diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt similarity index 82% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt index 1797f58..a547bac 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.pdf +package org.dueattendant149.bookreader.shared.pdf import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -48,6 +48,38 @@ data class SharedPdfAnnotationComment( val modifiedAt: Long = 0L ) +const val DEFAULT_SHARED_PDF_COMMENT_AUTHOR = "Reader" + +fun List.visiblePdfAnnotationComments(): List { + val visibleCommentIds = filter { it.contents.isNotBlank() }.map { it.id }.toSet() + return filter { it.contents.isNotBlank() } + .map { comment -> + if (comment.parentId != null && comment.parentId !in visibleCommentIds) { + comment.copy(parentId = null) + } else { + comment + } + } +} + +fun List.pdfCommentChildren(parentId: String?): List { + return filter { it.parentId == parentId } + .sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id })) +} + +fun List.withoutPdfCommentThread(commentId: String): List { + val childrenByParentId = groupBy { it.parentId } + val idsToRemove = mutableSetOf() + + fun collect(id: String) { + if (!idsToRemove.add(id)) return + childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) } + } + + collect(commentId) + return filterNot { it.id in idsToRemove } +} + @Serializable data class SharedPdfAnnotation( val id: String, @@ -162,13 +194,7 @@ object SharedPdfAnnotationDefaults { 0xFFFFFFFF.toInt() ) - val highlighterPalette: List = listOf( - 0x8CFF9800.toInt(), - 0x8CFFEB3B.toInt(), - 0x8C81C784.toInt(), - 0x8C64B5F6.toInt(), - 0x8CE1BEE7.toInt() - ) + val highlighterPalette: List = SharedPdfAndroidHighlightColors.palette.take(5) fun configFor(tool: PdfInkTool): PdfToolConfig { return when (tool) { @@ -176,8 +202,8 @@ object SharedPdfAnnotationDefaults { PdfInkTool.PEN -> PdfToolConfig(0xFFFF0000.toInt(), 0.008f) PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF0000FF.toInt(), 0.008f) PdfInkTool.PENCIL -> PdfToolConfig(0xFF444444.toInt(), 0.008f) - PdfInkTool.HIGHLIGHTER -> PdfToolConfig(0x8CFF9800.toInt(), 0.035f) - PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(0x8CFFEB3B.toInt(), 0.035f) + PdfInkTool.HIGHLIGHTER -> PdfToolConfig(highlighterPalette[0], 0.035f) + PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(highlighterPalette[1], 0.035f) PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 0.03f) PdfInkTool.TEXT -> PdfToolConfig(0xFF000000.toInt(), 0.02f) } @@ -211,7 +237,9 @@ data class SharedPdfHighlighterPalette( const val DefaultAlpha: Int = 0x8C const val MaxColors: Int = 5 val defaultColors: List - get() = SharedPdfAnnotationDefaults.highlighterPalette.map { it.withPdfHighlighterAlpha() } + get() = SharedPdfAnnotationDefaults.highlighterPalette + .take(MaxColors) + .map { it.withPdfHighlighterAlpha() } } } @@ -219,18 +247,21 @@ object SharedPdfAndroidHighlightColors { const val StoredAlpha: Int = 0x8C const val RenderAlpha: Float = 0.4f + val orderedNames: List = listOf("ORANGE", "YELLOW", "GREEN", "BLUE", "PURPLE") + val colorsByName: Map = mapOf( - "YELLOW" to 0xFFFBC02D.toInt(), - "GREEN" to 0xFF388E3C.toInt(), - "BLUE" to 0xFF1976D2.toInt(), - "RED" to 0xFFD32F2F.toInt() + "ORANGE" to 0xFFFF9800.toInt(), + "YELLOW" to 0xFFFFEB3B.toInt(), + "GREEN" to 0xFF81C784.toInt(), + "BLUE" to 0xFF64B5F6.toInt(), + "PURPLE" to 0xFFE1BEE7.toInt() ) val palette: List - get() = colorsByName.keys.map(::argbForName) + get() = orderedNames.map(::argbForName) fun argbForName(name: String): Int { - val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("YELLOW") + val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("ORANGE") return (StoredAlpha shl 24) or (opaqueArgb and 0x00FFFFFF) } @@ -242,7 +273,7 @@ object SharedPdfAndroidHighlightColors { val dg = ((rgb shr 8) and 0xFF) - ((candidate shr 8) and 0xFF) val db = (rgb and 0xFF) - (candidate and 0xFF) dr * dr + dg * dg + db * db - }?.key ?: "YELLOW" + }?.key ?: "ORANGE" } fun nearestArgb(argb: Int): Int { diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfReaderSession.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt similarity index 73% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfReaderSession.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt index 9332b1f..fa64956 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfReaderSession.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.shared.pdf +package org.dueattendant149.bookreader.shared.pdf -import com.aryan.reader.shared.PdfDisplayMode -import com.aryan.reader.shared.SearchHighlightMode +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.SearchHighlightMode import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -186,12 +186,20 @@ data class SharedPdfReaderState( val isTextSelectionMode: Boolean = false, val bookmarks: List = emptyList(), val selectedAnnotationId: String? = null, - val annotations: List = emptyList() + val annotations: List = emptyList(), + val toolConfigs: Map = emptyMap(), + val penPalette: List = SharedPdfAnnotationDefaults.penPalette, + val lastActivePenTool: PdfInkTool = PdfInkTool.PEN, + val lastActiveHighlighterTool: PdfInkTool = PdfInkTool.HIGHLIGHTER, + val annotationUndoStack: List = emptyList(), + val annotationRedoStack: List = emptyList() ) { val safePageCount: Int get() = pageCount.coerceAtLeast(0) val lastPageIndex: Int get() = (safePageCount - 1).coerceAtLeast(0) val canGoPrevious: Boolean get() = pageIndex > 0 val canGoNext: Boolean get() = pageIndex < lastPageIndex + val canUndoAnnotationEdit: Boolean get() = annotationUndoStack.isNotEmpty() + val canRedoAnnotationEdit: Boolean get() = annotationRedoStack.isNotEmpty() val progressPercent: Float get() = ((pageIndex + 1).toFloat() / safePageCount.coerceAtLeast(1)) * 100f fun coerced(zoomSpec: PdfZoomSpec = PdfZoomSpec()): SharedPdfReaderState { @@ -202,6 +210,10 @@ data class SharedPdfReaderState( activeSearchResultIndex = activeSearchResultIndex.coerceAtLeast(-1), zoom = zoomSpec.clamp(zoom), bookmarks = bookmarks.normalizedBookmarks(lastPageIndex), + penPalette = penPalette.sanitizedSharedPdfPenPalette(), + lastActivePenTool = lastActivePenTool.takeIf { it.isSharedPdfPenTool } ?: PdfInkTool.PEN, + lastActiveHighlighterTool = lastActiveHighlighterTool.takeIf { it.isSharedPdfHighlighterTool } + ?: PdfInkTool.HIGHLIGHTER, selectedAnnotationId = selectedAnnotationId?.takeIf { selectedId -> annotations.any { it.id == selectedId } } @@ -225,6 +237,11 @@ data class SharedPdfReaderState( } } +sealed interface SharedPdfAnnotationHistoryAction { + data class Add(val pageIndex: Int, val annotation: SharedPdfAnnotation) : SharedPdfAnnotationHistoryAction + data class Remove(val itemsByPage: Map>) : SharedPdfAnnotationHistoryAction +} + sealed interface SharedPdfReaderAction { data class GoToPage(val pageIndex: Int) : SharedPdfReaderAction data object PreviousPage : SharedPdfReaderAction @@ -248,6 +265,7 @@ sealed interface SharedPdfReaderAction { data class ToolSelected(val tool: PdfInkTool) : SharedPdfReaderAction data class ColorSelected(val colorArgb: Int) : SharedPdfReaderAction data class StrokeWidthChanged(val strokeWidth: Float) : SharedPdfReaderAction + data class PenPaletteChanged(val colors: List) : SharedPdfReaderAction data class TextSelectionModeChanged(val enabled: Boolean) : SharedPdfReaderAction data class BookmarksLoaded(val bookmarks: List) : SharedPdfReaderAction data class BookmarkToggled( @@ -262,6 +280,8 @@ sealed interface SharedPdfReaderAction { data class AnnotationDeleted(val annotationId: String) : SharedPdfReaderAction data class AnnotationsChanged(val annotations: List) : SharedPdfReaderAction data class UndoLastAnnotationOnPage(val pageIndex: Int) : SharedPdfReaderAction + data object UndoAnnotationEdit : SharedPdfReaderAction + data object RedoAnnotationEdit : SharedPdfReaderAction data class ClearPageAnnotations(val pageIndex: Int) : SharedPdfReaderAction } @@ -327,16 +347,23 @@ fun SharedPdfReaderState.reduce( } } is SharedPdfReaderAction.ToolSelected -> { - val config = SharedPdfAnnotationDefaults.configFor(action.tool) + val config = toolConfigFor(action.tool) copy( selectedTool = action.tool, selectedColorArgb = config.colorArgb, strokeWidth = config.strokeWidth, - isTextSelectionMode = false + isTextSelectionMode = false, + lastActivePenTool = if (action.tool.isSharedPdfPenTool) action.tool else lastActivePenTool, + lastActiveHighlighterTool = if (action.tool.isSharedPdfHighlighterTool) { + action.tool + } else { + lastActiveHighlighterTool + } ) } - is SharedPdfReaderAction.ColorSelected -> copy(selectedColorArgb = action.colorArgb) - is SharedPdfReaderAction.StrokeWidthChanged -> copy(strokeWidth = action.strokeWidth.coerceAtLeast(0.0001f)) + is SharedPdfReaderAction.ColorSelected -> withActiveToolColor(action.colorArgb) + is SharedPdfReaderAction.StrokeWidthChanged -> withActiveToolStrokeWidth(action.strokeWidth.coerceAtLeast(0.0001f)) + is SharedPdfReaderAction.PenPaletteChanged -> copy(penPalette = action.colors.sanitizedSharedPdfPenPalette()) is SharedPdfReaderAction.TextSelectionModeChanged -> { if (action.enabled) { val config = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE) @@ -365,10 +392,19 @@ fun SharedPdfReaderState.reduce( } copy(bookmarks = nextBookmarks.normalizedBookmarks(lastPageIndex)) } - is SharedPdfReaderAction.AnnotationsLoaded -> copy(annotations = action.annotations.toList()) + is SharedPdfReaderAction.AnnotationsLoaded -> copy( + annotations = action.annotations.toList(), + annotationUndoStack = emptyList(), + annotationRedoStack = emptyList() + ) is SharedPdfReaderAction.AnnotationAdded -> copy( annotations = annotations + action.annotation, - selectedAnnotationId = action.annotation.id + selectedAnnotationId = action.annotation.id, + annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Add( + pageIndex = action.annotation.pageIndex, + annotation = action.annotation + ), + annotationRedoStack = emptyList() ) is SharedPdfReaderAction.AnnotationSelected -> copy( selectedAnnotationId = action.annotationId?.takeIf { id -> annotations.any { it.id == id } } @@ -378,36 +414,158 @@ fun SharedPdfReaderState.reduce( if (index < 0) { this } else { - copy(annotations = annotations.toMutableList().also { it[index] = action.annotation }) + copy( + annotations = annotations.toMutableList().also { it[index] = action.annotation }, + annotationRedoStack = emptyList() + ) } } - is SharedPdfReaderAction.AnnotationDeleted -> copy( - annotations = annotations.filterNot { it.id == action.annotationId }, - selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId } + is SharedPdfReaderAction.AnnotationDeleted -> { + val removed = annotations.firstOrNull { it.id == action.annotationId } + if (removed == null) { + this + } else { + copy( + annotations = annotations.filterNot { it.id == action.annotationId }, + selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId }, + annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove( + itemsByPage = mapOf(removed.pageIndex to listOf(removed)) + ), + annotationRedoStack = emptyList() + ) + } + } + is SharedPdfReaderAction.AnnotationsChanged -> copy( + annotations = action.annotations.toList(), + annotationUndoStack = emptyList(), + annotationRedoStack = emptyList() ) - is SharedPdfReaderAction.AnnotationsChanged -> copy(annotations = action.annotations.toList()) is SharedPdfReaderAction.UndoLastAnnotationOnPage -> { val index = annotations.indexOfLast { it.pageIndex == action.pageIndex } if (index < 0) { this } else { + val removed = annotations[index] val removedId = annotations[index].id copy( annotations = annotations.toMutableList().also { it.removeAt(index) }, - selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId } + selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId }, + annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove( + itemsByPage = mapOf(removed.pageIndex to listOf(removed)) + ), + annotationRedoStack = emptyList() ) } } + SharedPdfReaderAction.UndoAnnotationEdit -> undoSharedPdfAnnotationEdit() + SharedPdfReaderAction.RedoAnnotationEdit -> redoSharedPdfAnnotationEdit() is SharedPdfReaderAction.ClearPageAnnotations -> { - val removedIds = annotations.filter { it.pageIndex == action.pageIndex }.map { it.id }.toSet() - copy( - annotations = annotations.filterNot { it.pageIndex == action.pageIndex }, - selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds } - ) + val removed = annotations.filter { it.pageIndex == action.pageIndex } + if (removed.isEmpty()) { + this + } else { + val removedIds = removed.mapTo(mutableSetOf()) { it.id } + copy( + annotations = annotations.filterNot { it.pageIndex == action.pageIndex }, + selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds }, + annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove( + itemsByPage = mapOf(action.pageIndex to removed) + ), + annotationRedoStack = emptyList() + ) + } } }.coerced(zoomSpec) } +private fun SharedPdfReaderState.toolConfigFor(tool: PdfInkTool): PdfToolConfig { + return toolConfigs[tool] ?: SharedPdfAnnotationDefaults.configFor(tool) +} + +private fun SharedPdfReaderState.withActiveToolColor(colorArgb: Int): SharedPdfReaderState { + if (!selectedTool.isSharedPdfConfigurableTool) { + return copy(selectedColorArgb = colorArgb) + } + val currentConfig = toolConfigFor(selectedTool) + return copy( + selectedColorArgb = colorArgb, + toolConfigs = toolConfigs + (selectedTool to currentConfig.copy(colorArgb = colorArgb)) + ) +} + +private fun SharedPdfReaderState.withActiveToolStrokeWidth(strokeWidth: Float): SharedPdfReaderState { + if (!selectedTool.isSharedPdfConfigurableTool) { + return copy(strokeWidth = strokeWidth) + } + val currentConfig = toolConfigFor(selectedTool) + return copy( + strokeWidth = strokeWidth, + toolConfigs = toolConfigs + (selectedTool to currentConfig.copy(strokeWidth = strokeWidth)) + ) +} + +private fun List.sanitizedSharedPdfPenPalette(): List { + val defaults = SharedPdfAnnotationDefaults.penPalette + val normalized = filter { it != 0 }.take(defaults.size) + val filled = if (normalized.isEmpty()) { + defaults + } else { + normalized + defaults.drop(normalized.size) + } + return filled.take(defaults.size) +} + +private val PdfInkTool.isSharedPdfPenTool: Boolean + get() = this == PdfInkTool.FOUNTAIN_PEN || this == PdfInkTool.PEN || this == PdfInkTool.PENCIL + +private val PdfInkTool.isSharedPdfHighlighterTool: Boolean + get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND + +private val PdfInkTool.isSharedPdfConfigurableTool: Boolean + get() = this != PdfInkTool.NONE + +private fun SharedPdfReaderState.undoSharedPdfAnnotationEdit(): SharedPdfReaderState { + val action = annotationUndoStack.lastOrNull() ?: return this + val nextUndoStack = annotationUndoStack.dropLast(1) + return when (action) { + is SharedPdfAnnotationHistoryAction.Add -> copy( + annotations = annotations.filterNot { it.id == action.annotation.id }, + selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotation.id }, + annotationUndoStack = nextUndoStack, + annotationRedoStack = annotationRedoStack + action + ) + + is SharedPdfAnnotationHistoryAction.Remove -> copy( + annotations = annotations + action.itemsByPage.values.flatten(), + annotationUndoStack = nextUndoStack, + annotationRedoStack = annotationRedoStack + action + ) + } +} + +private fun SharedPdfReaderState.redoSharedPdfAnnotationEdit(): SharedPdfReaderState { + val action = annotationRedoStack.lastOrNull() ?: return this + val nextRedoStack = annotationRedoStack.dropLast(1) + return when (action) { + is SharedPdfAnnotationHistoryAction.Add -> copy( + annotations = annotations + action.annotation, + selectedAnnotationId = action.annotation.id, + annotationUndoStack = annotationUndoStack + action, + annotationRedoStack = nextRedoStack + ) + + is SharedPdfAnnotationHistoryAction.Remove -> { + val removedIds = action.itemsByPage.values.flatten().mapTo(mutableSetOf()) { it.id } + copy( + annotations = annotations.filterNot { it.id in removedIds }, + selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds }, + annotationUndoStack = annotationUndoStack + action, + annotationRedoStack = nextRedoStack + ) + } + } +} + object SharedPdfSearchEngine { fun search( pageTexts: List, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometry.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt similarity index 97% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometry.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt index e257e7d..5752b08 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometry.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.pdf +package org.dueattendant149.bookreader.shared.pdf import kotlin.math.abs @@ -59,7 +59,9 @@ object PdfSelectionGeometry { chars: List, lineTolerance: Float = DefaultCharLineTolerance ): List { - return chars.groupByLine(lineTolerance).map { it.toCharLineBounds() } + return mergeBoundsByLine( + bounds = chars.groupByLine(lineTolerance).map { it.toCharLineBounds() } + ) } fun nearestCharOnLine( diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSpreadLayout.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt similarity index 89% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSpreadLayout.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt index 3fe9572..e0aba37 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSpreadLayout.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt @@ -1,7 +1,7 @@ -package com.aryan.reader.shared.pdf +package org.dueattendant149.bookreader.shared.pdf -import com.aryan.reader.shared.reader.ReaderPageSpreadMode -import com.aryan.reader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings object PdfSpreadLayout { fun isTwoPageSpreadEnabled(settings: ReaderSettings): Boolean { @@ -36,6 +36,15 @@ object PdfSpreadLayout { return listOf(start, start + 1).filter { it in 0 until pageCount } } + fun visiblePageIndicesForDisplay( + pageIndex: Int, + pageCount: Int, + settings: ReaderSettings + ): List { + val indices = visiblePageIndices(pageIndex, pageCount, settings) + return if (settings.rightToLeftPagination) indices.asReversed() else indices + } + fun spreadStartPageIndices( pageCount: Int, settings: ReaderSettings diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfVerticalLayout.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt similarity index 98% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfVerticalLayout.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt index 135c480..3c9739a 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfVerticalLayout.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.pdf +package org.dueattendant149.bookreader.shared.pdf import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfiumBridge.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt similarity index 98% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfiumBridge.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt index 8ae6e7d..28c9241 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfiumBridge.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.pdf +package org.dueattendant149.bookreader.shared.pdf /** * Platform-neutral surface for Pdfium functions that are not exposed by the diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt similarity index 97% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt index 0a4a890..2fab031 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt @@ -1,9 +1,7 @@ -package com.aryan.reader.shared.pdf +package org.dueattendant149.bookreader.shared.pdf import kotlin.math.sqrt -private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader" - data class SharedPdfAnnotationExportPayload( val inkAnnotations: List = emptyList(), val highlightAnnotations: List = emptyList() @@ -208,7 +206,7 @@ private fun List.toSingleVisiblePdfCommentThrea SharedPdfHighlightCommentExport( id = "${highlightId}_comments", parentId = null, - author = root.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }, + author = root.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }, contents = threadContents, createdAt = createdAt, modifiedAt = modifiedAt @@ -228,7 +226,7 @@ private fun List.formatAsPdfCommentThread(): St if (lines.isNotEmpty()) lines += "" val indent = " ".repeat(depth) - val author = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR } + val author = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } lines += "$indent$author:" comment.contents.lines().forEach { line -> lines += "$indent$line" diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt similarity index 77% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt index c5b85a4..1d4b6b4 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt @@ -1,6 +1,6 @@ -package com.aryan.reader.shared.pdf +package org.dueattendant149.bookreader.shared.pdf -import com.aryan.reader.shared.localFolderSyncSha256ShortHex +import org.dueattendant149.bookreader.shared.localFolderSyncSha256ShortHex import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -19,6 +19,7 @@ import kotlinx.serialization.json.longOrNull object SharedPdfAnnotationSidecarCodec { const val KEY_PDF_ANNOTATIONS = "pdfAnnotations" + const val KEY_PDF_ANNOTATION_DELETIONS = "pdfAnnotationDeletions" const val KEY_LEGACY_INK = "ink" const val KEY_LEGACY_TEXT_BOXES = "textBoxes" const val KEY_LEGACY_HIGHLIGHTS = "highlights" @@ -38,16 +39,17 @@ object SharedPdfAnnotationSidecarCodec { } fun annotationsFromData(data: JsonObject): List { - data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it) } + val deletedIds = annotationDeletionsFromData(data).keys + data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it).filterNot { annotation -> annotation.id in deletedIds } } data[KEY_LEGACY_INK]?.let { ink -> val decoded = decodeAnnotationsElement(ink) if (decoded.isNotEmpty() || ink.looksLikeSharedAnnotationStore()) { - return decoded + return decoded.filterNot { annotation -> annotation.id in deletedIds } } } - return legacyAndroidAnnotationsFromData(data) + return legacyAndroidAnnotationsFromData(data).filterNot { annotation -> annotation.id in deletedIds } } fun withCanonicalAnnotations(data: JsonObject): JsonObject { @@ -62,6 +64,80 @@ object SharedPdfAnnotationSidecarCodec { return json.encodeToString(JsonElement.serializer(), withCanonicalAnnotations(data)) } + fun mergeAnnotationDataJson( + localDataJson: String, + remoteDataJson: String, + preferRemoteOnConflict: Boolean + ): String { + val localData = parseObjectOrNull(localDataJson)?.sidecarDataObject() ?: JsonObject(emptyMap()) + val remoteData = parseObjectOrNull(remoteDataJson)?.sidecarDataObject() ?: JsonObject(emptyMap()) + val localCanonical = withCanonicalAnnotations(localData) + val remoteCanonical = withCanonicalAnnotations(remoteData) + val localAnnotations = annotationsFromData(localCanonical) + val remoteAnnotations = annotationsFromData(remoteCanonical) + val mergedDeletions = mergeAnnotationDeletions( + annotationDeletionsFromData(localCanonical), + annotationDeletionsFromData(remoteCanonical) + ) + val mergedById = linkedMapOf() + val first = if (preferRemoteOnConflict) localAnnotations else remoteAnnotations + val second = if (preferRemoteOnConflict) remoteAnnotations else localAnnotations + first.forEach { annotation -> + if (annotation.id !in mergedDeletions) mergedById[annotation.id] = annotation + } + second.forEach { annotation -> + if (annotation.id !in mergedDeletions) mergedById[annotation.id] = annotation + } + val base = (if (preferRemoteOnConflict) remoteCanonical else localCanonical).toMutableMap() + base[KEY_PDF_ANNOTATIONS] = encodeAnnotationsElement(mergedById.values.toList().sortedForSync()) + if (mergedDeletions.isNotEmpty()) { + base[KEY_PDF_ANNOTATION_DELETIONS] = encodeAnnotationDeletionsElement(mergedDeletions) + } else { + base.remove(KEY_PDF_ANNOTATION_DELETIONS) + } + return json.encodeToString(JsonElement.serializer(), JsonObject(base)) + } + + fun annotationCountFromDataJson(rawDataJson: String): Int { + val data = parseObjectOrNull(rawDataJson)?.sidecarDataObject() ?: return 0 + return annotationsFromData(withCanonicalAnnotations(data)).size + } + + fun annotationDeletionsFromData(data: JsonObject): Map { + return data[KEY_PDF_ANNOTATION_DELETIONS].parseAnnotationDeletions() + } + + fun annotationDeletionsFromJson(rawJson: String): Map { + val element = runCatching { json.parseToJsonElement(rawJson) }.getOrNull() ?: return emptyMap() + return when (element) { + is JsonObject -> { + val data = element.sidecarDataObject() + annotationDeletionsFromData(data).ifEmpty { element.parseAnnotationDeletions() } + } + else -> element.parseAnnotationDeletions() + } + } + + fun annotationDeletionsJson(deletions: Map): String { + return json.encodeToString(JsonElement.serializer(), encodeAnnotationDeletionsElement(deletions)) + } + + fun encodeAnnotationDeletionsElement(deletions: Map): JsonElement { + return JsonArray( + deletions + .filterKeys { it.isNotBlank() } + .toSortedMap() + .map { (id, deletedAt) -> + JsonObject( + mapOf( + "id" to JsonPrimitive(id), + "deletedAt" to JsonPrimitive(deletedAt) + ) + ) + } + ) + } + fun legacyAndroidDataFromAnnotations( annotations: List, existingData: JsonObject = JsonObject(emptyMap()) @@ -283,6 +359,47 @@ object SharedPdfAnnotationSidecarCodec { return runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() } + private fun List.sortedForSync(): List { + return sortedWith( + compareBy { it.pageIndex } + .thenBy { it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE } + .thenBy { it.id } + ) + } + + private fun mergeAnnotationDeletions( + local: Map, + remote: Map + ): Map { + if (local.isEmpty()) return remote + if (remote.isEmpty()) return local + return buildMap { + (local.keys + remote.keys).forEach { id -> + put(id, maxOf(local[id] ?: 0L, remote[id] ?: 0L)) + } + } + } + + private fun JsonElement?.parseAnnotationDeletions(): Map { + val element = this ?: return emptyMap() + val array = element.jsonArrayOrNull() + ?: element.jsonObjectOrNull()?.array(KEY_PDF_ANNOTATION_DELETIONS) + ?: return emptyMap() + return buildMap { + array.forEach { item -> + val primitiveId = item.jsonPrimitiveOrNull()?.contentOrNull + val obj = item.jsonObjectOrNull() + val id = primitiveId?.takeIf { it.isNotBlank() } ?: obj?.string("id") + if (id.isNullOrBlank()) return@forEach + val deletedAt = obj?.long("deletedAt") + ?: obj?.long("timestamp") + ?: obj?.long("ts") + ?: 0L + put(id, maxOf(this[id] ?: 0L, deletedAt)) + } + } + } + private fun stableAnnotationId(prefix: String, element: JsonElement): String { return "${prefix}_${localFolderSyncSha256ShortHex(json.encodeToString(JsonElement.serializer(), element))}" } @@ -314,6 +431,10 @@ object SharedPdfAnnotationSidecarCodec { this[KEY_LEGACY_HIGHLIGHTS] != null } + private fun JsonObject.sidecarDataObject(): JsonObject { + return this["data"]?.jsonObjectOrNull() ?: this + } + private fun JsonElement.jsonArrayOrNull(): JsonArray? { if (this is JsonNull) return null return runCatching { jsonArray }.getOrNull() @@ -324,6 +445,11 @@ object SharedPdfAnnotationSidecarCodec { return runCatching { jsonObject }.getOrNull() } + private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? { + if (this is JsonNull) return null + return runCatching { jsonPrimitive }.getOrNull() + } + private fun JsonObject.array(name: String): JsonArray? = this[name]?.jsonArrayOrNull() private fun JsonObject.objectValue(name: String): JsonObject? = this[name]?.jsonObjectOrNull() diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRendering.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRendering.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt index 184b41d..45cd479 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRendering.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.pdf +package org.dueattendant149.bookreader.shared.pdf import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfReflow.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfReflow.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt index 3109d00..c6dcf1f 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfReflow.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.pdf +package org.dueattendant149.bookreader.shared.pdf import kotlin.math.roundToInt diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichText.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichText.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt index ca5983c..9309bf3 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichText.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.pdf +package org.dueattendant149.bookreader.shared.pdf import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -52,6 +52,17 @@ const val SHARED_PDF_PAGE_BREAK_CHAR: Char = '\u000C' private const val SHARED_PDF_ZWSP = "\u200B" private const val SHARED_PDF_RICH_FONT_PATH_TAG = "pdf-rich-font-path" +internal fun sharedPdfRichTextSelectionBounds( + selectionStart: Int, + selectionEnd: Int, + textLength: Int +): Pair? { + val safeLength = textLength.coerceAtLeast(0) + val localStart = minOf(selectionStart, selectionEnd).coerceIn(0, safeLength) + val localEnd = maxOf(selectionStart, selectionEnd).coerceIn(0, safeLength) + return if (localStart < localEnd) localStart to localEnd else null +} + object SharedPdfRichTextLog { var enabled: Boolean = true diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotations.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotations.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt index 7ee25aa..ee330d6 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotations.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.pdf +package org.dueattendant149.bookreader.shared.pdf import androidx.compose.ui.unit.IntSize import kotlinx.serialization.Serializable diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt similarity index 77% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt index 470d60f..a34a562 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt @@ -1,13 +1,17 @@ -package com.aryan.reader.shared.reader +package org.dueattendant149.bookreader.shared.reader -import com.aryan.reader.paginatedreader.SemanticBlock -import com.aryan.reader.paginatedreader.SemanticFlexContainer -import com.aryan.reader.paginatedreader.SemanticList -import com.aryan.reader.paginatedreader.SemanticTable -import com.aryan.reader.paginatedreader.SemanticTextBlock -import com.aryan.reader.paginatedreader.SemanticWrappingBlock -import com.aryan.reader.shared.HighlightColor -import com.aryan.reader.shared.UserHighlight +import org.dueattendant149.bookreader.paginatedreader.SemanticBlock +import org.dueattendant149.bookreader.paginatedreader.SemanticFlexContainer +import org.dueattendant149.bookreader.paginatedreader.SemanticImage +import org.dueattendant149.bookreader.paginatedreader.SemanticList +import org.dueattendant149.bookreader.paginatedreader.SemanticMath +import org.dueattendant149.bookreader.paginatedreader.SemanticSpacer +import org.dueattendant149.bookreader.paginatedreader.SemanticTable +import org.dueattendant149.bookreader.paginatedreader.SemanticTextBlock +import org.dueattendant149.bookreader.paginatedreader.SemanticWrappingBlock +import org.dueattendant149.bookreader.shared.HighlightColor +import org.dueattendant149.bookreader.shared.UserHighlight +import org.dueattendant149.bookreader.shared.toStableReaderPositionCfi sealed interface ReaderLinkTarget { data class External(val url: String) : ReaderLinkTarget @@ -103,10 +107,10 @@ class ReaderEngine( highlights: List = emptyList() ): ReaderSessionState { val pages = pagesFor(book, settings) - val requestedInitialIndex = initialLocator + val locatorResolvedIndex = initialLocator ?.let { pages.findPageIndexForLocator(it) } ?.takeIf { it >= 0 } - ?: initialPageIndex + val requestedInitialIndex = locatorResolvedIndex ?: initialPageIndex val initialIndex = ReaderSpreadLayout.normalizePageIndex(requestedInitialIndex, pages.size, settings) val reader = PaginatedReaderState( book = book, @@ -114,7 +118,13 @@ class ReaderEngine( currentPageIndex = initialIndex, settings = settings ) - return ReaderSessionState( + logReaderPositionTrace { + "event=engine_create_session_start book=\"${book.title.positionTracePreview(120)}\" " + + "mode=${settings.readingMode} pages=${pages.size} initialPage=$initialPageIndex " + + "locatorResolved=${locatorResolvedIndex ?: "null"} requested=$requestedInitialIndex normalized=$initialIndex " + + "initialLocator=${initialLocator.positionTraceSummary()}" + } + val session = ReaderSessionState( reader = reader, bookmarks = bookmarks .mapNotNull { it.normalizedForBook(book, pages) } @@ -128,6 +138,13 @@ class ReaderEngine( ?.normalizedForResolvedPage(book, pages, requestedInitialIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0))) ?: reader.currentPage?.toLocator(book) ) + logReaderPositionTrace { + "event=engine_create_session_done book=\"${book.title.positionTracePreview(120)}\" " + + "mode=${settings.readingMode} currentPage=${session.reader.currentPageIndex} " + + "visiblePages=${session.reader.visiblePages.map { it.pageIndex }} " + + "navigationLocator=${session.navigationLocator.positionTraceSummary()}" + } + return session } fun next(state: ReaderSessionState): ReaderSessionState { @@ -149,10 +166,17 @@ class ReaderEngine( fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState { val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings) val page = state.reader.pages.getOrNull(target) + val locator = page?.let { + if (state.reader.settings.readingMode == ReaderReadingMode.VERTICAL) { + it.toVerticalScrollPageLocator(state.reader.book) + } else { + it.toLocator(state.reader.book) + } + } return state.copy( reader = state.reader.copy(currentPageIndex = target), activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target }, - navigationLocator = page?.toLocator(state.reader.book), + navigationLocator = locator, navigationRequestId = state.navigationRequestId + 1 ) } @@ -173,15 +197,14 @@ class ReaderEngine( } fun goToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState { - val requestedPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) } + val requestedPageIndex = state.reader.pages.findPageIndexForLocator(locator) .takeIf { it >= 0 } - ?: locator.pageIndex - ?.takeIf { it in state.reader.pages.indices } ?: return state val pageIndex = ReaderSpreadLayout.normalizePageIndex(requestedPageIndex, state.reader.pages.size, state.reader.settings) val page = state.reader.pages.getOrNull(pageIndex) ?: return state val requestedPage = state.reader.pages.getOrNull(requestedPageIndex) ?: page val requestedChapter = state.reader.book.chapters.getOrNull(requestedPage.chapterIndex) + val blockPosition = requestedPage.firstLocatorBlockPosition() val normalizedLocator = locator.copy(pageIndex = requestedPageIndex).withFallbacks( chapterIndex = requestedPage.chapterIndex, chapterId = requestedChapter?.id, @@ -189,6 +212,8 @@ class ReaderEngine( pageIndex = requestedPageIndex, startOffset = requestedPage.startOffset, endOffset = requestedPage.endOffset, + blockIndex = blockPosition?.blockIndex, + charOffset = blockPosition?.charOffset, textQuote = locator.textQuote ?: requestedPage.text.preview(), cfi = locator.cfi ?: requestedPage.toDesktopCfi() ) @@ -345,12 +370,26 @@ class ReaderEngine( fun syncVisiblePage(state: ReaderSessionState, pageIndex: Int, locator: ReaderLocator? = null): ReaderSessionState { val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings) val normalizedLocator = locator?.normalizedForPage(state, pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))) - if (target == state.reader.currentPageIndex && normalizedLocator == null) return state - return state.copy( + if (target == state.reader.currentPageIndex && normalizedLocator == null) { + logReaderPositionTrace { + "event=engine_sync_visible_skip reason=unchanged_no_locator mode=${state.reader.settings.readingMode} " + + "inputPage=$pageIndex target=$target current=${state.reader.currentPageIndex}" + } + return state + } + val next = state.copy( reader = state.reader.copy(currentPageIndex = target), activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex }, navigationLocator = normalizedLocator ?: state.navigationLocator ) + logReaderPositionTrace { + "event=engine_sync_visible_done mode=${state.reader.settings.readingMode} inputPage=$pageIndex " + + "target=$target previousPage=${state.reader.currentPageIndex} nextPage=${next.reader.currentPageIndex} " + + "inputLocator=${locator.positionTraceSummary()} normalizedLocator=${normalizedLocator.positionTraceSummary()} " + + "previousNavigation=${state.navigationLocator.positionTraceSummary()} " + + "nextNavigation=${next.navigationLocator.positionTraceSummary()}" + } + return next } fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState { @@ -509,13 +548,12 @@ class ReaderEngine( chapterTitle: String? = null, preview: String? = null ): ReaderSessionState { - val targetPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) } + val targetPageIndex = state.reader.pages.findPageIndexForLocator(locator) .takeIf { it >= 0 } - ?: locator.pageIndex - ?.takeIf { it in state.reader.pages.indices } ?: state.reader.currentPageIndex val page = state.reader.pages.getOrNull(targetPageIndex) ?: return state val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex) + val blockPosition = page.firstLocatorBlockPosition() val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks( chapterIndex = page.chapterIndex, chapterId = chapter?.id, @@ -523,8 +561,12 @@ class ReaderEngine( pageIndex = targetPageIndex, startOffset = page.startOffset, endOffset = page.endOffset, + blockIndex = blockPosition?.blockIndex, + charOffset = blockPosition?.charOffset, textQuote = preview ?: page.text.preview(), - cfi = locator.cfi ?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}" + cfi = locator.cfi + ?: blockPosition?.androidStyleCfi() + ?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}" ) val existing = state.bookmarks.firstOrNull { it.locator.sameLocation(normalizedLocator) || @@ -679,12 +721,13 @@ class ReaderEngine( if (state.searchResults.isEmpty()) return state val targetIndex = resultIndex.coerceIn(0, state.searchResults.lastIndex) val result = state.searchResults[targetIndex] - val requestedPage = state.reader.pages.indexOfFirst { page -> page.contains(result.locator) } + val requestedPage = state.reader.pages.findPageIndexForLocator(result.locator) .takeIf { it >= 0 } ?: result.pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0)) val targetPage = ReaderSpreadLayout.normalizePageIndex(requestedPage, state.reader.pages.size, state.reader.settings) val page = state.reader.pages.getOrNull(targetPage) val chapter = page?.let { state.reader.book.chapters.getOrNull(it.chapterIndex) } + val blockPosition = page?.firstLocatorBlockPosition() return state.copy( reader = state.reader.copy(currentPageIndex = targetPage), activeSearchResultIndex = targetIndex, @@ -692,7 +735,9 @@ class ReaderEngine( chapterIndex = page?.chapterIndex, chapterId = chapter?.id, href = chapter?.baseHref, - pageIndex = requestedPage + pageIndex = requestedPage, + blockIndex = blockPosition?.blockIndex, + charOffset = blockPosition?.charOffset ), navigationRequestId = state.navigationRequestId + 1 ) @@ -731,7 +776,7 @@ private fun ReaderPage.contains(locator: ReaderLocator): Boolean { val start = locator.startOffset ?: return false val end = locator.endOffset ?: start return if (start == end) { - start in startOffset..endOffset + containsCollapsedOffset(start) } else { start < endOffset && end > startOffset } @@ -740,11 +785,128 @@ private fun ReaderPage.contains(locator: ReaderLocator): Boolean { return targetPage != null && targetPage == pageIndex } +private fun ReaderPage.containsCollapsedOffset(offset: Int): Boolean { + return if (startOffset == endOffset) { + offset == startOffset + } else { + offset >= startOffset && offset < endOffset + } +} + private fun List.findPageIndexForLocator(locator: ReaderLocator): Int { - return indexOfFirst { page -> page.contains(locator) } - .takeIf { it >= 0 } - ?: locator.pageIndex?.takeIf { it in indices } - ?: -1 + if (locator.blockIndex != null) { + val blockIndex = findPageIndexForBlockLocator(locator) + if (blockIndex >= 0) return blockIndex + } + + if (locator.hasTextRange) { + val textRangeIndex = indexOfFirst { page -> page.containsTextRange(locator) } + if (textRangeIndex >= 0) return textRangeIndex + + if (locator.startOffset == locator.endOffset) { + val offset = locator.startOffset + val targetChapter = locator.chapterIndex + val finalBoundaryIndex = indexOfLast { page -> + (targetChapter == null || targetChapter == page.chapterIndex) && + page.startOffset < page.endOffset && + page.endOffset == offset + } + if (finalBoundaryIndex >= 0) return finalBoundaryIndex + } + } + + return locator.pageIndex?.takeIf { it in indices } ?: -1 +} + +private fun ReaderPage.containsTextRange(locator: ReaderLocator): Boolean { + val targetChapter = locator.chapterIndex + if (targetChapter != null && targetChapter != chapterIndex) return false + val start = locator.startOffset ?: return false + val end = locator.endOffset ?: start + return if (start == end) { + containsCollapsedOffset(start) + } else { + start < endOffset && end > startOffset + } +} + +private fun List.findPageIndexForBlockLocator(locator: ReaderLocator): Int { + val blockIndex = locator.blockIndex ?: return -1 + val charOffset = locator.charOffset + val targetChapter = locator.chapterIndex + var fallbackPageIndex = -1 + for ((pageIndex, page) in withIndex()) { + if (targetChapter != null && page.chapterIndex != targetChapter) continue + val blocks = page.semanticBlocks.flattenSemanticBlocks() + if (fallbackPageIndex < 0 && blocks.any { it.blockIndex == blockIndex }) { + fallbackPageIndex = pageIndex + } + if (charOffset == null) continue + for (block in blocks.filterIsInstance()) { + if (block.blockIndex != blockIndex) continue + val start = block.startCharOffsetInSource + val end = start + block.text.length + if (charOffset in start until end || (block.text.isEmpty() && charOffset == start)) { + return pageIndex + } + } + } + return fallbackPageIndex +} + +private data class ReaderBlockPosition( + val blockIndex: Int, + val charOffset: Int, + val cfi: String? = null, + val localCharOffset: Int = 0 +) { + fun androidStyleCfi(): String? { + val base = cfi + ?.takeIf { it.startsWith("/") } + ?.substringBefore(':') + ?: return null + return "$base:${localCharOffset.coerceAtLeast(0)}" + } +} + +private fun ReaderPage.firstLocatorBlockPosition(): ReaderBlockPosition? { + val blocks = semanticBlocks.flattenSemanticBlocks() + val textBlock = blocks + .filterIsInstance() + .firstOrNull { it.text.isNotBlank() } + ?: blocks.filterIsInstance().firstOrNull() + if (textBlock != null) { + return ReaderBlockPosition( + blockIndex = textBlock.blockIndex, + charOffset = textBlock.startCharOffsetInSource, + cfi = textBlock.cfi, + localCharOffset = 0 + ) + } + val firstBlock = blocks.firstOrNull() ?: return null + return ReaderBlockPosition( + blockIndex = firstBlock.blockIndex, + charOffset = 0, + cfi = firstBlock.cfi, + localCharOffset = 0 + ) +} + +private fun List.flattenSemanticBlocks(): List { + return flatMap { it.flattenSemanticBlock() } +} + +private fun SemanticBlock.flattenSemanticBlock(): List { + return when (this) { + is SemanticList -> listOf(this) + items + is SemanticTable -> listOf(this) + rows.flatMap { row -> row.flatMap { cell -> cell.content.flattenSemanticBlocks() } } + is SemanticFlexContainer -> listOf(this) + children.flattenSemanticBlocks() + is SemanticWrappingBlock -> listOf(this, floatedImage) + paragraphsToWrap + is SemanticImage, + is SemanticMath, + is SemanticSpacer, + is SemanticTextBlock -> listOf(this) + } } private fun ReaderLocator.normalizedForResolvedPage( @@ -756,6 +918,7 @@ private fun ReaderLocator.normalizedForResolvedPage( val chapter = book.chapters.getOrNull(page.chapterIndex) val start = startOffset ?: page.startOffset val end = (endOffset ?: start).coerceAtLeast(start) + val blockPosition = page.firstLocatorBlockPosition() return copy(pageIndex = page.pageIndex).withFallbacks( chapterIndex = page.chapterIndex, chapterId = chapter?.id, @@ -763,18 +926,25 @@ private fun ReaderLocator.normalizedForResolvedPage( pageIndex = page.pageIndex, startOffset = start, endOffset = end, + blockIndex = blockPosition?.blockIndex, + charOffset = blockPosition?.charOffset, textQuote = textQuote ?: page.text.preview(), - cfi = cfi ?: "desktop:${page.chapterIndex}:$start:$end" + cfi = cfi + ?.toStableReaderPositionCfi() + ?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") } + ?: blockPosition?.androidStyleCfi() + ?: "desktop:${page.chapterIndex}:$start:$end" ) } private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List): ReaderBookmark? { - val targetPageIndex = pages.indexOfFirst { page -> page.contains(locator) } + val targetPageIndex = pages.findPageIndexForLocator(locator) .takeIf { it >= 0 } ?: pageIndex.takeIf { it in pages.indices } ?: return null val page = pages.getOrNull(targetPageIndex) ?: return null val chapter = book.chapters.getOrNull(page.chapterIndex) + val blockPosition = page.firstLocatorBlockPosition() val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks( chapterIndex = page.chapterIndex, chapterId = chapter?.id, @@ -782,8 +952,10 @@ private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List String) { + logSharedReaderDiagnostic(ReaderPositionTraceLogTag, message) +} + +private fun ReaderLocator?.positionTraceSummary(maxTextLength: Int = 90): String { + if (this == null) return "null" + return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " + + "offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " + + "block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " + + "chapterId=\"${chapterId.orEmpty().positionTracePreview(80)}\" " + + "href=\"${href.orEmpty().positionTracePreview(120)}\" " + + "cfi=\"${cfi.orEmpty().positionTracePreview(180)}\" " + + "text=\"${textQuote.orEmpty().positionTracePreview(maxTextLength)}\"" +} + +private fun String.positionTracePreview(maxLength: Int = 96): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt similarity index 64% rename from shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt index 83c8179..1495b8a 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt @@ -1,23 +1,23 @@ -package com.aryan.reader.shared.reader +package org.dueattendant149.bookreader.shared.reader -import com.aryan.reader.paginatedreader.SemanticBlock -import com.aryan.reader.paginatedreader.SemanticFlexContainer -import com.aryan.reader.paginatedreader.SemanticHeader -import com.aryan.reader.paginatedreader.SemanticImage -import com.aryan.reader.paginatedreader.SemanticList -import com.aryan.reader.paginatedreader.SemanticListItem -import com.aryan.reader.paginatedreader.SemanticMath -import com.aryan.reader.paginatedreader.SemanticParagraph -import com.aryan.reader.paginatedreader.SemanticSpacer -import com.aryan.reader.paginatedreader.SemanticTable -import com.aryan.reader.paginatedreader.SemanticTextBlock -import com.aryan.reader.paginatedreader.SemanticWrappingBlock -import com.aryan.reader.paginatedreader.BorderStyle -import com.aryan.reader.paginatedreader.CssStyle -import com.aryan.reader.shared.HighlightColor -import com.aryan.reader.shared.ReaderHighlightPalette -import com.aryan.reader.shared.ReaderTexture -import com.aryan.reader.shared.UserHighlight +import org.dueattendant149.bookreader.paginatedreader.SemanticBlock +import org.dueattendant149.bookreader.paginatedreader.SemanticFlexContainer +import org.dueattendant149.bookreader.paginatedreader.SemanticHeader +import org.dueattendant149.bookreader.paginatedreader.SemanticImage +import org.dueattendant149.bookreader.paginatedreader.SemanticList +import org.dueattendant149.bookreader.paginatedreader.SemanticListItem +import org.dueattendant149.bookreader.paginatedreader.SemanticMath +import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph +import org.dueattendant149.bookreader.paginatedreader.SemanticSpacer +import org.dueattendant149.bookreader.paginatedreader.SemanticTable +import org.dueattendant149.bookreader.paginatedreader.SemanticTextBlock +import org.dueattendant149.bookreader.paginatedreader.SemanticWrappingBlock +import org.dueattendant149.bookreader.paginatedreader.BorderStyle +import org.dueattendant149.bookreader.paginatedreader.CssStyle +import org.dueattendant149.bookreader.shared.HighlightColor +import org.dueattendant149.bookreader.shared.ReaderHighlightPalette +import org.dueattendant149.bookreader.shared.ReaderTexture +import org.dueattendant149.bookreader.shared.UserHighlight import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.isSpecified @@ -38,9 +38,18 @@ object ReaderHtmlDocumentBuilder { readerAiFeaturesEnabled: Boolean = true, cloudTtsEnabled: Boolean = true, externalLookupEnabled: Boolean = true, - textureDataUri: String? = null + textureDataUri: String? = null, + renderedChapterRange: IntRange? = null ): String { - val body = book.chapters.mapIndexed { index, chapter -> + val renderedChapterIndices = renderedChapterRange + ?.asSequence() + ?.filter { it in book.chapters.indices } + ?.distinct() + ?.toList() + ?.takeIf { it.isNotEmpty() } + ?: book.chapters.indices.toList() + val body = renderedChapterIndices.joinToString("\n") { index -> + val chapter = book.chapters[index] val chapterText = chapter.normalizedReaderText() val chapterHtml = chapter.toHtml(searchQuery, searchOptions) .applyUserHighlights( @@ -56,7 +65,7 @@ object ReaderHtmlDocumentBuilder { """.trimIndent() - }.joinToString("\n") + } return document( title = book.title, settings = settings, @@ -133,6 +142,7 @@ object ReaderHtmlDocumentBuilder { textureDataUri: String? = null ): String { val appearance = settings.toDocumentAppearanceCss(textureDataUri) + val customFontCss = settings.readerCustomFontFaceCss() return """ (function () { var root = document.documentElement; @@ -144,6 +154,30 @@ object ReaderHtmlDocumentBuilder { root.style.setProperty('--reader-link-decoration', ${appearance.linkColors.decoration.toJsStringLiteral()}); root.style.setProperty('--reader-link-bg', ${appearance.linkColors.background.toJsStringLiteral()}); root.style.setProperty('--reader-highlight', ${appearance.highlight.toJsStringLiteral()}); + root.style.setProperty('--reader-font-size', ${"${settings.fontSize}px".toJsStringLiteral()}); + root.style.setProperty('--reader-line-height', ${settings.lineSpacing.toString().toJsStringLiteral()}); + root.style.setProperty('--reader-page-width', ${"${settings.pageWidth}px".toJsStringLiteral()}); + root.style.setProperty('--reader-margin', ${"${settings.margin}px".toJsStringLiteral()}); + root.style.setProperty('--reader-margin-x', ${"${settings.resolvedHorizontalMargin}px".toJsStringLiteral()}); + root.style.setProperty('--reader-margin-y', ${"${settings.resolvedVerticalMargin}px".toJsStringLiteral()}); + root.style.setProperty('--reader-vertical-margin-y', ${"${settings.readerVerticalMarginY()}px".toJsStringLiteral()}); + root.style.setProperty('--reader-vertical-page-width', 'max(0px, calc(100% - (var(--reader-margin-x) * 2)))'); + root.style.setProperty('--reader-paragraph-spacing', ${settings.paragraphSpacing.toString().toJsStringLiteral()}); + root.style.setProperty('--reader-image-scale', ${settings.readerImageScaleCss().toJsStringLiteral()}); + root.style.setProperty('--reader-align', ${settings.readerTextAlignCss().toJsStringLiteral()}); + root.style.setProperty('--reader-family', ${settings.readerFontFamilyCss().toJsStringLiteral()}); + var customFontCss = ${customFontCss.toJsStringLiteral()}; + var customFontStyle = document.getElementById('reader-custom-font-style'); + if (customFontCss) { + if (!customFontStyle) { + customFontStyle = document.createElement('style'); + customFontStyle.id = 'reader-custom-font-style'; + document.head.appendChild(customFontStyle); + } + customFontStyle.textContent = customFontCss; + } else if (customFontStyle && customFontStyle.parentNode) { + customFontStyle.parentNode.removeChild(customFontStyle); + } var textureStyle = document.getElementById('reader-texture-style'); if (!textureStyle) { textureStyle = document.createElement('style'); @@ -155,6 +189,28 @@ object ReaderHtmlDocumentBuilder { """.trimIndent() } + fun pageAnchorsUpdateScript(pages: List): String { + val pageAnchorJson = pages.toPageAnchorJson() + return """ + (function () { + if (window.readerSetPageAnchors) { + window.readerSetPageAnchors($pageAnchorJson); + } + })(); + """.trimIndent() + } + + fun highlightPaletteUpdateScript(highlightPalette: ReaderHighlightPalette): String { + val highlightButtons = highlightPalette.toSelectionPaletteButtons() + return """ + (function () { + var container = document.querySelector('#reader-selection-menu .reader-selection-colors'); + if (!container) return; + container.innerHTML = ${highlightButtons.toJsStringLiteral()}; + })(); + """.trimIndent() + } + private fun pageSectionHtml( book: SharedEpubBook, page: ReaderPage, @@ -210,29 +266,10 @@ object ReaderHtmlDocumentBuilder { textureDataUri: String? ): String { val appearance = settings.toDocumentAppearanceCss(textureDataUri) - val align = when (settings.textAlign) { - SharedReaderTextAlign.START -> "left" - SharedReaderTextAlign.RIGHT -> "right" - SharedReaderTextAlign.JUSTIFY -> "justify" - SharedReaderTextAlign.CENTER -> "center" - } - val customFontUrl = settings.customFontPath?.takeIf { it.isNotBlank() }?.toCssFontUrl() - val customFontCss = customFontUrl?.let { - "@font-face { font-family: 'ReaderCustomFont'; src: url('$it'); font-display: swap; }" - }.orEmpty() - val family = if (customFontUrl != null) { - "'ReaderCustomFont', Georgia, 'Times New Roman', serif" - } else { - when (settings.fontFamily) { - "Serif" -> "Georgia, 'Times New Roman', serif" - "Sans" -> "Inter, Segoe UI, Arial, sans-serif" - "Mono" -> "'Roboto Mono', Consolas, monospace" - else -> "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" - } - } - val highlightButtons = highlightPalette.sanitized().colors.joinToString("\n") { color -> - """""" - } + val align = settings.readerTextAlignCss() + val customFontCss = settings.readerCustomFontFaceCss() + val family = settings.readerFontFamilyCss() + val highlightButtons = highlightPalette.toSelectionPaletteButtons() val defineButton = if (readerAiFeaturesEnabled) { readerSelectionActionButton("define", "Define", ReaderSelectionIconDefinePath) } else { @@ -250,9 +287,10 @@ object ReaderHtmlDocumentBuilder { } val navigationAttributes = navigationLocator?.toNavigationAttributes().orEmpty() val pageAnchorJson = pageAnchors.toPageAnchorJson() + val verticalMarginY = settings.readerVerticalMarginY() return """ - + @@ -277,8 +315,11 @@ object ReaderHtmlDocumentBuilder { --reader-margin: ${settings.margin}px; --reader-margin-x: ${settings.resolvedHorizontalMargin}px; --reader-margin-y: ${settings.resolvedVerticalMargin}px; + --reader-vertical-margin-y: ${verticalMarginY}px; + --reader-vertical-content-width: 92ch; + --reader-vertical-page-width: max(0px, calc(100% - (var(--reader-margin-x) * 2))); --reader-paragraph-spacing: ${settings.paragraphSpacing}; - --reader-image-scale: ${(settings.imageScale * 100f).roundToInt().coerceIn(50, 200)}%; + --reader-image-scale: ${settings.readerImageScaleCss()}; --reader-align: $align; --reader-family: $family; } @@ -295,6 +336,12 @@ object ReaderHtmlDocumentBuilder { scrollbar-color: var(--reader-scrollbar-thumb) var(--reader-scrollbar-track); scrollbar-width: thin; } + html.reader-vertical-root { + width: 100%; + min-width: 0; + overflow-y: scroll; + scrollbar-width: thin; + } html::-webkit-scrollbar, body.reader-vertical::-webkit-scrollbar { width: 12px; @@ -322,6 +369,14 @@ object ReaderHtmlDocumentBuilder { position: relative; } body.reader-vertical { + width: 100%; + max-width: 100%; + min-height: 100vh; + min-height: 100dvh; + min-width: 0; + overflow-x: hidden; + overflow-y: auto; + padding: var(--reader-vertical-margin-y) 0; scrollbar-gutter: stable; } body.reader-paginated { @@ -335,6 +390,133 @@ object ReaderHtmlDocumentBuilder { position: relative; z-index: 1; } + body.reader-vertical .chapter { + content-visibility: auto; + contain-intrinsic-size: auto 1200px; + } + body.reader-vertical > .chapter, + body.reader-vertical > :not(.chapter):not(#reader-selection-menu):not(.reader-selection-handle):not(script):not(style), + body.reader-vertical > .chapter > :not(.reader-content), + body.reader-vertical > .chapter > .chapter-title, + body.reader-vertical > .chapter > .reader-content { + box-sizing: border-box !important; + min-width: 0 !important; + } + body.reader-vertical > .chapter { + width: 100% !important; + max-width: none !important; + margin: 0 !important; + } + body.reader-vertical > :not(.chapter):not(#reader-selection-menu):not(.reader-selection-handle):not(script):not(style), + body.reader-vertical > .chapter > :not(.reader-content), + body.reader-vertical > .chapter > .chapter-title, + body.reader-vertical > .chapter > .reader-content { + width: var(--reader-vertical-page-width) !important; + max-width: none !important; + margin-left: auto !important; + margin-right: auto !important; + } + body.reader-vertical > :not(.chapter):not(#reader-selection-menu):not(.reader-selection-handle):not(script):not(style), + body.reader-vertical > .chapter > :not(.reader-content) { + position: static !important; + left: auto !important; + right: auto !important; + top: auto !important; + bottom: auto !important; + transform: none !important; + float: none !important; + clear: none !important; + } + body.reader-vertical .reader-content :where(h1, h2, h3, h4, h5, h6, hgroup, center, [class*="title" i], [id*="title" i], [class*="heading" i], [id*="heading" i], [class*="dedication" i], [id*="dedication" i]) { + box-sizing: border-box !important; + width: auto !important; + max-width: 100% !important; + min-width: 0 !important; + margin-left: 0 !important; + margin-right: 0 !important; + padding-left: 0 !important; + padding-right: 0 !important; + text-indent: 0 !important; + position: static !important; + left: auto !important; + right: auto !important; + transform: none !important; + float: none !important; + clear: none !important; + } + body.reader-vertical .reader-content, + body.reader-vertical .reader-content p, + body.reader-vertical .reader-content li, + body.reader-vertical .reader-content div, + body.reader-vertical .reader-content h1, + body.reader-vertical .reader-content h2, + body.reader-vertical .reader-content h3, + body.reader-vertical .reader-content h4, + body.reader-vertical .reader-content h5, + body.reader-vertical .reader-content h6, + body.reader-vertical .reader-content blockquote { + text-align: var(--reader-align) !important; + } + body.reader-vertical .reader-content p, + body.reader-vertical .reader-content div, + body.reader-vertical .reader-content h1, + body.reader-vertical .reader-content h2, + body.reader-vertical .reader-content h3, + body.reader-vertical .reader-content h4, + body.reader-vertical .reader-content h5, + body.reader-vertical .reader-content h6, + body.reader-vertical .reader-content blockquote, + body.reader-vertical .reader-content section, + body.reader-vertical .reader-content article, + body.reader-vertical .reader-content header, + body.reader-vertical .reader-content footer, + body.reader-vertical .reader-content aside, + body.reader-vertical .reader-content figure, + body.reader-vertical .reader-content table, + body.reader-vertical .reader-content pre { + box-sizing: border-box !important; + max-width: 100% !important; + min-width: 0 !important; + position: static !important; + left: auto !important; + right: auto !important; + top: auto !important; + bottom: auto !important; + transform: none !important; + float: none !important; + clear: none !important; + } + body.reader-vertical .reader-content div, + body.reader-vertical .reader-content section, + body.reader-vertical .reader-content article, + body.reader-vertical .reader-content header, + body.reader-vertical .reader-content footer, + body.reader-vertical .reader-content aside, + body.reader-vertical .reader-content figure { + width: auto !important; + margin-left: 0 !important; + margin-right: 0 !important; + } + body.reader-vertical .reader-content > p, + body.reader-vertical .reader-content > div, + body.reader-vertical .reader-content > h1, + body.reader-vertical .reader-content > h2, + body.reader-vertical .reader-content > h3, + body.reader-vertical .reader-content > h4, + body.reader-vertical .reader-content > h5, + body.reader-vertical .reader-content > h6, + body.reader-vertical .reader-content > blockquote, + body.reader-vertical .reader-content > section, + body.reader-vertical .reader-content > article, + body.reader-vertical .reader-content > header, + body.reader-vertical .reader-content > footer, + body.reader-vertical .reader-content > aside, + body.reader-vertical .reader-content > figure, + body.reader-vertical .reader-content > table, + body.reader-vertical .reader-content > pre { + margin-left: 0 !important; + margin-right: 0 !important; + } body.reader-paginated .page { box-sizing: border-box; height: calc(100vh - (var(--reader-margin-y) * 2)); @@ -450,14 +632,22 @@ object ReaderHtmlDocumentBuilder { overflow-x: auto; } #reader-selection-menu .reader-selection-color { - width: 24px; - height: 24px; + width: 28px; + height: 28px; flex: 0 0 auto; padding: 0; border-radius: 999px; background: var(--selection-color); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--reader-fg) 18%, transparent); } + #reader-selection-menu .reader-selection-spectrum { + width: 28px; + height: 28px; + flex: 0 0 auto; + padding: 0; + border-radius: 999px; + background: conic-gradient(#f44336, #ff7f00, #ffeb3b, #4caf50, #2196f3, #4b0082, #8b00ff, #f44336); + } #reader-selection-menu .reader-selection-actions { display: grid; grid-template-columns: repeat(3, 70px); @@ -465,17 +655,22 @@ object ReaderHtmlDocumentBuilder { padding: 5px 6px 2px; } #reader-selection-menu .reader-selection-action { - min-height: 52px; + min-height: 56px; border-radius: 10px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; - padding: 6px 4px; - line-height: 1; + padding: 6px 4px 7px; + line-height: 1.15; white-space: nowrap; } + #reader-selection-menu .reader-selection-action span:last-child { + display: block; + line-height: 1.2; + padding-bottom: 1px; + } #reader-selection-menu .reader-selection-icon { display: grid; place-items: center; @@ -568,13 +763,26 @@ object ReaderHtmlDocumentBuilder { "), " ") + .replace(Regex("(?is)"), " ") + .replace(Regex("(?i)<\\s*br\\s*/?\\s*>"), "\n") + .replace(Regex("(?i)"), "\n") + .replace(Regex("(?is)<[^>]+>"), " "), + false + ).normalizeReaderWhitespace() + } + private fun String.sanitizeReaderHtml(): String { return replace(Regex("(?is)"), "") .replace(Regex("(?is)"), "") @@ -1400,19 +1632,28 @@ object SharedJvmBookLoader { val raw = src.trim().takeIf { it.isNotBlank() } ?: return null if (raw.startsWith("data:", ignoreCase = true)) return raw if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) return raw - if (raw.startsWith("file:", ignoreCase = true)) return raw + if (raw.startsWith("file:", ignoreCase = true)) return null val clean = raw.substringBefore('#').substringBefore('?').takeIf { it.isNotBlank() } ?: return null val decoded = runCatching { URLDecoder.decode(clean, Charsets.UTF_8.name()) }.getOrDefault(clean) - val direct = File(decoded) - if (direct.isAbsolute && direct.isFile) return direct.toURI().toString() + val chapterFile = chapterAbsPath.trim().takeIf { it.isNotBlank() }?.let { path -> + val file = File(path) + if (file.isAbsolute) file else null + } + val extractionRoot = extractionBasePath + .trim() + .takeIf { it.isNotBlank() } + ?.let { runCatching { File(it).canonicalFile }.getOrNull() } + ?: chapterFile + ?.parentFile + ?.let { runCatching { it.canonicalFile }.getOrNull() } + ?: return null - val chapterFile = chapterAbsPath.trim().takeIf { it.isNotBlank() }?.let(::File) - val chapterRelative = chapterFile?.parentFile?.let { File(it, decoded) } - if (chapterRelative?.isFile == true) return chapterRelative.toURI().toString() + val resolvedChapterFile = chapterFile ?: File(extractionRoot, chapterAbsPath) + val chapterRelative = resolvedChapterFile.parentFile?.let { File(it, decoded) } + fileInsideRootOrNull(extractionRoot, chapterRelative)?.let { return it.absolutePath } - val extractionRelative = extractionBasePath.trim().takeIf { it.isNotBlank() }?.let { File(it, decoded) } - if (extractionRelative?.isFile == true) return extractionRelative.toURI().toString() + fileInsideRootOrNull(extractionRoot, File(extractionRoot, decoded))?.let { return it.absolutePath } return null } @@ -1450,6 +1691,15 @@ object SharedJvmBookLoader { else -> File(this) } } + + private fun fileInsideRootOrNull(root: File, candidate: File?): File? { + val file = candidate ?: return null + val canonical = runCatching { file.canonicalFile }.getOrNull() ?: return null + val rootPath = root.path + val targetPath = canonical.path + val insideRoot = targetPath == rootPath || targetPath.startsWith(rootPath + File.separator) + return canonical.takeIf { insideRoot && it.isFile } + } } private fun List.semanticFallbackText(): String { diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmLruMemoryCache.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt similarity index 92% rename from shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmLruMemoryCache.kt rename to shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt index cd3a3f8..53a18ad 100644 --- a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmLruMemoryCache.kt +++ b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.reader +package org.dueattendant149.bookreader.shared.reader internal class SharedJvmLruMemoryCache( private val maxEntries: Int diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmUserDirectories.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt similarity index 95% rename from shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmUserDirectories.kt rename to shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt index 7fc0be8..dd3a88a 100644 --- a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmUserDirectories.kt +++ b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt @@ -1,4 +1,4 @@ -package com.aryan.reader.shared.reader +package org.dueattendant149.bookreader.shared.reader import java.io.File import java.util.Locale diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt similarity index 74% rename from shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt rename to shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt index 1c7724c..0ecce15 100644 --- a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt +++ b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt @@ -1,6 +1,7 @@ -package com.aryan.reader.shared.reader +package org.dueattendant149.bookreader.shared.reader import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextMeasurer @@ -8,6 +9,8 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.style.Hyphens import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.TextAlign @@ -19,25 +22,26 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp -import com.aryan.reader.paginatedreader.CssStyle -import com.aryan.reader.paginatedreader.SemanticBlock -import com.aryan.reader.paginatedreader.SemanticFlexContainer -import com.aryan.reader.paginatedreader.SemanticHeader -import com.aryan.reader.paginatedreader.SemanticImage -import com.aryan.reader.paginatedreader.SemanticList -import com.aryan.reader.paginatedreader.SemanticListItem -import com.aryan.reader.paginatedreader.SemanticMath -import com.aryan.reader.paginatedreader.SemanticParagraph -import com.aryan.reader.paginatedreader.SemanticSpacer -import com.aryan.reader.paginatedreader.SemanticTable -import com.aryan.reader.paginatedreader.SemanticTextBlock -import com.aryan.reader.paginatedreader.SemanticWrappingBlock +import org.dueattendant149.bookreader.paginatedreader.CssStyle +import org.dueattendant149.bookreader.paginatedreader.SemanticBlock +import org.dueattendant149.bookreader.paginatedreader.SemanticFlexContainer +import org.dueattendant149.bookreader.paginatedreader.SemanticHeader +import org.dueattendant149.bookreader.paginatedreader.SemanticImage +import org.dueattendant149.bookreader.paginatedreader.SemanticList +import org.dueattendant149.bookreader.paginatedreader.SemanticListItem +import org.dueattendant149.bookreader.paginatedreader.SemanticMath +import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph +import org.dueattendant149.bookreader.paginatedreader.SemanticSpacer +import org.dueattendant149.bookreader.paginatedreader.SemanticTable +import org.dueattendant149.bookreader.paginatedreader.SemanticTextBlock +import org.dueattendant149.bookreader.paginatedreader.SemanticWrappingBlock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield import kotlin.math.roundToInt class SharedMeasuredEpubPaginator( @@ -50,29 +54,33 @@ class SharedMeasuredEpubPaginator( suspend fun paginate( book: SharedEpubBook, settings: ReaderSettings, - viewport: ReaderViewportSpec + viewport: ReaderViewportSpec, + readCache: Boolean = true ): List { currentCoroutineContext().ensureActive() - pageCache?.load( - book = book, - settings = settings, - viewport = viewport, - density = density.density, - fontScale = density.fontScale - )?.let { cached -> - logEpubPagination { - "cache_hit book=\"${book.title.logPreview()}\" pages=${cached.size} " + - "viewport=${viewport.widthPx}x${viewport.heightPx} spread=${settings.pageSpreadMode}" + if (readCache) { + pageCache?.load( + book = book, + settings = settings, + viewport = viewport, + density = density.density, + fontScale = density.fontScale + )?.let { cached -> + logEpubPagination { + "cache_hit book=\"${book.title.logPreview()}\" pages=${cached.size} " + + "viewport=${viewport.widthPx}x${viewport.heightPx} spread=${settings.pageSpreadMode}" + } + logEpubPageFit { + "page_fit layer=cache_hit book=\"${book.title.logPreview()}\" pages=${cached.size} " + + "note=clear_book_cache_to_capture_layer_measured" + } + return cached } - logEpubPageFit { - "page_fit layer=cache_hit book=\"${book.title.logPreview()}\" pages=${cached.size} " + - "note=clear_book_cache_to_capture_layer_measured" - } - return cached } currentCoroutineContext().ensureActive() - val geometry = measuredPageGeometryFor(settings, viewport, density.density) + val geometryTerms = measuredPageGeometryTerms(settings, viewport, density.density) + val geometry = geometryTerms.geometry logEpubPagination { "paginate_start book=\"${book.title.logPreview()}\" chapters=${book.chapters.size} " + "viewport=${viewport.widthPx}x${viewport.heightPx} page=${geometry.pageWidthPx}x${geometry.pageHeightPx} " + @@ -89,6 +97,7 @@ class SharedMeasuredEpubPaginator( val pages = mutableListOf() book.chapters.forEachIndexed { chapterIndex, chapter -> currentCoroutineContext().ensureActive() + yield() pages += paginateChapter( chapter = chapter, chapterIndex = chapterIndex, @@ -124,6 +133,43 @@ class SharedMeasuredEpubPaginator( return measuredPages } + suspend fun paginateChapterWindow( + book: SharedEpubBook, + settings: ReaderSettings, + viewport: ReaderViewportSpec, + chapterIndex: Int, + firstPageIndex: Int + ): List { + currentCoroutineContext().ensureActive() + val chapter = book.chapters.getOrNull(chapterIndex) ?: return emptyList() + val geometryTerms = measuredPageGeometryTerms(settings, viewport, density.density) + val geometry = geometryTerms.geometry + val baseStyle = TextStyle( + fontSize = settings.fontSize.sp, + lineHeight = (settings.fontSize * settings.lineSpacing).sp, + fontFamily = fontFamily, + textAlign = settings.textAlign.toComposeTextAlign() + ).withAndroidPaginationTextMetrics() + logEpubPagination { + "chapter_window_start book=\"${book.title.logPreview()}\" chapter=$chapterIndex " + + "firstPage=${firstPageIndex + 1} viewport=${viewport.widthPx}x${viewport.heightPx} " + + "page=${geometry.pageWidthPx}x${geometry.pageHeightPx}" + } + val pages = paginateChapter( + chapter = chapter, + chapterIndex = chapterIndex, + firstPageIndex = firstPageIndex, + settings = settings, + geometry = geometry, + baseStyle = baseStyle + ).mapIndexed { index, page -> page.copy(pageIndex = firstPageIndex + index) } + logEpubPagination { + "chapter_window_complete book=\"${book.title.logPreview()}\" chapter=$chapterIndex " + + "pages=${pages.size} firstPage=${firstPageIndex + 1}" + } + return pages + } + private suspend fun paginateChapter( chapter: SharedEpubChapter, chapterIndex: Int, @@ -190,6 +236,13 @@ class SharedMeasuredEpubPaginator( "blocks=${pageBlocks.size} range=${page.startOffset}..${page.endOffset} " + "textChars=${page.text.length} tail=\"${pageBlockFits.measuredPageFitTail()}\"" } + logEpubCutoff { + "cutoff_probe layer=measured_overflow reason=$reason page=${page.pageIndex + 1} chapter=$chapterIndex " + + "usedPx=$usedHeight pageHeightPx=${geometry.pageHeightPx} remainingPx=$remainingPx " + + "overflowPx=${(-remainingPx).coerceAtLeast(0)} blocks=${pageBlocks.size} " + + "range=${page.startOffset}..${page.endOffset} textChars=${page.text.length} " + + "tail=\"${pageBlockFits.measuredPageFitTail()}\"" + } } logReaderGapPagination { val firstTopMargin = pageBlocks.firstOrNull()?.effectiveTopMarginPx() ?: 0 @@ -206,8 +259,11 @@ class SharedMeasuredEpubPaginator( usedHeight = 0 } + var processedBlocks = 0 while (queue.isNotEmpty()) { currentCoroutineContext().ensureActive() + processedBlocks += 1 + if (processedBlocks % 8 == 0) yield() val block = queue.removeFirst() val blockHeight = measureBlock(block, geometry, baseStyle, settings) val spaceBeforeBlock = block.collapsedMarginBefore(pageBlocks.lastOrNull(), settings) @@ -348,13 +404,7 @@ class SharedMeasuredEpubPaginator( settings = settings, includeTrailingBottomMargin = true ) - is SemanticWrappingBlock -> measureBlockStack( - blocks = listOf(block.floatedImage) + block.paragraphsToWrap, - geometry = geometry, - baseStyle = baseStyle, - settings = settings, - includeTrailingBottomMargin = true - ) + is SemanticWrappingBlock -> measureWrapping(block, geometry, baseStyle, settings) is SemanticImage -> measureImage(block, geometry, settings) is SemanticMath -> measureMath(block, geometry, baseStyle, settings) is SemanticSpacer -> if (block.isExplicitLineBreak) 8 else 16 @@ -392,7 +442,7 @@ class SharedMeasuredEpubPaginator( ): Int { currentCoroutineContext().ensureActive() val style = block.textStyle(baseStyle, settings) - val annotated = block.toAnnotatedString(style.fontSize.value) + val annotated = block.toAnnotatedString(style.fontSize.value, style.textAlign) val minimumLineHeight = style.lineHeight.takeIfSpecified() ?.let { lineHeight -> with(density) { lineHeight.toPx().roundToInt() } } ?: with(density) { (settings.fontSize * settings.lineSpacing).sp.toPx().roundToInt() } @@ -411,6 +461,7 @@ class SharedMeasuredEpubPaginator( style: TextStyle, widthPx: Int ): TextLayoutResult { + currentCoroutineContext().ensureActive() return withContext(Dispatchers.Main) { textMeasurer.measure( text = text, @@ -440,6 +491,110 @@ class SharedMeasuredEpubPaginator( } } + private suspend fun measureWrapping( + block: SemanticWrappingBlock, + geometry: MeasuredPageGeometry, + baseStyle: TextStyle, + settings: ReaderSettings + ): Int { + val contentWidth = block.measuredTextContentWidthPx(geometry) + val imageSize = measureImageSize(block.floatedImage, geometry, settings, maxWidthPx = contentWidth) + if (imageSize.first <= 0 || imageSize.second <= 0) { + return measureBlockStack( + blocks = block.paragraphsToWrap, + geometry = geometry, + baseStyle = baseStyle, + settings = settings, + includeTrailingBottomMargin = true + ) + } + + val wrappingWidth = (contentWidth - imageSize.first).coerceAtLeast(0) + if (wrappingWidth <= 0) { + val paragraphHeight = measureBlockStack( + blocks = block.paragraphsToWrap, + geometry = geometry, + baseStyle = baseStyle, + settings = settings, + includeTrailingBottomMargin = true + ) + return (imageSize.second + paragraphHeight).coerceAtLeast(1) + } + + var currentY = 0f + block.paragraphsToWrap.forEachIndexed { index, paragraph -> + val style = paragraph.textStyle(baseStyle, settings) + val annotated = paragraph.toAnnotatedString(style.fontSize.value, style.textAlign) + currentY = measureWrappedParagraphLines( + text = annotated, + style = style, + fullWidthPx = contentWidth, + wrappingWidthPx = wrappingWidth, + currentY = currentY, + imageHeightPx = imageSize.second + ) + if (index < block.paragraphsToWrap.lastIndex) { + currentY += block.paragraphsToWrap.measuredCollapsedParagraphGapPx(index, settings) + } + } + return maxOf(currentY.roundToInt(), imageSize.second).coerceAtLeast(1) + } + + private suspend fun measureWrappedParagraphLines( + text: AnnotatedString, + style: TextStyle, + fullWidthPx: Int, + wrappingWidthPx: Int, + currentY: Float, + imageHeightPx: Int + ): Float { + if (text.text.isBlank()) { + return currentY + (style.lineHeight.takeIfSpecified()?.let { with(density) { it.toPx() } } ?: 1f) + } + var y = currentY + var textOffset = 0 + while (textOffset < text.length) { + currentCoroutineContext().ensureActive() + val isBesideImage = y < imageHeightPx + val currentMaxWidth = if (isBesideImage) wrappingWidthPx else fullWidthPx + if (currentMaxWidth <= 0) { + if (isBesideImage) { + y = imageHeightPx.toFloat() + continue + } + break + } + val remaining = text.subSequence(textOffset, text.length) + val measuredRemaining = measureTextLayout(remaining, style, currentMaxWidth) + val firstLineEndOffset = measuredRemaining.getLineEnd(0, visibleEnd = true) + if (firstLineEndOffset == 0 && remaining.length > 0) { + textOffset++ + continue + } + if (firstLineEndOffset == 0) break + val lineText = remaining.subSequence(0, firstLineEndOffset) + y += measureTextLayout(lineText, style, currentMaxWidth).size.height + textOffset += firstLineEndOffset + while (textOffset < text.length && text.text[textOffset].isWhitespace()) { + textOffset++ + } + } + return y + } + + private fun List.measuredCollapsedParagraphGapPx( + index: Int, + settings: ReaderSettings + ): Int { + val current = getOrNull(index) ?: return 0 + val next = getOrNull(index + 1) ?: return 0 + val explicitGap = maxOf( + current.style.blockStyle.margin.bottom.toPxIfSpecified(), + next.style.blockStyle.margin.top.toPxIfSpecified() + ) + return explicitGap.takeIf { it != 0 } ?: settings.renderedDefaultBlockSpacingPx() + } + private suspend fun measureMath( block: SemanticMath, geometry: MeasuredPageGeometry, @@ -464,13 +619,23 @@ class SharedMeasuredEpubPaginator( } private fun measureImage(block: SemanticImage, geometry: MeasuredPageGeometry, settings: ReaderSettings): Int { + return measureImageSize(block, geometry, settings, maxWidthPx = geometry.pageWidthPx) + .second + } + + private fun measureImageSize( + block: SemanticImage, + geometry: MeasuredPageGeometry, + settings: ReaderSettings, + maxWidthPx: Int + ): Pair { val width = block.intrinsicWidth?.takeIf { it > 0f } val height = block.intrinsicHeight?.takeIf { it > 0f } val imageScale = settings.imageScale.coerceIn(0.5f, 2.0f) - val measured = when { + when { width != null && height != null -> { val style = block.style.blockStyle - val contentMaxWidth = geometry.pageWidthPx.toFloat() + val contentMaxWidth = maxWidthPx.toFloat() val baseWidth = if (style.width.isSpecified && style.width > 0.dp) { style.width.toPxInt().toFloat() } else { @@ -481,12 +646,30 @@ class SharedMeasuredEpubPaginator( scaledWidth = scaledWidth.coerceAtMost(style.maxWidth.toPxInt() * imageScale) } scaledWidth = scaledWidth.coerceAtMost(contentMaxWidth) - (scaledWidth * (height / width)).roundToInt() + val measuredWidth = scaledWidth.roundToInt().coerceAtLeast(1) + val measuredHeight = (scaledWidth * (height / width)).roundToInt() + return measuredWidth to measuredHeight.coerceIn( + 24, + (geometry.pageHeightPx * 0.86f).roundToInt().coerceAtLeast(24) + ) } - block.style.blockStyle.height.isSpecified && block.style.blockStyle.height > 0.dp -> block.style.blockStyle.height.toPxInt() - else -> with(density) { (settings.fontSize * 8f).sp.toPx().roundToInt() } } - return measured.coerceIn(24, (geometry.pageHeightPx * 0.86f).roundToInt().coerceAtLeast(24)) + val style = block.style.blockStyle + val measuredWidth = when { + style.width.isSpecified && style.width > 0.dp -> style.width.toPxInt() + style.maxWidth.isSpecified && style.maxWidth > 0.dp -> minOf(maxWidthPx, style.maxWidth.toPxInt()) + else -> maxWidthPx + }.coerceAtLeast(1) + val measuredHeight = if (style.height.isSpecified && style.height > 0.dp) { + style.height.toPxInt() + } else { + with(density) { (settings.fontSize * 8f).sp.toPx().roundToInt() } + } + val coercedHeight = measuredHeight.coerceIn( + 24, + (geometry.pageHeightPx * 0.86f).roundToInt().coerceAtLeast(24) + ) + return measuredWidth to coercedHeight } private suspend fun splitBlock( @@ -525,7 +708,7 @@ class SharedMeasuredEpubPaginator( if (availableTextHeight <= 0) return null val layoutResult = measureTextLayout( - text = block.toAnnotatedString(style.fontSize.value), + text = block.toAnnotatedString(style.fontSize.value, style.textAlign), style = style, widthPx = contentWidth ) @@ -544,7 +727,7 @@ class SharedMeasuredEpubPaginator( val remaining = splitSemanticTextBlockAtOffsetForPagination(block, splitOffset)?.second if (remaining != null && remaining.text.isNotBlank()) { val remainingLayout = measureTextLayout( - text = remaining.toAnnotatedString(style.fontSize.value), + text = remaining.toAnnotatedString(style.fontSize.value, style.textAlign), style = style, widthPx = contentWidth ) @@ -634,23 +817,23 @@ class SharedMeasuredEpubPaginator( private fun Dp.toPxInt(): Int = with(density) { toPx().roundToInt() } - private fun com.aryan.reader.paginatedreader.BoxBorders.verticalPx(): Int { + private fun org.dueattendant149.bookreader.paginatedreader.BoxBorders.verticalPx(): Int { return top.toPxIfSpecified() + bottom.toPxIfSpecified() } - private fun com.aryan.reader.paginatedreader.BoxBorders.horizontalPx(): Int { + private fun org.dueattendant149.bookreader.paginatedreader.BoxBorders.horizontalPx(): Int { return left.toPxIfSpecified() + right.toPxIfSpecified() } - private fun com.aryan.reader.paginatedreader.BlockStyle.verticalBorderPx(): Int { + private fun org.dueattendant149.bookreader.paginatedreader.BlockStyle.verticalBorderPx(): Int { return (borderTop?.width?.toPxIfSpecified() ?: 0) + (borderBottom?.width?.toPxIfSpecified() ?: 0) } - private fun com.aryan.reader.paginatedreader.BlockStyle.horizontalBorderPx(): Int { + private fun org.dueattendant149.bookreader.paginatedreader.BlockStyle.horizontalBorderPx(): Int { return (borderLeft?.width?.toPxIfSpecified() ?: 0) + (borderRight?.width?.toPxIfSpecified() ?: 0) } - private fun com.aryan.reader.paginatedreader.BlockStyle.horizontalOuterPx(): Int { + private fun org.dueattendant149.bookreader.paginatedreader.BlockStyle.horizontalOuterPx(): Int { return margin.horizontalPx() + padding.horizontalPx() + horizontalBorderPx() } @@ -736,26 +919,60 @@ internal data class MeasuredPageGeometry( viewport: ReaderViewportSpec, densityScale: Float = 1f ): MeasuredPageGeometry { - val safeWidth = viewport.widthPx.takeIf { it > 0 } ?: 980 - val safeHeight = viewport.heightPx.takeIf { it > 0 } ?: 720 - val scale = densityScale.takeIf { it.isFinite() && it > 0f } ?: 1f - val gutter = if (settings.isTwoPageSpreadEnabled()) MeasuredSpreadGutterPx.scaleCssPx(scale) else 0 - val horizontalMargin = settings.resolvedHorizontalMargin.scaleCssPx(scale) * 2 - val verticalMargin = settings.resolvedVerticalMargin.scaleCssPx(scale) * 2 - val contentWidth = (safeWidth - horizontalMargin).coerceAtLeast(1) - val configuredPageWidth = settings.pageWidth.scaleCssPx(scale).coerceAtLeast(1) - val pageWidth = if (settings.isTwoPageSpreadEnabled()) { - val spreadWidth = contentWidth.coerceAtMost((configuredPageWidth * 2) + gutter) - ((spreadWidth - gutter).coerceAtLeast(1) / 2).coerceAtLeast(1) - } else { - contentWidth.coerceAtMost(configuredPageWidth).coerceAtLeast(1) - } - val pageHeight = (safeHeight - verticalMargin).coerceAtLeast(1) - return MeasuredPageGeometry(pageWidthPx = pageWidth, pageHeightPx = pageHeight) + return measuredPageGeometryTerms(settings, viewport, densityScale).geometry } } } +private data class MeasuredPageGeometryTerms( + val safeWidthPx: Int, + val safeHeightPx: Int, + val pageHorizontalMarginPx: Int, + val pageVerticalMarginPx: Int, + val configuredPageWidthPx: Int, + val spreadGutterPx: Int, + val singlePageContentWidthPx: Int, + val twoPageAvailableOuterWidthPx: Int, + val twoPageAvailableContentWidthPx: Int, + val geometry: MeasuredPageGeometry +) + +private fun measuredPageGeometryTerms( + settings: ReaderSettings, + viewport: ReaderViewportSpec, + densityScale: Float = 1f +): MeasuredPageGeometryTerms { + val safeWidth = viewport.widthPx.takeIf { it > 0 } ?: 980 + val safeHeight = viewport.heightPx.takeIf { it > 0 } ?: 720 + val scale = densityScale.takeIf { it.isFinite() && it > 0f } ?: 1f + val pageHorizontalMargin = settings.resolvedHorizontalMargin.scaleCssPx(scale) + val pageVerticalMargin = settings.resolvedVerticalMargin.scaleCssPx(scale) + val configuredPageWidth = settings.pageWidth.scaleCssPx(scale).coerceAtLeast(1) + val usesSpreadPageSlot = settings.usesMeasuredPaginatedSpreadPageSlot() + val gutter = if (usesSpreadPageSlot) MeasuredSpreadGutterPx.scaleCssPx(scale) else 0 + val singlePageContentWidth = (safeWidth - (pageHorizontalMargin * 2)).coerceAtLeast(1) + val twoPageAvailableOuterWidth = ((safeWidth - gutter).coerceAtLeast(1) / 2).coerceAtLeast(1) + val twoPageAvailableContentWidth = (twoPageAvailableOuterWidth - (pageHorizontalMargin * 2)).coerceAtLeast(1) + val pageWidth = if (usesSpreadPageSlot) { + twoPageAvailableContentWidth.coerceAtMost(configuredPageWidth).coerceAtLeast(1) + } else { + singlePageContentWidth.coerceAtMost(configuredPageWidth).coerceAtLeast(1) + } + val pageHeight = (safeHeight - (pageVerticalMargin * 2)).coerceAtLeast(1) + return MeasuredPageGeometryTerms( + safeWidthPx = safeWidth, + safeHeightPx = safeHeight, + pageHorizontalMarginPx = pageHorizontalMargin, + pageVerticalMarginPx = pageVerticalMargin, + configuredPageWidthPx = configuredPageWidth, + spreadGutterPx = gutter, + singlePageContentWidthPx = singlePageContentWidth, + twoPageAvailableOuterWidthPx = twoPageAvailableOuterWidth, + twoPageAvailableContentWidthPx = twoPageAvailableContentWidth, + geometry = MeasuredPageGeometry(pageWidthPx = pageWidth, pageHeightPx = pageHeight) + ) +} + internal fun measuredPageGeometryFor( settings: ReaderSettings, viewport: ReaderViewportSpec, @@ -766,6 +983,10 @@ internal fun measuredPageGeometryFor( private const val MeasuredSpreadGutterPx = 28 +private fun ReaderSettings.usesMeasuredPaginatedSpreadPageSlot(): Boolean { + return readingMode == ReaderReadingMode.PAGINATED +} + private fun Int.scaleCssPx(scale: Float): Int { return (this * scale).roundToInt() } @@ -865,19 +1086,60 @@ private fun SemanticBlock.textBlocks(): List { } } -private fun SemanticTextBlock.toAnnotatedString(blockFontSizeSp: Float): AnnotatedString { +private fun SemanticTextBlock.toAnnotatedString( + blockFontSizeSp: Float, + fallbackTextAlign: TextAlign +): AnnotatedString { return buildAnnotatedString { - append(text) + withStyle(toMeasurementParagraphStyleForPagination(fallbackTextAlign)) { + append(text) + } spans.forEach { span -> val start = span.start.coerceIn(0, text.length) val end = span.end.coerceIn(start, text.length) if (start < end) { addStyle(span.style.toMeasurementSpanStyle(blockFontSizeSp), start, end) + addMeasurementWordSpacing( + text = text, + start = start, + end = end, + wordSpacing = span.style.wordSpacing + ) } } } } +internal fun SemanticTextBlock.toMeasurementParagraphStyleForPagination(fallbackTextAlign: TextAlign): ParagraphStyle { + return ParagraphStyle( + textAlign = resolveSharedReaderTextAlign( + cssTextAlign = style.paragraphStyle.textAlign, + fallbackTextAlign = fallbackTextAlign + ), + textIndent = style.paragraphStyle.textIndent, + lineBreak = LineBreak.Paragraph, + hyphens = style.toMeasurementHyphens() + ) +} + +private fun AnnotatedString.Builder.addMeasurementWordSpacing( + text: String, + start: Int, + end: Int, + wordSpacing: TextUnit +) { + if (!wordSpacing.isSpecified || wordSpacing.value == 0f) return + for (index in start until end) { + if (text[index] == ' ') { + addStyle(SpanStyle(letterSpacing = wordSpacing), index, index + 1) + } + } +} + +private fun CssStyle.toMeasurementHyphens(): Hyphens { + return if (hyphens == "auto") Hyphens.Auto else Hyphens.None +} + private fun SemanticTextBlock.textStyle(baseStyle: TextStyle, settings: ReaderSettings): TextStyle { val fontSize = (style.fontSize.takeIfSpecified() ?: style.spanStyle.fontSize.takeIfSpecified()) @@ -896,8 +1158,12 @@ private fun SemanticTextBlock.textStyle(baseStyle: TextStyle, settings: ReaderSe return baseStyle.copy( fontSize = fontSize, lineHeight = lineHeight, + fontFamily = style.spanStyle.fontFamily ?: baseStyle.fontFamily, fontWeight = if (this is SemanticHeader) FontWeight.Bold else baseStyle.fontWeight, - textAlign = style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: baseStyle.textAlign + textAlign = resolveSharedReaderTextAlign( + cssTextAlign = style.paragraphStyle.textAlign, + fallbackTextAlign = baseStyle.textAlign + ) ).withAndroidPaginationTextMetrics() } @@ -931,11 +1197,13 @@ private fun TextUnit.resolveLineHeightSp(fontSizeSp: Float): TextUnit { private fun CssStyle.toMeasurementSpanStyle(parentFontSizeSp: Float): SpanStyle { val resolvedFontSize = (spanStyle.fontSize.takeIfSpecified() ?: fontSize.takeIfSpecified()) ?.resolveFontSizeSp(parentFontSizeSp) - return if (resolvedFontSize == null) { - spanStyle - } else { - spanStyle.copy(fontSize = resolvedFontSize) - } + return spanStyle.copy( + fontSize = resolvedFontSize ?: spanStyle.fontSize, + fontFeatureSettings = resolveSharedReaderFontFeatureSettings( + existingSettings = spanStyle.fontFeatureSettings, + fontVariantNumeric = fontVariantNumeric + ) + ) } private fun headerScale(level: Int): Float { @@ -1146,6 +1414,12 @@ private fun logOversizedMeasuredPageFit( "usedPx=$usedPx pageHeightPx=$pageHeightPx remainingPx=$remainingPx blocks=1 " + "range=${page.startOffset}..${page.endOffset} textChars=${page.text.length} tail=\"${fit.format()}\"" } + logEpubCutoff { + "cutoff_probe layer=measured_overflow reason=$reason page=${page.pageIndex + 1} chapter=$chapterIndex " + + "usedPx=$usedPx pageHeightPx=$pageHeightPx remainingPx=$remainingPx " + + "overflowPx=${(-remainingPx).coerceAtLeast(0)} blocks=1 " + + "range=${page.startOffset}..${page.endOffset} textChars=${page.text.length} tail=\"${fit.format()}\"" + } } private fun String.toCssPxOrNull(containerPx: Int): Int? { @@ -1191,6 +1465,10 @@ private inline fun logEpubPageFit(message: () -> String) { logSharedReaderDiagnostic("EpistemeEpubPageFit", message) } +private inline fun logEpubCutoff(message: () -> String) { + logSharedReaderDiagnostic(SharedEpubCutoffDiagnosticsTag, message) +} + private inline fun logReaderGapPagination(message: () -> String) { logSharedReaderDiagnostic("EpistemeReaderGap", message) }