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

View file

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

12
.idea/vcs.xml generated 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> <span>&nbsp;Episteme Reader</span>
</h1> </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> </div>
<br/> <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 ## 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: The same core reading experience is available across editions. The main differences are distribution channel, network access, and whether proprietary online services are included.
* **PlayStore Version:** The full-featured release which includes proprietary code and features.
* **OSS Version (GitHub/F-Droid):** A fully open-source build.
* **OSS Offline Version (GitHub):** A strictly offline build with network permissions completely removed.
--- ## Core Features
## Feature Comparison Available across supported editions unless noted in the edition table:
### 📚 Supported Formats * **Formats:** PDF, EPUB, MOBI/AZW3, FB2, DOCX, ODT/FODT, TXT, Markdown, HTML, and comic archives.
| Feature | PlayStore | OSS | OSS Offline | * **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.
| **Documents:** PDF, DOCX, ODT/FODT | ✅ | ✅ | ✅ | * **Customization:** App themes, reader themes, custom local fonts, typography controls, spacing, margins, and layout tuning.
| **E-books & Text:** EPUB, MOBI, AZW3, FB2, MD, HTML, TXT | ✅ | ✅ | ✅ | * **Library tools:** Local folder sync, library organization, bookmarks, progress tracking, and file management.
| **Comics:** CBZ, CBR, CB7 | ✅ | ✅ | ✅ | * **Accessibility:** System text-to-speech, app language selection, and reader settings that adapt to different reading preferences.
| **View-Only:** CSV, TSV, JSON, XML, Logs, Code Files | ✅ | ✅ | ✅ |
### 📖 Core Reading Experience ## Editions
| 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)** | ✅ | ✅ | ✅ |
### ⚙️ Advanced & Network Features | Edition | Platform | Network access | Distribution | Notes |
| Feature | PlayStore | OSS (F-Droid) | OSS Offline | |---|---|---|---|---|
| :--- | :---: | :---: | :---: | | **Play Store** | Android | Online-capable | Google Play | Full Android release with proprietary extras such as ML Kit OCR, cloud sync, AI tools, cloud TTS, and PDF bubble zoom. |
| **Local Folder Sync & Library Management** | ✅ | ✅ | ✅ | | **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. |
| **Download Google Fonts** | ✅ | ✅ | ❌ | | **OSS Offline** | Android | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub, Obtainium | Open-source Android build with network permissions removed. |
| **OPDS Catalog Support** | ✅ | ✅ | ❌ | | **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. |
| **PDF Bubble Zoom Magnifier** | ✅ | ❌ | ❌ | | **Offline** | Windows desktop | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub | Desktop build focused on local reading with online services disabled. |
| **ML Kit OCR** (Scanned PDF Text Selection) | ✅ | ❌ | ❌ |
| **Cross-Device Cloud Sync** | ✅ | ❌ | ❌ |
| **AI Tools** (Summaries, Story Recap, Dictionary) | ✅ | 🔜 | ❌ |
| **Cloud Text-to-Speech** | ✅ | 🔜 | ❌ |
--- Future desktop platforms can use the same Standard and Offline model as support expands.
## Languages
Episteme Reader currently supports: English, Arabic, Belarusian, German, Spanish, Estonian, French, Hindi, Indonesian, Italian, Japanese, Korean, Dutch, Polish, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, and Chinese Simplified.
Want Episteme Reader in another language? Please request it through [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose) or start a thread in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions).
## Building from Source ## Building from Source
@ -70,7 +71,7 @@ To best serve different user preferences regarding privacy and network usage, Ep
cd episteme cd episteme
``` ```
2. Build: 2. Build Android:
* Open in Android Studio and run the `ossDebug` or `ossOfflineDebug` variant, or * Open in Android Studio and run the `ossDebug` or `ossOfflineDebug` variant, or
* Build from the command line: * Build from the command line:
```bash ```bash
@ -79,43 +80,56 @@ To best serve different user preferences regarding privacy and network usage, Ep
The APK will be generated at: The APK will be generated at:
`app/build/outputs/apk/oss/debug/Episteme-oss-v{version}-oss-debug.apk` `app/build/outputs/apk/oss/debug/Episteme-oss-v{version}-oss-debug.apk`
3. Build desktop:
```bash
./gradlew :desktopApp:packageReleaseDistributionForCurrentOS
```
For the offline desktop build, pass:
```bash
./gradlew :desktopApp:packageReleaseDistributionForCurrentOS -PdesktopFlavor=oss
```
## Open Source Libraries ## Open Source Libraries
Powered by the Android OSS ecosystem: Powered by the Kotlin, Android, and desktop OSS ecosystem:
* **Core & UI:** AndroidX, Jetpack Compose, Kotlinx Serialization
* **Document Engines:** PdfiumAndroidKt (PDF), libmobi (MOBI/AZW3) * **Core and UI:** Kotlin Multiplatform, Compose Multiplatform, AndroidX, Jetpack Compose, Kotlinx Serialization
* **Parsers:** Jsoup (HTML/EPUB), Flexmark (Markdown) * **Document engines:** PdfiumAndroidKt, PDFium, libmobi
* **Media & Image Loading:** Coil, Media3 (ExoPlayer) * **Parsers:** Jsoup, Flexmark, Apache Commons Compress
* **Utilities:** Room (Database), Timber (Logging) * **Media and image loading:** Coil, Media3
* **Utilities:** Room, Timber, JNA
## Contributors ## Contributors
| Contributor | Contribution | | Contributor | Contribution |
|---|---| |---|---|
| <img src="https://github.com/CCerrer.png?size=48" width="24" height="24" valign="middle" alt="CCerrer avatar">[CCerrer](https://github.com/CCerrer) | Testing & QA | | <img src="https://github.com/CCerrer.png?size=48" width="24" height="24" valign="middle" alt="CCerrer avatar"> [CCerrer](https://github.com/CCerrer) | Testing 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) | Translation (German) | | <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) | Translation (Turkish) | | <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) | Translation (Arabic) | | <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) | Translation (Russian) | | <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) | Translation (French) | | <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/) | Supporter | Platform |
|---|---|
## License | <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 |
Licensed under the GNU Affero General Public License v3.0 (AGPL3.0). See the [LICENSE](LICENSE) file.
## Support the Project ## Support the Project
Help make Episteme Reader even better: Help make Episteme Reader better:
* ❤️ [Support on Patreon](https://www.patreon.com/c/epistemereader) * [Sponsor on GitHub](https://github.com/sponsors/Aryan-Raj3112)
* ⭐ Star the repository to help visibility * [Support on Patreon](https://www.patreon.com/c/epistemereader)
* 🐞 Report bugs or request features via [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose) * Star the repository to help visibility
* 💬 Share feedback in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions) * Report bugs or request features via [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose)
* ✍️ Leave a review on the [Google Play Store](https://play.google.com/store/apps/details?id=com.aryan.reader) * Share feedback in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions)
* 📣 Tell a friend! * 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.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) 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) alias(libs.plugins.kotlin.ksp)
id("com.diffplug.spotless") version "8.2.1" id("com.diffplug.spotless") version "8.2.1"
alias(libs.plugins.kover) alias(libs.plugins.kover)
@ -50,15 +50,15 @@ kotlin {
} }
android { android {
namespace = "com.aryan.reader" namespace = "org.dueattendant149.bookreader"
compileSdk = 36 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.aryan.reader" applicationId = "org.dueattendant149.bookreader"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 53 versionCode = 54
versionName = "1.0.49" versionName = "1.0.50"
resourceConfigurations += configuredAppLocaleTags() resourceConfigurations += configuredAppLocaleTags()
.map { it.toAndroidResourceConfiguration() } .map { it.toAndroidResourceConfiguration() }
@ -171,6 +171,7 @@ android {
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
unitTests.all { unitTests.all {
it.maxHeapSize = "4g"
it.jvmArgs("-Xss2m") it.jvmArgs("-Xss2m")
} }
} }
@ -243,8 +244,6 @@ dependencies {
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation("androidx.appcompat:appcompat:1.7.1") implementation("androidx.appcompat:appcompat:1.7.1")
//noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break) //noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break)

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

View file

@ -0,0 +1,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.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi

View file

@ -1,5 +1,5 @@
// CssParserTest.kt // CssParserTest.kt
package com.aryan.reader.paginatedreader package org.dueattendant149.bookreader.paginatedreader
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
@ -17,49 +17,66 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class CssParserTest { class CssParserTest {
private val dummyConstraints = androidx.compose.ui.unit.Constraints()
private val baseFontSize = 16f
private val density = 1f
private fun parseTextColor(value: String): Color? {
val result = CssParser.parse(
cssContent = "p { color: $value; }",
cssPath = null,
baseFontSizeSp = baseFontSize,
density = density,
constraints = dummyConstraints,
isDarkTheme = false
)
return result.rules.byTag["p"]
?.firstOrNull()
?.style
?.spanStyle
?.color
?.takeIf { it.isSpecified }
}
@Test @Test
fun parseColor_handlesNamedColorsCorrectly() { fun parseColor_handlesNamedColorsCorrectly() {
assertThat(CssParser.parseColor("red")).isEqualTo(Color.Red) assertThat(parseTextColor("red")).isEqualTo(Color.Red)
assertThat(CssParser.parseColor("black")).isEqualTo(Color.Black) assertThat(parseTextColor("black")).isEqualTo(Color.Black)
assertThat(CssParser.parseColor("transparent")).isEqualTo(Color.Transparent) assertThat(parseTextColor("transparent")).isEqualTo(Color.Transparent)
} }
@Test @Test
fun parseColor_handles3DigitHexCodes() { fun parseColor_handles3DigitHexCodes() {
assertThat(CssParser.parseColor("#F0C")).isEqualTo(Color(0xFFFF00CC)) assertThat(parseTextColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
} }
@Test @Test
fun parseColor_handles6DigitHexCodes() { fun parseColor_handles6DigitHexCodes() {
assertThat(CssParser.parseColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC)) assertThat(parseTextColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
} }
@Test @Test
fun parseColor_handles8DigitHexCodes() { fun parseColor_handles8DigitHexCodes() {
assertThat(CssParser.parseColor("#80FF00CC")).isEqualTo(Color(0x80FF00CC)) assertThat(parseTextColor("#80FF00CC")).isEqualTo(Color(128, 255, 0, 204))
} }
@Test @Test
fun parseColor_handlesRgbFunction() { 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 @Test
fun parseColor_handlesRgbaFunction() { 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 @Test
fun parseColor_returnsNullForInvalidInput() { fun parseColor_returnsNullForInvalidInput() {
assertThat(CssParser.parseColor("not a color")).isNull() assertThat(parseTextColor("not a color")).isNull()
assertThat(CssParser.parseColor("#12345")).isNull() assertThat(parseTextColor("#12345")).isNull()
assertThat(CssParser.parseColor("rgb(1,2)")).isNull() assertThat(parseTextColor("rgb(1,2)")).isNull()
} }
private val dummyConstraints = androidx.compose.ui.unit.Constraints()
private val baseFontSize = 16f
private val density = 1f
@Test @Test
fun parse_handlesSimpleRule() { fun parse_handlesSimpleRule() {
val css = "p { color: red; }" val css = "p { color: red; }"
@ -120,12 +137,12 @@ class CssParserTest {
} }
p { color: black; } p { color: black; }
""".trimIndent() """.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.rules.byTag).containsKey("p")
assertThat(result.fontFaces).hasSize(1) assertThat(result.fontFaces).hasSize(1)
val fontFace = result.fontFaces.first() val fontFace = result.fontFaces.first()
assertThat(fontFace.fontFamily).isEqualTo("mycustomfont") assertThat(fontFace.fontFamily).isEqualTo("mycustomfont")
assertThat(fontFace.src).isEqualTo("/some/fonts/myfont.ttf") assertThat(fontFace.src).isEqualTo("OEBPS/fonts/myfont.ttf")
assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold) assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold)
assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal) assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal)
} }
@ -190,10 +207,11 @@ class CssParserTest {
val css = "div { border: 2px solid red; }" val css = "div { border: 2px solid red; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false) val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["div"]?.first()?.style?.blockStyle val style = result.rules.byTag["div"]?.first()?.style?.blockStyle
assertThat(style?.border).isNotNull() val expectedBorder = BorderStyle(width = 2.dp, color = Color.Red, style = "solid")
assertThat(style?.border?.width).isEqualTo(2.dp) assertThat(style?.borderTop).isEqualTo(expectedBorder)
assertThat(style?.border?.style).isEqualTo("solid") assertThat(style?.borderRight).isEqualTo(expectedBorder)
assertThat(style?.border?.color).isEqualTo(Color.Red) assertThat(style?.borderBottom).isEqualTo(expectedBorder)
assertThat(style?.borderLeft).isEqualTo(expectedBorder)
} }
@Test @Test
@ -262,9 +280,70 @@ class CssParserTest {
url("font.ttf") format("truetype"); url("font.ttf") format("truetype");
} }
""".trimIndent() """.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).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 @Test
@ -327,10 +406,10 @@ class CssParserTest {
} }
@Test @Test
fun parse_lineHeightClampsSmallEmValues() { fun parse_lineHeightPreservesUnitlessMultiplier() {
val css = "p { line-height: 1.1; }" // This is treated as 1.1em val css = "p { line-height: 1.1; }" // This is treated as 1.1em
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false) val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["p"]?.first()?.style val style = result.rules.byTag["p"]?.first()?.style
assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(2.0.em) assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(1.1.em)
} }
} }

View file

@ -1,9 +1,8 @@
// HtmlParserTest.kt // HtmlParserTest.kt
package com.aryan.reader.paginatedreader package org.dueattendant149.bookreader.paginatedreader
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
@ -41,7 +40,7 @@ class HtmlParserTest {
val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules
return htmlToSemanticBlocks( return androidHtmlToSemanticBlocks(
html = "<body>$html</body>", // Wrap in body to match real usage html = "<body>$html</body>", // Wrap in body to match real usage
cssRules = allRules, // Use the combined list of rules cssRules = allRules, // Use the combined list of rules
textStyle = defaultTextStyle, textStyle = defaultTextStyle,
@ -97,7 +96,61 @@ class HtmlParserTest {
assertThat(blocks).hasSize(1) assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph val pBlock = blocks.first() as SemanticParagraph
val blockStyle = pBlock.style.spanStyle val blockStyle = pBlock.style.spanStyle
assertThat(blockStyle.color).isEqualTo(Color.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 @Test
@ -197,7 +250,7 @@ class HtmlParserTest {
} }
@Test @Test
fun htmlToSemanticBlocks_complexInlineFormatting_isPreserved() { fun htmlToSemanticBlocks_complexInlineText_isPreserved() {
val html = "<p>This is <b>bold</b> and <i>italic</i> text.</p>" val html = "<p>This is <b>bold</b> and <i>italic</i> text.</p>"
val blocks = parse(html) val blocks = parse(html)
@ -205,17 +258,6 @@ class HtmlParserTest {
val pBlock = blocks.first() as SemanticParagraph val pBlock = blocks.first() as SemanticParagraph
assertThat(pBlock.text).isEqualTo("This is bold and italic text.") assertThat(pBlock.text).isEqualTo("This is bold and italic text.")
// Find the range for "bold" and check its style
val boldRange = pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "bold" }
assertThat(boldRange).isNotNull()
assertThat(boldRange!!.style.spanStyle.fontWeight).isEqualTo(FontWeight.Bold)
// Find the range for "italic" and check its style
val italicRange =
pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "italic" }
assertThat(italicRange).isNotNull()
assertThat(italicRange!!.style.spanStyle.fontStyle).isEqualTo(androidx.compose.ui.text.font.FontStyle.Italic)
} }
@Test @Test
@ -242,15 +284,14 @@ class HtmlParserTest {
} }
@Test @Test
fun htmlToSemanticBlocks_pseudoElements_areIgnoredByTheParser() { fun htmlToSemanticBlocks_beforePseudoElementContent_isIncludedInParagraphText() {
val css = "p::before { content: \"Note: \"; }" val css = "p::before { content: \"Note: \"; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<p>This is a test.</p>", cssRules = cssRules) val blocks = parse("<p>This is a test.</p>", cssRules = cssRules)
// The parser now ignores pseudo-elements, so only the paragraph content should be parsed.
assertThat(blocks).hasSize(1) assertThat(blocks).hasSize(1)
val pBlock = blocks[0] as SemanticParagraph val pBlock = blocks[0] as SemanticParagraph
assertThat(pBlock.text).isEqualTo("This is a test.") assertThat(pBlock.text).isEqualTo("Note: This is a test.")
} }
@Test @Test

View file

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

View file

@ -1,5 +1,5 @@
// PaginatedReaderDataTest.kt // PaginatedReaderDataTest.kt
package com.aryan.reader.paginatedreader package org.dueattendant149.bookreader.paginatedreader
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.ParagraphStyle
@ -75,7 +75,7 @@ class PaginatedReaderDataTest {
margin = BoxBorders(bottom = 10.dp, left = 10.dp), margin = BoxBorders(bottom = 10.dp, left = 10.dp),
width = 200.dp, width = 200.dp,
backgroundColor = Color.Black, backgroundColor = Color.Black,
border = BorderStyle(width = 1.dp, color = Color.Red) borderTop = BorderStyle(width = 1.dp, color = Color.Red)
) )
val merged = baseStyle.merge(overrideStyle) val merged = baseStyle.merge(overrideStyle)
@ -95,8 +95,8 @@ class PaginatedReaderDataTest {
// Other properties // Other properties
assertThat(merged.width).isEqualTo(200.dp) assertThat(merged.width).isEqualTo(200.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.Black) assertThat(merged.backgroundColor).isEqualTo(Color.Black)
assertThat(merged.border).isNotNull() assertThat(merged.borderTop).isNotNull()
assertThat(merged.border?.width).isEqualTo(1.dp) assertThat(merged.borderTop?.width).isEqualTo(1.dp)
} }
@Test @Test
@ -115,6 +115,9 @@ class PaginatedReaderDataTest {
assertThat(merged.margin.top).isEqualTo(5.dp) assertThat(merged.margin.top).isEqualTo(5.dp)
assertThat(merged.width).isEqualTo(100.dp) assertThat(merged.width).isEqualTo(100.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.White) assertThat(merged.backgroundColor).isEqualTo(Color.White)
assertThat(merged.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 // 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.text.AnnotatedString
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4 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 kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -42,14 +44,52 @@ class FakeSplittableMeasurementProvider(
} }
return null return null
} }
override suspend fun split(block: TableBlock, availableHeight: Int): Pair<TableBlock, TableBlock>? = null
override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair<FlexContainerBlock, FlexContainerBlock>? = null
} }
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
class PaginatorTest { class PaginatorTest {
private val testDensity = Density(density = 1f, fontScale = 1f) private val testDensity = Density(density = 1f, fontScale = 1f)
private val pageHeight = 1000 private val pageHeight = 1000
private fun List<ContentBlock>.withoutMeasuredHeights(): List<ContentBlock> {
return map { it.withoutMeasuredHeight() }
}
private fun ContentBlock.withoutMeasuredHeight(): ContentBlock {
return when (this) {
is ParagraphBlock -> copy(expectedHeight = 0)
is ImageBlock -> copy(expectedHeight = 0)
is HeaderBlock -> copy(expectedHeight = 0)
is SpacerBlock -> copy(expectedHeight = 0)
is QuoteBlock -> copy(expectedHeight = 0)
is ListItemBlock -> copy(expectedHeight = 0)
is TableBlock -> copy(
rows = rows.map { row ->
row.map { cell ->
cell.copy(content = cell.content.withoutMeasuredHeights())
}
},
expectedHeight = 0
)
is MathBlock -> copy(expectedHeight = 0)
is WrappingContentBlock -> copy(
floatedImage = floatedImage.copy(expectedHeight = 0),
paragraphsToWrap = paragraphsToWrap.map { it.copy(expectedHeight = 0) },
expectedHeight = 0
)
is FlexContainerBlock -> copy(
children = children.withoutMeasuredHeights(),
expectedHeight = 0
)
}
}
@Test @Test
fun paginate_givenEmptyBlocks_createsZeroPages() = runTest { fun paginate_givenEmptyBlocks_createsZeroPages() = runTest {
val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity) val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity)
@ -85,8 +125,53 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2) assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1) assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content).containsExactly(block2) 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 @Test
@ -110,8 +195,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2) assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1, part1).inOrder() assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1, part1).inOrder()
assertThat(pages[1].content).containsExactly(part2) assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(part2)
} }
@Test @Test
@ -136,8 +221,8 @@ class PaginatorTest {
val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity) val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2) assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(splitWrapper) assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(splitWrapper)
assertThat(pages[1].content).containsExactly(para2) assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(para2)
} }
@ -158,8 +243,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2) assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1) assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content).containsExactly(unsplittableBlock) assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(unsplittableBlock)
} }
@Test @Test
@ -172,7 +257,7 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(1) assertThat(pages).hasSize(1)
assertThat(pages[0].content).containsExactly(oversizedBlock) assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(oversizedBlock)
} }
@Test @Test
@ -248,8 +333,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2) assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1) assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content).containsExactly(splittableBlock) // Was not split assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(splittableBlock) // Was not split
} }
@Test @Test
@ -271,8 +356,7 @@ class PaginatorTest {
// Set page height so that a split is attempted. // Set page height so that a split is attempted.
val pages = paginate(blocks, 150, measurementProvider, testDensity) val pages = paginate(blocks, 150, measurementProvider, testDensity)
assertThat(pages).hasSize(1) assertThat(pages).hasSize(1)
// The page should be empty because part1 was empty, and the original block was re-added // Empty split heads are skipped so pagination keeps only the remaining content.
// to the remaining list. The next page then contains the full block. assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(part2)
assertThat(pages[0].content).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 // StyleUtilsTest.kt
package com.aryan.reader.paginatedreader package org.dueattendant149.bookreader.paginatedreader
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isUnspecified import androidx.compose.ui.unit.isUnspecified

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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 // BaseTtsSynthesizerTest.kt
package com.aryan.reader.tts package org.dueattendant149.bookreader.tts
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider

View file

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

View file

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

View file

@ -1,12 +1,12 @@
package com.aryan.reader.epubreader package org.dueattendant149.bookreader.epubreader
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.aryan.reader.RenderMode import org.dueattendant149.bookreader.RenderMode
import com.aryan.reader.epub.EpubBook import org.dueattendant149.bookreader.epub.EpubBook
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class EpubTestActivity : ComponentActivity() { class EpubTestActivity : ComponentActivity() {
@ -31,8 +31,8 @@ class EpubTestActivity : ComponentActivity() {
coverImagePath = null, coverImagePath = null,
onRenderModeChange = {}, onRenderModeChange = {},
customFonts = TODO(), 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 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.Bitmap
import android.graphics.RectF import android.graphics.RectF

View file

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

View file

@ -128,8 +128,15 @@
background-color: rgba(255, 236, 179, 0.8); background-color: rgba(255, 236, 179, 0.8);
/* Semi-transparent Gold */ /* Semi-transparent Gold */
color: black !important; color: black !important;
display: inline !important;
text-align: initial !important;
text-align-last: auto !important;
letter-spacing: normal !important;
word-spacing: normal !important;
padding: 0.1em 0; padding: 0.1em 0;
border-radius: 3px; border-radius: 3px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
} }
html.dark-theme span.tts-highlight { html.dark-theme span.tts-highlight {
@ -1496,6 +1503,16 @@
}; };
const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS"; const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS";
const TTS_HIGHLIGHT_BLOCK_SELECTOR = "p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th";
function getTtsHighlightBlock(node) {
if (!node) {
return document.body;
}
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return (element && element.closest(TTS_HIGHLIGHT_BLOCK_SELECTOR)) || document.body;
}
window.highlightFromCfi = function (cfi, textToHighlight, startOffset) { window.highlightFromCfi = function (cfi, textToHighlight, startOffset) {
console.log(`$ { console.log(`$ {
@ -1556,9 +1573,10 @@
, Text content: '${(location.node.textContent || "").substring(0, 50)}...' `); , Text content: '${(location.node.textContent || "").substring(0, 50)}...' `);
const baseNode = location.node; const baseNode = location.node;
const highlightRoot = getTtsHighlightBlock(baseNode);
let remainingOffset = startOffset; let remainingOffset = startOffset;
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); const treeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
treeWalker.currentNode = baseNode; treeWalker.currentNode = baseNode;
let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode(); let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode();
@ -1626,7 +1644,7 @@
} else { } else {
remainingTextLength -= availableLength; remainingTextLength -= availableLength;
// Important: We need a fresh walker starting from the endNode to find the *next* text node reliably // Important: We need a fresh walker starting from the endNode to find the *next* text node reliably
const nextNodeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); const nextNodeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
nextNodeWalker.currentNode = endNode; nextNodeWalker.currentNode = endNode;
endNode = nextNodeWalker.nextNode(); endNode = nextNodeWalker.nextNode();
endOffset = 0; // Start from the beginning of the next node endOffset = 0; // Start from the beginning of the next node
@ -1677,11 +1695,14 @@
TTS_HIGHLIGHT_LOG_TAG TTS_HIGHLIGHT_LOG_TAG
} }
: surroundContents failed, using fallback. Error: $ { : surroundContents failed, using same-block fallback. Error: $ {
e.message e.message
} }
`); `);
if (!highlightRoot.contains(range.commonAncestorContainer)) {
return "JS: Highlight range escaped current block.";
}
const contents = range.extractContents(); const contents = range.extractContents();
highlightSpan.appendChild(contents); highlightSpan.appendChild(contents);
range.insertNode(highlightSpan); range.insertNode(highlightSpan);
@ -2924,6 +2945,29 @@
}; };
const HL_LOG_TAG = "HIGHLIGHT_DEBUG"; const HL_LOG_TAG = "HIGHLIGHT_DEBUG";
const HL_RENDER_LOG_TAG = "AndroidHighlightRenderDiag";
function hlRenderPreview(value, maxLength) {
if (value === undefined || value === null) return "";
var text = String(value).replace(/\s+/g, " ");
var max = maxLength || 100;
return text.length > max ? text.substring(0, max) : text;
}
function hlRenderLocatorLabel(locator) {
locator = locator || {};
return "locatorChapter=" + (locator.chapterIndex === undefined || locator.chapterIndex === null ? "null" : locator.chapterIndex) +
" locatorPage=" + (locator.pageIndex === undefined || locator.pageIndex === null ? "null" : locator.pageIndex) +
" locatorOffsets=" + (locator.startOffset === undefined || locator.startOffset === null ? "null" : locator.startOffset) +
".." + (locator.endOffset === undefined || locator.endOffset === null ? "null" : locator.endOffset) +
" locatorBlock=" + (locator.blockIndex === undefined || locator.blockIndex === null ? "null" : locator.blockIndex) +
" locatorChar=" + (locator.charOffset === undefined || locator.charOffset === null ? "null" : locator.charOffset) +
" locatorCfi=" + hlRenderPreview(locator.cfi || "", 120);
}
function hlRenderLog(message) {
console.log(HL_RENDER_LOG_TAG + ": " + message);
}
window.HighlightBridgeHelper = { window.HighlightBridgeHelper = {
updateHighlightStyle: function (cfi, newColorClass, colorId) { updateHighlightStyle: function (cfi, newColorClass, colorId) {
@ -3280,11 +3324,13 @@
try { try {
var highlights = JSON.parse(jsonArrayString); var highlights = JSON.parse(jsonArrayString);
var self = this; var self = this;
hlRenderLog("webview_restore_start count=" + highlights.length);
highlights.forEach(function (h) { highlights.forEach(function (h) {
self.applyHighlight(h.cfi, h.text, h.cssClass); self.applyHighlightObject(h);
}); });
} catch (e) { } catch (e) {
hlRenderLog("webview_restore_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
console.log( console.log(
`$ { `$ {
HL_LOG_TAG HL_LOG_TAG
@ -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 { try {
cssClass = cssClass || "user-highlight-yellow";
var alreadyApplied = false; var alreadyApplied = false;
var spans = document.querySelectorAll(`span[data-cfi]`); var spans = document.querySelectorAll(`span[data-cfi]`);
for (var i = 0; i < spans.length; i++) { 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; alreadyApplied = true;
break; break;
} }
} }
if (alreadyApplied) return; if (alreadyApplied) {
hlRenderLog(
const location = window.getNodeAndOffsetFromCfi(cfi); "webview_apply_skip reason=already_applied cfi=" + hlRenderPreview(cfi || "", 120) +
if (!location || !location.node) return; " textLen=" + String(text || "").length + " " + hlRenderLocatorLabel(locator)
);
let startNode = location.node; return;
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();
}
}
} }
// 1. Text Verification / Healing var hasPreciseLocator = locator &&
if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) { locator.startOffset !== undefined && locator.startOffset !== null &&
const nodeVal = startNode.nodeValue; locator.endOffset !== undefined && locator.endOffset !== null &&
// Check if text matches at exact offset parseInt(locator.endOffset, 10) > parseInt(locator.startOffset, 10);
const substring = nodeVal.substring(startOffset, startOffset + text.length); hlRenderLog(
"webview_apply_start cfi=" + hlRenderPreview(cfi || "", 120) +
// Allow for some whitespace looseness (trim comparison) " textLen=" + String(text || "").length +
if (substring !== text && substring.trim() !== text.trim()) { " cssClass=" + cssClass +
console.log(`$ { " hasPreciseLocator=" + !!hasPreciseLocator + " " +
HL_LOG_TAG hlRenderLocatorLabel(locator)
} );
var rangeSource = "locator";
: Text mismatch at CFI. Searching nearby... Expected: '${text.substring(0, 10)}...', Found: '${substring.substring(0, 10)}...' `); var sourceCfi = (locator && locator.cfi) || cfi;
var hasSourceCfi = sourceCfi && String(sourceCfi).charAt(0) === "/";
// Try finding the text in the whole node var range = hasSourceCfi ? null : this.rangeFromLocator(locator, text);
const foundIndex = nodeVal.indexOf(text); if (!range && hasSourceCfi) {
rangeSource = "cfi";
if (foundIndex !== -1) { range = this.rangeFromCfi(sourceCfi, text || "");
console.log(`$ {
HL_LOG_TAG
}
: Found text elsewhere in node. Adjusting offset from $ {
startOffset
}
to $ {
foundIndex
}
.`);
startOffset = foundIndex;
} else {
// Simple fuzzy: Try finding first 20 chars
const partial = text.substring(0, Math.min(text.length, 20));
const partialIndex = nodeVal.indexOf(partial);
if (partialIndex !== -1) {
console.log(`$ {
HL_LOG_TAG
}
: Found partial match. Adjusting offset.`);
startOffset = partialIndex;
}
}
}
} }
if (!range) {
if (!startNode) return; rangeSource = "locator";
range = this.rangeFromLocator(locator, text);
const range = document.createRange();
// Set Start
if (startNode.nodeType === Node.TEXT_NODE) {
// Ensure offset is valid
if (startOffset > startNode.nodeValue.length) {
startOffset = Math.max(0, startNode.nodeValue.length - 1);
}
} }
if (!range && !hasPreciseLocator && !hasSourceCfi) {
range.setStart(startNode, startOffset); rangeSource = "text_search";
range = this.rangeFromVisibleTextSearch(text || "", locator);
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) {
if (endNode) { hlRenderLog(
range.setEnd(endNode, endOffset); "webview_apply_skip reason=no_range cfi=" + hlRenderPreview(cfi || "", 120) +
var normalizedRange = this.normalizeRangeBoundaries(range); " hasPreciseLocator=" + !!hasPreciseLocator + " " + hlRenderLocatorLabel(locator)
this.highlightRangeSafe(normalizedRange, cssClass, cfi); );
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) { } catch (e) {
hlRenderLog("webview_apply_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
console.log(e); 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.Arrangement
import androidx.compose.foundation.layout.Column 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 android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.aryan.reader.data.RecentFileItem import org.dueattendant149.bookreader.data.RecentFileItem
import timber.log.Timber import timber.log.Timber
class AndroidFolderPathResolver : FolderPathResolver { class AndroidFolderPathResolver : FolderPathResolver {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 * mail: epistemereader@gmail.com
*/ */
package com.aryan.reader package org.dueattendant149.bookreader
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.InputStream import java.io.InputStream
import java.security.MessageDigest import java.security.MessageDigest
@ -50,9 +51,8 @@ object FileHasher {
} }
hexString.toString() hexString.toString()
} catch (e: Exception) { } catch (e: Exception) {
// In a real app, you'd want to log this error Timber.e(e, "Failed to calculate SHA-256 hash")
e.printStackTrace()
null 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? { internal fun resolveFileTypeFromName(fileName: String?): FileType? {
return SharedFileCapabilities.resolveFileTypeForName(fileName) return SharedFileCapabilities.resolveFileTypeForName(fileName)

View file

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

View file

@ -1,10 +1,13 @@
// FontsScreen.kt // FontsScreen.kt
@file:Suppress("KotlinConstantConditions") @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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -27,10 +30,12 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
@ -46,6 +51,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -58,14 +64,21 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aryan.reader.shared.CustomFontItem import org.dueattendant149.bookreader.shared.CustomFontItem
import com.aryan.reader.shared.ui.SharedAppFontSelector import org.dueattendant149.bookreader.shared.CustomFontFamilyItem
import com.aryan.reader.shared.ui.SharedFontSettingsSection import org.dueattendant149.bookreader.shared.CustomFontVariantItem
import com.aryan.reader.shared.ui.SharedFontSettingsTabs import org.dueattendant149.bookreader.shared.fontFaceLabel
import com.aryan.reader.data.CustomFontEntity 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 import java.io.File
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -80,37 +93,68 @@ fun FontsScreen(
val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE)
// Dialog state var fontsPendingDelete by remember { mutableStateOf<List<CustomFontEntity>>(emptyList()) }
var showDeleteDialog by remember { mutableStateOf(false) }
var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) }
var showGoogleFontsSheet by remember { mutableStateOf(false) } var showGoogleFontsSheet by remember { mutableStateOf(false) }
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) } var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
var selectedFontIds by remember { mutableStateOf<Set<String>>(emptySet()) }
val pickFontLauncher = rememberFilePickerLauncher { uris -> val pickFontLauncher = rememberFilePickerLauncher(viewModel::importFonts)
uris.firstOrNull()?.let { viewModel.importFont(it) } 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( BackHandler(enabled = isFontSelectionMode) {
"font/ttf", "font/otf", "font/woff2", selectedFontIds = emptySet()
"application/x-font-ttf", "application/x-font-otf", }
"application/font-woff2", "application/vnd.ms-opentype",
"application/x-font-opentype"
)
Scaffold( Scaffold(
modifier = Modifier.statusBarsPadding(), modifier = Modifier.statusBarsPadding(),
topBar = { topBar = {
CustomTopAppBar( if (isFontSelectionMode) {
title = { Text(stringResource(R.string.custom_fonts)) }, ContextualTopAppBar(
navigationIcon = { selectedItemCount = selectedFonts.size,
IconButton(onClick = onBackClick) { onNavIconClick = { selectedFontIds = emptySet() },
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) 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 = { floatingActionButton = {
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) { if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty() && !isFontSelectionMode) {
Column( Column(
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
@ -136,10 +180,14 @@ fun FontsScreen(
) { padding -> ) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) { Box(modifier = Modifier.fillMaxSize().padding(padding)) {
val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() } val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
val fontFamilies = remember(sharedFonts) { sharedFonts.groupByFamily() }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
SharedFontSettingsTabs( SharedFontSettingsTabs(
selectedSection = selectedSection, selectedSection = selectedSection,
onSectionChange = { selectedSection = it }, onSectionChange = {
selectedFontIds = emptySet()
selectedSection = it
},
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp) modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
) )
@ -163,12 +211,20 @@ fun FontsScreen(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items(fonts, key = { it.id }) { font -> items(fontFamilies, key = { family -> family.variants.joinToString("|") { it.font.id } }) { family ->
FontListItem( FontFamilyListItem(
font = font, family = family,
onDelete = { selectedFontIds = selectedFontIds,
fontToDelete = font isSelectionMode = isFontSelectionMode,
showDeleteDialog = true 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) { if (fontsPendingDelete.isNotEmpty()) {
DeleteFontConfirmationDialog( DeleteFontsConfirmationDialog(
fontName = fontToDelete!!.displayName, fonts = fontsPendingDelete,
onConfirm = { onConfirm = {
fontToDelete?.let { viewModel.deleteFont(it.id) } val pendingIds = fontsPendingDelete.map { it.id }
showDeleteDialog = false viewModel.deleteFonts(pendingIds)
fontToDelete = null selectedFontIds = selectedFontIds - pendingIds.toSet()
fontsPendingDelete = emptyList()
}, },
onDismiss = { onDismiss = {
showDeleteDialog = false fontsPendingDelete = emptyList()
fontToDelete = null
} }
) )
} }
@ -388,9 +444,13 @@ fun GoogleFontsBottomSheet(
} }
// Existing unchanged components // Existing unchanged components
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun FontListItem( fun FontListItem(
font: CustomFontEntity, font: CustomFontEntity,
isSelected: Boolean,
isSelectionMode: Boolean,
onSelectionToggle: () -> Unit,
onDelete: () -> Unit onDelete: () -> Unit
) { ) {
val customTypeface = remember(font.path) { val customTypeface = remember(font.path) {
@ -402,8 +462,23 @@ fun FontListItem(
} }
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) .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)) { Column(modifier = Modifier.padding(16.dp)) {
Row( Row(
@ -411,17 +486,27 @@ fun FontListItem(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (isSelectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { onSelectionToggle() },
modifier = Modifier.padding(end = 8.dp)
)
}
Text( Text(
text = font.displayName, text = font.displayName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
) )
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) { if (!isSelectionMode) {
Icon( IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
Icons.Default.Delete, Icon(
contentDescription = stringResource(R.string.action_delete), Icons.Default.Delete,
tint = MaterialTheme.colorScheme.error 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> { private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
return filterNot { it.isDeleted } return filterNot { it.isDeleted }
.sortedBy { it.displayName.lowercase() } .sortedBy { it.displayName.lowercase() }
@ -476,15 +743,32 @@ private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontIte
} }
@Composable @Composable
fun DeleteFontConfirmationDialog( fun DeleteFontsConfirmationDialog(
fontName: String, fonts: List<CustomFontEntity>,
onConfirm: () -> Unit, onConfirm: () -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val isSingleFont = fonts.size == 1
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dialog_delete_font)) }, title = {
text = { Text(stringResource(R.string.dialog_delete_font_desc, fontName)) }, 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 = { confirmButton = {
TextButton( TextButton(
onClick = onConfirm, 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 // HomeScreen
@file:Suppress("DEPRECATION") @file:Suppress("DEPRECATION")
package com.aryan.reader package org.dueattendant149.bookreader
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
@ -130,6 +130,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -138,12 +139,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem import org.dueattendant149.bookreader.data.RecentFileItem
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -192,6 +194,7 @@ fun HomeScreen(
var showAboutDialog by remember { mutableStateOf(false) } var showAboutDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) } var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
var showBehaviorDialog by remember { mutableStateOf(false) } var showBehaviorDialog by remember { mutableStateOf(false) }
var showStrictFilterDialog by remember { mutableStateOf(false) } var showStrictFilterDialog by remember { mutableStateOf(false) }
var showClearBookCacheDialog by remember { mutableStateOf(false) } var showClearBookCacheDialog by remember { mutableStateOf(false) }
@ -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) { LaunchedEffect(uiState.isRequestingDrivePermission) {
if (uiState.isRequestingDrivePermission) { if (uiState.isRequestingDrivePermission) {
val intent = viewModel.getDriveSignInIntent(context) val intent = viewModel.getDriveSignInIntent(context)
@ -389,6 +421,12 @@ fun HomeScreen(
showInfoDialog = true showInfoDialog = true
} }
}, },
onSaveClick = selectedContextItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { saveOriginalItem(item) } },
onShareClick = selectedContextItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { shareOriginalItem(item) } },
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) }, onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
onDeleteClick = { showDeleteConfirmDialog = true }, onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllRecentFiles() }) onSelectAllClick = { viewModel.selectAllRecentFiles() })
@ -436,7 +474,7 @@ fun HomeScreen(
onRefresh = { viewModel.refreshLibrary() }, onRefresh = { viewModel.refreshLibrary() },
isRefreshing = uiState.isRefreshing, isRefreshing = uiState.isRefreshing,
isSyncEnabled = uiState.isSyncEnabled, isSyncEnabled = uiState.isSyncEnabled,
hasSyncedFolder = uiState.syncedFolders.isNotEmpty(), hasSyncedFolder = uiState.syncedFolders.any { it.localSyncEnabled },
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
) )
} }
@ -501,28 +539,17 @@ fun HomeScreen(
) )
} }
itemForInfoDialog?.let { item -> HydratedFileInfoDialog(
if (showInfoDialog) { item = itemForInfoDialog,
FileInfoDialog( isVisible = showInfoDialog,
item = item, uiState = uiState,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, viewModel = viewModel,
onDismiss = { onDismiss = {
showInfoDialog = false showInfoDialog = false
itemForInfoDialog = null itemForInfoDialog = null
}, },
onSaveMetadata = { metadata -> onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
viewModel.updateBookMetadata(item.bookId, metadata) )
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(item.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(item.bookId)
},
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
)
}
}
if (showClearReflowCacheDialog) { if (showClearReflowCacheDialog) {
DangerousFolderActionDialog( DangerousFolderActionDialog(
@ -767,7 +794,8 @@ private fun RecentFilesGrid(
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
} },
modifier = Modifier.testTag("HomeTab_${tab.bookId}")
) )
} }
} }
@ -817,6 +845,7 @@ fun RecentFileCard(
androidx.compose.material3.ElevatedCard( androidx.compose.material3.ElevatedCard(
modifier = modifier modifier = modifier
.testTag("HomeRecentFileCard_${item.bookId}")
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f } .graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then( .then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
@ -1485,7 +1514,7 @@ private fun AppDrawerContent(
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// legal links // legal links
if (uiState.currentUser != null && !isOss) { if (uiState.currentUser != null || (isOss && !BuildConfig.IS_OFFLINE)) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val baseStyle = MaterialTheme.typography.labelMedium val baseStyle = MaterialTheme.typography.labelMedium
var scaledTextStyle by remember { mutableStateOf(baseStyle) } var scaledTextStyle by remember { mutableStateOf(baseStyle) }
@ -1783,9 +1812,14 @@ fun ExternalFileBehaviorDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.options_external_file_behavior)) }, title = { Text(stringResource(R.string.options_external_file_behavior)) },
text = { text = {
Column { Column(modifier = Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) {
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) val options = listOf(
options.forEach { (value, labelRes) -> 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@ -1795,7 +1829,14 @@ fun ExternalFileBehaviorDialog(
) { ) {
RadioButton(selected = currentBehavior == value, onClick = null) RadioButton(selected = currentBehavior == value, onClick = null)
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
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 // LibraryScreen.kt
@file:Suppress("KotlinConstantConditions") @file:Suppress("KotlinConstantConditions")
package com.aryan.reader package org.dueattendant149.bookreader
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.Uri import android.net.Uri
@ -105,6 +105,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -120,6 +121,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -131,19 +133,24 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil.ImageLoader
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.decode.SvgDecoder
import com.aryan.reader.data.RecentFileItem import org.dueattendant149.bookreader.data.RecentFileItem
import com.aryan.reader.data.TagEntity import org.dueattendant149.bookreader.data.TagEntity
import com.aryan.reader.opds.OpdsAcquisition import org.dueattendant149.bookreader.opds.OpdsAcquisition
import com.aryan.reader.opds.OpdsCatalog import org.dueattendant149.bookreader.opds.OpdsCatalog
import com.aryan.reader.opds.OpdsDownloadState import org.dueattendant149.bookreader.opds.OpdsDownloadState
import com.aryan.reader.opds.OpdsEntry import org.dueattendant149.bookreader.opds.OpdsEntry
import com.aryan.reader.opds.OpdsViewModel 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.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -265,6 +272,36 @@ fun LibraryScreen(
var showDeleteShelvesDialog by remember { mutableStateOf(false) } var showDeleteShelvesDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) } var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
val saveOriginalLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
) { uri ->
val item = pendingSaveOriginalItem
pendingSaveOriginalItem = null
if (uri != null && item?.uriString != null) {
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
}
}
fun saveOriginalItem(item: RecentFileItem) {
if (!item.canExportOriginalFile()) return
pendingSaveOriginalItem = item
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
}
fun shareOriginalItem(item: RecentFileItem) {
val uriString = item.uriString ?: return
if (!item.canExportOriginalFile()) return
scope.launch {
viewModel.shareOriginalFile(
activityContext = context,
sourceUri = uriString.toUri(),
fileType = item.type,
filename = item.suggestedOriginalFileName()
)
}
}
BackHandler(enabled = isContextualModeActive) { BackHandler(enabled = isContextualModeActive) {
viewModel.clearContextualAction() viewModel.clearContextualAction()
@ -311,6 +348,12 @@ fun LibraryScreen(
showInfoDialog = true showInfoDialog = true
} }
}, },
onSaveClick = selectedItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { saveOriginalItem(item) } },
onShareClick = selectedItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { shareOriginalItem(item) } },
onDeleteClick = { showDeleteConfirmDialog = true }, onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllLibraryFiles() }, onSelectAllClick = { viewModel.selectAllLibraryFiles() },
onShelfClick = viewModel::onShelfClick, onShelfClick = viewModel::onShelfClick,
@ -325,6 +368,7 @@ fun LibraryScreen(
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) }, onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
syncedFolders = uiState.syncedFolders, syncedFolders = uiState.syncedFolders,
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) }, onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
onFolderLocalSyncChange = viewModel::setFolderLocalSyncEnabled,
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders, onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
downloadingBookIds = uiState.downloadingBookIds, downloadingBookIds = uiState.downloadingBookIds,
lastFolderScanTime = uiState.lastFolderScanTime, lastFolderScanTime = uiState.lastFolderScanTime,
@ -390,28 +434,17 @@ fun LibraryScreen(
) )
} }
itemForInfoDialog?.let { item -> HydratedFileInfoDialog(
if (showInfoDialog) { item = itemForInfoDialog,
FileInfoDialog( isVisible = showInfoDialog,
item = item, uiState = uiState,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, viewModel = viewModel,
onDismiss = { onDismiss = {
showInfoDialog = false showInfoDialog = false
itemForInfoDialog = null itemForInfoDialog = null
}, },
onSaveMetadata = { metadata -> onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
viewModel.updateBookMetadata(item.bookId, metadata) )
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(item.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(item.bookId)
},
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
)
}
}
CustomTopBanner(bannerMessage = uiState.bannerMessage) CustomTopBanner(bannerMessage = uiState.bannerMessage)
} }
} }
@ -429,10 +462,42 @@ fun ShelfScreen(
val sortOrder = uiState.sortOrder val sortOrder = uiState.sortOrder
val showRenameDialogFor = uiState.showRenameShelfDialogFor val showRenameDialogFor = uiState.showRenameShelfDialogFor
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
val context = LocalContext.current
val scope = rememberCoroutineScope()
var showRemoveFromShelfDialog by remember { mutableStateOf(false) } var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) } var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
val saveOriginalLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
) { uri ->
val item = pendingSaveOriginalItem
pendingSaveOriginalItem = null
if (uri != null && item?.uriString != null) {
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
}
}
fun saveOriginalItem(item: RecentFileItem) {
if (!item.canExportOriginalFile()) return
pendingSaveOriginalItem = item
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
}
fun shareOriginalItem(item: RecentFileItem) {
val uriString = item.uriString ?: return
if (!item.canExportOriginalFile()) return
scope.launch {
viewModel.shareOriginalFile(
activityContext = context,
sourceUri = uriString.toUri(),
fileType = item.type,
filename = item.suggestedOriginalFileName()
)
}
}
BackHandler(enabled = true) { BackHandler(enabled = true) {
when { when {
@ -484,6 +549,12 @@ fun ShelfScreen(
showInfoDialog = true showInfoDialog = true
} }
}, },
onSaveClick = selectedItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { saveOriginalItem(item) } },
onShareClick = selectedItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { shareOriginalItem(item) } },
onDeleteClick = { showRemoveFromShelfDialog = true }, onDeleteClick = { showRemoveFromShelfDialog = true },
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) }, onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) }, onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
@ -524,19 +595,14 @@ fun ShelfScreen(
) )
} }
itemForInfoDialog?.let { item -> HydratedFileInfoDialog(
if (showInfoDialog) { item = itemForInfoDialog,
FileInfoDialog( isVisible = showInfoDialog,
item = item, uiState = uiState,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, viewModel = viewModel,
onDismiss = { showInfoDialog = false; itemForInfoDialog = null }, onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) }, onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) }, )
onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) },
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
)
}
}
CustomTopBanner(bannerMessage = uiState.bannerMessage) CustomTopBanner(bannerMessage = uiState.bannerMessage)
} }
} }
@ -571,6 +637,8 @@ fun LibraryScreenContent(
onItemClick: (RecentFileItem) -> Unit, onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit, onItemLongClick: (RecentFileItem) -> Unit,
onInfoClick: () -> Unit, onInfoClick: () -> Unit,
onSaveClick: (() -> Unit)?,
onShareClick: (() -> Unit)?,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
onSelectAllClick: () -> Unit, onSelectAllClick: () -> Unit,
onShelfClick: (Shelf) -> Unit, onShelfClick: (Shelf) -> Unit,
@ -590,6 +658,7 @@ fun LibraryScreenContent(
isRefreshing: Boolean, isRefreshing: Boolean,
syncedFolders: List<SyncedFolder>, syncedFolders: List<SyncedFolder>,
onRemoveFolderClick: (SyncedFolder) -> Unit, onRemoveFolderClick: (SyncedFolder) -> Unit,
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onOpdsBookDownloaded: (Uri, String) -> Unit, onOpdsBookDownloaded: (Uri, String) -> Unit,
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit, onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit, onDeleteCatalogStreams: (String) -> Unit,
@ -632,6 +701,8 @@ fun LibraryScreenContent(
onTagClick = onTagClick, onTagClick = onTagClick,
onPinClick = onPinClick, onPinClick = onPinClick,
onInfoClick = onInfoClick, onInfoClick = onInfoClick,
onSaveClick = onSaveClick,
onShareClick = onShareClick,
onDeleteClick = onDeleteClick, onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick onSelectAllClick = onSelectAllClick
) )
@ -666,7 +737,8 @@ fun LibraryScreenContent(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.focusRequester(searchFocusRequester), .focusRequester(searchFocusRequester)
.testTag("LibrarySearchTextField"),
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
@ -693,7 +765,10 @@ fun LibraryScreenContent(
Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter)) Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter))
} }
Box { Box {
TextButton(onClick = { showSortMenu = true }) { TextButton(
onClick = { showSortMenu = true },
modifier = Modifier.testTag("LibrarySortButton")
) {
Icon( Icon(
painter = painterResource(id = R.drawable.sort), painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort), contentDescription = stringResource(R.string.content_desc_sort),
@ -822,7 +897,9 @@ fun LibraryScreenContent(
text = { Text(stringResource(R.string.fab_new_shelf)) }, text = { Text(stringResource(R.string.fab_new_shelf)) },
icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) }, icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) },
onClick = onNewShelfClick, onClick = onNewShelfClick,
modifier = Modifier.padding(16.dp) modifier = Modifier
.padding(16.dp)
.testTag("LibraryNewShelfFab")
) )
} }
} }
@ -888,6 +965,7 @@ fun LibraryScreenContent(
allRecentFiles = rawLibraryFiles, allRecentFiles = rawLibraryFiles,
onAddFolderClick = onSelectSyncFolderClick, onAddFolderClick = onSelectSyncFolderClick,
onRemoveFolderClick = onRemoveFolderClick, onRemoveFolderClick = onRemoveFolderClick,
onFolderLocalSyncChange = onFolderLocalSyncChange,
onEditFolderFiltersClick = onEditFolderFiltersClick, onEditFolderFiltersClick = onEditFolderFiltersClick,
onScanNowClick = onScanNowClick, onScanNowClick = onScanNowClick,
onSyncMetadataClick = onSyncMetadataClick, onSyncMetadataClick = onSyncMetadataClick,
@ -1031,6 +1109,8 @@ private fun ShelfDetailScreen(
onClearSelection: () -> Unit, onClearSelection: () -> Unit,
onTagClick: () -> Unit, onTagClick: () -> Unit,
onInfoClick: () -> Unit, onInfoClick: () -> Unit,
onSaveClick: (() -> Unit)?,
onShareClick: (() -> Unit)?,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
onRenameShelf: () -> Unit, onRenameShelf: () -> Unit,
onDeleteShelf: () -> Unit, onDeleteShelf: () -> Unit,
@ -1113,6 +1193,8 @@ private fun ShelfDetailScreen(
onNavIconClick = onClearSelection, onNavIconClick = onClearSelection,
onTagClick = onTagClick, onTagClick = onTagClick,
onInfoClick = onInfoClick, onInfoClick = onInfoClick,
onSaveClick = onSaveClick,
onShareClick = onShareClick,
onDeleteClick = onDeleteClick onDeleteClick = onDeleteClick
) )
} else if (isSearchActive) { } else if (isSearchActive) {
@ -1140,7 +1222,8 @@ private fun ShelfDetailScreen(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.focusRequester(searchFocusRequester), .focusRequester(searchFocusRequester)
.testTag("ShelfSearchTextField"),
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
@ -1193,7 +1276,10 @@ private fun ShelfDetailScreen(
}, },
actions = { actions = {
Box { Box {
TextButton(onClick = { showSortMenu = true }) { TextButton(
onClick = { showSortMenu = true },
modifier = Modifier.testTag("ShelfSortButton")
) {
Icon( Icon(
painter = painterResource(id = R.drawable.sort), painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort), contentDescription = stringResource(R.string.content_desc_sort),
@ -1375,7 +1461,10 @@ private fun AddBooksModeScreen(
}, },
actions = { actions = {
Box { Box {
TextButton(onClick = { showSortMenu = true }) { TextButton(
onClick = { showSortMenu = true },
modifier = Modifier.testTag("AddBooksSortButton")
) {
Icon( Icon(
painter = painterResource(id = R.drawable.sort), painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort), contentDescription = stringResource(R.string.content_desc_sort),
@ -1581,6 +1670,7 @@ private fun ShelfListItem(
), ),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag("ShelfItem_${shelf.id}")
.then( .then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
else Modifier else Modifier
@ -1659,6 +1749,7 @@ private fun LibraryListItem(
), ),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag("LibraryBookItem_${item.bookId}")
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f } .graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then( .then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
@ -1929,12 +2020,15 @@ private fun FolderSyncScreen(
allRecentFiles: List<RecentFileItem>, allRecentFiles: List<RecentFileItem>,
onAddFolderClick: () -> Unit, onAddFolderClick: () -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit, onRemoveFolderClick: (SyncedFolder) -> Unit,
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit, onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onScanNowClick: () -> Unit, onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit, onSyncMetadataClick: () -> Unit,
isLoading: Boolean isLoading: Boolean
) { ) {
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) } var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
var disablingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
val hasEnabledSyncFolders = syncedFolders.any { it.localSyncEnabled }
val folderStatsByUri = remember(allRecentFiles) { val folderStatsByUri = remember(allRecentFiles) {
allRecentFiles allRecentFiles
.asSequence() .asSequence()
@ -1973,7 +2067,7 @@ private fun FolderSyncScreen(
) { ) {
FilledTonalButton( FilledTonalButton(
onClick = onScanNowClick, onClick = onScanNowClick,
enabled = !isLoading, enabled = !isLoading && hasEnabledSyncFolders,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small shape = MaterialTheme.shapes.small
) { ) {
@ -1988,7 +2082,7 @@ private fun FolderSyncScreen(
androidx.compose.material3.OutlinedButton( androidx.compose.material3.OutlinedButton(
onClick = onSyncMetadataClick, onClick = onSyncMetadataClick,
enabled = !isLoading, enabled = !isLoading && hasEnabledSyncFolders,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small shape = MaterialTheme.shapes.small
) { ) {
@ -2016,6 +2110,13 @@ private fun FolderSyncScreen(
folder = folder, folder = folder,
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty, stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
onRemoveClick = onRemoveFolderClick, onRemoveClick = onRemoveFolderClick,
onLocalSyncToggleClick = { selectedFolder ->
if (selectedFolder.localSyncEnabled) {
disablingFolder = selectedFolder
} else {
onFolderLocalSyncChange(selectedFolder, true, false)
}
},
onEditFiltersClick = { editingFolder = folder } onEditFiltersClick = { editingFolder = folder }
) )
} }
@ -2033,6 +2134,46 @@ private fun FolderSyncScreen(
onDismiss = { editingFolder = null } onDismiss = { editingFolder = null }
) )
} }
disablingFolder?.let { folder ->
AlertDialog(
onDismissRequest = { disablingFolder = null },
title = { Text(stringResource(R.string.dialog_disable_folder_local_sync_title)) },
text = {
Text(
stringResource(
R.string.dialog_disable_folder_local_sync_desc,
LOCAL_FOLDER_SYNC_DATA_DIR
)
)
},
confirmButton = {
TextButton(
onClick = {
onFolderLocalSyncChange(folder, false, true)
disablingFolder = null
}
) {
Text(stringResource(R.string.action_disable_remove_sync_data))
}
},
dismissButton = {
Row {
TextButton(onClick = { disablingFolder = null }) {
Text(stringResource(R.string.action_cancel))
}
TextButton(
onClick = {
onFolderLocalSyncChange(folder, false, false)
disablingFolder = null
}
) {
Text(stringResource(R.string.action_disable_keep_sync_data))
}
}
}
)
}
} }
private data class FolderFileStats( private data class FolderFileStats(
@ -2050,6 +2191,7 @@ private fun FolderCard(
folder: SyncedFolder, folder: SyncedFolder,
stats: FolderFileStats, stats: FolderFileStats,
onRemoveClick: (SyncedFolder) -> Unit, onRemoveClick: (SyncedFolder) -> Unit,
onLocalSyncToggleClick: (SyncedFolder) -> Unit,
onEditFiltersClick: (SyncedFolder) -> Unit onEditFiltersClick: (SyncedFolder) -> Unit
) { ) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
@ -2075,13 +2217,22 @@ private fun FolderCard(
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text( Column(modifier = Modifier.weight(1f)) {
text = folder.name, Text(
style = MaterialTheme.typography.titleMedium, text = folder.name,
fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium,
maxLines = 1, fontWeight = FontWeight.Bold,
overflow = TextOverflow.Ellipsis 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 { Box {
@ -2096,6 +2247,21 @@ private fun FolderCard(
onEditFiltersClick(folder) onEditFiltersClick(folder)
} }
) )
DropdownMenuItem(
text = {
Text(
if (folder.localSyncEnabled) {
stringResource(R.string.menu_disable_folder_local_sync)
} else {
stringResource(R.string.menu_enable_folder_local_sync)
}
)
},
onClick = {
showMenu = false
onLocalSyncToggleClick(folder)
}
)
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_remove_folder)) }, text = { Text(stringResource(R.string.menu_remove_folder)) },
onClick = { onClick = {
@ -2377,6 +2543,7 @@ fun OpdsTab(
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle() val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
val downloadingState = uiState.downloadingState val downloadingState = uiState.downloadingState
val context = LocalContext.current val context = LocalContext.current
val coverImageLoader = rememberOpdsCoverImageLoader(uiState.currentCatalog)
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) } var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
var showCatalogDialog by remember { mutableStateOf(false) } var showCatalogDialog by remember { mutableStateOf(false) }
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) } var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
@ -2601,6 +2768,7 @@ fun OpdsTab(
entry = entry, entry = entry,
localLibraryFiles = localLibraryFiles, localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[entry.id], downloadState = downloadingState[entry.id],
coverImageLoader = coverImageLoader,
onDownloadClick = { acquisition -> onDownloadClick = { acquisition ->
opdsViewModel.downloadBook( opdsViewModel.downloadBook(
entry, acquisition, context entry, acquisition, context
@ -2649,6 +2817,7 @@ fun OpdsTab(
entry = selectedEntry!!, entry = selectedEntry!!,
localLibraryFiles = localLibraryFiles, localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[selectedEntry!!.id], downloadState = downloadingState[selectedEntry!!.id],
coverImageLoader = coverImageLoader,
onDownloadFormat = { acquisition -> onDownloadFormat = { acquisition ->
opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri -> opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
onBookDownloaded(downloadedUri, selectedEntry!!.title) onBookDownloaded(downloadedUri, selectedEntry!!.title)
@ -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 @Composable
fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) { fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
Surface( Surface(
@ -2853,13 +3045,20 @@ fun OpdsBookCard(
entry: OpdsEntry, entry: OpdsEntry,
localLibraryFiles: List<RecentFileItem>, localLibraryFiles: List<RecentFileItem>,
downloadState: OpdsDownloadState?, downloadState: OpdsDownloadState?,
coverImageLoader: ImageLoader,
onDownloadClick: (OpdsAcquisition) -> Unit, onDownloadClick: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit, onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit, onStreamClick: () -> Unit,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val libraryItem = remember(entry, localLibraryFiles) { val libraryItem = remember(entry, localLibraryFiles) {
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 isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress val progress = downloadState?.progress
@ -2878,6 +3077,7 @@ fun OpdsBookCard(
AsyncImage( AsyncImage(
model = entry.coverUrl, model = entry.coverUrl,
contentDescription = null, contentDescription = null,
imageLoader = coverImageLoader,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.size(width = 70.dp, height = 100.dp) .size(width = 70.dp, height = 100.dp)
@ -2984,6 +3184,7 @@ fun OpdsBookDetailsSheet(
entry: OpdsEntry, entry: OpdsEntry,
localLibraryFiles: List<RecentFileItem>, localLibraryFiles: List<RecentFileItem>,
downloadState: OpdsDownloadState?, downloadState: OpdsDownloadState?,
coverImageLoader: ImageLoader,
onDownloadFormat: (OpdsAcquisition) -> Unit, onDownloadFormat: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit, onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit, onStreamClick: () -> Unit,
@ -2992,7 +3193,13 @@ fun OpdsBookDetailsSheet(
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val libraryItem = remember(entry, localLibraryFiles) { 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 isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress val progress = downloadState?.progress
@ -3012,6 +3219,7 @@ fun OpdsBookDetailsSheet(
AsyncImage( AsyncImage(
model = entry.coverUrl, model = entry.coverUrl,
contentDescription = null, contentDescription = null,
imageLoader = coverImageLoader,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.size(width = 110.dp, height = 160.dp) .size(width = 110.dp, height = 160.dp)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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