Compare commits
24 commits
d4a1432ea1
...
5f64f3d722
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f64f3d722 | ||
|
|
e615128a23 | ||
|
|
625a4d5d2e | ||
|
|
a13d6599d1 | ||
|
|
6651169ce2 | ||
|
|
e4634ed251 | ||
|
|
e9455513a2 | ||
|
|
eec9c30580 | ||
|
|
4911b240ac | ||
|
|
080f7359b2 | ||
|
|
83dcafa4b6 | ||
|
|
35a06bf134 | ||
|
|
698e9f60bb | ||
|
|
9539d20807 | ||
|
|
86d4a48033 | ||
|
|
25e9fc32d7 | ||
|
|
7593691a9a | ||
|
|
415255902a | ||
|
|
483c9ec6bd | ||
|
|
4cbfb2aa0e | ||
|
|
abf65ae8bd | ||
|
|
e556008ff4 | ||
|
|
4a340ee642 | ||
|
|
7f1242c5bf |
831 changed files with 78252 additions and 18465 deletions
409
.github/workflows/desktop-release.yml
vendored
Normal file
409
.github/workflows/desktop-release.yml
vendored
Normal 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
7
.gitignore
vendored
|
|
@ -21,8 +21,9 @@ google-services.json
|
|||
.gradle/
|
||||
third_party/pdfium/
|
||||
*.tgz
|
||||
kcef-bundle/
|
||||
kcef-bundle-linux-x64/
|
||||
cache/
|
||||
worker/
|
||||
output/
|
||||
output/
|
||||
policies/
|
||||
episteme-bin/
|
||||
episteme-oss-bin/
|
||||
|
|
|
|||
40
.idea/androidTestResultsUserPreferences.xml
generated
40
.idea/androidTestResultsUserPreferences.xml
generated
|
|
@ -162,6 +162,19 @@
|
|||
</AndroidTestResultsTableState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="-908638882">
|
||||
<value>
|
||||
<AndroidTestResultsTableState>
|
||||
<option name="preferredColumnWidths">
|
||||
<map>
|
||||
<entry key="Duration" value="90" />
|
||||
<entry key="Tests" value="360" />
|
||||
<entry key="samsung SM-G990B2" value="120" />
|
||||
</map>
|
||||
</option>
|
||||
</AndroidTestResultsTableState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="-629314366">
|
||||
<value>
|
||||
<AndroidTestResultsTableState>
|
||||
|
|
@ -309,6 +322,19 @@
|
|||
</AndroidTestResultsTableState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="326896270">
|
||||
<value>
|
||||
<AndroidTestResultsTableState>
|
||||
<option name="preferredColumnWidths">
|
||||
<map>
|
||||
<entry key="Duration" value="90" />
|
||||
<entry key="Tests" value="360" />
|
||||
<entry key="samsung SM-G990B2" value="120" />
|
||||
</map>
|
||||
</option>
|
||||
</AndroidTestResultsTableState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="371509721">
|
||||
<value>
|
||||
<AndroidTestResultsTableState>
|
||||
|
|
@ -357,6 +383,7 @@
|
|||
<entry key="Duration" value="90" />
|
||||
<entry key="Pixel_5" value="120" />
|
||||
<entry key="Tests" value="360" />
|
||||
<entry key="samsung SM-G990B2" value="120" />
|
||||
</map>
|
||||
</option>
|
||||
</AndroidTestResultsTableState>
|
||||
|
|
@ -534,6 +561,19 @@
|
|||
</AndroidTestResultsTableState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="1500844897">
|
||||
<value>
|
||||
<AndroidTestResultsTableState>
|
||||
<option name="preferredColumnWidths">
|
||||
<map>
|
||||
<entry key="Duration" value="90" />
|
||||
<entry key="Tests" value="360" />
|
||||
<entry key="samsung SM-G990B2" value="120" />
|
||||
</map>
|
||||
</option>
|
||||
</AndroidTestResultsTableState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="1562612490">
|
||||
<value>
|
||||
<AndroidTestResultsTableState>
|
||||
|
|
|
|||
12
.idea/vcs.xml
generated
Normal file
12
.idea/vcs.xml
generated
Normal 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
76
AGENTS.md
Normal 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
148
README.md
|
|
@ -5,62 +5,63 @@
|
|||
<span> Episteme Reader</span>
|
||||
</h1>
|
||||
|
||||
<p>A modern, offline‑first, privacy‑focused document & e‑book 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> <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> <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> <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> <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> <a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/Aryan-Raj3112/episteme"><img alt="Get it on Obtainium" src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" height="44" align="absmiddle"/></a>
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||

|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/EPISTEME.png" alt="Episteme Reader on Android"/>
|
||||
<br/>
|
||||
<sub>Android</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/EPISTEME_desktop.png" alt="Episteme Reader on desktop"/>
|
||||
<br/>
|
||||
<sub>Desktop</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
|
||||
Episteme Reader is a comprehensive, customizable reader for documents and e-books on Android. It features a modern Jetpack Compose UI, powerful reading tools, and extensive theming.
|
||||
Episteme Reader is a customizable reader for documents, e-books, comics, and text-heavy files. The app is designed around local-first reading, deep typography controls, flexible layouts, and a consistent Kotlin Multiplatform core across Android and desktop.
|
||||
|
||||
To best serve different user preferences regarding privacy and network usage, Episteme Reader is available in three distinct editions:
|
||||
* **PlayStore Version:** The full-featured release which includes proprietary code and features.
|
||||
* **OSS Version (GitHub/F-Droid):** A fully open-source build.
|
||||
* **OSS Offline Version (GitHub):** A strictly offline build with network permissions completely removed.
|
||||
The same core reading experience is available across editions. The main differences are distribution channel, network access, and whether proprietary online services are included.
|
||||
|
||||
---
|
||||
## Core Features
|
||||
|
||||
## Feature Comparison
|
||||
Available across supported editions unless noted in the edition table:
|
||||
|
||||
### 📚 Supported Formats
|
||||
| Feature | PlayStore | OSS | OSS Offline |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| **Documents:** PDF, DOCX, ODT/FODT | ✅ | ✅ | ✅ |
|
||||
| **E-books & Text:** EPUB, MOBI, AZW3, FB2, MD, HTML, TXT | ✅ | ✅ | ✅ |
|
||||
| **Comics:** CBZ, CBR, CB7 | ✅ | ✅ | ✅ |
|
||||
| **View-Only:** CSV, TSV, JSON, XML, Logs, Code Files | ✅ | ✅ | ✅ |
|
||||
* **Formats:** PDF, EPUB, MOBI/AZW3, FB2, DOCX, ODT/FODT, TXT, Markdown, HTML, and comic archives.
|
||||
* **Reading modes:** Paginated reading, vertical scroll, PDF multi-tab reading, PDF reflow, auto-scroll, and musician mode.
|
||||
* **PDF tools:** Ink annotations, highlighting, erasing, text annotations, and reading-focused PDF controls.
|
||||
* **Customization:** App themes, reader themes, custom local fonts, typography controls, spacing, margins, and layout tuning.
|
||||
* **Library tools:** Local folder sync, library organization, bookmarks, progress tracking, and file management.
|
||||
* **Accessibility:** System text-to-speech, app language selection, and reader settings that adapt to different reading preferences.
|
||||
|
||||
### 📖 Core Reading Experience
|
||||
| Feature | PlayStore | OSS | OSS Offline |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| **Display Modes:** Paginated & Vertical Scroll | ✅ | ✅ | ✅ |
|
||||
| **PDF Multi-Tab Reading & Reflow** | ✅ | ✅ | ✅ |
|
||||
| **PDF Annotations:** Ink (Pen, Highlight, Erase) & Text | ✅ | ✅ | ✅ |
|
||||
| **App-wide Customization & Reader Theming** | ✅ | ✅ | ✅ |
|
||||
| **Custom Fonts:** Local Import (TTF and OTF) | ✅ | ✅ | ✅ |
|
||||
| **Typography Control** | ✅ | ✅ | ✅ |
|
||||
| **Auto-Scroll & Musician Mode** | ✅ | ✅ | ✅ |
|
||||
| **System Text-to-Speech (TTS)** | ✅ | ✅ | ✅ |
|
||||
## Editions
|
||||
|
||||
### ⚙️ Advanced & Network Features
|
||||
| Feature | PlayStore | OSS (F-Droid) | OSS Offline |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| **Local Folder Sync & Library Management** | ✅ | ✅ | ✅ |
|
||||
| **Download Google Fonts** | ✅ | ✅ | ❌ |
|
||||
| **OPDS Catalog Support** | ✅ | ✅ | ❌ |
|
||||
| **PDF Bubble Zoom Magnifier** | ✅ | ❌ | ❌ |
|
||||
| **ML Kit OCR** (Scanned PDF Text Selection) | ✅ | ❌ | ❌ |
|
||||
| **Cross-Device Cloud Sync** | ✅ | ❌ | ❌ |
|
||||
| **AI Tools** (Summaries, Story Recap, Dictionary) | ✅ | 🔜 | ❌ |
|
||||
| **Cloud Text-to-Speech** | ✅ | 🔜 | ❌ |
|
||||
| Edition | Platform | Network access | Distribution | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **Play Store** | Android | Online-capable | Google Play | Full Android release with proprietary extras such as ML Kit OCR, cloud sync, AI tools, cloud TTS, and PDF bubble zoom. |
|
||||
| **OSS** | Android | Online-capable | [epistemereader.com](https://epistemereader.com), GitHub, F-Droid, Obtainium | Fully open-source Android build with OPDS, downloadable fonts, and BYOK access to AI and cloud features. |
|
||||
| **OSS Offline** | Android | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub, Obtainium | Open-source Android build with network permissions removed. |
|
||||
| **Standard** | Windows desktop | Online-capable | [epistemereader.com](https://epistemereader.com), GitHub | Full-featured desktop release with the shared KMP reader core and online-capable services. |
|
||||
| **Offline** | Windows desktop | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub | Desktop build focused on local reading with online services disabled. |
|
||||
|
||||
---
|
||||
Future desktop platforms can use the same Standard and Offline model as support expands.
|
||||
|
||||
## Languages
|
||||
|
||||
Episteme Reader currently supports: English, Arabic, Belarusian, German, Spanish, Estonian, French, Hindi, Indonesian, Italian, Japanese, Korean, Dutch, Polish, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, and Chinese Simplified.
|
||||
|
||||
Want Episteme Reader in another language? Please request it through [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose) or start a thread in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions).
|
||||
|
||||
## Building from Source
|
||||
|
||||
|
|
@ -70,7 +71,7 @@ To best serve different user preferences regarding privacy and network usage, Ep
|
|||
cd episteme
|
||||
```
|
||||
|
||||
2. Build:
|
||||
2. Build Android:
|
||||
* Open in Android Studio and run the `ossDebug` or `ossOfflineDebug` variant, or
|
||||
* Build from the command line:
|
||||
```bash
|
||||
|
|
@ -79,43 +80,56 @@ To best serve different user preferences regarding privacy and network usage, Ep
|
|||
The APK will be generated at:
|
||||
`app/build/outputs/apk/oss/debug/Episteme-oss-v{version}-oss-debug.apk`
|
||||
|
||||
3. Build desktop:
|
||||
```bash
|
||||
./gradlew :desktopApp:packageReleaseDistributionForCurrentOS
|
||||
```
|
||||
For the offline desktop build, pass:
|
||||
```bash
|
||||
./gradlew :desktopApp:packageReleaseDistributionForCurrentOS -PdesktopFlavor=oss
|
||||
```
|
||||
|
||||
## Open Source Libraries
|
||||
|
||||
Powered by the Android OSS ecosystem:
|
||||
* **Core & UI:** AndroidX, Jetpack Compose, Kotlinx Serialization
|
||||
* **Document Engines:** PdfiumAndroidKt (PDF), libmobi (MOBI/AZW3)
|
||||
* **Parsers:** Jsoup (HTML/EPUB), Flexmark (Markdown)
|
||||
* **Media & Image Loading:** Coil, Media3 (ExoPlayer)
|
||||
* **Utilities:** Room (Database), Timber (Logging)
|
||||
Powered by the Kotlin, Android, and desktop OSS ecosystem:
|
||||
|
||||
* **Core and UI:** Kotlin Multiplatform, Compose Multiplatform, AndroidX, Jetpack Compose, Kotlinx Serialization
|
||||
* **Document engines:** PdfiumAndroidKt, PDFium, libmobi
|
||||
* **Parsers:** Jsoup, Flexmark, Apache Commons Compress
|
||||
* **Media and image loading:** Coil, Media3
|
||||
* **Utilities:** Room, Timber, JNA
|
||||
|
||||
## Contributors
|
||||
|
||||
| Contributor | Contribution |
|
||||
|---|---|
|
||||
| <img src="https://github.com/CCerrer.png?size=48" width="24" height="24" valign="middle" alt="CCerrer avatar">[CCerrer](https://github.com/CCerrer) | Testing & QA |
|
||||
| <img src="https://github.com/ottozumkeller.png?size=48" width="24" height="24" valign="middle" alt="ottozumkeller avatar"> [ottozumkeller](https://github.com/ottozumkeller) | Translation (German) |
|
||||
| <img src="https://github.com/TURBOKANTR.png?size=48" width="24" height="24" valign="middle" alt="TURBOKANTR avatar"> [TURBOKANTR](https://github.com/TURBOKANTR) | Translation (Turkish) |
|
||||
| <img src="https://github.com/eyadalkordy24.png?size=48" width="24" height="24" valign="middle" alt="eyadalkordy24 avatar">[eyadalkordy24](https://github.com/eyadalkordy24) | Translation (Arabic) |
|
||||
| <img src="https://github.com/berebara.png?size=48" width="24" height="24" valign="middle" alt="berebara avatar">[berebara](https://github.com/berebara) | Translation (Russian) |
|
||||
| <img src="https://github.com/mh4ckt3mh4ckt1c4s.png?size=48" width="24" height="24" valign="middle" alt="mh4ckt3mh4ckt1c4s avatar">[mh4ckt3mh4ckt1c4s](https://github.com/mh4ckt3mh4ckt1c4s) | Translation (French) |
|
||||
| <img src="https://github.com/CCerrer.png?size=48" width="24" height="24" valign="middle" alt="CCerrer avatar"> [CCerrer](https://github.com/CCerrer) | Testing and QA |
|
||||
| <img src="https://github.com/ottozumkeller.png?size=48" width="24" height="24" valign="middle" alt="ottozumkeller avatar"> [ottozumkeller](https://github.com/ottozumkeller) | German translation |
|
||||
| <img src="https://github.com/TURBOKANTR.png?size=48" width="24" height="24" valign="middle" alt="TURBOKANTR avatar"> [TURBOKANTR](https://github.com/TURBOKANTR) | Turkish translation |
|
||||
| <img src="https://github.com/eyadalkordy24.png?size=48" width="24" height="24" valign="middle" alt="eyadalkordy24 avatar"> [eyadalkordy24](https://github.com/eyadalkordy24) | Arabic translation |
|
||||
| <img src="https://github.com/berebara.png?size=48" width="24" height="24" valign="middle" alt="berebara avatar"> [berebara](https://github.com/berebara) | Russian translation |
|
||||
| <img src="https://github.com/mh4ckt3mh4ckt1c4s.png?size=48" width="24" height="24" valign="middle" alt="mh4ckt3mh4ckt1c4s avatar"> [mh4ckt3mh4ckt1c4s](https://github.com/mh4ckt3mh4ckt1c4s) | French translation |
|
||||
|
||||
## Translations
|
||||
## Supporters
|
||||
|
||||
Help translate Episteme Reader into your native language! [Weblate](https://hosted.weblate.org/engage/episteme/) is used to manage localization.
|
||||
Thank you to the people helping keep Episteme Reader moving:
|
||||
|
||||
[](https://hosted.weblate.org/engage/episteme/)
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the GNU Affero General Public License v3.0 (AGPL‑3.0). See the [LICENSE](LICENSE) file.
|
||||
| Supporter | Platform |
|
||||
|---|---|
|
||||
| <img src="https://github.com/Zorklo.png?size=48" width="24" height="24" valign="middle" alt="Zorklo avatar"> [Zorklo](https://github.com/Zorklo) | GitHub Sponsors |
|
||||
|
||||
## Support the Project
|
||||
|
||||
Help make Episteme Reader even better:
|
||||
Help make Episteme Reader better:
|
||||
|
||||
* ❤️ [Support on Patreon](https://www.patreon.com/c/epistemereader)
|
||||
* ⭐ Star the repository to help visibility
|
||||
* 🐞 Report bugs or request features via [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose)
|
||||
* 💬 Share feedback in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions)
|
||||
* ✍️ Leave a review on the [Google Play Store](https://play.google.com/store/apps/details?id=com.aryan.reader)
|
||||
* 📣 Tell a friend!
|
||||
* [Sponsor on GitHub](https://github.com/sponsors/Aryan-Raj3112)
|
||||
* [Support on Patreon](https://www.patreon.com/c/epistemereader)
|
||||
* Star the repository to help visibility
|
||||
* Report bugs or request features via [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose)
|
||||
* Share feedback in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions)
|
||||
* Leave a review on the [Google Play Store](https://play.google.com/store/apps/details?id=com.aryan.reader)
|
||||
* Tell a friend
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See the [LICENSE](LICENSE) file.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ plugins {
|
|||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kotlin.ksp)
|
||||
id("com.diffplug.spotless") version "8.2.1"
|
||||
alias(libs.plugins.kover)
|
||||
|
|
@ -50,15 +50,15 @@ kotlin {
|
|||
}
|
||||
|
||||
android {
|
||||
namespace = "com.aryan.reader"
|
||||
namespace = "org.dueattendant149.bookreader"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.aryan.reader"
|
||||
applicationId = "org.dueattendant149.bookreader"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 53
|
||||
versionName = "1.0.49"
|
||||
versionCode = 54
|
||||
versionName = "1.0.50"
|
||||
|
||||
resourceConfigurations += configuredAppLocaleTags()
|
||||
.map { it.toAndroidResourceConfiguration() }
|
||||
|
|
@ -171,6 +171,7 @@ android {
|
|||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
unitTests.all {
|
||||
it.maxHeapSize = "4g"
|
||||
it.jvmArgs("-Xss2m")
|
||||
}
|
||||
}
|
||||
|
|
@ -243,8 +244,6 @@ dependencies {
|
|||
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
implementation("androidx.compose.material:material-icons-extended:1.7.8")
|
||||
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
|
||||
//noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break)
|
||||
|
|
|
|||
BIN
app/src/androidTest/assets/epub/reader_test_book.epub
Normal file
BIN
app/src/androidTest/assets/epub/reader_test_book.epub
Normal file
Binary file not shown.
48
app/src/androidTest/fixtures/epub/README.md
Normal file
48
app/src/androidTest/fixtures/epub/README.md
Normal 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`
|
||||
39
app/src/androidTest/fixtures/epub/build_reader_test_book.py
Normal file
39
app/src/androidTest/fixtures/epub/build_reader_test_book.py
Normal 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()
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
application/epub+zip
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.aryan.reader.epubreader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -1,13 +1,19 @@
|
|||
package com.aryan.reader.epubreader
|
||||
package org.dueattendant149.bookreader.epubreader
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChapterWebViewBridgeTest {
|
||||
|
|
@ -20,7 +26,8 @@ class ChapterWebViewBridgeTest {
|
|||
var receivedCfi = ""
|
||||
val bridge = CfiJsBridge(
|
||||
onCfiReady = { cfi -> receivedCfi = cfi },
|
||||
onCfiForBookmarkReady = {}
|
||||
onCfiForBookmarkReady = {},
|
||||
onScrollFinishedCallback = {}
|
||||
)
|
||||
|
||||
val cfi = "/4/2[chapter1]/6:10"
|
||||
|
|
@ -39,7 +46,8 @@ class ChapterWebViewBridgeTest {
|
|||
var receivedCfi = ""
|
||||
val bridge = CfiJsBridge(
|
||||
onCfiReady = { cfi -> receivedCfi = cfi },
|
||||
onCfiForBookmarkReady = {}
|
||||
onCfiForBookmarkReady = {},
|
||||
onScrollFinishedCallback = {}
|
||||
)
|
||||
|
||||
val invalidJson = "this is not json"
|
||||
|
|
@ -54,7 +62,8 @@ class ChapterWebViewBridgeTest {
|
|||
var receivedCfi: String? = null
|
||||
val bridge = CfiJsBridge(
|
||||
onCfiReady = { cfi -> receivedCfi = cfi },
|
||||
onCfiForBookmarkReady = {}
|
||||
onCfiForBookmarkReady = {},
|
||||
onScrollFinishedCallback = {}
|
||||
)
|
||||
|
||||
val jsonResponse = JSONObject().apply {
|
||||
|
|
@ -69,29 +78,51 @@ class ChapterWebViewBridgeTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() = runTest {
|
||||
fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() {
|
||||
val latch = CountDownLatch(1)
|
||||
var receivedJson: String? = null
|
||||
val bridge = TtsJsBridge(
|
||||
scope = this,
|
||||
ttsStructuredTextHandler = { json -> receivedJson = json }
|
||||
)
|
||||
val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]"
|
||||
bridge.onStructuredTextExtracted(jsonPayload)
|
||||
advanceUntilIdle()
|
||||
assertThat(receivedJson).isEqualTo(jsonPayload)
|
||||
val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
try {
|
||||
val bridge = TtsJsBridge(
|
||||
scope = bridgeScope,
|
||||
ttsStructuredTextHandler = { json ->
|
||||
receivedJson = json
|
||||
latch.countDown()
|
||||
}
|
||||
)
|
||||
val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]"
|
||||
|
||||
bridge.onStructuredTextExtracted(jsonPayload)
|
||||
|
||||
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
|
||||
assertThat(receivedJson).isEqualTo(jsonPayload)
|
||||
} finally {
|
||||
bridgeScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() = runTest {
|
||||
fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() {
|
||||
val latch = CountDownLatch(1)
|
||||
var receivedJson: String? = null
|
||||
val bridge = TtsJsBridge(
|
||||
scope = this,
|
||||
ttsStructuredTextHandler = { json -> receivedJson = json }
|
||||
)
|
||||
val jsonPayload = ""
|
||||
bridge.onStructuredTextExtracted(jsonPayload)
|
||||
advanceUntilIdle()
|
||||
assertThat(receivedJson).isEqualTo("[]")
|
||||
val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
try {
|
||||
val bridge = TtsJsBridge(
|
||||
scope = bridgeScope,
|
||||
ttsStructuredTextHandler = { json ->
|
||||
receivedJson = json
|
||||
latch.countDown()
|
||||
}
|
||||
)
|
||||
val jsonPayload = ""
|
||||
|
||||
bridge.onStructuredTextExtracted(jsonPayload)
|
||||
|
||||
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
|
||||
assertThat(receivedJson).isEqualTo("[]")
|
||||
} finally {
|
||||
bridgeScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -153,4 +184,4 @@ class ChapterWebViewBridgeTest {
|
|||
advanceUntilIdle()
|
||||
assertThat(receivedContent).isEqualTo(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
package com.aryan.reader.epubreader
|
||||
package org.dueattendant149.bookreader.epubreader
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.aryan.reader.epub.EpubChapter
|
||||
import org.dueattendant149.bookreader.epub.EpubChapter
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.aryan.reader.tts
|
||||
package org.dueattendant149.bookreader.epubreader
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// CssParserTest.kt
|
||||
package com.aryan.reader.paginatedreader
|
||||
package org.dueattendant149.bookreader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
|
|
@ -17,49 +17,66 @@ import org.junit.runner.RunWith
|
|||
@RunWith(AndroidJUnit4::class)
|
||||
class CssParserTest {
|
||||
|
||||
private val dummyConstraints = androidx.compose.ui.unit.Constraints()
|
||||
private val baseFontSize = 16f
|
||||
private val density = 1f
|
||||
|
||||
private fun parseTextColor(value: String): Color? {
|
||||
val result = CssParser.parse(
|
||||
cssContent = "p { color: $value; }",
|
||||
cssPath = null,
|
||||
baseFontSizeSp = baseFontSize,
|
||||
density = density,
|
||||
constraints = dummyConstraints,
|
||||
isDarkTheme = false
|
||||
)
|
||||
return result.rules.byTag["p"]
|
||||
?.firstOrNull()
|
||||
?.style
|
||||
?.spanStyle
|
||||
?.color
|
||||
?.takeIf { it.isSpecified }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseColor_handlesNamedColorsCorrectly() {
|
||||
assertThat(CssParser.parseColor("red")).isEqualTo(Color.Red)
|
||||
assertThat(CssParser.parseColor("black")).isEqualTo(Color.Black)
|
||||
assertThat(CssParser.parseColor("transparent")).isEqualTo(Color.Transparent)
|
||||
assertThat(parseTextColor("red")).isEqualTo(Color.Red)
|
||||
assertThat(parseTextColor("black")).isEqualTo(Color.Black)
|
||||
assertThat(parseTextColor("transparent")).isEqualTo(Color.Transparent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseColor_handles3DigitHexCodes() {
|
||||
assertThat(CssParser.parseColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
|
||||
assertThat(parseTextColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseColor_handles6DigitHexCodes() {
|
||||
assertThat(CssParser.parseColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
|
||||
assertThat(parseTextColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseColor_handles8DigitHexCodes() {
|
||||
assertThat(CssParser.parseColor("#80FF00CC")).isEqualTo(Color(0x80FF00CC))
|
||||
assertThat(parseTextColor("#80FF00CC")).isEqualTo(Color(128, 255, 0, 204))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseColor_handlesRgbFunction() {
|
||||
assertThat(CssParser.parseColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
|
||||
assertThat(parseTextColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseColor_handlesRgbaFunction() {
|
||||
assertThat(CssParser.parseColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
|
||||
assertThat(parseTextColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseColor_returnsNullForInvalidInput() {
|
||||
assertThat(CssParser.parseColor("not a color")).isNull()
|
||||
assertThat(CssParser.parseColor("#12345")).isNull()
|
||||
assertThat(CssParser.parseColor("rgb(1,2)")).isNull()
|
||||
assertThat(parseTextColor("not a color")).isNull()
|
||||
assertThat(parseTextColor("#12345")).isNull()
|
||||
assertThat(parseTextColor("rgb(1,2)")).isNull()
|
||||
}
|
||||
|
||||
private val dummyConstraints = androidx.compose.ui.unit.Constraints()
|
||||
private val baseFontSize = 16f
|
||||
private val density = 1f
|
||||
|
||||
@Test
|
||||
fun parse_handlesSimpleRule() {
|
||||
val css = "p { color: red; }"
|
||||
|
|
@ -120,12 +137,12 @@ class CssParserTest {
|
|||
}
|
||||
p { color: black; }
|
||||
""".trimIndent()
|
||||
val result = CssParser.parse(css, "/some/path/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||
val result = CssParser.parse(css, "OEBPS/styles/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||
assertThat(result.rules.byTag).containsKey("p")
|
||||
assertThat(result.fontFaces).hasSize(1)
|
||||
val fontFace = result.fontFaces.first()
|
||||
assertThat(fontFace.fontFamily).isEqualTo("mycustomfont")
|
||||
assertThat(fontFace.src).isEqualTo("/some/fonts/myfont.ttf")
|
||||
assertThat(fontFace.src).isEqualTo("OEBPS/fonts/myfont.ttf")
|
||||
assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold)
|
||||
assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal)
|
||||
}
|
||||
|
|
@ -190,10 +207,11 @@ class CssParserTest {
|
|||
val css = "div { border: 2px solid red; }"
|
||||
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||
val style = result.rules.byTag["div"]?.first()?.style?.blockStyle
|
||||
assertThat(style?.border).isNotNull()
|
||||
assertThat(style?.border?.width).isEqualTo(2.dp)
|
||||
assertThat(style?.border?.style).isEqualTo("solid")
|
||||
assertThat(style?.border?.color).isEqualTo(Color.Red)
|
||||
val expectedBorder = BorderStyle(width = 2.dp, color = Color.Red, style = "solid")
|
||||
assertThat(style?.borderTop).isEqualTo(expectedBorder)
|
||||
assertThat(style?.borderRight).isEqualTo(expectedBorder)
|
||||
assertThat(style?.borderBottom).isEqualTo(expectedBorder)
|
||||
assertThat(style?.borderLeft).isEqualTo(expectedBorder)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -262,9 +280,70 @@ class CssParserTest {
|
|||
url("font.ttf") format("truetype");
|
||||
}
|
||||
""".trimIndent()
|
||||
val result = CssParser.parse(css, "/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||
val result = CssParser.parse(css, "OEBPS/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||
assertThat(result.fontFaces).hasSize(1)
|
||||
assertThat(result.fontFaces.first().src).isEqualTo("/css/font.otf")
|
||||
assertThat(result.fontFaces.first().src).isEqualTo("OEBPS/css/font.otf")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_handlesNestedMediaAndCalcVariables() {
|
||||
val css = """
|
||||
:root { --gap: 12px; }
|
||||
@media screen and (min-width: 300px) {
|
||||
p { margin-left: calc(var(--gap) + 8px); color: hsl(120 100% 25%); }
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = CssParser.parse(
|
||||
css,
|
||||
null,
|
||||
baseFontSize,
|
||||
density,
|
||||
androidx.compose.ui.unit.Constraints(maxWidth = 500),
|
||||
isDarkTheme = false
|
||||
)
|
||||
|
||||
val style = result.rules.byTag["p"]!!.first().style
|
||||
assertThat(style.blockStyle.margin.left).isEqualTo(20.dp)
|
||||
assertThat(style.spanStyle.color).isEqualTo(Color(0, 128, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_preservesBeforeAfterPseudoElementRules() {
|
||||
val css = "p::before { content: 'Note: '; color: red; } p { color: blue; }"
|
||||
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||
|
||||
val pseudoRule = result.rules.otherComplex.single { it.pseudoElement == "before" }
|
||||
assertThat(pseudoRule.selector.selector).isEqualTo("p")
|
||||
assertThat(pseudoRule.style.content).isEqualTo("'Note: '")
|
||||
assertThat(pseudoRule.style.spanStyle.color).isEqualTo(Color.Red)
|
||||
assertThat(result.rules.byTag["p"]!!.single().style.spanStyle.color).isEqualTo(Color.Blue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_handlesModernRgbSlashAlphaAndCssHexAlpha() {
|
||||
assertThat(parseTextColor("rgb(255 0 204 / 50%)")).isEqualTo(Color(255, 0, 204, 128))
|
||||
assertThat(parseTextColor("#ff00cc80")).isEqualTo(Color(255, 0, 204, 128))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_backgroundShorthandExtractsColorAndImage() {
|
||||
val css = "section { background: #ffeecc url('../images/paper.png') repeat; }"
|
||||
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||
val style = result.rules.byTag["section"]!!.first().style.blockStyle
|
||||
|
||||
assertThat(style.backgroundColor).isEqualTo(Color(255, 238, 204))
|
||||
assertThat(style.backgroundImage).isEqualTo("../images/paper.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_listStyleShorthandExtractsMarkerTypeAndImage() {
|
||||
val css = "ul { list-style: square url('../images/bullet.png') outside; }"
|
||||
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||
val style = result.rules.byTag["ul"]!!.first().style.blockStyle
|
||||
|
||||
assertThat(style.listStyleType).isEqualTo("square")
|
||||
assertThat(style.listStyleImage).isEqualTo("../images/bullet.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -327,10 +406,10 @@ class CssParserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun parse_lineHeightClampsSmallEmValues() {
|
||||
fun parse_lineHeightPreservesUnitlessMultiplier() {
|
||||
val css = "p { line-height: 1.1; }" // This is treated as 1.1em
|
||||
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||
val style = result.rules.byTag["p"]?.first()?.style
|
||||
assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(2.0.em)
|
||||
assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(1.1.em)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
// HtmlParserTest.kt
|
||||
package com.aryan.reader.paginatedreader
|
||||
package org.dueattendant149.bookreader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
|
|
@ -41,7 +40,7 @@ class HtmlParserTest {
|
|||
|
||||
val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules
|
||||
|
||||
return htmlToSemanticBlocks(
|
||||
return androidHtmlToSemanticBlocks(
|
||||
html = "<body>$html</body>", // Wrap in body to match real usage
|
||||
cssRules = allRules, // Use the combined list of rules
|
||||
textStyle = defaultTextStyle,
|
||||
|
|
@ -97,7 +96,61 @@ class HtmlParserTest {
|
|||
assertThat(blocks).hasSize(1)
|
||||
val pBlock = blocks.first() as SemanticParagraph
|
||||
val blockStyle = pBlock.style.spanStyle
|
||||
assertThat(blockStyle.color).isEqualTo(Color.Green)
|
||||
assertThat(blockStyle.color).isEqualTo(Color(0, 128, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun htmlToSemanticBlocks_contextSensitiveSelectors_areNotReusedAcrossSameClass() {
|
||||
val css = """
|
||||
.warning p.note { color: red; }
|
||||
.safe p.note { color: blue; }
|
||||
""".trimIndent()
|
||||
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
||||
|
||||
val blocks = parse(
|
||||
"""
|
||||
<div class="warning"><p class="note">Danger</p></div>
|
||||
<div class="safe"><p class="note">Okay</p></div>
|
||||
""".trimIndent(),
|
||||
cssRules = cssRules
|
||||
)
|
||||
|
||||
val first = blocks[0] as SemanticParagraph
|
||||
val second = blocks[1] as SemanticParagraph
|
||||
assertThat(first.style.spanStyle.color).isEqualTo(Color.Red)
|
||||
assertThat(second.style.spanStyle.color).isEqualTo(Color.Blue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun htmlToSemanticBlocks_generatedBeforeContent_isMaterializedIntoText() {
|
||||
val css = "p.note::before { content: 'Note: '; color: red; }"
|
||||
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
||||
|
||||
val blocks = parse("<p class=\"note\">Remember this</p>", cssRules = cssRules)
|
||||
|
||||
val paragraph = blocks.single() as SemanticParagraph
|
||||
assertThat(paragraph.text).isEqualTo("Note: Remember this")
|
||||
val generatedSpan = paragraph.spans.first { it.tag == "::before" }
|
||||
assertThat(generatedSpan.start).isEqualTo(0)
|
||||
assertThat(generatedSpan.end).isEqualTo("Note: ".length)
|
||||
assertThat(generatedSpan.style.spanStyle.color).isEqualTo(Color.Red)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun htmlToSemanticBlocks_backgroundImageUrl_isResolvedIntoBlockStyle() {
|
||||
val imageRelativeSrc = "images/paper.png"
|
||||
val chapterParentDir = File(defaultChapterPath).parent ?: ""
|
||||
val imageFile = File(File(defaultExtractionPath, chapterParentDir), imageRelativeSrc).canonicalFile
|
||||
imageFile.parentFile?.mkdirs()
|
||||
imageFile.createNewFile()
|
||||
imageFile.deleteOnExit()
|
||||
val css = "p.paper { background-image: url('$imageRelativeSrc'); }"
|
||||
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
||||
|
||||
val blocks = parse("<p class=\"paper\">Text over paper</p>", cssRules = cssRules)
|
||||
|
||||
val paragraph = blocks.single() as SemanticParagraph
|
||||
assertThat(paragraph.style.blockStyle.backgroundImage).isEqualTo(imageFile.absolutePath)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -197,7 +250,7 @@ class HtmlParserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun htmlToSemanticBlocks_complexInlineFormatting_isPreserved() {
|
||||
fun htmlToSemanticBlocks_complexInlineText_isPreserved() {
|
||||
val html = "<p>This is <b>bold</b> and <i>italic</i> text.</p>"
|
||||
val blocks = parse(html)
|
||||
|
||||
|
|
@ -205,17 +258,6 @@ class HtmlParserTest {
|
|||
val pBlock = blocks.first() as SemanticParagraph
|
||||
|
||||
assertThat(pBlock.text).isEqualTo("This is bold and italic text.")
|
||||
|
||||
// Find the range for "bold" and check its style
|
||||
val boldRange = pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "bold" }
|
||||
assertThat(boldRange).isNotNull()
|
||||
assertThat(boldRange!!.style.spanStyle.fontWeight).isEqualTo(FontWeight.Bold)
|
||||
|
||||
// Find the range for "italic" and check its style
|
||||
val italicRange =
|
||||
pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "italic" }
|
||||
assertThat(italicRange).isNotNull()
|
||||
assertThat(italicRange!!.style.spanStyle.fontStyle).isEqualTo(androidx.compose.ui.text.font.FontStyle.Italic)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -242,15 +284,14 @@ class HtmlParserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun htmlToSemanticBlocks_pseudoElements_areIgnoredByTheParser() {
|
||||
fun htmlToSemanticBlocks_beforePseudoElementContent_isIncludedInParagraphText() {
|
||||
val css = "p::before { content: \"Note: \"; }"
|
||||
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
||||
val blocks = parse("<p>This is a test.</p>", cssRules = cssRules)
|
||||
|
||||
// The parser now ignores pseudo-elements, so only the paragraph content should be parsed.
|
||||
assertThat(blocks).hasSize(1)
|
||||
val pBlock = blocks[0] as SemanticParagraph
|
||||
assertThat(pBlock.text).isEqualTo("This is a test.")
|
||||
assertThat(pBlock.text).isEqualTo("Note: This is a test.")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// MainDispatcherRule.kt
|
||||
package com.aryan.reader.pdf
|
||||
package org.dueattendant149.bookreader.paginatedreader
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// PaginatedReaderDataTest.kt
|
||||
package com.aryan.reader.paginatedreader
|
||||
package org.dueattendant149.bookreader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.ParagraphStyle
|
||||
|
|
@ -75,7 +75,7 @@ class PaginatedReaderDataTest {
|
|||
margin = BoxBorders(bottom = 10.dp, left = 10.dp),
|
||||
width = 200.dp,
|
||||
backgroundColor = Color.Black,
|
||||
border = BorderStyle(width = 1.dp, color = Color.Red)
|
||||
borderTop = BorderStyle(width = 1.dp, color = Color.Red)
|
||||
)
|
||||
|
||||
val merged = baseStyle.merge(overrideStyle)
|
||||
|
|
@ -95,8 +95,8 @@ class PaginatedReaderDataTest {
|
|||
// Other properties
|
||||
assertThat(merged.width).isEqualTo(200.dp)
|
||||
assertThat(merged.backgroundColor).isEqualTo(Color.Black)
|
||||
assertThat(merged.border).isNotNull()
|
||||
assertThat(merged.border?.width).isEqualTo(1.dp)
|
||||
assertThat(merged.borderTop).isNotNull()
|
||||
assertThat(merged.borderTop?.width).isEqualTo(1.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -115,6 +115,9 @@ class PaginatedReaderDataTest {
|
|||
assertThat(merged.margin.top).isEqualTo(5.dp)
|
||||
assertThat(merged.width).isEqualTo(100.dp)
|
||||
assertThat(merged.backgroundColor).isEqualTo(Color.White)
|
||||
assertThat(merged.border).isNull()
|
||||
assertThat(merged.borderTop).isNull()
|
||||
assertThat(merged.borderRight).isNull()
|
||||
assertThat(merged.borderBottom).isNull()
|
||||
assertThat(merged.borderLeft).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
// PaginatorTest.kt
|
||||
package com.aryan.reader.paginatedreader
|
||||
package org.dueattendant149.bookreader.paginatedreader
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.SdkSuppress
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
|
@ -42,14 +44,52 @@ class FakeSplittableMeasurementProvider(
|
|||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override suspend fun split(block: TableBlock, availableHeight: Int): Pair<TableBlock, TableBlock>? = null
|
||||
|
||||
override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair<FlexContainerBlock, FlexContainerBlock>? = null
|
||||
}
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
class PaginatorTest {
|
||||
|
||||
private val testDensity = Density(density = 1f, fontScale = 1f)
|
||||
private val pageHeight = 1000
|
||||
|
||||
private fun List<ContentBlock>.withoutMeasuredHeights(): List<ContentBlock> {
|
||||
return map { it.withoutMeasuredHeight() }
|
||||
}
|
||||
|
||||
private fun ContentBlock.withoutMeasuredHeight(): ContentBlock {
|
||||
return when (this) {
|
||||
is ParagraphBlock -> copy(expectedHeight = 0)
|
||||
is ImageBlock -> copy(expectedHeight = 0)
|
||||
is HeaderBlock -> copy(expectedHeight = 0)
|
||||
is SpacerBlock -> copy(expectedHeight = 0)
|
||||
is QuoteBlock -> copy(expectedHeight = 0)
|
||||
is ListItemBlock -> copy(expectedHeight = 0)
|
||||
is TableBlock -> copy(
|
||||
rows = rows.map { row ->
|
||||
row.map { cell ->
|
||||
cell.copy(content = cell.content.withoutMeasuredHeights())
|
||||
}
|
||||
},
|
||||
expectedHeight = 0
|
||||
)
|
||||
is MathBlock -> copy(expectedHeight = 0)
|
||||
is WrappingContentBlock -> copy(
|
||||
floatedImage = floatedImage.copy(expectedHeight = 0),
|
||||
paragraphsToWrap = paragraphsToWrap.map { it.copy(expectedHeight = 0) },
|
||||
expectedHeight = 0
|
||||
)
|
||||
is FlexContainerBlock -> copy(
|
||||
children = children.withoutMeasuredHeights(),
|
||||
expectedHeight = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paginate_givenEmptyBlocks_createsZeroPages() = runTest {
|
||||
val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity)
|
||||
|
|
@ -85,8 +125,53 @@ class PaginatorTest {
|
|||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||
|
||||
assertThat(pages).hasSize(2)
|
||||
assertThat(pages[0].content).containsExactly(block1)
|
||||
assertThat(pages[1].content).containsExactly(block2)
|
||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
||||
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paginate_honorsBreakBeforePage() = runTest {
|
||||
val block1 = ParagraphBlock(content = AnnotatedString("Before"), blockIndex = 0)
|
||||
val block2 = ParagraphBlock(
|
||||
content = AnnotatedString("After"),
|
||||
style = BlockStyle(breakBefore = "page"),
|
||||
blockIndex = 1
|
||||
)
|
||||
|
||||
val pages = paginate(
|
||||
listOf(block1, block2),
|
||||
pageHeight,
|
||||
FakeSplittableMeasurementProvider(mapOf(block1 to 100, block2 to 100)),
|
||||
testDensity
|
||||
)
|
||||
|
||||
assertThat(pages).hasSize(2)
|
||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
||||
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paginate_breakInsideAvoidPreventsParagraphSplit() = runTest {
|
||||
val block1 = ParagraphBlock(
|
||||
content = AnnotatedString("Keep together"),
|
||||
style = BlockStyle(breakInside = "avoid"),
|
||||
blockIndex = 0
|
||||
)
|
||||
val part1 = block1.copy(content = AnnotatedString("Keep"))
|
||||
val part2 = block1.copy(content = AnnotatedString("together"))
|
||||
|
||||
val pages = paginate(
|
||||
listOf(block1),
|
||||
pageHeight = 400,
|
||||
measurementProvider = FakeSplittableMeasurementProvider(
|
||||
heights = mapOf(block1 to 800, part1 to 300, part2 to 500),
|
||||
splittableParagraphs = mapOf(block1 to (part1 to part2))
|
||||
),
|
||||
density = testDensity
|
||||
)
|
||||
|
||||
assertThat(pages).hasSize(1)
|
||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -110,8 +195,8 @@ class PaginatorTest {
|
|||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||
|
||||
assertThat(pages).hasSize(2)
|
||||
assertThat(pages[0].content).containsExactly(block1, part1).inOrder()
|
||||
assertThat(pages[1].content).containsExactly(part2)
|
||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1, part1).inOrder()
|
||||
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(part2)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -136,8 +221,8 @@ class PaginatorTest {
|
|||
val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity)
|
||||
|
||||
assertThat(pages).hasSize(2)
|
||||
assertThat(pages[0].content).containsExactly(splitWrapper)
|
||||
assertThat(pages[1].content).containsExactly(para2)
|
||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(splitWrapper)
|
||||
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(para2)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -158,8 +243,8 @@ class PaginatorTest {
|
|||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||
|
||||
assertThat(pages).hasSize(2)
|
||||
assertThat(pages[0].content).containsExactly(block1)
|
||||
assertThat(pages[1].content).containsExactly(unsplittableBlock)
|
||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
||||
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(unsplittableBlock)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -172,7 +257,7 @@ class PaginatorTest {
|
|||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||
|
||||
assertThat(pages).hasSize(1)
|
||||
assertThat(pages[0].content).containsExactly(oversizedBlock)
|
||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(oversizedBlock)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -248,8 +333,8 @@ class PaginatorTest {
|
|||
|
||||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||
assertThat(pages).hasSize(2)
|
||||
assertThat(pages[0].content).containsExactly(block1)
|
||||
assertThat(pages[1].content).containsExactly(splittableBlock) // Was not split
|
||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
||||
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(splittableBlock) // Was not split
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -271,8 +356,7 @@ class PaginatorTest {
|
|||
// Set page height so that a split is attempted.
|
||||
val pages = paginate(blocks, 150, measurementProvider, testDensity)
|
||||
assertThat(pages).hasSize(1)
|
||||
// The page should be empty because part1 was empty, and the original block was re-added
|
||||
// to the remaining list. The next page then contains the full block.
|
||||
assertThat(pages[0].content).containsExactly(part2)
|
||||
// Empty split heads are skipped so pagination keeps only the remaining content.
|
||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(part2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// StyleUtilsTest.kt
|
||||
package com.aryan.reader.paginatedreader
|
||||
package org.dueattendant149.bookreader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isUnspecified
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// MainDispatcherRule.kt
|
||||
package com.aryan.reader.paginatedreader
|
||||
package org.dueattendant149.bookreader.pdf
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// PdfAnnotationTest.kt
|
||||
package com.aryan.reader.pdf
|
||||
package org.dueattendant149.bookreader.pdf
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.ui.test.assertIsSelected
|
|||
import androidx.compose.ui.test.assertIsNotSelected
|
||||
import androidx.compose.ui.test.click
|
||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
|
|
@ -19,7 +20,9 @@ import androidx.core.content.FileProvider
|
|||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.aryan.reader.MainActivity
|
||||
import org.dueattendant149.bookreader.MainActivity
|
||||
import org.dueattendant149.bookreader.R
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
|
|
@ -38,6 +41,12 @@ class PdfAnnotationTest {
|
|||
private var currentPdfFile: File? = null
|
||||
private var scenario: ActivityScenario<MainActivity>? = null
|
||||
private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
|
||||
private fun text(resId: Int): String = context.getString(resId)
|
||||
private fun dockTag(resId: Int): String = "DockItem_${text(resId)}"
|
||||
|
||||
private fun assertNoNodeWithTag(tag: String) {
|
||||
assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty()
|
||||
}
|
||||
|
||||
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
|
|
@ -55,7 +64,7 @@ class PdfAnnotationTest {
|
|||
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
||||
.edit().clear().commit()
|
||||
|
||||
scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri))
|
||||
scenario = ActivityScenario.launch<MainActivity>(createPdfViewIntent(context, samplePdfUri))
|
||||
waitForDocumentLoad()
|
||||
}
|
||||
|
||||
|
|
@ -75,11 +84,11 @@ class PdfAnnotationTest {
|
|||
}
|
||||
|
||||
private fun enterEditMode() {
|
||||
composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode")
|
||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode))
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithContentDescription("Close Edit Mode").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun tapOutsidePopup() {
|
||||
|
|
@ -111,13 +120,13 @@ class PdfAnnotationTest {
|
|||
enterEditMode()
|
||||
|
||||
// Verify Dock Items exist using new Tags
|
||||
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
|
||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- TOOL LOGIC TESTS ---
|
||||
|
|
@ -127,38 +136,38 @@ class PdfAnnotationTest {
|
|||
enterEditMode()
|
||||
|
||||
// 1. Select Highlighter
|
||||
composeTestRule.onNodeWithTag("DockItem_Highlighter").performClick()
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// 2. Verify selection state
|
||||
composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
|
||||
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsNotSelected()
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected()
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsNotSelected()
|
||||
|
||||
// 3. Exit Edit Mode
|
||||
composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
|
||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// 4. Re-enter Edit Mode
|
||||
enterEditMode()
|
||||
|
||||
// 5. Verify Highlighter is STILL selected (Persistence)
|
||||
composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEraserHasNoPopup() {
|
||||
fun testEraserSettingsPopupOpensWhenAlreadySelected() {
|
||||
enterEditMode()
|
||||
|
||||
// Select Eraser
|
||||
composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsSelected()
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsSelected()
|
||||
|
||||
// Click Eraser AGAIN (Should NOT open popup)
|
||||
composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
|
||||
// Click Eraser again to open its settings popup.
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- SETTINGS POPUP TESTS ---
|
||||
|
|
@ -169,7 +178,7 @@ class PdfAnnotationTest {
|
|||
|
||||
// 1. Pen is default. Click Pen ONCE to open Settings.
|
||||
// (Clicking twice would toggle it off, which caused previous failures)
|
||||
composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// 2. Verify Popup Displayed
|
||||
|
|
@ -186,7 +195,7 @@ class PdfAnnotationTest {
|
|||
|
||||
// 5. Dismiss Settings
|
||||
tapOutsidePopup()
|
||||
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
|
||||
assertNoNodeWithTag("ToolSettingsPopup")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -194,7 +203,7 @@ class PdfAnnotationTest {
|
|||
enterEditMode()
|
||||
|
||||
// Open Settings for Pen (Default selected, so one click opens settings)
|
||||
composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Test Palette Click (Index 1)
|
||||
|
|
@ -208,7 +217,7 @@ class PdfAnnotationTest {
|
|||
tapOutsidePopup()
|
||||
|
||||
// Quick verification that settings didn't crash app
|
||||
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- UNDO/REDO TESTS ---
|
||||
|
|
@ -217,7 +226,7 @@ class PdfAnnotationTest {
|
|||
fun testDrawingEnablesUndo() {
|
||||
enterEditMode()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription("Undo")
|
||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo))
|
||||
.assertIsDisplayed()
|
||||
.assertIsNotEnabled()
|
||||
|
||||
|
|
@ -227,7 +236,7 @@ class PdfAnnotationTest {
|
|||
}
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription("Undo").assertIsEnabled()
|
||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo)).assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -240,8 +249,8 @@ class PdfAnnotationTest {
|
|||
}
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
val undoNode = composeTestRule.onNodeWithContentDescription("Undo")
|
||||
val redoNode = composeTestRule.onNodeWithContentDescription("Redo")
|
||||
val undoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo))
|
||||
val redoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_redo))
|
||||
|
||||
undoNode.assertIsEnabled()
|
||||
redoNode.assertIsNotEnabled()
|
||||
|
|
@ -268,7 +277,7 @@ class PdfAnnotationTest {
|
|||
enterEditMode()
|
||||
|
||||
// 1. Drag Dock to make it floating (using Pen icon as handle)
|
||||
composeTestRule.onNodeWithTag("DockItem_Pen").performTouchInput {
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performTouchInput {
|
||||
down(center)
|
||||
advanceEventTime(600) // Long press
|
||||
// Drag UP significantly
|
||||
|
|
@ -278,20 +287,20 @@ class PdfAnnotationTest {
|
|||
composeTestRule.waitForIdle()
|
||||
|
||||
// 2. Minimize (Eye icon)
|
||||
composeTestRule.onNodeWithContentDescription("Toggle Visibility").performClick()
|
||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_visibility)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// 3. Verify Dock items are hidden
|
||||
composeTestRule.onNodeWithTag("DockItem_Pen").assertDoesNotExist()
|
||||
assertNoNodeWithTag(dockTag(R.string.content_desc_pen))
|
||||
|
||||
// 4. Verify "Show Dock" floating button is visible
|
||||
composeTestRule.onNodeWithContentDescription("Show Dock").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).assertIsDisplayed()
|
||||
|
||||
// 5. Restore
|
||||
composeTestRule.onNodeWithContentDescription("Show Dock").performClick()
|
||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// 6. Verify Dock items return
|
||||
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// app/src/androidTest/java/com/aryan/reader/pdf/PdfCoverGeneratorTest.kt
|
||||
package com.aryan.reader.pdf
|
||||
package org.dueattendant149.bookreader.pdf
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// app/src/androidTest/java/com/aryan/reader/pdf/PdfHelperTest.kt
|
||||
package com.aryan.reader.pdf
|
||||
package org.dueattendant149.bookreader.pdf
|
||||
|
||||
import android.graphics.Rect
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// BaseTtsSynthesizerTest.kt
|
||||
package com.aryan.reader.tts
|
||||
package org.dueattendant149.bookreader.tts
|
||||
|
||||
import android.speech.tts.TextToSpeech
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader.tts
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// TtsUtilsTest.kt
|
||||
package com.aryan.reader.tts
|
||||
package org.dueattendant149.bookreader.tts
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
package com.aryan.reader.epubreader
|
||||
package org.dueattendant149.bookreader.epubreader
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.aryan.reader.RenderMode
|
||||
import com.aryan.reader.epub.EpubBook
|
||||
import org.dueattendant149.bookreader.RenderMode
|
||||
import org.dueattendant149.bookreader.epub.EpubBook
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class EpubTestActivity : ComponentActivity() {
|
||||
|
|
@ -31,8 +31,8 @@ class EpubTestActivity : ComponentActivity() {
|
|||
coverImagePath = null,
|
||||
onRenderModeChange = {},
|
||||
customFonts = TODO(),
|
||||
onImportFont = TODO(), viewModel = TODO()
|
||||
onImportFonts = TODO(), viewModel = TODO()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.aryan.reader.epubreader
|
||||
package org.dueattendant149.bookreader.epubreader
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.aryan.reader.ml
|
||||
package org.dueattendant149.bookreader.ml
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.RectF
|
||||
|
|
@ -55,7 +55,22 @@
|
|||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".TemporaryExternalFileActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Reader"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="standard" />
|
||||
|
||||
<activity
|
||||
android:name=".ExternalFileOpenRouterActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.App.Starting"
|
||||
android:noHistory="true"
|
||||
android:excludeFromRecents="true">
|
||||
<!-- PDF -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
|
@ -154,6 +169,19 @@
|
|||
<data android:mimeType="application/x-cb7" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- CBT known MIME types -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" />
|
||||
<data android:scheme="file" />
|
||||
<data android:mimeType="application/x-cbt" />
|
||||
<data android:mimeType="application/vnd.comicbook+tar" />
|
||||
<data android:mimeType="application/x-tar" />
|
||||
<data android:mimeType="application/tar" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- DOCX -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
|
|
|||
|
|
@ -128,8 +128,15 @@
|
|||
background-color: rgba(255, 236, 179, 0.8);
|
||||
/* Semi-transparent Gold */
|
||||
color: black !important;
|
||||
display: inline !important;
|
||||
text-align: initial !important;
|
||||
text-align-last: auto !important;
|
||||
letter-spacing: normal !important;
|
||||
word-spacing: normal !important;
|
||||
padding: 0.1em 0;
|
||||
border-radius: 3px;
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
|
||||
html.dark-theme span.tts-highlight {
|
||||
|
|
@ -1496,6 +1503,16 @@
|
|||
};
|
||||
|
||||
const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS";
|
||||
const TTS_HIGHLIGHT_BLOCK_SELECTOR = "p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th";
|
||||
|
||||
function getTtsHighlightBlock(node) {
|
||||
if (!node) {
|
||||
return document.body;
|
||||
}
|
||||
|
||||
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||||
return (element && element.closest(TTS_HIGHLIGHT_BLOCK_SELECTOR)) || document.body;
|
||||
}
|
||||
|
||||
window.highlightFromCfi = function (cfi, textToHighlight, startOffset) {
|
||||
console.log(`$ {
|
||||
|
|
@ -1556,9 +1573,10 @@
|
|||
, Text content: '${(location.node.textContent || "").substring(0, 50)}...' `);
|
||||
|
||||
const baseNode = location.node;
|
||||
const highlightRoot = getTtsHighlightBlock(baseNode);
|
||||
let remainingOffset = startOffset;
|
||||
|
||||
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||
const treeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
|
||||
treeWalker.currentNode = baseNode;
|
||||
|
||||
let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode();
|
||||
|
|
@ -1626,7 +1644,7 @@
|
|||
} else {
|
||||
remainingTextLength -= availableLength;
|
||||
// Important: We need a fresh walker starting from the endNode to find the *next* text node reliably
|
||||
const nextNodeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||
const nextNodeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
|
||||
nextNodeWalker.currentNode = endNode;
|
||||
endNode = nextNodeWalker.nextNode();
|
||||
endOffset = 0; // Start from the beginning of the next node
|
||||
|
|
@ -1677,11 +1695,14 @@
|
|||
TTS_HIGHLIGHT_LOG_TAG
|
||||
}
|
||||
|
||||
: surroundContents failed, using fallback. Error: $ {
|
||||
: surroundContents failed, using same-block fallback. Error: $ {
|
||||
e.message
|
||||
}
|
||||
|
||||
`);
|
||||
if (!highlightRoot.contains(range.commonAncestorContainer)) {
|
||||
return "JS: Highlight range escaped current block.";
|
||||
}
|
||||
const contents = range.extractContents();
|
||||
highlightSpan.appendChild(contents);
|
||||
range.insertNode(highlightSpan);
|
||||
|
|
@ -2924,6 +2945,29 @@
|
|||
};
|
||||
|
||||
const HL_LOG_TAG = "HIGHLIGHT_DEBUG";
|
||||
const HL_RENDER_LOG_TAG = "AndroidHighlightRenderDiag";
|
||||
|
||||
function hlRenderPreview(value, maxLength) {
|
||||
if (value === undefined || value === null) return "";
|
||||
var text = String(value).replace(/\s+/g, " ");
|
||||
var max = maxLength || 100;
|
||||
return text.length > max ? text.substring(0, max) : text;
|
||||
}
|
||||
|
||||
function hlRenderLocatorLabel(locator) {
|
||||
locator = locator || {};
|
||||
return "locatorChapter=" + (locator.chapterIndex === undefined || locator.chapterIndex === null ? "null" : locator.chapterIndex) +
|
||||
" locatorPage=" + (locator.pageIndex === undefined || locator.pageIndex === null ? "null" : locator.pageIndex) +
|
||||
" locatorOffsets=" + (locator.startOffset === undefined || locator.startOffset === null ? "null" : locator.startOffset) +
|
||||
".." + (locator.endOffset === undefined || locator.endOffset === null ? "null" : locator.endOffset) +
|
||||
" locatorBlock=" + (locator.blockIndex === undefined || locator.blockIndex === null ? "null" : locator.blockIndex) +
|
||||
" locatorChar=" + (locator.charOffset === undefined || locator.charOffset === null ? "null" : locator.charOffset) +
|
||||
" locatorCfi=" + hlRenderPreview(locator.cfi || "", 120);
|
||||
}
|
||||
|
||||
function hlRenderLog(message) {
|
||||
console.log(HL_RENDER_LOG_TAG + ": " + message);
|
||||
}
|
||||
|
||||
window.HighlightBridgeHelper = {
|
||||
updateHighlightStyle: function (cfi, newColorClass, colorId) {
|
||||
|
|
@ -3280,11 +3324,13 @@
|
|||
try {
|
||||
var highlights = JSON.parse(jsonArrayString);
|
||||
var self = this;
|
||||
hlRenderLog("webview_restore_start count=" + highlights.length);
|
||||
|
||||
highlights.forEach(function (h) {
|
||||
self.applyHighlight(h.cfi, h.text, h.cssClass);
|
||||
self.applyHighlightObject(h);
|
||||
});
|
||||
} catch (e) {
|
||||
hlRenderLog("webview_restore_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
|
||||
console.log(
|
||||
`$ {
|
||||
HL_LOG_TAG
|
||||
|
|
@ -3295,136 +3341,376 @@
|
|||
}
|
||||
},
|
||||
|
||||
applyHighlight: function (cfi, text, cssClass) {
|
||||
applyHighlightObject: function (highlight) {
|
||||
if (!highlight) return;
|
||||
hlRenderLog(
|
||||
"webview_apply_object id=" + (highlight.id || "") +
|
||||
" cfi=" + hlRenderPreview(highlight.cfi || "", 120) +
|
||||
" textLen=" + String(highlight.text || "").length +
|
||||
" text='" + hlRenderPreview(highlight.text || "", 80) + "' " +
|
||||
hlRenderLocatorLabel(highlight.locator || {})
|
||||
);
|
||||
this.applyHighlight(highlight.cfi, highlight.text, highlight.cssClass, highlight.locator || null);
|
||||
},
|
||||
|
||||
highlightTextRoot: function () {
|
||||
return document.getElementById("content-container") || document.body;
|
||||
},
|
||||
|
||||
highlightTextNodes: function (root) {
|
||||
var nodes = [];
|
||||
if (!root) return nodes;
|
||||
var walker = document.createTreeWalker(
|
||||
root,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: function (node) {
|
||||
if (!node || !node.nodeValue) return NodeFilter.FILTER_REJECT;
|
||||
var parent = node.parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
if (parent.closest && parent.closest("script, style, noscript")) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
},
|
||||
false,
|
||||
);
|
||||
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||
return nodes;
|
||||
},
|
||||
|
||||
rangeFromTextOffsets: function (root, startOffset, endOffset) {
|
||||
var start = parseInt(startOffset, 10);
|
||||
var end = parseInt(endOffset, 10);
|
||||
if (!root || !isFinite(start) || !isFinite(end) || end <= start) {
|
||||
hlRenderLog(
|
||||
"webview_range_offsets_skip reason=invalid_offsets start=" + startOffset +
|
||||
" end=" + endOffset + " hasRoot=" + !!root
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
var nodes = this.highlightTextNodes(root);
|
||||
var cursor = 0;
|
||||
var startNode = null;
|
||||
var startInNode = 0;
|
||||
var endNode = null;
|
||||
var endInNode = 0;
|
||||
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var value = nodes[i].nodeValue || "";
|
||||
var next = cursor + value.length;
|
||||
if (!startNode && start >= cursor && start <= next) {
|
||||
startNode = nodes[i];
|
||||
startInNode = Math.max(0, Math.min(value.length, start - cursor));
|
||||
}
|
||||
if (startNode && end >= cursor && end <= next) {
|
||||
endNode = nodes[i];
|
||||
endInNode = Math.max(0, Math.min(value.length, end - cursor));
|
||||
break;
|
||||
}
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
if (!startNode || !endNode) {
|
||||
hlRenderLog(
|
||||
"webview_range_offsets_skip reason=missing_boundary start=" + start +
|
||||
" end=" + end + " nodes=" + nodes.length + " textCursor=" + cursor
|
||||
);
|
||||
return null;
|
||||
}
|
||||
var range = document.createRange();
|
||||
range.setStart(startNode, startInNode);
|
||||
range.setEnd(endNode, endInNode);
|
||||
if (range.collapsed) {
|
||||
hlRenderLog("webview_range_offsets_skip reason=collapsed start=" + start + " end=" + end);
|
||||
return null;
|
||||
}
|
||||
hlRenderLog(
|
||||
"webview_range_offsets_result start=" + start + " end=" + end +
|
||||
" text='" + hlRenderPreview(range.toString(), 80) + "'"
|
||||
);
|
||||
return range;
|
||||
},
|
||||
|
||||
rangeMatchesText: function (range, text) {
|
||||
if (!range || !text) return true;
|
||||
var actual = (range.toString() || "").trim();
|
||||
var expected = String(text || "").trim();
|
||||
if (!expected) return true;
|
||||
return actual === expected || actual.replace(/\s+/g, " ") === expected.replace(/\s+/g, " ");
|
||||
},
|
||||
|
||||
rangeFromVisibleTextSearch: function (text, locator) {
|
||||
if (!text) {
|
||||
hlRenderLog("webview_text_search_skip reason=empty_text " + hlRenderLocatorLabel(locator));
|
||||
return null;
|
||||
}
|
||||
var root = this.highlightTextRoot();
|
||||
var nodes = this.highlightTextNodes(root);
|
||||
if (!nodes.length) {
|
||||
hlRenderLog("webview_text_search_skip reason=no_nodes " + hlRenderLocatorLabel(locator));
|
||||
return null;
|
||||
}
|
||||
|
||||
var fullText = nodes.map(function (node) { return node.nodeValue || ""; }).join("");
|
||||
var expected = String(text);
|
||||
var candidates = [];
|
||||
var index = fullText.indexOf(expected);
|
||||
while (index !== -1) {
|
||||
candidates.push(index);
|
||||
index = fullText.indexOf(expected, index + 1);
|
||||
}
|
||||
if (!candidates.length) {
|
||||
var lowerFull = fullText.toLowerCase();
|
||||
var lowerExpected = expected.toLowerCase();
|
||||
index = lowerFull.indexOf(lowerExpected);
|
||||
while (index !== -1) {
|
||||
candidates.push(index);
|
||||
index = lowerFull.indexOf(lowerExpected, index + 1);
|
||||
}
|
||||
}
|
||||
if (!candidates.length && expected.length > 20) {
|
||||
var partial = expected.substring(0, Math.min(expected.length, 40));
|
||||
index = fullText.indexOf(partial);
|
||||
while (index !== -1) {
|
||||
candidates.push(index);
|
||||
index = fullText.indexOf(partial, index + 1);
|
||||
}
|
||||
}
|
||||
if (!candidates.length) {
|
||||
hlRenderLog(
|
||||
"webview_text_search_skip reason=no_candidates textLen=" + expected.length +
|
||||
" text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
var preferred = locator && locator.startOffset !== undefined && locator.startOffset !== null
|
||||
? parseInt(locator.startOffset, 10)
|
||||
: candidates[0];
|
||||
if (!isFinite(preferred)) preferred = candidates[0];
|
||||
var best = candidates.reduce(function (currentBest, candidate) {
|
||||
return Math.abs(candidate - preferred) < Math.abs(currentBest - preferred) ? candidate : currentBest;
|
||||
}, candidates[0]);
|
||||
hlRenderLog(
|
||||
"webview_text_search_candidate count=" + candidates.length +
|
||||
" preferred=" + preferred + " best=" + best +
|
||||
" text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator)
|
||||
);
|
||||
return this.rangeFromTextOffsets(root, best, best + expected.length);
|
||||
},
|
||||
|
||||
rangeFromLocator: function (locator, text) {
|
||||
if (!locator) {
|
||||
hlRenderLog("webview_locator_skip reason=missing_locator");
|
||||
return null;
|
||||
}
|
||||
if (locator.cfi && String(locator.cfi).charAt(0) === "/") {
|
||||
hlRenderLog(
|
||||
"webview_locator_skip reason=structural_cfi_prefers_cfi " +
|
||||
hlRenderLocatorLabel(locator)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
var root = this.highlightTextRoot();
|
||||
var range = this.rangeFromTextOffsets(root, locator.startOffset, locator.endOffset);
|
||||
if (range && this.rangeMatchesText(range, text)) {
|
||||
hlRenderLog(
|
||||
"webview_locator_result matched=true text='" + hlRenderPreview(range.toString(), 80) + "' " +
|
||||
hlRenderLocatorLabel(locator)
|
||||
);
|
||||
return range;
|
||||
}
|
||||
hlRenderLog(
|
||||
"webview_locator_skip reason=" + (range ? "text_mismatch" : "range_missing") +
|
||||
" rangeText='" + hlRenderPreview(range ? range.toString() : "", 80) + "' " +
|
||||
"expected='" + hlRenderPreview(text || "", 80) + "' " + hlRenderLocatorLabel(locator)
|
||||
);
|
||||
return null;
|
||||
},
|
||||
|
||||
rangeFromCfi: function (cfi, text) {
|
||||
if (!cfi || cfi.indexOf("desktop:") === 0) {
|
||||
hlRenderLog("webview_cfi_skip reason=unsupported cfi=" + hlRenderPreview(cfi || "", 120));
|
||||
return null;
|
||||
}
|
||||
var sourceCfi = String(cfi).split("|")[0];
|
||||
const location = window.getNodeAndOffsetFromCfi(sourceCfi);
|
||||
if (!location || !location.node) {
|
||||
hlRenderLog("webview_cfi_skip reason=missing_location cfi=" + hlRenderPreview(cfi, 120));
|
||||
return null;
|
||||
}
|
||||
|
||||
let startNode = location.node;
|
||||
let startOffset = location.offset;
|
||||
|
||||
if (startNode.nodeType === Node.TEXT_NODE) {
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||
walker.currentNode = startNode;
|
||||
|
||||
while (startNode && startOffset >= startNode.nodeValue.length) {
|
||||
if (startOffset === startNode.nodeValue.length) {
|
||||
const next = walker.nextNode();
|
||||
|
||||
if (next) {
|
||||
startOffset -= startNode.nodeValue.length;
|
||||
startNode = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
startOffset -= startNode.nodeValue.length;
|
||||
startNode = walker.nextNode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) {
|
||||
const nodeVal = startNode.nodeValue;
|
||||
const substring = nodeVal.substring(startOffset, startOffset + text.length);
|
||||
|
||||
if (substring !== text && substring.trim() !== text.trim()) {
|
||||
hlRenderLog(
|
||||
"webview_cfi_text_mismatch cfi=" + hlRenderPreview(cfi, 120) +
|
||||
" startOffset=" + startOffset +
|
||||
" actual='" + hlRenderPreview(substring, 80) + "'" +
|
||||
" expected='" + hlRenderPreview(text, 80) + "'"
|
||||
);
|
||||
console.log("HIGHLIGHT_DEBUG: Text mismatch at CFI. Searching nearby.");
|
||||
const foundIndex = nodeVal.indexOf(text);
|
||||
|
||||
if (foundIndex !== -1) {
|
||||
hlRenderLog(
|
||||
"webview_cfi_text_adjust reason=full_match oldStartOffset=" + startOffset +
|
||||
" newStartOffset=" + foundIndex + " cfi=" + hlRenderPreview(cfi, 120)
|
||||
);
|
||||
startOffset = foundIndex;
|
||||
} else {
|
||||
const partial = text.substring(0, Math.min(text.length, 20));
|
||||
const partialIndex = nodeVal.indexOf(partial);
|
||||
|
||||
if (partialIndex !== -1) {
|
||||
hlRenderLog(
|
||||
"webview_cfi_text_adjust reason=partial_match oldStartOffset=" + startOffset +
|
||||
" newStartOffset=" + partialIndex + " cfi=" + hlRenderPreview(cfi, 120)
|
||||
);
|
||||
startOffset = partialIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!startNode) {
|
||||
hlRenderLog("webview_cfi_skip reason=missing_start_node cfi=" + hlRenderPreview(cfi, 120));
|
||||
return null;
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
|
||||
if (startNode.nodeType === Node.TEXT_NODE && startOffset > startNode.nodeValue.length) {
|
||||
startOffset = Math.max(0, startNode.nodeValue.length - 1);
|
||||
}
|
||||
|
||||
range.setStart(startNode, startOffset);
|
||||
|
||||
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||
treeWalker.currentNode = startNode;
|
||||
|
||||
let remainingLen = text.length;
|
||||
let endNode = treeWalker.currentNode;
|
||||
let endOffset = startOffset;
|
||||
|
||||
while (remainingLen > 0 && endNode) {
|
||||
let avail = endNode.nodeValue.length - endOffset;
|
||||
|
||||
if (avail >= remainingLen) {
|
||||
endOffset += remainingLen;
|
||||
remainingLen = 0;
|
||||
} else {
|
||||
remainingLen -= avail;
|
||||
endNode = treeWalker.nextNode();
|
||||
endOffset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!endNode) {
|
||||
hlRenderLog("webview_cfi_skip reason=missing_end_node cfi=" + hlRenderPreview(cfi, 120));
|
||||
return null;
|
||||
}
|
||||
range.setEnd(endNode, endOffset);
|
||||
if (range.collapsed) {
|
||||
hlRenderLog("webview_cfi_skip reason=collapsed cfi=" + hlRenderPreview(cfi, 120));
|
||||
return null;
|
||||
}
|
||||
hlRenderLog(
|
||||
"webview_cfi_result cfi=" + hlRenderPreview(cfi, 120) +
|
||||
" text='" + hlRenderPreview(range.toString(), 80) + "'"
|
||||
);
|
||||
return range;
|
||||
},
|
||||
|
||||
applyHighlight: function (cfi, text, cssClass, locator) {
|
||||
try {
|
||||
cssClass = cssClass || "user-highlight-yellow";
|
||||
var alreadyApplied = false;
|
||||
var spans = document.querySelectorAll(`span[data-cfi]`);
|
||||
for (var i = 0; i < spans.length; i++) {
|
||||
if ((spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) {
|
||||
if (cfi && (spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) {
|
||||
alreadyApplied = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (alreadyApplied) return;
|
||||
|
||||
const location = window.getNodeAndOffsetFromCfi(cfi);
|
||||
if (!location || !location.node) return;
|
||||
|
||||
let startNode = location.node;
|
||||
let startOffset = location.offset;
|
||||
|
||||
if (startNode.nodeType === Node.TEXT_NODE) {
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||
walker.currentNode = startNode;
|
||||
|
||||
while (startNode && startOffset >= startNode.nodeValue.length) {
|
||||
if (startOffset === startNode.nodeValue.length) {
|
||||
const next = walker.nextNode();
|
||||
|
||||
if (next) {
|
||||
startOffset -= startNode.nodeValue.length;
|
||||
startNode = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
startOffset -= startNode.nodeValue.length;
|
||||
startNode = walker.nextNode();
|
||||
}
|
||||
}
|
||||
if (alreadyApplied) {
|
||||
hlRenderLog(
|
||||
"webview_apply_skip reason=already_applied cfi=" + hlRenderPreview(cfi || "", 120) +
|
||||
" textLen=" + String(text || "").length + " " + hlRenderLocatorLabel(locator)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Text Verification / Healing
|
||||
if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) {
|
||||
const nodeVal = startNode.nodeValue;
|
||||
// Check if text matches at exact offset
|
||||
const substring = nodeVal.substring(startOffset, startOffset + text.length);
|
||||
|
||||
// Allow for some whitespace looseness (trim comparison)
|
||||
if (substring !== text && substring.trim() !== text.trim()) {
|
||||
console.log(`$ {
|
||||
HL_LOG_TAG
|
||||
}
|
||||
|
||||
: Text mismatch at CFI. Searching nearby... Expected: '${text.substring(0, 10)}...', Found: '${substring.substring(0, 10)}...' `);
|
||||
|
||||
// Try finding the text in the whole node
|
||||
const foundIndex = nodeVal.indexOf(text);
|
||||
|
||||
if (foundIndex !== -1) {
|
||||
console.log(`$ {
|
||||
HL_LOG_TAG
|
||||
}
|
||||
|
||||
: Found text elsewhere in node. Adjusting offset from $ {
|
||||
startOffset
|
||||
}
|
||||
|
||||
to $ {
|
||||
foundIndex
|
||||
}
|
||||
|
||||
.`);
|
||||
startOffset = foundIndex;
|
||||
} else {
|
||||
// Simple fuzzy: Try finding first 20 chars
|
||||
const partial = text.substring(0, Math.min(text.length, 20));
|
||||
const partialIndex = nodeVal.indexOf(partial);
|
||||
|
||||
if (partialIndex !== -1) {
|
||||
console.log(`$ {
|
||||
HL_LOG_TAG
|
||||
}
|
||||
|
||||
: Found partial match. Adjusting offset.`);
|
||||
startOffset = partialIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
var hasPreciseLocator = locator &&
|
||||
locator.startOffset !== undefined && locator.startOffset !== null &&
|
||||
locator.endOffset !== undefined && locator.endOffset !== null &&
|
||||
parseInt(locator.endOffset, 10) > parseInt(locator.startOffset, 10);
|
||||
hlRenderLog(
|
||||
"webview_apply_start cfi=" + hlRenderPreview(cfi || "", 120) +
|
||||
" textLen=" + String(text || "").length +
|
||||
" cssClass=" + cssClass +
|
||||
" hasPreciseLocator=" + !!hasPreciseLocator + " " +
|
||||
hlRenderLocatorLabel(locator)
|
||||
);
|
||||
var rangeSource = "locator";
|
||||
var sourceCfi = (locator && locator.cfi) || cfi;
|
||||
var hasSourceCfi = sourceCfi && String(sourceCfi).charAt(0) === "/";
|
||||
var range = hasSourceCfi ? null : this.rangeFromLocator(locator, text);
|
||||
if (!range && hasSourceCfi) {
|
||||
rangeSource = "cfi";
|
||||
range = this.rangeFromCfi(sourceCfi, text || "");
|
||||
}
|
||||
|
||||
if (!startNode) return;
|
||||
|
||||
const range = document.createRange();
|
||||
|
||||
// Set Start
|
||||
if (startNode.nodeType === Node.TEXT_NODE) {
|
||||
// Ensure offset is valid
|
||||
if (startOffset > startNode.nodeValue.length) {
|
||||
startOffset = Math.max(0, startNode.nodeValue.length - 1);
|
||||
}
|
||||
if (!range) {
|
||||
rangeSource = "locator";
|
||||
range = this.rangeFromLocator(locator, text);
|
||||
}
|
||||
|
||||
range.setStart(startNode, startOffset);
|
||||
|
||||
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||
treeWalker.currentNode = startNode;
|
||||
|
||||
let currentNode = treeWalker.currentNode;
|
||||
let remainingOffset = startOffset;
|
||||
let remainingLen = text.length;
|
||||
let endNode = currentNode;
|
||||
let endOffset = startOffset;
|
||||
|
||||
while (remainingLen > 0 && endNode) {
|
||||
let avail = endNode.nodeValue.length - endOffset;
|
||||
|
||||
if (avail >= remainingLen) {
|
||||
endOffset += remainingLen;
|
||||
remainingLen = 0;
|
||||
} else {
|
||||
remainingLen -= avail;
|
||||
endNode = treeWalker.nextNode();
|
||||
endOffset = 0;
|
||||
}
|
||||
if (!range && !hasPreciseLocator && !hasSourceCfi) {
|
||||
rangeSource = "text_search";
|
||||
range = this.rangeFromVisibleTextSearch(text || "", locator);
|
||||
}
|
||||
|
||||
if (endNode) {
|
||||
range.setEnd(endNode, endOffset);
|
||||
var normalizedRange = this.normalizeRangeBoundaries(range);
|
||||
this.highlightRangeSafe(normalizedRange, cssClass, cfi);
|
||||
if (!range) {
|
||||
hlRenderLog(
|
||||
"webview_apply_skip reason=no_range cfi=" + hlRenderPreview(cfi || "", 120) +
|
||||
" hasPreciseLocator=" + !!hasPreciseLocator + " " + hlRenderLocatorLabel(locator)
|
||||
);
|
||||
return;
|
||||
}
|
||||
var normalizedRange = this.normalizeRangeBoundaries(range);
|
||||
this.highlightRangeSafe(normalizedRange, cssClass, cfi);
|
||||
hlRenderLog(
|
||||
"webview_apply_result applied=true cfi=" + hlRenderPreview(cfi || "", 120) +
|
||||
" source=" + rangeSource +
|
||||
" renderedText='" + hlRenderPreview(normalizedRange.toString(), 80) + "'"
|
||||
);
|
||||
} catch (e) {
|
||||
hlRenderLog("webview_apply_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
package com.aryan.reader.opds
|
||||
|
||||
typealias OpdsParser = com.aryan.reader.shared.opds.SharedOpdsParser
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
||||
import timber.log.Timber
|
||||
|
||||
class AndroidFolderPathResolver : FolderPathResolver {
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
import com.aryan.reader.shared.SharedSettingsHubInput
|
||||
import com.aryan.reader.shared.SharedSettingsPlatform
|
||||
import org.dueattendant149.bookreader.shared.SharedFeaturePolicy
|
||||
import org.dueattendant149.bookreader.shared.SharedSettingsHubInput
|
||||
import org.dueattendant149.bookreader.shared.SharedSettingsPlatform
|
||||
|
||||
fun androidSettingsHubInput(
|
||||
uiState: ReaderScreenState,
|
||||
|
|
@ -15,6 +15,8 @@ fun androidSettingsHubInput(
|
|||
val supportsOssAiKeys = isOssBuild && !isOfflineBuild
|
||||
val featurePolicy = if (isOfflineBuild) {
|
||||
SharedFeaturePolicy.OssOffline
|
||||
} else if (isOssBuild) {
|
||||
SharedFeaturePolicy.OssOnline
|
||||
} else {
|
||||
SharedFeaturePolicy.Standard
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.TagEntity
|
||||
import com.aryan.reader.shared.AppAction as SharedAppAction
|
||||
import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
|
||||
import com.aryan.reader.shared.SharedFolderPathResolver
|
||||
import com.aryan.reader.shared.SharedLibraryProjectionInput
|
||||
import com.aryan.reader.shared.SharedLibraryStateProjector
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.reduce
|
||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
||||
import org.dueattendant149.bookreader.data.TagEntity
|
||||
import org.dueattendant149.bookreader.shared.AppAction as SharedAppAction
|
||||
import org.dueattendant149.bookreader.shared.LibraryAction as SharedLibraryAction
|
||||
import org.dueattendant149.bookreader.shared.SharedFolderPathResolver
|
||||
import org.dueattendant149.bookreader.shared.SharedLibraryProjectionInput
|
||||
import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector
|
||||
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
||||
import org.dueattendant149.bookreader.shared.reduce
|
||||
|
||||
internal object AndroidSharedStateBridge {
|
||||
fun prepareLibraryProjection(
|
||||
|
|
@ -23,7 +23,8 @@ internal object AndroidSharedStateBridge {
|
|||
val sharedInput = SharedLibraryProjectionInput(
|
||||
state = projectionState.toSharedReaderScreenState(
|
||||
rawBooks = taggedBooks,
|
||||
dbTags = input.dbTags
|
||||
dbTags = input.dbTags,
|
||||
includeReaderAnnotations = false
|
||||
),
|
||||
booksFromStore = taggedBooks
|
||||
.filterNot { it.bookId.endsWith("_reflow") }
|
||||
|
|
@ -195,7 +196,8 @@ internal object AndroidSharedStateBridge {
|
|||
private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState {
|
||||
return toSharedReaderScreenState(
|
||||
rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles },
|
||||
dbTags = projectedState.allTags.ifEmpty { allTags }
|
||||
dbTags = projectedState.allTags.ifEmpty { allTags },
|
||||
includeReaderAnnotations = false
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import com.aryan.reader.data.CustomFontEntity
|
||||
import org.dueattendant149.bookreader.data.CustomFontEntity
|
||||
import java.io.File
|
||||
|
||||
fun AppFontPreference.toAndroidAppFontFamily(customFonts: List<CustomFontEntity>): FontFamily? {
|
||||
|
|
@ -17,10 +17,11 @@
|
|||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.os.Build
|
||||
import timber.log.Timber
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
|
|
@ -58,14 +59,14 @@ import androidx.navigation.NavHostController
|
|||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.aryan.reader.epubreader.EpubReaderScreen
|
||||
import com.aryan.reader.feedback.FeedbackScreen
|
||||
import com.aryan.reader.feedback.SupportProjectScreen
|
||||
import com.aryan.reader.pdf.PdfViewerScreen
|
||||
import com.aryan.reader.shared.ReaderFeatureSurface
|
||||
import com.aryan.reader.tts.ReaderTtsMiniBar
|
||||
import com.aryan.reader.tts.readerTtsMiniBarBottomPaddingDp
|
||||
import com.aryan.reader.tts.shouldShowReaderTtsMiniBar
|
||||
import org.dueattendant149.bookreader.epubreader.EpubReaderScreen
|
||||
import org.dueattendant149.bookreader.feedback.FeedbackScreen
|
||||
import org.dueattendant149.bookreader.feedback.SupportProjectScreen
|
||||
import org.dueattendant149.bookreader.pdf.PdfViewerScreen
|
||||
import org.dueattendant149.bookreader.shared.ReaderFeatureSurface
|
||||
import org.dueattendant149.bookreader.tts.ReaderTtsMiniBar
|
||||
import org.dueattendant149.bookreader.tts.readerTtsMiniBarBottomPaddingDp
|
||||
import org.dueattendant149.bookreader.tts.shouldShowReaderTtsMiniBar
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
object AppDestinations {
|
||||
|
|
@ -80,6 +81,18 @@ object AppDestinations {
|
|||
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
|
||||
}
|
||||
|
||||
fun shouldInterceptAppNavBack(
|
||||
currentRoute: String?,
|
||||
hasPreviousBackStackEntry: Boolean,
|
||||
isCurrentEntryResumed: Boolean
|
||||
): Boolean {
|
||||
if (!hasPreviousBackStackEntry || !isCurrentEntryResumed) return false
|
||||
return currentRoute != null &&
|
||||
currentRoute != AppDestinations.MAIN_ROUTE &&
|
||||
currentRoute != AppDestinations.PDF_VIEWER_ROUTE &&
|
||||
currentRoute != AppDestinations.EPUB_READER_ROUTE
|
||||
}
|
||||
|
||||
private fun NavHostController.isReadyForBackStackChange(): Boolean {
|
||||
return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
|
||||
}
|
||||
|
|
@ -166,6 +179,11 @@ fun AppNavigation(
|
|||
val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp(
|
||||
isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE
|
||||
).dp
|
||||
val shouldInterceptBack = shouldInterceptAppNavBack(
|
||||
currentRoute = currentRoute,
|
||||
hasPreviousBackStackEntry = navController.previousBackStackEntry != null,
|
||||
isCurrentEntryResumed = currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
|
||||
)
|
||||
|
||||
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
||||
if (!uiState.isLoading) {
|
||||
|
|
@ -195,14 +213,24 @@ fun AppNavigation(
|
|||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
BackHandler(enabled = shouldInterceptBack) {
|
||||
navController.popBackStackIfReady()
|
||||
}
|
||||
|
||||
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
|
||||
composable(AppDestinations.MAIN_ROUTE) {
|
||||
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
|
||||
MainScreen(
|
||||
viewModel = viewModel,
|
||||
windowSizeClass = windowSizeClass,
|
||||
navController = navController
|
||||
)
|
||||
if (uiState.isTemporaryExternalOpen) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
MainScreen(
|
||||
viewModel = viewModel,
|
||||
windowSizeClass = windowSizeClass,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Viewer Screen Composable
|
||||
|
|
@ -315,7 +343,7 @@ fun AppNavigation(
|
|||
},
|
||||
onRenderModeChange = viewModel::setRenderMode,
|
||||
customFonts = customFonts,
|
||||
onImportFont = viewModel::importFont,
|
||||
onImportFonts = viewModel::importFonts,
|
||||
viewModel = viewModel
|
||||
)
|
||||
|
||||
|
|
@ -1,20 +1,20 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.net.Uri
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.TagEntity
|
||||
import com.aryan.reader.epub.CalibreBundleResult
|
||||
import com.aryan.reader.epub.EpubBook
|
||||
import com.aryan.reader.paginatedreader.Locator
|
||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
||||
import org.dueattendant149.bookreader.data.TagEntity
|
||||
import org.dueattendant149.bookreader.epub.CalibreBundleResult
|
||||
import org.dueattendant149.bookreader.epub.EpubBook
|
||||
import org.dueattendant149.bookreader.paginatedreader.Locator
|
||||
import java.util.Date
|
||||
|
||||
typealias BannerMessage = com.aryan.reader.shared.BannerMessage
|
||||
typealias UserData = com.aryan.reader.shared.UserData
|
||||
typealias AppThemeMode = com.aryan.reader.shared.AppThemeMode
|
||||
typealias AppContrastOption = com.aryan.reader.shared.AppContrastOption
|
||||
typealias AppFontPreference = com.aryan.reader.shared.AppFontPreference
|
||||
typealias AppFontPreferenceKind = com.aryan.reader.shared.AppFontPreferenceKind
|
||||
typealias CustomAppTheme = com.aryan.reader.shared.CustomAppTheme
|
||||
typealias BannerMessage = org.dueattendant149.bookreader.shared.BannerMessage
|
||||
typealias UserData = org.dueattendant149.bookreader.shared.UserData
|
||||
typealias AppThemeMode = org.dueattendant149.bookreader.shared.AppThemeMode
|
||||
typealias AppContrastOption = org.dueattendant149.bookreader.shared.AppContrastOption
|
||||
typealias AppFontPreference = org.dueattendant149.bookreader.shared.AppFontPreference
|
||||
typealias AppFontPreferenceKind = org.dueattendant149.bookreader.shared.AppFontPreferenceKind
|
||||
typealias CustomAppTheme = org.dueattendant149.bookreader.shared.CustomAppTheme
|
||||
|
||||
data class ImportResult(
|
||||
val internalUri: Uri,
|
||||
|
|
@ -43,6 +43,7 @@ data class ReaderScreenState(
|
|||
val selectedEpubUri: Uri? = null,
|
||||
val selectedFileType: FileType? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val isTemporaryExternalOpen: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val contextualActionItems: Set<RecentFileItem> = emptySet(),
|
||||
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
|
|
@ -29,7 +29,7 @@ import java.io.FileOutputStream
|
|||
import java.io.InputStream
|
||||
import java.util.UUID
|
||||
import androidx.core.net.toUri
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||
|
||||
private const val BOOKS_DIR = "books"
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()}"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 == "[]"
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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})"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// Common.kt
|
||||
@file:OptIn(ExperimentalMaterial3Api::class) @file:Suppress("KotlinConstantConditions")
|
||||
|
||||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
|
|
@ -138,6 +138,7 @@ import androidx.compose.ui.graphics.luminance
|
|||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
|
|
@ -165,23 +166,23 @@ import androidx.compose.ui.window.PopupProperties
|
|||
import androidx.core.content.edit
|
||||
import androidx.core.graphics.toColorInt
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.aryan.reader.epubreader.PREF_CUSTOM_THEMES
|
||||
import com.aryan.reader.epubreader.PREF_READER_THEME
|
||||
import com.aryan.reader.paginatedreader.TtsChunk
|
||||
import com.aryan.reader.pdf.PdfHighlightColor
|
||||
import com.aryan.reader.shared.BuiltInReaderThemes
|
||||
import com.aryan.reader.shared.ReaderTextureFilePrefix
|
||||
import com.aryan.reader.shared.normalizeReaderTextureExtension
|
||||
import com.aryan.reader.shared.readerTextureDisplayName as sharedReaderTextureDisplayName
|
||||
import com.aryan.reader.shared.readerTextureMimeTypeForExtension
|
||||
import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
|
||||
import com.aryan.reader.tts.SpeakerSamplePlayer
|
||||
import com.aryan.reader.tts.TtsCacheManager
|
||||
import com.aryan.reader.tts.TtsPlaybackManager
|
||||
import com.aryan.reader.tts.formatBytes
|
||||
import com.aryan.reader.tts.loadTtsMode
|
||||
import com.aryan.reader.tts.rememberTtsController
|
||||
import com.aryan.reader.tts.splitTextIntoChunks
|
||||
import org.dueattendant149.bookreader.epubreader.PREF_CUSTOM_THEMES
|
||||
import org.dueattendant149.bookreader.epubreader.PREF_READER_THEME
|
||||
import org.dueattendant149.bookreader.paginatedreader.TtsChunk
|
||||
import org.dueattendant149.bookreader.pdf.PdfHighlightColor
|
||||
import org.dueattendant149.bookreader.shared.BuiltInReaderThemes
|
||||
import org.dueattendant149.bookreader.shared.ReaderTextureFilePrefix
|
||||
import org.dueattendant149.bookreader.shared.normalizeReaderTextureExtension
|
||||
import org.dueattendant149.bookreader.shared.readerTextureDisplayName as sharedReaderTextureDisplayName
|
||||
import org.dueattendant149.bookreader.shared.readerTextureMimeTypeForExtension
|
||||
import org.dueattendant149.bookreader.tts.GEMINI_TTS_SPEAKERS
|
||||
import org.dueattendant149.bookreader.tts.SpeakerSamplePlayer
|
||||
import org.dueattendant149.bookreader.tts.TtsCacheManager
|
||||
import org.dueattendant149.bookreader.tts.TtsPlaybackManager
|
||||
import org.dueattendant149.bookreader.tts.formatBytes
|
||||
import org.dueattendant149.bookreader.tts.loadTtsMode
|
||||
import org.dueattendant149.bookreader.tts.rememberTtsController
|
||||
import org.dueattendant149.bookreader.tts.splitTextIntoChunks
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -217,8 +218,8 @@ import kotlin.math.min
|
|||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
typealias ReaderTexture = com.aryan.reader.shared.ReaderTexture
|
||||
typealias ReaderTheme = com.aryan.reader.shared.ReaderTheme
|
||||
typealias ReaderTexture = org.dueattendant149.bookreader.shared.ReaderTexture
|
||||
typealias ReaderTheme = org.dueattendant149.bookreader.shared.ReaderTheme
|
||||
|
||||
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
|
||||
const val summarizeEndpoint = "/summarize"
|
||||
|
|
@ -473,9 +474,9 @@ data class SearchResult(
|
|||
val chunkIndex: Int
|
||||
)
|
||||
|
||||
typealias AiDefinitionResult = com.aryan.reader.shared.AiDefinitionResult
|
||||
typealias AiDefinitionResult = org.dueattendant149.bookreader.shared.AiDefinitionResult
|
||||
|
||||
typealias SummarizationResult = com.aryan.reader.shared.SummarizationResult
|
||||
typealias SummarizationResult = org.dueattendant149.bookreader.shared.SummarizationResult
|
||||
|
||||
data class CachedSummaryItem(
|
||||
val chapterIndex: Int,
|
||||
|
|
@ -862,6 +863,13 @@ fun AiDefinitionPopup(
|
|||
val ttsController = rememberTtsController()
|
||||
val ttsState by ttsController.ttsState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val maxPopupHeight = readerModalMaxHeightDp(
|
||||
screenHeightDp = configuration.screenHeightDp,
|
||||
fraction = 0.65f,
|
||||
verticalMarginDp = 48,
|
||||
preferredMinHeightDp = 180
|
||||
).dp
|
||||
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
|
@ -882,7 +890,7 @@ fun AiDefinitionPopup(
|
|||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 5.dp)
|
||||
.heightIn(min = 150.dp, max = 400.dp),
|
||||
.heightIn(max = maxPopupHeight),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
) {
|
||||
|
|
@ -2949,7 +2957,14 @@ private fun ThemeGridItem(
|
|||
Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
text = theme.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable { onThemeSelected(theme.id) }
|
||||
)
|
||||
|
||||
if (theme.isCustom && onEdit != null && onDelete != null) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
|
@ -3303,12 +3318,16 @@ fun ThemeColorPickerDialog(
|
|||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color(0xFF2C2C2C),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.padding(16.dp)
|
||||
.heightIn(max = maxDialogHeight)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -3488,12 +3507,16 @@ fun HighlightColorPickerDialog(
|
|||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color(0xFF2C2C2C),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.padding(16.dp)
|
||||
.heightIn(max = maxDialogHeight)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -3822,7 +3845,7 @@ fun AiResultContentView(
|
|||
result: SummarizationResult?,
|
||||
isLoading: Boolean,
|
||||
isMainTtsActive: Boolean,
|
||||
ttsController: com.aryan.reader.tts.TtsController,
|
||||
ttsController: org.dueattendant149.bookreader.tts.TtsController,
|
||||
ttsState: TtsPlaybackManager.TtsState,
|
||||
getAuthToken: suspend () -> String?,
|
||||
onRegenerate: (() -> Unit)? = null,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.util.Xml
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.aryan.reader.data.BookMetadataEdit
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.shared.reader.SharedEpubMetadataEditor
|
||||
import com.aryan.reader.shared.reader.SharedEpubMetadataSnapshot
|
||||
import com.aryan.reader.shared.reader.SharedEpubMetadataUpdate
|
||||
import org.dueattendant149.bookreader.data.BookMetadataEdit
|
||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataEditor
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataSnapshot
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataUpdate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
|
@ -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()
|
||||
|
|
@ -17,10 +17,11 @@
|
|||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
|
||||
|
|
@ -50,9 +51,8 @@ object FileHasher {
|
|||
}
|
||||
hexString.toString()
|
||||
} catch (e: Exception) {
|
||||
// In a real app, you'd want to log this error
|
||||
e.printStackTrace()
|
||||
Timber.e(e, "Failed to calculate SHA-256 hash")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||
|
||||
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
|
||||
return SharedFileCapabilities.resolveFileTypeForName(fileName)
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
// FolderSyncWorker.kt
|
||||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.content.Context
|
||||
import timber.log.Timber
|
||||
|
|
@ -29,25 +29,25 @@ import androidx.work.ExistingWorkPolicy
|
|||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.WorkManager
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.RecentFilesRepository
|
||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
||||
import org.dueattendant149.bookreader.data.RecentFilesRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.core.content.edit
|
||||
import com.aryan.reader.data.LocalSyncUtils
|
||||
import com.aryan.reader.data.FolderBookMetadata
|
||||
import com.aryan.reader.data.toSharedFolderBookMetadata
|
||||
import com.aryan.reader.shared.BookItem as SharedBookItem
|
||||
import com.aryan.reader.shared.EpubAnnotationSerializer
|
||||
import com.aryan.reader.shared.EpubBookmark
|
||||
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||
import com.aryan.reader.shared.LocalFolderSyncEngine
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.SharedFolderScannedFile
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import org.dueattendant149.bookreader.data.LocalSyncUtils
|
||||
import org.dueattendant149.bookreader.data.FolderBookMetadata
|
||||
import org.dueattendant149.bookreader.data.toSharedFolderBookMetadata
|
||||
import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem
|
||||
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
|
||||
import org.dueattendant149.bookreader.shared.EpubBookmark
|
||||
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||
import org.dueattendant149.bookreader.shared.LocalFolderSyncEngine
|
||||
import org.dueattendant149.bookreader.shared.ReaderLocator
|
||||
import org.dueattendant149.bookreader.shared.SharedFolderScannedFile
|
||||
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderBookmark
|
||||
import java.io.File
|
||||
import android.provider.DocumentsContract
|
||||
|
||||
|
|
@ -72,45 +72,27 @@ class FolderSyncWorker(
|
|||
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
|
||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
val jsonString = prefs.getString("synced_folders_list_json", null)
|
||||
val folders = mutableListOf<Pair<String, Set<FileType>>>()
|
||||
|
||||
if (jsonString != null) {
|
||||
try {
|
||||
val array = org.json.JSONArray(jsonString)
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.getJSONObject(i)
|
||||
val uri = obj.getString("uri")
|
||||
val allowedFileTypes = mutableSetOf<FileType>()
|
||||
if (obj.has("allowedFileTypes")) {
|
||||
val typesArray = obj.getJSONArray("allowedFileTypes")
|
||||
for (j in 0 until typesArray.length()) {
|
||||
try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
|
||||
}
|
||||
} else {
|
||||
allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES)
|
||||
}
|
||||
folders.add(Pair(uri, allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }))
|
||||
}
|
||||
} catch (e: Exception) { Timber.e(e) }
|
||||
} else {
|
||||
val single = prefs.getString("synced_folder_uri", null)
|
||||
if (single != null) folders.add(Pair(single, ANDROID_SYNCABLE_FILE_TYPES))
|
||||
}
|
||||
val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null)
|
||||
val folders = SyncedFolderPrefs.decodeSyncedFolders(
|
||||
jsonString = jsonString,
|
||||
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
|
||||
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
|
||||
)
|
||||
|
||||
if (folders.isEmpty()) {
|
||||
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val enabledFolders = folders.filter { it.localSyncEnabled }
|
||||
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
|
||||
folders
|
||||
enabledFolders
|
||||
} else {
|
||||
folders.filter { it.first == targetFolderUri }
|
||||
enabledFolders.filter { it.uriString == targetFolderUri }
|
||||
}
|
||||
|
||||
if (foldersToProcess.isEmpty()) {
|
||||
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked target=$targetFolderUri")
|
||||
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
|
|
@ -123,8 +105,8 @@ class FolderSyncWorker(
|
|||
syncMutex.withLock {
|
||||
var allSuccess = true
|
||||
|
||||
for ((uriString, allowedTypes) in foldersToProcess) {
|
||||
val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
|
||||
for (folderConfig in foldersToProcess) {
|
||||
val success = performSyncForFolder(folderConfig, isMetadataOnly)
|
||||
if (!success) allSuccess = false
|
||||
}
|
||||
|
||||
|
|
@ -132,13 +114,14 @@ class FolderSyncWorker(
|
|||
try {
|
||||
val array = org.json.JSONArray(jsonString)
|
||||
val now = System.currentTimeMillis()
|
||||
val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString }
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.getJSONObject(i)
|
||||
if (targetFolderUri.isNullOrBlank() || obj.optString("uri") == targetFolderUri) {
|
||||
if (obj.optString("uri") in processedUris) {
|
||||
obj.put("lastScanTime", now)
|
||||
}
|
||||
}
|
||||
prefs.edit { putString("synced_folders_list_json", array.toString()) }
|
||||
prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) }
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +136,9 @@ class FolderSyncWorker(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean {
|
||||
private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean {
|
||||
val folderUriString = folderConfig.uriString
|
||||
val allowedFileTypes = folderConfig.allowedFileTypes
|
||||
if (folderUriString.isBlank()) return true
|
||||
val folderUri = folderUriString.toUri()
|
||||
val folderStart = ReaderPerfLog.nowNanos()
|
||||
|
|
@ -235,9 +220,10 @@ class FolderSyncWorker(
|
|||
val nowMillis = System.currentTimeMillis()
|
||||
val folder = SyncedFolder(
|
||||
uriString = folderUriString,
|
||||
name = documentTree.name ?: "Local Folder",
|
||||
name = documentTree.name ?: folderConfig.name,
|
||||
lastScanTime = nowMillis,
|
||||
allowedFileTypes = allowedFileTypes
|
||||
allowedFileTypes = allowedFileTypes,
|
||||
localSyncEnabled = true
|
||||
)
|
||||
val sharedState = SharedReaderScreenState(
|
||||
rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
|
||||
|
|
@ -564,7 +550,8 @@ class FolderSyncWorker(
|
|||
lastPageIndex = lastPage,
|
||||
readerPosition = readerPositionOrNull(),
|
||||
readerBookmarks = parseReaderBookmarks(),
|
||||
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
|
||||
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson),
|
||||
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -613,8 +600,8 @@ class FolderSyncWorker(
|
|||
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
|
||||
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
|
||||
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
|
||||
locatorBlockIndex = appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
|
||||
locatorCharOffset = appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
|
||||
locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
|
||||
locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent,
|
||||
isAvailable = true,
|
||||
|
|
@ -637,6 +624,7 @@ class FolderSyncWorker(
|
|||
originalDescription = originalDescription,
|
||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
|
||||
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
|
||||
tags = existing?.tags.orEmpty()
|
||||
)
|
||||
}
|
||||
|
|
@ -720,18 +708,12 @@ class FolderSyncWorker(
|
|||
|
||||
private fun isFolderStillLinked(folderUriString: String): Boolean {
|
||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||
val jsonString = prefs.getString("synced_folders_list_json", null)
|
||||
if (jsonString != null) {
|
||||
return try {
|
||||
val array = org.json.JSONArray(jsonString)
|
||||
(0 until array.length()).any { index ->
|
||||
array.getJSONObject(index).optString("uri") == folderUriString
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
return prefs.getString("synced_folder_uri", null) == folderUriString
|
||||
return SyncedFolderPrefs.isLocalSyncEnabled(
|
||||
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
|
||||
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
|
||||
folderUriString = folderUriString,
|
||||
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
|
||||
)
|
||||
}
|
||||
|
||||
private fun getFileType(name: String, mimeType: String?): FileType? {
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
// FontsScreen.kt
|
||||
@file:Suppress("KotlinConstantConditions")
|
||||
|
||||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -27,10 +30,12 @@ import androidx.compose.material.icons.filled.Check
|
|||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
|
|
@ -46,6 +51,7 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -58,14 +64,21 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
import com.aryan.reader.shared.ui.SharedAppFontSelector
|
||||
import com.aryan.reader.shared.ui.SharedFontSettingsSection
|
||||
import com.aryan.reader.shared.ui.SharedFontSettingsTabs
|
||||
import com.aryan.reader.data.CustomFontEntity
|
||||
import org.dueattendant149.bookreader.shared.CustomFontItem
|
||||
import org.dueattendant149.bookreader.shared.CustomFontFamilyItem
|
||||
import org.dueattendant149.bookreader.shared.CustomFontVariantItem
|
||||
import org.dueattendant149.bookreader.shared.fontFaceLabel
|
||||
import org.dueattendant149.bookreader.shared.fontFaceSummary
|
||||
import org.dueattendant149.bookreader.shared.groupByFamily
|
||||
import org.dueattendant149.bookreader.shared.hasVariableWeightFace
|
||||
import org.dueattendant149.bookreader.shared.ui.SharedAppFontSelector
|
||||
import org.dueattendant149.bookreader.shared.ui.SharedFontSettingsSection
|
||||
import org.dueattendant149.bookreader.shared.ui.SharedFontSettingsTabs
|
||||
import org.dueattendant149.bookreader.data.CustomFontEntity
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -80,37 +93,68 @@ fun FontsScreen(
|
|||
|
||||
val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE)
|
||||
|
||||
// Dialog state
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) }
|
||||
var fontsPendingDelete by remember { mutableStateOf<List<CustomFontEntity>>(emptyList()) }
|
||||
var showGoogleFontsSheet by remember { mutableStateOf(false) }
|
||||
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
|
||||
var selectedFontIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
|
||||
val pickFontLauncher = rememberFilePickerLauncher { uris ->
|
||||
uris.firstOrNull()?.let { viewModel.importFont(it) }
|
||||
val pickFontLauncher = rememberFilePickerLauncher(viewModel::importFonts)
|
||||
val fontMimeTypes = remember { supportedFontMimeTypes() }
|
||||
val allFontIds = remember(fonts) { fonts.mapTo(mutableSetOf()) { it.id } }
|
||||
val selectedFonts = remember(fonts, selectedFontIds) {
|
||||
fonts.filter { it.id in selectedFontIds }
|
||||
}
|
||||
val fontEntitiesById = remember(fonts) { fonts.associateBy { it.id } }
|
||||
val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty()
|
||||
|
||||
LaunchedEffect(fonts) {
|
||||
selectedFontIds = selectedFontIds.intersect(allFontIds)
|
||||
}
|
||||
|
||||
val fontMimeTypes = arrayOf(
|
||||
"font/ttf", "font/otf", "font/woff2",
|
||||
"application/x-font-ttf", "application/x-font-otf",
|
||||
"application/font-woff2", "application/vnd.ms-opentype",
|
||||
"application/x-font-opentype"
|
||||
)
|
||||
BackHandler(enabled = isFontSelectionMode) {
|
||||
selectedFontIds = emptySet()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
topBar = {
|
||||
CustomTopAppBar(
|
||||
title = { Text(stringResource(R.string.custom_fonts)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||
if (isFontSelectionMode) {
|
||||
ContextualTopAppBar(
|
||||
selectedItemCount = selectedFonts.size,
|
||||
onNavIconClick = { selectedFontIds = emptySet() },
|
||||
onSelectAllClick = {
|
||||
selectedFontIds = if (selectedFontIds.containsAll(allFontIds)) {
|
||||
emptySet()
|
||||
} else {
|
||||
allFontIds
|
||||
}
|
||||
},
|
||||
onDeleteClick = {
|
||||
if (selectedFonts.isNotEmpty()) {
|
||||
fontsPendingDelete = selectedFonts
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
CustomTopAppBar(
|
||||
title = { Text(stringResource(R.string.custom_fonts)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
|
||||
IconButton(onClick = { selectedFontIds = allFontIds }) {
|
||||
Icon(Icons.Default.SelectAll, contentDescription = stringResource(R.string.select_all))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
|
||||
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty() && !isFontSelectionMode) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
|
|
@ -136,10 +180,14 @@ fun FontsScreen(
|
|||
) { padding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
|
||||
val fontFamilies = remember(sharedFonts) { sharedFonts.groupByFamily() }
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
SharedFontSettingsTabs(
|
||||
selectedSection = selectedSection,
|
||||
onSectionChange = { selectedSection = it },
|
||||
onSectionChange = {
|
||||
selectedFontIds = emptySet()
|
||||
selectedSection = it
|
||||
},
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
|
||||
)
|
||||
|
||||
|
|
@ -163,12 +211,20 @@ fun FontsScreen(
|
|||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(fonts, key = { it.id }) { font ->
|
||||
FontListItem(
|
||||
font = font,
|
||||
onDelete = {
|
||||
fontToDelete = font
|
||||
showDeleteDialog = true
|
||||
items(fontFamilies, key = { family -> family.variants.joinToString("|") { it.font.id } }) { family ->
|
||||
FontFamilyListItem(
|
||||
family = family,
|
||||
selectedFontIds = selectedFontIds,
|
||||
isSelectionMode = isFontSelectionMode,
|
||||
fontEntityForId = { id -> fontEntitiesById[id] },
|
||||
onVariantSelectionToggle = { id ->
|
||||
selectedFontIds = selectedFontIds.toggle(id)
|
||||
},
|
||||
onFamilySelectionToggle = {
|
||||
selectedFontIds = selectedFontIds.toggleAll(family.variants.map { it.font.id })
|
||||
},
|
||||
onDeleteVariant = { id ->
|
||||
fontEntitiesById[id]?.let { fontsPendingDelete = listOf(it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -208,17 +264,17 @@ fun FontsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (showDeleteDialog && fontToDelete != null) {
|
||||
DeleteFontConfirmationDialog(
|
||||
fontName = fontToDelete!!.displayName,
|
||||
if (fontsPendingDelete.isNotEmpty()) {
|
||||
DeleteFontsConfirmationDialog(
|
||||
fonts = fontsPendingDelete,
|
||||
onConfirm = {
|
||||
fontToDelete?.let { viewModel.deleteFont(it.id) }
|
||||
showDeleteDialog = false
|
||||
fontToDelete = null
|
||||
val pendingIds = fontsPendingDelete.map { it.id }
|
||||
viewModel.deleteFonts(pendingIds)
|
||||
selectedFontIds = selectedFontIds - pendingIds.toSet()
|
||||
fontsPendingDelete = emptyList()
|
||||
},
|
||||
onDismiss = {
|
||||
showDeleteDialog = false
|
||||
fontToDelete = null
|
||||
fontsPendingDelete = emptyList()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -388,9 +444,13 @@ fun GoogleFontsBottomSheet(
|
|||
}
|
||||
|
||||
// Existing unchanged components
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun FontListItem(
|
||||
font: CustomFontEntity,
|
||||
isSelected: Boolean,
|
||||
isSelectionMode: Boolean,
|
||||
onSelectionToggle: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val customTypeface = remember(font.path) {
|
||||
|
|
@ -402,8 +462,23 @@ fun FontListItem(
|
|||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (isSelectionMode) {
|
||||
onSelectionToggle()
|
||||
}
|
||||
},
|
||||
onLongClick = onSelectionToggle
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
|
|
@ -411,17 +486,27 @@ fun FontListItem(
|
|||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isSelectionMode) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onSelectionToggle() },
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = font.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.action_delete),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
if (!isSelectionMode) {
|
||||
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.action_delete),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -459,6 +544,188 @@ fun FontListItem(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun FontFamilyListItem(
|
||||
family: CustomFontFamilyItem,
|
||||
selectedFontIds: Set<String>,
|
||||
isSelectionMode: Boolean,
|
||||
fontEntityForId: (String) -> CustomFontEntity?,
|
||||
onVariantSelectionToggle: (String) -> Unit,
|
||||
onFamilySelectionToggle: () -> Unit,
|
||||
onDeleteVariant: (String) -> Unit
|
||||
) {
|
||||
val baseFont = remember(family) {
|
||||
family.variants.firstOrNull { it.fontFaceLabel() == "Regular" }?.font ?: family.variants.first().font
|
||||
}
|
||||
val customTypeface = remember(baseFont.path) {
|
||||
try {
|
||||
FontFamily(Font(File(baseFont.path)))
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
val familyFontIds = remember(family) { family.variants.map { it.font.id }.toSet() }
|
||||
val isSelected = familyFontIds.any { it in selectedFontIds }
|
||||
val allSelected = familyFontIds.all { it in selectedFontIds }
|
||||
val faceSummary = remember(family) {
|
||||
buildString {
|
||||
append(family.fontFaceSummary())
|
||||
if (family.hasVariableWeightFace()) append(" - Variable weight")
|
||||
append(" - ${family.variants.size} file")
|
||||
if (family.variants.size != 1) append("s")
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (isSelectionMode) {
|
||||
onFamilySelectionToggle()
|
||||
}
|
||||
},
|
||||
onLongClick = onFamilySelectionToggle
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isSelectionMode) {
|
||||
Checkbox(
|
||||
checked = allSelected,
|
||||
onCheckedChange = { onFamilySelectionToggle() },
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = family.familyName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = faceSummary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), MaterialTheme.shapes.small)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
if (customTypeface != null) {
|
||||
Text(
|
||||
text = stringResource(R.string.font_preview_text),
|
||||
fontFamily = customTypeface,
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.font_preview_error),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
family.variants.forEachIndexed { index, variant ->
|
||||
FontVariantRow(
|
||||
variant = variant,
|
||||
entity = fontEntityForId(variant.font.id),
|
||||
isSelected = variant.font.id in selectedFontIds,
|
||||
isSelectionMode = isSelectionMode,
|
||||
onSelectionToggle = { onVariantSelectionToggle(variant.font.id) },
|
||||
onDelete = { onDeleteVariant(variant.font.id) }
|
||||
)
|
||||
if (index != family.variants.lastIndex) {
|
||||
HorizontalDivider(modifier = Modifier.padding(start = if (isSelectionMode) 48.dp else 0.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FontVariantRow(
|
||||
variant: CustomFontVariantItem,
|
||||
entity: CustomFontEntity?,
|
||||
isSelected: Boolean,
|
||||
isSelectionMode: Boolean,
|
||||
onSelectionToggle: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable(enabled = isSelectionMode) { onSelectionToggle() }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isSelectionMode) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onSelectionToggle() },
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = variant.fontFaceLabel(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = entity?.fileName ?: variant.font.fileName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = variant.font.fileExtension.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
if (!isSelectionMode) {
|
||||
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.action_delete),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
|
||||
return filterNot { it.isDeleted }
|
||||
.sortedBy { it.displayName.lowercase() }
|
||||
|
|
@ -476,15 +743,32 @@ private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontIte
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteFontConfirmationDialog(
|
||||
fontName: String,
|
||||
fun DeleteFontsConfirmationDialog(
|
||||
fonts: List<CustomFontEntity>,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val isSingleFont = fonts.size == 1
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.dialog_delete_font)) },
|
||||
text = { Text(stringResource(R.string.dialog_delete_font_desc, fontName)) },
|
||||
title = {
|
||||
Text(
|
||||
if (isSingleFont) {
|
||||
stringResource(R.string.dialog_delete_font)
|
||||
} else {
|
||||
stringResource(R.string.dialog_delete_fonts)
|
||||
}
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
if (isSingleFont) {
|
||||
stringResource(R.string.dialog_delete_font_desc, fonts.first().displayName)
|
||||
} else {
|
||||
stringResource(R.string.dialog_delete_fonts_desc, fonts.size)
|
||||
}
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onConfirm,
|
||||
|
|
@ -498,3 +782,12 @@ fun DeleteFontConfirmationDialog(
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun Set<String>.toggle(id: String): Set<String> {
|
||||
return if (id in this) this - id else this + id
|
||||
}
|
||||
|
||||
private fun Set<String>.toggleAll(ids: List<String>): Set<String> {
|
||||
val idSet = ids.toSet()
|
||||
return if (containsAll(idSet)) this - idSet else this + idSet
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
// HomeScreen
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
|
|
@ -130,6 +130,7 @@ import androidx.compose.ui.graphics.toArgb
|
|||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
|
@ -138,12 +139,13 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.text.SimpleDateFormat
|
||||
|
|
@ -192,6 +194,7 @@ fun HomeScreen(
|
|||
var showAboutDialog by remember { mutableStateOf(false) }
|
||||
var showInfoDialog by remember { mutableStateOf(false) }
|
||||
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||
var showBehaviorDialog by remember { mutableStateOf(false) }
|
||||
var showStrictFilterDialog by remember { mutableStateOf(false) }
|
||||
var showClearBookCacheDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -220,6 +223,35 @@ fun HomeScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val saveOriginalLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||
) { uri ->
|
||||
val item = pendingSaveOriginalItem
|
||||
pendingSaveOriginalItem = null
|
||||
if (uri != null && item?.uriString != null) {
|
||||
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveOriginalItem(item: RecentFileItem) {
|
||||
if (!item.canExportOriginalFile()) return
|
||||
pendingSaveOriginalItem = item
|
||||
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
|
||||
}
|
||||
|
||||
fun shareOriginalItem(item: RecentFileItem) {
|
||||
val uriString = item.uriString ?: return
|
||||
if (!item.canExportOriginalFile()) return
|
||||
scope.launch {
|
||||
viewModel.shareOriginalFile(
|
||||
activityContext = context,
|
||||
sourceUri = uriString.toUri(),
|
||||
fileType = item.type,
|
||||
filename = item.suggestedOriginalFileName()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.isRequestingDrivePermission) {
|
||||
if (uiState.isRequestingDrivePermission) {
|
||||
val intent = viewModel.getDriveSignInIntent(context)
|
||||
|
|
@ -389,6 +421,12 @@ fun HomeScreen(
|
|||
showInfoDialog = true
|
||||
}
|
||||
},
|
||||
onSaveClick = selectedContextItems.singleOrNull()
|
||||
?.takeIf { it.canExportOriginalFile() }
|
||||
?.let { item -> { saveOriginalItem(item) } },
|
||||
onShareClick = selectedContextItems.singleOrNull()
|
||||
?.takeIf { it.canExportOriginalFile() }
|
||||
?.let { item -> { shareOriginalItem(item) } },
|
||||
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
|
||||
onDeleteClick = { showDeleteConfirmDialog = true },
|
||||
onSelectAllClick = { viewModel.selectAllRecentFiles() })
|
||||
|
|
@ -436,7 +474,7 @@ fun HomeScreen(
|
|||
onRefresh = { viewModel.refreshLibrary() },
|
||||
isRefreshing = uiState.isRefreshing,
|
||||
isSyncEnabled = uiState.isSyncEnabled,
|
||||
hasSyncedFolder = uiState.syncedFolders.isNotEmpty(),
|
||||
hasSyncedFolder = uiState.syncedFolders.any { it.localSyncEnabled },
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
|
||||
)
|
||||
}
|
||||
|
|
@ -501,28 +539,17 @@ fun HomeScreen(
|
|||
)
|
||||
}
|
||||
|
||||
itemForInfoDialog?.let { item ->
|
||||
if (showInfoDialog) {
|
||||
FileInfoDialog(
|
||||
item = item,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = {
|
||||
showInfoDialog = false
|
||||
itemForInfoDialog = null
|
||||
},
|
||||
onSaveMetadata = { metadata ->
|
||||
viewModel.updateBookMetadata(item.bookId, metadata)
|
||||
},
|
||||
onSaveDisplayName = { name ->
|
||||
viewModel.updateCustomName(item.bookId, name)
|
||||
},
|
||||
onRestoreMetadata = {
|
||||
viewModel.restoreOriginalBookMetadata(item.bookId)
|
||||
},
|
||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
HydratedFileInfoDialog(
|
||||
item = itemForInfoDialog,
|
||||
isVisible = showInfoDialog,
|
||||
uiState = uiState,
|
||||
viewModel = viewModel,
|
||||
onDismiss = {
|
||||
showInfoDialog = false
|
||||
itemForInfoDialog = null
|
||||
},
|
||||
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
|
||||
)
|
||||
|
||||
if (showClearReflowCacheDialog) {
|
||||
DangerousFolderActionDialog(
|
||||
|
|
@ -767,7 +794,8 @@ private fun RecentFilesGrid(
|
|||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag("HomeTab_${tab.bookId}")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -817,6 +845,7 @@ fun RecentFileCard(
|
|||
|
||||
androidx.compose.material3.ElevatedCard(
|
||||
modifier = modifier
|
||||
.testTag("HomeRecentFileCard_${item.bookId}")
|
||||
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
|
||||
.then(
|
||||
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
||||
|
|
@ -1485,7 +1514,7 @@ private fun AppDrawerContent(
|
|||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// legal links
|
||||
if (uiState.currentUser != null && !isOss) {
|
||||
if (uiState.currentUser != null || (isOss && !BuildConfig.IS_OFFLINE)) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val baseStyle = MaterialTheme.typography.labelMedium
|
||||
var scaledTextStyle by remember { mutableStateOf(baseStyle) }
|
||||
|
|
@ -1783,9 +1812,14 @@ fun ExternalFileBehaviorDialog(
|
|||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.options_external_file_behavior)) },
|
||||
text = {
|
||||
Column {
|
||||
val options = listOf("ASK" to R.string.external_file_behavior_ask, "KEEP" to R.string.external_file_behavior_keep, "DELETE" to R.string.external_file_behavior_delete)
|
||||
options.forEach { (value, labelRes) ->
|
||||
Column(modifier = Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) {
|
||||
val options = listOf(
|
||||
Triple("ASK", R.string.external_file_behavior_ask, R.string.external_file_behavior_ask_desc),
|
||||
Triple("KEEP", R.string.external_file_behavior_keep, R.string.external_file_behavior_keep_desc),
|
||||
Triple("DELETE", R.string.external_file_behavior_delete, R.string.external_file_behavior_delete_desc),
|
||||
Triple("TEMPORARY", R.string.external_file_behavior_temporary, R.string.external_file_behavior_temporary_desc)
|
||||
)
|
||||
options.forEach { (value, labelRes, descriptionRes) ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
|
|
@ -1795,7 +1829,14 @@ fun ExternalFileBehaviorDialog(
|
|||
) {
|
||||
RadioButton(selected = currentBehavior == value, onClick = null)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(stringResource(labelRes))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(labelRes))
|
||||
Text(
|
||||
text = stringResource(descriptionRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
// LibraryScreen.kt
|
||||
@file:Suppress("KotlinConstantConditions")
|
||||
|
||||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
|
|
@ -105,6 +105,7 @@ import androidx.compose.material3.TextButton
|
|||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -120,6 +121,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -131,19 +133,24 @@ import androidx.compose.ui.text.input.TextFieldValue
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.TagEntity
|
||||
import com.aryan.reader.opds.OpdsAcquisition
|
||||
import com.aryan.reader.opds.OpdsCatalog
|
||||
import com.aryan.reader.opds.OpdsDownloadState
|
||||
import com.aryan.reader.opds.OpdsEntry
|
||||
import com.aryan.reader.opds.OpdsViewModel
|
||||
import coil.decode.SvgDecoder
|
||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
||||
import org.dueattendant149.bookreader.data.TagEntity
|
||||
import org.dueattendant149.bookreader.opds.OpdsAcquisition
|
||||
import org.dueattendant149.bookreader.opds.OpdsCatalog
|
||||
import org.dueattendant149.bookreader.opds.OpdsDownloadState
|
||||
import org.dueattendant149.bookreader.opds.OpdsEntry
|
||||
import org.dueattendant149.bookreader.opds.OpdsRepository
|
||||
import org.dueattendant149.bookreader.opds.OpdsViewModel
|
||||
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||
import org.dueattendant149.bookreader.shared.opds.SharedOpdsLocalBookMatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
|
@ -265,6 +272,36 @@ fun LibraryScreen(
|
|||
var showDeleteShelvesDialog by remember { mutableStateOf(false) }
|
||||
var showInfoDialog by remember { mutableStateOf(false) }
|
||||
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||
|
||||
val saveOriginalLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||
) { uri ->
|
||||
val item = pendingSaveOriginalItem
|
||||
pendingSaveOriginalItem = null
|
||||
if (uri != null && item?.uriString != null) {
|
||||
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveOriginalItem(item: RecentFileItem) {
|
||||
if (!item.canExportOriginalFile()) return
|
||||
pendingSaveOriginalItem = item
|
||||
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
|
||||
}
|
||||
|
||||
fun shareOriginalItem(item: RecentFileItem) {
|
||||
val uriString = item.uriString ?: return
|
||||
if (!item.canExportOriginalFile()) return
|
||||
scope.launch {
|
||||
viewModel.shareOriginalFile(
|
||||
activityContext = context,
|
||||
sourceUri = uriString.toUri(),
|
||||
fileType = item.type,
|
||||
filename = item.suggestedOriginalFileName()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = isContextualModeActive) {
|
||||
viewModel.clearContextualAction()
|
||||
|
|
@ -311,6 +348,12 @@ fun LibraryScreen(
|
|||
showInfoDialog = true
|
||||
}
|
||||
},
|
||||
onSaveClick = selectedItems.singleOrNull()
|
||||
?.takeIf { it.canExportOriginalFile() }
|
||||
?.let { item -> { saveOriginalItem(item) } },
|
||||
onShareClick = selectedItems.singleOrNull()
|
||||
?.takeIf { it.canExportOriginalFile() }
|
||||
?.let { item -> { shareOriginalItem(item) } },
|
||||
onDeleteClick = { showDeleteConfirmDialog = true },
|
||||
onSelectAllClick = { viewModel.selectAllLibraryFiles() },
|
||||
onShelfClick = viewModel::onShelfClick,
|
||||
|
|
@ -325,6 +368,7 @@ fun LibraryScreen(
|
|||
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
|
||||
syncedFolders = uiState.syncedFolders,
|
||||
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
|
||||
onFolderLocalSyncChange = viewModel::setFolderLocalSyncEnabled,
|
||||
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
|
||||
downloadingBookIds = uiState.downloadingBookIds,
|
||||
lastFolderScanTime = uiState.lastFolderScanTime,
|
||||
|
|
@ -390,28 +434,17 @@ fun LibraryScreen(
|
|||
)
|
||||
}
|
||||
|
||||
itemForInfoDialog?.let { item ->
|
||||
if (showInfoDialog) {
|
||||
FileInfoDialog(
|
||||
item = item,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = {
|
||||
showInfoDialog = false
|
||||
itemForInfoDialog = null
|
||||
},
|
||||
onSaveMetadata = { metadata ->
|
||||
viewModel.updateBookMetadata(item.bookId, metadata)
|
||||
},
|
||||
onSaveDisplayName = { name ->
|
||||
viewModel.updateCustomName(item.bookId, name)
|
||||
},
|
||||
onRestoreMetadata = {
|
||||
viewModel.restoreOriginalBookMetadata(item.bookId)
|
||||
},
|
||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
HydratedFileInfoDialog(
|
||||
item = itemForInfoDialog,
|
||||
isVisible = showInfoDialog,
|
||||
uiState = uiState,
|
||||
viewModel = viewModel,
|
||||
onDismiss = {
|
||||
showInfoDialog = false
|
||||
itemForInfoDialog = null
|
||||
},
|
||||
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
|
||||
)
|
||||
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
||||
}
|
||||
}
|
||||
|
|
@ -429,10 +462,42 @@ fun ShelfScreen(
|
|||
val sortOrder = uiState.sortOrder
|
||||
val showRenameDialogFor = uiState.showRenameShelfDialogFor
|
||||
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
|
||||
var showInfoDialog by remember { mutableStateOf(false) }
|
||||
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||
|
||||
val saveOriginalLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||
) { uri ->
|
||||
val item = pendingSaveOriginalItem
|
||||
pendingSaveOriginalItem = null
|
||||
if (uri != null && item?.uriString != null) {
|
||||
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveOriginalItem(item: RecentFileItem) {
|
||||
if (!item.canExportOriginalFile()) return
|
||||
pendingSaveOriginalItem = item
|
||||
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
|
||||
}
|
||||
|
||||
fun shareOriginalItem(item: RecentFileItem) {
|
||||
val uriString = item.uriString ?: return
|
||||
if (!item.canExportOriginalFile()) return
|
||||
scope.launch {
|
||||
viewModel.shareOriginalFile(
|
||||
activityContext = context,
|
||||
sourceUri = uriString.toUri(),
|
||||
fileType = item.type,
|
||||
filename = item.suggestedOriginalFileName()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
when {
|
||||
|
|
@ -484,6 +549,12 @@ fun ShelfScreen(
|
|||
showInfoDialog = true
|
||||
}
|
||||
},
|
||||
onSaveClick = selectedItems.singleOrNull()
|
||||
?.takeIf { it.canExportOriginalFile() }
|
||||
?.let { item -> { saveOriginalItem(item) } },
|
||||
onShareClick = selectedItems.singleOrNull()
|
||||
?.takeIf { it.canExportOriginalFile() }
|
||||
?.let { item -> { shareOriginalItem(item) } },
|
||||
onDeleteClick = { showRemoveFromShelfDialog = true },
|
||||
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
|
||||
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
|
||||
|
|
@ -524,19 +595,14 @@ fun ShelfScreen(
|
|||
)
|
||||
}
|
||||
|
||||
itemForInfoDialog?.let { item ->
|
||||
if (showInfoDialog) {
|
||||
FileInfoDialog(
|
||||
item = item,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
|
||||
onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) },
|
||||
onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
|
||||
onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) },
|
||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
HydratedFileInfoDialog(
|
||||
item = itemForInfoDialog,
|
||||
isVisible = showInfoDialog,
|
||||
uiState = uiState,
|
||||
viewModel = viewModel,
|
||||
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
|
||||
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
|
||||
)
|
||||
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
||||
}
|
||||
}
|
||||
|
|
@ -571,6 +637,8 @@ fun LibraryScreenContent(
|
|||
onItemClick: (RecentFileItem) -> Unit,
|
||||
onItemLongClick: (RecentFileItem) -> Unit,
|
||||
onInfoClick: () -> Unit,
|
||||
onSaveClick: (() -> Unit)?,
|
||||
onShareClick: (() -> Unit)?,
|
||||
onDeleteClick: () -> Unit,
|
||||
onSelectAllClick: () -> Unit,
|
||||
onShelfClick: (Shelf) -> Unit,
|
||||
|
|
@ -590,6 +658,7 @@ fun LibraryScreenContent(
|
|||
isRefreshing: Boolean,
|
||||
syncedFolders: List<SyncedFolder>,
|
||||
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
||||
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
|
||||
onOpdsBookDownloaded: (Uri, String) -> Unit,
|
||||
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
||||
onDeleteCatalogStreams: (String) -> Unit,
|
||||
|
|
@ -632,6 +701,8 @@ fun LibraryScreenContent(
|
|||
onTagClick = onTagClick,
|
||||
onPinClick = onPinClick,
|
||||
onInfoClick = onInfoClick,
|
||||
onSaveClick = onSaveClick,
|
||||
onShareClick = onShareClick,
|
||||
onDeleteClick = onDeleteClick,
|
||||
onSelectAllClick = onSelectAllClick
|
||||
)
|
||||
|
|
@ -666,7 +737,8 @@ fun LibraryScreenContent(
|
|||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 4.dp)
|
||||
.focusRequester(searchFocusRequester),
|
||||
.focusRequester(searchFocusRequester)
|
||||
.testTag("LibrarySearchTextField"),
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
|
|
@ -693,7 +765,10 @@ fun LibraryScreenContent(
|
|||
Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter))
|
||||
}
|
||||
Box {
|
||||
TextButton(onClick = { showSortMenu = true }) {
|
||||
TextButton(
|
||||
onClick = { showSortMenu = true },
|
||||
modifier = Modifier.testTag("LibrarySortButton")
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.sort),
|
||||
contentDescription = stringResource(R.string.content_desc_sort),
|
||||
|
|
@ -822,7 +897,9 @@ fun LibraryScreenContent(
|
|||
text = { Text(stringResource(R.string.fab_new_shelf)) },
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) },
|
||||
onClick = onNewShelfClick,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.testTag("LibraryNewShelfFab")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -888,6 +965,7 @@ fun LibraryScreenContent(
|
|||
allRecentFiles = rawLibraryFiles,
|
||||
onAddFolderClick = onSelectSyncFolderClick,
|
||||
onRemoveFolderClick = onRemoveFolderClick,
|
||||
onFolderLocalSyncChange = onFolderLocalSyncChange,
|
||||
onEditFolderFiltersClick = onEditFolderFiltersClick,
|
||||
onScanNowClick = onScanNowClick,
|
||||
onSyncMetadataClick = onSyncMetadataClick,
|
||||
|
|
@ -1031,6 +1109,8 @@ private fun ShelfDetailScreen(
|
|||
onClearSelection: () -> Unit,
|
||||
onTagClick: () -> Unit,
|
||||
onInfoClick: () -> Unit,
|
||||
onSaveClick: (() -> Unit)?,
|
||||
onShareClick: (() -> Unit)?,
|
||||
onDeleteClick: () -> Unit,
|
||||
onRenameShelf: () -> Unit,
|
||||
onDeleteShelf: () -> Unit,
|
||||
|
|
@ -1113,6 +1193,8 @@ private fun ShelfDetailScreen(
|
|||
onNavIconClick = onClearSelection,
|
||||
onTagClick = onTagClick,
|
||||
onInfoClick = onInfoClick,
|
||||
onSaveClick = onSaveClick,
|
||||
onShareClick = onShareClick,
|
||||
onDeleteClick = onDeleteClick
|
||||
)
|
||||
} else if (isSearchActive) {
|
||||
|
|
@ -1140,7 +1222,8 @@ private fun ShelfDetailScreen(
|
|||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 4.dp)
|
||||
.focusRequester(searchFocusRequester),
|
||||
.focusRequester(searchFocusRequester)
|
||||
.testTag("ShelfSearchTextField"),
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
|
|
@ -1193,7 +1276,10 @@ private fun ShelfDetailScreen(
|
|||
},
|
||||
actions = {
|
||||
Box {
|
||||
TextButton(onClick = { showSortMenu = true }) {
|
||||
TextButton(
|
||||
onClick = { showSortMenu = true },
|
||||
modifier = Modifier.testTag("ShelfSortButton")
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.sort),
|
||||
contentDescription = stringResource(R.string.content_desc_sort),
|
||||
|
|
@ -1375,7 +1461,10 @@ private fun AddBooksModeScreen(
|
|||
},
|
||||
actions = {
|
||||
Box {
|
||||
TextButton(onClick = { showSortMenu = true }) {
|
||||
TextButton(
|
||||
onClick = { showSortMenu = true },
|
||||
modifier = Modifier.testTag("AddBooksSortButton")
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.sort),
|
||||
contentDescription = stringResource(R.string.content_desc_sort),
|
||||
|
|
@ -1581,6 +1670,7 @@ private fun ShelfListItem(
|
|||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("ShelfItem_${shelf.id}")
|
||||
.then(
|
||||
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
||||
else Modifier
|
||||
|
|
@ -1659,6 +1749,7 @@ private fun LibraryListItem(
|
|||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("LibraryBookItem_${item.bookId}")
|
||||
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
|
||||
.then(
|
||||
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
||||
|
|
@ -1929,12 +2020,15 @@ private fun FolderSyncScreen(
|
|||
allRecentFiles: List<RecentFileItem>,
|
||||
onAddFolderClick: () -> Unit,
|
||||
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
||||
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
|
||||
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
|
||||
onScanNowClick: () -> Unit,
|
||||
onSyncMetadataClick: () -> Unit,
|
||||
isLoading: Boolean
|
||||
) {
|
||||
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
|
||||
var disablingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
|
||||
val hasEnabledSyncFolders = syncedFolders.any { it.localSyncEnabled }
|
||||
val folderStatsByUri = remember(allRecentFiles) {
|
||||
allRecentFiles
|
||||
.asSequence()
|
||||
|
|
@ -1973,7 +2067,7 @@ private fun FolderSyncScreen(
|
|||
) {
|
||||
FilledTonalButton(
|
||||
onClick = onScanNowClick,
|
||||
enabled = !isLoading,
|
||||
enabled = !isLoading && hasEnabledSyncFolders,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
|
|
@ -1988,7 +2082,7 @@ private fun FolderSyncScreen(
|
|||
|
||||
androidx.compose.material3.OutlinedButton(
|
||||
onClick = onSyncMetadataClick,
|
||||
enabled = !isLoading,
|
||||
enabled = !isLoading && hasEnabledSyncFolders,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
|
|
@ -2016,6 +2110,13 @@ private fun FolderSyncScreen(
|
|||
folder = folder,
|
||||
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
|
||||
onRemoveClick = onRemoveFolderClick,
|
||||
onLocalSyncToggleClick = { selectedFolder ->
|
||||
if (selectedFolder.localSyncEnabled) {
|
||||
disablingFolder = selectedFolder
|
||||
} else {
|
||||
onFolderLocalSyncChange(selectedFolder, true, false)
|
||||
}
|
||||
},
|
||||
onEditFiltersClick = { editingFolder = folder }
|
||||
)
|
||||
}
|
||||
|
|
@ -2033,6 +2134,46 @@ private fun FolderSyncScreen(
|
|||
onDismiss = { editingFolder = null }
|
||||
)
|
||||
}
|
||||
|
||||
disablingFolder?.let { folder ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { disablingFolder = null },
|
||||
title = { Text(stringResource(R.string.dialog_disable_folder_local_sync_title)) },
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.dialog_disable_folder_local_sync_desc,
|
||||
LOCAL_FOLDER_SYNC_DATA_DIR
|
||||
)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onFolderLocalSyncChange(folder, false, true)
|
||||
disablingFolder = null
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.action_disable_remove_sync_data))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = { disablingFolder = null }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
onFolderLocalSyncChange(folder, false, false)
|
||||
disablingFolder = null
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.action_disable_keep_sync_data))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class FolderFileStats(
|
||||
|
|
@ -2050,6 +2191,7 @@ private fun FolderCard(
|
|||
folder: SyncedFolder,
|
||||
stats: FolderFileStats,
|
||||
onRemoveClick: (SyncedFolder) -> Unit,
|
||||
onLocalSyncToggleClick: (SyncedFolder) -> Unit,
|
||||
onEditFiltersClick: (SyncedFolder) -> Unit
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
|
@ -2075,13 +2217,22 @@ private fun FolderCard(
|
|||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = folder.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = folder.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!folder.localSyncEnabled) {
|
||||
Text(
|
||||
text = stringResource(R.string.folder_local_sync_disabled),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
|
|
@ -2096,6 +2247,21 @@ private fun FolderCard(
|
|||
onEditFiltersClick(folder)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
if (folder.localSyncEnabled) {
|
||||
stringResource(R.string.menu_disable_folder_local_sync)
|
||||
} else {
|
||||
stringResource(R.string.menu_enable_folder_local_sync)
|
||||
}
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onLocalSyncToggleClick(folder)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_remove_folder)) },
|
||||
onClick = {
|
||||
|
|
@ -2377,6 +2543,7 @@ fun OpdsTab(
|
|||
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val downloadingState = uiState.downloadingState
|
||||
val context = LocalContext.current
|
||||
val coverImageLoader = rememberOpdsCoverImageLoader(uiState.currentCatalog)
|
||||
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
|
||||
var showCatalogDialog by remember { mutableStateOf(false) }
|
||||
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
|
||||
|
|
@ -2601,6 +2768,7 @@ fun OpdsTab(
|
|||
entry = entry,
|
||||
localLibraryFiles = localLibraryFiles,
|
||||
downloadState = downloadingState[entry.id],
|
||||
coverImageLoader = coverImageLoader,
|
||||
onDownloadClick = { acquisition ->
|
||||
opdsViewModel.downloadBook(
|
||||
entry, acquisition, context
|
||||
|
|
@ -2649,6 +2817,7 @@ fun OpdsTab(
|
|||
entry = selectedEntry!!,
|
||||
localLibraryFiles = localLibraryFiles,
|
||||
downloadState = downloadingState[selectedEntry!!.id],
|
||||
coverImageLoader = coverImageLoader,
|
||||
onDownloadFormat = { acquisition ->
|
||||
opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
|
||||
onBookDownloaded(downloadedUri, selectedEntry!!.title)
|
||||
|
|
@ -2776,6 +2945,29 @@ fun OpdsTab(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberOpdsCoverImageLoader(catalog: OpdsCatalog?): ImageLoader {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val username = catalog?.username
|
||||
val password = catalog?.password
|
||||
val imageLoader = remember(context, username, password) {
|
||||
ImageLoader.Builder(context)
|
||||
.okHttpClient {
|
||||
OpdsRepository.sharedHttpClient.newBuilder()
|
||||
.authenticator(OpdsRepository.OpdsAuthenticator(username, password))
|
||||
.build()
|
||||
}
|
||||
.components {
|
||||
add(SvgDecoder.Factory())
|
||||
}
|
||||
.build()
|
||||
}
|
||||
DisposableEffect(imageLoader) {
|
||||
onDispose { imageLoader.shutdown() }
|
||||
}
|
||||
return imageLoader
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
|
||||
Surface(
|
||||
|
|
@ -2853,13 +3045,20 @@ fun OpdsBookCard(
|
|||
entry: OpdsEntry,
|
||||
localLibraryFiles: List<RecentFileItem>,
|
||||
downloadState: OpdsDownloadState?,
|
||||
coverImageLoader: ImageLoader,
|
||||
onDownloadClick: (OpdsAcquisition) -> Unit,
|
||||
onReadClick: (RecentFileItem) -> Unit,
|
||||
onStreamClick: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val libraryItem = remember(entry, localLibraryFiles) {
|
||||
localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
|
||||
SharedOpdsLocalBookMatcher.find(
|
||||
entry = entry,
|
||||
books = localLibraryFiles,
|
||||
title = { it.title },
|
||||
displayName = { it.displayName },
|
||||
path = { it.uriString }
|
||||
)
|
||||
}
|
||||
val isDownloading = downloadState?.isDownloading == true
|
||||
val progress = downloadState?.progress
|
||||
|
|
@ -2878,6 +3077,7 @@ fun OpdsBookCard(
|
|||
AsyncImage(
|
||||
model = entry.coverUrl,
|
||||
contentDescription = null,
|
||||
imageLoader = coverImageLoader,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(width = 70.dp, height = 100.dp)
|
||||
|
|
@ -2984,6 +3184,7 @@ fun OpdsBookDetailsSheet(
|
|||
entry: OpdsEntry,
|
||||
localLibraryFiles: List<RecentFileItem>,
|
||||
downloadState: OpdsDownloadState?,
|
||||
coverImageLoader: ImageLoader,
|
||||
onDownloadFormat: (OpdsAcquisition) -> Unit,
|
||||
onReadClick: (RecentFileItem) -> Unit,
|
||||
onStreamClick: () -> Unit,
|
||||
|
|
@ -2992,7 +3193,13 @@ fun OpdsBookDetailsSheet(
|
|||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val libraryItem = remember(entry, localLibraryFiles) {
|
||||
localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
|
||||
SharedOpdsLocalBookMatcher.find(
|
||||
entry = entry,
|
||||
books = localLibraryFiles,
|
||||
title = { it.title },
|
||||
displayName = { it.displayName },
|
||||
path = { it.uriString }
|
||||
)
|
||||
}
|
||||
val isDownloading = downloadState?.isDownloading == true
|
||||
val progress = downloadState?.progress
|
||||
|
|
@ -3012,6 +3219,7 @@ fun OpdsBookDetailsSheet(
|
|||
AsyncImage(
|
||||
model = entry.coverUrl,
|
||||
contentDescription = null,
|
||||
imageLoader = coverImageLoader,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(width = 110.dp, height = 160.dp)
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import com.aryan.reader.data.BookShelfCrossRef
|
||||
import com.aryan.reader.data.BookTagCrossRef
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.ShelfEntity
|
||||
import com.aryan.reader.data.TagEntity
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.applyLibraryFilters as sharedApplyLibraryFilters
|
||||
import com.aryan.reader.shared.filterBySearch as sharedFilterBySearch
|
||||
import com.aryan.reader.shared.sortBooks as sharedSortBooks
|
||||
import org.dueattendant149.bookreader.data.BookShelfCrossRef
|
||||
import org.dueattendant149.bookreader.data.BookTagCrossRef
|
||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
||||
import org.dueattendant149.bookreader.data.ShelfEntity
|
||||
import org.dueattendant149.bookreader.data.TagEntity
|
||||
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
||||
import org.dueattendant149.bookreader.shared.applyLibraryFilters as sharedApplyLibraryFilters
|
||||
import org.dueattendant149.bookreader.shared.filterBySearch as sharedFilterBySearch
|
||||
import org.dueattendant149.bookreader.shared.sortBooks as sharedSortBooks
|
||||
|
||||
fun interface FolderPathResolver {
|
||||
fun relativeFolderSegments(item: RecentFileItem): List<String>
|
||||
|
|
@ -151,7 +151,7 @@ fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFil
|
|||
return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder))
|
||||
}
|
||||
|
||||
private fun List<RecentFileItem>.mapSharedResults(sharedBooks: List<com.aryan.reader.shared.BookItem>): List<RecentFileItem> {
|
||||
private fun List<RecentFileItem>.mapSharedResults(sharedBooks: List<org.dueattendant149.bookreader.shared.BookItem>): List<RecentFileItem> {
|
||||
val byId = associateBy { it.bookId }
|
||||
return sharedBooks.mapNotNull { byId[it.id] }
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
|
|
@ -41,8 +41,8 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.aryan.reader.data.PlatformFeaturesRepository
|
||||
import com.aryan.reader.ui.theme.AppTheme
|
||||
import org.dueattendant149.bookreader.data.PlatformFeaturesRepository
|
||||
import org.dueattendant149.bookreader.ui.theme.AppTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
|
|
@ -50,18 +50,20 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.aryan.reader.tts.ACTION_OPEN_TTS_SESSION
|
||||
import com.aryan.reader.tts.EXTRA_TTS_BOOK_ID
|
||||
import com.aryan.reader.tts.EXTRA_TTS_CHAPTER_INDEX
|
||||
import com.aryan.reader.tts.EXTRA_TTS_PAGE_INDEX
|
||||
import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI
|
||||
import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET
|
||||
import org.dueattendant149.bookreader.tts.ACTION_OPEN_TTS_SESSION
|
||||
import org.dueattendant149.bookreader.tts.EXTRA_TTS_BOOK_ID
|
||||
import org.dueattendant149.bookreader.tts.EXTRA_TTS_CHAPTER_INDEX
|
||||
import org.dueattendant149.bookreader.tts.EXTRA_TTS_PAGE_INDEX
|
||||
import org.dueattendant149.bookreader.tts.EXTRA_TTS_SOURCE_CFI
|
||||
import org.dueattendant149.bookreader.tts.EXTRA_TTS_START_OFFSET
|
||||
|
||||
@UnstableApi
|
||||
class MainActivity : AppCompatActivity() {
|
||||
open class MainActivity : AppCompatActivity() {
|
||||
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
|
||||
private val isTemporaryExternalOpen: Boolean
|
||||
get() = intent?.getBooleanExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, false) == true
|
||||
|
||||
private val updateLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartIntentSenderForResult()
|
||||
|
|
@ -86,6 +88,14 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.temporaryExternalOpenFinished.collect {
|
||||
if (isTemporaryExternalOpen) {
|
||||
finishAndRemoveTask()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
|
@ -160,7 +170,12 @@ class MainActivity : AppCompatActivity() {
|
|||
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
|
||||
Timber.d("Received VIEW intent with URI: ${intent.data}")
|
||||
val uri = intent.data!!
|
||||
viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true)
|
||||
viewModel.onFileSelected(
|
||||
uri,
|
||||
isFromRecent = false,
|
||||
isExternalIntent = true,
|
||||
isTemporaryExternalIntent = isTemporaryExternalOpen
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
internal const val KEY_RENDER_MODE = "render_mode"
|
||||
internal const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
// MainScreen.kt
|
||||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
// MetadataExtractionWorker.kt
|
||||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.OpenableColumns
|
||||
|
|
@ -10,10 +10,10 @@ import androidx.work.ExistingWorkPolicy
|
|||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.WorkManager
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.RecentFilesRepository
|
||||
import com.aryan.reader.pdf.PdfiumCoreProvider
|
||||
import com.aryan.reader.pdf.PdfiumEngineProvider
|
||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
||||
import org.dueattendant149.bookreader.data.RecentFilesRepository
|
||||
import org.dueattendant149.bookreader.pdf.PdfiumCoreProvider
|
||||
import org.dueattendant149.bookreader.pdf.PdfiumEngineProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
|
@ -50,11 +50,20 @@ class MetadataExtractionWorker(
|
|||
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
|
||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
val hasLegacy = prefs.contains("synced_folder_uri")
|
||||
val hasNew = prefs.contains("synced_folders_list_json")
|
||||
val linkedFolders = SyncedFolderPrefs.decodeSyncedFolders(
|
||||
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
|
||||
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null)
|
||||
)
|
||||
val enabledFolderUris = linkedFolders
|
||||
.filter { it.localSyncEnabled }
|
||||
.mapTo(mutableSetOf()) { it.uriString }
|
||||
|
||||
if (!hasLegacy && !hasNew) {
|
||||
ReaderPerfLog.d("MetadataWorker skipped: no linked folders")
|
||||
if (enabledFolderUris.isEmpty()) {
|
||||
ReaderPerfLog.d("MetadataWorker skipped: no linked folders with sync enabled")
|
||||
return@withContext Result.success()
|
||||
}
|
||||
if (!sourceFolderUri.isNullOrBlank() && sourceFolderUri !in enabledFolderUris) {
|
||||
ReaderPerfLog.d("MetadataWorker skipped: folder sync disabled folder=$sourceFolderUri")
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +71,9 @@ class MetadataExtractionWorker(
|
|||
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
|
||||
sourceFolderUri = sourceFolderUri,
|
||||
limit = METADATA_WORKER_BOOK_BATCH_SIZE
|
||||
)
|
||||
).filter { item ->
|
||||
item.sourceFolderUri != null && item.sourceFolderUri in enabledFolderUris
|
||||
}
|
||||
|
||||
if (filesToProcess.isEmpty()) {
|
||||
ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")
|
||||
|
|
@ -17,14 +17,14 @@
|
|||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.app.Application
|
||||
import android.webkit.WebView
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.SvgDecoder
|
||||
import com.aryan.reader.paginatedreader.SvgStringFetcher
|
||||
import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher
|
||||
import timber.log.Timber // Add this
|
||||
|
||||
class MyApplication : Application(), ImageLoaderFactory {
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.app.Activity
|
||||
import timber.log.Timber
|
||||
|
|
@ -61,7 +61,7 @@ import androidx.compose.ui.text.style.TextDecoration
|
|||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.data.ProductDetailsEntity
|
||||
import org.dueattendant149.bookreader.data.ProductDetailsEntity
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.NumberFormat
|
||||
import java.util.Currency
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.util.Base64
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Window
|
||||
|
|
@ -10,11 +10,16 @@ import androidx.compose.foundation.layout.Spacer
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
|
@ -28,20 +33,37 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.edit
|
||||
import org.dueattendant149.bookreader.shared.ui.ReaderMinimalSlider
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val READER_PREFS_NAME = "reader_prefs"
|
||||
private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system"
|
||||
private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value"
|
||||
private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f
|
||||
private const val MIN_CUSTOM_BRIGHTNESS = 0.05f
|
||||
private const val MIN_CUSTOM_BRIGHTNESS_PERCENT = 1
|
||||
private const val MAX_CUSTOM_BRIGHTNESS_PERCENT = 100
|
||||
private const val CUSTOM_BRIGHTNESS_STEP_PERCENT = 1
|
||||
private const val MIN_CUSTOM_BRIGHTNESS = 0.01f
|
||||
|
||||
data class ReaderBrightnessSettings(
|
||||
val useSystemBrightness: Boolean = true,
|
||||
val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS
|
||||
) {
|
||||
val safeCustomBrightness: Float
|
||||
get() = customBrightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
|
||||
get() = normalizeReaderBrightness(customBrightness)
|
||||
}
|
||||
|
||||
internal fun normalizeReaderBrightness(brightness: Float): Float {
|
||||
val percent = (brightness * 100f).roundToInt()
|
||||
.coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
|
||||
return percent / 100f
|
||||
}
|
||||
|
||||
internal fun stepReaderBrightness(brightness: Float, percentDelta: Int): Float {
|
||||
val currentPercent = (normalizeReaderBrightness(brightness) * 100f).roundToInt()
|
||||
val nextPercent = (currentPercent + percentDelta)
|
||||
.coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
|
||||
return nextPercent / 100f
|
||||
}
|
||||
|
||||
fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
|
||||
|
|
@ -49,7 +71,7 @@ fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
|
|||
return ReaderBrightnessSettings(
|
||||
useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true),
|
||||
customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS)
|
||||
.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
|
||||
.let(::normalizeReaderBrightness)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -162,17 +184,9 @@ fun ReaderBrightnessSheet(
|
|||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = settings.safeCustomBrightness,
|
||||
onValueChange = { brightness ->
|
||||
onSettingsChange(
|
||||
settings.copy(
|
||||
useSystemBrightness = false,
|
||||
customBrightness = brightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
|
||||
)
|
||||
)
|
||||
},
|
||||
valueRange = MIN_CUSTOM_BRIGHTNESS..1f
|
||||
ReaderBrightnessControl(
|
||||
settings = settings,
|
||||
onSettingsChange = onSettingsChange
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.reader_brightness_custom_desc),
|
||||
|
|
@ -186,6 +200,67 @@ fun ReaderBrightnessSheet(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderBrightnessControl(
|
||||
settings: ReaderBrightnessSettings,
|
||||
onSettingsChange: (ReaderBrightnessSettings) -> Unit
|
||||
) {
|
||||
val brightness = settings.safeCustomBrightness
|
||||
val canDecrease = brightness > MIN_CUSTOM_BRIGHTNESS
|
||||
val canIncrease = brightness < 1f
|
||||
|
||||
fun updateBrightness(value: Float) {
|
||||
onSettingsChange(
|
||||
settings.copy(
|
||||
useSystemBrightness = false,
|
||||
customBrightness = normalizeReaderBrightness(value)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
updateBrightness(stepReaderBrightness(brightness, -CUSTOM_BRIGHTNESS_STEP_PERCENT))
|
||||
},
|
||||
enabled = canDecrease,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Remove,
|
||||
contentDescription = stringResource(R.string.content_desc_decrease),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
ReaderMinimalSlider(
|
||||
value = brightness,
|
||||
onValueChange = ::updateBrightness,
|
||||
valueRange = MIN_CUSTOM_BRIGHTNESS..1f,
|
||||
activeColor = MaterialTheme.colorScheme.primary,
|
||||
inactiveColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
||||
thumbColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
updateBrightness(stepReaderBrightness(brightness, CUSTOM_BRIGHTNESS_STEP_PERCENT))
|
||||
},
|
||||
enabled = canIncrease,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.content_desc_increase),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Window.setReaderBrightness(brightness: Float) {
|
||||
attributes = attributes.apply {
|
||||
screenBrightness = brightness
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import androidx.compose.runtime.setValue
|
||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
||||
|
||||
@Composable
|
||||
private fun rememberReaderFileInfoItem(
|
||||
|
|
@ -64,28 +67,17 @@ internal fun ReaderFileInfoDialogs(
|
|||
}
|
||||
}
|
||||
|
||||
item?.let { fileInfoItem ->
|
||||
if (isFileInfoVisible) {
|
||||
FileInfoDialog(
|
||||
item = fileInfoItem,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = { onFileInfoVisibleChange(false) },
|
||||
onSaveMetadata = { metadata ->
|
||||
viewModel.updateBookMetadata(fileInfoItem.bookId, metadata)
|
||||
},
|
||||
onSaveDisplayName = { name ->
|
||||
viewModel.updateCustomName(fileInfoItem.bookId, name)
|
||||
},
|
||||
onRestoreMetadata = {
|
||||
viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId)
|
||||
},
|
||||
onOpenTags = {
|
||||
onFileInfoVisibleChange(false)
|
||||
viewModel.openTagSelection(setOf(fileInfoItem.bookId))
|
||||
}
|
||||
)
|
||||
HydratedFileInfoDialog(
|
||||
item = item,
|
||||
isVisible = isFileInfoVisible,
|
||||
uiState = uiState,
|
||||
viewModel = viewModel,
|
||||
onDismiss = { onFileInfoVisibleChange(false) },
|
||||
onOpenTags = { bookId ->
|
||||
onFileInfoVisibleChange(false)
|
||||
viewModel.openTagSelection(setOf(bookId))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
|
||||
TagSelectionBottomSheet(
|
||||
|
|
@ -102,3 +94,49 @@ internal fun ReaderFileInfoDialogs(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HydratedFileInfoDialog(
|
||||
item: RecentFileItem?,
|
||||
isVisible: Boolean,
|
||||
uiState: ReaderScreenState,
|
||||
viewModel: MainViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onOpenTags: (String) -> Unit
|
||||
) {
|
||||
var fileInfoItem by remember(item?.bookId) { mutableStateOf(item) }
|
||||
var hasResolvedFullItem by remember(item?.bookId) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(item) {
|
||||
fileInfoItem = item
|
||||
hasResolvedFullItem = false
|
||||
}
|
||||
|
||||
LaunchedEffect(isVisible, item?.bookId) {
|
||||
if (isVisible && item != null) {
|
||||
fileInfoItem = viewModel.getFileInfoItem(item.bookId)?.copy(tags = item.tags) ?: item
|
||||
hasResolvedFullItem = true
|
||||
}
|
||||
}
|
||||
|
||||
val resolvedItem = fileInfoItem
|
||||
if (isVisible && resolvedItem != null && hasResolvedFullItem) {
|
||||
FileInfoDialog(
|
||||
item = resolvedItem,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = onDismiss,
|
||||
onSaveMetadata = { metadata ->
|
||||
viewModel.updateBookMetadata(resolvedItem.bookId, metadata)
|
||||
},
|
||||
onSaveDisplayName = { name ->
|
||||
viewModel.updateCustomName(resolvedItem.bookId, name)
|
||||
},
|
||||
onRestoreMetadata = {
|
||||
viewModel.restoreOriginalBookMetadata(resolvedItem.bookId)
|
||||
},
|
||||
onOpenTags = {
|
||||
onOpenTags(resolvedItem.bookId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()}"
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.aryan.reader
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import timber.log.Timber
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue