Compare commits

..

No commits in common. "5f64f3d722e635ac8ae50c5b24e9994fe25128b7" and "d4a1432ea13298fd054e6bdbd9d49418c8432ca8" have entirely different histories.

831 changed files with 18453 additions and 78240 deletions

View file

@ -1,409 +0,0 @@
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

5
.gitignore vendored
View file

@ -21,9 +21,8 @@ google-services.json
.gradle/ .gradle/
third_party/pdfium/ third_party/pdfium/
*.tgz *.tgz
kcef-bundle/
kcef-bundle-linux-x64/
cache/ cache/
worker/ worker/
output/ output/
policies/
episteme-bin/
episteme-oss-bin/

View file

@ -162,19 +162,6 @@
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
</value> </value>
</entry> </entry>
<entry key="-908638882">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-629314366"> <entry key="-629314366">
<value> <value>
<AndroidTestResultsTableState> <AndroidTestResultsTableState>
@ -322,19 +309,6 @@
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
</value> </value>
</entry> </entry>
<entry key="326896270">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="371509721"> <entry key="371509721">
<value> <value>
<AndroidTestResultsTableState> <AndroidTestResultsTableState>
@ -383,7 +357,6 @@
<entry key="Duration" value="90" /> <entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" /> <entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" /> <entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map> </map>
</option> </option>
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
@ -561,19 +534,6 @@
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
</value> </value>
</entry> </entry>
<entry key="1500844897">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1562612490"> <entry key="1562612490">
<value> <value>
<AndroidTestResultsTableState> <AndroidTestResultsTableState>

12
.idea/vcs.xml generated
View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2/brotli" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2/brotli/research/esaxx" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2/brotli/research/libdivsufsort" vcs="Git" />
<mapping directory="$PROJECT_DIR$/episteme-bin" vcs="Git" />
<mapping directory="$PROJECT_DIR$/episteme-oss-bin" vcs="Git" />
</component>
</project>

View file

@ -1,76 +0,0 @@
# 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

150
README.md
View file

@ -5,63 +5,62 @@
<span>&nbsp;Episteme Reader</span> <span>&nbsp;Episteme Reader</span>
</h1> </h1>
<p>A modern, offline-first, privacy-focused document and e-book reader for Android and desktop, built with Kotlin Multiplatform and Compose.</p> <p>A modern, offlinefirst, privacyfocused document & ebook reader for Android, built with Kotlin and Jetpack Compose.</p>
<a href="https://epistemereader.com"><img alt="Download from epistemereader.com" src="https://img.shields.io/badge/Download-epistemereader.com-2f6f5e?style=for-the-badge" height="44" align="absmiddle"/></a>&nbsp;&nbsp;<a href="https://f-droid.org/packages/com.aryan.reader.oss/"><img alt="Get it on F-Droid" src="https://f-droid.org/badge/get-it-on.png" height="66" align="absmiddle"/></a>&nbsp;<a href="https://play.google.com/store/apps/details?id=com.aryan.reader"><img alt="Get it on Google Play" src="https://upload.wikimedia.org/wikipedia/commons/7/78/Google_Play_Store_badge_EN.svg" height="44" align="absmiddle"/></a>&nbsp;&nbsp;&nbsp;<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/Aryan-Raj3112/episteme"><img alt="Get it on Obtainium" src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" height="44" align="absmiddle"/></a> <a href="https://f-droid.org/packages/com.aryan.reader.oss/"><img alt="Get it on F-Droid" src="https://f-droid.org/badge/get-it-on.png" height="66" align="absmiddle"/></a>&nbsp;<a href="https://play.google.com/store/apps/details?id=com.aryan.reader"><img alt="Get it on Google Play" src="https://upload.wikimedia.org/wikipedia/commons/7/78/Google_Play_Store_badge_EN.svg" height="44" align="absmiddle"/></a>&nbsp;&nbsp;&nbsp;<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/Aryan-Raj3112/episteme"><img alt="Get it on Obtainium" src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" height="44" align="absmiddle"/></a>
</div> </div>
<br/> <br/>
<table> ![Episteme Reader Preview](docs/EPISTEME.png)
<tr>
<td width="50%" align="center">
<img src="docs/EPISTEME.png" alt="Episteme Reader on Android"/>
<br/>
<sub>Android</sub>
</td>
<td width="50%" align="center">
<img src="docs/EPISTEME_desktop.png" alt="Episteme Reader on desktop"/>
<br/>
<sub>Desktop</sub>
</td>
</tr>
</table>
## Overview ## Overview
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. 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.
The same core reading experience is available across editions. The main differences are distribution channel, network access, and whether proprietary online services are included. 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.
## Core Features ---
Available across supported editions unless noted in the edition table: ## Feature Comparison
* **Formats:** PDF, EPUB, MOBI/AZW3, FB2, DOCX, ODT/FODT, TXT, Markdown, HTML, and comic archives. ### 📚 Supported Formats
* **Reading modes:** Paginated reading, vertical scroll, PDF multi-tab reading, PDF reflow, auto-scroll, and musician mode. | Feature | PlayStore | OSS | OSS Offline |
* **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. | **Documents:** PDF, DOCX, ODT/FODT | ✅ | ✅ | ✅ |
* **Library tools:** Local folder sync, library organization, bookmarks, progress tracking, and file management. | **E-books & Text:** EPUB, MOBI, AZW3, FB2, MD, HTML, TXT | ✅ | ✅ | ✅ |
* **Accessibility:** System text-to-speech, app language selection, and reader settings that adapt to different reading preferences. | **Comics:** CBZ, CBR, CB7 | ✅ | ✅ | ✅ |
| **View-Only:** CSV, TSV, JSON, XML, Logs, Code Files | ✅ | ✅ | ✅ |
## Editions ### 📖 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)** | ✅ | ✅ | ✅ |
| Edition | Platform | Network access | Distribution | Notes | ### ⚙️ Advanced & Network Features
|---|---|---|---|---| | Feature | PlayStore | OSS (F-Droid) | OSS Offline |
| **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. | | **Local Folder Sync & Library Management** | ✅ | ✅ | ✅ |
| **OSS Offline** | Android | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub, Obtainium | Open-source Android build with network permissions removed. | | **Download Google Fonts** | ✅ | ✅ | ❌ |
| **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. | | **OPDS Catalog Support** | ✅ | ✅ | ❌ |
| **Offline** | Windows desktop | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub | Desktop build focused on local reading with online services disabled. | | **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** | ✅ | 🔜 | ❌ |
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 ## Building from Source
@ -71,7 +70,7 @@ Want Episteme Reader in another language? Please request it through [GitHub Issu
cd episteme cd episteme
``` ```
2. Build Android: 2. Build:
* Open in Android Studio and run the `ossDebug` or `ossOfflineDebug` variant, or * Open in Android Studio and run the `ossDebug` or `ossOfflineDebug` variant, or
* Build from the command line: * Build from the command line:
```bash ```bash
@ -80,56 +79,43 @@ Want Episteme Reader in another language? Please request it through [GitHub Issu
The APK will be generated at: The APK will be generated at:
`app/build/outputs/apk/oss/debug/Episteme-oss-v{version}-oss-debug.apk` `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 ## Open Source Libraries
Powered by the Kotlin, Android, and desktop OSS ecosystem: Powered by the Android OSS ecosystem:
* **Core & UI:** AndroidX, Jetpack Compose, Kotlinx Serialization
* **Core and UI:** Kotlin Multiplatform, Compose Multiplatform, AndroidX, Jetpack Compose, Kotlinx Serialization * **Document Engines:** PdfiumAndroidKt (PDF), libmobi (MOBI/AZW3)
* **Document engines:** PdfiumAndroidKt, PDFium, libmobi * **Parsers:** Jsoup (HTML/EPUB), Flexmark (Markdown)
* **Parsers:** Jsoup, Flexmark, Apache Commons Compress * **Media & Image Loading:** Coil, Media3 (ExoPlayer)
* **Media and image loading:** Coil, Media3 * **Utilities:** Room (Database), Timber (Logging)
* **Utilities:** Room, Timber, JNA
## Contributors ## Contributors
| Contributor | Contribution | | Contributor | Contribution |
|---|---| |---|---|
| <img src="https://github.com/CCerrer.png?size=48" width="24" height="24" valign="middle" alt="CCerrer avatar"> [CCerrer](https://github.com/CCerrer) | Testing and QA | | <img src="https://github.com/CCerrer.png?size=48" width="24" height="24" valign="middle" alt="CCerrer avatar">[CCerrer](https://github.com/CCerrer) | Testing & QA |
| <img src="https://github.com/ottozumkeller.png?size=48" width="24" height="24" valign="middle" alt="ottozumkeller avatar"> [ottozumkeller](https://github.com/ottozumkeller) | German translation | | <img src="https://github.com/ottozumkeller.png?size=48" width="24" height="24" valign="middle" alt="ottozumkeller avatar"> [ottozumkeller](https://github.com/ottozumkeller) | Translation (German) |
| <img src="https://github.com/TURBOKANTR.png?size=48" width="24" height="24" valign="middle" alt="TURBOKANTR avatar"> [TURBOKANTR](https://github.com/TURBOKANTR) | Turkish translation | | <img src="https://github.com/TURBOKANTR.png?size=48" width="24" height="24" valign="middle" alt="TURBOKANTR avatar"> [TURBOKANTR](https://github.com/TURBOKANTR) | Translation (Turkish) |
| <img src="https://github.com/eyadalkordy24.png?size=48" width="24" height="24" valign="middle" alt="eyadalkordy24 avatar"> [eyadalkordy24](https://github.com/eyadalkordy24) | Arabic translation | | <img src="https://github.com/eyadalkordy24.png?size=48" width="24" height="24" valign="middle" alt="eyadalkordy24 avatar">[eyadalkordy24](https://github.com/eyadalkordy24) | Translation (Arabic) |
| <img src="https://github.com/berebara.png?size=48" width="24" height="24" valign="middle" alt="berebara avatar"> [berebara](https://github.com/berebara) | Russian translation | | <img src="https://github.com/berebara.png?size=48" width="24" height="24" valign="middle" alt="berebara avatar">[berebara](https://github.com/berebara) | Translation (Russian) |
| <img src="https://github.com/mh4ckt3mh4ckt1c4s.png?size=48" width="24" height="24" valign="middle" alt="mh4ckt3mh4ckt1c4s avatar"> [mh4ckt3mh4ckt1c4s](https://github.com/mh4ckt3mh4ckt1c4s) | French translation | | <img src="https://github.com/mh4ckt3mh4ckt1c4s.png?size=48" width="24" height="24" valign="middle" alt="mh4ckt3mh4ckt1c4s avatar">[mh4ckt3mh4ckt1c4s](https://github.com/mh4ckt3mh4ckt1c4s) | Translation (French) |
## Supporters ## Translations
Thank you to the people helping keep Episteme Reader moving: Help translate Episteme Reader into your native language! [Weblate](https://hosted.weblate.org/engage/episteme/) is used to manage localization.
| Supporter | Platform | [![Translation status](https://hosted.weblate.org/widget/episteme/multi-auto.svg)](https://hosted.weblate.org/engage/episteme/)
|---|---|
| <img src="https://github.com/Zorklo.png?size=48" width="24" height="24" valign="middle" alt="Zorklo avatar"> [Zorklo](https://github.com/Zorklo) | GitHub Sponsors |
## Support the Project
Help make Episteme Reader better:
* [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 ## License
Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See the [LICENSE](LICENSE) file. Licensed under the GNU Affero General Public License v3.0 (AGPL3.0). See the [LICENSE](LICENSE) file.
## Support the Project
Help make Episteme Reader even 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!

View file

@ -9,7 +9,7 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization) id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
alias(libs.plugins.kotlin.ksp) alias(libs.plugins.kotlin.ksp)
id("com.diffplug.spotless") version "8.2.1" id("com.diffplug.spotless") version "8.2.1"
alias(libs.plugins.kover) alias(libs.plugins.kover)
@ -50,15 +50,15 @@ kotlin {
} }
android { android {
namespace = "org.dueattendant149.bookreader" namespace = "com.aryan.reader"
compileSdk = 36 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "org.dueattendant149.bookreader" applicationId = "com.aryan.reader"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 54 versionCode = 53
versionName = "1.0.50" versionName = "1.0.49"
resourceConfigurations += configuredAppLocaleTags() resourceConfigurations += configuredAppLocaleTags()
.map { it.toAndroidResourceConfiguration() } .map { it.toAndroidResourceConfiguration() }
@ -171,7 +171,6 @@ android {
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
unitTests.all { unitTests.all {
it.maxHeapSize = "4g"
it.jvmArgs("-Xss2m") it.jvmArgs("-Xss2m")
} }
} }
@ -244,6 +243,8 @@ dependencies {
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation("androidx.appcompat:appcompat:1.7.1") implementation("androidx.appcompat:appcompat:1.7.1")
//noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break) //noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break)

View file

@ -1,48 +0,0 @@
# 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`

View file

@ -1,39 +0,0 @@
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()

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>

View file

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
<title>Chapter One</title>
<link rel="stylesheet" type="text/css" href="../styles/reader-test.css"/>
</head>
<body>
<section id="chapter-one">
<h1 id="chapter-one-heading">Chapter One: Stable Opening</h1>
<p id="opening-paragraph">This EPUB is intentionally plain so Android UI tests can rely on stable text, stable IDs, and stable chapter order.</p>
<p id="position-target-alpha"><span class="target">POSITION_TARGET_ALPHA</span> appears near the start of chapter one. Use this marker for first-position and restore-position checks.</p>
<p id="highlight-target-bravo"><span class="target">HIGHLIGHT_TARGET_BRAVO</span> is a short highlight target. It is surrounded by ordinary words so selection handles have context.</p>
<p id="cfi-target-charlie"><span class="target">CFI_TARGET_CHARLIE</span> sits inside a paragraph with a fixed element id. It can be used for CFI and locator assertions.</p>
<p id="chapter-one-filler-01">Chapter one filler paragraph 01 keeps the document tall enough for scroll and pagination tests.</p>
<p id="chapter-one-filler-02">Chapter one filler paragraph 02 keeps the document tall enough for scroll and pagination tests.</p>
<p id="chapter-one-filler-03">Chapter one filler paragraph 03 keeps the document tall enough for scroll and pagination tests.</p>
<p id="chapter-one-filler-04">Chapter one filler paragraph 04 keeps the document tall enough for scroll and pagination tests.</p>
<p id="chapter-one-filler-05">Chapter one filler paragraph 05 keeps the document tall enough for scroll and pagination tests.</p>
<p id="chapter-one-filler-06">Chapter one filler paragraph 06 keeps the document tall enough for scroll and pagination tests.</p>
<p id="chapter-one-filler-07">Chapter one filler paragraph 07 keeps the document tall enough for scroll and pagination tests.</p>
<p id="chapter-one-filler-08">Chapter one filler paragraph 08 keeps the document tall enough for scroll and pagination tests.</p>
<p id="chapter-one-end">END_OF_CHAPTER_ONE_MARKER</p>
</section>
</body>
</html>

View file

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
<title>Chapter Two</title>
<link rel="stylesheet" type="text/css" href="../styles/reader-test.css"/>
</head>
<body>
<section id="chapter-two">
<h1 id="chapter-two-heading">Chapter Two: Search And Bookmarks</h1>
<p id="search-target-delta"><span class="target">SEARCH_TARGET_DELTA</span> appears exactly once in the book. Use it for search result navigation.</p>
<p id="bookmark-target-echo"><span class="target">BOOKMARK_TARGET_ECHO</span> is positioned near the top of chapter two for bookmark add, list, and return flows.</p>
<p id="position-target-foxtrot"><span class="target">POSITION_TARGET_FOXTROT</span> is lower in chapter two and is useful for persistence tests after a chapter change.</p>
<p id="chapter-two-filler-01">Chapter two filler paragraph 01 provides enough height for gesture based navigation.</p>
<p id="chapter-two-filler-02">Chapter two filler paragraph 02 provides enough height for gesture based navigation.</p>
<p id="chapter-two-filler-03">Chapter two filler paragraph 03 provides enough height for gesture based navigation.</p>
<p id="chapter-two-filler-04">Chapter two filler paragraph 04 provides enough height for gesture based navigation.</p>
<p id="chapter-two-filler-05">Chapter two filler paragraph 05 provides enough height for gesture based navigation.</p>
<p id="chapter-two-filler-06">Chapter two filler paragraph 06 provides enough height for gesture based navigation.</p>
<p id="chapter-two-filler-07">Chapter two filler paragraph 07 provides enough height for gesture based navigation.</p>
<p id="chapter-two-filler-08">Chapter two filler paragraph 08 provides enough height for gesture based navigation.</p>
<p id="chapter-two-end">END_OF_CHAPTER_TWO_MARKER</p>
</section>
</body>
</html>

View file

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
<title>Chapter Three</title>
<link rel="stylesheet" type="text/css" href="../styles/reader-test.css"/>
</head>
<body>
<section id="chapter-three">
<h1 id="chapter-three-heading">Chapter Three: Annotation Targets</h1>
<p id="annotation-target-golf"><span class="target">ANNOTATION_TARGET_GOLF</span> is reserved for tests that verify highlight or note persistence across app restarts.</p>
<p id="cfi-target-hotel"><span class="target">CFI_TARGET_HOTEL</span> is a second fixed CFI target in a later chapter.</p>
<p id="inline-image-note" class="fixture-note">The following SVG is local to the EPUB and can be used later for image rendering checks.</p>
<figure id="fixture-diagram">
<img src="../images/fixture-diagram.svg" alt="Fixture diagram"/>
<figcaption>Local SVG image for EPUB resource resolution.</figcaption>
</figure>
<p id="chapter-three-filler-01">Chapter three filler paragraph 01 rounds out the fixture.</p>
<p id="chapter-three-filler-02">Chapter three filler paragraph 02 rounds out the fixture.</p>
<p id="chapter-three-filler-03">Chapter three filler paragraph 03 rounds out the fixture.</p>
<p id="chapter-three-end">END_OF_CHAPTER_THREE_MARKER</p>
</section>
</body>
</html>

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="120" viewBox="0 0 320 120" role="img" aria-labelledby="title desc">
<title id="title">Fixture diagram</title>
<desc id="desc">A simple local SVG used by EPUB UI tests.</desc>
<rect x="1" y="1" width="318" height="118" fill="#f6f8fb" stroke="#4a90e2" stroke-width="2"/>
<circle cx="70" cy="60" r="28" fill="#4a90e2"/>
<rect x="130" y="34" width="130" height="52" fill="#2f855a"/>
<text x="160" y="66" font-size="16" fill="#ffffff">EPUB FIXTURE</text>
</svg>

Before

Width:  |  Height:  |  Size: 568 B

View file

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en" xml:lang="en">
<head>
<title>Reader Android UI Test Book</title>
<link rel="stylesheet" type="text/css" href="styles/reader-test.css"/>
</head>
<body>
<nav epub:type="toc" id="toc">
<h1>Contents</h1>
<ol>
<li><a href="chapters/chapter-01.xhtml#chapter-one-heading">Chapter One</a></li>
<li><a href="chapters/chapter-02.xhtml#chapter-two-heading">Chapter Two</a></li>
<li><a href="chapters/chapter-03.xhtml#chapter-three-heading">Chapter Three</a></li>
</ol>
</nav>
<nav epub:type="landmarks" id="landmarks">
<h2>Landmarks</h2>
<ol>
<li><a epub:type="bodymatter" href="chapters/chapter-01.xhtml">Start</a></li>
</ol>
</nav>
</body>
</html>

View file

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id" xml:lang="en">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="book-id">urn:uuid:reader-android-ui-test-epub</dc:identifier>
<dc:title>Reader Android UI Test Book</dc:title>
<dc:creator>Reader Test Fixtures</dc:creator>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
</metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
<item id="style" href="styles/reader-test.css" media-type="text/css"/>
<item id="chapter-1" href="chapters/chapter-01.xhtml" media-type="application/xhtml+xml"/>
<item id="chapter-2" href="chapters/chapter-02.xhtml" media-type="application/xhtml+xml"/>
<item id="chapter-3" href="chapters/chapter-03.xhtml" media-type="application/xhtml+xml"/>
<item id="inline-svg" href="images/fixture-diagram.svg" media-type="image/svg+xml"/>
</manifest>
<spine>
<itemref idref="chapter-1"/>
<itemref idref="chapter-2"/>
<itemref idref="chapter-3"/>
</spine>
</package>

View file

@ -1,21 +0,0 @@
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;
}

View file

@ -1 +0,0 @@
application/epub+zip

View file

@ -0,0 +1,146 @@
// 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)
}
}

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi

View file

@ -1,19 +1,13 @@
package org.dueattendant149.bookreader.epubreader package com.aryan.reader.epubreader
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class ChapterWebViewBridgeTest { class ChapterWebViewBridgeTest {
@ -26,8 +20,7 @@ class ChapterWebViewBridgeTest {
var receivedCfi = "" var receivedCfi = ""
val bridge = CfiJsBridge( val bridge = CfiJsBridge(
onCfiReady = { cfi -> receivedCfi = cfi }, onCfiReady = { cfi -> receivedCfi = cfi },
onCfiForBookmarkReady = {}, onCfiForBookmarkReady = {}
onScrollFinishedCallback = {}
) )
val cfi = "/4/2[chapter1]/6:10" val cfi = "/4/2[chapter1]/6:10"
@ -46,8 +39,7 @@ class ChapterWebViewBridgeTest {
var receivedCfi = "" var receivedCfi = ""
val bridge = CfiJsBridge( val bridge = CfiJsBridge(
onCfiReady = { cfi -> receivedCfi = cfi }, onCfiReady = { cfi -> receivedCfi = cfi },
onCfiForBookmarkReady = {}, onCfiForBookmarkReady = {}
onScrollFinishedCallback = {}
) )
val invalidJson = "this is not json" val invalidJson = "this is not json"
@ -62,8 +54,7 @@ class ChapterWebViewBridgeTest {
var receivedCfi: String? = null var receivedCfi: String? = null
val bridge = CfiJsBridge( val bridge = CfiJsBridge(
onCfiReady = { cfi -> receivedCfi = cfi }, onCfiReady = { cfi -> receivedCfi = cfi },
onCfiForBookmarkReady = {}, onCfiForBookmarkReady = {}
onScrollFinishedCallback = {}
) )
val jsonResponse = JSONObject().apply { val jsonResponse = JSONObject().apply {
@ -78,51 +69,29 @@ class ChapterWebViewBridgeTest {
} }
@Test @Test
fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() { fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() = runTest {
val latch = CountDownLatch(1)
var receivedJson: String? = null var receivedJson: String? = null
val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
try {
val bridge = TtsJsBridge( val bridge = TtsJsBridge(
scope = bridgeScope, scope = this,
ttsStructuredTextHandler = { json -> ttsStructuredTextHandler = { json -> receivedJson = json }
receivedJson = json
latch.countDown()
}
) )
val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]" val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]"
bridge.onStructuredTextExtracted(jsonPayload) bridge.onStructuredTextExtracted(jsonPayload)
advanceUntilIdle()
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
assertThat(receivedJson).isEqualTo(jsonPayload) assertThat(receivedJson).isEqualTo(jsonPayload)
} finally {
bridgeScope.cancel()
}
} }
@Test @Test
fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() { fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() = runTest {
val latch = CountDownLatch(1)
var receivedJson: String? = null var receivedJson: String? = null
val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
try {
val bridge = TtsJsBridge( val bridge = TtsJsBridge(
scope = bridgeScope, scope = this,
ttsStructuredTextHandler = { json -> ttsStructuredTextHandler = { json -> receivedJson = json }
receivedJson = json
latch.countDown()
}
) )
val jsonPayload = "" val jsonPayload = ""
bridge.onStructuredTextExtracted(jsonPayload) bridge.onStructuredTextExtracted(jsonPayload)
advanceUntilIdle()
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
assertThat(receivedJson).isEqualTo("[]") assertThat(receivedJson).isEqualTo("[]")
} finally {
bridgeScope.cancel()
}
} }
@Test @Test

View file

@ -1,9 +1,9 @@
package org.dueattendant149.bookreader.epubreader package com.aryan.reader.epubreader
import android.content.Context import android.content.Context
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import org.dueattendant149.bookreader.epub.EpubChapter import com.aryan.reader.epub.EpubChapter
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject

View file

@ -0,0 +1,204 @@
// 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("<html><body><p>A simple Test case.</p></body></html>")
val chapter2File = File(testDir, "chapter2.html")
chapter2File.writeText("<html><body><p>Another test case here.</p><p>The word Test appears twice.</p></body></html>")
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<SearchResult> {
val TAG = "EpubReaderLogicTest"
Timber.d("Starting search for query: '$query'")
return withContext(Dispatchers.IO) {
val results = mutableListOf<SearchResult>()
book.chapters.forEachIndexed { chapterIndex, chapter ->
try {
val fullPath = "${book.extractionBasePath}/${chapter.htmlFilePath}"
Timber.d("Chapter ${chapterIndex + 1}: Checking path '$fullPath'")
val htmlFile = File(fullPath)
if (!htmlFile.exists()) {
Timber.e("File does not exist: $fullPath")
return@forEachIndexed
}
val doc = Jsoup.parse(htmlFile, "UTF-8")
val bodyChildren = doc.body().children().toList()
val chunks = bodyChildren.chunked(20)
chunks.forEachIndexed { chunkIndex, chunkOfElements ->
val chunkHtml = chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
val content = Jsoup.parse(chunkHtml).text()
var lastIndex = -1
while (true) {
lastIndex = content.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true)
if (lastIndex == -1) break
Timber.d("Found potential match for '$query' at index $lastIndex.")
val isWordStart = lastIndex == 0 || !content[lastIndex - 1].isLetterOrDigit()
Timber.d("Is it a word start? -> $isWordStart")
if (isWordStart) {
Timber.d("Match is a word start. Adding to results.")
val snippetStart = max(0, lastIndex - 35)
val snippetEnd = min(content.length, lastIndex + query.length + 35)
val rawSnippet = content.substring(snippetStart, snippetEnd)
val annotatedSnippet = buildAnnotatedString {
append(rawSnippet)
val highlightStart = content.indexOf(query, lastIndex, ignoreCase = true) - snippetStart
val highlightEnd = highlightStart + query.length
addStyle(
style = SpanStyle(fontWeight = FontWeight.Bold),
start = highlightStart,
end = highlightEnd
)
}
results.add(
SearchResult(
locationInSource = chapterIndex,
locationTitle = chapter.title,
snippet = annotatedSnippet,
query = query,
occurrenceIndexInLocation = results.count { it.locationInSource == chapterIndex },
chunkIndex = chunkIndex
)
)
}
}
}
} catch (e: Exception) {
Timber.e("Error during search in chapter ${chapter.title}", e)
throw e
}
}
Timber.d("Search finished. Total results found: ${results.size}")
results
}
}
@Test
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
)
}
}
}
}

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader.tts package com.aryan.reader.epubreader
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi

View file

@ -1,5 +1,5 @@
// CssParserTest.kt // CssParserTest.kt
package org.dueattendant149.bookreader.paginatedreader package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
@ -17,66 +17,49 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class CssParserTest { class CssParserTest {
private val dummyConstraints = androidx.compose.ui.unit.Constraints()
private val baseFontSize = 16f
private val density = 1f
private fun parseTextColor(value: String): Color? {
val result = CssParser.parse(
cssContent = "p { color: $value; }",
cssPath = null,
baseFontSizeSp = baseFontSize,
density = density,
constraints = dummyConstraints,
isDarkTheme = false
)
return result.rules.byTag["p"]
?.firstOrNull()
?.style
?.spanStyle
?.color
?.takeIf { it.isSpecified }
}
@Test @Test
fun parseColor_handlesNamedColorsCorrectly() { fun parseColor_handlesNamedColorsCorrectly() {
assertThat(parseTextColor("red")).isEqualTo(Color.Red) assertThat(CssParser.parseColor("red")).isEqualTo(Color.Red)
assertThat(parseTextColor("black")).isEqualTo(Color.Black) assertThat(CssParser.parseColor("black")).isEqualTo(Color.Black)
assertThat(parseTextColor("transparent")).isEqualTo(Color.Transparent) assertThat(CssParser.parseColor("transparent")).isEqualTo(Color.Transparent)
} }
@Test @Test
fun parseColor_handles3DigitHexCodes() { fun parseColor_handles3DigitHexCodes() {
assertThat(parseTextColor("#F0C")).isEqualTo(Color(0xFFFF00CC)) assertThat(CssParser.parseColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
} }
@Test @Test
fun parseColor_handles6DigitHexCodes() { fun parseColor_handles6DigitHexCodes() {
assertThat(parseTextColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC)) assertThat(CssParser.parseColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
} }
@Test @Test
fun parseColor_handles8DigitHexCodes() { fun parseColor_handles8DigitHexCodes() {
assertThat(parseTextColor("#80FF00CC")).isEqualTo(Color(128, 255, 0, 204)) assertThat(CssParser.parseColor("#80FF00CC")).isEqualTo(Color(0x80FF00CC))
} }
@Test @Test
fun parseColor_handlesRgbFunction() { fun parseColor_handlesRgbFunction() {
assertThat(parseTextColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204)) assertThat(CssParser.parseColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
} }
@Test @Test
fun parseColor_handlesRgbaFunction() { fun parseColor_handlesRgbaFunction() {
assertThat(parseTextColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128)) assertThat(CssParser.parseColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
} }
@Test @Test
fun parseColor_returnsNullForInvalidInput() { fun parseColor_returnsNullForInvalidInput() {
assertThat(parseTextColor("not a color")).isNull() assertThat(CssParser.parseColor("not a color")).isNull()
assertThat(parseTextColor("#12345")).isNull() assertThat(CssParser.parseColor("#12345")).isNull()
assertThat(parseTextColor("rgb(1,2)")).isNull() assertThat(CssParser.parseColor("rgb(1,2)")).isNull()
} }
private val dummyConstraints = androidx.compose.ui.unit.Constraints()
private val baseFontSize = 16f
private val density = 1f
@Test @Test
fun parse_handlesSimpleRule() { fun parse_handlesSimpleRule() {
val css = "p { color: red; }" val css = "p { color: red; }"
@ -137,12 +120,12 @@ class CssParserTest {
} }
p { color: black; } p { color: black; }
""".trimIndent() """.trimIndent()
val result = CssParser.parse(css, "OEBPS/styles/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false) val result = CssParser.parse(css, "/some/path/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.rules.byTag).containsKey("p") assertThat(result.rules.byTag).containsKey("p")
assertThat(result.fontFaces).hasSize(1) assertThat(result.fontFaces).hasSize(1)
val fontFace = result.fontFaces.first() val fontFace = result.fontFaces.first()
assertThat(fontFace.fontFamily).isEqualTo("mycustomfont") assertThat(fontFace.fontFamily).isEqualTo("mycustomfont")
assertThat(fontFace.src).isEqualTo("OEBPS/fonts/myfont.ttf") assertThat(fontFace.src).isEqualTo("/some/fonts/myfont.ttf")
assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold) assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold)
assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal) assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal)
} }
@ -207,11 +190,10 @@ class CssParserTest {
val css = "div { border: 2px solid red; }" val css = "div { border: 2px solid red; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false) val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["div"]?.first()?.style?.blockStyle val style = result.rules.byTag["div"]?.first()?.style?.blockStyle
val expectedBorder = BorderStyle(width = 2.dp, color = Color.Red, style = "solid") assertThat(style?.border).isNotNull()
assertThat(style?.borderTop).isEqualTo(expectedBorder) assertThat(style?.border?.width).isEqualTo(2.dp)
assertThat(style?.borderRight).isEqualTo(expectedBorder) assertThat(style?.border?.style).isEqualTo("solid")
assertThat(style?.borderBottom).isEqualTo(expectedBorder) assertThat(style?.border?.color).isEqualTo(Color.Red)
assertThat(style?.borderLeft).isEqualTo(expectedBorder)
} }
@Test @Test
@ -280,70 +262,9 @@ class CssParserTest {
url("font.ttf") format("truetype"); url("font.ttf") format("truetype");
} }
""".trimIndent() """.trimIndent()
val result = CssParser.parse(css, "OEBPS/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false) val result = CssParser.parse(css, "/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.fontFaces).hasSize(1) assertThat(result.fontFaces).hasSize(1)
assertThat(result.fontFaces.first().src).isEqualTo("OEBPS/css/font.otf") assertThat(result.fontFaces.first().src).isEqualTo("/css/font.otf")
}
@Test
fun parse_handlesNestedMediaAndCalcVariables() {
val css = """
:root { --gap: 12px; }
@media screen and (min-width: 300px) {
p { margin-left: calc(var(--gap) + 8px); color: hsl(120 100% 25%); }
}
""".trimIndent()
val result = CssParser.parse(
css,
null,
baseFontSize,
density,
androidx.compose.ui.unit.Constraints(maxWidth = 500),
isDarkTheme = false
)
val style = result.rules.byTag["p"]!!.first().style
assertThat(style.blockStyle.margin.left).isEqualTo(20.dp)
assertThat(style.spanStyle.color).isEqualTo(Color(0, 128, 0))
}
@Test
fun parse_preservesBeforeAfterPseudoElementRules() {
val css = "p::before { content: 'Note: '; color: red; } p { color: blue; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val pseudoRule = result.rules.otherComplex.single { it.pseudoElement == "before" }
assertThat(pseudoRule.selector.selector).isEqualTo("p")
assertThat(pseudoRule.style.content).isEqualTo("'Note: '")
assertThat(pseudoRule.style.spanStyle.color).isEqualTo(Color.Red)
assertThat(result.rules.byTag["p"]!!.single().style.spanStyle.color).isEqualTo(Color.Blue)
}
@Test
fun parse_handlesModernRgbSlashAlphaAndCssHexAlpha() {
assertThat(parseTextColor("rgb(255 0 204 / 50%)")).isEqualTo(Color(255, 0, 204, 128))
assertThat(parseTextColor("#ff00cc80")).isEqualTo(Color(255, 0, 204, 128))
}
@Test
fun parse_backgroundShorthandExtractsColorAndImage() {
val css = "section { background: #ffeecc url('../images/paper.png') repeat; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["section"]!!.first().style.blockStyle
assertThat(style.backgroundColor).isEqualTo(Color(255, 238, 204))
assertThat(style.backgroundImage).isEqualTo("../images/paper.png")
}
@Test
fun parse_listStyleShorthandExtractsMarkerTypeAndImage() {
val css = "ul { list-style: square url('../images/bullet.png') outside; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["ul"]!!.first().style.blockStyle
assertThat(style.listStyleType).isEqualTo("square")
assertThat(style.listStyleImage).isEqualTo("../images/bullet.png")
} }
@Test @Test
@ -406,10 +327,10 @@ class CssParserTest {
} }
@Test @Test
fun parse_lineHeightPreservesUnitlessMultiplier() { fun parse_lineHeightClampsSmallEmValues() {
val css = "p { line-height: 1.1; }" // This is treated as 1.1em val css = "p { line-height: 1.1; }" // This is treated as 1.1em
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false) val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["p"]?.first()?.style val style = result.rules.byTag["p"]?.first()?.style
assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(1.1.em) assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(2.0.em)
} }
} }

View file

@ -1,8 +1,9 @@
// HtmlParserTest.kt // HtmlParserTest.kt
package org.dueattendant149.bookreader.paginatedreader package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
@ -40,7 +41,7 @@ class HtmlParserTest {
val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules
return androidHtmlToSemanticBlocks( return htmlToSemanticBlocks(
html = "<body>$html</body>", // Wrap in body to match real usage html = "<body>$html</body>", // Wrap in body to match real usage
cssRules = allRules, // Use the combined list of rules cssRules = allRules, // Use the combined list of rules
textStyle = defaultTextStyle, textStyle = defaultTextStyle,
@ -96,61 +97,7 @@ class HtmlParserTest {
assertThat(blocks).hasSize(1) assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph val pBlock = blocks.first() as SemanticParagraph
val blockStyle = pBlock.style.spanStyle val blockStyle = pBlock.style.spanStyle
assertThat(blockStyle.color).isEqualTo(Color(0, 128, 0)) assertThat(blockStyle.color).isEqualTo(Color.Green)
}
@Test
fun htmlToSemanticBlocks_contextSensitiveSelectors_areNotReusedAcrossSameClass() {
val css = """
.warning p.note { color: red; }
.safe p.note { color: blue; }
""".trimIndent()
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse(
"""
<div class="warning"><p class="note">Danger</p></div>
<div class="safe"><p class="note">Okay</p></div>
""".trimIndent(),
cssRules = cssRules
)
val first = blocks[0] as SemanticParagraph
val second = blocks[1] as SemanticParagraph
assertThat(first.style.spanStyle.color).isEqualTo(Color.Red)
assertThat(second.style.spanStyle.color).isEqualTo(Color.Blue)
}
@Test
fun htmlToSemanticBlocks_generatedBeforeContent_isMaterializedIntoText() {
val css = "p.note::before { content: 'Note: '; color: red; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<p class=\"note\">Remember this</p>", cssRules = cssRules)
val paragraph = blocks.single() as SemanticParagraph
assertThat(paragraph.text).isEqualTo("Note: Remember this")
val generatedSpan = paragraph.spans.first { it.tag == "::before" }
assertThat(generatedSpan.start).isEqualTo(0)
assertThat(generatedSpan.end).isEqualTo("Note: ".length)
assertThat(generatedSpan.style.spanStyle.color).isEqualTo(Color.Red)
}
@Test
fun htmlToSemanticBlocks_backgroundImageUrl_isResolvedIntoBlockStyle() {
val imageRelativeSrc = "images/paper.png"
val chapterParentDir = File(defaultChapterPath).parent ?: ""
val imageFile = File(File(defaultExtractionPath, chapterParentDir), imageRelativeSrc).canonicalFile
imageFile.parentFile?.mkdirs()
imageFile.createNewFile()
imageFile.deleteOnExit()
val css = "p.paper { background-image: url('$imageRelativeSrc'); }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<p class=\"paper\">Text over paper</p>", cssRules = cssRules)
val paragraph = blocks.single() as SemanticParagraph
assertThat(paragraph.style.blockStyle.backgroundImage).isEqualTo(imageFile.absolutePath)
} }
@Test @Test
@ -250,7 +197,7 @@ class HtmlParserTest {
} }
@Test @Test
fun htmlToSemanticBlocks_complexInlineText_isPreserved() { fun htmlToSemanticBlocks_complexInlineFormatting_isPreserved() {
val html = "<p>This is <b>bold</b> and <i>italic</i> text.</p>" val html = "<p>This is <b>bold</b> and <i>italic</i> text.</p>"
val blocks = parse(html) val blocks = parse(html)
@ -258,6 +205,17 @@ class HtmlParserTest {
val pBlock = blocks.first() as SemanticParagraph val pBlock = blocks.first() as SemanticParagraph
assertThat(pBlock.text).isEqualTo("This is bold and italic text.") assertThat(pBlock.text).isEqualTo("This is bold and italic text.")
// Find the range for "bold" and check its style
val boldRange = pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "bold" }
assertThat(boldRange).isNotNull()
assertThat(boldRange!!.style.spanStyle.fontWeight).isEqualTo(FontWeight.Bold)
// Find the range for "italic" and check its style
val italicRange =
pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "italic" }
assertThat(italicRange).isNotNull()
assertThat(italicRange!!.style.spanStyle.fontStyle).isEqualTo(androidx.compose.ui.text.font.FontStyle.Italic)
} }
@Test @Test
@ -284,14 +242,15 @@ class HtmlParserTest {
} }
@Test @Test
fun htmlToSemanticBlocks_beforePseudoElementContent_isIncludedInParagraphText() { fun htmlToSemanticBlocks_pseudoElements_areIgnoredByTheParser() {
val css = "p::before { content: \"Note: \"; }" val css = "p::before { content: \"Note: \"; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<p>This is a test.</p>", cssRules = cssRules) val blocks = parse("<p>This is a test.</p>", cssRules = cssRules)
// The parser now ignores pseudo-elements, so only the paragraph content should be parsed.
assertThat(blocks).hasSize(1) assertThat(blocks).hasSize(1)
val pBlock = blocks[0] as SemanticParagraph val pBlock = blocks[0] as SemanticParagraph
assertThat(pBlock.text).isEqualTo("Note: This is a test.") assertThat(pBlock.text).isEqualTo("This is a test.")
} }
@Test @Test

View file

@ -1,5 +1,5 @@
// MainDispatcherRule.kt // MainDispatcherRule.kt
package org.dueattendant149.bookreader.pdf package com.aryan.reader.paginatedreader
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi

View file

@ -1,5 +1,5 @@
// PaginatedReaderDataTest.kt // PaginatedReaderDataTest.kt
package org.dueattendant149.bookreader.paginatedreader package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.ParagraphStyle
@ -75,7 +75,7 @@ class PaginatedReaderDataTest {
margin = BoxBorders(bottom = 10.dp, left = 10.dp), margin = BoxBorders(bottom = 10.dp, left = 10.dp),
width = 200.dp, width = 200.dp,
backgroundColor = Color.Black, backgroundColor = Color.Black,
borderTop = BorderStyle(width = 1.dp, color = Color.Red) border = BorderStyle(width = 1.dp, color = Color.Red)
) )
val merged = baseStyle.merge(overrideStyle) val merged = baseStyle.merge(overrideStyle)
@ -95,8 +95,8 @@ class PaginatedReaderDataTest {
// Other properties // Other properties
assertThat(merged.width).isEqualTo(200.dp) assertThat(merged.width).isEqualTo(200.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.Black) assertThat(merged.backgroundColor).isEqualTo(Color.Black)
assertThat(merged.borderTop).isNotNull() assertThat(merged.border).isNotNull()
assertThat(merged.borderTop?.width).isEqualTo(1.dp) assertThat(merged.border?.width).isEqualTo(1.dp)
} }
@Test @Test
@ -115,9 +115,6 @@ class PaginatedReaderDataTest {
assertThat(merged.margin.top).isEqualTo(5.dp) assertThat(merged.margin.top).isEqualTo(5.dp)
assertThat(merged.width).isEqualTo(100.dp) assertThat(merged.width).isEqualTo(100.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.White) assertThat(merged.backgroundColor).isEqualTo(Color.White)
assertThat(merged.borderTop).isNull() assertThat(merged.border).isNull()
assertThat(merged.borderRight).isNull()
assertThat(merged.borderBottom).isNull()
assertThat(merged.borderLeft).isNull()
} }
} }

View file

@ -0,0 +1,304 @@
// 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<Int> = 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<Context>()
val textMeasurer = mockk<TextMeasurer>(relaxed = true)
val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
val textStyle = TextStyle.Default
val density = Density(1f)
val mathMLRenderer = mockk<MathMLRenderer>(relaxed = true)
val testBook = EpubBook(
fileName = "test.epub",
title = "Test Book",
author = "Test Author",
language = "en",
coverImage = null,
chapters = listOf(
EpubChapter(
chapterId = "ch1",
title = "Chapter 1",
htmlFilePath = "ch1.html",
absPath = "/ops/ch1.html",
htmlContent = "<p>Some content</p>",
plainTextContent = "Some content"
)
),
css = mapOf("/ops/style.css" to "p {color: red;}"),
extractionBasePath = ""
)
// Mock dependencies for BookPaginator
val mockDao = mockk<BookCacheDao>(relaxed = true)
coEvery { mockDao.getProcessedBook(any()) } returns null // Simulate cache miss
val mockDb = mockk<BookCacheDatabase>()
every { mockDb.bookCacheDao() } returns mockDao
mockkObject(BookCacheDatabase.Companion)
every { BookCacheDatabase.getDatabase(any()) } returns mockDb
mockkObject(BookProcessingWorker.Companion)
every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
// Pre-condition check
assertThat(viewModel.uiState.value.isLoading).isTrue()
assertThat(viewModel.paginator).isNull()
// Act
viewModel.initialize(
book = testBook,
textMeasurer = textMeasurer,
textConstraints = constraints,
textStyle = textStyle,
density = density,
isDarkTheme = false,
context = context,
initialChapterToPaginate = 0,
mathMLRenderer = mathMLRenderer
)
advanceUntilIdle() // Allow coroutines to complete
// Assert
assertThat(viewModel.paginator).isInstanceOf(BookPaginator::class.java)
assertThat(viewModel.uiState.value.isLoading).isFalse()
assertThat(viewModel.uiState.value.totalPageCount).isGreaterThan(0)
}
@Test
fun initialize_isIdempotent() = runTest {
// Arrange
val viewModel = PaginatedReaderViewModel()
val context = ApplicationProvider.getApplicationContext<Context>()
val textMeasurer = mockk<TextMeasurer>(relaxed = true)
val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
val textStyle = TextStyle.Default
val density = Density(1f)
val mathMLRenderer = mockk<MathMLRenderer>(relaxed = true)
val testBook = EpubBook(
fileName = "test.epub",
title = "Test Book",
author = "Test Author",
language = "en",
coverImage = null,
chapters = listOf(
EpubChapter(
chapterId = "ch1",
title = "Chapter 1",
htmlFilePath = "ch1.html",
absPath = "/ops/ch1.html",
htmlContent = "<p>Some content</p>",
plainTextContent = "Some content"
)
),
css = mapOf("/ops/style.css" to "p {color: red;}"),
extractionBasePath = ""
)
// Mock dependencies
val mockDao = mockk<BookCacheDao>(relaxed = true)
coEvery { mockDao.getProcessedBook(any()) } returns null
val mockDb = mockk<BookCacheDatabase>()
every { mockDb.bookCacheDao() } returns mockDao
mockkObject(BookCacheDatabase.Companion)
every { BookCacheDatabase.getDatabase(any()) } returns mockDb
mockkObject(BookProcessingWorker.Companion)
every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
// Act
viewModel.initialize(
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)
}
}

View file

@ -1,13 +1,11 @@
// PaginatorTest.kt // PaginatorTest.kt
package org.dueattendant149.bookreader.paginatedreader package com.aryan.reader.paginatedreader
import android.os.Build
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SdkSuppress
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -44,52 +42,14 @@ class FakeSplittableMeasurementProvider(
} }
return null return null
} }
override suspend fun split(block: TableBlock, availableHeight: Int): Pair<TableBlock, TableBlock>? = null
override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair<FlexContainerBlock, FlexContainerBlock>? = null
} }
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
class PaginatorTest { class PaginatorTest {
private val testDensity = Density(density = 1f, fontScale = 1f) private val testDensity = Density(density = 1f, fontScale = 1f)
private val pageHeight = 1000 private val pageHeight = 1000
private fun List<ContentBlock>.withoutMeasuredHeights(): List<ContentBlock> {
return map { it.withoutMeasuredHeight() }
}
private fun ContentBlock.withoutMeasuredHeight(): ContentBlock {
return when (this) {
is ParagraphBlock -> copy(expectedHeight = 0)
is ImageBlock -> copy(expectedHeight = 0)
is HeaderBlock -> copy(expectedHeight = 0)
is SpacerBlock -> copy(expectedHeight = 0)
is QuoteBlock -> copy(expectedHeight = 0)
is ListItemBlock -> copy(expectedHeight = 0)
is TableBlock -> copy(
rows = rows.map { row ->
row.map { cell ->
cell.copy(content = cell.content.withoutMeasuredHeights())
}
},
expectedHeight = 0
)
is MathBlock -> copy(expectedHeight = 0)
is WrappingContentBlock -> copy(
floatedImage = floatedImage.copy(expectedHeight = 0),
paragraphsToWrap = paragraphsToWrap.map { it.copy(expectedHeight = 0) },
expectedHeight = 0
)
is FlexContainerBlock -> copy(
children = children.withoutMeasuredHeights(),
expectedHeight = 0
)
}
}
@Test @Test
fun paginate_givenEmptyBlocks_createsZeroPages() = runTest { fun paginate_givenEmptyBlocks_createsZeroPages() = runTest {
val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity) val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity)
@ -125,53 +85,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2) assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1) assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2) assertThat(pages[1].content).containsExactly(block2)
}
@Test
fun paginate_honorsBreakBeforePage() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("Before"), blockIndex = 0)
val block2 = ParagraphBlock(
content = AnnotatedString("After"),
style = BlockStyle(breakBefore = "page"),
blockIndex = 1
)
val pages = paginate(
listOf(block1, block2),
pageHeight,
FakeSplittableMeasurementProvider(mapOf(block1 to 100, block2 to 100)),
testDensity
)
assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
}
@Test
fun paginate_breakInsideAvoidPreventsParagraphSplit() = runTest {
val block1 = ParagraphBlock(
content = AnnotatedString("Keep together"),
style = BlockStyle(breakInside = "avoid"),
blockIndex = 0
)
val part1 = block1.copy(content = AnnotatedString("Keep"))
val part2 = block1.copy(content = AnnotatedString("together"))
val pages = paginate(
listOf(block1),
pageHeight = 400,
measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 800, part1 to 300, part2 to 500),
splittableParagraphs = mapOf(block1 to (part1 to part2))
),
density = testDensity
)
assertThat(pages).hasSize(1)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
} }
@Test @Test
@ -195,8 +110,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2) assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1, part1).inOrder() assertThat(pages[0].content).containsExactly(block1, part1).inOrder()
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(part2) assertThat(pages[1].content).containsExactly(part2)
} }
@Test @Test
@ -221,8 +136,8 @@ class PaginatorTest {
val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity) val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2) assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(splitWrapper) assertThat(pages[0].content).containsExactly(splitWrapper)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(para2) assertThat(pages[1].content).containsExactly(para2)
} }
@ -243,8 +158,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2) assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1) assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(unsplittableBlock) assertThat(pages[1].content).containsExactly(unsplittableBlock)
} }
@Test @Test
@ -257,7 +172,7 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(1) assertThat(pages).hasSize(1)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(oversizedBlock) assertThat(pages[0].content).containsExactly(oversizedBlock)
} }
@Test @Test
@ -333,8 +248,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2) assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1) assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(splittableBlock) // Was not split assertThat(pages[1].content).containsExactly(splittableBlock) // Was not split
} }
@Test @Test
@ -356,7 +271,8 @@ class PaginatorTest {
// Set page height so that a split is attempted. // Set page height so that a split is attempted.
val pages = paginate(blocks, 150, measurementProvider, testDensity) val pages = paginate(blocks, 150, measurementProvider, testDensity)
assertThat(pages).hasSize(1) assertThat(pages).hasSize(1)
// Empty split heads are skipped so pagination keeps only the remaining content. // The page should be empty because part1 was empty, and the original block was re-added
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(part2) // to the remaining list. The next page then contains the full block.
assertThat(pages[0].content).containsExactly(part2)
} }
} }

View file

@ -1,5 +1,5 @@
// StyleUtilsTest.kt // StyleUtilsTest.kt
package org.dueattendant149.bookreader.paginatedreader package com.aryan.reader.paginatedreader
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isUnspecified import androidx.compose.ui.unit.isUnspecified

View file

@ -1,5 +1,5 @@
// MainDispatcherRule.kt // MainDispatcherRule.kt
package org.dueattendant149.bookreader.paginatedreader package com.aryan.reader.pdf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi

View file

@ -1,5 +1,5 @@
// PdfAnnotationTest.kt // PdfAnnotationTest.kt
package org.dueattendant149.bookreader.pdf package com.aryan.reader.pdf
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -11,7 +11,6 @@ import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.click import androidx.compose.ui.test.click
import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
@ -20,9 +19,7 @@ import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import org.dueattendant149.bookreader.MainActivity import com.aryan.reader.MainActivity
import org.dueattendant149.bookreader.R
import com.google.common.truth.Truth.assertThat
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
@ -41,12 +38,6 @@ class PdfAnnotationTest {
private var currentPdfFile: File? = null private var currentPdfFile: File? = null
private var scenario: ActivityScenario<MainActivity>? = null private var scenario: ActivityScenario<MainActivity>? = null
private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") } private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
private fun text(resId: Int): String = context.getString(resId)
private fun dockTag(resId: Int): String = "DockItem_${text(resId)}"
private fun assertNoNodeWithTag(tag: String) {
assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty()
}
private fun createPdfViewIntent(context: Context, uri: Uri): Intent { private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
return Intent(context, MainActivity::class.java).apply { return Intent(context, MainActivity::class.java).apply {
@ -64,7 +55,7 @@ class PdfAnnotationTest {
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE) context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.edit().clear().commit() .edit().clear().commit()
scenario = ActivityScenario.launch<MainActivity>(createPdfViewIntent(context, samplePdfUri)) scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri))
waitForDocumentLoad() waitForDocumentLoad()
} }
@ -84,11 +75,11 @@ class PdfAnnotationTest {
} }
private fun enterEditMode() { private fun enterEditMode() {
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode)) composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode")
.assertIsDisplayed() .assertIsDisplayed()
.performClick() .performClick()
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).assertIsDisplayed() composeTestRule.onNodeWithContentDescription("Close Edit Mode").assertIsDisplayed()
} }
private fun tapOutsidePopup() { private fun tapOutsidePopup() {
@ -120,13 +111,13 @@ class PdfAnnotationTest {
enterEditMode() enterEditMode()
// Verify Dock Items exist using new Tags // Verify Dock Items exist using new Tags
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed() composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsDisplayed() composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsDisplayed()
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsDisplayed() composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick() composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode)).assertIsDisplayed() composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode").assertIsDisplayed()
} }
// --- TOOL LOGIC TESTS --- // --- TOOL LOGIC TESTS ---
@ -136,38 +127,38 @@ class PdfAnnotationTest {
enterEditMode() enterEditMode()
// 1. Select Highlighter // 1. Select Highlighter
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).performClick() composeTestRule.onNodeWithTag("DockItem_Highlighter").performClick()
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
// 2. Verify selection state // 2. Verify selection state
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected() composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsNotSelected() composeTestRule.onNodeWithTag("DockItem_Pen").assertIsNotSelected()
// 3. Exit Edit Mode // 3. Exit Edit Mode
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick() composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
// 4. Re-enter Edit Mode // 4. Re-enter Edit Mode
enterEditMode() enterEditMode()
// 5. Verify Highlighter is STILL selected (Persistence) // 5. Verify Highlighter is STILL selected (Persistence)
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected() composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
} }
@Test @Test
fun testEraserSettingsPopupOpensWhenAlreadySelected() { fun testEraserHasNoPopup() {
enterEditMode() enterEditMode()
// Select Eraser // Select Eraser
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick() composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsSelected() composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsSelected()
// Click Eraser again to open its settings popup. // Click Eraser AGAIN (Should NOT open popup)
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick() composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertIsDisplayed() composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
} }
// --- SETTINGS POPUP TESTS --- // --- SETTINGS POPUP TESTS ---
@ -178,7 +169,7 @@ class PdfAnnotationTest {
// 1. Pen is default. Click Pen ONCE to open Settings. // 1. Pen is default. Click Pen ONCE to open Settings.
// (Clicking twice would toggle it off, which caused previous failures) // (Clicking twice would toggle it off, which caused previous failures)
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick() composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
// 2. Verify Popup Displayed // 2. Verify Popup Displayed
@ -195,7 +186,7 @@ class PdfAnnotationTest {
// 5. Dismiss Settings // 5. Dismiss Settings
tapOutsidePopup() tapOutsidePopup()
assertNoNodeWithTag("ToolSettingsPopup") composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
} }
@Test @Test
@ -203,7 +194,7 @@ class PdfAnnotationTest {
enterEditMode() enterEditMode()
// Open Settings for Pen (Default selected, so one click opens settings) // Open Settings for Pen (Default selected, so one click opens settings)
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick() composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
// Test Palette Click (Index 1) // Test Palette Click (Index 1)
@ -217,7 +208,7 @@ class PdfAnnotationTest {
tapOutsidePopup() tapOutsidePopup()
// Quick verification that settings didn't crash app // Quick verification that settings didn't crash app
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed() composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
} }
// --- UNDO/REDO TESTS --- // --- UNDO/REDO TESTS ---
@ -226,7 +217,7 @@ class PdfAnnotationTest {
fun testDrawingEnablesUndo() { fun testDrawingEnablesUndo() {
enterEditMode() enterEditMode()
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo)) composeTestRule.onNodeWithContentDescription("Undo")
.assertIsDisplayed() .assertIsDisplayed()
.assertIsNotEnabled() .assertIsNotEnabled()
@ -236,7 +227,7 @@ class PdfAnnotationTest {
} }
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo)).assertIsEnabled() composeTestRule.onNodeWithContentDescription("Undo").assertIsEnabled()
} }
@Test @Test
@ -249,8 +240,8 @@ class PdfAnnotationTest {
} }
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
val undoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo)) val undoNode = composeTestRule.onNodeWithContentDescription("Undo")
val redoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_redo)) val redoNode = composeTestRule.onNodeWithContentDescription("Redo")
undoNode.assertIsEnabled() undoNode.assertIsEnabled()
redoNode.assertIsNotEnabled() redoNode.assertIsNotEnabled()
@ -277,7 +268,7 @@ class PdfAnnotationTest {
enterEditMode() enterEditMode()
// 1. Drag Dock to make it floating (using Pen icon as handle) // 1. Drag Dock to make it floating (using Pen icon as handle)
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performTouchInput { composeTestRule.onNodeWithTag("DockItem_Pen").performTouchInput {
down(center) down(center)
advanceEventTime(600) // Long press advanceEventTime(600) // Long press
// Drag UP significantly // Drag UP significantly
@ -287,20 +278,20 @@ class PdfAnnotationTest {
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
// 2. Minimize (Eye icon) // 2. Minimize (Eye icon)
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_visibility)).performClick() composeTestRule.onNodeWithContentDescription("Toggle Visibility").performClick()
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
// 3. Verify Dock items are hidden // 3. Verify Dock items are hidden
assertNoNodeWithTag(dockTag(R.string.content_desc_pen)) composeTestRule.onNodeWithTag("DockItem_Pen").assertDoesNotExist()
// 4. Verify "Show Dock" floating button is visible // 4. Verify "Show Dock" floating button is visible
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).assertIsDisplayed() composeTestRule.onNodeWithContentDescription("Show Dock").assertIsDisplayed()
// 5. Restore // 5. Restore
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).performClick() composeTestRule.onNodeWithContentDescription("Show Dock").performClick()
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
// 6. Verify Dock items return // 6. Verify Dock items return
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed() composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
} }
} }

View file

@ -1,5 +1,5 @@
// app/src/androidTest/java/com/aryan/reader/pdf/PdfCoverGeneratorTest.kt // app/src/androidTest/java/com/aryan/reader/pdf/PdfCoverGeneratorTest.kt
package org.dueattendant149.bookreader.pdf package com.aryan.reader.pdf
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri

View file

@ -1,5 +1,5 @@
// app/src/androidTest/java/com/aryan/reader/pdf/PdfHelperTest.kt // app/src/androidTest/java/com/aryan/reader/pdf/PdfHelperTest.kt
package org.dueattendant149.bookreader.pdf package com.aryan.reader.pdf
import android.graphics.Rect import android.graphics.Rect
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4

View file

@ -0,0 +1,313 @@
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>()
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<MainActivity>(createPdfViewIntent(context, samplePdfUri))
@After
fun tearDown() {
currentPdfFile?.let {
if (it.exists()) it.delete()
}
}
private fun waitForDocumentLoad(pageText: String = "Page 1 of 4") {
composeTestRule.waitUntil(timeoutMillis = 15_000) {
composeTestRule
.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()
}
}

View file

@ -1,5 +1,5 @@
// BaseTtsSynthesizerTest.kt // BaseTtsSynthesizerTest.kt
package org.dueattendant149.bookreader.tts package com.aryan.reader.tts
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader.epubreader package com.aryan.reader.tts
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi

View file

@ -1,5 +1,5 @@
// TtsUtilsTest.kt // TtsUtilsTest.kt
package org.dueattendant149.bookreader.tts package com.aryan.reader.tts
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.Test import org.junit.Test

View file

@ -1,117 +0,0 @@
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)
}
}

View file

@ -1,126 +0,0 @@
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)
}
}

View file

@ -1,400 +0,0 @@
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<Shelf> = emptyList(),
onTagClick: () -> Unit = {},
onPinClick: () -> Unit = {},
onInfoClick: () -> Unit = {},
onSelectAllClick: () -> Unit = {},
onDeleteClick: () -> Unit = {},
onShelfClick: (Shelf) -> Unit = {},
onShelfLongClick: (Shelf) -> Unit = {},
onNewShelfClick: () -> Unit = {}
) {
val searchQuery = mutableStateOf("")
val isSearchActive = mutableStateOf(false)
val filters = mutableStateOf(initialFilters)
val sortOrder = mutableStateOf(SortOrder.RECENT)
val selectedItems = mutableStateOf(emptySet<RecentFileItem>())
composeTestRule.setContent {
val pagerState = rememberPagerState(
initialPage = initialPage,
pageCount = { 3 }
)
val visibleBooks = sortFiles(
applyLibraryFilters(
filterBySearch(libraryBooks, searchQuery.value),
filters.value
),
sortOrder.value
)
MaterialTheme {
LibraryScreenContent(
tabTitles = listOf(
text(R.string.tab_all_books),
text(R.string.tab_shelves),
text(R.string.tab_folders)
),
recentFiles = visibleBooks,
rawLibraryFiles = libraryBooks,
shelves = shelves,
selectedItems = selectedItems.value,
selectedShelves = emptySet(),
sortOrder = sortOrder.value,
libraryFilters = filters.value,
allTags = listOf(focusTag),
pinnedLibraryBookIds = emptySet(),
pagerState = pagerState,
scope = rememberCoroutineScope(),
searchQuery = searchQuery.value,
isSearchActive = isSearchActive.value,
onSearchQueryChange = { searchQuery.value = it },
onSearchActiveChange = { isSearchActive.value = it },
onSortOrderChange = { sortOrder.value = it },
onFilterClick = {},
onClearFilters = { filters.value = LibraryFilters() },
onRemoveFilter = { filters.value = it },
onTagClick = onTagClick,
onPinClick = onPinClick,
onClearSelection = { selectedItems.value = emptySet() },
onItemClick = {},
onItemLongClick = { item -> selectedItems.value = setOf(item) },
onInfoClick = onInfoClick,
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<TagEntity> = emptyList()
): RecentFileItem {
return RecentFileItem(
bookId = bookId,
uriString = "content://library-test/$bookId",
type = type,
displayName = displayName,
timestamp = timestamp,
title = title,
author = author,
progressPercentage = progress,
isRecent = true,
isAvailable = true,
fileSize = timestamp * 10,
tags = tags
)
}
private fun assertBookAbove(upperBookId: String, lowerBookId: String) {
assertThat(bookTop(upperBookId)).isLessThan(bookTop(lowerBookId))
}
private fun assertNoText(value: String) {
assertThat(composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes()).isEmpty()
}
private fun bookTop(bookId: String): Float {
return composeTestRule
.onNodeWithTag("LibraryBookItem_$bookId")
.fetchSemanticsNode()
.boundsInRoot
.top
}
private fun text(resId: Int, vararg args: Any): String {
return context.getString(resId, *args)
}
}

View file

@ -1,108 +0,0 @@
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("<html><body><p>A simple Test case.</p></body></html>")
}
val chapter2File = File(testDir, "chapter2.html").apply {
writeText("<html><body><p>Another test case here.</p><p>The word Test appears twice.</p></body></html>")
}
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")
}
}

View file

@ -1,818 +0,0 @@
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<MainActivity>? = null
private lateinit var fixtureBookId: String
@Before
fun setup() {
clearReaderPrefs()
fixtureBookId = requireNotNull(
runBlocking {
FileHasher.calculateSha256 {
instrumentationContext.assets.open(fixtureAssetName)
}
}
)
runBlocking {
AppDatabase.getDatabase(targetContext)
.recentFileDao()
.deleteFilePermanently(listOf(fixtureBookId))
}
}
@After
fun tearDown() {
scenario?.close()
currentEpubFile?.let {
if (it.exists()) it.delete()
}
}
@Test
fun fixtureEpub_opensReaderAndShowsBookTitle() {
launchFixtureReader()
waitForReader()
showReaderChrome()
waitForText(fixtureBookTitle)
composeTestRule.onNodeWithText(fixtureBookTitle).assertIsDisplayed()
assertThat(hasContentDescription(text(R.string.tooltip_search))).isTrue()
assertThat(hasContentDescription(text(R.string.content_desc_chapters_menu))).isTrue()
}
@Test
fun fixtureEpub_recordsInitialReadingPosition() {
launchFixtureReader()
waitForReader()
waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
recentFile?.lastChapterIndex != null &&
recentFile.locatorBlockIndex != null &&
recentFile.locatorCharOffset != null &&
recentFile.progressPercentage != null
}
val recentFile = readFixtureRecentFile()
assertThat(recentFile?.lastChapterIndex).isAtLeast(0)
assertThat(recentFile?.locatorBlockIndex).isAtLeast(0)
assertThat(recentFile?.locatorCharOffset).isAtLeast(0)
assertThat(recentFile?.progressPercentage).isAtLeast(0f)
}
@Test
fun fixtureEpub_restoresSeededReadingPositionWithoutResettingToStart() {
launchFixtureReader { fixtureUri ->
seedFixtureRecentFile(
uriString = fixtureUri.toString(),
chapterIndex = 1,
blockIndex = 1,
charOffset = 0,
progress = 45f
)
}
waitForReader()
waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
recentFile?.lastChapterIndex == 1 &&
recentFile.locatorBlockIndex == 1 &&
recentFile.locatorCharOffset == 0 &&
(recentFile.progressPercentage ?: 0f) >= 45f
}
}
@Test
fun fixtureEpub_drawerShowsFixtureChapters() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_chapters_menu))
waitForText(text(R.string.tab_chapters))
waitForTextContaining("Chapter One")
waitForTextContaining("Chapter Two")
waitForTextContaining("Chapter Three")
}
@Test
fun fixtureEpub_drawerShowsFixtureImageCatalog() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_chapters_menu))
clickText(text(R.string.tab_images))
waitForText("Fixture diagram")
waitForTextContaining("Chapter Three")
assertThat(hasContentDescription(text(R.string.content_desc_download_image))).isTrue()
}
@Test
fun fixtureEpub_searchFindsUniqueFixtureMarker() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.tooltip_search))
waitForTag("SearchTextField")
composeTestRule.onNodeWithTag("SearchTextField").performTextInput("SEARCH_TARGET_DELTA")
composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("SEARCH_TARGET_DELTA")
waitForTag("SearchResultItem_1", timeoutMillis = 20_000)
composeTestRule.onNodeWithTag("SearchResultItem_1").assertIsDisplayed()
}
@Test
fun fixtureEpub_searchNavigationPositionSurvivesReadingModeSwitches() {
launchFixtureReader()
waitForReader()
navigateToFixtureSearchResult("SEARCH_TARGET_DELTA", expectedChapterIndex = 1)
clickContentDescription(text(R.string.tooltip_close_search))
waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
recentFile?.lastChapterIndex == 1 &&
recentFile.locatorBlockIndex != null &&
recentFile.locatorCharOffset != null &&
recentFile.progressPercentage != null
}
openOverflowMenu()
clickText(text(R.string.menu_change_reading_mode))
clickText(text(R.string.menu_reading_mode_paginated))
waitForRenderMode(RenderMode.PAGINATED)
waitForRecentFile(timeoutMillis = 20_000) { recentFile ->
recentFile?.lastChapterIndex == 1 &&
(recentFile.progressPercentage ?: 0f) > 0f
}
openOverflowMenu()
clickText(text(R.string.menu_change_reading_mode))
clickText(text(R.string.menu_reading_mode_vertical_webview))
waitForRenderMode(RenderMode.VERTICAL_SCROLL)
waitForRecentFile(timeoutMillis = 20_000) { recentFile ->
recentFile?.lastChapterIndex == 1 &&
(recentFile.progressPercentage ?: 0f) > 0f
}
}
@Test
fun fixtureEpub_addsCurrentPageBookmarkPersistsAndDeletes() {
launchFixtureReader()
waitForReader()
openOverflowMenu()
clickText(text(R.string.menu_bookmark_this_page))
waitForFixtureBookmarks(timeoutMillis = 20_000) { bookmarksJson ->
parseBookmarksJson(bookmarksJson).isNotEmpty()
}
clickReaderControl(text(R.string.content_desc_chapters_menu))
clickText(text(R.string.tab_bookmarks))
waitForTextContaining("Chapter One")
assertThat(hasContentDescription(text(R.string.content_desc_more_options_bookmark))).isTrue()
clickContentDescription(text(R.string.content_desc_more_options_bookmark))
clickText(text(R.string.action_delete))
waitForText(text(R.string.dialog_delete_bookmark))
clickText(text(R.string.action_delete))
waitForText(text(R.string.no_bookmarks_yet))
waitForFixtureBookmarks(timeoutMillis = 10_000) { bookmarksJson ->
parseBookmarksJson(bookmarksJson).isEmpty()
}
}
@Test
fun fixtureEpub_drawerShowsSeededBookmarkAndSupportsRenameDelete() {
seedFixtureBookmark()
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_chapters_menu))
clickText(text(R.string.tab_bookmarks))
waitForText("BOOKMARK_TARGET_ECHO")
waitForText("Chapter Two")
clickContentDescription(text(R.string.content_desc_more_options_bookmark))
clickText(text(R.string.action_rename))
waitForText(text(R.string.dialog_rename_bookmark))
composeTestRule.onNode(hasSetTextAction()).performTextInput("Renamed fixture bookmark")
clickText(text(R.string.action_save))
waitForText("Renamed fixture bookmark")
clickContentDescription(text(R.string.content_desc_more_options_bookmark))
clickText(text(R.string.action_delete))
waitForText(text(R.string.dialog_delete_bookmark))
clickText(text(R.string.action_delete))
waitForText(text(R.string.no_bookmarks_yet))
}
@Test
fun fixtureEpub_drawerShowsSeededAnnotationWithNoteAndFilter() {
seedFixtureHighlight()
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_chapters_menu))
clickText(text(R.string.tab_annotations))
waitForText("ANNOTATION_TARGET_GOLF")
waitForText("Fixture note survives startup")
waitForTextContaining("Chapter Three")
clickText(text(R.string.filter_with_notes))
waitForText("ANNOTATION_TARGET_GOLF")
waitForText("Fixture note survives startup")
}
@Test
fun fixtureEpub_seededAnnotationSupportsColorNoteAndDeletePersistence() {
seedFixtureHighlight()
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_chapters_menu))
clickText(text(R.string.tab_annotations))
waitForText("ANNOTATION_TARGET_GOLF")
clickContentDescription(text(R.string.content_desc_options))
composeTestRule.onNodeWithTag("HighlightColor_blue").performClick()
waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
parseHighlightsJson(highlightsJson).singleOrNull()?.color == HighlightColor.BLUE
}
clickContentDescription(text(R.string.content_desc_options))
clickText(text(R.string.menu_edit_note))
waitForText(text(R.string.action_save_note))
composeTestRule.onNode(hasSetTextAction())
.performTextClearance()
composeTestRule.onNode(hasSetTextAction())
.performTextInput("Updated fixture note")
clickText(text(R.string.action_save_note))
waitForText("Updated fixture note")
waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
parseHighlightsJson(highlightsJson).singleOrNull()?.note == "Updated fixture note"
}
clickContentDescription(text(R.string.content_desc_options))
clickText(text(R.string.action_delete))
waitForText(text(R.string.dialog_delete_highlight))
clickText(text(R.string.action_delete))
waitForText(text(R.string.no_highlights_yet))
waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
parseHighlightsJson(highlightsJson).isEmpty()
}
}
@Test
fun fixtureEpub_overflowSwitchesReadingModeAndTogglesPageOptions() {
launchFixtureReader()
waitForReader()
openOverflowMenu()
clickText(text(R.string.menu_change_reading_mode))
clickText(text(R.string.menu_reading_mode_paginated))
waitForRenderMode(RenderMode.PAGINATED)
composeTestRule.waitForIdle()
openOverflowMenu()
clickText(text(R.string.menu_tap_to_turn_pages))
assertReaderSettingEventually(
prefsName = "epub_reader_settings",
key = "tap_to_navigate_enabled",
expected = true
)
openOverflowMenu()
clickText(text(R.string.menu_realistic_page_turns))
assertReaderSettingEventually(
prefsName = "reader_prefs",
key = "page_turn_animation_enabled",
expected = true
)
openOverflowMenu()
clickText(text(R.string.menu_keep_screen_on))
assertReaderSettingEventually(
prefsName = "reader_prefs",
key = "keep_screen_on_enabled",
expected = true
)
}
@Test
fun fixtureEpub_visualOptionsSheetPersistsProgressPosition() {
launchFixtureReader()
waitForReader()
openOverflowMenu()
clickText(text(R.string.menu_visual_options))
waitForText(text(R.string.visual_options_system_ui))
waitForText(text(R.string.visual_options_progress_bar))
waitForText(text(R.string.visual_options_progress_bar_position))
clickText(text(R.string.label_top))
composeTestRule.waitUntil(timeoutMillis = 5_000) {
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.getInt("reader_page_info_position", 0) == 1
}
}
@Test
fun fixtureEpub_formatPanelShowsControlsAndPersistsLocalFontSize() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_text_formatting))
waitForText(text(R.string.section_font_alignment))
waitForText(text(R.string.section_layout_spacing))
waitForText(text(R.string.label_font_size))
waitForText(text(R.string.label_line_height))
waitForText(text(R.string.label_paragraph_gap))
waitForText(text(R.string.label_image_size))
waitForText(text(R.string.label_horizontal_margin))
waitForText(text(R.string.label_vertical_margin))
composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_select_mode))[0]
.performClick()
clickText(text(R.string.format_local))
composeTestRule.waitUntil(timeoutMillis = 5_000) {
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.getBoolean("format_is_local_$fixtureBookId", false)
}
composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_increase))[0]
.performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) {
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.getFloat("local_font_size_$fixtureBookId", 1.0f) > 1.0f
}
}
@Test
fun fixtureEpub_fontSelectionSheetPersistsFontFamily() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_text_formatting))
waitForText(text(R.string.section_font_alignment))
clickContentDescription(text(R.string.content_desc_select_font_family))
waitForText(text(R.string.select_font))
waitForText(text(R.string.tab_presets))
waitForText(text(R.string.tab_imported))
clickText("Lato")
composeTestRule.waitUntil(timeoutMillis = 5_000) {
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.getString("reader_font_family", "original") == "lato"
}
}
@Test
fun fixtureEpub_themePanelShowsThemesAndPersistsSelection() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.tooltip_theme_desc))
waitForText(text(R.string.reading_themes))
waitForText(text(R.string.theme_solid_colors))
waitForText("Light")
waitForText("Dark")
clickText("Sepia")
composeTestRule.waitUntil(timeoutMillis = 5_000) {
targetContext.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
.getString(PREF_READER_THEME, "system") == "sepia"
}
}
@Test
fun fixtureEpub_drawerShowsEmptyBookmarkAndAnnotationStates() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_chapters_menu))
clickText(text(R.string.tab_bookmarks))
waitForText(text(R.string.no_bookmarks_yet))
clickText(text(R.string.tab_annotations))
waitForText(text(R.string.no_highlights_yet))
}
@Test
fun fixtureEpub_searchCanClearAndClose() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.tooltip_search))
waitForTag("SearchTextField")
composeTestRule.onNodeWithTag("SearchTextField")
.performTextInput("SEARCH_TARGET_DELTA")
waitForTextContaining("SEARCH_TARGET_DELTA")
clickContentDescription(text(R.string.tooltip_clear_search))
waitForNoContentDescription(text(R.string.tooltip_clear_search))
clickContentDescription(text(R.string.tooltip_close_search))
composeTestRule.waitUntil(timeoutMillis = 5_000) {
hasContentDescription(text(R.string.tooltip_search))
}
}
@Test
fun fixtureEpub_dictionarySettingsShowsLookupSections() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_dictionary_settings))
waitForText(text(R.string.dict_lookup_settings))
waitForText(text(R.string.tooltip_dictionary))
waitForText(text(R.string.dict_translate))
waitForText(text(R.string.tooltip_search))
}
@Test
fun fixtureEpub_ttsReplacementSheetShowsGlobalAndBookScopes() {
launchFixtureReader()
waitForReader()
openOverflowMenu()
clickText(text(R.string.menu_tts_settings))
clickText(text(R.string.menu_tts_word_replacements))
waitForText(text(R.string.menu_tts_word_replacements))
waitForText(fixtureBookTitle)
waitForText(text(R.string.tts_replacements_tab_global))
waitForText(text(R.string.tts_replacements_tab_this_book))
waitForText(text(R.string.tts_replacements_enable))
}
private fun launchFixtureReader(beforeLaunch: (Uri) -> Unit = {}) {
val fixtureUri = copyAndroidTestAssetToCache(fixtureAssetName)
beforeLaunch(fixtureUri)
scenario = ActivityScenario.launch<MainActivity>(createEpubViewIntent(fixtureUri))
}
private fun clearReaderPrefs() {
listOf(
"epub_reader_settings",
"epub_reader_bookmarks",
"reader_prefs"
).forEach { prefsName ->
targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
}
targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
.edit()
.putString("render_mode", "VERTICAL_SCROLL")
.commit()
}
private fun text(resId: Int): String = targetContext.getString(resId)
private fun seedFixtureBookmark() {
val bookmark = org.json.JSONObject().apply {
put("cfi", "android-locator:1:1:0")
put("chapterTitle", "Chapter Two")
put("label", org.json.JSONObject.NULL)
put("snippet", "BOOKMARK_TARGET_ECHO")
put("pageInChapter", 1)
put("totalPagesInChapter", 3)
put("chapterIndex", 1)
put(
"locator",
org.json.JSONObject().apply {
put("chapterIndex", 1)
put("chapterId", "chapter-02")
put("pageIndex", 0)
put("blockIndex", 1)
put("charOffset", 0)
put("textQuote", "BOOKMARK_TARGET_ECHO")
put("cfi", "android-locator:1:1:0")
}
)
}
targetContext.getSharedPreferences("epub_reader_bookmarks", Context.MODE_PRIVATE)
.edit()
.putStringSet("bookmarks_cfi_$sanitizedFixtureBookTitle", setOf(bookmark.toString()))
.commit()
}
private fun seedFixtureHighlight() {
val highlight = UserHighlight(
id = "fixture_annotation_golf",
cfi = "android-locator:2:1:0",
text = "ANNOTATION_TARGET_GOLF",
color = HighlightColor.GREEN,
chapterIndex = 2,
note = "Fixture note survives startup",
locator = ReaderLocator(
chapterIndex = 2,
chapterId = "chapter-03",
blockIndex = 1,
charOffset = 0,
textQuote = "ANNOTATION_TARGET_GOLF",
cfi = "android-locator:2:1:0"
)
)
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.edit()
.putString("highlights_data_$sanitizedFixtureBookTitle", highlightsToJson(listOf(highlight)))
.commit()
}
private fun seedFixtureRecentFile(
uriString: String,
chapterIndex: Int,
blockIndex: Int,
charOffset: Int,
progress: Float
) {
val now = System.currentTimeMillis()
runBlocking {
AppDatabase.getDatabase(targetContext)
.recentFileDao()
.insertOrUpdateFile(
RecentFileEntity(
bookId = fixtureBookId,
uriString = uriString,
type = FileType.EPUB,
displayName = fixtureBookTitle,
timestamp = now,
coverImagePath = null,
title = fixtureBookTitle,
author = "Fixture Author",
lastChapterIndex = chapterIndex,
lastPage = null,
lastPositionCfi = "android-locator:$chapterIndex:$blockIndex:$charOffset",
progressPercentage = progress,
isRecent = true,
isAvailable = true,
lastModifiedTimestamp = now,
isDeleted = false,
locatorBlockIndex = blockIndex,
locatorCharOffset = charOffset,
bookmarks = null,
sourceFolderUri = null,
isReflowPreferred = false,
customName = null,
highlights = null,
fileSize = 0L,
fileContentModifiedTimestamp = 0L,
seriesName = null,
seriesIndex = null,
description = null,
folderTextMetadataParsed = false,
folderCoverMetadataParsed = false,
originalTitle = fixtureBookTitle,
originalAuthor = "Fixture Author",
originalSeriesName = null,
originalSeriesIndex = null,
originalDescription = null
)
)
}
}
private fun createEpubViewIntent(uri: Uri): Intent {
return Intent(targetContext, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
setDataAndType(uri, "application/epub+zip")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
private fun copyAndroidTestAssetToCache(assetName: String): Uri {
val file = File(targetContext.cacheDir, "${UUID.randomUUID()}_reader_test_book.epub")
currentEpubFile = file
instrumentationContext.assets.open(assetName).use { inputStream ->
file.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
return FileProvider.getUriForFile(
targetContext,
"${targetContext.packageName}.provider",
file
)
}
private fun navigateToFixtureSearchResult(query: String, expectedChapterIndex: Int) {
clickReaderControl(text(R.string.tooltip_search))
waitForTag("SearchTextField")
composeTestRule.onNodeWithTag("SearchTextField").performTextInput(query)
waitForTag("SearchResultItem_$expectedChapterIndex", timeoutMillis = 20_000)
composeTestRule.onNodeWithTag("SearchResultItem_$expectedChapterIndex").performClick()
}
private fun waitForReader() {
waitForTag("ReaderContainer", timeoutMillis = 30_000)
}
private fun showReaderChrome() {
if (hasAnyReaderControl()) return
composeTestRule.onRoot().performTouchInput { click(center) }
composeTestRule.waitUntil(timeoutMillis = 5_000) {
hasAnyReaderControl()
}
}
private fun clickReaderControl(contentDescription: String) {
showReaderChrome()
composeTestRule.waitUntil(timeoutMillis = 5_000) {
hasContentDescription(contentDescription)
}
composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick()
}
private fun clickContentDescription(contentDescription: String) {
composeTestRule.waitUntil(timeoutMillis = 5_000) {
hasContentDescription(contentDescription)
}
composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick()
}
private fun clickText(value: String) {
composeTestRule.waitUntil(timeoutMillis = 10_000) {
composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onAllNodesWithText(value)[0].performClick()
}
private fun openOverflowMenu() {
clickReaderControl(text(R.string.content_desc_more_options))
}
private fun hasAnyReaderControl(): Boolean {
return hasContentDescription(text(R.string.tooltip_search)) ||
hasContentDescription(text(R.string.content_desc_chapters_menu)) ||
hasContentDescription(text(R.string.content_desc_more_options))
}
private fun hasContentDescription(contentDescription: String): Boolean {
return composeTestRule
.onAllNodesWithContentDescription(contentDescription)
.fetchSemanticsNodes()
.isNotEmpty()
}
private fun waitForTag(tag: String, timeoutMillis: Long = 10_000) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
}
}
private fun waitForText(value: String, timeoutMillis: Long = 10_000) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty()
}
}
private fun waitForTextContaining(value: String, timeoutMillis: Long = 10_000) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
composeTestRule.onAllNodesWithText(value, substring = true).fetchSemanticsNodes().isNotEmpty()
}
}
private fun waitForNoContentDescription(contentDescription: String, timeoutMillis: Long = 5_000) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
composeTestRule
.onAllNodesWithContentDescription(contentDescription)
.fetchSemanticsNodes()
.isEmpty()
}
}
private fun waitForRenderMode(expected: RenderMode) {
composeTestRule.waitUntil(timeoutMillis = 20_000) {
targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
.getString("render_mode", RenderMode.VERTICAL_SCROLL.name) == expected.name
}
}
private fun assertReaderSettingEventually(
prefsName: String,
key: String,
expected: Boolean
) {
composeTestRule.waitUntil(timeoutMillis = 5_000) {
targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
.getBoolean(key, !expected) == expected
}
}
private fun readFixtureRecentFile(): RecentFileEntity? {
return runBlocking {
AppDatabase.getDatabase(targetContext)
.recentFileDao()
.getFileByBookId(fixtureBookId)
}
}
private fun waitForRecentFile(
timeoutMillis: Long = 10_000,
predicate: (RecentFileEntity?) -> Boolean
) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
predicate(readFixtureRecentFile())
}
}
private fun waitForFixtureBookmarks(
timeoutMillis: Long = 10_000,
predicate: (String?) -> Boolean
) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
predicate(readFixtureRecentFile()?.bookmarks)
}
}
private fun waitForFixtureHighlights(
timeoutMillis: Long = 10_000,
predicate: (String?) -> Boolean
) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
predicate(readFixtureRecentFile()?.highlights)
}
}
private fun parseBookmarksJson(rawJson: String?): Set<Bookmark> {
return EpubAnnotationSerializer.parseBookmarksJson(rawJson)
}
}

View file

@ -1,180 +0,0 @@
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<Int> = 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>): 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<Context>()
val existingPaginator = viewModel.paginator
viewModel.initialize(
book = EpubBook(
fileName = "test.epub",
title = "Test Book",
author = "Test Author",
language = "en",
coverImage = null
),
textMeasurer = mockk<TextMeasurer>(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<MathMLRenderer>(relaxed = true),
paragraphGapMultiplier = 1.0f
)
advanceUntilIdle()
assertThat(viewModel.paginator).isSameInstanceAs(existingPaginator)
}
}

View file

@ -1,103 +0,0 @@
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"
}
}

View file

@ -1,258 +0,0 @@
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<MainActivity>? = null
@Before
fun setup() {
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
val samplePdfUri = copyAssetToCache(context, "sample.pdf")
scenario = ActivityScenario.launch<MainActivity>(createPdfViewIntent(context, samplePdfUri))
}
@After
fun tearDown() {
scenario?.close()
currentPdfFile?.let {
if (it.exists()) it.delete()
}
}
private fun text(resId: Int, vararg args: Any): String = context.getString(resId, *args)
private fun assertNoNodeWithTag(tag: String) {
assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty()
}
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
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
}
}
}

View file

@ -1,12 +1,12 @@
package org.dueattendant149.bookreader.epubreader package com.aryan.reader.epubreader
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import org.dueattendant149.bookreader.RenderMode import com.aryan.reader.RenderMode
import org.dueattendant149.bookreader.epub.EpubBook import com.aryan.reader.epub.EpubBook
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class EpubTestActivity : ComponentActivity() { class EpubTestActivity : ComponentActivity() {
@ -31,7 +31,7 @@ class EpubTestActivity : ComponentActivity() {
coverImagePath = null, coverImagePath = null,
onRenderModeChange = {}, onRenderModeChange = {},
customFonts = TODO(), customFonts = TODO(),
onImportFonts = TODO(), viewModel = TODO() onImportFont = TODO(), viewModel = TODO()
) )
} }
} }

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader.epubreader package com.aryan.reader.epubreader
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader.ml package com.aryan.reader.ml
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.RectF import android.graphics.RectF

View file

@ -55,22 +55,7 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity>
<activity
android:name=".TemporaryExternalFileActivity"
android:exported="false"
android:theme="@style/Theme.Reader"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:excludeFromRecents="true"
android:launchMode="standard" />
<activity
android:name=".ExternalFileOpenRouterActivity"
android:exported="true"
android:theme="@style/Theme.App.Starting"
android:noHistory="true"
android:excludeFromRecents="true">
<!-- PDF --> <!-- PDF -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@ -169,19 +154,6 @@
<data android:mimeType="application/x-cb7" /> <data android:mimeType="application/x-cb7" />
</intent-filter> </intent-filter>
<!-- CBT known MIME types -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="application/x-cbt" />
<data android:mimeType="application/vnd.comicbook+tar" />
<data android:mimeType="application/x-tar" />
<data android:mimeType="application/tar" />
</intent-filter>
<!-- DOCX --> <!-- DOCX -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />

View file

@ -128,15 +128,8 @@
background-color: rgba(255, 236, 179, 0.8); background-color: rgba(255, 236, 179, 0.8);
/* Semi-transparent Gold */ /* Semi-transparent Gold */
color: black !important; 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; padding: 0.1em 0;
border-radius: 3px; border-radius: 3px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
} }
html.dark-theme span.tts-highlight { html.dark-theme span.tts-highlight {
@ -1503,16 +1496,6 @@
}; };
const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS"; 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) { window.highlightFromCfi = function (cfi, textToHighlight, startOffset) {
console.log(`$ { console.log(`$ {
@ -1573,10 +1556,9 @@
, Text content: '${(location.node.textContent || "").substring(0, 50)}...' `); , Text content: '${(location.node.textContent || "").substring(0, 50)}...' `);
const baseNode = location.node; const baseNode = location.node;
const highlightRoot = getTtsHighlightBlock(baseNode);
let remainingOffset = startOffset; let remainingOffset = startOffset;
const treeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false); const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
treeWalker.currentNode = baseNode; treeWalker.currentNode = baseNode;
let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode(); let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode();
@ -1644,7 +1626,7 @@
} else { } else {
remainingTextLength -= availableLength; remainingTextLength -= availableLength;
// Important: We need a fresh walker starting from the endNode to find the *next* text node reliably // Important: We need a fresh walker starting from the endNode to find the *next* text node reliably
const nextNodeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false); const nextNodeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
nextNodeWalker.currentNode = endNode; nextNodeWalker.currentNode = endNode;
endNode = nextNodeWalker.nextNode(); endNode = nextNodeWalker.nextNode();
endOffset = 0; // Start from the beginning of the next node endOffset = 0; // Start from the beginning of the next node
@ -1695,14 +1677,11 @@
TTS_HIGHLIGHT_LOG_TAG TTS_HIGHLIGHT_LOG_TAG
} }
: surroundContents failed, using same-block fallback. Error: $ { : surroundContents failed, using fallback. Error: $ {
e.message e.message
} }
`); `);
if (!highlightRoot.contains(range.commonAncestorContainer)) {
return "JS: Highlight range escaped current block.";
}
const contents = range.extractContents(); const contents = range.extractContents();
highlightSpan.appendChild(contents); highlightSpan.appendChild(contents);
range.insertNode(highlightSpan); range.insertNode(highlightSpan);
@ -2945,29 +2924,6 @@
}; };
const HL_LOG_TAG = "HIGHLIGHT_DEBUG"; const HL_LOG_TAG = "HIGHLIGHT_DEBUG";
const HL_RENDER_LOG_TAG = "AndroidHighlightRenderDiag";
function hlRenderPreview(value, maxLength) {
if (value === undefined || value === null) return "";
var text = String(value).replace(/\s+/g, " ");
var max = maxLength || 100;
return text.length > max ? text.substring(0, max) : text;
}
function hlRenderLocatorLabel(locator) {
locator = locator || {};
return "locatorChapter=" + (locator.chapterIndex === undefined || locator.chapterIndex === null ? "null" : locator.chapterIndex) +
" locatorPage=" + (locator.pageIndex === undefined || locator.pageIndex === null ? "null" : locator.pageIndex) +
" locatorOffsets=" + (locator.startOffset === undefined || locator.startOffset === null ? "null" : locator.startOffset) +
".." + (locator.endOffset === undefined || locator.endOffset === null ? "null" : locator.endOffset) +
" locatorBlock=" + (locator.blockIndex === undefined || locator.blockIndex === null ? "null" : locator.blockIndex) +
" locatorChar=" + (locator.charOffset === undefined || locator.charOffset === null ? "null" : locator.charOffset) +
" locatorCfi=" + hlRenderPreview(locator.cfi || "", 120);
}
function hlRenderLog(message) {
console.log(HL_RENDER_LOG_TAG + ": " + message);
}
window.HighlightBridgeHelper = { window.HighlightBridgeHelper = {
updateHighlightStyle: function (cfi, newColorClass, colorId) { updateHighlightStyle: function (cfi, newColorClass, colorId) {
@ -3324,13 +3280,11 @@
try { try {
var highlights = JSON.parse(jsonArrayString); var highlights = JSON.parse(jsonArrayString);
var self = this; var self = this;
hlRenderLog("webview_restore_start count=" + highlights.length);
highlights.forEach(function (h) { highlights.forEach(function (h) {
self.applyHighlightObject(h); self.applyHighlight(h.cfi, h.text, h.cssClass);
}); });
} catch (e) { } catch (e) {
hlRenderLog("webview_restore_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
console.log( console.log(
`$ { `$ {
HL_LOG_TAG HL_LOG_TAG
@ -3341,205 +3295,20 @@
} }
}, },
applyHighlightObject: function (highlight) { applyHighlight: function (cfi, text, cssClass) {
if (!highlight) return; try {
hlRenderLog( var alreadyApplied = false;
"webview_apply_object id=" + (highlight.id || "") + var spans = document.querySelectorAll(`span[data-cfi]`);
" cfi=" + hlRenderPreview(highlight.cfi || "", 120) + for (var i = 0; i < spans.length; i++) {
" textLen=" + String(highlight.text || "").length + if ((spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) {
" text='" + hlRenderPreview(highlight.text || "", 80) + "' " + alreadyApplied = true;
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; break;
} }
cursor = next;
} }
if (alreadyApplied) return;
if (!startNode || !endNode) { const location = window.getNodeAndOffsetFromCfi(cfi);
hlRenderLog( if (!location || !location.node) return;
"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 startNode = location.node;
let startOffset = location.offset; let startOffset = location.offset;
@ -3565,59 +3334,76 @@
} }
} }
// 1. Text Verification / Healing
if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) { if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) {
const nodeVal = startNode.nodeValue; const nodeVal = startNode.nodeValue;
// Check if text matches at exact offset
const substring = nodeVal.substring(startOffset, startOffset + text.length); const substring = nodeVal.substring(startOffset, startOffset + text.length);
// Allow for some whitespace looseness (trim comparison)
if (substring !== text && substring.trim() !== text.trim()) { if (substring !== text && substring.trim() !== text.trim()) {
hlRenderLog( console.log(`$ {
"webview_cfi_text_mismatch cfi=" + hlRenderPreview(cfi, 120) + HL_LOG_TAG
" startOffset=" + startOffset + }
" actual='" + hlRenderPreview(substring, 80) + "'" +
" expected='" + hlRenderPreview(text, 80) + "'" : Text mismatch at CFI. Searching nearby... Expected: '${text.substring(0, 10)}...', Found: '${substring.substring(0, 10)}...' `);
);
console.log("HIGHLIGHT_DEBUG: Text mismatch at CFI. Searching nearby."); // Try finding the text in the whole node
const foundIndex = nodeVal.indexOf(text); const foundIndex = nodeVal.indexOf(text);
if (foundIndex !== -1) { if (foundIndex !== -1) {
hlRenderLog( console.log(`$ {
"webview_cfi_text_adjust reason=full_match oldStartOffset=" + startOffset + HL_LOG_TAG
" newStartOffset=" + foundIndex + " cfi=" + hlRenderPreview(cfi, 120) }
);
: Found text elsewhere in node. Adjusting offset from $ {
startOffset
}
to $ {
foundIndex
}
.`);
startOffset = foundIndex; startOffset = foundIndex;
} else { } else {
// Simple fuzzy: Try finding first 20 chars
const partial = text.substring(0, Math.min(text.length, 20)); const partial = text.substring(0, Math.min(text.length, 20));
const partialIndex = nodeVal.indexOf(partial); const partialIndex = nodeVal.indexOf(partial);
if (partialIndex !== -1) { if (partialIndex !== -1) {
hlRenderLog( console.log(`$ {
"webview_cfi_text_adjust reason=partial_match oldStartOffset=" + startOffset + HL_LOG_TAG
" newStartOffset=" + partialIndex + " cfi=" + hlRenderPreview(cfi, 120) }
);
: Found partial match. Adjusting offset.`);
startOffset = partialIndex; startOffset = partialIndex;
} }
} }
} }
} }
if (!startNode) { if (!startNode) return;
hlRenderLog("webview_cfi_skip reason=missing_start_node cfi=" + hlRenderPreview(cfi, 120));
return null;
}
const range = document.createRange(); const range = document.createRange();
if (startNode.nodeType === Node.TEXT_NODE && startOffset > startNode.nodeValue.length) { // 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); startOffset = Math.max(0, startNode.nodeValue.length - 1);
} }
}
range.setStart(startNode, startOffset); range.setStart(startNode, startOffset);
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
treeWalker.currentNode = startNode; treeWalker.currentNode = startNode;
let currentNode = treeWalker.currentNode;
let remainingOffset = startOffset;
let remainingLen = text.length; let remainingLen = text.length;
let endNode = treeWalker.currentNode; let endNode = currentNode;
let endOffset = startOffset; let endOffset = startOffset;
while (remainingLen > 0 && endNode) { while (remainingLen > 0 && endNode) {
@ -3633,84 +3419,12 @@
} }
} }
if (!endNode) { if (endNode) {
hlRenderLog("webview_cfi_skip reason=missing_end_node cfi=" + hlRenderPreview(cfi, 120));
return null;
}
range.setEnd(endNode, endOffset); 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 (cfi && (spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) {
alreadyApplied = true;
break;
}
}
if (alreadyApplied) {
hlRenderLog(
"webview_apply_skip reason=already_applied cfi=" + hlRenderPreview(cfi || "", 120) +
" textLen=" + String(text || "").length + " " + hlRenderLocatorLabel(locator)
);
return;
}
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 (!range) {
rangeSource = "locator";
range = this.rangeFromLocator(locator, text);
}
if (!range && !hasPreciseLocator && !hasSourceCfi) {
rangeSource = "text_search";
range = this.rangeFromVisibleTextSearch(text || "", locator);
}
if (!range) {
hlRenderLog(
"webview_apply_skip reason=no_range cfi=" + hlRenderPreview(cfi || "", 120) +
" hasPreciseLocator=" + !!hasPreciseLocator + " " + hlRenderLocatorLabel(locator)
);
return;
}
var normalizedRange = this.normalizeRangeBoundaries(range); var normalizedRange = this.normalizeRangeBoundaries(range);
this.highlightRangeSafe(normalizedRange, cssClass, cfi); this.highlightRangeSafe(normalizedRange, cssClass, cfi);
hlRenderLog( }
"webview_apply_result applied=true cfi=" + hlRenderPreview(cfi || "", 120) +
" source=" + rangeSource +
" renderedText='" + hlRenderPreview(normalizedRange.toString(), 80) + "'"
);
} catch (e) { } catch (e) {
hlRenderLog("webview_apply_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
console.log(e); console.log(e);
} }
}, },

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column

View file

@ -1,8 +1,8 @@
package org.dueattendant149.bookreader package com.aryan.reader
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import org.dueattendant149.bookreader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import timber.log.Timber import timber.log.Timber
class AndroidFolderPathResolver : FolderPathResolver { class AndroidFolderPathResolver : FolderPathResolver {

View file

@ -1,8 +1,8 @@
package org.dueattendant149.bookreader package com.aryan.reader
import org.dueattendant149.bookreader.shared.SharedFeaturePolicy import com.aryan.reader.shared.SharedFeaturePolicy
import org.dueattendant149.bookreader.shared.SharedSettingsHubInput import com.aryan.reader.shared.SharedSettingsHubInput
import org.dueattendant149.bookreader.shared.SharedSettingsPlatform import com.aryan.reader.shared.SharedSettingsPlatform
fun androidSettingsHubInput( fun androidSettingsHubInput(
uiState: ReaderScreenState, uiState: ReaderScreenState,
@ -15,8 +15,6 @@ fun androidSettingsHubInput(
val supportsOssAiKeys = isOssBuild && !isOfflineBuild val supportsOssAiKeys = isOssBuild && !isOfflineBuild
val featurePolicy = if (isOfflineBuild) { val featurePolicy = if (isOfflineBuild) {
SharedFeaturePolicy.OssOffline SharedFeaturePolicy.OssOffline
} else if (isOssBuild) {
SharedFeaturePolicy.OssOnline
} else { } else {
SharedFeaturePolicy.Standard SharedFeaturePolicy.Standard
} }

View file

@ -1,14 +1,14 @@
package org.dueattendant149.bookreader package com.aryan.reader
import org.dueattendant149.bookreader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import org.dueattendant149.bookreader.data.TagEntity import com.aryan.reader.data.TagEntity
import org.dueattendant149.bookreader.shared.AppAction as SharedAppAction import com.aryan.reader.shared.AppAction as SharedAppAction
import org.dueattendant149.bookreader.shared.LibraryAction as SharedLibraryAction import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
import org.dueattendant149.bookreader.shared.SharedFolderPathResolver import com.aryan.reader.shared.SharedFolderPathResolver
import org.dueattendant149.bookreader.shared.SharedLibraryProjectionInput import com.aryan.reader.shared.SharedLibraryProjectionInput
import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector import com.aryan.reader.shared.SharedLibraryStateProjector
import org.dueattendant149.bookreader.shared.SharedReaderScreenState import com.aryan.reader.shared.SharedReaderScreenState
import org.dueattendant149.bookreader.shared.reduce import com.aryan.reader.shared.reduce
internal object AndroidSharedStateBridge { internal object AndroidSharedStateBridge {
fun prepareLibraryProjection( fun prepareLibraryProjection(
@ -23,8 +23,7 @@ internal object AndroidSharedStateBridge {
val sharedInput = SharedLibraryProjectionInput( val sharedInput = SharedLibraryProjectionInput(
state = projectionState.toSharedReaderScreenState( state = projectionState.toSharedReaderScreenState(
rawBooks = taggedBooks, rawBooks = taggedBooks,
dbTags = input.dbTags, dbTags = input.dbTags
includeReaderAnnotations = false
), ),
booksFromStore = taggedBooks booksFromStore = taggedBooks
.filterNot { it.bookId.endsWith("_reflow") } .filterNot { it.bookId.endsWith("_reflow") }
@ -196,8 +195,7 @@ internal object AndroidSharedStateBridge {
private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState { private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState {
return toSharedReaderScreenState( return toSharedReaderScreenState(
rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles }, rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles },
dbTags = projectedState.allTags.ifEmpty { allTags }, dbTags = projectedState.allTags.ifEmpty { allTags }
includeReaderAnnotations = false
) )
} }

View file

@ -1,8 +1,8 @@
package org.dueattendant149.bookreader package com.aryan.reader
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import org.dueattendant149.bookreader.data.CustomFontEntity import com.aryan.reader.data.CustomFontEntity
import java.io.File import java.io.File
fun AppFontPreference.toAndroidAppFontFamily(customFonts: List<CustomFontEntity>): FontFamily? { fun AppFontPreference.toAndroidAppFontFamily(customFonts: List<CustomFontEntity>): FontFamily? {

View file

@ -17,11 +17,10 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
package org.dueattendant149.bookreader package com.aryan.reader
import android.os.Build import android.os.Build
import timber.log.Timber import timber.log.Timber
import androidx.activity.compose.BackHandler
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@ -59,14 +58,14 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import org.dueattendant149.bookreader.epubreader.EpubReaderScreen import com.aryan.reader.epubreader.EpubReaderScreen
import org.dueattendant149.bookreader.feedback.FeedbackScreen import com.aryan.reader.feedback.FeedbackScreen
import org.dueattendant149.bookreader.feedback.SupportProjectScreen import com.aryan.reader.feedback.SupportProjectScreen
import org.dueattendant149.bookreader.pdf.PdfViewerScreen import com.aryan.reader.pdf.PdfViewerScreen
import org.dueattendant149.bookreader.shared.ReaderFeatureSurface import com.aryan.reader.shared.ReaderFeatureSurface
import org.dueattendant149.bookreader.tts.ReaderTtsMiniBar import com.aryan.reader.tts.ReaderTtsMiniBar
import org.dueattendant149.bookreader.tts.readerTtsMiniBarBottomPaddingDp import com.aryan.reader.tts.readerTtsMiniBarBottomPaddingDp
import org.dueattendant149.bookreader.tts.shouldShowReaderTtsMiniBar import com.aryan.reader.tts.shouldShowReaderTtsMiniBar
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
object AppDestinations { object AppDestinations {
@ -81,18 +80,6 @@ object AppDestinations {
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route" const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
} }
fun shouldInterceptAppNavBack(
currentRoute: String?,
hasPreviousBackStackEntry: Boolean,
isCurrentEntryResumed: Boolean
): Boolean {
if (!hasPreviousBackStackEntry || !isCurrentEntryResumed) return false
return currentRoute != null &&
currentRoute != AppDestinations.MAIN_ROUTE &&
currentRoute != AppDestinations.PDF_VIEWER_ROUTE &&
currentRoute != AppDestinations.EPUB_READER_ROUTE
}
private fun NavHostController.isReadyForBackStackChange(): Boolean { private fun NavHostController.isReadyForBackStackChange(): Boolean {
return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
} }
@ -179,11 +166,6 @@ fun AppNavigation(
val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp( val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp(
isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE
).dp ).dp
val shouldInterceptBack = shouldInterceptAppNavBack(
currentRoute = currentRoute,
hasPreviousBackStackEntry = navController.previousBackStackEntry != null,
isCurrentEntryResumed = currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
)
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) { if (!uiState.isLoading) {
@ -213,25 +195,15 @@ fun AppNavigation(
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
BackHandler(enabled = shouldInterceptBack) {
navController.popBackStackIfReady()
}
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) { NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
composable(AppDestinations.MAIN_ROUTE) { composable(AppDestinations.MAIN_ROUTE) {
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).") Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
if (uiState.isTemporaryExternalOpen) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
MainScreen( MainScreen(
viewModel = viewModel, viewModel = viewModel,
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,
navController = navController navController = navController
) )
} }
}
// PDF Viewer Screen Composable // PDF Viewer Screen Composable
composable(route = AppDestinations.PDF_VIEWER_ROUTE) { composable(route = AppDestinations.PDF_VIEWER_ROUTE) {
@ -343,7 +315,7 @@ fun AppNavigation(
}, },
onRenderModeChange = viewModel::setRenderMode, onRenderModeChange = viewModel::setRenderMode,
customFonts = customFonts, customFonts = customFonts,
onImportFonts = viewModel::importFonts, onImportFont = viewModel::importFont,
viewModel = viewModel viewModel = viewModel
) )

View file

@ -1,20 +1,20 @@
package org.dueattendant149.bookreader package com.aryan.reader
import android.net.Uri import android.net.Uri
import org.dueattendant149.bookreader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import org.dueattendant149.bookreader.data.TagEntity import com.aryan.reader.data.TagEntity
import org.dueattendant149.bookreader.epub.CalibreBundleResult import com.aryan.reader.epub.CalibreBundleResult
import org.dueattendant149.bookreader.epub.EpubBook import com.aryan.reader.epub.EpubBook
import org.dueattendant149.bookreader.paginatedreader.Locator import com.aryan.reader.paginatedreader.Locator
import java.util.Date import java.util.Date
typealias BannerMessage = org.dueattendant149.bookreader.shared.BannerMessage typealias BannerMessage = com.aryan.reader.shared.BannerMessage
typealias UserData = org.dueattendant149.bookreader.shared.UserData typealias UserData = com.aryan.reader.shared.UserData
typealias AppThemeMode = org.dueattendant149.bookreader.shared.AppThemeMode typealias AppThemeMode = com.aryan.reader.shared.AppThemeMode
typealias AppContrastOption = org.dueattendant149.bookreader.shared.AppContrastOption typealias AppContrastOption = com.aryan.reader.shared.AppContrastOption
typealias AppFontPreference = org.dueattendant149.bookreader.shared.AppFontPreference typealias AppFontPreference = com.aryan.reader.shared.AppFontPreference
typealias AppFontPreferenceKind = org.dueattendant149.bookreader.shared.AppFontPreferenceKind typealias AppFontPreferenceKind = com.aryan.reader.shared.AppFontPreferenceKind
typealias CustomAppTheme = org.dueattendant149.bookreader.shared.CustomAppTheme typealias CustomAppTheme = com.aryan.reader.shared.CustomAppTheme
data class ImportResult( data class ImportResult(
val internalUri: Uri, val internalUri: Uri,
@ -43,7 +43,6 @@ data class ReaderScreenState(
val selectedEpubUri: Uri? = null, val selectedEpubUri: Uri? = null,
val selectedFileType: FileType? = null, val selectedFileType: FileType? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isTemporaryExternalOpen: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val contextualActionItems: Set<RecentFileItem> = emptySet(), val contextualActionItems: Set<RecentFileItem> = emptySet(),
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL, val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,

View file

@ -17,7 +17,7 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
package org.dueattendant149.bookreader package com.aryan.reader
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
@ -29,7 +29,7 @@ import java.io.FileOutputStream
import java.io.InputStream import java.io.InputStream
import java.util.UUID import java.util.UUID
import androidx.core.net.toUri import androidx.core.net.toUri
import org.dueattendant149.bookreader.shared.SharedFileCapabilities import com.aryan.reader.shared.SharedFileCapabilities
private const val BOOKS_DIR = "books" private const val BOOKS_DIR = "books"

View file

@ -1,7 +1,7 @@
// Common.kt // Common.kt
@file:OptIn(ExperimentalMaterial3Api::class) @file:Suppress("KotlinConstantConditions") @file:OptIn(ExperimentalMaterial3Api::class) @file:Suppress("KotlinConstantConditions")
package org.dueattendant149.bookreader package com.aryan.reader
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
@ -138,7 +138,6 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@ -166,23 +165,23 @@ import androidx.compose.ui.window.PopupProperties
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import org.dueattendant149.bookreader.epubreader.PREF_CUSTOM_THEMES import com.aryan.reader.epubreader.PREF_CUSTOM_THEMES
import org.dueattendant149.bookreader.epubreader.PREF_READER_THEME import com.aryan.reader.epubreader.PREF_READER_THEME
import org.dueattendant149.bookreader.paginatedreader.TtsChunk import com.aryan.reader.paginatedreader.TtsChunk
import org.dueattendant149.bookreader.pdf.PdfHighlightColor import com.aryan.reader.pdf.PdfHighlightColor
import org.dueattendant149.bookreader.shared.BuiltInReaderThemes import com.aryan.reader.shared.BuiltInReaderThemes
import org.dueattendant149.bookreader.shared.ReaderTextureFilePrefix import com.aryan.reader.shared.ReaderTextureFilePrefix
import org.dueattendant149.bookreader.shared.normalizeReaderTextureExtension import com.aryan.reader.shared.normalizeReaderTextureExtension
import org.dueattendant149.bookreader.shared.readerTextureDisplayName as sharedReaderTextureDisplayName import com.aryan.reader.shared.readerTextureDisplayName as sharedReaderTextureDisplayName
import org.dueattendant149.bookreader.shared.readerTextureMimeTypeForExtension import com.aryan.reader.shared.readerTextureMimeTypeForExtension
import org.dueattendant149.bookreader.tts.GEMINI_TTS_SPEAKERS import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
import org.dueattendant149.bookreader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.SpeakerSamplePlayer
import org.dueattendant149.bookreader.tts.TtsCacheManager import com.aryan.reader.tts.TtsCacheManager
import org.dueattendant149.bookreader.tts.TtsPlaybackManager import com.aryan.reader.tts.TtsPlaybackManager
import org.dueattendant149.bookreader.tts.formatBytes import com.aryan.reader.tts.formatBytes
import org.dueattendant149.bookreader.tts.loadTtsMode import com.aryan.reader.tts.loadTtsMode
import org.dueattendant149.bookreader.tts.rememberTtsController import com.aryan.reader.tts.rememberTtsController
import org.dueattendant149.bookreader.tts.splitTextIntoChunks import com.aryan.reader.tts.splitTextIntoChunks
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -218,8 +217,8 @@ import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sqrt import kotlin.math.sqrt
typealias ReaderTexture = org.dueattendant149.bookreader.shared.ReaderTexture typealias ReaderTexture = com.aryan.reader.shared.ReaderTexture
typealias ReaderTheme = org.dueattendant149.bookreader.shared.ReaderTheme typealias ReaderTheme = com.aryan.reader.shared.ReaderTheme
const val aiServerBasePath = BuildConfig.AI_WORKER_URL const val aiServerBasePath = BuildConfig.AI_WORKER_URL
const val summarizeEndpoint = "/summarize" const val summarizeEndpoint = "/summarize"
@ -474,9 +473,9 @@ data class SearchResult(
val chunkIndex: Int val chunkIndex: Int
) )
typealias AiDefinitionResult = org.dueattendant149.bookreader.shared.AiDefinitionResult typealias AiDefinitionResult = com.aryan.reader.shared.AiDefinitionResult
typealias SummarizationResult = org.dueattendant149.bookreader.shared.SummarizationResult typealias SummarizationResult = com.aryan.reader.shared.SummarizationResult
data class CachedSummaryItem( data class CachedSummaryItem(
val chapterIndex: Int, val chapterIndex: Int,
@ -863,13 +862,6 @@ fun AiDefinitionPopup(
val ttsController = rememberTtsController() val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState() val ttsState by ttsController.ttsState.collectAsState()
val context = LocalContext.current 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 @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -890,7 +882,7 @@ fun AiDefinitionPopup(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 5.dp) .padding(horizontal = 16.dp, vertical = 5.dp)
.heightIn(max = maxPopupHeight), .heightIn(min = 150.dp, max = 400.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) { ) {
@ -2957,14 +2949,7 @@ private fun ThemeGridItem(
Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
text = theme.name,
style = MaterialTheme.typography.labelSmall,
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable { onThemeSelected(theme.id) }
)
if (theme.isCustom && onEdit != null && onDelete != null) { if (theme.isCustom && onEdit != null && onDelete != null) {
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
@ -3318,16 +3303,12 @@ fun ThemeColorPickerDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false) properties = DialogProperties(usePlatformDefaultWidth = false)
) { ) {
val configuration = LocalConfiguration.current
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
Surface( Surface(
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C), color = Color(0xFF2C2C2C),
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.9f) .fillMaxWidth(0.9f)
.padding(16.dp) .padding(16.dp)
.heightIn(max = maxDialogHeight)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -3507,16 +3488,12 @@ fun HighlightColorPickerDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false) properties = DialogProperties(usePlatformDefaultWidth = false)
) { ) {
val configuration = LocalConfiguration.current
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
Surface( Surface(
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C), color = Color(0xFF2C2C2C),
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.9f) .fillMaxWidth(0.9f)
.padding(16.dp) .padding(16.dp)
.heightIn(max = maxDialogHeight)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -3845,7 +3822,7 @@ fun AiResultContentView(
result: SummarizationResult?, result: SummarizationResult?,
isLoading: Boolean, isLoading: Boolean,
isMainTtsActive: Boolean, isMainTtsActive: Boolean,
ttsController: org.dueattendant149.bookreader.tts.TtsController, ttsController: com.aryan.reader.tts.TtsController,
ttsState: TtsPlaybackManager.TtsState, ttsState: TtsPlaybackManager.TtsState,
getAuthToken: suspend () -> String?, getAuthToken: suspend () -> String?,
onRegenerate: (() -> Unit)? = null, onRegenerate: (() -> Unit)? = null,

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import android.util.Xml import android.util.Xml
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser

View file

@ -1,14 +1,14 @@
package org.dueattendant149.bookreader package com.aryan.reader
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import org.dueattendant149.bookreader.data.BookMetadataEdit import com.aryan.reader.data.BookMetadataEdit
import org.dueattendant149.bookreader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataEditor import com.aryan.reader.shared.reader.SharedEpubMetadataEditor
import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataSnapshot import com.aryan.reader.shared.reader.SharedEpubMetadataSnapshot
import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataUpdate import com.aryan.reader.shared.reader.SharedEpubMetadataUpdate
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File

View file

@ -17,11 +17,10 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
package org.dueattendant149.bookreader package com.aryan.reader
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.InputStream import java.io.InputStream
import java.security.MessageDigest import java.security.MessageDigest
@ -51,7 +50,8 @@ object FileHasher {
} }
hexString.toString() hexString.toString()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to calculate SHA-256 hash") // In a real app, you'd want to log this error
e.printStackTrace()
null null
} }
} }

View file

@ -1,6 +1,6 @@
package org.dueattendant149.bookreader package com.aryan.reader
import org.dueattendant149.bookreader.shared.SharedFileCapabilities import com.aryan.reader.shared.SharedFileCapabilities
internal fun resolveFileTypeFromName(fileName: String?): FileType? { internal fun resolveFileTypeFromName(fileName: String?): FileType? {
return SharedFileCapabilities.resolveFileTypeForName(fileName) return SharedFileCapabilities.resolveFileTypeForName(fileName)

View file

@ -18,7 +18,7 @@
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
// FolderSyncWorker.kt // FolderSyncWorker.kt
package org.dueattendant149.bookreader package com.aryan.reader
import android.content.Context import android.content.Context
import timber.log.Timber import timber.log.Timber
@ -29,25 +29,25 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.WorkManager import androidx.work.WorkManager
import org.dueattendant149.bookreader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import org.dueattendant149.bookreader.data.RecentFilesRepository import com.aryan.reader.data.RecentFilesRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.core.content.edit import androidx.core.content.edit
import org.dueattendant149.bookreader.data.LocalSyncUtils import com.aryan.reader.data.LocalSyncUtils
import org.dueattendant149.bookreader.data.FolderBookMetadata import com.aryan.reader.data.FolderBookMetadata
import org.dueattendant149.bookreader.data.toSharedFolderBookMetadata import com.aryan.reader.data.toSharedFolderBookMetadata
import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem import com.aryan.reader.shared.BookItem as SharedBookItem
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer import com.aryan.reader.shared.EpubAnnotationSerializer
import org.dueattendant149.bookreader.shared.EpubBookmark import com.aryan.reader.shared.EpubBookmark
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
import org.dueattendant149.bookreader.shared.LocalFolderSyncEngine import com.aryan.reader.shared.LocalFolderSyncEngine
import org.dueattendant149.bookreader.shared.ReaderLocator import com.aryan.reader.shared.ReaderLocator
import org.dueattendant149.bookreader.shared.SharedFolderScannedFile import com.aryan.reader.shared.SharedFolderScannedFile
import org.dueattendant149.bookreader.shared.SharedReaderScreenState import com.aryan.reader.shared.SharedReaderScreenState
import org.dueattendant149.bookreader.shared.reader.ReaderBookmark import com.aryan.reader.shared.reader.ReaderBookmark
import java.io.File import java.io.File
import android.provider.DocumentsContract import android.provider.DocumentsContract
@ -72,27 +72,45 @@ class FolderSyncWorker(
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI) val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null) val jsonString = prefs.getString("synced_folders_list_json", null)
val folders = SyncedFolderPrefs.decodeSyncedFolders( val folders = mutableListOf<Pair<String, Set<FileType>>>()
jsonString = jsonString,
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null), if (jsonString != null) {
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES 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<FileType>()
if (obj.has("allowedFileTypes")) {
val typesArray = obj.getJSONArray("allowedFileTypes")
for (j in 0 until typesArray.length()) {
try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
}
} else {
allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES)
}
folders.add(Pair(uri, allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }))
}
} catch (e: Exception) { Timber.e(e) }
} else {
val single = prefs.getString("synced_folder_uri", null)
if (single != null) folders.add(Pair(single, ANDROID_SYNCABLE_FILE_TYPES))
}
if (folders.isEmpty()) { if (folders.isEmpty()) {
ReaderPerfLog.w("FolderSync worker aborted: no linked folders") ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
return Result.success() return Result.success()
} }
val enabledFolders = folders.filter { it.localSyncEnabled }
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) { val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
enabledFolders folders
} else { } else {
enabledFolders.filter { it.uriString == targetFolderUri } folders.filter { it.first == targetFolderUri }
} }
if (foldersToProcess.isEmpty()) { if (foldersToProcess.isEmpty()) {
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri") ReaderPerfLog.w("FolderSync worker aborted: target folder not linked target=$targetFolderUri")
return Result.success() return Result.success()
} }
@ -105,8 +123,8 @@ class FolderSyncWorker(
syncMutex.withLock { syncMutex.withLock {
var allSuccess = true var allSuccess = true
for (folderConfig in foldersToProcess) { for ((uriString, allowedTypes) in foldersToProcess) {
val success = performSyncForFolder(folderConfig, isMetadataOnly) val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
if (!success) allSuccess = false if (!success) allSuccess = false
} }
@ -114,14 +132,13 @@ class FolderSyncWorker(
try { try {
val array = org.json.JSONArray(jsonString) val array = org.json.JSONArray(jsonString)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString }
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
val obj = array.getJSONObject(i) val obj = array.getJSONObject(i)
if (obj.optString("uri") in processedUris) { if (targetFolderUri.isNullOrBlank() || obj.optString("uri") == targetFolderUri) {
obj.put("lastScanTime", now) obj.put("lastScanTime", now)
} }
} }
prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) } prefs.edit { putString("synced_folders_list_json", array.toString()) }
} catch (_: Exception) {} } catch (_: Exception) {}
} }
@ -136,9 +153,7 @@ class FolderSyncWorker(
} }
} }
private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean { private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean {
val folderUriString = folderConfig.uriString
val allowedFileTypes = folderConfig.allowedFileTypes
if (folderUriString.isBlank()) return true if (folderUriString.isBlank()) return true
val folderUri = folderUriString.toUri() val folderUri = folderUriString.toUri()
val folderStart = ReaderPerfLog.nowNanos() val folderStart = ReaderPerfLog.nowNanos()
@ -220,10 +235,9 @@ class FolderSyncWorker(
val nowMillis = System.currentTimeMillis() val nowMillis = System.currentTimeMillis()
val folder = SyncedFolder( val folder = SyncedFolder(
uriString = folderUriString, uriString = folderUriString,
name = documentTree.name ?: folderConfig.name, name = documentTree.name ?: "Local Folder",
lastScanTime = nowMillis, lastScanTime = nowMillis,
allowedFileTypes = allowedFileTypes, allowedFileTypes = allowedFileTypes
localSyncEnabled = true
) )
val sharedState = SharedReaderScreenState( val sharedState = SharedReaderScreenState(
rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() }, rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
@ -550,8 +564,7 @@ class FolderSyncWorker(
lastPageIndex = lastPage, lastPageIndex = lastPage,
readerPosition = readerPositionOrNull(), readerPosition = readerPositionOrNull(),
readerBookmarks = parseReaderBookmarks(), readerBookmarks = parseReaderBookmarks(),
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson), readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
) )
} }
@ -600,8 +613,8 @@ class FolderSyncWorker(
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex, lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage, lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi, lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex, locatorBlockIndex = appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset, locatorCharOffset = appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
progressPercentage = progressPercentage, progressPercentage = progressPercentage,
isRecent = isRecent, isRecent = isRecent,
isAvailable = true, isAvailable = true,
@ -624,7 +637,6 @@ class FolderSyncWorker(
originalDescription = originalDescription, originalDescription = originalDescription,
folderTextMetadataParsed = folderTextMetadataParsed, folderTextMetadataParsed = folderTextMetadataParsed,
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false, folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
tags = existing?.tags.orEmpty() tags = existing?.tags.orEmpty()
) )
} }
@ -708,12 +720,18 @@ class FolderSyncWorker(
private fun isFolderStillLinked(folderUriString: String): Boolean { private fun isFolderStillLinked(folderUriString: String): Boolean {
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
return SyncedFolderPrefs.isLocalSyncEnabled( val jsonString = prefs.getString("synced_folders_list_json", null)
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null), if (jsonString != null) {
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null), return try {
folderUriString = folderUriString, val array = org.json.JSONArray(jsonString)
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES (0 until array.length()).any { index ->
) array.getJSONObject(index).optString("uri") == folderUriString
}
} catch (_: Exception) {
false
}
}
return prefs.getString("synced_folder_uri", null) == folderUriString
} }
private fun getFileType(name: String, mimeType: String?): FileType? { private fun getFileType(name: String, mimeType: String?): FileType? {

View file

@ -1,13 +1,10 @@
// FontsScreen.kt // FontsScreen.kt
@file:Suppress("KotlinConstantConditions") @file:Suppress("KotlinConstantConditions")
package org.dueattendant149.bookreader package com.aryan.reader
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -30,12 +27,10 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
@ -51,7 +46,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -64,21 +58,14 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookreader.shared.CustomFontItem import com.aryan.reader.shared.CustomFontItem
import org.dueattendant149.bookreader.shared.CustomFontFamilyItem import com.aryan.reader.shared.ui.SharedAppFontSelector
import org.dueattendant149.bookreader.shared.CustomFontVariantItem import com.aryan.reader.shared.ui.SharedFontSettingsSection
import org.dueattendant149.bookreader.shared.fontFaceLabel import com.aryan.reader.shared.ui.SharedFontSettingsTabs
import org.dueattendant149.bookreader.shared.fontFaceSummary import com.aryan.reader.data.CustomFontEntity
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 import java.io.File
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -93,68 +80,37 @@ fun FontsScreen(
val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE)
var fontsPendingDelete by remember { mutableStateOf<List<CustomFontEntity>>(emptyList()) } // Dialog state
var showDeleteDialog by remember { mutableStateOf(false) }
var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) }
var showGoogleFontsSheet by remember { mutableStateOf(false) } var showGoogleFontsSheet by remember { mutableStateOf(false) }
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) } var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
var selectedFontIds by remember { mutableStateOf<Set<String>>(emptySet()) }
val pickFontLauncher = rememberFilePickerLauncher(viewModel::importFonts) val pickFontLauncher = rememberFilePickerLauncher { uris ->
val fontMimeTypes = remember { supportedFontMimeTypes() } uris.firstOrNull()?.let { viewModel.importFont(it) }
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)
} }
BackHandler(enabled = isFontSelectionMode) { val fontMimeTypes = arrayOf(
selectedFontIds = emptySet() "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"
)
Scaffold( Scaffold(
modifier = Modifier.statusBarsPadding(), modifier = Modifier.statusBarsPadding(),
topBar = { topBar = {
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( CustomTopAppBar(
title = { Text(stringResource(R.string.custom_fonts)) }, title = { Text(stringResource(R.string.custom_fonts)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBackClick) { IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
} }
},
actions = {
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
IconButton(onClick = { selectedFontIds = allFontIds }) {
Icon(Icons.Default.SelectAll, contentDescription = stringResource(R.string.select_all))
}
}
} }
) )
}
}, },
floatingActionButton = { floatingActionButton = {
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty() && !isFontSelectionMode) { if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
Column( Column(
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
@ -180,14 +136,10 @@ fun FontsScreen(
) { padding -> ) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) { Box(modifier = Modifier.fillMaxSize().padding(padding)) {
val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() } val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
val fontFamilies = remember(sharedFonts) { sharedFonts.groupByFamily() }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
SharedFontSettingsTabs( SharedFontSettingsTabs(
selectedSection = selectedSection, selectedSection = selectedSection,
onSectionChange = { onSectionChange = { selectedSection = it },
selectedFontIds = emptySet()
selectedSection = it
},
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp) modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
) )
@ -211,20 +163,12 @@ fun FontsScreen(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items(fontFamilies, key = { family -> family.variants.joinToString("|") { it.font.id } }) { family -> items(fonts, key = { it.id }) { font ->
FontFamilyListItem( FontListItem(
family = family, font = font,
selectedFontIds = selectedFontIds, onDelete = {
isSelectionMode = isFontSelectionMode, fontToDelete = font
fontEntityForId = { id -> fontEntitiesById[id] }, showDeleteDialog = true
onVariantSelectionToggle = { id ->
selectedFontIds = selectedFontIds.toggle(id)
},
onFamilySelectionToggle = {
selectedFontIds = selectedFontIds.toggleAll(family.variants.map { it.font.id })
},
onDeleteVariant = { id ->
fontEntitiesById[id]?.let { fontsPendingDelete = listOf(it) }
} }
) )
} }
@ -264,17 +208,17 @@ fun FontsScreen(
} }
} }
if (fontsPendingDelete.isNotEmpty()) { if (showDeleteDialog && fontToDelete != null) {
DeleteFontsConfirmationDialog( DeleteFontConfirmationDialog(
fonts = fontsPendingDelete, fontName = fontToDelete!!.displayName,
onConfirm = { onConfirm = {
val pendingIds = fontsPendingDelete.map { it.id } fontToDelete?.let { viewModel.deleteFont(it.id) }
viewModel.deleteFonts(pendingIds) showDeleteDialog = false
selectedFontIds = selectedFontIds - pendingIds.toSet() fontToDelete = null
fontsPendingDelete = emptyList()
}, },
onDismiss = { onDismiss = {
fontsPendingDelete = emptyList() showDeleteDialog = false
fontToDelete = null
} }
) )
} }
@ -444,13 +388,9 @@ fun GoogleFontsBottomSheet(
} }
// Existing unchanged components // Existing unchanged components
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun FontListItem( fun FontListItem(
font: CustomFontEntity, font: CustomFontEntity,
isSelected: Boolean,
isSelectionMode: Boolean,
onSelectionToggle: () -> Unit,
onDelete: () -> Unit onDelete: () -> Unit
) { ) {
val customTypeface = remember(font.path) { val customTypeface = remember(font.path) {
@ -462,23 +402,8 @@ fun FontListItem(
} }
Card( Card(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
.combinedClickable(
onClick = {
if (isSelectionMode) {
onSelectionToggle()
}
},
onLongClick = onSelectionToggle
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
} else {
MaterialTheme.colorScheme.surface
}
)
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Row( Row(
@ -486,20 +411,11 @@ fun FontListItem(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (isSelectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { onSelectionToggle() },
modifier = Modifier.padding(end = 8.dp)
)
}
Text( Text(
text = font.displayName, text = font.displayName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold
modifier = Modifier.weight(1f)
) )
if (!isSelectionMode) {
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) { IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
Icon( Icon(
Icons.Default.Delete, Icons.Default.Delete,
@ -508,7 +424,6 @@ fun FontListItem(
) )
} }
} }
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
@ -544,188 +459,6 @@ fun FontListItem(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FontFamilyListItem(
family: CustomFontFamilyItem,
selectedFontIds: Set<String>,
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<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> { private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
return filterNot { it.isDeleted } return filterNot { it.isDeleted }
.sortedBy { it.displayName.lowercase() } .sortedBy { it.displayName.lowercase() }
@ -743,32 +476,15 @@ private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontIte
} }
@Composable @Composable
fun DeleteFontsConfirmationDialog( fun DeleteFontConfirmationDialog(
fonts: List<CustomFontEntity>, fontName: String,
onConfirm: () -> Unit, onConfirm: () -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val isSingleFont = fonts.size == 1
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { title = { Text(stringResource(R.string.dialog_delete_font)) },
Text( text = { Text(stringResource(R.string.dialog_delete_font_desc, fontName)) },
if (isSingleFont) {
stringResource(R.string.dialog_delete_font)
} else {
stringResource(R.string.dialog_delete_fonts)
}
)
},
text = {
Text(
if (isSingleFont) {
stringResource(R.string.dialog_delete_font_desc, fonts.first().displayName)
} else {
stringResource(R.string.dialog_delete_fonts_desc, fonts.size)
}
)
},
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = onConfirm, onClick = onConfirm,
@ -782,12 +498,3 @@ fun DeleteFontsConfirmationDialog(
} }
) )
} }
private fun Set<String>.toggle(id: String): Set<String> {
return if (id in this) this - id else this + id
}
private fun Set<String>.toggleAll(ids: List<String>): Set<String> {
val idSet = ids.toSet()
return if (containsAll(idSet)) this - idSet else this + idSet
}

View file

@ -20,7 +20,7 @@
// HomeScreen // HomeScreen
@file:Suppress("DEPRECATION") @file:Suppress("DEPRECATION")
package org.dueattendant149.bookreader package com.aryan.reader
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
@ -130,7 +130,6 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -139,13 +138,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import org.dueattendant149.bookreader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -194,7 +192,6 @@ fun HomeScreen(
var showAboutDialog by remember { mutableStateOf(false) } var showAboutDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) } var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
var showBehaviorDialog by remember { mutableStateOf(false) } var showBehaviorDialog by remember { mutableStateOf(false) }
var showStrictFilterDialog by remember { mutableStateOf(false) } var showStrictFilterDialog by remember { mutableStateOf(false) }
var showClearBookCacheDialog by remember { mutableStateOf(false) } var showClearBookCacheDialog by remember { mutableStateOf(false) }
@ -223,35 +220,6 @@ 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) { LaunchedEffect(uiState.isRequestingDrivePermission) {
if (uiState.isRequestingDrivePermission) { if (uiState.isRequestingDrivePermission) {
val intent = viewModel.getDriveSignInIntent(context) val intent = viewModel.getDriveSignInIntent(context)
@ -421,12 +389,6 @@ fun HomeScreen(
showInfoDialog = true 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) }, onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
onDeleteClick = { showDeleteConfirmDialog = true }, onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllRecentFiles() }) onSelectAllClick = { viewModel.selectAllRecentFiles() })
@ -474,7 +436,7 @@ fun HomeScreen(
onRefresh = { viewModel.refreshLibrary() }, onRefresh = { viewModel.refreshLibrary() },
isRefreshing = uiState.isRefreshing, isRefreshing = uiState.isRefreshing,
isSyncEnabled = uiState.isSyncEnabled, isSyncEnabled = uiState.isSyncEnabled,
hasSyncedFolder = uiState.syncedFolders.any { it.localSyncEnabled }, hasSyncedFolder = uiState.syncedFolders.isNotEmpty(),
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
) )
} }
@ -539,17 +501,28 @@ fun HomeScreen(
) )
} }
HydratedFileInfoDialog( itemForInfoDialog?.let { item ->
item = itemForInfoDialog, if (showInfoDialog) {
isVisible = showInfoDialog, FileInfoDialog(
uiState = uiState, item = item,
viewModel = viewModel, usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = { onDismiss = {
showInfoDialog = false showInfoDialog = false
itemForInfoDialog = null itemForInfoDialog = null
}, },
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) } 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)) }
) )
}
}
if (showClearReflowCacheDialog) { if (showClearReflowCacheDialog) {
DangerousFolderActionDialog( DangerousFolderActionDialog(
@ -794,8 +767,7 @@ private fun RecentFilesGrid(
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
}, }
modifier = Modifier.testTag("HomeTab_${tab.bookId}")
) )
} }
} }
@ -845,7 +817,6 @@ fun RecentFileCard(
androidx.compose.material3.ElevatedCard( androidx.compose.material3.ElevatedCard(
modifier = modifier modifier = modifier
.testTag("HomeRecentFileCard_${item.bookId}")
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f } .graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then( .then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
@ -1514,7 +1485,7 @@ private fun AppDrawerContent(
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// legal links // legal links
if (uiState.currentUser != null || (isOss && !BuildConfig.IS_OFFLINE)) { if (uiState.currentUser != null && !isOss) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val baseStyle = MaterialTheme.typography.labelMedium val baseStyle = MaterialTheme.typography.labelMedium
var scaledTextStyle by remember { mutableStateOf(baseStyle) } var scaledTextStyle by remember { mutableStateOf(baseStyle) }
@ -1812,14 +1783,9 @@ fun ExternalFileBehaviorDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.options_external_file_behavior)) }, title = { Text(stringResource(R.string.options_external_file_behavior)) },
text = { text = {
Column(modifier = Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) { Column {
val options = listOf( 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)
Triple("ASK", R.string.external_file_behavior_ask, R.string.external_file_behavior_ask_desc), options.forEach { (value, labelRes) ->
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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@ -1829,14 +1795,7 @@ fun ExternalFileBehaviorDialog(
) { ) {
RadioButton(selected = currentBehavior == value, onClick = null) RadioButton(selected = currentBehavior == value, onClick = null)
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(labelRes)) Text(stringResource(labelRes))
Text(
text = stringResource(descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }
} }

View file

@ -0,0 +1,41 @@
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<RecentFileItem>,
val directBooks: List<RecentFileItem> = books,
val parentShelfId: String? = null,
val childShelfIds: List<String> = 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
}

View file

@ -20,7 +20,7 @@
// LibraryScreen.kt // LibraryScreen.kt
@file:Suppress("KotlinConstantConditions") @file:Suppress("KotlinConstantConditions")
package org.dueattendant149.bookreader package com.aryan.reader
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.Uri import android.net.Uri
@ -105,7 +105,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -121,7 +120,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -133,24 +131,19 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil.ImageLoader
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.decode.SvgDecoder import coil.request.ImageRequest
import org.dueattendant149.bookreader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import org.dueattendant149.bookreader.data.TagEntity import com.aryan.reader.data.TagEntity
import org.dueattendant149.bookreader.opds.OpdsAcquisition import com.aryan.reader.opds.OpdsAcquisition
import org.dueattendant149.bookreader.opds.OpdsCatalog import com.aryan.reader.opds.OpdsCatalog
import org.dueattendant149.bookreader.opds.OpdsDownloadState import com.aryan.reader.opds.OpdsDownloadState
import org.dueattendant149.bookreader.opds.OpdsEntry import com.aryan.reader.opds.OpdsEntry
import org.dueattendant149.bookreader.opds.OpdsRepository import com.aryan.reader.opds.OpdsViewModel
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.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -272,36 +265,6 @@ fun LibraryScreen(
var showDeleteShelvesDialog by remember { mutableStateOf(false) } var showDeleteShelvesDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) } var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(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) { BackHandler(enabled = isContextualModeActive) {
viewModel.clearContextualAction() viewModel.clearContextualAction()
@ -348,12 +311,6 @@ fun LibraryScreen(
showInfoDialog = true 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 }, onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllLibraryFiles() }, onSelectAllClick = { viewModel.selectAllLibraryFiles() },
onShelfClick = viewModel::onShelfClick, onShelfClick = viewModel::onShelfClick,
@ -368,7 +325,6 @@ fun LibraryScreen(
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) }, onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
syncedFolders = uiState.syncedFolders, syncedFolders = uiState.syncedFolders,
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) }, onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
onFolderLocalSyncChange = viewModel::setFolderLocalSyncEnabled,
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders, onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
downloadingBookIds = uiState.downloadingBookIds, downloadingBookIds = uiState.downloadingBookIds,
lastFolderScanTime = uiState.lastFolderScanTime, lastFolderScanTime = uiState.lastFolderScanTime,
@ -434,17 +390,28 @@ fun LibraryScreen(
) )
} }
HydratedFileInfoDialog( itemForInfoDialog?.let { item ->
item = itemForInfoDialog, if (showInfoDialog) {
isVisible = showInfoDialog, FileInfoDialog(
uiState = uiState, item = item,
viewModel = viewModel, usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = { onDismiss = {
showInfoDialog = false showInfoDialog = false
itemForInfoDialog = null itemForInfoDialog = null
}, },
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) } 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)) }
) )
}
}
CustomTopBanner(bannerMessage = uiState.bannerMessage) CustomTopBanner(bannerMessage = uiState.bannerMessage)
} }
} }
@ -462,42 +429,10 @@ fun ShelfScreen(
val sortOrder = uiState.sortOrder val sortOrder = uiState.sortOrder
val showRenameDialogFor = uiState.showRenameShelfDialogFor val showRenameDialogFor = uiState.showRenameShelfDialogFor
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
val context = LocalContext.current
val scope = rememberCoroutineScope()
var showRemoveFromShelfDialog by remember { mutableStateOf(false) } var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) } var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(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) { BackHandler(enabled = true) {
when { when {
@ -549,12 +484,6 @@ fun ShelfScreen(
showInfoDialog = true 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 }, onDeleteClick = { showRemoveFromShelfDialog = true },
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) }, onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) }, onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
@ -595,14 +524,19 @@ fun ShelfScreen(
) )
} }
HydratedFileInfoDialog( itemForInfoDialog?.let { item ->
item = itemForInfoDialog, if (showInfoDialog) {
isVisible = showInfoDialog, FileInfoDialog(
uiState = uiState, item = item,
viewModel = viewModel, usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = { showInfoDialog = false; itemForInfoDialog = null }, onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) } 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)) }
) )
}
}
CustomTopBanner(bannerMessage = uiState.bannerMessage) CustomTopBanner(bannerMessage = uiState.bannerMessage)
} }
} }
@ -637,8 +571,6 @@ fun LibraryScreenContent(
onItemClick: (RecentFileItem) -> Unit, onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit, onItemLongClick: (RecentFileItem) -> Unit,
onInfoClick: () -> Unit, onInfoClick: () -> Unit,
onSaveClick: (() -> Unit)?,
onShareClick: (() -> Unit)?,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
onSelectAllClick: () -> Unit, onSelectAllClick: () -> Unit,
onShelfClick: (Shelf) -> Unit, onShelfClick: (Shelf) -> Unit,
@ -658,7 +590,6 @@ fun LibraryScreenContent(
isRefreshing: Boolean, isRefreshing: Boolean,
syncedFolders: List<SyncedFolder>, syncedFolders: List<SyncedFolder>,
onRemoveFolderClick: (SyncedFolder) -> Unit, onRemoveFolderClick: (SyncedFolder) -> Unit,
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onOpdsBookDownloaded: (Uri, String) -> Unit, onOpdsBookDownloaded: (Uri, String) -> Unit,
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit, onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit, onDeleteCatalogStreams: (String) -> Unit,
@ -701,8 +632,6 @@ fun LibraryScreenContent(
onTagClick = onTagClick, onTagClick = onTagClick,
onPinClick = onPinClick, onPinClick = onPinClick,
onInfoClick = onInfoClick, onInfoClick = onInfoClick,
onSaveClick = onSaveClick,
onShareClick = onShareClick,
onDeleteClick = onDeleteClick, onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick onSelectAllClick = onSelectAllClick
) )
@ -737,8 +666,7 @@ fun LibraryScreenContent(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.focusRequester(searchFocusRequester) .focusRequester(searchFocusRequester),
.testTag("LibrarySearchTextField"),
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
@ -765,10 +693,7 @@ fun LibraryScreenContent(
Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter)) Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter))
} }
Box { Box {
TextButton( TextButton(onClick = { showSortMenu = true }) {
onClick = { showSortMenu = true },
modifier = Modifier.testTag("LibrarySortButton")
) {
Icon( Icon(
painter = painterResource(id = R.drawable.sort), painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort), contentDescription = stringResource(R.string.content_desc_sort),
@ -897,9 +822,7 @@ fun LibraryScreenContent(
text = { Text(stringResource(R.string.fab_new_shelf)) }, text = { Text(stringResource(R.string.fab_new_shelf)) },
icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) }, icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) },
onClick = onNewShelfClick, onClick = onNewShelfClick,
modifier = Modifier modifier = Modifier.padding(16.dp)
.padding(16.dp)
.testTag("LibraryNewShelfFab")
) )
} }
} }
@ -965,7 +888,6 @@ fun LibraryScreenContent(
allRecentFiles = rawLibraryFiles, allRecentFiles = rawLibraryFiles,
onAddFolderClick = onSelectSyncFolderClick, onAddFolderClick = onSelectSyncFolderClick,
onRemoveFolderClick = onRemoveFolderClick, onRemoveFolderClick = onRemoveFolderClick,
onFolderLocalSyncChange = onFolderLocalSyncChange,
onEditFolderFiltersClick = onEditFolderFiltersClick, onEditFolderFiltersClick = onEditFolderFiltersClick,
onScanNowClick = onScanNowClick, onScanNowClick = onScanNowClick,
onSyncMetadataClick = onSyncMetadataClick, onSyncMetadataClick = onSyncMetadataClick,
@ -1109,8 +1031,6 @@ private fun ShelfDetailScreen(
onClearSelection: () -> Unit, onClearSelection: () -> Unit,
onTagClick: () -> Unit, onTagClick: () -> Unit,
onInfoClick: () -> Unit, onInfoClick: () -> Unit,
onSaveClick: (() -> Unit)?,
onShareClick: (() -> Unit)?,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
onRenameShelf: () -> Unit, onRenameShelf: () -> Unit,
onDeleteShelf: () -> Unit, onDeleteShelf: () -> Unit,
@ -1193,8 +1113,6 @@ private fun ShelfDetailScreen(
onNavIconClick = onClearSelection, onNavIconClick = onClearSelection,
onTagClick = onTagClick, onTagClick = onTagClick,
onInfoClick = onInfoClick, onInfoClick = onInfoClick,
onSaveClick = onSaveClick,
onShareClick = onShareClick,
onDeleteClick = onDeleteClick onDeleteClick = onDeleteClick
) )
} else if (isSearchActive) { } else if (isSearchActive) {
@ -1222,8 +1140,7 @@ private fun ShelfDetailScreen(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.focusRequester(searchFocusRequester) .focusRequester(searchFocusRequester),
.testTag("ShelfSearchTextField"),
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
@ -1276,10 +1193,7 @@ private fun ShelfDetailScreen(
}, },
actions = { actions = {
Box { Box {
TextButton( TextButton(onClick = { showSortMenu = true }) {
onClick = { showSortMenu = true },
modifier = Modifier.testTag("ShelfSortButton")
) {
Icon( Icon(
painter = painterResource(id = R.drawable.sort), painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort), contentDescription = stringResource(R.string.content_desc_sort),
@ -1461,10 +1375,7 @@ private fun AddBooksModeScreen(
}, },
actions = { actions = {
Box { Box {
TextButton( TextButton(onClick = { showSortMenu = true }) {
onClick = { showSortMenu = true },
modifier = Modifier.testTag("AddBooksSortButton")
) {
Icon( Icon(
painter = painterResource(id = R.drawable.sort), painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort), contentDescription = stringResource(R.string.content_desc_sort),
@ -1670,7 +1581,6 @@ private fun ShelfListItem(
), ),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag("ShelfItem_${shelf.id}")
.then( .then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
else Modifier else Modifier
@ -1749,7 +1659,6 @@ private fun LibraryListItem(
), ),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag("LibraryBookItem_${item.bookId}")
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f } .graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then( .then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
@ -2020,15 +1929,12 @@ private fun FolderSyncScreen(
allRecentFiles: List<RecentFileItem>, allRecentFiles: List<RecentFileItem>,
onAddFolderClick: () -> Unit, onAddFolderClick: () -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit, onRemoveFolderClick: (SyncedFolder) -> Unit,
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit, onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onScanNowClick: () -> Unit, onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit, onSyncMetadataClick: () -> Unit,
isLoading: Boolean isLoading: Boolean
) { ) {
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) } var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
var disablingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
val hasEnabledSyncFolders = syncedFolders.any { it.localSyncEnabled }
val folderStatsByUri = remember(allRecentFiles) { val folderStatsByUri = remember(allRecentFiles) {
allRecentFiles allRecentFiles
.asSequence() .asSequence()
@ -2067,7 +1973,7 @@ private fun FolderSyncScreen(
) { ) {
FilledTonalButton( FilledTonalButton(
onClick = onScanNowClick, onClick = onScanNowClick,
enabled = !isLoading && hasEnabledSyncFolders, enabled = !isLoading,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small shape = MaterialTheme.shapes.small
) { ) {
@ -2082,7 +1988,7 @@ private fun FolderSyncScreen(
androidx.compose.material3.OutlinedButton( androidx.compose.material3.OutlinedButton(
onClick = onSyncMetadataClick, onClick = onSyncMetadataClick,
enabled = !isLoading && hasEnabledSyncFolders, enabled = !isLoading,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small shape = MaterialTheme.shapes.small
) { ) {
@ -2110,13 +2016,6 @@ private fun FolderSyncScreen(
folder = folder, folder = folder,
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty, stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
onRemoveClick = onRemoveFolderClick, onRemoveClick = onRemoveFolderClick,
onLocalSyncToggleClick = { selectedFolder ->
if (selectedFolder.localSyncEnabled) {
disablingFolder = selectedFolder
} else {
onFolderLocalSyncChange(selectedFolder, true, false)
}
},
onEditFiltersClick = { editingFolder = folder } onEditFiltersClick = { editingFolder = folder }
) )
} }
@ -2134,46 +2033,6 @@ private fun FolderSyncScreen(
onDismiss = { editingFolder = null } onDismiss = { editingFolder = null }
) )
} }
disablingFolder?.let { folder ->
AlertDialog(
onDismissRequest = { disablingFolder = null },
title = { Text(stringResource(R.string.dialog_disable_folder_local_sync_title)) },
text = {
Text(
stringResource(
R.string.dialog_disable_folder_local_sync_desc,
LOCAL_FOLDER_SYNC_DATA_DIR
)
)
},
confirmButton = {
TextButton(
onClick = {
onFolderLocalSyncChange(folder, false, true)
disablingFolder = null
}
) {
Text(stringResource(R.string.action_disable_remove_sync_data))
}
},
dismissButton = {
Row {
TextButton(onClick = { disablingFolder = null }) {
Text(stringResource(R.string.action_cancel))
}
TextButton(
onClick = {
onFolderLocalSyncChange(folder, false, false)
disablingFolder = null
}
) {
Text(stringResource(R.string.action_disable_keep_sync_data))
}
}
}
)
}
} }
private data class FolderFileStats( private data class FolderFileStats(
@ -2191,7 +2050,6 @@ private fun FolderCard(
folder: SyncedFolder, folder: SyncedFolder,
stats: FolderFileStats, stats: FolderFileStats,
onRemoveClick: (SyncedFolder) -> Unit, onRemoveClick: (SyncedFolder) -> Unit,
onLocalSyncToggleClick: (SyncedFolder) -> Unit,
onEditFiltersClick: (SyncedFolder) -> Unit onEditFiltersClick: (SyncedFolder) -> Unit
) { ) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
@ -2217,7 +2075,6 @@ private fun FolderCard(
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = folder.name, text = folder.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@ -2225,14 +2082,6 @@ private fun FolderCard(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (!folder.localSyncEnabled) {
Text(
text = stringResource(R.string.folder_local_sync_disabled),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
}
} }
Box { Box {
@ -2247,21 +2096,6 @@ private fun FolderCard(
onEditFiltersClick(folder) onEditFiltersClick(folder)
} }
) )
DropdownMenuItem(
text = {
Text(
if (folder.localSyncEnabled) {
stringResource(R.string.menu_disable_folder_local_sync)
} else {
stringResource(R.string.menu_enable_folder_local_sync)
}
)
},
onClick = {
showMenu = false
onLocalSyncToggleClick(folder)
}
)
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_remove_folder)) }, text = { Text(stringResource(R.string.menu_remove_folder)) },
onClick = { onClick = {
@ -2543,7 +2377,6 @@ fun OpdsTab(
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle() val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
val downloadingState = uiState.downloadingState val downloadingState = uiState.downloadingState
val context = LocalContext.current val context = LocalContext.current
val coverImageLoader = rememberOpdsCoverImageLoader(uiState.currentCatalog)
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) } var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
var showCatalogDialog by remember { mutableStateOf(false) } var showCatalogDialog by remember { mutableStateOf(false) }
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) } var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
@ -2768,7 +2601,6 @@ fun OpdsTab(
entry = entry, entry = entry,
localLibraryFiles = localLibraryFiles, localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[entry.id], downloadState = downloadingState[entry.id],
coverImageLoader = coverImageLoader,
onDownloadClick = { acquisition -> onDownloadClick = { acquisition ->
opdsViewModel.downloadBook( opdsViewModel.downloadBook(
entry, acquisition, context entry, acquisition, context
@ -2817,7 +2649,6 @@ fun OpdsTab(
entry = selectedEntry!!, entry = selectedEntry!!,
localLibraryFiles = localLibraryFiles, localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[selectedEntry!!.id], downloadState = downloadingState[selectedEntry!!.id],
coverImageLoader = coverImageLoader,
onDownloadFormat = { acquisition -> onDownloadFormat = { acquisition ->
opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri -> opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
onBookDownloaded(downloadedUri, selectedEntry!!.title) onBookDownloaded(downloadedUri, selectedEntry!!.title)
@ -2945,29 +2776,6 @@ fun OpdsTab(
} }
} }
@Composable
private fun rememberOpdsCoverImageLoader(catalog: OpdsCatalog?): ImageLoader {
val context = LocalContext.current.applicationContext
val username = catalog?.username
val password = catalog?.password
val imageLoader = remember(context, username, password) {
ImageLoader.Builder(context)
.okHttpClient {
OpdsRepository.sharedHttpClient.newBuilder()
.authenticator(OpdsRepository.OpdsAuthenticator(username, password))
.build()
}
.components {
add(SvgDecoder.Factory())
}
.build()
}
DisposableEffect(imageLoader) {
onDispose { imageLoader.shutdown() }
}
return imageLoader
}
@Composable @Composable
fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) { fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
Surface( Surface(
@ -3045,20 +2853,13 @@ fun OpdsBookCard(
entry: OpdsEntry, entry: OpdsEntry,
localLibraryFiles: List<RecentFileItem>, localLibraryFiles: List<RecentFileItem>,
downloadState: OpdsDownloadState?, downloadState: OpdsDownloadState?,
coverImageLoader: ImageLoader,
onDownloadClick: (OpdsAcquisition) -> Unit, onDownloadClick: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit, onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit, onStreamClick: () -> Unit,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val libraryItem = remember(entry, localLibraryFiles) { val libraryItem = remember(entry, localLibraryFiles) {
SharedOpdsLocalBookMatcher.find( localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
entry = entry,
books = localLibraryFiles,
title = { it.title },
displayName = { it.displayName },
path = { it.uriString }
)
} }
val isDownloading = downloadState?.isDownloading == true val isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress val progress = downloadState?.progress
@ -3077,7 +2878,6 @@ fun OpdsBookCard(
AsyncImage( AsyncImage(
model = entry.coverUrl, model = entry.coverUrl,
contentDescription = null, contentDescription = null,
imageLoader = coverImageLoader,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.size(width = 70.dp, height = 100.dp) .size(width = 70.dp, height = 100.dp)
@ -3184,7 +2984,6 @@ fun OpdsBookDetailsSheet(
entry: OpdsEntry, entry: OpdsEntry,
localLibraryFiles: List<RecentFileItem>, localLibraryFiles: List<RecentFileItem>,
downloadState: OpdsDownloadState?, downloadState: OpdsDownloadState?,
coverImageLoader: ImageLoader,
onDownloadFormat: (OpdsAcquisition) -> Unit, onDownloadFormat: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit, onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit, onStreamClick: () -> Unit,
@ -3193,13 +2992,7 @@ fun OpdsBookDetailsSheet(
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val libraryItem = remember(entry, localLibraryFiles) { val libraryItem = remember(entry, localLibraryFiles) {
SharedOpdsLocalBookMatcher.find( localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
entry = entry,
books = localLibraryFiles,
title = { it.title },
displayName = { it.displayName },
path = { it.uriString }
)
} }
val isDownloading = downloadState?.isDownloading == true val isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress val progress = downloadState?.progress
@ -3219,7 +3012,6 @@ fun OpdsBookDetailsSheet(
AsyncImage( AsyncImage(
model = entry.coverUrl, model = entry.coverUrl,
contentDescription = null, contentDescription = null,
imageLoader = coverImageLoader,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.size(width = 110.dp, height = 160.dp) .size(width = 110.dp, height = 160.dp)

View file

@ -1,14 +1,14 @@
package org.dueattendant149.bookreader package com.aryan.reader
import org.dueattendant149.bookreader.data.BookShelfCrossRef import com.aryan.reader.data.BookShelfCrossRef
import org.dueattendant149.bookreader.data.BookTagCrossRef import com.aryan.reader.data.BookTagCrossRef
import org.dueattendant149.bookreader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import org.dueattendant149.bookreader.data.ShelfEntity import com.aryan.reader.data.ShelfEntity
import org.dueattendant149.bookreader.data.TagEntity import com.aryan.reader.data.TagEntity
import org.dueattendant149.bookreader.shared.SharedReaderScreenState import com.aryan.reader.shared.SharedReaderScreenState
import org.dueattendant149.bookreader.shared.applyLibraryFilters as sharedApplyLibraryFilters import com.aryan.reader.shared.applyLibraryFilters as sharedApplyLibraryFilters
import org.dueattendant149.bookreader.shared.filterBySearch as sharedFilterBySearch import com.aryan.reader.shared.filterBySearch as sharedFilterBySearch
import org.dueattendant149.bookreader.shared.sortBooks as sharedSortBooks import com.aryan.reader.shared.sortBooks as sharedSortBooks
fun interface FolderPathResolver { fun interface FolderPathResolver {
fun relativeFolderSegments(item: RecentFileItem): List<String> fun relativeFolderSegments(item: RecentFileItem): List<String>
@ -151,7 +151,7 @@ fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFil
return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder)) return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder))
} }
private fun List<RecentFileItem>.mapSharedResults(sharedBooks: List<org.dueattendant149.bookreader.shared.BookItem>): List<RecentFileItem> { private fun List<RecentFileItem>.mapSharedResults(sharedBooks: List<com.aryan.reader.shared.BookItem>): List<RecentFileItem> {
val byId = associateBy { it.bookId } val byId = associateBy { it.bookId }
return sharedBooks.mapNotNull { byId[it.id] } return sharedBooks.mapNotNull { byId[it.id] }
} }

View file

@ -17,7 +17,7 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
package org.dueattendant149.bookreader package com.aryan.reader
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
@ -41,8 +41,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import org.dueattendant149.bookreader.data.PlatformFeaturesRepository import com.aryan.reader.data.PlatformFeaturesRepository
import org.dueattendant149.bookreader.ui.theme.AppTheme import com.aryan.reader.ui.theme.AppTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@ -50,20 +50,18 @@ import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import org.dueattendant149.bookreader.tts.ACTION_OPEN_TTS_SESSION import com.aryan.reader.tts.ACTION_OPEN_TTS_SESSION
import org.dueattendant149.bookreader.tts.EXTRA_TTS_BOOK_ID import com.aryan.reader.tts.EXTRA_TTS_BOOK_ID
import org.dueattendant149.bookreader.tts.EXTRA_TTS_CHAPTER_INDEX import com.aryan.reader.tts.EXTRA_TTS_CHAPTER_INDEX
import org.dueattendant149.bookreader.tts.EXTRA_TTS_PAGE_INDEX import com.aryan.reader.tts.EXTRA_TTS_PAGE_INDEX
import org.dueattendant149.bookreader.tts.EXTRA_TTS_SOURCE_CFI import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI
import org.dueattendant149.bookreader.tts.EXTRA_TTS_START_OFFSET import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET
@UnstableApi @UnstableApi
open class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels() private val viewModel: MainViewModel by viewModels()
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
private val isTemporaryExternalOpen: Boolean
get() = intent?.getBooleanExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, false) == true
private val updateLauncher = registerForActivityResult( private val updateLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult() ActivityResultContracts.StartIntentSenderForResult()
@ -88,14 +86,6 @@ open class MainActivity : AppCompatActivity() {
} }
} }
lifecycleScope.launch {
viewModel.temporaryExternalOpenFinished.collect {
if (isTemporaryExternalOpen) {
finishAndRemoveTask()
}
}
}
if (savedInstanceState == null) { if (savedInstanceState == null) {
handleIntent(intent) handleIntent(intent)
} }
@ -170,12 +160,7 @@ open class MainActivity : AppCompatActivity() {
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) { if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
Timber.d("Received VIEW intent with URI: ${intent.data}") Timber.d("Received VIEW intent with URI: ${intent.data}")
val uri = intent.data!! val uri = intent.data!!
viewModel.onFileSelected( viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true)
uri,
isFromRecent = false,
isExternalIntent = true,
isTemporaryExternalIntent = isTemporaryExternalOpen
)
} }
} }
} }

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
internal const val KEY_RENDER_MODE = "render_mode" internal const val KEY_RENDER_MODE = "render_mode"
internal const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled" internal const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"

View file

@ -18,7 +18,7 @@
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
// MainScreen.kt // MainScreen.kt
package org.dueattendant149.bookreader package com.aryan.reader
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge

View file

@ -1,5 +1,5 @@
// MetadataExtractionWorker.kt // MetadataExtractionWorker.kt
package org.dueattendant149.bookreader package com.aryan.reader
import android.content.Context import android.content.Context
import android.provider.OpenableColumns import android.provider.OpenableColumns
@ -10,10 +10,10 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.WorkManager import androidx.work.WorkManager
import org.dueattendant149.bookreader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import org.dueattendant149.bookreader.data.RecentFilesRepository import com.aryan.reader.data.RecentFilesRepository
import org.dueattendant149.bookreader.pdf.PdfiumCoreProvider import com.aryan.reader.pdf.PdfiumCoreProvider
import org.dueattendant149.bookreader.pdf.PdfiumEngineProvider import com.aryan.reader.pdf.PdfiumEngineProvider
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
@ -50,20 +50,11 @@ class MetadataExtractionWorker(
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI) val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val linkedFolders = SyncedFolderPrefs.decodeSyncedFolders( val hasLegacy = prefs.contains("synced_folder_uri")
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null), val hasNew = prefs.contains("synced_folders_list_json")
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null)
)
val enabledFolderUris = linkedFolders
.filter { it.localSyncEnabled }
.mapTo(mutableSetOf()) { it.uriString }
if (enabledFolderUris.isEmpty()) { if (!hasLegacy && !hasNew) {
ReaderPerfLog.d("MetadataWorker skipped: no linked folders with sync enabled") ReaderPerfLog.d("MetadataWorker skipped: no linked folders")
return@withContext Result.success()
}
if (!sourceFolderUri.isNullOrBlank() && sourceFolderUri !in enabledFolderUris) {
ReaderPerfLog.d("MetadataWorker skipped: folder sync disabled folder=$sourceFolderUri")
return@withContext Result.success() return@withContext Result.success()
} }
@ -71,9 +62,7 @@ class MetadataExtractionWorker(
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata( val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
sourceFolderUri = sourceFolderUri, sourceFolderUri = sourceFolderUri,
limit = METADATA_WORKER_BOOK_BATCH_SIZE limit = METADATA_WORKER_BOOK_BATCH_SIZE
).filter { item -> )
item.sourceFolderUri != null && item.sourceFolderUri in enabledFolderUris
}
if (filesToProcess.isEmpty()) { if (filesToProcess.isEmpty()) {
ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}") ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")

View file

@ -17,14 +17,14 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
package org.dueattendant149.bookreader package com.aryan.reader
import android.app.Application import android.app.Application
import android.webkit.WebView import android.webkit.WebView
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher import com.aryan.reader.paginatedreader.SvgStringFetcher
import timber.log.Timber // Add this import timber.log.Timber // Add this
class MyApplication : Application(), ImageLoaderFactory { class MyApplication : Application(), ImageLoaderFactory {

View file

@ -17,7 +17,7 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
package org.dueattendant149.bookreader package com.aryan.reader
import android.app.Activity import android.app.Activity
import timber.log.Timber 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.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.dueattendant149.bookreader.data.ProductDetailsEntity import com.aryan.reader.data.ProductDetailsEntity
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.NumberFormat import java.text.NumberFormat
import java.util.Currency import java.util.Currency

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Base64 import java.util.Base64

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import android.content.Context import android.content.Context
import android.view.Window import android.view.Window
@ -10,16 +10,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -33,37 +28,20 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.edit import androidx.core.content.edit
import org.dueattendant149.bookreader.shared.ui.ReaderMinimalSlider
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val READER_PREFS_NAME = "reader_prefs" private const val READER_PREFS_NAME = "reader_prefs"
private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system" private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system"
private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value" private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value"
private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f
private const val MIN_CUSTOM_BRIGHTNESS_PERCENT = 1 private const val MIN_CUSTOM_BRIGHTNESS = 0.05f
private const val MAX_CUSTOM_BRIGHTNESS_PERCENT = 100
private const val CUSTOM_BRIGHTNESS_STEP_PERCENT = 1
private const val MIN_CUSTOM_BRIGHTNESS = 0.01f
data class ReaderBrightnessSettings( data class ReaderBrightnessSettings(
val useSystemBrightness: Boolean = true, val useSystemBrightness: Boolean = true,
val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS
) { ) {
val safeCustomBrightness: Float val safeCustomBrightness: Float
get() = normalizeReaderBrightness(customBrightness) get() = customBrightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
}
internal fun normalizeReaderBrightness(brightness: Float): Float {
val percent = (brightness * 100f).roundToInt()
.coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
return percent / 100f
}
internal fun stepReaderBrightness(brightness: Float, percentDelta: Int): Float {
val currentPercent = (normalizeReaderBrightness(brightness) * 100f).roundToInt()
val nextPercent = (currentPercent + percentDelta)
.coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
return nextPercent / 100f
} }
fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings { fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
@ -71,7 +49,7 @@ fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
return ReaderBrightnessSettings( return ReaderBrightnessSettings(
useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true), useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true),
customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS) customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS)
.let(::normalizeReaderBrightness) .coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
) )
} }
@ -184,9 +162,17 @@ fun ReaderBrightnessSheet(
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} }
ReaderBrightnessControl( Slider(
settings = settings, value = settings.safeCustomBrightness,
onSettingsChange = onSettingsChange onValueChange = { brightness ->
onSettingsChange(
settings.copy(
useSystemBrightness = false,
customBrightness = brightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
)
)
},
valueRange = MIN_CUSTOM_BRIGHTNESS..1f
) )
Text( Text(
text = stringResource(R.string.reader_brightness_custom_desc), text = stringResource(R.string.reader_brightness_custom_desc),
@ -200,67 +186,6 @@ fun ReaderBrightnessSheet(
} }
} }
@Composable
private fun ReaderBrightnessControl(
settings: ReaderBrightnessSettings,
onSettingsChange: (ReaderBrightnessSettings) -> Unit
) {
val brightness = settings.safeCustomBrightness
val canDecrease = brightness > MIN_CUSTOM_BRIGHTNESS
val canIncrease = brightness < 1f
fun updateBrightness(value: Float) {
onSettingsChange(
settings.copy(
useSystemBrightness = false,
customBrightness = normalizeReaderBrightness(value)
)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
IconButton(
onClick = {
updateBrightness(stepReaderBrightness(brightness, -CUSTOM_BRIGHTNESS_STEP_PERCENT))
},
enabled = canDecrease,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.Remove,
contentDescription = stringResource(R.string.content_desc_decrease),
modifier = Modifier.size(18.dp)
)
}
ReaderMinimalSlider(
value = brightness,
onValueChange = ::updateBrightness,
valueRange = MIN_CUSTOM_BRIGHTNESS..1f,
activeColor = MaterialTheme.colorScheme.primary,
inactiveColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
thumbColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = {
updateBrightness(stepReaderBrightness(brightness, CUSTOM_BRIGHTNESS_STEP_PERCENT))
},
enabled = canIncrease,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.content_desc_increase),
modifier = Modifier.size(18.dp)
)
}
}
}
private fun Window.setReaderBrightness(brightness: Float) { private fun Window.setReaderBrightness(brightness: Float) {
attributes = attributes.apply { attributes = attributes.apply {
screenBrightness = brightness screenBrightness = brightness

View file

@ -1,12 +1,9 @@
package org.dueattendant149.bookreader package com.aryan.reader
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import com.aryan.reader.data.RecentFileItem
import org.dueattendant149.bookreader.data.RecentFileItem
@Composable @Composable
private fun rememberReaderFileInfoItem( private fun rememberReaderFileInfoItem(
@ -67,17 +64,28 @@ internal fun ReaderFileInfoDialogs(
} }
} }
HydratedFileInfoDialog( item?.let { fileInfoItem ->
item = item, if (isFileInfoVisible) {
isVisible = isFileInfoVisible, FileInfoDialog(
uiState = uiState, item = fileInfoItem,
viewModel = viewModel, usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = { onFileInfoVisibleChange(false) }, onDismiss = { onFileInfoVisibleChange(false) },
onOpenTags = { bookId -> onSaveMetadata = { metadata ->
viewModel.updateBookMetadata(fileInfoItem.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(fileInfoItem.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId)
},
onOpenTags = {
onFileInfoVisibleChange(false) onFileInfoVisibleChange(false)
viewModel.openTagSelection(setOf(bookId)) viewModel.openTagSelection(setOf(fileInfoItem.bookId))
} }
) )
}
}
if (uiState.showTagSelectionDialogFor.isNotEmpty()) { if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
TagSelectionBottomSheet( TagSelectionBottomSheet(
@ -94,49 +102,3 @@ 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)
}
)
}
}

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import timber.log.Timber import timber.log.Timber

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import android.content.Context import android.content.Context
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -56,21 +56,6 @@ internal fun shouldRenderReaderSlider(
isSearchActive: Boolean isSearchActive: Boolean
): Boolean = isToggledOn && isBottomChromeVisible && !isSearchActive ): Boolean = isToggledOn && isBottomChromeVisible && !isSearchActive
internal fun readerSliderStepPage(
currentPage: Int,
delta: Int,
minPage: Int,
maxPage: Int
): Int {
val lowerBound = min(minPage, maxPage)
val upperBound = max(minPage, maxPage)
val nextPage = currentPage.toLong() + delta.toLong()
return nextPage
.coerceIn(lowerBound.toLong(), upperBound.toLong())
.toInt()
}
internal fun readerSliderTogglePreferenceKey(bookId: String): String = internal fun readerSliderTogglePreferenceKey(bookId: String): String =
READER_SLIDER_TOGGLE_PREFIX + bookId READER_SLIDER_TOGGLE_PREFIX + bookId

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@ -27,45 +27,45 @@ import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import org.dueattendant149.bookreader.data.CustomFontEntity import com.aryan.reader.data.CustomFontEntity
import org.dueattendant149.bookreader.epubreader.FormatSettings as AndroidFormatSettings import com.aryan.reader.epubreader.FormatSettings as AndroidFormatSettings
import org.dueattendant149.bookreader.epubreader.ReaderFont as AndroidReaderFont import com.aryan.reader.epubreader.ReaderFont as AndroidReaderFont
import org.dueattendant149.bookreader.epubreader.ReaderTextAlign as AndroidReaderTextAlign import com.aryan.reader.epubreader.ReaderTextAlign as AndroidReaderTextAlign
import org.dueattendant149.bookreader.epubreader.loadFormatSettings import com.aryan.reader.epubreader.loadFormatSettings
import org.dueattendant149.bookreader.epubreader.loadPageInfoMode import com.aryan.reader.epubreader.loadPageInfoMode
import org.dueattendant149.bookreader.epubreader.loadPageInfoPosition import com.aryan.reader.epubreader.loadPageInfoPosition
import org.dueattendant149.bookreader.epubreader.loadPullToTurn import com.aryan.reader.epubreader.loadPullToTurn
import org.dueattendant149.bookreader.epubreader.loadPullToTurnMultiplier import com.aryan.reader.epubreader.loadPullToTurnMultiplier
import org.dueattendant149.bookreader.epubreader.loadSystemUiMode import com.aryan.reader.epubreader.loadSystemUiMode
import org.dueattendant149.bookreader.epubreader.savePageInfoMode import com.aryan.reader.epubreader.savePageInfoMode
import org.dueattendant149.bookreader.epubreader.savePageInfoPosition import com.aryan.reader.epubreader.savePageInfoPosition
import org.dueattendant149.bookreader.epubreader.savePullToTurn import com.aryan.reader.epubreader.savePullToTurn
import org.dueattendant149.bookreader.epubreader.savePullToTurnMultiplier import com.aryan.reader.epubreader.savePullToTurnMultiplier
import org.dueattendant149.bookreader.epubreader.saveReaderSettings import com.aryan.reader.epubreader.saveReaderSettings
import org.dueattendant149.bookreader.epubreader.saveSystemUiMode import com.aryan.reader.epubreader.saveSystemUiMode
import org.dueattendant149.bookreader.pdf.savePdfSystemUiMode import com.aryan.reader.pdf.savePdfSystemUiMode
import org.dueattendant149.bookreader.pdf.savePdfThemeId import com.aryan.reader.pdf.savePdfThemeId
import org.dueattendant149.bookreader.pdf.savePdfVerticalPageGapVisible import com.aryan.reader.pdf.savePdfVerticalPageGapVisible
import org.dueattendant149.bookreader.pdf.savePdfPageNumberOverlayVisible import com.aryan.reader.pdf.savePdfPageNumberOverlayVisible
import org.dueattendant149.bookreader.pdf.loadPdfSystemUiMode import com.aryan.reader.pdf.loadPdfSystemUiMode
import org.dueattendant149.bookreader.pdf.loadPdfThemeId import com.aryan.reader.pdf.loadPdfThemeId
import org.dueattendant149.bookreader.pdf.loadPdfVerticalPageGapVisible import com.aryan.reader.pdf.loadPdfVerticalPageGapVisible
import org.dueattendant149.bookreader.pdf.loadPdfPageNumberOverlayVisible import com.aryan.reader.pdf.loadPdfPageNumberOverlayVisible
import org.dueattendant149.bookreader.shared.BuiltInPdfReaderThemes import com.aryan.reader.shared.BuiltInPdfReaderThemes
import org.dueattendant149.bookreader.shared.CustomFontItem import com.aryan.reader.shared.CustomFontItem
import org.dueattendant149.bookreader.shared.SharedSettingsAction import com.aryan.reader.shared.SharedSettingsAction
import org.dueattendant149.bookreader.shared.SharedSettingsDestination import com.aryan.reader.shared.SharedSettingsDestination
import org.dueattendant149.bookreader.shared.parentDestination import com.aryan.reader.shared.parentDestination
import org.dueattendant149.bookreader.shared.toReaderSettingsFontFamily import com.aryan.reader.shared.toReaderSettingsFontFamily
import org.dueattendant149.bookreader.shared.toSharedReaderTextAlign import com.aryan.reader.shared.toSharedReaderTextAlign
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode import com.aryan.reader.shared.reader.ReaderReadingMode
import org.dueattendant149.bookreader.shared.reader.ReaderSettings import com.aryan.reader.shared.reader.ReaderSettings
import org.dueattendant149.bookreader.shared.reader.SharedReaderTextAlign import com.aryan.reader.shared.reader.SharedReaderTextAlign
import org.dueattendant149.bookreader.shared.readerThemeById import com.aryan.reader.shared.readerThemeById
import org.dueattendant149.bookreader.shared.sharedSettingsHubModel import com.aryan.reader.shared.sharedSettingsHubModel
import org.dueattendant149.bookreader.shared.toReaderSettings import com.aryan.reader.shared.toReaderSettings
import org.dueattendant149.bookreader.shared.ui.SharedSettingsHub import com.aryan.reader.shared.ui.SharedSettingsHub
import org.dueattendant149.bookreader.tts.loadTtsMode import com.aryan.reader.tts.loadTtsMode
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt import kotlin.math.roundToInt

View file

@ -18,7 +18,7 @@
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
// SharedComposables.kt // SharedComposables.kt
package org.dueattendant149.bookreader package com.aryan.reader
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -43,7 +43,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import org.dueattendant149.bookreader.data.TagEntity import com.aryan.reader.data.TagEntity
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -87,7 +87,6 @@ import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Restore import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.SelectAll 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.FileOpen
import androidx.compose.material.icons.outlined.Gavel import androidx.compose.material.icons.outlined.Gavel
import androidx.compose.material.icons.outlined.Policy import androidx.compose.material.icons.outlined.Policy
@ -143,14 +142,10 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import org.dueattendant149.bookreader.shared.SharedFileCapabilities import com.aryan.reader.data.BookMetadataEdit
import org.dueattendant149.bookreader.shared.SharedLegalLinks import com.aryan.reader.data.RecentFileItem
import org.dueattendant149.bookreader.shared.SharedLegalProfile import com.aryan.reader.shared.SharedText
import org.dueattendant149.bookreader.data.BookMetadataEdit import com.aryan.reader.shared.ui.SharedMarkdownText
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 timber.log.Timber
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@ -159,25 +154,9 @@ import kotlin.math.log10
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.roundToInt import kotlin.math.roundToInt
internal fun legalLinksForAndroidFlavor(flavor: String = BuildConfig.FLAVOR): SharedLegalLinks { internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
val profile = if (flavor == "oss") SharedLegalProfile.OSS else SharedLegalProfile.STANDARD internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
return sharedLegalLinksForProfile(profile) internal const val LICENSES_URL = "https://aryan-raj3112.github.io/reader-policy/licenses.html"
}
internal val PRIVACY_POLICY_URL: String get() = legalLinksForAndroidFlavor().privacyPolicyUrl
internal val TERMS_URL: String get() = legalLinksForAndroidFlavor().termsUrl
internal val LICENSES_URL: String get() = legalLinksForAndroidFlavor().licensesUrl
fun supportedFontMimeTypes(): Array<String> = arrayOf(
"font/ttf",
"font/otf",
"font/woff2",
"application/x-font-ttf",
"application/x-font-otf",
"application/font-woff2",
"application/vnd.ms-opentype",
"application/x-font-opentype"
)
class CustomTabUriHandler(private val context: Context) : UriHandler { class CustomTabUriHandler(private val context: Context) : UriHandler {
override fun openUri(uri: String) { override fun openUri(uri: String) {
@ -276,8 +255,6 @@ fun ContextualTopAppBar(
selectedItemCount: Int, selectedItemCount: Int,
onNavIconClick: () -> Unit, onNavIconClick: () -> Unit,
onInfoClick: (() -> Unit)? = null, onInfoClick: (() -> Unit)? = null,
onSaveClick: (() -> Unit)? = null,
onShareClick: (() -> Unit)? = null,
onTagClick: (() -> Unit)? = null, onTagClick: (() -> Unit)? = null,
onSelectAllClick: (() -> Unit)? = null, onSelectAllClick: (() -> Unit)? = null,
onPinClick: (() -> Unit)? = null, onPinClick: (() -> Unit)? = null,
@ -306,16 +283,6 @@ fun ContextualTopAppBar(
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.info)) 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) { if (onSelectAllClick != null) {
IconButton(onClick = onSelectAllClick) { IconButton(onClick = onSelectAllClick) {
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all)) Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all))
@ -1270,10 +1237,7 @@ fun AboutDialog(onDismiss: () -> Unit) {
subtitle = stringResource(R.string.about_github_desc), subtitle = stringResource(R.string.about_github_desc),
onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme") } onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme") }
) )
} else {
Spacer(modifier = Modifier.height(10.dp))
}
AboutInfoRow( AboutInfoRow(
icon = { icon = {
Icon( Icon(
@ -1320,6 +1284,7 @@ fun AboutDialog(onDismiss: () -> Unit) {
onClick = { uriHandler.openUri(LICENSES_URL) } onClick = { uriHandler.openUri(LICENSES_URL) }
) )
} }
}
}, },
confirmButton = { confirmButton = {
TextButton( TextButton(
@ -1598,31 +1563,6 @@ fun RecentFileItem.isOpdsStream(): Boolean {
return uriString?.startsWith("opds-pse://") == true 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 @Composable
private fun statusBadgeColors(overlay: Boolean): Pair<Color, Color> { private fun statusBadgeColors(overlay: Boolean): Pair<Color, Color> {
val container = if (overlay) { val container = if (overlay) {

View file

@ -1,22 +1,20 @@
package org.dueattendant149.bookreader package com.aryan.reader
import org.dueattendant149.bookreader.data.BookShelfCrossRef import com.aryan.reader.data.BookShelfCrossRef
import org.dueattendant149.bookreader.data.BookTagCrossRef import com.aryan.reader.data.BookTagCrossRef
import org.dueattendant149.bookreader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import org.dueattendant149.bookreader.data.ShelfEntity import com.aryan.reader.data.ShelfEntity
import org.dueattendant149.bookreader.data.TagEntity import com.aryan.reader.data.TagEntity
import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem import com.aryan.reader.shared.BookItem as SharedBookItem
import org.dueattendant149.bookreader.shared.BookShelfRef as SharedBookShelfRef import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer import com.aryan.reader.shared.EpubAnnotationSerializer
import org.dueattendant149.bookreader.shared.FileType as SharedFileType import com.aryan.reader.shared.FileType as SharedFileType
import org.dueattendant149.bookreader.shared.LibraryFilters as SharedLibraryFilters import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
import org.dueattendant149.bookreader.shared.ReaderLocator as SharedReaderLocator import com.aryan.reader.shared.SharedReaderScreenState
import org.dueattendant149.bookreader.shared.SharedReaderScreenState import com.aryan.reader.shared.Shelf as SharedShelf
import org.dueattendant149.bookreader.shared.Shelf as SharedShelf import com.aryan.reader.shared.ShelfRecord
import org.dueattendant149.bookreader.shared.ShelfRecord import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
import org.dueattendant149.bookreader.shared.SyncedFolder as SharedSyncedFolder import com.aryan.reader.shared.Tag as SharedTag
import org.dueattendant149.bookreader.shared.Tag as SharedTag
import org.dueattendant149.bookreader.shared.toStablePositionCfi
fun FileType.toSharedFileType(): SharedFileType = this fun FileType.toSharedFileType(): SharedFileType = this
@ -31,21 +29,11 @@ fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder = this
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this
fun RecentFileItem.toSharedBookItem(): SharedBookItem { fun RecentFileItem.toSharedBookItem(): SharedBookItem {
return toSharedBookItem(
displayName = customName ?: displayName,
includeReaderAnnotations = true
)
}
private fun RecentFileItem.toSharedBookItem(
displayName: String,
includeReaderAnnotations: Boolean
): SharedBookItem {
return SharedBookItem( return SharedBookItem(
id = bookId, id = bookId,
path = uriString, path = uriString,
type = type, type = type,
displayName = displayName, displayName = customName ?: displayName,
timestamp = timestamp, timestamp = timestamp,
coverImagePath = coverImagePath, coverImagePath = coverImagePath,
title = title, title = title,
@ -65,22 +53,13 @@ private fun RecentFileItem.toSharedBookItem(
seriesName = seriesName, seriesName = seriesName,
seriesIndex = seriesIndex, seriesIndex = seriesIndex,
lastPageIndex = lastPage, lastPageIndex = lastPage,
readerPosition = toSharedReaderLocatorOrNull(),
tags = tags.map { it.toSharedTag() }, tags = tags.map { it.toSharedTag() },
readerHighlights = if (includeReaderAnnotations) { readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
} else {
emptyList()
},
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
) )
} }
fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem { fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem {
return toSharedBookItem( return toSharedBookItem().copy(displayName = displayName)
displayName = displayName,
includeReaderAnnotations = false
)
} }
fun SharedBookItem.toRecentFileItem( fun SharedBookItem.toRecentFileItem(
@ -88,49 +67,11 @@ fun SharedBookItem.toRecentFileItem(
tagEntitiesById: Map<String, TagEntity> = emptyMap() tagEntitiesById: Map<String, TagEntity> = emptyMap()
): RecentFileItem { ): RecentFileItem {
val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) } val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
val positionCfi = readerPosition?.toSharedPositionCfi() return androidBooksById[id]?.copy(tags = resolvedTags)
androidBooksById[id]?.let { existing -> ?.copy(
val mappedLastChapterIndex = readerPosition?.chapterIndex ?: existing.lastChapterIndex
val mappedLastPositionCfi = positionCfi ?: existing.lastPositionCfi
val mappedLocatorBlockIndex = readerPosition?.blockIndex ?: existing.locatorBlockIndex
val mappedLocatorCharOffset = readerPosition?.charOffset ?: existing.locatorCharOffset
if (
existing.uriString == path &&
existing.type == type &&
existing.timestamp == timestamp &&
existing.coverImagePath == coverImagePath &&
existing.title == title &&
existing.author == author &&
existing.description == description &&
existing.originalTitle == originalTitle &&
existing.originalAuthor == originalAuthor &&
existing.originalSeriesName == originalSeriesName &&
existing.originalSeriesIndex == originalSeriesIndex &&
existing.originalDescription == originalDescription &&
existing.lastPage == lastPageIndex &&
existing.progressPercentage == progressPercentage &&
existing.isRecent == isRecent &&
existing.sourceFolderUri == sourceFolder &&
existing.fileSize == fileSize &&
existing.fileContentModifiedTimestamp == fileContentModifiedTimestamp &&
existing.seriesName == seriesName &&
existing.seriesIndex == seriesIndex &&
existing.folderTextMetadataParsed == folderTextMetadataParsed &&
existing.lastChapterIndex == mappedLastChapterIndex &&
existing.lastPositionCfi == mappedLastPositionCfi &&
existing.locatorBlockIndex == mappedLocatorBlockIndex &&
existing.locatorCharOffset == mappedLocatorCharOffset &&
existing.readingPositionModifiedTimestamp == readingPositionModifiedTimestamp &&
existing.tags == resolvedTags
) {
return existing
}
return existing.copy(
uriString = path, uriString = path,
type = type, type = type,
displayName = existing.displayName, displayName = androidBooksById[id]?.displayName ?: displayName,
timestamp = timestamp, timestamp = timestamp,
coverImagePath = coverImagePath, coverImagePath = coverImagePath,
title = title, title = title,
@ -149,17 +90,9 @@ fun SharedBookItem.toRecentFileItem(
fileContentModifiedTimestamp = fileContentModifiedTimestamp, fileContentModifiedTimestamp = fileContentModifiedTimestamp,
seriesName = seriesName, seriesName = seriesName,
seriesIndex = seriesIndex, seriesIndex = seriesIndex,
folderTextMetadataParsed = folderTextMetadataParsed, folderTextMetadataParsed = folderTextMetadataParsed
lastChapterIndex = mappedLastChapterIndex,
lastPositionCfi = mappedLastPositionCfi,
locatorBlockIndex = mappedLocatorBlockIndex,
locatorCharOffset = mappedLocatorCharOffset,
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
tags = resolvedTags
) )
} ?: RecentFileItem(
return RecentFileItem(
bookId = id, bookId = id,
uriString = path, uriString = path,
type = type, type = type,
@ -183,39 +116,10 @@ fun SharedBookItem.toRecentFileItem(
seriesName = seriesName, seriesName = seriesName,
seriesIndex = seriesIndex, seriesIndex = seriesIndex,
folderTextMetadataParsed = folderTextMetadataParsed, folderTextMetadataParsed = folderTextMetadataParsed,
lastChapterIndex = readerPosition?.chapterIndex,
lastPositionCfi = positionCfi,
locatorBlockIndex = readerPosition?.blockIndex,
locatorCharOffset = readerPosition?.charOffset,
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
tags = resolvedTags tags = resolvedTags
) )
} }
private fun RecentFileItem.toSharedReaderLocatorOrNull(): SharedReaderLocator? {
if (
lastChapterIndex == null &&
lastPage == null &&
lastPositionCfi.isNullOrBlank() &&
locatorBlockIndex == null &&
locatorCharOffset == null
) {
return null
}
return SharedReaderLocator.fromLegacy(
chapterIndex = lastChapterIndex,
cfi = lastPositionCfi,
pageIndex = lastPage
).withFallbacks(
blockIndex = locatorBlockIndex,
charOffset = locatorCharOffset
)
}
private fun SharedReaderLocator.toSharedPositionCfi(): String? {
return toStablePositionCfi()
}
fun TagEntity.toSharedTag(): SharedTag { fun TagEntity.toSharedTag(): SharedTag {
return SharedTag( return SharedTag(
id = id, id = id,
@ -263,16 +167,8 @@ fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
fun ReaderScreenState.toSharedReaderScreenState( fun ReaderScreenState.toSharedReaderScreenState(
rawBooks: List<RecentFileItem> = rawLibraryFiles, rawBooks: List<RecentFileItem> = rawLibraryFiles,
dbTags: List<TagEntity> = allTags, dbTags: List<TagEntity> = allTags
includeReaderAnnotations: Boolean = true
): SharedReaderScreenState { ): SharedReaderScreenState {
fun RecentFileItem.toStateSharedBookItem(): SharedBookItem {
return toSharedBookItem(
displayName = customName ?: displayName,
includeReaderAnnotations = includeReaderAnnotations
)
}
return SharedReaderScreenState( return SharedReaderScreenState(
selectedBookId = selectedBookId, selectedBookId = selectedBookId,
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(), selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
@ -306,16 +202,16 @@ fun ReaderScreenState.toSharedReaderScreenState(
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
reflowProgress = reflowProgress, reflowProgress = reflowProgress,
recentBooks = recentFiles.map { it.toStateSharedBookItem() }, recentBooks = recentFiles.map { it.toSharedBookItem() },
libraryBooks = allRecentFiles.map { it.toStateSharedBookItem() }, libraryBooks = allRecentFiles.map { it.toSharedBookItem() },
rawLibraryBooks = rawBooks.map { it.toStateSharedBookItem() }, rawLibraryBooks = rawBooks.map { it.toSharedBookItem() },
pinnedHomeBookIds = pinnedHomeBookIds, pinnedHomeBookIds = pinnedHomeBookIds,
pinnedLibraryBookIds = pinnedLibraryBookIds, pinnedLibraryBookIds = pinnedLibraryBookIds,
libraryFilters = libraryFilters, libraryFilters = libraryFilters,
recentFilesLimit = recentFilesLimit, recentFilesLimit = recentFilesLimit,
isTabsEnabled = isTabsEnabled, isTabsEnabled = isTabsEnabled,
openTabIds = openTabIds, openTabIds = openTabIds,
openTabs = openTabs.map { it.toStateSharedBookItem() }, openTabs = openTabs.map { it.toSharedBookItem() },
activeTabBookId = activeTabBookId, activeTabBookId = activeTabBookId,
showExternalFileSavePromptFor = showExternalFileSavePromptFor, showExternalFileSavePromptFor = showExternalFileSavePromptFor,
externalFileBehavior = externalFileBehavior, externalFileBehavior = externalFileBehavior,
@ -341,14 +237,7 @@ fun List<RecentFileItem>.withResolvedTags(
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry -> val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
entry.value.mapNotNull { tagsById[it.tagId] } entry.value.mapNotNull { tagsById[it.tagId] }
} }
return map { item -> return map { item -> item.copy(tags = bookTagsMap[item.bookId].orEmpty()) }
val resolvedTags = bookTagsMap[item.bookId].orEmpty()
if (item.tags == resolvedTags) {
item
} else {
item.copy(tags = resolvedTags)
}
}
} }
fun SharedReaderScreenState.toAndroidReaderScreenState( fun SharedReaderScreenState.toAndroidReaderScreenState(
@ -357,11 +246,8 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
tagEntitiesById: Map<String, TagEntity> = emptyMap() tagEntitiesById: Map<String, TagEntity> = emptyMap()
): ReaderScreenState { ): ReaderScreenState {
val fallbackBooksById = rawLibraryBooks.associateBy { it.id } val fallbackBooksById = rawLibraryBooks.associateBy { it.id }
val mappedBooksById = LinkedHashMap<String, RecentFileItem>()
fun SharedBookItem.toAndroidBook(): RecentFileItem { fun SharedBookItem.toAndroidBook(): RecentFileItem {
return mappedBooksById.getOrPut(id) { return toRecentFileItem(androidBooksById, tagEntitiesById)
toRecentFileItem(androidBooksById, tagEntitiesById)
}
} }
fun bookById(bookId: String): RecentFileItem? { fun bookById(bookId: String): RecentFileItem? {
return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook() return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook()
@ -374,7 +260,7 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
isAddingBooksToShelf = isAddingBooksToShelf, isAddingBooksToShelf = isAddingBooksToShelf,
contextualActionShelfIds = selectedShelfIds, contextualActionShelfIds = selectedShelfIds,
contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) }, contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) },
shelves = shelves.map { shelf -> shelf.toAndroidShelf { book -> book.toAndroidBook() } }, shelves = shelves.map { it.toAndroidShelf(androidBooksById, tagEntitiesById) },
openTabs = openTabs.map { it.toAndroidBook() }, openTabs = openTabs.map { it.toAndroidBook() },
openTabIds = openTabIds, openTabIds = openTabIds,
activeTabBookId = activeTabBookId, activeTabBookId = activeTabBookId,
@ -386,19 +272,13 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
fun SharedShelf.toAndroidShelf( fun SharedShelf.toAndroidShelf(
androidBooksById: Map<String, RecentFileItem> = emptyMap(), androidBooksById: Map<String, RecentFileItem> = emptyMap(),
tagEntitiesById: Map<String, TagEntity> = emptyMap() tagEntitiesById: Map<String, TagEntity> = emptyMap()
): Shelf {
return toAndroidShelf { it.toRecentFileItem(androidBooksById, tagEntitiesById) }
}
private fun SharedShelf.toAndroidShelf(
resolveBook: (SharedBookItem) -> RecentFileItem
): Shelf { ): Shelf {
return Shelf( return Shelf(
id = id, id = id,
name = name, name = name,
type = type, type = type,
books = books.map(resolveBook), books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
directBooks = directBooks.map(resolveBook), directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
parentShelfId = parentShelfId, parentShelfId = parentShelfId,
childShelfIds = childShelfIds, childShelfIds = childShelfIds,
depth = depth, depth = depth,

View file

@ -1,5 +1,5 @@
// StorageTracker.kt // StorageTracker.kt
package org.dueattendant149.bookreader package com.aryan.reader
import android.content.Context import android.content.Context
import timber.log.Timber import timber.log.Timber

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@ -31,7 +31,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import org.dueattendant149.bookreader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import java.io.File import java.io.File
import kotlin.math.absoluteValue import kotlin.math.absoluteValue

View file

@ -1,11 +1,11 @@
package org.dueattendant149.bookreader package com.aryan.reader
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import org.dueattendant149.bookreader.paginatedreader.TtsChunk import com.aryan.reader.paginatedreader.TtsChunk
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementEngine import com.aryan.reader.shared.ReaderTtsReplacementEngine
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferencesJson import com.aryan.reader.shared.ReaderTtsReplacementPreferencesJson
private const val READER_PREFS_NAME = "reader_prefs" private const val READER_PREFS_NAME = "reader_prefs"
private const val TTS_REPLACEMENTS_KEY = "tts_word_replacements_json" private const val TTS_REPLACEMENTS_KEY = "tts_word_replacements_json"

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementBookSettings import com.aryan.reader.shared.ReaderTtsReplacementBookSettings
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementEngine import com.aryan.reader.shared.ReaderTtsReplacementEngine
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementRule import com.aryan.reader.shared.ReaderTtsReplacementRule
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementSuggestions import com.aryan.reader.shared.ReaderTtsReplacementSuggestions
private enum class TtsReplacementScope { private enum class TtsReplacementScope {
Global, Global,

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader package com.aryan.reader
import androidx.annotation.StringRes import androidx.annotation.StringRes
import java.text.Normalizer import java.text.Normalizer
@ -67,8 +67,7 @@ val supportedAppLanguageOptions = listOf(
"中文", "中文",
"简体中文", "简体中文",
) )
), )
AppLanguageOption("et", R.string.language_estonian, listOf("estonian", "eesti"))
) )
val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions

View file

@ -17,7 +17,7 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
package org.dueattendant149.bookreader.data package com.aryan.reader.data
import android.content.Context import android.content.Context
import androidx.room.Database import androidx.room.Database
@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
TagEntity::class, TagEntity::class,
BookTagCrossRef::class BookTagCrossRef::class
], ],
version = 23, version = 22,
exportSchema = false exportSchema = false
) )
@TypeConverters(FileTypeConverter::class) @TypeConverters(FileTypeConverter::class)
@ -288,25 +288,6 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
val MIGRATION_22_23 = object : Migration(22, 23) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN readingPositionModifiedTimestamp INTEGER NOT NULL DEFAULT 0")
db.execSQL("""
UPDATE recent_files
SET readingPositionModifiedTimestamp = lastModifiedTimestamp
WHERE lastModifiedTimestamp > 0
AND (
lastChapterIndex IS NOT NULL OR
lastPage IS NOT NULL OR
lastPositionCfi IS NOT NULL OR
locatorBlockIndex IS NOT NULL OR
locatorCharOffset IS NOT NULL OR
COALESCE(progressPercentage, 0) > 0
)
""")
}
}
fun getDatabase(context: Context): AppDatabase { fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) { return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder( val instance = Room.databaseBuilder(
@ -320,7 +301,7 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20,
MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23 MIGRATION_20_21, MIGRATION_21_22
) )
.fallbackToDestructiveMigration(false) .fallbackToDestructiveMigration(false)
.build() .build()

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader.data package com.aryan.reader.data
data class BookMetadataEdit( data class BookMetadataEdit(
val title: String?, val title: String?,

View file

@ -17,7 +17,7 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
package org.dueattendant149.bookreader.data package com.aryan.reader.data
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert

View file

@ -17,7 +17,7 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
package org.dueattendant149.bookreader.data package com.aryan.reader.data
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity

View file

@ -17,10 +17,10 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
package org.dueattendant149.bookreader.data package com.aryan.reader.data
import androidx.room.TypeConverter import androidx.room.TypeConverter
import org.dueattendant149.bookreader.FileType import com.aryan.reader.FileType
class FileTypeConverter { class FileTypeConverter {
@TypeConverter @TypeConverter

Some files were not shown because too many files have changed in this diff Show more