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/
third_party/pdfium/
*.tgz
kcef-bundle/
kcef-bundle-linux-x64/
cache/
worker/
output/
policies/
episteme-bin/
episteme-oss-bin/

View file

@ -162,19 +162,6 @@
</AndroidTestResultsTableState>
</value>
</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">
<value>
<AndroidTestResultsTableState>
@ -322,19 +309,6 @@
</AndroidTestResultsTableState>
</value>
</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">
<value>
<AndroidTestResultsTableState>
@ -383,7 +357,6 @@
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
@ -561,19 +534,6 @@
</AndroidTestResultsTableState>
</value>
</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">
<value>
<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>
</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>
<br/>
<table>
<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>
![Episteme Reader Preview](docs/EPISTEME.png)
## 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.
* **Reading modes:** Paginated reading, vertical scroll, PDF multi-tab reading, PDF reflow, auto-scroll, and musician mode.
* **PDF tools:** Ink annotations, highlighting, erasing, text annotations, and reading-focused PDF controls.
* **Customization:** App themes, reader themes, custom local fonts, typography controls, spacing, margins, and layout tuning.
* **Library tools:** Local folder sync, library organization, bookmarks, progress tracking, and file management.
* **Accessibility:** System text-to-speech, app language selection, and reader settings that adapt to different reading preferences.
### 📚 Supported Formats
| Feature | PlayStore | OSS | OSS Offline |
| :--- | :---: | :---: | :---: |
| **Documents:** PDF, DOCX, ODT/FODT | ✅ | ✅ | ✅ |
| **E-books & Text:** EPUB, MOBI, AZW3, FB2, MD, HTML, TXT | ✅ | ✅ | ✅ |
| **Comics:** CBZ, CBR, CB7 | ✅ | ✅ | ✅ |
| **View-Only:** CSV, TSV, JSON, XML, Logs, Code Files | ✅ | ✅ | ✅ |
## 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 |
|---|---|---|---|---|
| **Play Store** | Android | Online-capable | Google Play | Full Android release with proprietary extras such as ML Kit OCR, cloud sync, AI tools, cloud TTS, and PDF bubble zoom. |
| **OSS** | Android | Online-capable | [epistemereader.com](https://epistemereader.com), GitHub, F-Droid, Obtainium | Fully open-source Android build with OPDS, downloadable fonts, and BYOK access to AI and cloud features. |
| **OSS Offline** | Android | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub, Obtainium | Open-source Android build with network permissions removed. |
| **Standard** | Windows desktop | Online-capable | [epistemereader.com](https://epistemereader.com), GitHub | Full-featured desktop release with the shared KMP reader core and online-capable services. |
| **Offline** | Windows desktop | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub | Desktop build focused on local reading with online services disabled. |
### ⚙️ Advanced & Network Features
| Feature | PlayStore | OSS (F-Droid) | OSS Offline |
| :--- | :---: | :---: | :---: |
| **Local Folder Sync & Library Management** | ✅ | ✅ | ✅ |
| **Download Google Fonts** | ✅ | ✅ | ❌ |
| **OPDS Catalog Support** | ✅ | ✅ | ❌ |
| **PDF Bubble Zoom Magnifier** | ✅ | ❌ | ❌ |
| **ML Kit OCR** (Scanned PDF Text Selection) | ✅ | ❌ | ❌ |
| **Cross-Device Cloud Sync** | ✅ | ❌ | ❌ |
| **AI Tools** (Summaries, Story Recap, Dictionary) | ✅ | 🔜 | ❌ |
| **Cloud Text-to-Speech** | ✅ | 🔜 | ❌ |
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
@ -71,7 +70,7 @@ Want Episteme Reader in another language? Please request it through [GitHub Issu
cd episteme
```
2. Build Android:
2. Build:
* Open in Android Studio and run the `ossDebug` or `ossOfflineDebug` variant, or
* Build from the command line:
```bash
@ -80,56 +79,43 @@ Want Episteme Reader in another language? Please request it through [GitHub Issu
The APK will be generated at:
`app/build/outputs/apk/oss/debug/Episteme-oss-v{version}-oss-debug.apk`
3. Build desktop:
```bash
./gradlew :desktopApp:packageReleaseDistributionForCurrentOS
```
For the offline desktop build, pass:
```bash
./gradlew :desktopApp:packageReleaseDistributionForCurrentOS -PdesktopFlavor=oss
```
## Open Source Libraries
Powered by the Kotlin, Android, and desktop OSS ecosystem:
* **Core and UI:** Kotlin Multiplatform, Compose Multiplatform, AndroidX, Jetpack Compose, Kotlinx Serialization
* **Document engines:** PdfiumAndroidKt, PDFium, libmobi
* **Parsers:** Jsoup, Flexmark, Apache Commons Compress
* **Media and image loading:** Coil, Media3
* **Utilities:** Room, Timber, JNA
Powered by the Android OSS ecosystem:
* **Core & UI:** AndroidX, Jetpack Compose, Kotlinx Serialization
* **Document Engines:** PdfiumAndroidKt (PDF), libmobi (MOBI/AZW3)
* **Parsers:** Jsoup (HTML/EPUB), Flexmark (Markdown)
* **Media & Image Loading:** Coil, Media3 (ExoPlayer)
* **Utilities:** Room (Database), Timber (Logging)
## Contributors
| 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/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/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/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/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/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/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) | 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) | 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) | 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) | 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) | 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 |
|---|---|
| <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
[![Translation status](https://hosted.weblate.org/widget/episteme/multi-auto.svg)](https://hosted.weblate.org/engage/episteme/)
## License
Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See the [LICENSE](LICENSE) file.
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.kotlin.android)
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)
id("com.diffplug.spotless") version "8.2.1"
alias(libs.plugins.kover)
@ -50,15 +50,15 @@ kotlin {
}
android {
namespace = "org.dueattendant149.bookreader"
namespace = "com.aryan.reader"
compileSdk = 36
defaultConfig {
applicationId = "org.dueattendant149.bookreader"
applicationId = "com.aryan.reader"
minSdk = 26
targetSdk = 35
versionCode = 54
versionName = "1.0.50"
versionCode = 53
versionName = "1.0.49"
resourceConfigurations += configuredAppLocaleTags()
.map { it.toAndroidResourceConfiguration() }
@ -171,7 +171,6 @@ android {
testOptions {
unitTests.isReturnDefaultValues = true
unitTests.all {
it.maxHeapSize = "4g"
it.jvmArgs("-Xss2m")
}
}
@ -244,6 +243,8 @@ dependencies {
ksp(libs.androidx.room.compiler)
implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation("androidx.appcompat:appcompat:1.7.1")
//noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break)

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

View file

@ -1,9 +1,9 @@
package org.dueattendant149.bookreader.epubreader
package com.aryan.reader.epubreader
import android.content.Context
import androidx.test.core.app.ApplicationProvider
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 org.json.JSONArray
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.ExperimentalCoroutinesApi

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
// PaginatedReaderDataTest.kt
package org.dueattendant149.bookreader.paginatedreader
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.ParagraphStyle
@ -75,7 +75,7 @@ class PaginatedReaderDataTest {
margin = BoxBorders(bottom = 10.dp, left = 10.dp),
width = 200.dp,
backgroundColor = Color.Black,
borderTop = BorderStyle(width = 1.dp, color = Color.Red)
border = BorderStyle(width = 1.dp, color = Color.Red)
)
val merged = baseStyle.merge(overrideStyle)
@ -95,8 +95,8 @@ class PaginatedReaderDataTest {
// Other properties
assertThat(merged.width).isEqualTo(200.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.Black)
assertThat(merged.borderTop).isNotNull()
assertThat(merged.borderTop?.width).isEqualTo(1.dp)
assertThat(merged.border).isNotNull()
assertThat(merged.border?.width).isEqualTo(1.dp)
}
@Test
@ -115,9 +115,6 @@ class PaginatedReaderDataTest {
assertThat(merged.margin.top).isEqualTo(5.dp)
assertThat(merged.width).isEqualTo(100.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.White)
assertThat(merged.borderTop).isNull()
assertThat(merged.borderRight).isNull()
assertThat(merged.borderBottom).isNull()
assertThat(merged.borderLeft).isNull()
assertThat(merged.border).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
package org.dueattendant149.bookreader.paginatedreader
package com.aryan.reader.paginatedreader
import android.os.Build
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Density
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 androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@ -44,52 +42,14 @@ class FakeSplittableMeasurementProvider(
}
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)
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
class PaginatorTest {
private val testDensity = Density(density = 1f, fontScale = 1f)
private val pageHeight = 1000
private fun List<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
fun paginate_givenEmptyBlocks_createsZeroPages() = runTest {
val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity)
@ -125,53 +85,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
}
@Test
fun paginate_honorsBreakBeforePage() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("Before"), blockIndex = 0)
val block2 = ParagraphBlock(
content = AnnotatedString("After"),
style = BlockStyle(breakBefore = "page"),
blockIndex = 1
)
val pages = paginate(
listOf(block1, block2),
pageHeight,
FakeSplittableMeasurementProvider(mapOf(block1 to 100, block2 to 100)),
testDensity
)
assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
}
@Test
fun paginate_breakInsideAvoidPreventsParagraphSplit() = runTest {
val block1 = ParagraphBlock(
content = AnnotatedString("Keep together"),
style = BlockStyle(breakInside = "avoid"),
blockIndex = 0
)
val part1 = block1.copy(content = AnnotatedString("Keep"))
val part2 = block1.copy(content = AnnotatedString("together"))
val pages = paginate(
listOf(block1),
pageHeight = 400,
measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 800, part1 to 300, part2 to 500),
splittableParagraphs = mapOf(block1 to (part1 to part2))
),
density = testDensity
)
assertThat(pages).hasSize(1)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content).containsExactly(block2)
}
@Test
@ -195,8 +110,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1, part1).inOrder()
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(part2)
assertThat(pages[0].content).containsExactly(block1, part1).inOrder()
assertThat(pages[1].content).containsExactly(part2)
}
@Test
@ -221,8 +136,8 @@ class PaginatorTest {
val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(splitWrapper)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(para2)
assertThat(pages[0].content).containsExactly(splitWrapper)
assertThat(pages[1].content).containsExactly(para2)
}
@ -243,8 +158,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(unsplittableBlock)
assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content).containsExactly(unsplittableBlock)
}
@Test
@ -257,7 +172,7 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(oversizedBlock)
assertThat(pages[0].content).containsExactly(oversizedBlock)
}
@Test
@ -333,8 +248,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(splittableBlock) // Was not split
assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content).containsExactly(splittableBlock) // Was not split
}
@Test
@ -356,7 +271,8 @@ class PaginatorTest {
// Set page height so that a split is attempted.
val pages = paginate(blocks, 150, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
// Empty split heads are skipped so pagination keeps only the remaining content.
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(part2)
// The page should be empty because part1 was empty, and the original block was re-added
// to the remaining list. The next page then contains the full block.
assertThat(pages[0].content).containsExactly(part2)
}
}

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
// 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 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
package org.dueattendant149.bookreader.tts
package com.aryan.reader.tts
import android.speech.tts.TextToSpeech
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.ExperimentalCoroutinesApi

View file

@ -1,5 +1,5 @@
// TtsUtilsTest.kt
package org.dueattendant149.bookreader.tts
package com.aryan.reader.tts
import com.google.common.truth.Truth.assertThat
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.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.RequiresApi
import org.dueattendant149.bookreader.RenderMode
import org.dueattendant149.bookreader.epub.EpubBook
import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubBook
import kotlinx.serialization.json.Json
class EpubTestActivity : ComponentActivity() {
@ -31,7 +31,7 @@ class EpubTestActivity : ComponentActivity() {
coverImagePath = null,
onRenderModeChange = {},
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

View file

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

View file

@ -55,22 +55,7 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</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 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -169,19 +154,6 @@
<data android:mimeType="application/x-cb7" />
</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 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

View file

@ -128,15 +128,8 @@
background-color: rgba(255, 236, 179, 0.8);
/* Semi-transparent Gold */
color: black !important;
display: inline !important;
text-align: initial !important;
text-align-last: auto !important;
letter-spacing: normal !important;
word-spacing: normal !important;
padding: 0.1em 0;
border-radius: 3px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
html.dark-theme span.tts-highlight {
@ -1503,16 +1496,6 @@
};
const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS";
const TTS_HIGHLIGHT_BLOCK_SELECTOR = "p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th";
function getTtsHighlightBlock(node) {
if (!node) {
return document.body;
}
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return (element && element.closest(TTS_HIGHLIGHT_BLOCK_SELECTOR)) || document.body;
}
window.highlightFromCfi = function (cfi, textToHighlight, startOffset) {
console.log(`$ {
@ -1573,10 +1556,9 @@
, Text content: '${(location.node.textContent || "").substring(0, 50)}...' `);
const baseNode = location.node;
const highlightRoot = getTtsHighlightBlock(baseNode);
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;
let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode();
@ -1644,7 +1626,7 @@
} else {
remainingTextLength -= availableLength;
// Important: We need a fresh walker starting from the endNode to find the *next* text node reliably
const nextNodeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
const nextNodeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
nextNodeWalker.currentNode = endNode;
endNode = nextNodeWalker.nextNode();
endOffset = 0; // Start from the beginning of the next node
@ -1695,14 +1677,11 @@
TTS_HIGHLIGHT_LOG_TAG
}
: surroundContents failed, using same-block fallback. Error: $ {
: surroundContents failed, using fallback. Error: $ {
e.message
}
`);
if (!highlightRoot.contains(range.commonAncestorContainer)) {
return "JS: Highlight range escaped current block.";
}
const contents = range.extractContents();
highlightSpan.appendChild(contents);
range.insertNode(highlightSpan);
@ -2945,29 +2924,6 @@
};
const HL_LOG_TAG = "HIGHLIGHT_DEBUG";
const HL_RENDER_LOG_TAG = "AndroidHighlightRenderDiag";
function hlRenderPreview(value, maxLength) {
if (value === undefined || value === null) return "";
var text = String(value).replace(/\s+/g, " ");
var max = maxLength || 100;
return text.length > max ? text.substring(0, max) : text;
}
function hlRenderLocatorLabel(locator) {
locator = locator || {};
return "locatorChapter=" + (locator.chapterIndex === undefined || locator.chapterIndex === null ? "null" : locator.chapterIndex) +
" locatorPage=" + (locator.pageIndex === undefined || locator.pageIndex === null ? "null" : locator.pageIndex) +
" locatorOffsets=" + (locator.startOffset === undefined || locator.startOffset === null ? "null" : locator.startOffset) +
".." + (locator.endOffset === undefined || locator.endOffset === null ? "null" : locator.endOffset) +
" locatorBlock=" + (locator.blockIndex === undefined || locator.blockIndex === null ? "null" : locator.blockIndex) +
" locatorChar=" + (locator.charOffset === undefined || locator.charOffset === null ? "null" : locator.charOffset) +
" locatorCfi=" + hlRenderPreview(locator.cfi || "", 120);
}
function hlRenderLog(message) {
console.log(HL_RENDER_LOG_TAG + ": " + message);
}
window.HighlightBridgeHelper = {
updateHighlightStyle: function (cfi, newColorClass, colorId) {
@ -3324,13 +3280,11 @@
try {
var highlights = JSON.parse(jsonArrayString);
var self = this;
hlRenderLog("webview_restore_start count=" + highlights.length);
highlights.forEach(function (h) {
self.applyHighlightObject(h);
self.applyHighlight(h.cfi, h.text, h.cssClass);
});
} catch (e) {
hlRenderLog("webview_restore_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
console.log(
`$ {
HL_LOG_TAG
@ -3341,376 +3295,136 @@
}
},
applyHighlightObject: function (highlight) {
if (!highlight) return;
hlRenderLog(
"webview_apply_object id=" + (highlight.id || "") +
" cfi=" + hlRenderPreview(highlight.cfi || "", 120) +
" textLen=" + String(highlight.text || "").length +
" text='" + hlRenderPreview(highlight.text || "", 80) + "' " +
hlRenderLocatorLabel(highlight.locator || {})
);
this.applyHighlight(highlight.cfi, highlight.text, highlight.cssClass, highlight.locator || null);
},
highlightTextRoot: function () {
return document.getElementById("content-container") || document.body;
},
highlightTextNodes: function (root) {
var nodes = [];
if (!root) return nodes;
var walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode: function (node) {
if (!node || !node.nodeValue) return NodeFilter.FILTER_REJECT;
var parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
if (parent.closest && parent.closest("script, style, noscript")) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
},
},
false,
);
while (walker.nextNode()) nodes.push(walker.currentNode);
return nodes;
},
rangeFromTextOffsets: function (root, startOffset, endOffset) {
var start = parseInt(startOffset, 10);
var end = parseInt(endOffset, 10);
if (!root || !isFinite(start) || !isFinite(end) || end <= start) {
hlRenderLog(
"webview_range_offsets_skip reason=invalid_offsets start=" + startOffset +
" end=" + endOffset + " hasRoot=" + !!root
);
return null;
}
var nodes = this.highlightTextNodes(root);
var cursor = 0;
var startNode = null;
var startInNode = 0;
var endNode = null;
var endInNode = 0;
for (var i = 0; i < nodes.length; i++) {
var value = nodes[i].nodeValue || "";
var next = cursor + value.length;
if (!startNode && start >= cursor && start <= next) {
startNode = nodes[i];
startInNode = Math.max(0, Math.min(value.length, start - cursor));
}
if (startNode && end >= cursor && end <= next) {
endNode = nodes[i];
endInNode = Math.max(0, Math.min(value.length, end - cursor));
break;
}
cursor = next;
}
if (!startNode || !endNode) {
hlRenderLog(
"webview_range_offsets_skip reason=missing_boundary start=" + start +
" end=" + end + " nodes=" + nodes.length + " textCursor=" + cursor
);
return null;
}
var range = document.createRange();
range.setStart(startNode, startInNode);
range.setEnd(endNode, endInNode);
if (range.collapsed) {
hlRenderLog("webview_range_offsets_skip reason=collapsed start=" + start + " end=" + end);
return null;
}
hlRenderLog(
"webview_range_offsets_result start=" + start + " end=" + end +
" text='" + hlRenderPreview(range.toString(), 80) + "'"
);
return range;
},
rangeMatchesText: function (range, text) {
if (!range || !text) return true;
var actual = (range.toString() || "").trim();
var expected = String(text || "").trim();
if (!expected) return true;
return actual === expected || actual.replace(/\s+/g, " ") === expected.replace(/\s+/g, " ");
},
rangeFromVisibleTextSearch: function (text, locator) {
if (!text) {
hlRenderLog("webview_text_search_skip reason=empty_text " + hlRenderLocatorLabel(locator));
return null;
}
var root = this.highlightTextRoot();
var nodes = this.highlightTextNodes(root);
if (!nodes.length) {
hlRenderLog("webview_text_search_skip reason=no_nodes " + hlRenderLocatorLabel(locator));
return null;
}
var fullText = nodes.map(function (node) { return node.nodeValue || ""; }).join("");
var expected = String(text);
var candidates = [];
var index = fullText.indexOf(expected);
while (index !== -1) {
candidates.push(index);
index = fullText.indexOf(expected, index + 1);
}
if (!candidates.length) {
var lowerFull = fullText.toLowerCase();
var lowerExpected = expected.toLowerCase();
index = lowerFull.indexOf(lowerExpected);
while (index !== -1) {
candidates.push(index);
index = lowerFull.indexOf(lowerExpected, index + 1);
}
}
if (!candidates.length && expected.length > 20) {
var partial = expected.substring(0, Math.min(expected.length, 40));
index = fullText.indexOf(partial);
while (index !== -1) {
candidates.push(index);
index = fullText.indexOf(partial, index + 1);
}
}
if (!candidates.length) {
hlRenderLog(
"webview_text_search_skip reason=no_candidates textLen=" + expected.length +
" text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator)
);
return null;
}
var preferred = locator && locator.startOffset !== undefined && locator.startOffset !== null
? parseInt(locator.startOffset, 10)
: candidates[0];
if (!isFinite(preferred)) preferred = candidates[0];
var best = candidates.reduce(function (currentBest, candidate) {
return Math.abs(candidate - preferred) < Math.abs(currentBest - preferred) ? candidate : currentBest;
}, candidates[0]);
hlRenderLog(
"webview_text_search_candidate count=" + candidates.length +
" preferred=" + preferred + " best=" + best +
" text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator)
);
return this.rangeFromTextOffsets(root, best, best + expected.length);
},
rangeFromLocator: function (locator, text) {
if (!locator) {
hlRenderLog("webview_locator_skip reason=missing_locator");
return null;
}
if (locator.cfi && String(locator.cfi).charAt(0) === "/") {
hlRenderLog(
"webview_locator_skip reason=structural_cfi_prefers_cfi " +
hlRenderLocatorLabel(locator)
);
return null;
}
var root = this.highlightTextRoot();
var range = this.rangeFromTextOffsets(root, locator.startOffset, locator.endOffset);
if (range && this.rangeMatchesText(range, text)) {
hlRenderLog(
"webview_locator_result matched=true text='" + hlRenderPreview(range.toString(), 80) + "' " +
hlRenderLocatorLabel(locator)
);
return range;
}
hlRenderLog(
"webview_locator_skip reason=" + (range ? "text_mismatch" : "range_missing") +
" rangeText='" + hlRenderPreview(range ? range.toString() : "", 80) + "' " +
"expected='" + hlRenderPreview(text || "", 80) + "' " + hlRenderLocatorLabel(locator)
);
return null;
},
rangeFromCfi: function (cfi, text) {
if (!cfi || cfi.indexOf("desktop:") === 0) {
hlRenderLog("webview_cfi_skip reason=unsupported cfi=" + hlRenderPreview(cfi || "", 120));
return null;
}
var sourceCfi = String(cfi).split("|")[0];
const location = window.getNodeAndOffsetFromCfi(sourceCfi);
if (!location || !location.node) {
hlRenderLog("webview_cfi_skip reason=missing_location cfi=" + hlRenderPreview(cfi, 120));
return null;
}
let startNode = location.node;
let startOffset = location.offset;
if (startNode.nodeType === Node.TEXT_NODE) {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
walker.currentNode = startNode;
while (startNode && startOffset >= startNode.nodeValue.length) {
if (startOffset === startNode.nodeValue.length) {
const next = walker.nextNode();
if (next) {
startOffset -= startNode.nodeValue.length;
startNode = next;
} else {
break;
}
} else {
startOffset -= startNode.nodeValue.length;
startNode = walker.nextNode();
}
}
}
if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) {
const nodeVal = startNode.nodeValue;
const substring = nodeVal.substring(startOffset, startOffset + text.length);
if (substring !== text && substring.trim() !== text.trim()) {
hlRenderLog(
"webview_cfi_text_mismatch cfi=" + hlRenderPreview(cfi, 120) +
" startOffset=" + startOffset +
" actual='" + hlRenderPreview(substring, 80) + "'" +
" expected='" + hlRenderPreview(text, 80) + "'"
);
console.log("HIGHLIGHT_DEBUG: Text mismatch at CFI. Searching nearby.");
const foundIndex = nodeVal.indexOf(text);
if (foundIndex !== -1) {
hlRenderLog(
"webview_cfi_text_adjust reason=full_match oldStartOffset=" + startOffset +
" newStartOffset=" + foundIndex + " cfi=" + hlRenderPreview(cfi, 120)
);
startOffset = foundIndex;
} else {
const partial = text.substring(0, Math.min(text.length, 20));
const partialIndex = nodeVal.indexOf(partial);
if (partialIndex !== -1) {
hlRenderLog(
"webview_cfi_text_adjust reason=partial_match oldStartOffset=" + startOffset +
" newStartOffset=" + partialIndex + " cfi=" + hlRenderPreview(cfi, 120)
);
startOffset = partialIndex;
}
}
}
}
if (!startNode) {
hlRenderLog("webview_cfi_skip reason=missing_start_node cfi=" + hlRenderPreview(cfi, 120));
return null;
}
const range = document.createRange();
if (startNode.nodeType === Node.TEXT_NODE && startOffset > startNode.nodeValue.length) {
startOffset = Math.max(0, startNode.nodeValue.length - 1);
}
range.setStart(startNode, startOffset);
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
treeWalker.currentNode = startNode;
let remainingLen = text.length;
let endNode = treeWalker.currentNode;
let endOffset = startOffset;
while (remainingLen > 0 && endNode) {
let avail = endNode.nodeValue.length - endOffset;
if (avail >= remainingLen) {
endOffset += remainingLen;
remainingLen = 0;
} else {
remainingLen -= avail;
endNode = treeWalker.nextNode();
endOffset = 0;
}
}
if (!endNode) {
hlRenderLog("webview_cfi_skip reason=missing_end_node cfi=" + hlRenderPreview(cfi, 120));
return null;
}
range.setEnd(endNode, endOffset);
if (range.collapsed) {
hlRenderLog("webview_cfi_skip reason=collapsed cfi=" + hlRenderPreview(cfi, 120));
return null;
}
hlRenderLog(
"webview_cfi_result cfi=" + hlRenderPreview(cfi, 120) +
" text='" + hlRenderPreview(range.toString(), 80) + "'"
);
return range;
},
applyHighlight: function (cfi, text, cssClass, locator) {
applyHighlight: function (cfi, text, cssClass) {
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)) {
if ((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;
if (alreadyApplied) return;
const location = window.getNodeAndOffsetFromCfi(cfi);
if (!location || !location.node) return;
let startNode = location.node;
let startOffset = location.offset;
if (startNode.nodeType === Node.TEXT_NODE) {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
walker.currentNode = startNode;
while (startNode && startOffset >= startNode.nodeValue.length) {
if (startOffset === startNode.nodeValue.length) {
const next = walker.nextNode();
if (next) {
startOffset -= startNode.nodeValue.length;
startNode = next;
} else {
break;
}
} else {
startOffset -= startNode.nodeValue.length;
startNode = walker.nextNode();
}
}
}
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 || "");
// 1. Text Verification / Healing
if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) {
const nodeVal = startNode.nodeValue;
// Check if text matches at exact offset
const substring = nodeVal.substring(startOffset, startOffset + text.length);
// Allow for some whitespace looseness (trim comparison)
if (substring !== text && substring.trim() !== text.trim()) {
console.log(`$ {
HL_LOG_TAG
}
: Text mismatch at CFI. Searching nearby... Expected: '${text.substring(0, 10)}...', Found: '${substring.substring(0, 10)}...' `);
// Try finding the text in the whole node
const foundIndex = nodeVal.indexOf(text);
if (foundIndex !== -1) {
console.log(`$ {
HL_LOG_TAG
}
: Found text elsewhere in node. Adjusting offset from $ {
startOffset
}
to $ {
foundIndex
}
.`);
startOffset = foundIndex;
} else {
// Simple fuzzy: Try finding first 20 chars
const partial = text.substring(0, Math.min(text.length, 20));
const partialIndex = nodeVal.indexOf(partial);
if (partialIndex !== -1) {
console.log(`$ {
HL_LOG_TAG
}
: Found partial match. Adjusting offset.`);
startOffset = partialIndex;
}
}
}
}
if (!range) {
rangeSource = "locator";
range = this.rangeFromLocator(locator, text);
if (!startNode) return;
const range = document.createRange();
// Set Start
if (startNode.nodeType === Node.TEXT_NODE) {
// Ensure offset is valid
if (startOffset > startNode.nodeValue.length) {
startOffset = Math.max(0, startNode.nodeValue.length - 1);
}
}
if (!range && !hasPreciseLocator && !hasSourceCfi) {
rangeSource = "text_search";
range = this.rangeFromVisibleTextSearch(text || "", locator);
range.setStart(startNode, startOffset);
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
treeWalker.currentNode = startNode;
let currentNode = treeWalker.currentNode;
let remainingOffset = startOffset;
let remainingLen = text.length;
let endNode = currentNode;
let endOffset = startOffset;
while (remainingLen > 0 && endNode) {
let avail = endNode.nodeValue.length - endOffset;
if (avail >= remainingLen) {
endOffset += remainingLen;
remainingLen = 0;
} else {
remainingLen -= avail;
endNode = treeWalker.nextNode();
endOffset = 0;
}
}
if (!range) {
hlRenderLog(
"webview_apply_skip reason=no_range cfi=" + hlRenderPreview(cfi || "", 120) +
" hasPreciseLocator=" + !!hasPreciseLocator + " " + hlRenderLocatorLabel(locator)
);
return;
if (endNode) {
range.setEnd(endNode, endOffset);
var normalizedRange = this.normalizeRangeBoundaries(range);
this.highlightRangeSafe(normalizedRange, cssClass, cfi);
}
var normalizedRange = this.normalizeRangeBoundaries(range);
this.highlightRangeSafe(normalizedRange, cssClass, cfi);
hlRenderLog(
"webview_apply_result applied=true cfi=" + hlRenderPreview(cfi || "", 120) +
" source=" + rangeSource +
" renderedText='" + hlRenderPreview(normalizedRange.toString(), 80) + "'"
);
} catch (e) {
hlRenderLog("webview_apply_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
console.log(e);
}
},

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.Column

View file

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

View file

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

View file

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

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.FontFamily
import org.dueattendant149.bookreader.data.CustomFontEntity
import com.aryan.reader.data.CustomFontEntity
import java.io.File
fun AppFontPreference.toAndroidAppFontFamily(customFonts: List<CustomFontEntity>): FontFamily? {

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader
package com.aryan.reader
import android.util.Xml
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.net.Uri
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import org.dueattendant149.bookreader.data.BookMetadataEdit
import org.dueattendant149.bookreader.data.RecentFileItem
import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataEditor
import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataSnapshot
import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataUpdate
import com.aryan.reader.data.BookMetadataEdit
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.shared.reader.SharedEpubMetadataEditor
import com.aryan.reader.shared.reader.SharedEpubMetadataSnapshot
import com.aryan.reader.shared.reader.SharedEpubMetadataUpdate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

View file

@ -17,11 +17,10 @@
*
* mail: epistemereader@gmail.com
*/
package org.dueattendant149.bookreader
package com.aryan.reader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.InputStream
import java.security.MessageDigest
@ -51,7 +50,8 @@ object FileHasher {
}
hexString.toString()
} 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
}
}

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? {
return SharedFileCapabilities.resolveFileTypeForName(fileName)

View file

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

View file

@ -1,13 +1,10 @@
// FontsScreen.kt
@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.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
@ -51,7 +46,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -64,21 +58,14 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookreader.shared.CustomFontItem
import org.dueattendant149.bookreader.shared.CustomFontFamilyItem
import org.dueattendant149.bookreader.shared.CustomFontVariantItem
import org.dueattendant149.bookreader.shared.fontFaceLabel
import org.dueattendant149.bookreader.shared.fontFaceSummary
import org.dueattendant149.bookreader.shared.groupByFamily
import org.dueattendant149.bookreader.shared.hasVariableWeightFace
import org.dueattendant149.bookreader.shared.ui.SharedAppFontSelector
import org.dueattendant149.bookreader.shared.ui.SharedFontSettingsSection
import org.dueattendant149.bookreader.shared.ui.SharedFontSettingsTabs
import org.dueattendant149.bookreader.data.CustomFontEntity
import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.ui.SharedAppFontSelector
import com.aryan.reader.shared.ui.SharedFontSettingsSection
import com.aryan.reader.shared.ui.SharedFontSettingsTabs
import com.aryan.reader.data.CustomFontEntity
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@ -93,68 +80,37 @@ fun FontsScreen(
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 selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
var selectedFontIds by remember { mutableStateOf<Set<String>>(emptySet()) }
val pickFontLauncher = rememberFilePickerLauncher(viewModel::importFonts)
val fontMimeTypes = remember { supportedFontMimeTypes() }
val allFontIds = remember(fonts) { fonts.mapTo(mutableSetOf()) { it.id } }
val selectedFonts = remember(fonts, selectedFontIds) {
fonts.filter { it.id in selectedFontIds }
}
val fontEntitiesById = remember(fonts) { fonts.associateBy { it.id } }
val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty()
LaunchedEffect(fonts) {
selectedFontIds = selectedFontIds.intersect(allFontIds)
val pickFontLauncher = rememberFilePickerLauncher { uris ->
uris.firstOrNull()?.let { viewModel.importFont(it) }
}
BackHandler(enabled = isFontSelectionMode) {
selectedFontIds = emptySet()
}
val fontMimeTypes = arrayOf(
"font/ttf", "font/otf", "font/woff2",
"application/x-font-ttf", "application/x-font-otf",
"application/font-woff2", "application/vnd.ms-opentype",
"application/x-font-opentype"
)
Scaffold(
modifier = Modifier.statusBarsPadding(),
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
}
CustomTopAppBar(
title = { Text(stringResource(R.string.custom_fonts)) },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
)
} else {
CustomTopAppBar(
title = { Text(stringResource(R.string.custom_fonts)) },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
IconButton(onClick = { selectedFontIds = allFontIds }) {
Icon(Icons.Default.SelectAll, contentDescription = stringResource(R.string.select_all))
}
}
}
)
}
}
)
},
floatingActionButton = {
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty() && !isFontSelectionMode) {
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp)
@ -180,14 +136,10 @@ fun FontsScreen(
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
val fontFamilies = remember(sharedFonts) { sharedFonts.groupByFamily() }
Column(modifier = Modifier.fillMaxSize()) {
SharedFontSettingsTabs(
selectedSection = selectedSection,
onSectionChange = {
selectedFontIds = emptySet()
selectedSection = it
},
onSectionChange = { selectedSection = it },
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),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(fontFamilies, key = { family -> family.variants.joinToString("|") { it.font.id } }) { family ->
FontFamilyListItem(
family = family,
selectedFontIds = selectedFontIds,
isSelectionMode = isFontSelectionMode,
fontEntityForId = { id -> fontEntitiesById[id] },
onVariantSelectionToggle = { id ->
selectedFontIds = selectedFontIds.toggle(id)
},
onFamilySelectionToggle = {
selectedFontIds = selectedFontIds.toggleAll(family.variants.map { it.font.id })
},
onDeleteVariant = { id ->
fontEntitiesById[id]?.let { fontsPendingDelete = listOf(it) }
items(fonts, key = { it.id }) { font ->
FontListItem(
font = font,
onDelete = {
fontToDelete = font
showDeleteDialog = true
}
)
}
@ -264,17 +208,17 @@ fun FontsScreen(
}
}
if (fontsPendingDelete.isNotEmpty()) {
DeleteFontsConfirmationDialog(
fonts = fontsPendingDelete,
if (showDeleteDialog && fontToDelete != null) {
DeleteFontConfirmationDialog(
fontName = fontToDelete!!.displayName,
onConfirm = {
val pendingIds = fontsPendingDelete.map { it.id }
viewModel.deleteFonts(pendingIds)
selectedFontIds = selectedFontIds - pendingIds.toSet()
fontsPendingDelete = emptyList()
fontToDelete?.let { viewModel.deleteFont(it.id) }
showDeleteDialog = false
fontToDelete = null
},
onDismiss = {
fontsPendingDelete = emptyList()
showDeleteDialog = false
fontToDelete = null
}
)
}
@ -444,13 +388,9 @@ fun GoogleFontsBottomSheet(
}
// Existing unchanged components
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FontListItem(
font: CustomFontEntity,
isSelected: Boolean,
isSelectionMode: Boolean,
onSelectionToggle: () -> Unit,
onDelete: () -> Unit
) {
val customTypeface = remember(font.path) {
@ -462,23 +402,8 @@ fun FontListItem(
}
Card(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
if (isSelectionMode) {
onSelectionToggle()
}
},
onLongClick = onSelectionToggle
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
} else {
MaterialTheme.colorScheme.surface
}
)
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
@ -486,27 +411,17 @@ fun FontListItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (isSelectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { onSelectionToggle() },
modifier = Modifier.padding(end = 8.dp)
)
}
Text(
text = font.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
fontWeight = FontWeight.Bold
)
if (!isSelectionMode) {
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error
)
}
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error
)
}
}
@ -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> {
return filterNot { it.isDeleted }
.sortedBy { it.displayName.lowercase() }
@ -743,32 +476,15 @@ private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontIte
}
@Composable
fun DeleteFontsConfirmationDialog(
fonts: List<CustomFontEntity>,
fun DeleteFontConfirmationDialog(
fontName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val isSingleFont = fonts.size == 1
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
if (isSingleFont) {
stringResource(R.string.dialog_delete_font)
} else {
stringResource(R.string.dialog_delete_fonts)
}
)
},
text = {
Text(
if (isSingleFont) {
stringResource(R.string.dialog_delete_font_desc, fonts.first().displayName)
} else {
stringResource(R.string.dialog_delete_fonts_desc, fonts.size)
}
)
},
title = { Text(stringResource(R.string.dialog_delete_font)) },
text = { Text(stringResource(R.string.dialog_delete_font_desc, fontName)) },
confirmButton = {
TextButton(
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
@file:Suppress("DEPRECATION")
package org.dueattendant149.bookreader
package com.aryan.reader
import android.annotation.SuppressLint
import android.app.Activity
@ -130,7 +130,6 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -139,13 +138,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import coil.request.ImageRequest
import org.dueattendant149.bookreader.data.RecentFileItem
import com.aryan.reader.data.RecentFileItem
import kotlinx.coroutines.launch
import timber.log.Timber
import java.text.SimpleDateFormat
@ -194,7 +192,6 @@ fun HomeScreen(
var showAboutDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
var showBehaviorDialog by remember { mutableStateOf(false) }
var showStrictFilterDialog 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) {
if (uiState.isRequestingDrivePermission) {
val intent = viewModel.getDriveSignInIntent(context)
@ -421,12 +389,6 @@ fun HomeScreen(
showInfoDialog = true
}
},
onSaveClick = selectedContextItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { saveOriginalItem(item) } },
onShareClick = selectedContextItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { shareOriginalItem(item) } },
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllRecentFiles() })
@ -474,7 +436,7 @@ fun HomeScreen(
onRefresh = { viewModel.refreshLibrary() },
isRefreshing = uiState.isRefreshing,
isSyncEnabled = uiState.isSyncEnabled,
hasSyncedFolder = uiState.syncedFolders.any { it.localSyncEnabled },
hasSyncedFolder = uiState.syncedFolders.isNotEmpty(),
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
)
}
@ -539,17 +501,28 @@ fun HomeScreen(
)
}
HydratedFileInfoDialog(
item = itemForInfoDialog,
isVisible = showInfoDialog,
uiState = uiState,
viewModel = viewModel,
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
)
itemForInfoDialog?.let { item ->
if (showInfoDialog) {
FileInfoDialog(
item = item,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onSaveMetadata = { metadata ->
viewModel.updateBookMetadata(item.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(item.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(item.bookId)
},
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
)
}
}
if (showClearReflowCacheDialog) {
DangerousFolderActionDialog(
@ -794,8 +767,7 @@ private fun RecentFilesGrid(
modifier = Modifier.size(16.dp)
)
}
},
modifier = Modifier.testTag("HomeTab_${tab.bookId}")
}
)
}
}
@ -845,7 +817,6 @@ fun RecentFileCard(
androidx.compose.material3.ElevatedCard(
modifier = modifier
.testTag("HomeRecentFileCard_${item.bookId}")
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
@ -1514,7 +1485,7 @@ private fun AppDrawerContent(
Spacer(modifier = Modifier.weight(1f))
// legal links
if (uiState.currentUser != null || (isOss && !BuildConfig.IS_OFFLINE)) {
if (uiState.currentUser != null && !isOss) {
val uriHandler = LocalUriHandler.current
val baseStyle = MaterialTheme.typography.labelMedium
var scaledTextStyle by remember { mutableStateOf(baseStyle) }
@ -1812,14 +1783,9 @@ fun ExternalFileBehaviorDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.options_external_file_behavior)) },
text = {
Column(modifier = Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) {
val options = listOf(
Triple("ASK", R.string.external_file_behavior_ask, R.string.external_file_behavior_ask_desc),
Triple("KEEP", R.string.external_file_behavior_keep, R.string.external_file_behavior_keep_desc),
Triple("DELETE", R.string.external_file_behavior_delete, R.string.external_file_behavior_delete_desc),
Triple("TEMPORARY", R.string.external_file_behavior_temporary, R.string.external_file_behavior_temporary_desc)
)
options.forEach { (value, labelRes, descriptionRes) ->
Column {
val options = listOf("ASK" to R.string.external_file_behavior_ask, "KEEP" to R.string.external_file_behavior_keep, "DELETE" to R.string.external_file_behavior_delete)
options.forEach { (value, labelRes) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@ -1829,14 +1795,7 @@ fun ExternalFileBehaviorDialog(
) {
RadioButton(selected = currentBehavior == value, onClick = null)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(labelRes))
Text(
text = stringResource(descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(stringResource(labelRes))
}
}
}

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

View file

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

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_FOLDER_SYNC_ENABLED = "folder_sync_enabled"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader
package com.aryan.reader
import android.content.Context
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -33,37 +28,20 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import org.dueattendant149.bookreader.shared.ui.ReaderMinimalSlider
import kotlin.math.roundToInt
private const val READER_PREFS_NAME = "reader_prefs"
private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system"
private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value"
private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f
private const val MIN_CUSTOM_BRIGHTNESS_PERCENT = 1
private const val MAX_CUSTOM_BRIGHTNESS_PERCENT = 100
private const val CUSTOM_BRIGHTNESS_STEP_PERCENT = 1
private const val MIN_CUSTOM_BRIGHTNESS = 0.01f
private const val MIN_CUSTOM_BRIGHTNESS = 0.05f
data class ReaderBrightnessSettings(
val useSystemBrightness: Boolean = true,
val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS
) {
val safeCustomBrightness: Float
get() = normalizeReaderBrightness(customBrightness)
}
internal fun normalizeReaderBrightness(brightness: Float): Float {
val percent = (brightness * 100f).roundToInt()
.coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
return percent / 100f
}
internal fun stepReaderBrightness(brightness: Float, percentDelta: Int): Float {
val currentPercent = (normalizeReaderBrightness(brightness) * 100f).roundToInt()
val nextPercent = (currentPercent + percentDelta)
.coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
return nextPercent / 100f
get() = customBrightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
}
fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
@ -71,7 +49,7 @@ fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
return ReaderBrightnessSettings(
useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true),
customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS)
.let(::normalizeReaderBrightness)
.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
)
}
@ -184,9 +162,17 @@ fun ReaderBrightnessSheet(
color = MaterialTheme.colorScheme.primary
)
}
ReaderBrightnessControl(
settings = settings,
onSettingsChange = onSettingsChange
Slider(
value = settings.safeCustomBrightness,
onValueChange = { brightness ->
onSettingsChange(
settings.copy(
useSystemBrightness = false,
customBrightness = brightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
)
)
},
valueRange = MIN_CUSTOM_BRIGHTNESS..1f
)
Text(
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) {
attributes = attributes.apply {
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import org.dueattendant149.bookreader.data.RecentFileItem
import com.aryan.reader.data.RecentFileItem
@Composable
private fun rememberReaderFileInfoItem(
@ -67,17 +64,28 @@ internal fun ReaderFileInfoDialogs(
}
}
HydratedFileInfoDialog(
item = item,
isVisible = isFileInfoVisible,
uiState = uiState,
viewModel = viewModel,
onDismiss = { onFileInfoVisibleChange(false) },
onOpenTags = { bookId ->
onFileInfoVisibleChange(false)
viewModel.openTagSelection(setOf(bookId))
item?.let { fileInfoItem ->
if (isFileInfoVisible) {
FileInfoDialog(
item = fileInfoItem,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = { onFileInfoVisibleChange(false) },
onSaveMetadata = { metadata ->
viewModel.updateBookMetadata(fileInfoItem.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(fileInfoItem.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId)
},
onOpenTags = {
onFileInfoVisibleChange(false)
viewModel.openTagSelection(setOf(fileInfoItem.bookId))
}
)
}
)
}
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
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 androidx.core.content.edit

View file

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

View file

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

View file

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

View file

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

View file

@ -18,7 +18,7 @@
* mail: epistemereader@gmail.com
*/
// SharedComposables.kt
package org.dueattendant149.bookreader
package com.aryan.reader
import android.content.Context
import android.content.Intent
@ -43,7 +43,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import org.dueattendant149.bookreader.data.TagEntity
import com.aryan.reader.data.TagEntity
import androidx.compose.material.icons.filled.Search
import androidx.compose.foundation.BorderStroke
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.Save
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.FileOpen
import androidx.compose.material.icons.outlined.Gavel
import androidx.compose.material.icons.outlined.Policy
@ -143,14 +142,10 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.core.text.HtmlCompat
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
import org.dueattendant149.bookreader.shared.SharedLegalLinks
import org.dueattendant149.bookreader.shared.SharedLegalProfile
import org.dueattendant149.bookreader.data.BookMetadataEdit
import org.dueattendant149.bookreader.data.RecentFileItem
import org.dueattendant149.bookreader.shared.SharedText
import org.dueattendant149.bookreader.shared.sharedLegalLinksForProfile
import org.dueattendant149.bookreader.shared.ui.SharedMarkdownText
import com.aryan.reader.data.BookMetadataEdit
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.shared.SharedText
import com.aryan.reader.shared.ui.SharedMarkdownText
import timber.log.Timber
import java.text.SimpleDateFormat
import java.util.Date
@ -159,25 +154,9 @@ import kotlin.math.log10
import kotlin.math.pow
import kotlin.math.roundToInt
internal fun legalLinksForAndroidFlavor(flavor: String = BuildConfig.FLAVOR): SharedLegalLinks {
val profile = if (flavor == "oss") SharedLegalProfile.OSS else SharedLegalProfile.STANDARD
return sharedLegalLinksForProfile(profile)
}
internal val PRIVACY_POLICY_URL: String get() = legalLinksForAndroidFlavor().privacyPolicyUrl
internal val TERMS_URL: String get() = legalLinksForAndroidFlavor().termsUrl
internal val LICENSES_URL: String get() = legalLinksForAndroidFlavor().licensesUrl
fun supportedFontMimeTypes(): Array<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"
)
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
internal const val LICENSES_URL = "https://aryan-raj3112.github.io/reader-policy/licenses.html"
class CustomTabUriHandler(private val context: Context) : UriHandler {
override fun openUri(uri: String) {
@ -276,8 +255,6 @@ fun ContextualTopAppBar(
selectedItemCount: Int,
onNavIconClick: () -> Unit,
onInfoClick: (() -> Unit)? = null,
onSaveClick: (() -> Unit)? = null,
onShareClick: (() -> Unit)? = null,
onTagClick: (() -> Unit)? = null,
onSelectAllClick: (() -> Unit)? = null,
onPinClick: (() -> Unit)? = null,
@ -306,16 +283,6 @@ fun ContextualTopAppBar(
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.info))
}
}
if (selectedItemCount == 1 && onSaveClick != null) {
IconButton(onClick = onSaveClick) {
Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.action_save_copy_to_device))
}
}
if (selectedItemCount == 1 && onShareClick != null) {
IconButton(onClick = onShareClick) {
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.action_share))
}
}
if (onSelectAllClick != null) {
IconButton(onClick = onSelectAllClick) {
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all))
@ -1270,55 +1237,53 @@ fun AboutDialog(onDismiss: () -> Unit) {
subtitle = stringResource(R.string.about_github_desc),
onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme") }
)
} else {
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.Policy,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.legal_privacy_policy),
subtitle = stringResource(R.string.about_privacy_desc),
onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
)
Spacer(modifier = Modifier.height(10.dp))
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.Gavel,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.legal_terms_of_service),
subtitle = stringResource(R.string.about_terms_desc),
onClick = { uriHandler.openUri(TERMS_URL) }
)
Spacer(modifier = Modifier.height(10.dp))
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.FileOpen,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.legal_licenses),
subtitle = stringResource(R.string.about_licenses_desc),
onClick = { uriHandler.openUri(LICENSES_URL) }
)
}
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.Policy,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.legal_privacy_policy),
subtitle = stringResource(R.string.about_privacy_desc),
onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
)
Spacer(modifier = Modifier.height(10.dp))
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.Gavel,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.legal_terms_of_service),
subtitle = stringResource(R.string.about_terms_desc),
onClick = { uriHandler.openUri(TERMS_URL) }
)
Spacer(modifier = Modifier.height(10.dp))
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.FileOpen,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.legal_licenses),
subtitle = stringResource(R.string.about_licenses_desc),
onClick = { uriHandler.openUri(LICENSES_URL) }
)
}
},
confirmButton = {
@ -1598,31 +1563,6 @@ fun RecentFileItem.isOpdsStream(): Boolean {
return uriString?.startsWith("opds-pse://") == true
}
fun RecentFileItem.canExportOriginalFile(): Boolean {
return uriString != null && !isOpdsStream()
}
fun RecentFileItem.suggestedOriginalFileName(): String {
val fallbackExtension = SharedFileCapabilities.primaryExtensionFor(type)
val baseName = displayName
.takeIf { it.isNotBlank() }
?: title?.takeIf { it.isNotBlank() }
?: "book"
val sanitized = baseName
.replace(Regex("""[\\/:*?"<>|]+"""), "_")
.trim()
.take(120)
.ifBlank { "book" }
return if (
fallbackExtension != null &&
!sanitized.endsWith(".$fallbackExtension", ignoreCase = true)
) {
"$sanitized.$fallbackExtension"
} else {
sanitized
}
}
@Composable
private fun statusBadgeColors(overlay: Boolean): Pair<Color, Color> {
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 org.dueattendant149.bookreader.data.BookTagCrossRef
import org.dueattendant149.bookreader.data.RecentFileItem
import org.dueattendant149.bookreader.data.ShelfEntity
import org.dueattendant149.bookreader.data.TagEntity
import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem
import org.dueattendant149.bookreader.shared.BookShelfRef as SharedBookShelfRef
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
import org.dueattendant149.bookreader.shared.FileType as SharedFileType
import org.dueattendant149.bookreader.shared.LibraryFilters as SharedLibraryFilters
import org.dueattendant149.bookreader.shared.ReaderLocator as SharedReaderLocator
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
import org.dueattendant149.bookreader.shared.Shelf as SharedShelf
import org.dueattendant149.bookreader.shared.ShelfRecord
import org.dueattendant149.bookreader.shared.SyncedFolder as SharedSyncedFolder
import org.dueattendant149.bookreader.shared.Tag as SharedTag
import org.dueattendant149.bookreader.shared.toStablePositionCfi
import com.aryan.reader.data.BookShelfCrossRef
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity
import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.BookItem as SharedBookItem
import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
import com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.FileType as SharedFileType
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.Shelf as SharedShelf
import com.aryan.reader.shared.ShelfRecord
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
import com.aryan.reader.shared.Tag as SharedTag
fun FileType.toSharedFileType(): SharedFileType = this
@ -31,21 +29,11 @@ fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder = this
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this
fun RecentFileItem.toSharedBookItem(): SharedBookItem {
return toSharedBookItem(
displayName = customName ?: displayName,
includeReaderAnnotations = true
)
}
private fun RecentFileItem.toSharedBookItem(
displayName: String,
includeReaderAnnotations: Boolean
): SharedBookItem {
return SharedBookItem(
id = bookId,
path = uriString,
type = type,
displayName = displayName,
displayName = customName ?: displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
@ -65,22 +53,13 @@ private fun RecentFileItem.toSharedBookItem(
seriesName = seriesName,
seriesIndex = seriesIndex,
lastPageIndex = lastPage,
readerPosition = toSharedReaderLocatorOrNull(),
tags = tags.map { it.toSharedTag() },
readerHighlights = if (includeReaderAnnotations) {
EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
} else {
emptyList()
},
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
)
}
fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem {
return toSharedBookItem(
displayName = displayName,
includeReaderAnnotations = false
)
return toSharedBookItem().copy(displayName = displayName)
}
fun SharedBookItem.toRecentFileItem(
@ -88,49 +67,36 @@ fun SharedBookItem.toRecentFileItem(
tagEntitiesById: Map<String, TagEntity> = emptyMap()
): RecentFileItem {
val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
val positionCfi = readerPosition?.toSharedPositionCfi()
androidBooksById[id]?.let { existing ->
val mappedLastChapterIndex = readerPosition?.chapterIndex ?: existing.lastChapterIndex
val mappedLastPositionCfi = positionCfi ?: existing.lastPositionCfi
val mappedLocatorBlockIndex = readerPosition?.blockIndex ?: existing.locatorBlockIndex
val mappedLocatorCharOffset = readerPosition?.charOffset ?: existing.locatorCharOffset
if (
existing.uriString == path &&
existing.type == type &&
existing.timestamp == timestamp &&
existing.coverImagePath == coverImagePath &&
existing.title == title &&
existing.author == author &&
existing.description == description &&
existing.originalTitle == originalTitle &&
existing.originalAuthor == originalAuthor &&
existing.originalSeriesName == originalSeriesName &&
existing.originalSeriesIndex == originalSeriesIndex &&
existing.originalDescription == originalDescription &&
existing.lastPage == lastPageIndex &&
existing.progressPercentage == progressPercentage &&
existing.isRecent == isRecent &&
existing.sourceFolderUri == sourceFolder &&
existing.fileSize == fileSize &&
existing.fileContentModifiedTimestamp == fileContentModifiedTimestamp &&
existing.seriesName == seriesName &&
existing.seriesIndex == seriesIndex &&
existing.folderTextMetadataParsed == folderTextMetadataParsed &&
existing.lastChapterIndex == mappedLastChapterIndex &&
existing.lastPositionCfi == mappedLastPositionCfi &&
existing.locatorBlockIndex == mappedLocatorBlockIndex &&
existing.locatorCharOffset == mappedLocatorCharOffset &&
existing.readingPositionModifiedTimestamp == readingPositionModifiedTimestamp &&
existing.tags == resolvedTags
) {
return existing
}
return existing.copy(
return androidBooksById[id]?.copy(tags = resolvedTags)
?.copy(
uriString = path,
type = type,
displayName = existing.displayName,
displayName = androidBooksById[id]?.displayName ?: displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
author = author,
description = description,
originalTitle = originalTitle,
originalAuthor = originalAuthor,
originalSeriesName = originalSeriesName,
originalSeriesIndex = originalSeriesIndex,
originalDescription = originalDescription,
lastPage = lastPageIndex,
progressPercentage = progressPercentage,
isRecent = isRecent,
sourceFolderUri = sourceFolder,
fileSize = fileSize,
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
seriesName = seriesName,
seriesIndex = seriesIndex,
folderTextMetadataParsed = folderTextMetadataParsed
)
?: RecentFileItem(
bookId = id,
uriString = path,
type = type,
displayName = displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
@ -150,70 +116,8 @@ fun SharedBookItem.toRecentFileItem(
seriesName = seriesName,
seriesIndex = seriesIndex,
folderTextMetadataParsed = folderTextMetadataParsed,
lastChapterIndex = mappedLastChapterIndex,
lastPositionCfi = mappedLastPositionCfi,
locatorBlockIndex = mappedLocatorBlockIndex,
locatorCharOffset = mappedLocatorCharOffset,
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
tags = resolvedTags
)
}
return RecentFileItem(
bookId = id,
uriString = path,
type = type,
displayName = displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
author = author,
description = description,
originalTitle = originalTitle,
originalAuthor = originalAuthor,
originalSeriesName = originalSeriesName,
originalSeriesIndex = originalSeriesIndex,
originalDescription = originalDescription,
lastPage = lastPageIndex,
progressPercentage = progressPercentage,
isRecent = isRecent,
sourceFolderUri = sourceFolder,
fileSize = fileSize,
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
seriesName = seriesName,
seriesIndex = seriesIndex,
folderTextMetadataParsed = folderTextMetadataParsed,
lastChapterIndex = readerPosition?.chapterIndex,
lastPositionCfi = positionCfi,
locatorBlockIndex = readerPosition?.blockIndex,
locatorCharOffset = readerPosition?.charOffset,
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
tags = resolvedTags
)
}
private fun RecentFileItem.toSharedReaderLocatorOrNull(): SharedReaderLocator? {
if (
lastChapterIndex == null &&
lastPage == null &&
lastPositionCfi.isNullOrBlank() &&
locatorBlockIndex == null &&
locatorCharOffset == null
) {
return null
}
return SharedReaderLocator.fromLegacy(
chapterIndex = lastChapterIndex,
cfi = lastPositionCfi,
pageIndex = lastPage
).withFallbacks(
blockIndex = locatorBlockIndex,
charOffset = locatorCharOffset
)
}
private fun SharedReaderLocator.toSharedPositionCfi(): String? {
return toStablePositionCfi()
}
fun TagEntity.toSharedTag(): SharedTag {
@ -263,16 +167,8 @@ fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
fun ReaderScreenState.toSharedReaderScreenState(
rawBooks: List<RecentFileItem> = rawLibraryFiles,
dbTags: List<TagEntity> = allTags,
includeReaderAnnotations: Boolean = true
dbTags: List<TagEntity> = allTags
): SharedReaderScreenState {
fun RecentFileItem.toStateSharedBookItem(): SharedBookItem {
return toSharedBookItem(
displayName = customName ?: displayName,
includeReaderAnnotations = includeReaderAnnotations
)
}
return SharedReaderScreenState(
selectedBookId = selectedBookId,
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
@ -306,16 +202,16 @@ fun ReaderScreenState.toSharedReaderScreenState(
isSearchActive = isSearchActive,
isRefreshing = isRefreshing,
reflowProgress = reflowProgress,
recentBooks = recentFiles.map { it.toStateSharedBookItem() },
libraryBooks = allRecentFiles.map { it.toStateSharedBookItem() },
rawLibraryBooks = rawBooks.map { it.toStateSharedBookItem() },
recentBooks = recentFiles.map { it.toSharedBookItem() },
libraryBooks = allRecentFiles.map { it.toSharedBookItem() },
rawLibraryBooks = rawBooks.map { it.toSharedBookItem() },
pinnedHomeBookIds = pinnedHomeBookIds,
pinnedLibraryBookIds = pinnedLibraryBookIds,
libraryFilters = libraryFilters,
recentFilesLimit = recentFilesLimit,
isTabsEnabled = isTabsEnabled,
openTabIds = openTabIds,
openTabs = openTabs.map { it.toStateSharedBookItem() },
openTabs = openTabs.map { it.toSharedBookItem() },
activeTabBookId = activeTabBookId,
showExternalFileSavePromptFor = showExternalFileSavePromptFor,
externalFileBehavior = externalFileBehavior,
@ -341,14 +237,7 @@ fun List<RecentFileItem>.withResolvedTags(
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
entry.value.mapNotNull { tagsById[it.tagId] }
}
return map { item ->
val resolvedTags = bookTagsMap[item.bookId].orEmpty()
if (item.tags == resolvedTags) {
item
} else {
item.copy(tags = resolvedTags)
}
}
return map { item -> item.copy(tags = bookTagsMap[item.bookId].orEmpty()) }
}
fun SharedReaderScreenState.toAndroidReaderScreenState(
@ -357,11 +246,8 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
tagEntitiesById: Map<String, TagEntity> = emptyMap()
): ReaderScreenState {
val fallbackBooksById = rawLibraryBooks.associateBy { it.id }
val mappedBooksById = LinkedHashMap<String, RecentFileItem>()
fun SharedBookItem.toAndroidBook(): RecentFileItem {
return mappedBooksById.getOrPut(id) {
toRecentFileItem(androidBooksById, tagEntitiesById)
}
return toRecentFileItem(androidBooksById, tagEntitiesById)
}
fun bookById(bookId: String): RecentFileItem? {
return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook()
@ -374,7 +260,7 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
isAddingBooksToShelf = isAddingBooksToShelf,
contextualActionShelfIds = selectedShelfIds,
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() },
openTabIds = openTabIds,
activeTabBookId = activeTabBookId,
@ -386,19 +272,13 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
fun SharedShelf.toAndroidShelf(
androidBooksById: Map<String, RecentFileItem> = emptyMap(),
tagEntitiesById: Map<String, TagEntity> = emptyMap()
): Shelf {
return toAndroidShelf { it.toRecentFileItem(androidBooksById, tagEntitiesById) }
}
private fun SharedShelf.toAndroidShelf(
resolveBook: (SharedBookItem) -> RecentFileItem
): Shelf {
return Shelf(
id = id,
name = name,
type = type,
books = books.map(resolveBook),
directBooks = directBooks.map(resolveBook),
books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
parentShelfId = parentShelfId,
childShelfIds = childShelfIds,
depth = depth,

View file

@ -1,5 +1,5 @@
// StorageTracker.kt
package org.dueattendant149.bookreader
package com.aryan.reader
import android.content.Context
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.border
@ -31,7 +31,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import org.dueattendant149.bookreader.data.RecentFileItem
import com.aryan.reader.data.RecentFileItem
import java.io.File
import kotlin.math.absoluteValue

View file

@ -1,11 +1,11 @@
package org.dueattendant149.bookreader
package com.aryan.reader
import android.content.Context
import androidx.core.content.edit
import org.dueattendant149.bookreader.paginatedreader.TtsChunk
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementEngine
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferencesJson
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.shared.ReaderTtsReplacementEngine
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementPreferencesJson
private const val READER_PREFS_NAME = "reader_prefs"
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.Box
@ -56,11 +56,11 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementBookSettings
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementEngine
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementRule
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementSuggestions
import com.aryan.reader.shared.ReaderTtsReplacementBookSettings
import com.aryan.reader.shared.ReaderTtsReplacementEngine
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementRule
import com.aryan.reader.shared.ReaderTtsReplacementSuggestions
private enum class TtsReplacementScope {
Global,

View file

@ -1,4 +1,4 @@
package org.dueattendant149.bookreader
package com.aryan.reader
import androidx.annotation.StringRes
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

View file

@ -17,7 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
package org.dueattendant149.bookreader.data
package com.aryan.reader.data
import android.content.Context
import androidx.room.Database
@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
TagEntity::class,
BookTagCrossRef::class
],
version = 23,
version = 22,
exportSchema = false
)
@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 {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
@ -320,7 +301,7 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20,
MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23
MIGRATION_20_21, MIGRATION_21_22
)
.fallbackToDestructiveMigration(false)
.build()

View file

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

View file

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

View file

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

View file

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

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