Compare commits

..

24 commits

Author SHA1 Message Date
Atte149
5f64f3d722 phase 0: fork Episteme, rename package to org.dueattendant149.bookreader
- Cloned from Aryan-Raj3112/episteme (AGPL-3.0)
- Package: com.aryan.reader → org.dueattendant149.bookreader
- Application ID: org.dueattendant149.bookreader
- Added AGENTS.md with migration plan
- Upstream: github.com/Aryan-Raj3112/episteme
- Origin: git.dueattendant149.org/Atte149/book-reader
2026-06-24 00:33:58 +03:00
Aryan
e615128a23
Bump version to 1.0.50 (54) (#382) 2026-06-14 14:06:30 +05:30
Aryan
625a4d5d2e
Linux support (#381)
* Add desktop release CI and support for Arch Linux packaging

* Make Gradle wrapper executable in desktop-release workflow

* Make Gradle wrapper executable in desktop-release workflow

* Make Gradle wrapper executable in desktop-release workflow

* Configure Gradle and update Java environment in desktop-release workflow

* Update Java setup and AUR packaging in desktop release workflow

* Update Java setup and AUR packaging in desktop release workflow

* Add MSIX packaging support for Windows desktop distribution

* Update AUR packaging metadata and validation

* Use spine toc attribute for NCX resolution

* crash fixes

* Implement automatic discovery and injection of EPUB font face siblings

* Enhance custom font support with family grouping and variable font handling

* Optimize metadata loading and improve TTS highlighting

* Add keyboard navigation support for EPUB reader

* Refine PDF spread page sizing to respect aspect ratios

* Implement responsive maximum height for reader popups and sheets

* Handle TTS generation failures by skipping problematic chunks

* Refactor PDF tile rendering logic and zoom indicator behavior

* Prefer block and offset locators over page index in native vertical flow

* Implement save and share actions for original book files

* Add Estonian language support

* Implement temporary viewing mode for external files

* Implement direct opening for temporary external files without library persistence

* fix failing tests

* Import SharedFileCapabilities in DesktopLibraryUi

* Improve native vertical reader progress, persistence, and image support

* Center target in viewport for native vertical reader and support animated scrolling
2026-06-14 13:43:49 +05:30
Aryan
a13d6599d1
Update README (#363) 2026-06-03 10:39:54 +05:30
Aryan
6651169ce2
Desktop update (#362)
* Update documentation images

* Introduce native vertical EPUB reader for Desktop

* Add libsecret support for Linux secret storage

* Update localized strings and plurals across multiple languages

* Update PDF annotation UI and highlighter settings on desktop
2026-06-03 09:45:50 +05:30
Aryan
e4634ed251
Merge pull request #360 from Aryan-Raj3112/translations
Translations update from Hosted Weblate
2026-06-02 01:03:06 +05:30
Hosted Weblate
e9455513a2
Merge remote-tracking branch 'origin/main' 2026-06-01 19:32:10 +00:00
Aryan
eec9c30580
Merge pull request #359 from Aryan-Raj3112/translations
Translations update from Hosted Weblate
2026-06-02 01:01:59 +05:30
Hosted Weblate
4911b240ac
Merge remote-tracking branch 'origin/main' 2026-06-01 19:21:51 +00:00
Hosted Weblate
080f7359b2
Merge remote-tracking branch 'origin/main' 2026-06-01 19:21:50 +00:00
Aryan
83dcafa4b6
Windows ga (#358)
* Enhance Cloud TTS with navigation controls and shared UI overlay in desktop app

* Refactor Cloud TTS voice settings and remove standalone settings overlay on desktop app

* Persist reader window state and improve slider interaction in desktop app

* Improve PDF page transitions and refine focus management in desktop app

* Refactor scrollbar interaction and adjust desktop modal focus handling

* Improve PDF sidecar synchronization and cross-platform metadata compatibility

* Refactor PDF annotation comment logic to shared module and implement Desktop UI

* Refactor reader screen to use tap-to-toggle and full-width styling in desktop app

* Refactor reader workspace layout and chrome-panel interactions

* Implement global search keyboard shortcuts and focusable chrome layers

* Implement flavor-specific legal links and update the About UI

* Refactor reader UI controls on desktop app

* Enhance desktop folder sync with background metadata extraction and improved error handling

* Refactor Library UI and remove redundant Home tab in desktop app

* Add custom tooltips to reader icon buttons in desktop app

* Integrate app theme controls into reader interfaces on desktop

* Add right-to-left pagination support and improve focus restoration on desktop app

* Improve EPUB pagination geometry and diagnostic logging for layout cutoffs on desktop app

* Update desktop reader defaults and implement settings migration

* Implement block-based position tracking in ReaderLocator

* Enhance EPUB highlighting reliability in desktop app

* Add support for custom reader themes and update highlight palette logic in desktop app

* Replace the Tools panel with a "More" dropdown menu and refactor account UI

* Implement account profile header in desktop sidebar

* Implement cloud sync reliability improvements and sidebar toggle on desktop app

* Improve EPUB annotation synchronization and highlight mapping accuracy in desktop app

* Integrate WebView2 for EPUB vertical rendering on Windows

* Refactor reader layout logic and enhance WebView2 diagnostics

* Improve vertical reading layout and WebView2 resizing on Desktop

* Refine vertical reading mode layout and margin handling

* Enhance reader locator precision and Desktop mode-switching reliability

* Implement chapter-level caching and warm-start pagination in desktop app

* Replace bundled KCEF with native system webviews via SWT

* Refactor EPUB page info bar visibility and layout logic

* Improve PDF toolbar persistence and fix tab reactivation logic

* Enable multi-selection and bulk operations for custom fonts

* Refactor instrumentation tests

* Add EPUB UI test fixture and initial instrumentation tests

* Expand EpubReader UI tests and improve accessibility

* Add instrumentation tests and test tags for library and reader screens

* Enhance OPDS parser logic and catalog integration

* Add support for toggling local synchronization on a per-folder basis.

* Implement tri-state sizing for the TTS overlay

* Persist TTS overlay size across sessions

* Refactor reader brightness control and add incremental step buttons

* Improve CSS support, pagination control, and style-aware semantic caching

* Improve link handling, interaction, and diagnostics in the paginated reader

* crash fixes

* Implement persistent pending removal for external files

* Implement book-specific word replacements

* Add native vertical reading mode with custom renderer

* Implement text selection and navigation improvements for the native vertical reader

* Implement locator-based navigation and improved vertical scrolling in native vertical mode in epub

* Implement lazy loading and chapter prefetching for native vertical reader

* Improve window lifecycle and disposal handling on Desktop

* Optimize vertical reading performance in desktop app

* Enhance TTS start accuracy and diagnostic logging on desktop

* Refactor AI settings visibility on desktop

* Improve pagination height measurement and enhance cutoff diagnostics

* Implement lifecycle management and improve justified text splitting for pagination

* Refine AI usage tracking and force AI feature visibility on Desktop

* Add descriptive context comments and usage examples to string and plural resources.

* Optimize performance and memory usage in search and state mapping

* Replace reader page sliders with minimal slider and navigation controls

* Add support for CBT comic archives

* Harden file path validation and XML parsing to prevent security vulnerabilities

* Implement local account profile caching and optimize desktop performance

* Improve desktop persistence reliability and add Linux secure storage support

* Improved PDF zoom stability and layout prediction during zoom commits

* Improved PDF spread layout prediction, reader focus restoration, and account profile caching

* Enhance highlight precision and scoping using block-local offsets and CFIs

* Enhance cloud book content synchronization and background downloads

* Implement granular timestamp tracking for reading positions and PDF annotations

* Restrict diagnostic logging and stack traces to debug builds

* Refine PDF page gaps and reader chrome interaction logic

* Refactor PDF highlight rendering and overhaul Desktop sidebar UI

* Implement a new interaction dock and undo/redo history for PDF annotations in desktop

* Enhance PDF color picker and improve navigation scroll restoration

* Add highlight palette customization and improve selection menu UI in desktop app epub reader

* Enhance desktop shelf management and library organization
2026-06-02 00:51:42 +05:30
Waamaru
35a06bf134
Translated using Weblate (French)
Currently translated at 51.5% (773 of 1499 strings)

Translation: Episteme/strings
Translate-URL: https://hosted.weblate.org/projects/episteme/strings/fr/
2026-05-30 11:01:01 +02:00
Sasha Glazko
698e9f60bb
Translated using Weblate (Belarusian)
Currently translated at 75.3% (1130 of 1499 strings)

Translation: Episteme/strings
Translate-URL: https://hosted.weblate.org/projects/episteme/strings/be/
2026-05-27 20:11:36 +02:00
Jonas L.
9539d20807
Translated using Weblate (German)
Currently translated at 71.5% (1072 of 1499 strings)

Translation: Episteme/strings
Translate-URL: https://hosted.weblate.org/projects/episteme/strings/de/
2026-05-27 20:11:34 +02:00
Sasha Glazko
86d4a48033
Translated using Weblate (Belarusian)
Currently translated at 75.3% (1129 of 1499 strings)

Translation: Episteme/strings
Translate-URL: https://hosted.weblate.org/projects/episteme/strings/be/
2026-05-24 18:11:43 +00:00
berebara
25e9fc32d7
Translated using Weblate (Russian)
Currently translated at 99.8% (1497 of 1499 strings)

Translation: Episteme/strings
Translate-URL: https://hosted.weblate.org/projects/episteme/strings/ru/
2026-05-24 18:11:41 +00:00
Otto Zumkeller
7593691a9a
Translated using Weblate (German)
Currently translated at 71.3% (1069 of 1499 strings)

Translation: Episteme/strings
Translate-URL: https://hosted.weblate.org/projects/episteme/strings/de/
2026-05-23 16:11:36 +02:00
Welligston da Silva Sinfronio
415255902a
Translated using Weblate (Portuguese (Brazil))
Currently translated at 74.5% (1117 of 1499 strings)

Translation: Episteme/strings
Translate-URL: https://hosted.weblate.org/projects/episteme/strings/pt_BR/
2026-05-22 15:11:43 +02:00
Arif Budiman
483c9ec6bd
Translated using Weblate (Indonesian)
Currently translated at 100.0% (1499 of 1499 strings)

Translation: Episteme/strings
Translate-URL: https://hosted.weblate.org/projects/episteme/strings/id/
2026-05-22 15:11:40 +02:00
Hosted Weblate
4cbfb2aa0e
Merge remote-tracking branch 'origin/main' 2026-05-20 19:55:09 +02:00
Hosted Weblate
abf65ae8bd
Merge remote-tracking branch 'origin/main' 2026-05-20 18:44:09 +02:00
Priit Jõerüüt
e556008ff4
Translated using Weblate (Estonian)
Currently translated at 2.9% (33 of 1111 strings)

Translation: Episteme/strings
Translate-URL: https://hosted.weblate.org/projects/episteme/strings/et/
2026-05-20 10:11:32 +00:00
Priit Jõerüüt
4a340ee642
Added translation using Weblate (Estonian) 2026-05-19 09:26:19 +02:00
berebara
7f1242c5bf
Translated using Weblate (Russian)
Currently translated at 100.0% (1111 of 1111 strings)

Translation: Episteme/strings
Translate-URL: https://hosted.weblate.org/projects/episteme/strings/ru/
2026-05-18 09:24:05 +00:00
831 changed files with 78252 additions and 18465 deletions

409
.github/workflows/desktop-release.yml vendored Normal file
View file

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

7
.gitignore vendored
View file

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

View file

@ -162,6 +162,19 @@
</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>
@ -309,6 +322,19 @@
</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>
@ -357,6 +383,7 @@
<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>
@ -534,6 +561,19 @@
</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 Normal file
View file

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

76
AGENTS.md Normal file
View file

@ -0,0 +1,76 @@
# Book Reader — Episteme fork + bookshelf-api/ABS
> Форк `Aryan-Raj3112/episteme` под bookshelf-api + Audiobookshelf + Material You 3 (Myne-style UI).
## Stack
- **Язык:** Kotlin 100%
- **UI:** Jetpack Compose + Material3 (Myne-style: round cards, dynamic color, minimal)
- **DI:** Dagger Hilt
- **БД:** Room
- **Настройки:** DataStore
- **Сеть:** Retrofit + OkHttp + Kotlinx Serialization
- **Аудио:** ExoPlayer (Media3) + MediaSession
- **Фоновые задачи:** WorkManager
- **Читалка:** Episteme paginatedreader (CSS-движок, ContentBlock/Page модель)
- **Форматы:** EPUB, FB2, PDF, MOBI/AZW3, DOCX, ODT, TXT, Markdown, HTML, comics
- **minSdk:** 26 (Episteme default)
- **compileSdk/targetSdk:** 36
## Origin
- **upstream:** `https://github.com/Aryan-Raj3112/episteme.git` (AGPL-3.0)
- **origin:** `https://git.dueattendant149.org/Atte149/book-reader.git` (Forgejo)
- **Лицензия:** AGPL-3.0 (наследуется от Episteme)
## Goal
- Взять зрелый reader engine из Episteme (пагинация, все форматы, TTS, annotations)
- Redesign UI под Myne-стиль (Material You 3, round cards, dynamic color)
- Заменить backend (OPDS/Gutenberg/LocalFolder) на bookshelf-api + ABS
- Добавить фичи из Book's Story: audio player, RSVP, Librarr search, TTS jobs, cache, progress sync, upload
## Layout
- `app/src/main/` — Android app (после KMP→Android упрощения)
- `app/src/oss/` — OSS flavor (без проприетарных ML Kit/cloud)
- `app/src/releaseOffline/` — offline-only flavor
- ~~`desktopApp/`~~ — удалён (Android-only)
- ~~`shared/`~~ — слит в `app/src/main/` (после Этапа 1)
## Backend
- **bookshelf-api:** `https://books.dueattendant149.org/api/v1/*`
- `GET /api/v1/books/libraries`
- `GET /api/v1/books/{item_id}`
- `GET /api/v1/books/library/{id}/items`
- `POST /api/v1/search`
- `POST /api/v1/tts`, `GET /api/v1/tts/{job_id}`
- `POST /api/v1/download`, `GET /api/v1/download/list`
- **Audiobookshelf (напрямую):**
- `GET /api/items/{id}/file/{ino}`
- `GET /api/items/{id}/cover`
- `GET /api/items/{id}/download`
## WARNs
- **Не пушить в `upstream` (Aryan-Raj3112/episteme).** Только в `origin` (Forgejo).
- **AGPL-3.0** — если публикуем, исходный код должен быть открыт (включая server-side modifications если есть network use).
- **`pro` flavor** содержит проприетарные компоненты (ML Kit OCR, cloud sync) — не использовать, только `oss`.
- **Секреты** (ABS token, API keys) — только в `.env` / DataStore, никогда в коде.
## Build
```bash
./gradlew :app:assembleOssDebug
```
## Status
- Phase 0: clone + package rename — completed
- Phase 1: KMP → Android-only — pending
- Phase 2: backend swap (bookshelf-api + ABS) — pending
- Phase 3: UI redesign (Myne-style) — pending
- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — pending
- Phase 5: reader engine integration — pending
- Phase 6: build, test, deploy — pending

148
README.md
View file

@ -5,62 +5,63 @@
<span>&nbsp;Episteme Reader</span>
</h1>
<p>A modern, offlinefirst, privacyfocused document & ebook reader for Android, built with Kotlin and Jetpack Compose.</p>
<p>A modern, offline-first, privacy-focused document and e-book reader for Android and desktop, built with Kotlin Multiplatform and Compose.</p>
<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://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>
</div>
<br/>
![Episteme Reader Preview](docs/EPISTEME.png)
<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>
## Overview
Episteme Reader is a comprehensive, customizable reader for documents and e-books on Android. It features a modern Jetpack Compose UI, powerful reading tools, and extensive theming.
Episteme Reader is a customizable reader for documents, e-books, comics, and text-heavy files. The app is designed around local-first reading, deep typography controls, flexible layouts, and a consistent Kotlin Multiplatform core across Android and desktop.
To best serve different user preferences regarding privacy and network usage, Episteme Reader is available in three distinct editions:
* **PlayStore Version:** The full-featured release which includes proprietary code and features.
* **OSS Version (GitHub/F-Droid):** A fully open-source build.
* **OSS Offline Version (GitHub):** A strictly offline build with network permissions completely removed.
The same core reading experience is available across editions. The main differences are distribution channel, network access, and whether proprietary online services are included.
---
## Core Features
## Feature Comparison
Available across supported editions unless noted in the edition table:
### 📚 Supported Formats
| Feature | PlayStore | OSS | OSS Offline |
| :--- | :---: | :---: | :---: |
| **Documents:** PDF, DOCX, ODT/FODT | ✅ | ✅ | ✅ |
| **E-books & Text:** EPUB, MOBI, AZW3, FB2, MD, HTML, TXT | ✅ | ✅ | ✅ |
| **Comics:** CBZ, CBR, CB7 | ✅ | ✅ | ✅ |
| **View-Only:** CSV, TSV, JSON, XML, Logs, Code Files | ✅ | ✅ | ✅ |
* **Formats:** PDF, EPUB, MOBI/AZW3, FB2, DOCX, ODT/FODT, TXT, Markdown, HTML, and comic archives.
* **Reading modes:** Paginated reading, vertical scroll, PDF multi-tab reading, PDF reflow, auto-scroll, and musician mode.
* **PDF tools:** Ink annotations, highlighting, erasing, text annotations, and reading-focused PDF controls.
* **Customization:** App themes, reader themes, custom local fonts, typography controls, spacing, margins, and layout tuning.
* **Library tools:** Local folder sync, library organization, bookmarks, progress tracking, and file management.
* **Accessibility:** System text-to-speech, app language selection, and reader settings that adapt to different reading preferences.
### 📖 Core Reading Experience
| Feature | PlayStore | OSS | OSS Offline |
| :--- | :---: | :---: | :---: |
| **Display Modes:** Paginated & Vertical Scroll | ✅ | ✅ | ✅ |
| **PDF Multi-Tab Reading & Reflow** | ✅ | ✅ | ✅ |
| **PDF Annotations:** Ink (Pen, Highlight, Erase) & Text | ✅ | ✅ | ✅ |
| **App-wide Customization & Reader Theming** | ✅ | ✅ | ✅ |
| **Custom Fonts:** Local Import (TTF and OTF) | ✅ | ✅ | ✅ |
| **Typography Control** | ✅ | ✅ | ✅ |
| **Auto-Scroll & Musician Mode** | ✅ | ✅ | ✅ |
| **System Text-to-Speech (TTS)** | ✅ | ✅ | ✅ |
## Editions
### ⚙️ Advanced & Network Features
| Feature | PlayStore | OSS (F-Droid) | OSS Offline |
| :--- | :---: | :---: | :---: |
| **Local Folder Sync & Library Management** | ✅ | ✅ | ✅ |
| **Download Google Fonts** | ✅ | ✅ | ❌ |
| **OPDS Catalog Support** | ✅ | ✅ | ❌ |
| **PDF Bubble Zoom Magnifier** | ✅ | ❌ | ❌ |
| **ML Kit OCR** (Scanned PDF Text Selection) | ✅ | ❌ | ❌ |
| **Cross-Device Cloud Sync** | ✅ | ❌ | ❌ |
| **AI Tools** (Summaries, Story Recap, Dictionary) | ✅ | 🔜 | ❌ |
| **Cloud Text-to-Speech** | ✅ | 🔜 | ❌ |
| Edition | Platform | Network access | Distribution | Notes |
|---|---|---|---|---|
| **Play Store** | Android | Online-capable | Google Play | Full Android release with proprietary extras such as ML Kit OCR, cloud sync, AI tools, cloud TTS, and PDF bubble zoom. |
| **OSS** | Android | Online-capable | [epistemereader.com](https://epistemereader.com), GitHub, F-Droid, Obtainium | Fully open-source Android build with OPDS, downloadable fonts, and BYOK access to AI and cloud features. |
| **OSS Offline** | Android | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub, Obtainium | Open-source Android build with network permissions removed. |
| **Standard** | Windows desktop | Online-capable | [epistemereader.com](https://epistemereader.com), GitHub | Full-featured desktop release with the shared KMP reader core and online-capable services. |
| **Offline** | Windows desktop | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub | Desktop build focused on local reading with online services disabled. |
---
Future desktop platforms can use the same Standard and Offline model as support expands.
## Languages
Episteme Reader currently supports: English, Arabic, Belarusian, German, Spanish, Estonian, French, Hindi, Indonesian, Italian, Japanese, Korean, Dutch, Polish, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, and Chinese Simplified.
Want Episteme Reader in another language? Please request it through [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose) or start a thread in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions).
## Building from Source
@ -70,7 +71,7 @@ To best serve different user preferences regarding privacy and network usage, Ep
cd episteme
```
2. Build:
2. Build Android:
* Open in Android Studio and run the `ossDebug` or `ossOfflineDebug` variant, or
* Build from the command line:
```bash
@ -79,43 +80,56 @@ To best serve different user preferences regarding privacy and network usage, Ep
The APK will be generated at:
`app/build/outputs/apk/oss/debug/Episteme-oss-v{version}-oss-debug.apk`
3. Build desktop:
```bash
./gradlew :desktopApp:packageReleaseDistributionForCurrentOS
```
For the offline desktop build, pass:
```bash
./gradlew :desktopApp:packageReleaseDistributionForCurrentOS -PdesktopFlavor=oss
```
## Open Source Libraries
Powered by the Android OSS ecosystem:
* **Core & UI:** AndroidX, Jetpack Compose, Kotlinx Serialization
* **Document Engines:** PdfiumAndroidKt (PDF), libmobi (MOBI/AZW3)
* **Parsers:** Jsoup (HTML/EPUB), Flexmark (Markdown)
* **Media & Image Loading:** Coil, Media3 (ExoPlayer)
* **Utilities:** Room (Database), Timber (Logging)
Powered by the Kotlin, Android, and desktop OSS ecosystem:
* **Core and UI:** Kotlin Multiplatform, Compose Multiplatform, AndroidX, Jetpack Compose, Kotlinx Serialization
* **Document engines:** PdfiumAndroidKt, PDFium, libmobi
* **Parsers:** Jsoup, Flexmark, Apache Commons Compress
* **Media and image loading:** Coil, Media3
* **Utilities:** Room, Timber, JNA
## Contributors
| Contributor | Contribution |
|---|---|
| <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) |
| <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 |
## Translations
## Supporters
Help translate Episteme Reader into your native language! [Weblate](https://hosted.weblate.org/engage/episteme/) is used to manage localization.
Thank you to the people helping keep Episteme Reader moving:
[![Translation status](https://hosted.weblate.org/widget/episteme/multi-auto.svg)](https://hosted.weblate.org/engage/episteme/)
## License
Licensed under the GNU Affero General Public License v3.0 (AGPL3.0). See the [LICENSE](LICENSE) file.
| 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 even better:
Help make Episteme Reader better:
* ❤️ [Support on Patreon](https://www.patreon.com/c/epistemereader)
* ⭐ Star the repository to help visibility
* 🐞 Report bugs or request features via [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose)
* 💬 Share feedback in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions)
* ✍️ Leave a review on the [Google Play Store](https://play.google.com/store/apps/details?id=com.aryan.reader)
* 📣 Tell a friend!
* [Sponsor on GitHub](https://github.com/sponsors/Aryan-Raj3112)
* [Support on Patreon](https://www.patreon.com/c/epistemereader)
* Star the repository to help visibility
* Report bugs or request features via [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose)
* Share feedback in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions)
* Leave a review on the [Google Play Store](https://play.google.com/store/apps/details?id=com.aryan.reader)
* Tell a friend
## License
Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See the [LICENSE](LICENSE) file.

View file

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

Binary file not shown.

View file

@ -0,0 +1,48 @@
# EPUB UI Test Fixture
`reader_test_book.epub` is a deterministic EPUB fixture for Android instrumentation and UI tests.
Use it for reader flows that need stable EPUB content:
- open/import EPUB through a `content://` URI
- restore reading position
- navigate with chapter anchors
- create or verify bookmarks
- search unique fixture markers
- exercise highlight and CFI-related flows
The generated EPUB is stored at:
`app/src/androidTest/assets/epub/reader_test_book.epub`
The source files live in:
`app/src/androidTest/fixtures/epub/reader_test_book`
Regenerate the EPUB after editing the source:
```powershell
python app/src/androidTest/fixtures/epub/build_reader_test_book.py
```
Tests should copy the asset from the instrumentation context, not the target app context:
```kotlin
val testContext = InstrumentationRegistry.getInstrumentation().context
testContext.assets.open("epub/reader_test_book.epub")
```
Current basic UI coverage using this fixture lives in:
`app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderScreenTest.kt`
Stable markers intentionally embedded in the book:
- `POSITION_TARGET_ALPHA`
- `HIGHLIGHT_TARGET_BRAVO`
- `CFI_TARGET_CHARLIE`
- `SEARCH_TARGET_DELTA`
- `BOOKMARK_TARGET_ECHO`
- `POSITION_TARGET_FOXTROT`
- `ANNOTATION_TARGET_GOLF`
- `CFI_TARGET_HOTEL`

View file

@ -0,0 +1,39 @@
from __future__ import annotations
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile, ZipInfo
ROOT = Path(__file__).resolve().parent
SOURCE_DIR = ROOT / "reader_test_book"
OUTPUT = ROOT.parents[1] / "assets" / "epub" / "reader_test_book.epub"
FIXED_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
def add_file(epub: ZipFile, source: Path, archive_name: str, compression: int) -> None:
info = ZipInfo(archive_name, FIXED_TIMESTAMP)
info.compress_type = compression
info.external_attr = 0o644 << 16
epub.writestr(info, source.read_bytes())
def main() -> None:
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
with ZipFile(OUTPUT, "w") as epub:
info = ZipInfo("mimetype", FIXED_TIMESTAMP)
info.compress_type = ZIP_STORED
info.external_attr = 0o644 << 16
epub.writestr(info, b"application/epub+zip")
for source in sorted(SOURCE_DIR.rglob("*")):
if not source.is_file() or source.name == "mimetype":
continue
archive_name = source.relative_to(SOURCE_DIR).as_posix()
add_file(epub, source, archive_name, ZIP_DEFLATED)
print(f"Wrote {OUTPUT}")
if __name__ == "__main__":
main()

View file

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

View file

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

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 568 B

View file

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

View file

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

View file

@ -0,0 +1,21 @@
body {
font-family: serif;
line-height: 1.5;
}
h1 {
font-size: 1.4em;
}
p {
margin: 0 0 1em 0;
}
.fixture-note {
border-left: 0.25em solid #4a90e2;
padding-left: 0.75em;
}
.target {
font-weight: bold;
}

View file

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

View file

@ -1,146 +0,0 @@
// AppNavigationTest.kt
package com.aryan.reader
import android.net.Uri
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.aryan.reader.epub.EpubBook
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AppNavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
private lateinit var navController: TestNavHostController
private val fakeUiState = MutableStateFlow(ReaderScreenState())
// Mock ViewModel that uses the fake state
private val fakeViewModel: MainViewModel = object : MainViewModel(
ApplicationProvider.getApplicationContext()
) {
override val uiState = fakeUiState
override fun clearSelectedFile() {
fakeUiState.value = fakeUiState.value.copy(
selectedFileType = null,
selectedPdfUri = null,
selectedEpubBook = null
)
}
}
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Before
fun setup() {
composeTestRule.setContent {
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(ComposeNavigator())
AppNavigation(
navController = navController,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(400.dp, 800.dp)),
viewModel = fakeViewModel
)
}
}
@Test
fun appNavigation_defaultStartDestination_isMainRoute() {
val currentRoute = navController.currentBackStackEntry?.destination?.route
assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
}
@Test
fun appNavigation_whenPdfSelected_navigatesToPdfViewer() {
// Trigger state change
fakeUiState.value = ReaderScreenState(
selectedFileType = FileType.PDF,
selectedPdfUri = Uri.parse("content://dummy.pdf")
)
// Let compose recompose and run LaunchedEffect
composeTestRule.waitForIdle()
val currentRoute = navController.currentBackStackEntry?.destination?.route
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
}
@Test
fun appNavigation_whenPptxSelected_navigatesToPdfViewer() {
fakeUiState.value = ReaderScreenState(
selectedFileType = FileType.PPTX,
selectedPdfUri = Uri.parse("content://dummy.pptx")
)
composeTestRule.waitForIdle()
val currentRoute = navController.currentBackStackEntry?.destination?.route
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
}
@Test
fun appNavigation_whenEpubSelected_navigatesToEpubReader() {
// Trigger state change
fakeUiState.value = ReaderScreenState(
selectedFileType = FileType.EPUB,
selectedEpubBook = EpubBook(
fileName = "dummy.epub",
title = "Dummy Book",
author = "Author",
language = "en",
coverImage = null
)
)
composeTestRule.waitForIdle()
val currentRoute = navController.currentBackStackEntry?.destination?.route
assertEquals(AppDestinations.EPUB_READER_ROUTE, currentRoute)
}
@Test
fun appNavigation_whenFileCleared_navigatesBackToMain() {
// First, navigate to PDF viewer
fakeUiState.value = ReaderScreenState(
selectedFileType = FileType.PDF,
selectedPdfUri = Uri.parse("content://dummy.pdf")
)
composeTestRule.waitForIdle()
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route)
// Then, trigger the clear action (simulating onNavigateBack)
fakeViewModel.clearSelectedFile()
composeTestRule.waitForIdle()
val currentRoute = navController.currentBackStackEntry?.destination?.route
assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
}
@Test
fun appNavigation_whenUnknownFileTypeSelected_navigatesBackToMain() {
fakeUiState.value = ReaderScreenState(
selectedFileType = FileType.PDF,
selectedPdfUri = Uri.parse("content://dummy.pdf")
)
composeTestRule.waitForIdle()
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route)
fakeUiState.value = ReaderScreenState(selectedFileType = FileType.UNKNOWN)
composeTestRule.waitForIdle()
assertEquals(AppDestinations.MAIN_ROUTE, navController.currentBackStackEntry?.destination?.route)
}
}

View file

@ -1,204 +0,0 @@
// app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt
package com.aryan.reader.epubreader
import android.content.Context
import timber.log.Timber
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.aryan.reader.SearchResult
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import kotlin.math.max
import kotlin.math.min
@RunWith(AndroidJUnit4::class)
class EpubReaderLogicTest {
private lateinit var context: Context
private lateinit var testDir: File
private lateinit var mockEpubBook: EpubBook
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
testDir = File(context.cacheDir, "test_epub").apply { mkdirs() }
// Create dummy chapter files
val chapter1File = File(testDir, "chapter1.html")
chapter1File.writeText("<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,304 +0,0 @@
// PaginatedReaderViewModelTest.kt
package com.aryan.reader.paginatedreader
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.aryan.reader.SearchResult
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
private class FakePaginator(
initiallyLoading: Boolean,
initialPageCount: Int,
initialGeneration: Int
) : IPaginator {
override var isLoading by mutableStateOf(initiallyLoading)
override var totalPageCount by mutableIntStateOf(initialPageCount)
override var generation by mutableIntStateOf(initialGeneration)
override val pageShiftRequest: Flow<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,313 +0,0 @@
package com.aryan.reader.pdf
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipe
import androidx.core.content.FileProvider
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.GrantPermissionRule
import com.aryan.reader.MainActivity
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class PdfViewerScreenTest {
@get:Rule
val composeTestRule = createEmptyComposeRule()
@get:Rule
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
@org.junit.Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
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

@ -0,0 +1,117 @@
package org.dueattendant149.bookreader
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.dueattendant149.bookreader.shared.FileType
import org.dueattendant149.bookreader.shared.ReaderFeatureSurface
import org.dueattendant149.bookreader.shared.ReaderPlatform
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AppNavigationTest {
@Test
fun appDestinations_useStableReaderRoutes() {
assertThat(AppDestinations.MAIN_ROUTE).isEqualTo("main")
assertThat(AppDestinations.PDF_VIEWER_ROUTE).isEqualTo("pdf_viewer")
assertThat(AppDestinations.EPUB_READER_ROUTE).isEqualTo("epub_reader")
}
@Test
fun androidReaderSurface_mapsPdfBackedTypesToPdfViewer() {
val mappedSurfaces = listOf(
FileType.PDF,
FileType.CBZ,
FileType.CBR,
FileType.CB7,
FileType.CBT,
FileType.PPTX
).associateWith { it.readerSurfaceOnAndroid() }
assertThat(mappedSurfaces).containsExactly(
FileType.PDF, ReaderFeatureSurface.PDF_VIEWER,
FileType.CBZ, ReaderFeatureSurface.PDF_VIEWER,
FileType.CBR, ReaderFeatureSurface.PDF_VIEWER,
FileType.CB7, ReaderFeatureSurface.PDF_VIEWER,
FileType.CBT, ReaderFeatureSurface.PDF_VIEWER,
FileType.PPTX, ReaderFeatureSurface.PDF_VIEWER
)
}
@Test
fun androidReaderSurface_mapsTextBackedTypesToEpubReader() {
val mappedSurfaces = listOf(
FileType.EPUB,
FileType.MOBI,
FileType.MD,
FileType.TXT,
FileType.HTML,
FileType.FB2,
FileType.DOCX,
FileType.ODT,
FileType.FODT
).associateWith { it.readerSurfaceOnAndroid() }
assertThat(mappedSurfaces).containsExactly(
FileType.EPUB, ReaderFeatureSurface.EPUB_READER,
FileType.MOBI, ReaderFeatureSurface.EPUB_READER,
FileType.MD, ReaderFeatureSurface.EPUB_READER,
FileType.TXT, ReaderFeatureSurface.EPUB_READER,
FileType.HTML, ReaderFeatureSurface.EPUB_READER,
FileType.FB2, ReaderFeatureSurface.EPUB_READER,
FileType.DOCX, ReaderFeatureSurface.EPUB_READER,
FileType.ODT, ReaderFeatureSurface.EPUB_READER,
FileType.FODT, ReaderFeatureSurface.EPUB_READER
)
}
@Test
fun androidReaderSurface_returnsNullForUnknownFileType() {
assertThat(FileType.UNKNOWN.readerSurfaceOnAndroid()).isNull()
}
@Test
fun appNavBackInterceptor_onlyHandlesResumedNonReaderBackStackEntries() {
assertThat(
shouldInterceptAppNavBack(
currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
hasPreviousBackStackEntry = true,
isCurrentEntryResumed = true
)
).isTrue()
assertThat(
shouldInterceptAppNavBack(
currentRoute = AppDestinations.MAIN_ROUTE,
hasPreviousBackStackEntry = true,
isCurrentEntryResumed = true
)
).isFalse()
assertThat(
shouldInterceptAppNavBack(
currentRoute = AppDestinations.PDF_VIEWER_ROUTE,
hasPreviousBackStackEntry = true,
isCurrentEntryResumed = true
)
).isFalse()
assertThat(
shouldInterceptAppNavBack(
currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
hasPreviousBackStackEntry = false,
isCurrentEntryResumed = true
)
).isFalse()
assertThat(
shouldInterceptAppNavBack(
currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
hasPreviousBackStackEntry = true,
isCurrentEntryResumed = false
)
).isFalse()
}
private fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? {
return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID)
}
}

View file

@ -0,0 +1,126 @@
package org.dueattendant149.bookreader
import android.content.Context
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.dueattendant149.bookreader.data.RecentFileItem
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class HomeRecentFileCardTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun recentFileCardShowsProgressAndUnavailableState() {
val item = recentBook(
bookId = "home_unavailable_epub",
title = "Unavailable Field Guide",
author = "Casey Example",
progress = 42f,
isAvailable = false
)
setRecentFileCard(item = item)
composeTestRule.onNodeWithTag("HomeRecentFileCard_home_unavailable_epub").assertIsDisplayed()
composeTestRule.onNodeWithText("Unavailable Field Guide").assertIsDisplayed()
composeTestRule.onNodeWithText("Casey Example").assertIsDisplayed()
composeTestRule.onNodeWithText("42%").assertIsDisplayed()
composeTestRule.onAllNodesWithContentDescription(text(R.string.not_available_locally))[0]
.assertIsDisplayed()
}
@Test
fun recentFileCardClickLongClickAndSelectedOverlayWork() {
val item = recentBook(
bookId = "home_selected_pdf",
title = "Selected Position Notes",
author = "Morgan Example",
progress = 7f
)
var clicked = false
var longClicked = false
setRecentFileCard(
item = item,
isSelected = true,
onClick = { clicked = true },
onLongClick = { longClicked = true }
)
composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_selected))[0]
.assertIsDisplayed()
composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performClick()
composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performTouchInput {
down(center)
advanceEventTime(600)
up()
}
assertThat(clicked).isTrue()
assertThat(longClicked).isTrue()
}
private fun setRecentFileCard(
item: RecentFileItem,
isSelected: Boolean = false,
isPinned: Boolean = false,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) {
composeTestRule.setContent {
MaterialTheme {
RecentFileCard(
item = item,
isSelected = isSelected,
isPinned = isPinned,
onClick = onClick,
onLongClick = onLongClick,
isDownloading = false,
usePdfFileNameAsDisplayName = false
)
}
}
}
private fun recentBook(
bookId: String,
title: String,
author: String,
progress: Float,
isAvailable: Boolean = true
): RecentFileItem {
return RecentFileItem(
bookId = bookId,
uriString = "content://home-test/$bookId",
type = FileType.EPUB,
displayName = "$bookId.epub",
timestamp = 1_000L,
title = title,
author = author,
progressPercentage = progress,
isRecent = true,
isAvailable = isAvailable
)
}
private fun text(resId: Int): String {
return context.getString(resId)
}
}

View file

@ -0,0 +1,400 @@
package org.dueattendant149.bookreader
import android.content.Context
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.dueattendant149.bookreader.data.RecentFileItem
import org.dueattendant149.bookreader.data.TagEntity
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@OptIn(ExperimentalFoundationApi::class)
@RunWith(AndroidJUnit4::class)
class LibraryScreenContentTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context = ApplicationProvider.getApplicationContext()
private val focusTag = TagEntity(id = "tag_focus", name = "Focus", color = null, createdAt = 1L)
private val libraryBooks = listOf(
libraryBook(
bookId = "pdf_beta",
type = FileType.PDF,
displayName = "beta.pdf",
title = "Beta Manual",
author = "Mira Example",
timestamp = 3_000L,
progress = 84f
),
libraryBook(
bookId = "epub_gamma",
type = FileType.EPUB,
displayName = "gamma.epub",
title = "Gamma Field Notes",
author = "Nora Example",
timestamp = 2_000L,
progress = 47f,
tags = listOf(focusTag)
),
libraryBook(
bookId = "epub_alpha",
type = FileType.EPUB,
displayName = "alpha.epub",
title = "Alpha Orchard",
author = "Zara Example",
timestamp = 1_000L,
progress = 12f,
tags = listOf(focusTag)
)
)
@Test
fun searchFiltersAndClearRestoresLibraryList() {
setLibraryContent()
composeTestRule.onNodeWithText("Beta Manual").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick()
composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Gamma")
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty()
}
assertNoText("Alpha Orchard")
assertNoText("Beta Manual")
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() &&
composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_search)).performClick()
composeTestRule.onNodeWithText(text(R.string.library_title)).assertIsDisplayed()
}
@Test
fun searchMatchesAuthorAndTagNames() {
setLibraryContent()
composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick()
composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Zara")
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty()
}
assertNoText("Gamma Field Notes")
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick()
composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Focus")
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() &&
composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty()
}
assertNoText("Beta Manual")
}
@Test
fun activeFileTypeFilterChipCanBeCleared() {
setLibraryContent(initialFilters = LibraryFilters(fileTypes = setOf(FileType.EPUB)))
composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed()
composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed()
assertNoText("Beta Manual")
composeTestRule.onNodeWithText(text(R.string.filter_types, FileType.EPUB.name)).performClick()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty()
}
}
@Test
fun tagAndReadStatusFiltersUseSharedLibraryRules() {
setLibraryContent(
initialFilters = LibraryFilters(
tagIds = setOf(focusTag.id),
readStatus = ReadStatusFilter.IN_PROGRESS
)
)
composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed()
composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed()
assertNoText("Beta Manual")
composeTestRule.onNodeWithText(text(R.string.filter_tags, focusTag.name)).assertIsDisplayed()
composeTestRule.onNodeWithText(
text(R.string.filter_status, text(ReadStatusFilter.IN_PROGRESS.labelRes))
).assertIsDisplayed()
}
@Test
fun clearSelectionReturnsToNormalToolbar() {
setLibraryContent()
composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput {
down(center)
advanceEventTime(600)
up()
}
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithContentDescription(text(R.string.clear_selection)).performClick()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(text(R.string.library_title)).fetchSemanticsNodes().isNotEmpty()
}
}
@Test
fun sortMenuSelectionReordersLibraryItems() {
setLibraryContent()
assertBookAbove("pdf_beta", "epub_alpha")
composeTestRule.onNodeWithTag("LibrarySortButton").performClick()
composeTestRule.onNodeWithText(text(SortOrder.TITLE_ASC.labelRes)).performClick()
composeTestRule.waitUntil(5_000) {
runCatching {
bookTop("epub_alpha") < bookTop("pdf_beta")
}.getOrDefault(false)
}
assertBookAbove("epub_alpha", "pdf_beta")
}
@Test
fun longPressBookShowsContextualToolbarActions() {
var tagClicked = false
var pinClicked = false
var infoClicked = false
var selectAllClicked = false
var deleteClicked = false
setLibraryContent(
onTagClick = { tagClicked = true },
onPinClick = { pinClicked = true },
onInfoClick = { infoClicked = true },
onSelectAllClick = { selectAllClicked = true },
onDeleteClick = { deleteClicked = true }
)
composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput {
down(center)
advanceEventTime(600)
up()
}
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_tag)).performClick()
composeTestRule.onNodeWithContentDescription(text(R.string.pin_unpin)).performClick()
composeTestRule.onNodeWithContentDescription(text(R.string.info)).performClick()
composeTestRule.onNodeWithContentDescription(text(R.string.select_all)).performClick()
composeTestRule.onNodeWithContentDescription(text(R.string.action_delete)).performClick()
assertThat(tagClicked).isTrue()
assertThat(pinClicked).isTrue()
assertThat(infoClicked).isTrue()
assertThat(selectAllClicked).isTrue()
assertThat(deleteClicked).isTrue()
}
@Test
fun shelvesTabShowsShelfRowsAndNewShelfAction() {
val shelf = Shelf(
id = "manual_favorites",
name = "Manual Favorites",
type = ShelfType.MANUAL,
books = listOf(libraryBooks[0], libraryBooks[1])
)
var clickedShelfId: String? = null
var longClickedShelfId: String? = null
var newShelfClicked = false
setLibraryContent(
initialPage = 1,
shelves = listOf(shelf),
onShelfClick = { clickedShelfId = it.id },
onShelfLongClick = { longClickedShelfId = it.id },
onNewShelfClick = { newShelfClicked = true }
)
composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").assertIsDisplayed()
composeTestRule.onNodeWithText("Manual Favorites").assertIsDisplayed()
composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performClick()
composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performTouchInput {
down(center)
advanceEventTime(600)
up()
}
composeTestRule.onNodeWithTag("LibraryNewShelfFab").performClick()
assertThat(clickedShelfId).isEqualTo("manual_favorites")
assertThat(longClickedShelfId).isEqualTo("manual_favorites")
assertThat(newShelfClicked).isTrue()
}
private fun setLibraryContent(
initialFilters: LibraryFilters = LibraryFilters(),
initialPage: Int = 0,
shelves: List<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,4 +1,4 @@
package com.aryan.reader.epubreader
package org.dueattendant149.bookreader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi

View file

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

View file

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

View file

@ -0,0 +1,108 @@
package org.dueattendant149.bookreader.epubreader
import android.content.Context
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.dueattendant149.bookreader.epub.EpubBook
import org.dueattendant149.bookreader.epub.EpubChapter
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class EpubReaderLogicTest {
private lateinit var context: Context
private lateinit var testDir: File
private lateinit var testBook: EpubBook
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
testDir = File(context.cacheDir, "test_epub_search").apply {
deleteRecursively()
mkdirs()
}
val chapter1File = File(testDir, "chapter1.html").apply {
writeText("<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

@ -0,0 +1,818 @@
package org.dueattendant149.bookreader.epubreader
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.click
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.dueattendant149.bookreader.FileType
import org.dueattendant149.bookreader.FileHasher
import org.dueattendant149.bookreader.MainActivity
import org.dueattendant149.bookreader.R
import org.dueattendant149.bookreader.RenderMode
import org.dueattendant149.bookreader.data.AppDatabase
import org.dueattendant149.bookreader.data.RecentFileEntity
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
import org.dueattendant149.bookreader.shared.ReaderLocator
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class EpubReaderScreenTest {
@get:Rule
val composeTestRule = createEmptyComposeRule()
private val fixtureAssetName = "epub/reader_test_book.epub"
private val fixtureBookTitle = "Reader Android UI Test Book"
private val sanitizedFixtureBookTitle = "ReaderAndroidUITestBook"
private val targetContext: Context = ApplicationProvider.getApplicationContext()
private val instrumentationContext: Context = InstrumentationRegistry.getInstrumentation().context
private var currentEpubFile: File? = null
private var scenario: ActivityScenario<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,4 +1,4 @@
package com.aryan.reader.tts
package org.dueattendant149.bookreader.epubreader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,180 @@
package org.dueattendant149.bookreader.paginatedreader
import android.content.Context
import android.os.Build
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SdkSuppress
import org.dueattendant149.bookreader.SearchResult
import org.dueattendant149.bookreader.epub.EpubBook
import com.google.common.truth.Truth.assertThat
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
private class FakePaginator(
initiallyLoading: Boolean,
initialPageCount: Int,
initialGeneration: Int
) : IPaginator {
override var isLoading by mutableStateOf(initiallyLoading)
override var totalPageCount by mutableIntStateOf(initialPageCount)
override var generation by mutableIntStateOf(initialGeneration)
override val pageShiftRequest: Flow<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,11 +1,13 @@
// PaginatorTest.kt
package com.aryan.reader.paginatedreader
package org.dueattendant149.bookreader.paginatedreader
import android.os.Build
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SdkSuppress
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@ -42,14 +44,52 @@ class FakeSplittableMeasurementProvider(
}
return null
}
override suspend fun split(block: TableBlock, availableHeight: Int): Pair<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)
@ -85,8 +125,53 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content).containsExactly(block2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
}
@Test
fun paginate_honorsBreakBeforePage() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("Before"), blockIndex = 0)
val block2 = ParagraphBlock(
content = AnnotatedString("After"),
style = BlockStyle(breakBefore = "page"),
blockIndex = 1
)
val pages = paginate(
listOf(block1, block2),
pageHeight,
FakeSplittableMeasurementProvider(mapOf(block1 to 100, block2 to 100)),
testDensity
)
assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
}
@Test
fun paginate_breakInsideAvoidPreventsParagraphSplit() = runTest {
val block1 = ParagraphBlock(
content = AnnotatedString("Keep together"),
style = BlockStyle(breakInside = "avoid"),
blockIndex = 0
)
val part1 = block1.copy(content = AnnotatedString("Keep"))
val part2 = block1.copy(content = AnnotatedString("together"))
val pages = paginate(
listOf(block1),
pageHeight = 400,
measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 800, part1 to 300, part2 to 500),
splittableParagraphs = mapOf(block1 to (part1 to part2))
),
density = testDensity
)
assertThat(pages).hasSize(1)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
}
@Test
@ -110,8 +195,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1, part1).inOrder()
assertThat(pages[1].content).containsExactly(part2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1, part1).inOrder()
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(part2)
}
@Test
@ -136,8 +221,8 @@ class PaginatorTest {
val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(splitWrapper)
assertThat(pages[1].content).containsExactly(para2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(splitWrapper)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(para2)
}
@ -158,8 +243,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content).containsExactly(unsplittableBlock)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(unsplittableBlock)
}
@Test
@ -172,7 +257,7 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
assertThat(pages[0].content).containsExactly(oversizedBlock)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(oversizedBlock)
}
@Test
@ -248,8 +333,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content).containsExactly(splittableBlock) // Was not split
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(splittableBlock) // Was not split
}
@Test
@ -271,8 +356,7 @@ class PaginatorTest {
// Set page height so that a split is attempted.
val pages = paginate(blocks, 150, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
// The page should be empty because part1 was empty, and the original block was re-added
// to the remaining list. The next page then contains the full block.
assertThat(pages[0].content).containsExactly(part2)
// Empty split heads are skipped so pagination keeps only the remaining content.
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(part2)
}
}
}

View file

@ -0,0 +1,103 @@
package org.dueattendant149.bookreader.paginatedreader
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.sp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ReaderLinkHitTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun urlAnnotationAtPositionIgnoresSameLineSpaceAfterLink() {
lateinit var text: AnnotatedString
var layoutResult: TextLayoutResult? = null
composeTestRule.setContent {
val textMeasurer = rememberTextMeasurer()
text = linkText("Open")
layoutResult = textMeasurer.measure(
text = text,
style = TextStyle(fontSize = 24.sp),
constraints = Constraints.fixedWidth(500)
)
}
composeTestRule.waitForIdle()
val layout = checkNotNull(layoutResult)
val firstBox = layout.getBoundingBox(0)
val lastBox = layout.getBoundingBox(text.length - 1)
val y = (firstBox.top + firstBox.bottom) / 2f
assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(firstBox.left + 1f, y)))
.isEqualTo(HREF)
assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(lastBox.right + 60f, y)))
.isNull()
}
@Test
fun urlAnnotationAtPositionAppliesTextStartOffsetForWrappedLineLayouts() {
lateinit var fullText: AnnotatedString
var lineLayoutResult: TextLayoutResult? = null
val prefix = "Before "
val label = "Open"
composeTestRule.setContent {
val textMeasurer = rememberTextMeasurer()
fullText = buildAnnotatedString {
append(prefix)
append(label)
addStringAnnotation("URL", HREF, prefix.length, prefix.length + label.length)
}
lineLayoutResult = textMeasurer.measure(
text = fullText.subSequence(prefix.length, fullText.length),
style = TextStyle(fontSize = 24.sp),
constraints = Constraints.fixedWidth(500)
)
}
composeTestRule.waitForIdle()
val layout = checkNotNull(lineLayoutResult)
val firstBox = layout.getBoundingBox(0)
val lastBox = layout.getBoundingBox(label.length - 1)
val y = (firstBox.top + firstBox.bottom) / 2f
assertThat(
fullText.readerUrlAnnotationAtPosition(
layout = layout,
position = Offset(firstBox.left + 1f, y),
textStartOffset = prefix.length
)
).isEqualTo(HREF)
assertThat(
fullText.readerUrlAnnotationAtPosition(
layout = layout,
position = Offset(lastBox.right + 60f, y),
textStartOffset = prefix.length
)
).isNull()
}
private fun linkText(label: String) = buildAnnotatedString {
append(label)
addStringAnnotation("URL", HREF, 0, label.length)
}
private companion object {
const val HREF = "chapter.xhtml#target"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,258 @@
package org.dueattendant149.bookreader.pdf
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.GrantPermissionRule
import org.dueattendant149.bookreader.MainActivity
import org.dueattendant149.bookreader.R
import com.google.common.truth.Truth.assertThat
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class PdfViewerScreenTest {
@get:Rule
val composeTestRule = createEmptyComposeRule()
@get:Rule
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
private val context: Context = ApplicationProvider.getApplicationContext()
private var currentPdfFile: File? = null
private var scenario: ActivityScenario<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,5 +1,5 @@
// BaseTtsSynthesizerTest.kt
package com.aryan.reader.tts
package org.dueattendant149.bookreader.tts
import android.speech.tts.TextToSpeech
import androidx.test.core.app.ApplicationProvider

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,41 +0,0 @@
package com.aryan.reader
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.shared.ReaderFeatureSurface
import com.aryan.reader.shared.ReaderPlatform
import com.aryan.reader.shared.SharedFileCapabilities
typealias AddBooksSource = com.aryan.reader.shared.AddBooksSource
typealias FileType = com.aryan.reader.shared.FileType
typealias RenderMode = com.aryan.reader.shared.RenderMode
typealias SortOrder = com.aryan.reader.shared.SortOrder
typealias ReadStatusFilter = com.aryan.reader.shared.ReadStatusFilter
typealias LibraryFilters = com.aryan.reader.shared.LibraryFilters
typealias SyncedFolder = com.aryan.reader.shared.SyncedFolder
typealias ShelfType = com.aryan.reader.shared.ShelfType
internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID)
internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES
internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES
internal fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? {
return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID)
}
data class Shelf(
val id: String,
val name: String,
val type: ShelfType,
val books: List<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

@ -1,20 +0,0 @@
package com.aryan.reader.data
import com.aryan.reader.toSharedBookItem
import com.aryan.reader.shared.SmartCollectionEngine as SharedSmartCollectionEngine
typealias SmartField = com.aryan.reader.shared.SmartField
typealias SmartOperator = com.aryan.reader.shared.SmartOperator
typealias SmartRule = com.aryan.reader.shared.SmartRule
typealias SmartCollectionDefinition = com.aryan.reader.shared.SmartCollectionDefinition
object SmartCollectionEngine {
fun toJson(definition: SmartCollectionDefinition): String =
SharedSmartCollectionEngine.toJson(definition)
fun fromJson(json: String?): SmartCollectionDefinition? =
SharedSmartCollectionEngine.fromJson(json)
fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean =
SharedSmartCollectionEngine.evaluate(book.toSharedBookItem(), definition)
}

View file

@ -1,44 +0,0 @@
/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
package com.aryan.reader.epub
import org.w3c.dom.Document
import org.w3c.dom.Element
import org.w3c.dom.Node
import org.w3c.dom.NodeList
import java.io.InputStream
import javax.xml.parsers.DocumentBuilderFactory
fun parseXMLFile(inputSteam: InputStream): Document? =
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(inputSteam)
fun parseXMLFile(byteArray: ByteArray): Document? = parseXMLFile(byteArray.inputStream())
fun String.asFileName(): String = this.replace("/", "_")
fun Document.selectFirstTag(tag: String): Node? = getElementsByTagName(tag).item(0)
fun Node.selectFirstChildTag(tag: String) = childElements.find { it.tagName == tag }
fun Node.selectChildTag(tag: String) = childElements.filter { it.tagName == tag }
fun Node.getAttributeValue(attribute: String): String? =
attributes?.getNamedItem(attribute)?.textContent
val NodeList.elements get() = (0..length).asSequence().mapNotNull { item(it) as? Element }
val Node.childElements get() = childNodes.elements

View file

@ -1,252 +0,0 @@
/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
package com.aryan.reader.epubreader
import timber.log.Timber
import android.webkit.WebView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchNavigationControls
import com.aryan.reader.SearchResult
import com.aryan.reader.SearchResultsPanel
import com.aryan.reader.SearchState
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.paginatedreader.IPaginator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import java.io.File
import kotlin.math.max
import kotlin.math.min
/**
* Creates the search implementation for EPUB chapters.
*/
fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List<SearchResult> = { query ->
withContext(Dispatchers.Default) {
val results = mutableListOf<SearchResult>()
epubBook.chapters.forEachIndexed { chapterIndex, chapter ->
try {
val htmlFile = File(epubBook.extractionBasePath, chapter.contentFilePath())
if (!htmlFile.exists()) return@forEachIndexed
val doc = Jsoup.parse(htmlFile, "UTF-8")
val bodyChildren = doc.body().children().toList()
val chunks = bodyChildren.chunked(20)
chunks.forEachIndexed { chunkIndex, chunkOfElements ->
val chunkHtml = chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
val content = Jsoup.parse(chunkHtml).text()
var lastIndex = -1
while (true) {
lastIndex = content.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true)
if (lastIndex == -1) break
val isWordStart = lastIndex == 0 || !content[lastIndex - 1].isLetterOrDigit()
if (isWordStart) {
val snippetStart = max(0, lastIndex - 35)
val snippetEnd = min(content.length, lastIndex + query.length + 35)
val rawSnippet = content.substring(snippetStart, snippetEnd)
val annotatedSnippet = buildAnnotatedString {
append(rawSnippet)
val highlightStart = content.indexOf(query, lastIndex, ignoreCase = true) - snippetStart
val highlightEnd = highlightStart + query.length
addStyle(
style = SpanStyle(fontWeight = FontWeight.Bold),
start = highlightStart,
end = highlightEnd
)
}
results.add(
SearchResult(
locationInSource = chapterIndex,
locationTitle = chapter.title,
snippet = annotatedSnippet,
query = query,
occurrenceIndexInLocation = results.count { it.locationInSource == chapterIndex },
chunkIndex = chunkIndex
)
)
}
}
}
} catch (e: Exception) {
Timber.e("Failed to search in chapter $chapterIndex", e)
}
}
results
}
}
/**
* Handles the navigation to a specific search result.
*/
fun performSearchResultNavigation(
index: Int,
searchState: SearchState,
renderMode: RenderMode,
currentChapterIndex: Int,
loadedChunkCount: Int,
webView: WebView?,
paginator: IPaginator?,
coroutineScope: CoroutineScope,
onVerticalChapterChange: (chapterIndex: Int, chunkIndex: Int, result: SearchResult) -> Unit,
onVerticalScrollToResult: (result: SearchResult) -> Unit,
onPaginatedScrollToPage: suspend (pageIndex: Int) -> Unit
) {
if (index !in searchState.searchResults.indices) return
val result = searchState.searchResults[index]
searchState.currentSearchResultIndex = index
when (renderMode) {
RenderMode.VERTICAL_SCROLL -> {
if (currentChapterIndex != result.locationInSource) {
onVerticalChapterChange(result.locationInSource, result.chunkIndex, result)
} else {
if (result.chunkIndex >= loadedChunkCount) {
onVerticalChapterChange(result.locationInSource, result.chunkIndex, result)
} else {
webView?.let {
val js = "javascript:window.scrollToOccurrence(${result.occurrenceIndexInLocation});"
it.evaluateJavascript(js, null)
}
onVerticalScrollToResult(result)
}
}
}
RenderMode.PAGINATED -> {
paginator?.findPageForSearchResult(result) { pageIndex ->
coroutineScope.launch {
onPaginatedScrollToPage(pageIndex)
}
}
}
}
}
@Composable
fun EpubReaderSearchEffects(
searchState: SearchState,
webViewRef: WebView?,
currentChapterIndex: Int,
focusRequester: FocusRequester
) {
// 1. Auto-Highlight in WebView
LaunchedEffect(searchState.searchResults, currentChapterIndex) {
val query = searchState.searchQuery
if (query.isBlank()) {
webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
return@LaunchedEffect
}
val resultsInCurrentChapter = searchState.searchResults.any { it.locationInSource == currentChapterIndex }
if (resultsInCurrentChapter) {
webViewRef?.let { webView ->
val escapedQuery = escapeJsString(query)
val js = "javascript:window.highlightAllOccurrences('${escapedQuery}');"
Timber.d("Highligting: $js")
webView.evaluateJavascript(js, null)
}
} else {
webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
}
}
// 2. Focus Management
LaunchedEffect(searchState.isSearchActive) {
if (searchState.isSearchActive) {
delay(100)
focusRequester.requestFocus()
} else {
webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
}
}
}
@Composable
fun EpubReaderSearchOverlay(
searchState: SearchState,
onNavigateResult: (Int) -> Unit,
bottomPadding: Dp
) {
val keyboardController = LocalSoftwareKeyboardController.current
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
// Search Results Panel
AnimatedVisibility(
visible = searchState.isSearchActive && searchState.showSearchResultsPanel,
enter = slideInVertically { -it } + fadeIn(),
exit = slideOutVertically { -it } + fadeOut(),
) {
SearchResultsPanel(
results = searchState.searchResults,
isSearching = searchState.isSearchInProgress,
onResultClick = { result ->
val resultIndex = searchState.searchResults.indexOf(result)
if (resultIndex != -1) {
onNavigateResult(resultIndex)
}
searchState.showSearchResultsPanel = false
keyboardController?.hide()
},
modifier = Modifier.padding(top = 50.dp)
)
}
AnimatedVisibility(
visible = searchState.isSearchActive && !searchState.showSearchResultsPanel && searchState.hasResults,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = bottomPadding + 45.dp + 16.dp, end = 16.dp)
) {
SearchNavigationControls(
searchState = searchState,
onNavigate = { index -> onNavigateResult(index) }
)
}
}
}

View file

@ -1,10 +0,0 @@
package com.aryan.reader.opds
typealias OpdsCatalog = com.aryan.reader.shared.opds.OpdsCatalog
typealias OpdsFacet = com.aryan.reader.shared.opds.OpdsFacet
typealias OpdsFeed = com.aryan.reader.shared.opds.OpdsFeed
typealias OpdsAuthor = com.aryan.reader.shared.opds.OpdsAuthor
typealias OpdsAcquisition = com.aryan.reader.shared.opds.OpdsAcquisition
typealias OpdsEntry = com.aryan.reader.shared.opds.OpdsEntry
typealias OpdsDownloadState = com.aryan.reader.shared.opds.SharedOpdsDownloadState
typealias OpdsScreenState = com.aryan.reader.shared.opds.SharedOpdsScreenState

View file

@ -1,3 +0,0 @@
package com.aryan.reader.opds
typealias OpdsParser = com.aryan.reader.shared.opds.SharedOpdsParser

View file

@ -1,86 +0,0 @@
/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
package com.aryan.reader.pdf.data
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
class PdfAnnotationRepository(private val context: Context) {
private fun getFile(bookId: String): File {
val safeBookId = bookId.replace("/", "_")
val dir = File(context.filesDir, "annotations")
if (!dir.exists()) dir.mkdirs()
return File(dir, "annotation_$safeBookId.json")
}
suspend fun saveAnnotations(bookId: String, annotations: Map<Int, List<PdfAnnotation>>) {
withContext(Dispatchers.IO) {
try {
Timber.tag("AnnotationSync").d("Start saving local JSON for $bookId. Count: ${annotations.size}")
if (annotations.isEmpty()) {
val file = getFile(bookId)
if (file.exists()) file.delete()
return@withContext
}
val json = AnnotationSerializer.toJson(annotations)
val file = getFile(bookId)
file.writeText(json)
Timber.tag("AnnotationSync").d("Finished saving local JSON for $bookId. Path: ${file.absolutePath}, Size: ${file.length()}")
} catch (e: Exception) {
Timber.tag("AnnotationSync").e(e, "Failed to save local annotations")
}
}
}
suspend fun loadAnnotations(bookId: String): Map<Int, List<PdfAnnotation>> {
return withContext(Dispatchers.IO) {
try {
val file = getFile(bookId)
if (file.exists()) {
val json = file.readText()
Timber.tag("AnnotationSync").d("Loaded local JSON for $bookId. Size: ${file.length()}")
AnnotationSerializer.fromJson(json)
} else {
Timber.tag("AnnotationSync").d("No local annotation file found for $bookId")
emptyMap()
}
} catch (e: Exception) {
Timber.tag("AnnotationSync").e(e, "Failed to load local annotations")
emptyMap()
}
}
}
fun getAnnotationFileForSync(bookId: String): File? {
val file = getFile(bookId)
val valid = file.exists() && file.length() > 0
Timber.tag("AnnotationSync").d("Checking file for sync: $bookId. Exists: ${file.exists()}, Size: ${file.length()} bytes. Valid: $valid")
return if (valid) file else null
}
}

View file

@ -1,8 +0,0 @@
package com.aryan.reader.tts
const val ACTION_OPEN_TTS_SESSION = "com.aryan.reader.tts.OPEN_SESSION"
const val EXTRA_TTS_BOOK_ID = "com.aryan.reader.tts.extra.BOOK_ID"
const val EXTRA_TTS_CHAPTER_INDEX = "com.aryan.reader.tts.extra.CHAPTER_INDEX"
const val EXTRA_TTS_SOURCE_CFI = "com.aryan.reader.tts.extra.SOURCE_CFI"
const val EXTRA_TTS_START_OFFSET = "com.aryan.reader.tts.extra.START_OFFSET"
const val EXTRA_TTS_PAGE_INDEX = "com.aryan.reader.tts.extra.PAGE_INDEX"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,80 @@
package org.dueattendant149.bookreader
import android.content.Context
import androidx.core.content.edit
import org.dueattendant149.bookreader.shared.ReaderBookReplacementPreferences
import org.dueattendant149.bookreader.shared.ReaderBookReplacementPreferencesJson
import org.dueattendant149.bookreader.shared.ReaderWordReplacementEngine
import org.dueattendant149.bookreader.shared.ReaderWordReplacementRule
import org.jsoup.nodes.Document
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
private const val READER_PREFS_NAME = "reader_prefs"
private const val BOOK_REPLACEMENTS_KEY = "book_word_replacements_json"
fun loadBookReplacementPreferences(context: Context): ReaderBookReplacementPreferences {
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
return ReaderBookReplacementPreferencesJson.decodeOrEmpty(prefs.getString(BOOK_REPLACEMENTS_KEY, null))
}
fun saveBookReplacementPreferences(
context: Context,
preferences: ReaderBookReplacementPreferences,
) {
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putString(BOOK_REPLACEMENTS_KEY, ReaderBookReplacementPreferencesJson.encode(preferences))
}
}
internal fun applyBookReplacementsToHtmlDocument(
document: Document,
preferences: ReaderBookReplacementPreferences,
fileId: String?,
): Boolean {
val rules = preferences.activeRulesForFile(fileId)
if (rules.isEmpty()) return false
var changed = false
fun rewriteTextNodes(node: Node) {
if (node is TextNode && !node.hasReplacementBlockedAncestor()) {
val original = node.wholeText
val replaced = applyBookReplacementRules(original, rules)
if (replaced != original) {
node.text(replaced)
changed = true
}
return
}
node.childNodes().forEach(::rewriteTextNodes)
}
document.body()?.let(::rewriteTextNodes)
return changed
}
private fun applyBookReplacementRules(
text: String,
rules: List<ReaderWordReplacementRule>,
): String {
return ReaderWordReplacementEngine.apply(
text = text,
rules = rules,
).text
}
private fun TextNode.hasReplacementBlockedAncestor(): Boolean {
var current: Node? = parent()
while (current != null) {
when (current.nodeName().lowercase()) {
"script",
"style",
"noscript" -> return true
}
current = current.parent()
}
return false
}

View file

@ -0,0 +1,400 @@
package org.dueattendant149.bookreader
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.dueattendant149.bookreader.shared.ReaderBookReplacementEngine
import org.dueattendant149.bookreader.shared.ReaderBookReplacementPreferences
import org.dueattendant149.bookreader.shared.ReaderWordReplacementRule
private data class BookRuleEditTarget(
val ruleId: String? = null,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookWordReplacementsSheet(
isVisible: Boolean,
bookId: String,
bookTitle: String?,
preferences: ReaderBookReplacementPreferences,
onPreferencesChange: (ReaderBookReplacementPreferences) -> Unit,
onDismiss: () -> Unit,
) {
if (!isVisible) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var editTarget by remember(bookId) { mutableStateOf<BookRuleEditTarget?>(null) }
val rules = preferences.rulesForFile(bookId)
val editingRule = editTarget?.ruleId?.let { id -> rules.firstOrNull { it.id == id } }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 720.dp)
.imePadding()
.padding(horizontal = 20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.menu_book_word_replacements),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
text = bookTitle?.takeIf { it.isNotBlank() } ?: stringResource(R.string.book_replacements_current_book),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
}
}
Spacer(modifier = Modifier.height(12.dp))
LazyColumn(
modifier = Modifier.heightIn(max = 560.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
TextButton(
onClick = { editTarget = BookRuleEditTarget() },
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.book_replacements_add_rule))
}
}
if (editTarget != null) {
item {
BookRuleEditorCard(
seedRule = editingRule,
onCancel = { editTarget = null },
onSave = { rule ->
val updatedRules = if (editingRule == null) {
rules + rule
} else {
rules.map { if (it.id == editingRule.id) rule else it }
}
onPreferencesChange(preferences.withFileRules(bookId, updatedRules))
editTarget = null
},
)
}
}
item {
BookReplacementRuleList(
rules = rules,
emptyTextRes = R.string.book_replacements_empty,
onToggle = { rule, enabled ->
onPreferencesChange(
preferences.withFileRules(
bookId,
rules.map { if (it.id == rule.id) it.copy(enabled = enabled) else it },
),
)
},
onEdit = { editTarget = BookRuleEditTarget(it.id) },
onDelete = { rule ->
onPreferencesChange(preferences.withFileRules(bookId, rules.filterNot { it.id == rule.id }))
},
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun BookRuleEditorCard(
seedRule: ReaderWordReplacementRule?,
onCancel: () -> Unit,
onSave: (ReaderWordReplacementRule) -> Unit,
) {
val draftRuleId = remember(seedRule?.id) { seedRule?.id ?: newBookReplacementRuleId() }
val initial = seedRule ?: ReaderWordReplacementRule(
id = draftRuleId,
from = "",
to = "",
)
var from by remember(initial.id) { mutableStateOf(initial.from) }
var to by remember(initial.id) { mutableStateOf(initial.to) }
var enabled by remember(initial.id) { mutableStateOf(initial.enabled) }
var isRegex by remember(initial.id) { mutableStateOf(initial.isRegex) }
var wholeWord by remember(initial.id) { mutableStateOf(initial.wholeWord) }
var matchCase by remember(initial.id) { mutableStateOf(initial.matchCase) }
val defaultPreviewInput = stringResource(R.string.book_replacements_preview_default)
var previewInput by remember(initial.id, defaultPreviewInput) {
mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: defaultPreviewInput)
}
val draft = ReaderWordReplacementRule(
id = initial.id,
from = from,
to = to,
enabled = enabled,
isRegex = isRegex,
matchCase = matchCase,
wholeWord = wholeWord,
)
val validation = ReaderBookReplacementEngine.validate(draft)
val previewOutput = if (validation.isValid) {
ReaderBookReplacementEngine.apply(
text = previewInput,
preferences = ReaderBookReplacementPreferences(fileRules = mapOf("preview" to listOf(draft.copy(enabled = true)))),
fileId = "preview",
).text
} else {
previewInput
}
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.35f)),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(if (seedRule == null) R.string.book_replacements_new_replacement else R.string.book_replacements_edit_replacement),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
OutlinedTextField(
value = from,
onValueChange = { from = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.tts_replacements_label_replace)) },
singleLine = !isRegex,
isError = !validation.isValid,
supportingText = if (validation.message != null) {
{ Text(validation.message.orEmpty()) }
} else {
null
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Text,
),
)
OutlinedTextField(
value = to,
onValueChange = { to = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.book_replacements_label_with)) },
singleLine = !isRegex,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item {
FilterChip(
selected = enabled,
onClick = { enabled = !enabled },
label = { Text(stringResource(R.string.tts_replacements_chip_enabled)) },
leadingIcon = if (enabled) {
{ Icon(Icons.Default.Check, contentDescription = null) }
} else {
null
},
)
}
item {
FilterChip(
selected = isRegex,
onClick = { isRegex = !isRegex },
label = { Text(stringResource(R.string.tts_replacements_chip_regex)) },
)
}
item {
FilterChip(
selected = wholeWord,
onClick = { wholeWord = !wholeWord },
label = { Text(stringResource(R.string.tts_replacements_chip_whole_word)) },
)
}
item {
FilterChip(
selected = matchCase,
onClick = { matchCase = !matchCase },
label = { Text(stringResource(R.string.tts_replacements_chip_match_case)) },
)
}
}
OutlinedTextField(
value = previewInput,
onValueChange = { previewInput = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.tts_replacements_label_preview_input)) },
minLines = 2,
)
Text(
text = previewOutput,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.action_cancel))
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { onSave(draft) },
enabled = validation.isValid,
) {
Text(stringResource(R.string.action_save))
}
}
}
}
}
@Composable
private fun BookReplacementRuleList(
rules: List<ReaderWordReplacementRule>,
@StringRes emptyTextRes: Int,
onToggle: (ReaderWordReplacementRule, Boolean) -> Unit,
onEdit: (ReaderWordReplacementRule) -> Unit,
onDelete: (ReaderWordReplacementRule) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.tts_replacements_rules),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
if (rules.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(emptyTextRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
rules.forEach { rule ->
val emptyLabel = stringResource(R.string.book_replacements_empty_replacement)
ListItem(
headlineContent = {
Text(
text = rule.summaryText(emptyLabel),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Text(rule.optionSummary())
},
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = rule.enabled,
onCheckedChange = { onToggle(rule, it) },
)
IconButton(onClick = { onEdit(rule) }) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.action_edit))
}
IconButton(onClick = { onDelete(rule) }) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete))
}
}
},
)
}
}
}
private fun ReaderWordReplacementRule.summaryText(emptyLabel: String): String {
val replacement = to.ifBlank { emptyLabel }
return "$from -> $replacement"
}
@Composable
private fun ReaderWordReplacementRule.optionSummary(): String {
val regexLabel = stringResource(R.string.tts_replacements_chip_regex)
val plainTextLabel = stringResource(R.string.tts_replacements_plain_text)
val wholeWordLabel = stringResource(R.string.tts_replacements_chip_whole_word)
val caseSensitiveLabel = stringResource(R.string.tts_replacements_case_sensitive)
val parts = buildList {
add(if (isRegex) regexLabel else plainTextLabel)
if (wholeWord) add(wholeWordLabel)
if (matchCase) add(caseSensitiveLabel)
}
return parts.joinToString(" - ")
}
private fun newBookReplacementRuleId(): String {
return "book_rule_${System.currentTimeMillis()}"
}

View file

@ -0,0 +1,31 @@
package org.dueattendant149.bookreader
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import timber.log.Timber
internal fun copyPlainTextToClipboard(
context: Context,
label: String,
text: String
): Boolean {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(label, text)
return setPrimaryClipSafely {
clipboard.setPrimaryClip(clip)
}
}
internal inline fun setPrimaryClipSafely(setPrimaryClip: () -> Unit): Boolean {
return try {
setPrimaryClip()
true
} catch (e: SecurityException) {
Timber.w(e, "Clipboard write rejected by system policy")
false
} catch (e: RuntimeException) {
Timber.w(e, "Clipboard write failed")
false
}
}

View file

@ -0,0 +1,51 @@
package org.dueattendant149.bookreader
import org.dueattendant149.bookreader.data.BookMetadata
import org.dueattendant149.bookreader.data.RecentFileItem
internal fun RecentFileItem.needsRemoteEpubAnnotationMetadataGuard(): Boolean {
return type in EPUB_READER_FILE_TYPES &&
(bookmarksJson.isNullOrBlank() || highlightsJson.isNullOrBlank())
}
internal fun RecentFileItem.mergeRemoteEpubAnnotationMetadata(remote: BookMetadata?): RecentFileItem {
if (remote == null || remote.isDeleted || type !in EPUB_READER_FILE_TYPES || !remote.isEpubReaderMetadata()) {
return this
}
val nextBookmarks = if (bookmarksJson.isNullOrBlank() && remote.bookmarksJson.hasCloudAnnotationPayload()) {
remote.bookmarksJson
} else {
bookmarksJson
}
val nextHighlights = if (highlightsJson.isNullOrBlank() && remote.highlightsJson.hasCloudAnnotationPayload()) {
remote.highlightsJson
} else {
highlightsJson
}
if (nextBookmarks == bookmarksJson && nextHighlights == highlightsJson) return this
return copy(
bookmarksJson = nextBookmarks,
highlightsJson = nextHighlights
)
}
private fun BookMetadata.isEpubReaderMetadata(): Boolean {
val remoteType = runCatching { FileType.valueOf(type) }.getOrNull() ?: return false
return remoteType in EPUB_READER_FILE_TYPES
}
internal fun String?.hasCloudAnnotationPayload(): Boolean {
val normalized = this?.trim().orEmpty()
return normalized.isNotEmpty() && normalized != "[]"
}
internal fun annotationJsonEquivalentForNoop(existing: String?, incoming: String): Boolean {
val existingNormalized = existing?.trim().orEmpty()
val incomingNormalized = incoming.trim()
if (existingNormalized == incomingNormalized) return true
return existingNormalized.isAnnotationJsonEmpty() && incomingNormalized.isAnnotationJsonEmpty()
}
private fun String.isAnnotationJsonEmpty(): Boolean {
return isBlank() || this == "[]"
}

View file

@ -0,0 +1,75 @@
package org.dueattendant149.bookreader
import java.io.File
internal data class AndroidPdfCloudSidecarState(
val hasInk: Boolean,
val inkTimestamp: Long,
val hasDeletedInk: Boolean = false,
val deletedInkTimestamp: Long = 0L,
val hasRichText: Boolean,
val richTextTimestamp: Long,
val hasLayout: Boolean,
val layoutTimestamp: Long,
val hasTextBoxes: Boolean,
val textBoxesTimestamp: Long,
val hasHighlights: Boolean,
val highlightsTimestamp: Long
) {
val hasAnnotationPayload: Boolean
get() = hasInk || hasDeletedInk || hasRichText || hasTextBoxes || hasHighlights
val annotationPayloadTimestamp: Long
get() = maxOf(
inkTimestamp.takeIf { hasInk } ?: 0L,
deletedInkTimestamp.takeIf { hasDeletedInk } ?: 0L,
richTextTimestamp.takeIf { hasRichText } ?: 0L,
textBoxesTimestamp.takeIf { hasTextBoxes } ?: 0L,
highlightsTimestamp.takeIf { hasHighlights } ?: 0L
)
val bundleTimestamp: Long
get() = if (hasAnnotationPayload) {
maxOf(annotationPayloadTimestamp, layoutTimestamp.takeIf { hasLayout } ?: 0L)
} else {
0L
}
}
internal fun shouldUploadLocalPdfCloudAnnotations(
localSidecars: AndroidPdfCloudSidecarState,
remoteHasAnnotations: Boolean,
remoteAnnotationModifiedTimestamp: Long
): Boolean {
return localSidecars.hasAnnotationPayload &&
(!remoteHasAnnotations || localSidecars.annotationPayloadTimestamp > remoteAnnotationModifiedTimestamp)
}
internal fun shouldDownloadRemotePdfCloudAnnotations(
localSidecars: AndroidPdfCloudSidecarState,
localAnnotationsShouldUpload: Boolean,
remoteHasAnnotations: Boolean,
remoteAnnotationModifiedTimestamp: Long
): Boolean {
if (localAnnotationsShouldUpload || !remoteHasAnnotations) return false
return !localSidecars.hasAnnotationPayload ||
remoteAnnotationModifiedTimestamp > localSidecars.annotationPayloadTimestamp
}
internal fun File?.hasSyncableCloudAnnotationPayload(): Boolean {
val file = this ?: return false
if (!file.isFile || file.length() <= 0L) return false
val trimmed = runCatching { file.readText().trim() }.getOrDefault("")
return trimmed.isNotBlank() && trimmed != "[]" && trimmed != "{}"
}
internal fun markPdfCloudAnnotationSidecarsSynced(timestamp: Long, vararg files: File?) {
if (timestamp <= 0L) return
files.forEach { file ->
if (file?.exists() == true) {
file.setLastModified(timestamp)
}
}
}
internal fun cloudPdfAnnotationDriveFileName(bookId: String): String = "annotation_$bookId.json"

View file

@ -0,0 +1,70 @@
package org.dueattendant149.bookreader
import android.util.Log
import org.dueattendant149.bookreader.data.BookMetadata
import org.dueattendant149.bookreader.data.RecentFileItem
import org.dueattendant149.bookreader.data.effectiveAnnotationModifiedTimestamp
import org.dueattendant149.bookreader.data.effectiveReadingPositionModifiedTimestamp
import timber.log.Timber
internal const val CloudSyncTraceTag = "EpistemeCloudSync"
internal const val CloudAnnotationSyncTraceTag = "EpistemeCloudAnnotations"
internal fun logCloudSyncTrace(message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.d(CloudSyncTraceTag, text)
Timber.tag(CloudSyncTraceTag).d(text)
}
internal fun logCloudSyncError(error: Throwable, message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.e(CloudSyncTraceTag, text, error)
Timber.tag(CloudSyncTraceTag).e(error, text)
}
internal fun logCloudAnnotationSyncTrace(message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.d(CloudAnnotationSyncTraceTag, text)
Timber.tag(CloudAnnotationSyncTraceTag).d(text)
}
internal fun logCloudAnnotationSyncError(error: Throwable, message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.e(CloudAnnotationSyncTraceTag, text, error)
Timber.tag(CloudAnnotationSyncTraceTag).e(error, text)
}
internal fun RecentFileItem.cloudSyncTraceSummary(prefix: String = "local"): String {
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
"contentTs=$fileContentModifiedTimestamp " +
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
"bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
}
internal fun BookMetadata.cloudSyncTraceSummary(prefix: String = "remote"): String {
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
"annTs=${effectiveAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
"hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
"highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
}
internal fun String?.cloudSyncPreview(maxLength: Int = 80): String {
val value = this ?: return "null"
return if (value.length <= maxLength) value else value.take(maxLength) + "..."
}
internal fun String?.cloudSyncAnnotationSummary(): String {
val value = this?.trim() ?: return "null"
return when {
value.isEmpty() -> "blank"
value == "[]" -> "empty"
else -> "present(${value.length})"
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,57 @@
package org.dueattendant149.bookreader
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
const val EXTRA_TEMPORARY_EXTERNAL_OPEN = "org.dueattendant149.bookreader.extra.TEMPORARY_EXTERNAL_OPEN"
object ExternalFileOpenRouteDecider {
const val BEHAVIOR_TEMPORARY = "TEMPORARY"
fun shouldOpenTemporary(externalFileBehavior: String?): Boolean {
return externalFileBehavior == BEHAVIOR_TEMPORARY
}
fun targetActivityClass(externalFileBehavior: String?): Class<out Activity> {
return if (shouldOpenTemporary(externalFileBehavior)) {
TemporaryExternalFileActivity::class.java
} else {
MainActivity::class.java
}
}
}
class ExternalFileOpenRouterActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
routeExternalOpen(intent)
finish()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
routeExternalOpen(intent)
finish()
}
private fun routeExternalOpen(sourceIntent: Intent?) {
if (sourceIntent?.action != Intent.ACTION_VIEW || sourceIntent.data == null) return
val prefs = getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val behavior = prefs.getString("external_file_behavior", "ASK")
val temporary = ExternalFileOpenRouteDecider.shouldOpenTemporary(behavior)
val targetIntent = Intent(sourceIntent).apply {
setClass(this@ExternalFileOpenRouterActivity, ExternalFileOpenRouteDecider.targetActivityClass(behavior))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
if (temporary) {
putExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, true)
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
}
}
startActivity(targetIntent)
}
}
class TemporaryExternalFileActivity : MainActivity()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,14 @@
package com.aryan.reader
package org.dueattendant149.bookreader
import com.aryan.reader.data.BookShelfCrossRef
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity
import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.applyLibraryFilters as sharedApplyLibraryFilters
import com.aryan.reader.shared.filterBySearch as sharedFilterBySearch
import com.aryan.reader.shared.sortBooks as sharedSortBooks
import org.dueattendant149.bookreader.data.BookShelfCrossRef
import org.dueattendant149.bookreader.data.BookTagCrossRef
import org.dueattendant149.bookreader.data.RecentFileItem
import org.dueattendant149.bookreader.data.ShelfEntity
import org.dueattendant149.bookreader.data.TagEntity
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
import org.dueattendant149.bookreader.shared.applyLibraryFilters as sharedApplyLibraryFilters
import org.dueattendant149.bookreader.shared.filterBySearch as sharedFilterBySearch
import org.dueattendant149.bookreader.shared.sortBooks as sharedSortBooks
fun interface FolderPathResolver {
fun relativeFolderSegments(item: RecentFileItem): List<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<com.aryan.reader.shared.BookItem>): List<RecentFileItem> {
private fun List<RecentFileItem>.mapSharedResults(sharedBooks: List<org.dueattendant149.bookreader.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 com.aryan.reader
package org.dueattendant149.bookreader
import android.content.Intent
import android.os.Build
@ -41,8 +41,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.aryan.reader.data.PlatformFeaturesRepository
import com.aryan.reader.ui.theme.AppTheme
import org.dueattendant149.bookreader.data.PlatformFeaturesRepository
import org.dueattendant149.bookreader.ui.theme.AppTheme
import kotlinx.coroutines.launch
import timber.log.Timber
import androidx.compose.foundation.isSystemInDarkTheme
@ -50,18 +50,20 @@ import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.tts.ACTION_OPEN_TTS_SESSION
import com.aryan.reader.tts.EXTRA_TTS_BOOK_ID
import com.aryan.reader.tts.EXTRA_TTS_CHAPTER_INDEX
import com.aryan.reader.tts.EXTRA_TTS_PAGE_INDEX
import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI
import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET
import org.dueattendant149.bookreader.tts.ACTION_OPEN_TTS_SESSION
import org.dueattendant149.bookreader.tts.EXTRA_TTS_BOOK_ID
import org.dueattendant149.bookreader.tts.EXTRA_TTS_CHAPTER_INDEX
import org.dueattendant149.bookreader.tts.EXTRA_TTS_PAGE_INDEX
import org.dueattendant149.bookreader.tts.EXTRA_TTS_SOURCE_CFI
import org.dueattendant149.bookreader.tts.EXTRA_TTS_START_OFFSET
@UnstableApi
class MainActivity : AppCompatActivity() {
open class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
private val isTemporaryExternalOpen: Boolean
get() = intent?.getBooleanExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, false) == true
private val updateLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
@ -86,6 +88,14 @@ class MainActivity : AppCompatActivity() {
}
}
lifecycleScope.launch {
viewModel.temporaryExternalOpenFinished.collect {
if (isTemporaryExternalOpen) {
finishAndRemoveTask()
}
}
}
if (savedInstanceState == null) {
handleIntent(intent)
}
@ -160,7 +170,12 @@ class MainActivity : AppCompatActivity() {
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
Timber.d("Received VIEW intent with URI: ${intent.data}")
val uri = intent.data!!
viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true)
viewModel.onFileSelected(
uri,
isFromRecent = false,
isExternalIntent = true,
isTemporaryExternalIntent = isTemporaryExternalOpen
)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,12 @@
package com.aryan.reader
package org.dueattendant149.bookreader
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.aryan.reader.data.RecentFileItem
import androidx.compose.runtime.setValue
import org.dueattendant149.bookreader.data.RecentFileItem
@Composable
private fun rememberReaderFileInfoItem(
@ -64,28 +67,17 @@ internal fun ReaderFileInfoDialogs(
}
}
item?.let { fileInfoItem ->
if (isFileInfoVisible) {
FileInfoDialog(
item = fileInfoItem,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = { onFileInfoVisibleChange(false) },
onSaveMetadata = { metadata ->
viewModel.updateBookMetadata(fileInfoItem.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(fileInfoItem.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId)
},
onOpenTags = {
onFileInfoVisibleChange(false)
viewModel.openTagSelection(setOf(fileInfoItem.bookId))
}
)
HydratedFileInfoDialog(
item = item,
isVisible = isFileInfoVisible,
uiState = uiState,
viewModel = viewModel,
onDismiss = { onFileInfoVisibleChange(false) },
onOpenTags = { bookId ->
onFileInfoVisibleChange(false)
viewModel.openTagSelection(setOf(bookId))
}
}
)
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
TagSelectionBottomSheet(
@ -102,3 +94,49 @@ internal fun ReaderFileInfoDialogs(
)
}
}
@Composable
internal fun HydratedFileInfoDialog(
item: RecentFileItem?,
isVisible: Boolean,
uiState: ReaderScreenState,
viewModel: MainViewModel,
onDismiss: () -> Unit,
onOpenTags: (String) -> Unit
) {
var fileInfoItem by remember(item?.bookId) { mutableStateOf(item) }
var hasResolvedFullItem by remember(item?.bookId) { mutableStateOf(false) }
LaunchedEffect(item) {
fileInfoItem = item
hasResolvedFullItem = false
}
LaunchedEffect(isVisible, item?.bookId) {
if (isVisible && item != null) {
fileInfoItem = viewModel.getFileInfoItem(item.bookId)?.copy(tags = item.tags) ?: item
hasResolvedFullItem = true
}
}
val resolvedItem = fileInfoItem
if (isVisible && resolvedItem != null && hasResolvedFullItem) {
FileInfoDialog(
item = resolvedItem,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = onDismiss,
onSaveMetadata = { metadata ->
viewModel.updateBookMetadata(resolvedItem.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(resolvedItem.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(resolvedItem.bookId)
},
onOpenTags = {
onOpenTags(resolvedItem.bookId)
}
)
}
}

View file

@ -0,0 +1,15 @@
package org.dueattendant149.bookreader
import org.dueattendant149.bookreader.shared.detectFontVariant
import org.dueattendant149.bookreader.shared.familyFilenameSignature
import org.dueattendant149.bookreader.shared.supportsVariableWeightAxis
const val ReaderFontDiagnosticsTag = "ReaderFontDiag"
fun readerFontDiagnosticSummary(nameWithoutExtension: String): String {
val variant = nameWithoutExtension.detectFontVariant()
return "name='$nameWithoutExtension' " +
"signature='${nameWithoutExtension.familyFilenameSignature()}' " +
"variant=$variant " +
"variableWght=${nameWithoutExtension.supportsVariableWeightAxis()}"
}

View file

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

View file

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

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