Compare commits
No commits in common. "5f64f3d722e635ac8ae50c5b24e9994fe25128b7" and "d4a1432ea13298fd054e6bdbd9d49418c8432ca8" have entirely different histories.
5f64f3d722
...
d4a1432ea1
831 changed files with 18453 additions and 78240 deletions
409
.github/workflows/desktop-release.yml
vendored
409
.github/workflows/desktop-release.yml
vendored
|
|
@ -1,409 +0,0 @@
|
||||||
name: Desktop release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: Desktop version, for example 1.0.1
|
|
||||||
required: true
|
|
||||||
default: "1.0.1"
|
|
||||||
release_tag:
|
|
||||||
description: GitHub release tag for desktop artifacts
|
|
||||||
required: true
|
|
||||||
default: "v1.0.1"
|
|
||||||
pdfium_tag:
|
|
||||||
description: GitHub release tag containing Pdfium zip assets
|
|
||||||
required: true
|
|
||||||
default: "pdfium-desktop-v1"
|
|
||||||
draft:
|
|
||||||
description: Create or update the desktop release as a draft
|
|
||||||
required: true
|
|
||||||
default: "true"
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- "true"
|
|
||||||
- "false"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
DESKTOP_FIREBASE_PROJECT_ID: ${{ secrets.DESKTOP_FIREBASE_PROJECT_ID }}
|
|
||||||
DESKTOP_FIREBASE_WEB_API_KEY: ${{ secrets.DESKTOP_FIREBASE_WEB_API_KEY }}
|
|
||||||
DESKTOP_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.DESKTOP_GOOGLE_OAUTH_CLIENT_ID }}
|
|
||||||
DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
windows-msi:
|
|
||||||
name: Windows MSI (${{ matrix.flavor.label }})
|
|
||||||
runs-on: windows-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
flavor:
|
|
||||||
- label: standard
|
|
||||||
gradle: standard
|
|
||||||
- label: oss
|
|
||||||
gradle: oss
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Configure Gradle
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
@'
|
|
||||||
kotlin.code.style=official
|
|
||||||
kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
|
|
||||||
kotlin.mpp.applyDefaultHierarchyTemplate=false
|
|
||||||
org.gradle.caching=true
|
|
||||||
org.gradle.configuration-cache=true
|
|
||||||
org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
|
|
||||||
org.gradle.workers.max=2
|
|
||||||
'@ | Set-Content -Path gradle.properties -NoNewline -Encoding UTF8
|
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
- name: Download Pdfium
|
|
||||||
shell: pwsh
|
|
||||||
run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
|
|
||||||
- name: Install WiX
|
|
||||||
shell: pwsh
|
|
||||||
run: choco install wixtoolset --no-progress -y
|
|
||||||
|
|
||||||
- name: Build MSI
|
|
||||||
shell: pwsh
|
|
||||||
env:
|
|
||||||
DESKTOP_FLAVOR: ${{ matrix.flavor.gradle }}
|
|
||||||
DESKTOP_VERSION: ${{ inputs.version }}
|
|
||||||
run: |
|
|
||||||
$gradleArgs = @(
|
|
||||||
"-PdesktopOnly=true",
|
|
||||||
"-PdesktopFlavor=$env:DESKTOP_FLAVOR",
|
|
||||||
"-PdesktopVersion=$env:DESKTOP_VERSION",
|
|
||||||
"-PdesktopPackageVersion=$env:DESKTOP_VERSION",
|
|
||||||
":desktopApp:packageReleaseMsi"
|
|
||||||
)
|
|
||||||
./gradlew.bat @gradleArgs
|
|
||||||
|
|
||||||
- name: Stage MSI
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
New-Item -ItemType Directory -Force release-assets | Out-Null
|
|
||||||
$source = Get-ChildItem desktopApp/build/compose/binaries/main-release/msi -Filter *.msi | Select-Object -First 1
|
|
||||||
if ($null -eq $source) { throw "MSI output not found" }
|
|
||||||
Copy-Item $source.FullName "release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-windows-x64.msi"
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: windows-msi-${{ matrix.flavor.label }}
|
|
||||||
path: release-assets/*
|
|
||||||
|
|
||||||
ubuntu-deb-tar:
|
|
||||||
name: Ubuntu DEB and tar (${{ matrix.flavor.label }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
flavor:
|
|
||||||
- label: standard
|
|
||||||
gradle: standard
|
|
||||||
- label: oss
|
|
||||||
gradle: oss
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Configure Gradle
|
|
||||||
run: |
|
|
||||||
cat > gradle.properties <<'EOF'
|
|
||||||
kotlin.code.style=official
|
|
||||||
kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
|
|
||||||
kotlin.mpp.applyDefaultHierarchyTemplate=false
|
|
||||||
org.gradle.caching=true
|
|
||||||
org.gradle.configuration-cache=true
|
|
||||||
org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
|
|
||||||
org.gradle.workers.max=2
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
- name: Make Gradle wrapper executable
|
|
||||||
run: chmod +x gradlew
|
|
||||||
|
|
||||||
- name: Install packaging tools
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y fakeroot dpkg-dev binutils
|
|
||||||
|
|
||||||
- name: Download Pdfium
|
|
||||||
shell: pwsh
|
|
||||||
run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
|
|
||||||
- name: Build DEB and Linux tarball
|
|
||||||
run: >
|
|
||||||
./gradlew
|
|
||||||
-PdesktopOnly=true
|
|
||||||
-PdesktopFlavor=${{ matrix.flavor.gradle }}
|
|
||||||
-PdesktopVersion=${{ inputs.version }}
|
|
||||||
-PdesktopPackageVersion=${{ inputs.version }}
|
|
||||||
:desktopApp:packageDeb
|
|
||||||
:desktopApp:packageLinuxTar
|
|
||||||
|
|
||||||
- name: Stage DEB and tarball
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p release-assets
|
|
||||||
cp "$(find desktopApp/build/compose/binaries/main/deb -name '*.deb' -print -quit)" \
|
|
||||||
"release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-amd64.deb"
|
|
||||||
cp "$(find desktopApp/build/compose/binaries/main/linux-tar -name '*.tar.gz' -print -quit)" \
|
|
||||||
"release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x64.tar.gz"
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ubuntu-deb-tar-${{ matrix.flavor.label }}
|
|
||||||
path: release-assets/*
|
|
||||||
|
|
||||||
fedora-rpm:
|
|
||||||
name: Fedora RPM (${{ matrix.flavor.label }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
flavor:
|
|
||||||
- label: standard
|
|
||||||
gradle: standard
|
|
||||||
- label: oss
|
|
||||||
gradle: oss
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Configure Gradle
|
|
||||||
run: |
|
|
||||||
cat > gradle.properties <<'EOF'
|
|
||||||
kotlin.code.style=official
|
|
||||||
kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
|
|
||||||
kotlin.mpp.applyDefaultHierarchyTemplate=false
|
|
||||||
org.gradle.caching=true
|
|
||||||
org.gradle.configuration-cache=true
|
|
||||||
org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
|
|
||||||
org.gradle.workers.max=2
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
- name: Make Gradle wrapper executable
|
|
||||||
run: chmod +x gradlew
|
|
||||||
|
|
||||||
- name: Download Pdfium
|
|
||||||
shell: pwsh
|
|
||||||
run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
|
|
||||||
- name: Build RPM in Fedora
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker run --rm \
|
|
||||||
-v "$PWD:/workspace" \
|
|
||||||
-v "$JAVA_HOME:/host-jdk-21:ro" \
|
|
||||||
-w /workspace \
|
|
||||||
-e DESKTOP_FIREBASE_PROJECT_ID \
|
|
||||||
-e DESKTOP_FIREBASE_WEB_API_KEY \
|
|
||||||
-e DESKTOP_GOOGLE_OAUTH_CLIENT_ID \
|
|
||||||
-e DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET \
|
|
||||||
fedora:44 bash -lc '
|
|
||||||
set -euo pipefail
|
|
||||||
dnf install -y ca-certificates findutils git rpm-build unzip which
|
|
||||||
update-ca-trust
|
|
||||||
export JAVA_HOME=/host-jdk-21
|
|
||||||
export PATH="$JAVA_HOME/bin:$PATH"
|
|
||||||
if [ -f /etc/pki/java/cacerts ]; then
|
|
||||||
export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/etc/pki/java/cacerts ${JAVA_TOOL_OPTIONS:-}"
|
|
||||||
elif [ -f /etc/pki/ca-trust/extracted/java/cacerts ]; then
|
|
||||||
export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/etc/pki/ca-trust/extracted/java/cacerts ${JAVA_TOOL_OPTIONS:-}"
|
|
||||||
fi
|
|
||||||
JPACKAGE_HOME="$JAVA_HOME"
|
|
||||||
./gradlew \
|
|
||||||
-PdesktopOnly=true \
|
|
||||||
-PdesktopFlavor=${{ matrix.flavor.gradle }} \
|
|
||||||
-PdesktopVersion=${{ inputs.version }} \
|
|
||||||
-PdesktopPackageVersion=${{ inputs.version }} \
|
|
||||||
-PdesktopPackagingJavaHome="$JPACKAGE_HOME" \
|
|
||||||
:desktopApp:packageRpm
|
|
||||||
'
|
|
||||||
sudo chown -R "$USER:$USER" desktopApp/build
|
|
||||||
|
|
||||||
- name: Stage RPM
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p release-assets
|
|
||||||
cp "$(find desktopApp/build/compose/binaries/main/rpm -name '*.rpm' -print -quit)" \
|
|
||||||
"release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x86_64.rpm"
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: fedora-rpm-${{ matrix.flavor.label }}
|
|
||||||
path: release-assets/*
|
|
||||||
|
|
||||||
arch-package:
|
|
||||||
name: Arch package (${{ matrix.flavor.label }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
flavor:
|
|
||||||
- label: standard
|
|
||||||
gradle: standard
|
|
||||||
aur: episteme-bin
|
|
||||||
- label: oss
|
|
||||||
gradle: oss
|
|
||||||
aur: episteme-oss-bin
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Configure Gradle
|
|
||||||
run: |
|
|
||||||
cat > gradle.properties <<'EOF'
|
|
||||||
kotlin.code.style=official
|
|
||||||
kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
|
|
||||||
kotlin.mpp.applyDefaultHierarchyTemplate=false
|
|
||||||
org.gradle.caching=true
|
|
||||||
org.gradle.configuration-cache=true
|
|
||||||
org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
|
|
||||||
org.gradle.workers.max=2
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Make Gradle wrapper executable
|
|
||||||
run: chmod +x gradlew
|
|
||||||
|
|
||||||
- name: Download Pdfium
|
|
||||||
shell: pwsh
|
|
||||||
run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
|
|
||||||
- name: Build Arch package
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker run --rm \
|
|
||||||
-v "$PWD:/workspace" \
|
|
||||||
-w /workspace \
|
|
||||||
-e DESKTOP_FIREBASE_PROJECT_ID \
|
|
||||||
-e DESKTOP_FIREBASE_WEB_API_KEY \
|
|
||||||
-e DESKTOP_GOOGLE_OAUTH_CLIENT_ID \
|
|
||||||
-e DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET \
|
|
||||||
archlinux:latest bash -lc '
|
|
||||||
set -euo pipefail
|
|
||||||
pacman -Syu --noconfirm
|
|
||||||
pacman -S --needed --noconfirm base-devel desktop-file-utils git jdk21-openjdk namcap unzip
|
|
||||||
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
|
|
||||||
export PATH="$JAVA_HOME/bin:$PATH"
|
|
||||||
useradd -m builder
|
|
||||||
chown -R builder:builder /workspace
|
|
||||||
runuser -u builder -- env \
|
|
||||||
JAVA_HOME="$JAVA_HOME" \
|
|
||||||
PATH="$PATH" \
|
|
||||||
DESKTOP_FIREBASE_PROJECT_ID="$DESKTOP_FIREBASE_PROJECT_ID" \
|
|
||||||
DESKTOP_FIREBASE_WEB_API_KEY="$DESKTOP_FIREBASE_WEB_API_KEY" \
|
|
||||||
DESKTOP_GOOGLE_OAUTH_CLIENT_ID="$DESKTOP_GOOGLE_OAUTH_CLIENT_ID" \
|
|
||||||
DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET="$DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET" \
|
|
||||||
bash -lc "cd /workspace && ./gradlew \
|
|
||||||
-PdesktopOnly=true \
|
|
||||||
-PdesktopFlavor=${{ matrix.flavor.gradle }} \
|
|
||||||
-PdesktopVersion=${{ inputs.version }} \
|
|
||||||
-PdesktopPackageVersion=${{ inputs.version }} \
|
|
||||||
-PdesktopAurPackageRelease=1 \
|
|
||||||
:desktopApp:prepareAurPackage && \
|
|
||||||
cd desktopApp/build/aur/${{ matrix.flavor.aur }} && \
|
|
||||||
makepkg -sf --cleanbuild --nodeps && \
|
|
||||||
makepkg --printsrcinfo > .SRCINFO.generated && \
|
|
||||||
cmp -s .SRCINFO .SRCINFO.generated && \
|
|
||||||
pkgfile=\$(find . -maxdepth 1 -name '*.pkg.tar.zst' -print -quit) && \
|
|
||||||
desktop_file=${{ matrix.flavor.aur }} && \
|
|
||||||
desktop_file=\${desktop_file%-bin}.desktop && \
|
|
||||||
tmpdir=\$(mktemp -d) && \
|
|
||||||
bsdtar -xOf \"\$pkgfile\" \"usr/share/applications/\$desktop_file\" > \"\$tmpdir/\$desktop_file\" && \
|
|
||||||
desktop-file-validate \"\$tmpdir/\$desktop_file\" && \
|
|
||||||
namcap PKGBUILD && \
|
|
||||||
namcap \"\$pkgfile\" && \
|
|
||||||
cd /workspace && \
|
|
||||||
mkdir -p release-assets && \
|
|
||||||
cp \$(find desktopApp/build/aur/${{ matrix.flavor.aur }} -maxdepth 1 -name '*.pkg.tar.zst' -print -quit) \
|
|
||||||
release-assets/${{ matrix.flavor.aur }}-${{ inputs.version }}-1-x86_64.pkg.tar.zst && \
|
|
||||||
./gradlew \
|
|
||||||
-PdesktopOnly=true \
|
|
||||||
-PdesktopFlavor=${{ matrix.flavor.gradle }} \
|
|
||||||
-PdesktopVersion=${{ inputs.version }} \
|
|
||||||
-PdesktopPackageVersion=${{ inputs.version }} \
|
|
||||||
-PdesktopAurPackageRelease=1 \
|
|
||||||
-PdesktopAurSourceUrl=https://github.com/${{ github.repository }}/releases/download/${{ inputs.release_tag }}/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x64.tar.gz \
|
|
||||||
:desktopApp:prepareAurPackage && \
|
|
||||||
cd desktopApp/build/aur/${{ matrix.flavor.aur }} && \
|
|
||||||
makepkg --printsrcinfo > .SRCINFO.generated && \
|
|
||||||
cmp -s .SRCINFO .SRCINFO.generated"
|
|
||||||
'
|
|
||||||
sudo chown -R "$USER:$USER" desktopApp/build release-assets
|
|
||||||
|
|
||||||
- name: Stage Arch package and AUR metadata
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p release-assets
|
|
||||||
tar -C "desktopApp/build/aur/${{ matrix.flavor.aur }}" \
|
|
||||||
-czf "release-assets/aur-${{ matrix.flavor.aur }}-${{ inputs.version }}.tar.gz" \
|
|
||||||
PKGBUILD .SRCINFO
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: arch-package-${{ matrix.flavor.label }}
|
|
||||||
path: release-assets/*
|
|
||||||
|
|
||||||
publish-release:
|
|
||||||
name: Publish draft release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- windows-msi
|
|
||||||
- ubuntu-deb-tar
|
|
||||||
- fedora-rpm
|
|
||||||
- arch-package
|
|
||||||
steps:
|
|
||||||
- uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: downloaded-artifacts
|
|
||||||
|
|
||||||
- name: Collect release assets
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p release-assets
|
|
||||||
find downloaded-artifacts -type f -exec cp {} release-assets/ \;
|
|
||||||
(cd release-assets && sha256sum * > SHA256SUMS.txt)
|
|
||||||
|
|
||||||
- name: Create or update GitHub release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if ! gh release view "${{ inputs.release_tag }}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
|
||||||
draft_flag=""
|
|
||||||
if [ "${{ inputs.draft }}" = "true" ]; then
|
|
||||||
draft_flag="--draft"
|
|
||||||
fi
|
|
||||||
gh release create "${{ inputs.release_tag }}" \
|
|
||||||
--repo "${{ github.repository }}" \
|
|
||||||
--title "Episteme ${{ inputs.version }}" \
|
|
||||||
--notes "Desktop release ${{ inputs.version }}" \
|
|
||||||
$draft_flag
|
|
||||||
fi
|
|
||||||
gh release upload "${{ inputs.release_tag }}" release-assets/* \
|
|
||||||
--repo "${{ github.repository }}" \
|
|
||||||
--clobber
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -21,9 +21,8 @@ google-services.json
|
||||||
.gradle/
|
.gradle/
|
||||||
third_party/pdfium/
|
third_party/pdfium/
|
||||||
*.tgz
|
*.tgz
|
||||||
|
kcef-bundle/
|
||||||
|
kcef-bundle-linux-x64/
|
||||||
cache/
|
cache/
|
||||||
worker/
|
worker/
|
||||||
output/
|
output/
|
||||||
policies/
|
|
||||||
episteme-bin/
|
|
||||||
episteme-oss-bin/
|
|
||||||
|
|
|
||||||
40
.idea/androidTestResultsUserPreferences.xml
generated
40
.idea/androidTestResultsUserPreferences.xml
generated
|
|
@ -162,19 +162,6 @@
|
||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
<entry key="-908638882">
|
|
||||||
<value>
|
|
||||||
<AndroidTestResultsTableState>
|
|
||||||
<option name="preferredColumnWidths">
|
|
||||||
<map>
|
|
||||||
<entry key="Duration" value="90" />
|
|
||||||
<entry key="Tests" value="360" />
|
|
||||||
<entry key="samsung SM-G990B2" value="120" />
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</AndroidTestResultsTableState>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
<entry key="-629314366">
|
<entry key="-629314366">
|
||||||
<value>
|
<value>
|
||||||
<AndroidTestResultsTableState>
|
<AndroidTestResultsTableState>
|
||||||
|
|
@ -322,19 +309,6 @@
|
||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
<entry key="326896270">
|
|
||||||
<value>
|
|
||||||
<AndroidTestResultsTableState>
|
|
||||||
<option name="preferredColumnWidths">
|
|
||||||
<map>
|
|
||||||
<entry key="Duration" value="90" />
|
|
||||||
<entry key="Tests" value="360" />
|
|
||||||
<entry key="samsung SM-G990B2" value="120" />
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</AndroidTestResultsTableState>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
<entry key="371509721">
|
<entry key="371509721">
|
||||||
<value>
|
<value>
|
||||||
<AndroidTestResultsTableState>
|
<AndroidTestResultsTableState>
|
||||||
|
|
@ -383,7 +357,6 @@
|
||||||
<entry key="Duration" value="90" />
|
<entry key="Duration" value="90" />
|
||||||
<entry key="Pixel_5" value="120" />
|
<entry key="Pixel_5" value="120" />
|
||||||
<entry key="Tests" value="360" />
|
<entry key="Tests" value="360" />
|
||||||
<entry key="samsung SM-G990B2" value="120" />
|
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
|
|
@ -561,19 +534,6 @@
|
||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
<entry key="1500844897">
|
|
||||||
<value>
|
|
||||||
<AndroidTestResultsTableState>
|
|
||||||
<option name="preferredColumnWidths">
|
|
||||||
<map>
|
|
||||||
<entry key="Duration" value="90" />
|
|
||||||
<entry key="Tests" value="360" />
|
|
||||||
<entry key="samsung SM-G990B2" value="120" />
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</AndroidTestResultsTableState>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
<entry key="1562612490">
|
<entry key="1562612490">
|
||||||
<value>
|
<value>
|
||||||
<AndroidTestResultsTableState>
|
<AndroidTestResultsTableState>
|
||||||
|
|
|
||||||
12
.idea/vcs.xml
generated
12
.idea/vcs.xml
generated
|
|
@ -1,12 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
|
||||||
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2" vcs="Git" />
|
|
||||||
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2/brotli" vcs="Git" />
|
|
||||||
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2/brotli/research/esaxx" vcs="Git" />
|
|
||||||
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2/brotli/research/libdivsufsort" vcs="Git" />
|
|
||||||
<mapping directory="$PROJECT_DIR$/episteme-bin" vcs="Git" />
|
|
||||||
<mapping directory="$PROJECT_DIR$/episteme-oss-bin" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
76
AGENTS.md
76
AGENTS.md
|
|
@ -1,76 +0,0 @@
|
||||||
# Book Reader — Episteme fork + bookshelf-api/ABS
|
|
||||||
|
|
||||||
> Форк `Aryan-Raj3112/episteme` под bookshelf-api + Audiobookshelf + Material You 3 (Myne-style UI).
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Язык:** Kotlin 100%
|
|
||||||
- **UI:** Jetpack Compose + Material3 (Myne-style: round cards, dynamic color, minimal)
|
|
||||||
- **DI:** Dagger Hilt
|
|
||||||
- **БД:** Room
|
|
||||||
- **Настройки:** DataStore
|
|
||||||
- **Сеть:** Retrofit + OkHttp + Kotlinx Serialization
|
|
||||||
- **Аудио:** ExoPlayer (Media3) + MediaSession
|
|
||||||
- **Фоновые задачи:** WorkManager
|
|
||||||
- **Читалка:** Episteme paginatedreader (CSS-движок, ContentBlock/Page модель)
|
|
||||||
- **Форматы:** EPUB, FB2, PDF, MOBI/AZW3, DOCX, ODT, TXT, Markdown, HTML, comics
|
|
||||||
- **minSdk:** 26 (Episteme default)
|
|
||||||
- **compileSdk/targetSdk:** 36
|
|
||||||
|
|
||||||
## Origin
|
|
||||||
|
|
||||||
- **upstream:** `https://github.com/Aryan-Raj3112/episteme.git` (AGPL-3.0)
|
|
||||||
- **origin:** `https://git.dueattendant149.org/Atte149/book-reader.git` (Forgejo)
|
|
||||||
- **Лицензия:** AGPL-3.0 (наследуется от Episteme)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
- Взять зрелый reader engine из Episteme (пагинация, все форматы, TTS, annotations)
|
|
||||||
- Redesign UI под Myne-стиль (Material You 3, round cards, dynamic color)
|
|
||||||
- Заменить backend (OPDS/Gutenberg/LocalFolder) на bookshelf-api + ABS
|
|
||||||
- Добавить фичи из Book's Story: audio player, RSVP, Librarr search, TTS jobs, cache, progress sync, upload
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
- `app/src/main/` — Android app (после KMP→Android упрощения)
|
|
||||||
- `app/src/oss/` — OSS flavor (без проприетарных ML Kit/cloud)
|
|
||||||
- `app/src/releaseOffline/` — offline-only flavor
|
|
||||||
- ~~`desktopApp/`~~ — удалён (Android-only)
|
|
||||||
- ~~`shared/`~~ — слит в `app/src/main/` (после Этапа 1)
|
|
||||||
|
|
||||||
## Backend
|
|
||||||
|
|
||||||
- **bookshelf-api:** `https://books.dueattendant149.org/api/v1/*`
|
|
||||||
- `GET /api/v1/books/libraries`
|
|
||||||
- `GET /api/v1/books/{item_id}`
|
|
||||||
- `GET /api/v1/books/library/{id}/items`
|
|
||||||
- `POST /api/v1/search`
|
|
||||||
- `POST /api/v1/tts`, `GET /api/v1/tts/{job_id}`
|
|
||||||
- `POST /api/v1/download`, `GET /api/v1/download/list`
|
|
||||||
- **Audiobookshelf (напрямую):**
|
|
||||||
- `GET /api/items/{id}/file/{ino}`
|
|
||||||
- `GET /api/items/{id}/cover`
|
|
||||||
- `GET /api/items/{id}/download`
|
|
||||||
|
|
||||||
## WARNs
|
|
||||||
|
|
||||||
- **Не пушить в `upstream` (Aryan-Raj3112/episteme).** Только в `origin` (Forgejo).
|
|
||||||
- **AGPL-3.0** — если публикуем, исходный код должен быть открыт (включая server-side modifications если есть network use).
|
|
||||||
- **`pro` flavor** содержит проприетарные компоненты (ML Kit OCR, cloud sync) — не использовать, только `oss`.
|
|
||||||
- **Секреты** (ABS token, API keys) — только в `.env` / DataStore, никогда в коде.
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew :app:assembleOssDebug
|
|
||||||
```
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
- Phase 0: clone + package rename — completed
|
|
||||||
- Phase 1: KMP → Android-only — pending
|
|
||||||
- Phase 2: backend swap (bookshelf-api + ABS) — pending
|
|
||||||
- Phase 3: UI redesign (Myne-style) — pending
|
|
||||||
- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — pending
|
|
||||||
- Phase 5: reader engine integration — pending
|
|
||||||
- Phase 6: build, test, deploy — pending
|
|
||||||
150
README.md
150
README.md
|
|
@ -5,63 +5,62 @@
|
||||||
<span> Episteme Reader</span>
|
<span> Episteme Reader</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p>A modern, offline-first, privacy-focused document and e-book reader for Android and desktop, built with Kotlin Multiplatform and Compose.</p>
|
<p>A modern, offline‑first, privacy‑focused document & e‑book reader for Android, built with Kotlin and Jetpack Compose.</p>
|
||||||
|
|
||||||
<a href="https://epistemereader.com"><img alt="Download from epistemereader.com" src="https://img.shields.io/badge/Download-epistemereader.com-2f6f5e?style=for-the-badge" height="44" align="absmiddle"/></a> <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://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>
|
</div>
|
||||||
|
|
||||||
<br/>
|
<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
|
## Overview
|
||||||
|
|
||||||
Episteme Reader is a customizable reader for documents, e-books, comics, and text-heavy files. The app is designed around local-first reading, deep typography controls, flexible layouts, and a consistent Kotlin Multiplatform core across Android and desktop.
|
Episteme Reader is a comprehensive, customizable reader for documents and e-books on Android. It features a modern Jetpack Compose UI, powerful reading tools, and extensive theming.
|
||||||
|
|
||||||
The same core reading experience is available across editions. The main differences are distribution channel, network access, and whether proprietary online services are included.
|
To best serve different user preferences regarding privacy and network usage, Episteme Reader is available in three distinct editions:
|
||||||
|
* **PlayStore Version:** The full-featured release which includes proprietary code and features.
|
||||||
|
* **OSS Version (GitHub/F-Droid):** A fully open-source build.
|
||||||
|
* **OSS Offline Version (GitHub):** A strictly offline build with network permissions completely removed.
|
||||||
|
|
||||||
## Core Features
|
---
|
||||||
|
|
||||||
Available across supported editions unless noted in the edition table:
|
## Feature Comparison
|
||||||
|
|
||||||
* **Formats:** PDF, EPUB, MOBI/AZW3, FB2, DOCX, ODT/FODT, TXT, Markdown, HTML, and comic archives.
|
### 📚 Supported Formats
|
||||||
* **Reading modes:** Paginated reading, vertical scroll, PDF multi-tab reading, PDF reflow, auto-scroll, and musician mode.
|
| Feature | PlayStore | OSS | OSS Offline |
|
||||||
* **PDF tools:** Ink annotations, highlighting, erasing, text annotations, and reading-focused PDF controls.
|
| :--- | :---: | :---: | :---: |
|
||||||
* **Customization:** App themes, reader themes, custom local fonts, typography controls, spacing, margins, and layout tuning.
|
| **Documents:** PDF, DOCX, ODT/FODT | ✅ | ✅ | ✅ |
|
||||||
* **Library tools:** Local folder sync, library organization, bookmarks, progress tracking, and file management.
|
| **E-books & Text:** EPUB, MOBI, AZW3, FB2, MD, HTML, TXT | ✅ | ✅ | ✅ |
|
||||||
* **Accessibility:** System text-to-speech, app language selection, and reader settings that adapt to different reading preferences.
|
| **Comics:** CBZ, CBR, CB7 | ✅ | ✅ | ✅ |
|
||||||
|
| **View-Only:** CSV, TSV, JSON, XML, Logs, Code Files | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
## Editions
|
### 📖 Core Reading Experience
|
||||||
|
| Feature | PlayStore | OSS | OSS Offline |
|
||||||
|
| :--- | :---: | :---: | :---: |
|
||||||
|
| **Display Modes:** Paginated & Vertical Scroll | ✅ | ✅ | ✅ |
|
||||||
|
| **PDF Multi-Tab Reading & Reflow** | ✅ | ✅ | ✅ |
|
||||||
|
| **PDF Annotations:** Ink (Pen, Highlight, Erase) & Text | ✅ | ✅ | ✅ |
|
||||||
|
| **App-wide Customization & Reader Theming** | ✅ | ✅ | ✅ |
|
||||||
|
| **Custom Fonts:** Local Import (TTF and OTF) | ✅ | ✅ | ✅ |
|
||||||
|
| **Typography Control** | ✅ | ✅ | ✅ |
|
||||||
|
| **Auto-Scroll & Musician Mode** | ✅ | ✅ | ✅ |
|
||||||
|
| **System Text-to-Speech (TTS)** | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
| Edition | Platform | Network access | Distribution | Notes |
|
### ⚙️ Advanced & Network Features
|
||||||
|---|---|---|---|---|
|
| Feature | PlayStore | OSS (F-Droid) | OSS Offline |
|
||||||
| **Play Store** | Android | Online-capable | Google Play | Full Android release with proprietary extras such as ML Kit OCR, cloud sync, AI tools, cloud TTS, and PDF bubble zoom. |
|
| :--- | :---: | :---: | :---: |
|
||||||
| **OSS** | Android | Online-capable | [epistemereader.com](https://epistemereader.com), GitHub, F-Droid, Obtainium | Fully open-source Android build with OPDS, downloadable fonts, and BYOK access to AI and cloud features. |
|
| **Local Folder Sync & Library Management** | ✅ | ✅ | ✅ |
|
||||||
| **OSS Offline** | Android | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub, Obtainium | Open-source Android build with network permissions removed. |
|
| **Download Google Fonts** | ✅ | ✅ | ❌ |
|
||||||
| **Standard** | Windows desktop | Online-capable | [epistemereader.com](https://epistemereader.com), GitHub | Full-featured desktop release with the shared KMP reader core and online-capable services. |
|
| **OPDS Catalog Support** | ✅ | ✅ | ❌ |
|
||||||
| **Offline** | Windows desktop | Offline-only | [epistemereader.com](https://epistemereader.com), GitHub | Desktop build focused on local reading with online services disabled. |
|
| **PDF Bubble Zoom Magnifier** | ✅ | ❌ | ❌ |
|
||||||
|
| **ML Kit OCR** (Scanned PDF Text Selection) | ✅ | ❌ | ❌ |
|
||||||
|
| **Cross-Device Cloud Sync** | ✅ | ❌ | ❌ |
|
||||||
|
| **AI Tools** (Summaries, Story Recap, Dictionary) | ✅ | 🔜 | ❌ |
|
||||||
|
| **Cloud Text-to-Speech** | ✅ | 🔜 | ❌ |
|
||||||
|
|
||||||
Future desktop platforms can use the same Standard and Offline model as support expands.
|
---
|
||||||
|
|
||||||
## Languages
|
|
||||||
|
|
||||||
Episteme Reader currently supports: English, Arabic, Belarusian, German, Spanish, Estonian, French, Hindi, Indonesian, Italian, Japanese, Korean, Dutch, Polish, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, and Chinese Simplified.
|
|
||||||
|
|
||||||
Want Episteme Reader in another language? Please request it through [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose) or start a thread in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions).
|
|
||||||
|
|
||||||
## Building from Source
|
## Building from Source
|
||||||
|
|
||||||
|
|
@ -71,7 +70,7 @@ Want Episteme Reader in another language? Please request it through [GitHub Issu
|
||||||
cd episteme
|
cd episteme
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Build Android:
|
2. Build:
|
||||||
* Open in Android Studio and run the `ossDebug` or `ossOfflineDebug` variant, or
|
* Open in Android Studio and run the `ossDebug` or `ossOfflineDebug` variant, or
|
||||||
* Build from the command line:
|
* Build from the command line:
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -80,56 +79,43 @@ Want Episteme Reader in another language? Please request it through [GitHub Issu
|
||||||
The APK will be generated at:
|
The APK will be generated at:
|
||||||
`app/build/outputs/apk/oss/debug/Episteme-oss-v{version}-oss-debug.apk`
|
`app/build/outputs/apk/oss/debug/Episteme-oss-v{version}-oss-debug.apk`
|
||||||
|
|
||||||
3. Build desktop:
|
|
||||||
```bash
|
|
||||||
./gradlew :desktopApp:packageReleaseDistributionForCurrentOS
|
|
||||||
```
|
|
||||||
For the offline desktop build, pass:
|
|
||||||
```bash
|
|
||||||
./gradlew :desktopApp:packageReleaseDistributionForCurrentOS -PdesktopFlavor=oss
|
|
||||||
```
|
|
||||||
|
|
||||||
## Open Source Libraries
|
## Open Source Libraries
|
||||||
|
|
||||||
Powered by the Kotlin, Android, and desktop OSS ecosystem:
|
Powered by the Android OSS ecosystem:
|
||||||
|
* **Core & UI:** AndroidX, Jetpack Compose, Kotlinx Serialization
|
||||||
* **Core and UI:** Kotlin Multiplatform, Compose Multiplatform, AndroidX, Jetpack Compose, Kotlinx Serialization
|
* **Document Engines:** PdfiumAndroidKt (PDF), libmobi (MOBI/AZW3)
|
||||||
* **Document engines:** PdfiumAndroidKt, PDFium, libmobi
|
* **Parsers:** Jsoup (HTML/EPUB), Flexmark (Markdown)
|
||||||
* **Parsers:** Jsoup, Flexmark, Apache Commons Compress
|
* **Media & Image Loading:** Coil, Media3 (ExoPlayer)
|
||||||
* **Media and image loading:** Coil, Media3
|
* **Utilities:** Room (Database), Timber (Logging)
|
||||||
* **Utilities:** Room, Timber, JNA
|
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
| Contributor | Contribution |
|
| Contributor | Contribution |
|
||||||
|---|---|
|
|---|---|
|
||||||
| <img src="https://github.com/CCerrer.png?size=48" width="24" height="24" valign="middle" alt="CCerrer avatar"> [CCerrer](https://github.com/CCerrer) | Testing and QA |
|
| <img src="https://github.com/CCerrer.png?size=48" width="24" height="24" valign="middle" alt="CCerrer avatar">[CCerrer](https://github.com/CCerrer) | Testing & QA |
|
||||||
| <img src="https://github.com/ottozumkeller.png?size=48" width="24" height="24" valign="middle" alt="ottozumkeller avatar"> [ottozumkeller](https://github.com/ottozumkeller) | German translation |
|
| <img src="https://github.com/ottozumkeller.png?size=48" width="24" height="24" valign="middle" alt="ottozumkeller avatar"> [ottozumkeller](https://github.com/ottozumkeller) | Translation (German) |
|
||||||
| <img src="https://github.com/TURBOKANTR.png?size=48" width="24" height="24" valign="middle" alt="TURBOKANTR avatar"> [TURBOKANTR](https://github.com/TURBOKANTR) | Turkish translation |
|
| <img src="https://github.com/TURBOKANTR.png?size=48" width="24" height="24" valign="middle" alt="TURBOKANTR avatar"> [TURBOKANTR](https://github.com/TURBOKANTR) | Translation (Turkish) |
|
||||||
| <img src="https://github.com/eyadalkordy24.png?size=48" width="24" height="24" valign="middle" alt="eyadalkordy24 avatar"> [eyadalkordy24](https://github.com/eyadalkordy24) | Arabic translation |
|
| <img src="https://github.com/eyadalkordy24.png?size=48" width="24" height="24" valign="middle" alt="eyadalkordy24 avatar">[eyadalkordy24](https://github.com/eyadalkordy24) | Translation (Arabic) |
|
||||||
| <img src="https://github.com/berebara.png?size=48" width="24" height="24" valign="middle" alt="berebara avatar"> [berebara](https://github.com/berebara) | Russian translation |
|
| <img src="https://github.com/berebara.png?size=48" width="24" height="24" valign="middle" alt="berebara avatar">[berebara](https://github.com/berebara) | Translation (Russian) |
|
||||||
| <img src="https://github.com/mh4ckt3mh4ckt1c4s.png?size=48" width="24" height="24" valign="middle" alt="mh4ckt3mh4ckt1c4s avatar"> [mh4ckt3mh4ckt1c4s](https://github.com/mh4ckt3mh4ckt1c4s) | French translation |
|
| <img src="https://github.com/mh4ckt3mh4ckt1c4s.png?size=48" width="24" height="24" valign="middle" alt="mh4ckt3mh4ckt1c4s avatar">[mh4ckt3mh4ckt1c4s](https://github.com/mh4ckt3mh4ckt1c4s) | Translation (French) |
|
||||||
|
|
||||||
## Supporters
|
## Translations
|
||||||
|
|
||||||
Thank you to the people helping keep Episteme Reader moving:
|
Help translate Episteme Reader into your native language! [Weblate](https://hosted.weblate.org/engage/episteme/) is used to manage localization.
|
||||||
|
|
||||||
| Supporter | Platform |
|
[](https://hosted.weblate.org/engage/episteme/)
|
||||||
|---|---|
|
|
||||||
| <img src="https://github.com/Zorklo.png?size=48" width="24" height="24" valign="middle" alt="Zorklo avatar"> [Zorklo](https://github.com/Zorklo) | GitHub Sponsors |
|
|
||||||
|
|
||||||
## Support the Project
|
|
||||||
|
|
||||||
Help make Episteme Reader better:
|
|
||||||
|
|
||||||
* [Sponsor on GitHub](https://github.com/sponsors/Aryan-Raj3112)
|
|
||||||
* [Support on Patreon](https://www.patreon.com/c/epistemereader)
|
|
||||||
* Star the repository to help visibility
|
|
||||||
* Report bugs or request features via [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose)
|
|
||||||
* Share feedback in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions)
|
|
||||||
* Leave a review on the [Google Play Store](https://play.google.com/store/apps/details?id=com.aryan.reader)
|
|
||||||
* Tell a friend
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See the [LICENSE](LICENSE) file.
|
Licensed under the GNU Affero General Public License v3.0 (AGPL‑3.0). See the [LICENSE](LICENSE) file.
|
||||||
|
|
||||||
|
## Support the Project
|
||||||
|
|
||||||
|
Help make Episteme Reader even better:
|
||||||
|
|
||||||
|
* ❤️ [Support on Patreon](https://www.patreon.com/c/epistemereader)
|
||||||
|
* ⭐ Star the repository to help visibility
|
||||||
|
* 🐞 Report bugs or request features via [GitHub Issues](https://github.com/Aryan-Raj3112/episteme/issues/new/choose)
|
||||||
|
* 💬 Share feedback in [Discussions](https://github.com/Aryan-Raj3112/episteme/discussions)
|
||||||
|
* ✍️ Leave a review on the [Google Play Store](https://play.google.com/store/apps/details?id=com.aryan.reader)
|
||||||
|
* 📣 Tell a friend!
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
|
||||||
alias(libs.plugins.kotlin.ksp)
|
alias(libs.plugins.kotlin.ksp)
|
||||||
id("com.diffplug.spotless") version "8.2.1"
|
id("com.diffplug.spotless") version "8.2.1"
|
||||||
alias(libs.plugins.kover)
|
alias(libs.plugins.kover)
|
||||||
|
|
@ -50,15 +50,15 @@ kotlin {
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "org.dueattendant149.bookreader"
|
namespace = "com.aryan.reader"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "org.dueattendant149.bookreader"
|
applicationId = "com.aryan.reader"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 54
|
versionCode = 53
|
||||||
versionName = "1.0.50"
|
versionName = "1.0.49"
|
||||||
|
|
||||||
resourceConfigurations += configuredAppLocaleTags()
|
resourceConfigurations += configuredAppLocaleTags()
|
||||||
.map { it.toAndroidResourceConfiguration() }
|
.map { it.toAndroidResourceConfiguration() }
|
||||||
|
|
@ -171,7 +171,6 @@ android {
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
unitTests.all {
|
unitTests.all {
|
||||||
it.maxHeapSize = "4g"
|
|
||||||
it.jvmArgs("-Xss2m")
|
it.jvmArgs("-Xss2m")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -244,6 +243,8 @@ dependencies {
|
||||||
|
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
|
implementation("androidx.compose.material:material-icons-extended:1.7.8")
|
||||||
|
|
||||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
|
|
||||||
//noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break)
|
//noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break)
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,48 +0,0 @@
|
||||||
# EPUB UI Test Fixture
|
|
||||||
|
|
||||||
`reader_test_book.epub` is a deterministic EPUB fixture for Android instrumentation and UI tests.
|
|
||||||
|
|
||||||
Use it for reader flows that need stable EPUB content:
|
|
||||||
|
|
||||||
- open/import EPUB through a `content://` URI
|
|
||||||
- restore reading position
|
|
||||||
- navigate with chapter anchors
|
|
||||||
- create or verify bookmarks
|
|
||||||
- search unique fixture markers
|
|
||||||
- exercise highlight and CFI-related flows
|
|
||||||
|
|
||||||
The generated EPUB is stored at:
|
|
||||||
|
|
||||||
`app/src/androidTest/assets/epub/reader_test_book.epub`
|
|
||||||
|
|
||||||
The source files live in:
|
|
||||||
|
|
||||||
`app/src/androidTest/fixtures/epub/reader_test_book`
|
|
||||||
|
|
||||||
Regenerate the EPUB after editing the source:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python app/src/androidTest/fixtures/epub/build_reader_test_book.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Tests should copy the asset from the instrumentation context, not the target app context:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
val testContext = InstrumentationRegistry.getInstrumentation().context
|
|
||||||
testContext.assets.open("epub/reader_test_book.epub")
|
|
||||||
```
|
|
||||||
|
|
||||||
Current basic UI coverage using this fixture lives in:
|
|
||||||
|
|
||||||
`app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderScreenTest.kt`
|
|
||||||
|
|
||||||
Stable markers intentionally embedded in the book:
|
|
||||||
|
|
||||||
- `POSITION_TARGET_ALPHA`
|
|
||||||
- `HIGHLIGHT_TARGET_BRAVO`
|
|
||||||
- `CFI_TARGET_CHARLIE`
|
|
||||||
- `SEARCH_TARGET_DELTA`
|
|
||||||
- `BOOKMARK_TARGET_ECHO`
|
|
||||||
- `POSITION_TARGET_FOXTROT`
|
|
||||||
- `ANNOTATION_TARGET_GOLF`
|
|
||||||
- `CFI_TARGET_HOTEL`
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile, ZipInfo
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
|
||||||
SOURCE_DIR = ROOT / "reader_test_book"
|
|
||||||
OUTPUT = ROOT.parents[1] / "assets" / "epub" / "reader_test_book.epub"
|
|
||||||
FIXED_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def add_file(epub: ZipFile, source: Path, archive_name: str, compression: int) -> None:
|
|
||||||
info = ZipInfo(archive_name, FIXED_TIMESTAMP)
|
|
||||||
info.compress_type = compression
|
|
||||||
info.external_attr = 0o644 << 16
|
|
||||||
epub.writestr(info, source.read_bytes())
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with ZipFile(OUTPUT, "w") as epub:
|
|
||||||
info = ZipInfo("mimetype", FIXED_TIMESTAMP)
|
|
||||||
info.compress_type = ZIP_STORED
|
|
||||||
info.external_attr = 0o644 << 16
|
|
||||||
epub.writestr(info, b"application/epub+zip")
|
|
||||||
|
|
||||||
for source in sorted(SOURCE_DIR.rglob("*")):
|
|
||||||
if not source.is_file() or source.name == "mimetype":
|
|
||||||
continue
|
|
||||||
archive_name = source.relative_to(SOURCE_DIR).as_posix()
|
|
||||||
add_file(epub, source, archive_name, ZIP_DEFLATED)
|
|
||||||
|
|
||||||
print(f"Wrote {OUTPUT}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
|
||||||
<rootfiles>
|
|
||||||
<rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/>
|
|
||||||
</rootfiles>
|
|
||||||
</container>
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
|
|
||||||
<head>
|
|
||||||
<title>Chapter One</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="../styles/reader-test.css"/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<section id="chapter-one">
|
|
||||||
<h1 id="chapter-one-heading">Chapter One: Stable Opening</h1>
|
|
||||||
<p id="opening-paragraph">This EPUB is intentionally plain so Android UI tests can rely on stable text, stable IDs, and stable chapter order.</p>
|
|
||||||
<p id="position-target-alpha"><span class="target">POSITION_TARGET_ALPHA</span> appears near the start of chapter one. Use this marker for first-position and restore-position checks.</p>
|
|
||||||
<p id="highlight-target-bravo"><span class="target">HIGHLIGHT_TARGET_BRAVO</span> is a short highlight target. It is surrounded by ordinary words so selection handles have context.</p>
|
|
||||||
<p id="cfi-target-charlie"><span class="target">CFI_TARGET_CHARLIE</span> sits inside a paragraph with a fixed element id. It can be used for CFI and locator assertions.</p>
|
|
||||||
<p id="chapter-one-filler-01">Chapter one filler paragraph 01 keeps the document tall enough for scroll and pagination tests.</p>
|
|
||||||
<p id="chapter-one-filler-02">Chapter one filler paragraph 02 keeps the document tall enough for scroll and pagination tests.</p>
|
|
||||||
<p id="chapter-one-filler-03">Chapter one filler paragraph 03 keeps the document tall enough for scroll and pagination tests.</p>
|
|
||||||
<p id="chapter-one-filler-04">Chapter one filler paragraph 04 keeps the document tall enough for scroll and pagination tests.</p>
|
|
||||||
<p id="chapter-one-filler-05">Chapter one filler paragraph 05 keeps the document tall enough for scroll and pagination tests.</p>
|
|
||||||
<p id="chapter-one-filler-06">Chapter one filler paragraph 06 keeps the document tall enough for scroll and pagination tests.</p>
|
|
||||||
<p id="chapter-one-filler-07">Chapter one filler paragraph 07 keeps the document tall enough for scroll and pagination tests.</p>
|
|
||||||
<p id="chapter-one-filler-08">Chapter one filler paragraph 08 keeps the document tall enough for scroll and pagination tests.</p>
|
|
||||||
<p id="chapter-one-end">END_OF_CHAPTER_ONE_MARKER</p>
|
|
||||||
</section>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
|
|
||||||
<head>
|
|
||||||
<title>Chapter Two</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="../styles/reader-test.css"/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<section id="chapter-two">
|
|
||||||
<h1 id="chapter-two-heading">Chapter Two: Search And Bookmarks</h1>
|
|
||||||
<p id="search-target-delta"><span class="target">SEARCH_TARGET_DELTA</span> appears exactly once in the book. Use it for search result navigation.</p>
|
|
||||||
<p id="bookmark-target-echo"><span class="target">BOOKMARK_TARGET_ECHO</span> is positioned near the top of chapter two for bookmark add, list, and return flows.</p>
|
|
||||||
<p id="position-target-foxtrot"><span class="target">POSITION_TARGET_FOXTROT</span> is lower in chapter two and is useful for persistence tests after a chapter change.</p>
|
|
||||||
<p id="chapter-two-filler-01">Chapter two filler paragraph 01 provides enough height for gesture based navigation.</p>
|
|
||||||
<p id="chapter-two-filler-02">Chapter two filler paragraph 02 provides enough height for gesture based navigation.</p>
|
|
||||||
<p id="chapter-two-filler-03">Chapter two filler paragraph 03 provides enough height for gesture based navigation.</p>
|
|
||||||
<p id="chapter-two-filler-04">Chapter two filler paragraph 04 provides enough height for gesture based navigation.</p>
|
|
||||||
<p id="chapter-two-filler-05">Chapter two filler paragraph 05 provides enough height for gesture based navigation.</p>
|
|
||||||
<p id="chapter-two-filler-06">Chapter two filler paragraph 06 provides enough height for gesture based navigation.</p>
|
|
||||||
<p id="chapter-two-filler-07">Chapter two filler paragraph 07 provides enough height for gesture based navigation.</p>
|
|
||||||
<p id="chapter-two-filler-08">Chapter two filler paragraph 08 provides enough height for gesture based navigation.</p>
|
|
||||||
<p id="chapter-two-end">END_OF_CHAPTER_TWO_MARKER</p>
|
|
||||||
</section>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
|
|
||||||
<head>
|
|
||||||
<title>Chapter Three</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="../styles/reader-test.css"/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<section id="chapter-three">
|
|
||||||
<h1 id="chapter-three-heading">Chapter Three: Annotation Targets</h1>
|
|
||||||
<p id="annotation-target-golf"><span class="target">ANNOTATION_TARGET_GOLF</span> is reserved for tests that verify highlight or note persistence across app restarts.</p>
|
|
||||||
<p id="cfi-target-hotel"><span class="target">CFI_TARGET_HOTEL</span> is a second fixed CFI target in a later chapter.</p>
|
|
||||||
<p id="inline-image-note" class="fixture-note">The following SVG is local to the EPUB and can be used later for image rendering checks.</p>
|
|
||||||
<figure id="fixture-diagram">
|
|
||||||
<img src="../images/fixture-diagram.svg" alt="Fixture diagram"/>
|
|
||||||
<figcaption>Local SVG image for EPUB resource resolution.</figcaption>
|
|
||||||
</figure>
|
|
||||||
<p id="chapter-three-filler-01">Chapter three filler paragraph 01 rounds out the fixture.</p>
|
|
||||||
<p id="chapter-three-filler-02">Chapter three filler paragraph 02 rounds out the fixture.</p>
|
|
||||||
<p id="chapter-three-filler-03">Chapter three filler paragraph 03 rounds out the fixture.</p>
|
|
||||||
<p id="chapter-three-end">END_OF_CHAPTER_THREE_MARKER</p>
|
|
||||||
</section>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="120" viewBox="0 0 320 120" role="img" aria-labelledby="title desc">
|
|
||||||
<title id="title">Fixture diagram</title>
|
|
||||||
<desc id="desc">A simple local SVG used by EPUB UI tests.</desc>
|
|
||||||
<rect x="1" y="1" width="318" height="118" fill="#f6f8fb" stroke="#4a90e2" stroke-width="2"/>
|
|
||||||
<circle cx="70" cy="60" r="28" fill="#4a90e2"/>
|
|
||||||
<rect x="130" y="34" width="130" height="52" fill="#2f855a"/>
|
|
||||||
<text x="160" y="66" font-size="16" fill="#ffffff">EPUB FIXTURE</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 568 B |
|
|
@ -1,24 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en" xml:lang="en">
|
|
||||||
<head>
|
|
||||||
<title>Reader Android UI Test Book</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="styles/reader-test.css"/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav epub:type="toc" id="toc">
|
|
||||||
<h1>Contents</h1>
|
|
||||||
<ol>
|
|
||||||
<li><a href="chapters/chapter-01.xhtml#chapter-one-heading">Chapter One</a></li>
|
|
||||||
<li><a href="chapters/chapter-02.xhtml#chapter-two-heading">Chapter Two</a></li>
|
|
||||||
<li><a href="chapters/chapter-03.xhtml#chapter-three-heading">Chapter Three</a></li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
<nav epub:type="landmarks" id="landmarks">
|
|
||||||
<h2>Landmarks</h2>
|
|
||||||
<ol>
|
|
||||||
<li><a epub:type="bodymatter" href="chapters/chapter-01.xhtml">Start</a></li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id" xml:lang="en">
|
|
||||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
||||||
<dc:identifier id="book-id">urn:uuid:reader-android-ui-test-epub</dc:identifier>
|
|
||||||
<dc:title>Reader Android UI Test Book</dc:title>
|
|
||||||
<dc:creator>Reader Test Fixtures</dc:creator>
|
|
||||||
<dc:language>en</dc:language>
|
|
||||||
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
|
|
||||||
</metadata>
|
|
||||||
<manifest>
|
|
||||||
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
|
||||||
<item id="style" href="styles/reader-test.css" media-type="text/css"/>
|
|
||||||
<item id="chapter-1" href="chapters/chapter-01.xhtml" media-type="application/xhtml+xml"/>
|
|
||||||
<item id="chapter-2" href="chapters/chapter-02.xhtml" media-type="application/xhtml+xml"/>
|
|
||||||
<item id="chapter-3" href="chapters/chapter-03.xhtml" media-type="application/xhtml+xml"/>
|
|
||||||
<item id="inline-svg" href="images/fixture-diagram.svg" media-type="image/svg+xml"/>
|
|
||||||
</manifest>
|
|
||||||
<spine>
|
|
||||||
<itemref idref="chapter-1"/>
|
|
||||||
<itemref idref="chapter-2"/>
|
|
||||||
<itemref idref="chapter-3"/>
|
|
||||||
</spine>
|
|
||||||
</package>
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
body {
|
|
||||||
font-family: serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0 0 1em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fixture-note {
|
|
||||||
border-left: 0.25em solid #4a90e2;
|
|
||||||
padding-left: 0.75em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.target {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
application/epub+zip
|
|
||||||
146
app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt
Normal file
146
app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
// AppNavigationTest.kt
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||||
|
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.compose.ComposeNavigator
|
||||||
|
import androidx.navigation.testing.TestNavHostController
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.aryan.reader.epub.EpubBook
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AppNavigationTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
private lateinit var navController: TestNavHostController
|
||||||
|
private val fakeUiState = MutableStateFlow(ReaderScreenState())
|
||||||
|
|
||||||
|
// Mock ViewModel that uses the fake state
|
||||||
|
private val fakeViewModel: MainViewModel = object : MainViewModel(
|
||||||
|
ApplicationProvider.getApplicationContext()
|
||||||
|
) {
|
||||||
|
override val uiState = fakeUiState
|
||||||
|
override fun clearSelectedFile() {
|
||||||
|
fakeUiState.value = fakeUiState.value.copy(
|
||||||
|
selectedFileType = null,
|
||||||
|
selectedPdfUri = null,
|
||||||
|
selectedEpubBook = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
navController = TestNavHostController(LocalContext.current)
|
||||||
|
navController.navigatorProvider.addNavigator(ComposeNavigator())
|
||||||
|
AppNavigation(
|
||||||
|
navController = navController,
|
||||||
|
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(400.dp, 800.dp)),
|
||||||
|
viewModel = fakeViewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appNavigation_defaultStartDestination_isMainRoute() {
|
||||||
|
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||||
|
assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appNavigation_whenPdfSelected_navigatesToPdfViewer() {
|
||||||
|
// Trigger state change
|
||||||
|
fakeUiState.value = ReaderScreenState(
|
||||||
|
selectedFileType = FileType.PDF,
|
||||||
|
selectedPdfUri = Uri.parse("content://dummy.pdf")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Let compose recompose and run LaunchedEffect
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||||
|
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appNavigation_whenPptxSelected_navigatesToPdfViewer() {
|
||||||
|
fakeUiState.value = ReaderScreenState(
|
||||||
|
selectedFileType = FileType.PPTX,
|
||||||
|
selectedPdfUri = Uri.parse("content://dummy.pptx")
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||||
|
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appNavigation_whenEpubSelected_navigatesToEpubReader() {
|
||||||
|
// Trigger state change
|
||||||
|
fakeUiState.value = ReaderScreenState(
|
||||||
|
selectedFileType = FileType.EPUB,
|
||||||
|
selectedEpubBook = EpubBook(
|
||||||
|
fileName = "dummy.epub",
|
||||||
|
title = "Dummy Book",
|
||||||
|
author = "Author",
|
||||||
|
language = "en",
|
||||||
|
coverImage = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||||
|
assertEquals(AppDestinations.EPUB_READER_ROUTE, currentRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appNavigation_whenFileCleared_navigatesBackToMain() {
|
||||||
|
// First, navigate to PDF viewer
|
||||||
|
fakeUiState.value = ReaderScreenState(
|
||||||
|
selectedFileType = FileType.PDF,
|
||||||
|
selectedPdfUri = Uri.parse("content://dummy.pdf")
|
||||||
|
)
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route)
|
||||||
|
|
||||||
|
// Then, trigger the clear action (simulating onNavigateBack)
|
||||||
|
fakeViewModel.clearSelectedFile()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||||
|
assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appNavigation_whenUnknownFileTypeSelected_navigatesBackToMain() {
|
||||||
|
fakeUiState.value = ReaderScreenState(
|
||||||
|
selectedFileType = FileType.PDF,
|
||||||
|
selectedPdfUri = Uri.parse("content://dummy.pdf")
|
||||||
|
)
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route)
|
||||||
|
|
||||||
|
fakeUiState.value = ReaderScreenState(selectedFileType = FileType.UNKNOWN)
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(AppDestinations.MAIN_ROUTE, navController.currentBackStackEntry?.destination?.route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -1,19 +1,13 @@
|
||||||
package org.dueattendant149.bookreader.epubreader
|
package com.aryan.reader.epubreader
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class ChapterWebViewBridgeTest {
|
class ChapterWebViewBridgeTest {
|
||||||
|
|
@ -26,8 +20,7 @@ class ChapterWebViewBridgeTest {
|
||||||
var receivedCfi = ""
|
var receivedCfi = ""
|
||||||
val bridge = CfiJsBridge(
|
val bridge = CfiJsBridge(
|
||||||
onCfiReady = { cfi -> receivedCfi = cfi },
|
onCfiReady = { cfi -> receivedCfi = cfi },
|
||||||
onCfiForBookmarkReady = {},
|
onCfiForBookmarkReady = {}
|
||||||
onScrollFinishedCallback = {}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val cfi = "/4/2[chapter1]/6:10"
|
val cfi = "/4/2[chapter1]/6:10"
|
||||||
|
|
@ -46,8 +39,7 @@ class ChapterWebViewBridgeTest {
|
||||||
var receivedCfi = ""
|
var receivedCfi = ""
|
||||||
val bridge = CfiJsBridge(
|
val bridge = CfiJsBridge(
|
||||||
onCfiReady = { cfi -> receivedCfi = cfi },
|
onCfiReady = { cfi -> receivedCfi = cfi },
|
||||||
onCfiForBookmarkReady = {},
|
onCfiForBookmarkReady = {}
|
||||||
onScrollFinishedCallback = {}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val invalidJson = "this is not json"
|
val invalidJson = "this is not json"
|
||||||
|
|
@ -62,8 +54,7 @@ class ChapterWebViewBridgeTest {
|
||||||
var receivedCfi: String? = null
|
var receivedCfi: String? = null
|
||||||
val bridge = CfiJsBridge(
|
val bridge = CfiJsBridge(
|
||||||
onCfiReady = { cfi -> receivedCfi = cfi },
|
onCfiReady = { cfi -> receivedCfi = cfi },
|
||||||
onCfiForBookmarkReady = {},
|
onCfiForBookmarkReady = {}
|
||||||
onScrollFinishedCallback = {}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val jsonResponse = JSONObject().apply {
|
val jsonResponse = JSONObject().apply {
|
||||||
|
|
@ -78,51 +69,29 @@ class ChapterWebViewBridgeTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() {
|
fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() = runTest {
|
||||||
val latch = CountDownLatch(1)
|
|
||||||
var receivedJson: String? = null
|
var receivedJson: String? = null
|
||||||
val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
val bridge = TtsJsBridge(
|
||||||
try {
|
scope = this,
|
||||||
val bridge = TtsJsBridge(
|
ttsStructuredTextHandler = { json -> receivedJson = json }
|
||||||
scope = bridgeScope,
|
)
|
||||||
ttsStructuredTextHandler = { json ->
|
val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]"
|
||||||
receivedJson = json
|
bridge.onStructuredTextExtracted(jsonPayload)
|
||||||
latch.countDown()
|
advanceUntilIdle()
|
||||||
}
|
assertThat(receivedJson).isEqualTo(jsonPayload)
|
||||||
)
|
|
||||||
val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]"
|
|
||||||
|
|
||||||
bridge.onStructuredTextExtracted(jsonPayload)
|
|
||||||
|
|
||||||
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
|
|
||||||
assertThat(receivedJson).isEqualTo(jsonPayload)
|
|
||||||
} finally {
|
|
||||||
bridgeScope.cancel()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() {
|
fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() = runTest {
|
||||||
val latch = CountDownLatch(1)
|
|
||||||
var receivedJson: String? = null
|
var receivedJson: String? = null
|
||||||
val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
val bridge = TtsJsBridge(
|
||||||
try {
|
scope = this,
|
||||||
val bridge = TtsJsBridge(
|
ttsStructuredTextHandler = { json -> receivedJson = json }
|
||||||
scope = bridgeScope,
|
)
|
||||||
ttsStructuredTextHandler = { json ->
|
val jsonPayload = ""
|
||||||
receivedJson = json
|
bridge.onStructuredTextExtracted(jsonPayload)
|
||||||
latch.countDown()
|
advanceUntilIdle()
|
||||||
}
|
assertThat(receivedJson).isEqualTo("[]")
|
||||||
)
|
|
||||||
val jsonPayload = ""
|
|
||||||
|
|
||||||
bridge.onStructuredTextExtracted(jsonPayload)
|
|
||||||
|
|
||||||
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
|
|
||||||
assertThat(receivedJson).isEqualTo("[]")
|
|
||||||
} finally {
|
|
||||||
bridgeScope.cancel()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
package org.dueattendant149.bookreader.epubreader
|
package com.aryan.reader.epubreader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import org.dueattendant149.bookreader.epub.EpubChapter
|
import com.aryan.reader.epub.EpubChapter
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
// app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt
|
||||||
|
package com.aryan.reader.epubreader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import timber.log.Timber
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.aryan.reader.SearchResult
|
||||||
|
import com.aryan.reader.epub.EpubBook
|
||||||
|
import com.aryan.reader.epub.EpubChapter
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class EpubReaderLogicTest {
|
||||||
|
|
||||||
|
private lateinit var context: Context
|
||||||
|
private lateinit var testDir: File
|
||||||
|
private lateinit var mockEpubBook: EpubBook
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
testDir = File(context.cacheDir, "test_epub").apply { mkdirs() }
|
||||||
|
|
||||||
|
// Create dummy chapter files
|
||||||
|
val chapter1File = File(testDir, "chapter1.html")
|
||||||
|
chapter1File.writeText("<html><body><p>A simple Test case.</p></body></html>")
|
||||||
|
|
||||||
|
val chapter2File = File(testDir, "chapter2.html")
|
||||||
|
chapter2File.writeText("<html><body><p>Another test case here.</p><p>The word Test appears twice.</p></body></html>")
|
||||||
|
|
||||||
|
mockEpubBook = EpubBook(
|
||||||
|
fileName = "test.epub",
|
||||||
|
title = "Test Book",
|
||||||
|
author = "Tester",
|
||||||
|
language = "en",
|
||||||
|
coverImage = null,
|
||||||
|
extractionBasePath = testDir.absolutePath,
|
||||||
|
chapters = listOf(
|
||||||
|
EpubChapter(
|
||||||
|
chapterId = "ch1",
|
||||||
|
absPath = chapter1File.absolutePath,
|
||||||
|
title = "Chapter 1",
|
||||||
|
htmlFilePath = "chapter1.html",
|
||||||
|
plainTextContent = "",
|
||||||
|
htmlContent = ""
|
||||||
|
),
|
||||||
|
EpubChapter(
|
||||||
|
chapterId = "ch2",
|
||||||
|
absPath = chapter2File.absolutePath,
|
||||||
|
title = "Chapter 2",
|
||||||
|
htmlFilePath = "chapter2.html",
|
||||||
|
plainTextContent = "",
|
||||||
|
htmlContent = ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
testDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun searchEpub(book: EpubBook, query: String): List<SearchResult> {
|
||||||
|
val TAG = "EpubReaderLogicTest"
|
||||||
|
Timber.d("Starting search for query: '$query'")
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val results = mutableListOf<SearchResult>()
|
||||||
|
book.chapters.forEachIndexed { chapterIndex, chapter ->
|
||||||
|
try {
|
||||||
|
val fullPath = "${book.extractionBasePath}/${chapter.htmlFilePath}"
|
||||||
|
Timber.d("Chapter ${chapterIndex + 1}: Checking path '$fullPath'")
|
||||||
|
val htmlFile = File(fullPath)
|
||||||
|
if (!htmlFile.exists()) {
|
||||||
|
Timber.e("File does not exist: $fullPath")
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
|
||||||
|
val doc = Jsoup.parse(htmlFile, "UTF-8")
|
||||||
|
val bodyChildren = doc.body().children().toList()
|
||||||
|
val chunks = bodyChildren.chunked(20)
|
||||||
|
|
||||||
|
chunks.forEachIndexed { chunkIndex, chunkOfElements ->
|
||||||
|
val chunkHtml = chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
|
||||||
|
val content = Jsoup.parse(chunkHtml).text()
|
||||||
|
var lastIndex = -1
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
lastIndex = content.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true)
|
||||||
|
if (lastIndex == -1) break
|
||||||
|
|
||||||
|
Timber.d("Found potential match for '$query' at index $lastIndex.")
|
||||||
|
val isWordStart = lastIndex == 0 || !content[lastIndex - 1].isLetterOrDigit()
|
||||||
|
Timber.d("Is it a word start? -> $isWordStart")
|
||||||
|
if (isWordStart) {
|
||||||
|
Timber.d("Match is a word start. Adding to results.")
|
||||||
|
val snippetStart = max(0, lastIndex - 35)
|
||||||
|
val snippetEnd = min(content.length, lastIndex + query.length + 35)
|
||||||
|
val rawSnippet = content.substring(snippetStart, snippetEnd)
|
||||||
|
val annotatedSnippet = buildAnnotatedString {
|
||||||
|
append(rawSnippet)
|
||||||
|
val highlightStart = content.indexOf(query, lastIndex, ignoreCase = true) - snippetStart
|
||||||
|
val highlightEnd = highlightStart + query.length
|
||||||
|
addStyle(
|
||||||
|
style = SpanStyle(fontWeight = FontWeight.Bold),
|
||||||
|
start = highlightStart,
|
||||||
|
end = highlightEnd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
results.add(
|
||||||
|
SearchResult(
|
||||||
|
locationInSource = chapterIndex,
|
||||||
|
locationTitle = chapter.title,
|
||||||
|
snippet = annotatedSnippet,
|
||||||
|
query = query,
|
||||||
|
occurrenceIndexInLocation = results.count { it.locationInSource == chapterIndex },
|
||||||
|
chunkIndex = chunkIndex
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e("Error during search in chapter ${chapter.title}", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Timber.d("Search finished. Total results found: ${results.size}")
|
||||||
|
results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun search_findsCorrectResults() = runBlocking {
|
||||||
|
val results = searchEpub(mockEpubBook, "case")
|
||||||
|
assertThat(results).hasSize(2)
|
||||||
|
assertThat(results.count { it.locationTitle == "Chapter 1" }).isEqualTo(1)
|
||||||
|
assertThat(results.count { it.locationTitle == "Chapter 2" }).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun search_isCaseInsensitive() = runBlocking {
|
||||||
|
val results = searchEpub(mockEpubBook, "test")
|
||||||
|
assertThat(results).hasSize(3)
|
||||||
|
assertThat(results[0].locationTitle).isEqualTo("Chapter 1")
|
||||||
|
assertThat(results[1].locationTitle).isEqualTo("Chapter 2")
|
||||||
|
assertThat(results[2].locationTitle).isEqualTo("Chapter 2")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun search_noResultsFound() = runBlocking {
|
||||||
|
val results = searchEpub(mockEpubBook, "nonexistent")
|
||||||
|
assertThat(results).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun search_createsCorrectSnippetHighlight() = runBlocking {
|
||||||
|
val query = "Test"
|
||||||
|
mockEpubBook.chapters.first()
|
||||||
|
val content = "A simple Test case."
|
||||||
|
val annotatedString = buildAnnotatedStringWithHighlight(content, query)
|
||||||
|
|
||||||
|
val spanStyles = annotatedString.spanStyles
|
||||||
|
assertThat(spanStyles).hasSize(1)
|
||||||
|
|
||||||
|
val style = spanStyles.first().item
|
||||||
|
assertThat(style.fontWeight).isEqualTo(FontWeight.Bold)
|
||||||
|
|
||||||
|
val start = spanStyles.first().start
|
||||||
|
val end = spanStyles.first().end
|
||||||
|
assertThat(annotatedString.substring(start, end)).isEqualTo(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("SameParameterValue")
|
||||||
|
private fun buildAnnotatedStringWithHighlight(content: String, query: String): AnnotatedString {
|
||||||
|
return buildAnnotatedString {
|
||||||
|
append(content)
|
||||||
|
val highlightStart = content.indexOf(query, ignoreCase = true)
|
||||||
|
if (highlightStart != -1) {
|
||||||
|
addStyle(
|
||||||
|
style = SpanStyle(fontWeight = FontWeight.Bold),
|
||||||
|
start = highlightStart,
|
||||||
|
end = highlightStart + query.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader.tts
|
package com.aryan.reader.epubreader
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// CssParserTest.kt
|
// CssParserTest.kt
|
||||||
package org.dueattendant149.bookreader.paginatedreader
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
|
@ -17,66 +17,49 @@ import org.junit.runner.RunWith
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class CssParserTest {
|
class CssParserTest {
|
||||||
|
|
||||||
private val dummyConstraints = androidx.compose.ui.unit.Constraints()
|
|
||||||
private val baseFontSize = 16f
|
|
||||||
private val density = 1f
|
|
||||||
|
|
||||||
private fun parseTextColor(value: String): Color? {
|
|
||||||
val result = CssParser.parse(
|
|
||||||
cssContent = "p { color: $value; }",
|
|
||||||
cssPath = null,
|
|
||||||
baseFontSizeSp = baseFontSize,
|
|
||||||
density = density,
|
|
||||||
constraints = dummyConstraints,
|
|
||||||
isDarkTheme = false
|
|
||||||
)
|
|
||||||
return result.rules.byTag["p"]
|
|
||||||
?.firstOrNull()
|
|
||||||
?.style
|
|
||||||
?.spanStyle
|
|
||||||
?.color
|
|
||||||
?.takeIf { it.isSpecified }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_handlesNamedColorsCorrectly() {
|
fun parseColor_handlesNamedColorsCorrectly() {
|
||||||
assertThat(parseTextColor("red")).isEqualTo(Color.Red)
|
assertThat(CssParser.parseColor("red")).isEqualTo(Color.Red)
|
||||||
assertThat(parseTextColor("black")).isEqualTo(Color.Black)
|
assertThat(CssParser.parseColor("black")).isEqualTo(Color.Black)
|
||||||
assertThat(parseTextColor("transparent")).isEqualTo(Color.Transparent)
|
assertThat(CssParser.parseColor("transparent")).isEqualTo(Color.Transparent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_handles3DigitHexCodes() {
|
fun parseColor_handles3DigitHexCodes() {
|
||||||
assertThat(parseTextColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
|
assertThat(CssParser.parseColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_handles6DigitHexCodes() {
|
fun parseColor_handles6DigitHexCodes() {
|
||||||
assertThat(parseTextColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
|
assertThat(CssParser.parseColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_handles8DigitHexCodes() {
|
fun parseColor_handles8DigitHexCodes() {
|
||||||
assertThat(parseTextColor("#80FF00CC")).isEqualTo(Color(128, 255, 0, 204))
|
assertThat(CssParser.parseColor("#80FF00CC")).isEqualTo(Color(0x80FF00CC))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_handlesRgbFunction() {
|
fun parseColor_handlesRgbFunction() {
|
||||||
assertThat(parseTextColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
|
assertThat(CssParser.parseColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_handlesRgbaFunction() {
|
fun parseColor_handlesRgbaFunction() {
|
||||||
assertThat(parseTextColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
|
assertThat(CssParser.parseColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseColor_returnsNullForInvalidInput() {
|
fun parseColor_returnsNullForInvalidInput() {
|
||||||
assertThat(parseTextColor("not a color")).isNull()
|
assertThat(CssParser.parseColor("not a color")).isNull()
|
||||||
assertThat(parseTextColor("#12345")).isNull()
|
assertThat(CssParser.parseColor("#12345")).isNull()
|
||||||
assertThat(parseTextColor("rgb(1,2)")).isNull()
|
assertThat(CssParser.parseColor("rgb(1,2)")).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val dummyConstraints = androidx.compose.ui.unit.Constraints()
|
||||||
|
private val baseFontSize = 16f
|
||||||
|
private val density = 1f
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parse_handlesSimpleRule() {
|
fun parse_handlesSimpleRule() {
|
||||||
val css = "p { color: red; }"
|
val css = "p { color: red; }"
|
||||||
|
|
@ -137,12 +120,12 @@ class CssParserTest {
|
||||||
}
|
}
|
||||||
p { color: black; }
|
p { color: black; }
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val result = CssParser.parse(css, "OEBPS/styles/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
val result = CssParser.parse(css, "/some/path/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||||
assertThat(result.rules.byTag).containsKey("p")
|
assertThat(result.rules.byTag).containsKey("p")
|
||||||
assertThat(result.fontFaces).hasSize(1)
|
assertThat(result.fontFaces).hasSize(1)
|
||||||
val fontFace = result.fontFaces.first()
|
val fontFace = result.fontFaces.first()
|
||||||
assertThat(fontFace.fontFamily).isEqualTo("mycustomfont")
|
assertThat(fontFace.fontFamily).isEqualTo("mycustomfont")
|
||||||
assertThat(fontFace.src).isEqualTo("OEBPS/fonts/myfont.ttf")
|
assertThat(fontFace.src).isEqualTo("/some/fonts/myfont.ttf")
|
||||||
assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold)
|
assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold)
|
||||||
assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal)
|
assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal)
|
||||||
}
|
}
|
||||||
|
|
@ -207,11 +190,10 @@ class CssParserTest {
|
||||||
val css = "div { border: 2px solid red; }"
|
val css = "div { border: 2px solid red; }"
|
||||||
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||||
val style = result.rules.byTag["div"]?.first()?.style?.blockStyle
|
val style = result.rules.byTag["div"]?.first()?.style?.blockStyle
|
||||||
val expectedBorder = BorderStyle(width = 2.dp, color = Color.Red, style = "solid")
|
assertThat(style?.border).isNotNull()
|
||||||
assertThat(style?.borderTop).isEqualTo(expectedBorder)
|
assertThat(style?.border?.width).isEqualTo(2.dp)
|
||||||
assertThat(style?.borderRight).isEqualTo(expectedBorder)
|
assertThat(style?.border?.style).isEqualTo("solid")
|
||||||
assertThat(style?.borderBottom).isEqualTo(expectedBorder)
|
assertThat(style?.border?.color).isEqualTo(Color.Red)
|
||||||
assertThat(style?.borderLeft).isEqualTo(expectedBorder)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -280,70 +262,9 @@ class CssParserTest {
|
||||||
url("font.ttf") format("truetype");
|
url("font.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val result = CssParser.parse(css, "OEBPS/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
val result = CssParser.parse(css, "/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||||
assertThat(result.fontFaces).hasSize(1)
|
assertThat(result.fontFaces).hasSize(1)
|
||||||
assertThat(result.fontFaces.first().src).isEqualTo("OEBPS/css/font.otf")
|
assertThat(result.fontFaces.first().src).isEqualTo("/css/font.otf")
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parse_handlesNestedMediaAndCalcVariables() {
|
|
||||||
val css = """
|
|
||||||
:root { --gap: 12px; }
|
|
||||||
@media screen and (min-width: 300px) {
|
|
||||||
p { margin-left: calc(var(--gap) + 8px); color: hsl(120 100% 25%); }
|
|
||||||
}
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val result = CssParser.parse(
|
|
||||||
css,
|
|
||||||
null,
|
|
||||||
baseFontSize,
|
|
||||||
density,
|
|
||||||
androidx.compose.ui.unit.Constraints(maxWidth = 500),
|
|
||||||
isDarkTheme = false
|
|
||||||
)
|
|
||||||
|
|
||||||
val style = result.rules.byTag["p"]!!.first().style
|
|
||||||
assertThat(style.blockStyle.margin.left).isEqualTo(20.dp)
|
|
||||||
assertThat(style.spanStyle.color).isEqualTo(Color(0, 128, 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parse_preservesBeforeAfterPseudoElementRules() {
|
|
||||||
val css = "p::before { content: 'Note: '; color: red; } p { color: blue; }"
|
|
||||||
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
|
||||||
|
|
||||||
val pseudoRule = result.rules.otherComplex.single { it.pseudoElement == "before" }
|
|
||||||
assertThat(pseudoRule.selector.selector).isEqualTo("p")
|
|
||||||
assertThat(pseudoRule.style.content).isEqualTo("'Note: '")
|
|
||||||
assertThat(pseudoRule.style.spanStyle.color).isEqualTo(Color.Red)
|
|
||||||
assertThat(result.rules.byTag["p"]!!.single().style.spanStyle.color).isEqualTo(Color.Blue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parse_handlesModernRgbSlashAlphaAndCssHexAlpha() {
|
|
||||||
assertThat(parseTextColor("rgb(255 0 204 / 50%)")).isEqualTo(Color(255, 0, 204, 128))
|
|
||||||
assertThat(parseTextColor("#ff00cc80")).isEqualTo(Color(255, 0, 204, 128))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parse_backgroundShorthandExtractsColorAndImage() {
|
|
||||||
val css = "section { background: #ffeecc url('../images/paper.png') repeat; }"
|
|
||||||
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
|
||||||
val style = result.rules.byTag["section"]!!.first().style.blockStyle
|
|
||||||
|
|
||||||
assertThat(style.backgroundColor).isEqualTo(Color(255, 238, 204))
|
|
||||||
assertThat(style.backgroundImage).isEqualTo("../images/paper.png")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parse_listStyleShorthandExtractsMarkerTypeAndImage() {
|
|
||||||
val css = "ul { list-style: square url('../images/bullet.png') outside; }"
|
|
||||||
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
|
||||||
val style = result.rules.byTag["ul"]!!.first().style.blockStyle
|
|
||||||
|
|
||||||
assertThat(style.listStyleType).isEqualTo("square")
|
|
||||||
assertThat(style.listStyleImage).isEqualTo("../images/bullet.png")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -406,10 +327,10 @@ class CssParserTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parse_lineHeightPreservesUnitlessMultiplier() {
|
fun parse_lineHeightClampsSmallEmValues() {
|
||||||
val css = "p { line-height: 1.1; }" // This is treated as 1.1em
|
val css = "p { line-height: 1.1; }" // This is treated as 1.1em
|
||||||
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
|
||||||
val style = result.rules.byTag["p"]?.first()?.style
|
val style = result.rules.byTag["p"]?.first()?.style
|
||||||
assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(1.1.em)
|
assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(2.0.em)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
// HtmlParserTest.kt
|
// HtmlParserTest.kt
|
||||||
package org.dueattendant149.bookreader.paginatedreader
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
|
|
@ -40,7 +41,7 @@ class HtmlParserTest {
|
||||||
|
|
||||||
val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules
|
val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules
|
||||||
|
|
||||||
return androidHtmlToSemanticBlocks(
|
return htmlToSemanticBlocks(
|
||||||
html = "<body>$html</body>", // Wrap in body to match real usage
|
html = "<body>$html</body>", // Wrap in body to match real usage
|
||||||
cssRules = allRules, // Use the combined list of rules
|
cssRules = allRules, // Use the combined list of rules
|
||||||
textStyle = defaultTextStyle,
|
textStyle = defaultTextStyle,
|
||||||
|
|
@ -96,61 +97,7 @@ class HtmlParserTest {
|
||||||
assertThat(blocks).hasSize(1)
|
assertThat(blocks).hasSize(1)
|
||||||
val pBlock = blocks.first() as SemanticParagraph
|
val pBlock = blocks.first() as SemanticParagraph
|
||||||
val blockStyle = pBlock.style.spanStyle
|
val blockStyle = pBlock.style.spanStyle
|
||||||
assertThat(blockStyle.color).isEqualTo(Color(0, 128, 0))
|
assertThat(blockStyle.color).isEqualTo(Color.Green)
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun htmlToSemanticBlocks_contextSensitiveSelectors_areNotReusedAcrossSameClass() {
|
|
||||||
val css = """
|
|
||||||
.warning p.note { color: red; }
|
|
||||||
.safe p.note { color: blue; }
|
|
||||||
""".trimIndent()
|
|
||||||
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
|
||||||
|
|
||||||
val blocks = parse(
|
|
||||||
"""
|
|
||||||
<div class="warning"><p class="note">Danger</p></div>
|
|
||||||
<div class="safe"><p class="note">Okay</p></div>
|
|
||||||
""".trimIndent(),
|
|
||||||
cssRules = cssRules
|
|
||||||
)
|
|
||||||
|
|
||||||
val first = blocks[0] as SemanticParagraph
|
|
||||||
val second = blocks[1] as SemanticParagraph
|
|
||||||
assertThat(first.style.spanStyle.color).isEqualTo(Color.Red)
|
|
||||||
assertThat(second.style.spanStyle.color).isEqualTo(Color.Blue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun htmlToSemanticBlocks_generatedBeforeContent_isMaterializedIntoText() {
|
|
||||||
val css = "p.note::before { content: 'Note: '; color: red; }"
|
|
||||||
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
|
||||||
|
|
||||||
val blocks = parse("<p class=\"note\">Remember this</p>", cssRules = cssRules)
|
|
||||||
|
|
||||||
val paragraph = blocks.single() as SemanticParagraph
|
|
||||||
assertThat(paragraph.text).isEqualTo("Note: Remember this")
|
|
||||||
val generatedSpan = paragraph.spans.first { it.tag == "::before" }
|
|
||||||
assertThat(generatedSpan.start).isEqualTo(0)
|
|
||||||
assertThat(generatedSpan.end).isEqualTo("Note: ".length)
|
|
||||||
assertThat(generatedSpan.style.spanStyle.color).isEqualTo(Color.Red)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun htmlToSemanticBlocks_backgroundImageUrl_isResolvedIntoBlockStyle() {
|
|
||||||
val imageRelativeSrc = "images/paper.png"
|
|
||||||
val chapterParentDir = File(defaultChapterPath).parent ?: ""
|
|
||||||
val imageFile = File(File(defaultExtractionPath, chapterParentDir), imageRelativeSrc).canonicalFile
|
|
||||||
imageFile.parentFile?.mkdirs()
|
|
||||||
imageFile.createNewFile()
|
|
||||||
imageFile.deleteOnExit()
|
|
||||||
val css = "p.paper { background-image: url('$imageRelativeSrc'); }"
|
|
||||||
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
|
||||||
|
|
||||||
val blocks = parse("<p class=\"paper\">Text over paper</p>", cssRules = cssRules)
|
|
||||||
|
|
||||||
val paragraph = blocks.single() as SemanticParagraph
|
|
||||||
assertThat(paragraph.style.blockStyle.backgroundImage).isEqualTo(imageFile.absolutePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -250,7 +197,7 @@ class HtmlParserTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun htmlToSemanticBlocks_complexInlineText_isPreserved() {
|
fun htmlToSemanticBlocks_complexInlineFormatting_isPreserved() {
|
||||||
val html = "<p>This is <b>bold</b> and <i>italic</i> text.</p>"
|
val html = "<p>This is <b>bold</b> and <i>italic</i> text.</p>"
|
||||||
val blocks = parse(html)
|
val blocks = parse(html)
|
||||||
|
|
||||||
|
|
@ -258,6 +205,17 @@ class HtmlParserTest {
|
||||||
val pBlock = blocks.first() as SemanticParagraph
|
val pBlock = blocks.first() as SemanticParagraph
|
||||||
|
|
||||||
assertThat(pBlock.text).isEqualTo("This is bold and italic text.")
|
assertThat(pBlock.text).isEqualTo("This is bold and italic text.")
|
||||||
|
|
||||||
|
// Find the range for "bold" and check its style
|
||||||
|
val boldRange = pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "bold" }
|
||||||
|
assertThat(boldRange).isNotNull()
|
||||||
|
assertThat(boldRange!!.style.spanStyle.fontWeight).isEqualTo(FontWeight.Bold)
|
||||||
|
|
||||||
|
// Find the range for "italic" and check its style
|
||||||
|
val italicRange =
|
||||||
|
pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "italic" }
|
||||||
|
assertThat(italicRange).isNotNull()
|
||||||
|
assertThat(italicRange!!.style.spanStyle.fontStyle).isEqualTo(androidx.compose.ui.text.font.FontStyle.Italic)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -284,14 +242,15 @@ class HtmlParserTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun htmlToSemanticBlocks_beforePseudoElementContent_isIncludedInParagraphText() {
|
fun htmlToSemanticBlocks_pseudoElements_areIgnoredByTheParser() {
|
||||||
val css = "p::before { content: \"Note: \"; }"
|
val css = "p::before { content: \"Note: \"; }"
|
||||||
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
|
||||||
val blocks = parse("<p>This is a test.</p>", cssRules = cssRules)
|
val blocks = parse("<p>This is a test.</p>", cssRules = cssRules)
|
||||||
|
|
||||||
|
// The parser now ignores pseudo-elements, so only the paragraph content should be parsed.
|
||||||
assertThat(blocks).hasSize(1)
|
assertThat(blocks).hasSize(1)
|
||||||
val pBlock = blocks[0] as SemanticParagraph
|
val pBlock = blocks[0] as SemanticParagraph
|
||||||
assertThat(pBlock.text).isEqualTo("Note: This is a test.")
|
assertThat(pBlock.text).isEqualTo("This is a test.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// MainDispatcherRule.kt
|
// MainDispatcherRule.kt
|
||||||
package org.dueattendant149.bookreader.pdf
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// PaginatedReaderDataTest.kt
|
// PaginatedReaderDataTest.kt
|
||||||
package org.dueattendant149.bookreader.paginatedreader
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.ParagraphStyle
|
import androidx.compose.ui.text.ParagraphStyle
|
||||||
|
|
@ -75,7 +75,7 @@ class PaginatedReaderDataTest {
|
||||||
margin = BoxBorders(bottom = 10.dp, left = 10.dp),
|
margin = BoxBorders(bottom = 10.dp, left = 10.dp),
|
||||||
width = 200.dp,
|
width = 200.dp,
|
||||||
backgroundColor = Color.Black,
|
backgroundColor = Color.Black,
|
||||||
borderTop = BorderStyle(width = 1.dp, color = Color.Red)
|
border = BorderStyle(width = 1.dp, color = Color.Red)
|
||||||
)
|
)
|
||||||
|
|
||||||
val merged = baseStyle.merge(overrideStyle)
|
val merged = baseStyle.merge(overrideStyle)
|
||||||
|
|
@ -95,8 +95,8 @@ class PaginatedReaderDataTest {
|
||||||
// Other properties
|
// Other properties
|
||||||
assertThat(merged.width).isEqualTo(200.dp)
|
assertThat(merged.width).isEqualTo(200.dp)
|
||||||
assertThat(merged.backgroundColor).isEqualTo(Color.Black)
|
assertThat(merged.backgroundColor).isEqualTo(Color.Black)
|
||||||
assertThat(merged.borderTop).isNotNull()
|
assertThat(merged.border).isNotNull()
|
||||||
assertThat(merged.borderTop?.width).isEqualTo(1.dp)
|
assertThat(merged.border?.width).isEqualTo(1.dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -115,9 +115,6 @@ class PaginatedReaderDataTest {
|
||||||
assertThat(merged.margin.top).isEqualTo(5.dp)
|
assertThat(merged.margin.top).isEqualTo(5.dp)
|
||||||
assertThat(merged.width).isEqualTo(100.dp)
|
assertThat(merged.width).isEqualTo(100.dp)
|
||||||
assertThat(merged.backgroundColor).isEqualTo(Color.White)
|
assertThat(merged.backgroundColor).isEqualTo(Color.White)
|
||||||
assertThat(merged.borderTop).isNull()
|
assertThat(merged.border).isNull()
|
||||||
assertThat(merged.borderRight).isNull()
|
|
||||||
assertThat(merged.borderBottom).isNull()
|
|
||||||
assertThat(merged.borderLeft).isNull()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,304 @@
|
||||||
|
// PaginatedReaderViewModelTest.kt
|
||||||
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.aryan.reader.SearchResult
|
||||||
|
import com.aryan.reader.epub.EpubBook
|
||||||
|
import com.aryan.reader.epub.EpubChapter
|
||||||
|
import com.aryan.reader.paginatedreader.data.BookCacheDao
|
||||||
|
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
||||||
|
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
private class FakePaginator(
|
||||||
|
initiallyLoading: Boolean,
|
||||||
|
initialPageCount: Int,
|
||||||
|
initialGeneration: Int
|
||||||
|
) : IPaginator {
|
||||||
|
override var isLoading by mutableStateOf(initiallyLoading)
|
||||||
|
override var totalPageCount by mutableIntStateOf(initialPageCount)
|
||||||
|
override var generation by mutableIntStateOf(initialGeneration)
|
||||||
|
override val pageShiftRequest: Flow<Int> = emptyFlow()
|
||||||
|
|
||||||
|
var lastNavigatedHref: String? = null
|
||||||
|
var lastNavigatedChapter: String? = null
|
||||||
|
|
||||||
|
override fun getPageContent(pageIndex: Int): Page? = null
|
||||||
|
override fun getChapterPathForPage(pageIndex: Int): String? = null
|
||||||
|
override fun getPlainTextForChapter(chapterIndex: Int): String? = null
|
||||||
|
|
||||||
|
override fun navigateToHref(
|
||||||
|
currentChapterAbsPath: String,
|
||||||
|
href: String,
|
||||||
|
onNavigationComplete: (pageIndex: Int) -> Unit
|
||||||
|
) {
|
||||||
|
lastNavigatedChapter = currentChapterAbsPath
|
||||||
|
lastNavigatedHref = href
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findPageForSearchResult(
|
||||||
|
result: SearchResult,
|
||||||
|
onResult: (Int) -> Unit
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
// Add stubs for the other missing interface members
|
||||||
|
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (Int) -> Unit) = Unit
|
||||||
|
override fun findPageForCfiAndOffset(
|
||||||
|
chapterIndex: Int,
|
||||||
|
cfi: String,
|
||||||
|
charOffset: Int
|
||||||
|
): Int? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findChapterIndexForPage(pageIndex: Int): Int? = null
|
||||||
|
override fun getCfiForPage(pageIndex: Int): String? = null
|
||||||
|
override fun onUserScrolledTo(pageIndex: Int) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PaginatedReaderViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private lateinit var viewModel: PaginatedReaderViewModel
|
||||||
|
private lateinit var fakePaginator: FakePaginator
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
viewModel = PaginatedReaderViewModel()
|
||||||
|
fakePaginator = FakePaginator(
|
||||||
|
initiallyLoading = true,
|
||||||
|
initialPageCount = 0,
|
||||||
|
initialGeneration = 0
|
||||||
|
)
|
||||||
|
viewModel.setPaginatorForTest(fakePaginator)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uiState_reflectsPaginatorInitialState() = runTest {
|
||||||
|
val initialState = viewModel.uiState.value
|
||||||
|
assertThat(initialState.isLoading).isTrue()
|
||||||
|
assertThat(initialState.totalPageCount).isEqualTo(0)
|
||||||
|
assertThat(initialState.generation).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uiState_updatesWhenPaginatorIsLoadingChanges() = runTest {
|
||||||
|
assertThat(viewModel.uiState.value.isLoading).isTrue()
|
||||||
|
|
||||||
|
fakePaginator.isLoading = false
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(viewModel.uiState.value.isLoading).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uiState_updatesWhenPaginatorTotalPageCountChanges() = runTest {
|
||||||
|
assertThat(viewModel.uiState.value.totalPageCount).isEqualTo(0)
|
||||||
|
|
||||||
|
fakePaginator.totalPageCount = 123
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(viewModel.uiState.value.totalPageCount).isEqualTo(123)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uiState_updatesWhenPaginatorGenerationChanges() = runTest {
|
||||||
|
assertThat(viewModel.uiState.value.generation).isEqualTo(0)
|
||||||
|
|
||||||
|
fakePaginator.generation = 5
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(viewModel.uiState.value.generation).isEqualTo(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onLinkClick_callsPaginatorNavigateToHrefWithCorrectArguments() {
|
||||||
|
val currentChapter = "chapter1.xhtml"
|
||||||
|
val href = "#section2"
|
||||||
|
|
||||||
|
viewModel.onLinkClick(currentChapter, href) {}
|
||||||
|
|
||||||
|
assertThat(fakePaginator.lastNavigatedChapter).isEqualTo(currentChapter)
|
||||||
|
assertThat(fakePaginator.lastNavigatedHref).isEqualTo(href)
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
fun initialize_createsARealPaginatorAndUpdateState() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val viewModel = PaginatedReaderViewModel() // Create a fresh ViewModel
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val textMeasurer = mockk<TextMeasurer>(relaxed = true)
|
||||||
|
val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
|
||||||
|
val textStyle = TextStyle.Default
|
||||||
|
val density = Density(1f)
|
||||||
|
val mathMLRenderer = mockk<MathMLRenderer>(relaxed = true)
|
||||||
|
val testBook = EpubBook(
|
||||||
|
fileName = "test.epub",
|
||||||
|
title = "Test Book",
|
||||||
|
author = "Test Author",
|
||||||
|
language = "en",
|
||||||
|
coverImage = null,
|
||||||
|
chapters = listOf(
|
||||||
|
EpubChapter(
|
||||||
|
chapterId = "ch1",
|
||||||
|
title = "Chapter 1",
|
||||||
|
htmlFilePath = "ch1.html",
|
||||||
|
absPath = "/ops/ch1.html",
|
||||||
|
htmlContent = "<p>Some content</p>",
|
||||||
|
plainTextContent = "Some content"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
css = mapOf("/ops/style.css" to "p {color: red;}"),
|
||||||
|
extractionBasePath = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mock dependencies for BookPaginator
|
||||||
|
val mockDao = mockk<BookCacheDao>(relaxed = true)
|
||||||
|
coEvery { mockDao.getProcessedBook(any()) } returns null // Simulate cache miss
|
||||||
|
|
||||||
|
val mockDb = mockk<BookCacheDatabase>()
|
||||||
|
every { mockDb.bookCacheDao() } returns mockDao
|
||||||
|
|
||||||
|
mockkObject(BookCacheDatabase.Companion)
|
||||||
|
every { BookCacheDatabase.getDatabase(any()) } returns mockDb
|
||||||
|
|
||||||
|
mockkObject(BookProcessingWorker.Companion)
|
||||||
|
every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
|
||||||
|
|
||||||
|
// Pre-condition check
|
||||||
|
assertThat(viewModel.uiState.value.isLoading).isTrue()
|
||||||
|
assertThat(viewModel.paginator).isNull()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
viewModel.initialize(
|
||||||
|
book = testBook,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
textConstraints = constraints,
|
||||||
|
textStyle = textStyle,
|
||||||
|
density = density,
|
||||||
|
isDarkTheme = false,
|
||||||
|
context = context,
|
||||||
|
initialChapterToPaginate = 0,
|
||||||
|
mathMLRenderer = mathMLRenderer
|
||||||
|
)
|
||||||
|
advanceUntilIdle() // Allow coroutines to complete
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(viewModel.paginator).isInstanceOf(BookPaginator::class.java)
|
||||||
|
assertThat(viewModel.uiState.value.isLoading).isFalse()
|
||||||
|
assertThat(viewModel.uiState.value.totalPageCount).isGreaterThan(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initialize_isIdempotent() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val viewModel = PaginatedReaderViewModel()
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val textMeasurer = mockk<TextMeasurer>(relaxed = true)
|
||||||
|
val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
|
||||||
|
val textStyle = TextStyle.Default
|
||||||
|
val density = Density(1f)
|
||||||
|
val mathMLRenderer = mockk<MathMLRenderer>(relaxed = true)
|
||||||
|
val testBook = EpubBook(
|
||||||
|
fileName = "test.epub",
|
||||||
|
title = "Test Book",
|
||||||
|
author = "Test Author",
|
||||||
|
language = "en",
|
||||||
|
coverImage = null,
|
||||||
|
chapters = listOf(
|
||||||
|
EpubChapter(
|
||||||
|
chapterId = "ch1",
|
||||||
|
title = "Chapter 1",
|
||||||
|
htmlFilePath = "ch1.html",
|
||||||
|
absPath = "/ops/ch1.html",
|
||||||
|
htmlContent = "<p>Some content</p>",
|
||||||
|
plainTextContent = "Some content"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
css = mapOf("/ops/style.css" to "p {color: red;}"),
|
||||||
|
extractionBasePath = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
val mockDao = mockk<BookCacheDao>(relaxed = true)
|
||||||
|
coEvery { mockDao.getProcessedBook(any()) } returns null
|
||||||
|
val mockDb = mockk<BookCacheDatabase>()
|
||||||
|
every { mockDb.bookCacheDao() } returns mockDao
|
||||||
|
mockkObject(BookCacheDatabase.Companion)
|
||||||
|
every { BookCacheDatabase.getDatabase(any()) } returns mockDb
|
||||||
|
mockkObject(BookProcessingWorker.Companion)
|
||||||
|
every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
|
||||||
|
|
||||||
|
// Act
|
||||||
|
viewModel.initialize(
|
||||||
|
book = testBook,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
textConstraints = constraints,
|
||||||
|
textStyle = textStyle,
|
||||||
|
density = density,
|
||||||
|
isDarkTheme = false,
|
||||||
|
context = context,
|
||||||
|
initialChapterToPaginate = 0,
|
||||||
|
mathMLRenderer = mathMLRenderer
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val firstPaginator = viewModel.paginator
|
||||||
|
assertThat(firstPaginator).isNotNull()
|
||||||
|
|
||||||
|
// Act again
|
||||||
|
viewModel.initialize(
|
||||||
|
book = testBook,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
textConstraints = constraints,
|
||||||
|
textStyle = textStyle,
|
||||||
|
density = density,
|
||||||
|
isDarkTheme = false,
|
||||||
|
context = context,
|
||||||
|
initialChapterToPaginate = 0,
|
||||||
|
mathMLRenderer = mathMLRenderer
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val secondPaginator = viewModel.paginator
|
||||||
|
assertThat(secondPaginator).isSameInstanceAs(firstPaginator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
// PaginatorTest.kt
|
// PaginatorTest.kt
|
||||||
package org.dueattendant149.bookreader.paginatedreader
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.SdkSuppress
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
@ -44,52 +42,14 @@ class FakeSplittableMeasurementProvider(
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun split(block: TableBlock, availableHeight: Int): Pair<TableBlock, TableBlock>? = null
|
|
||||||
|
|
||||||
override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair<FlexContainerBlock, FlexContainerBlock>? = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
|
||||||
class PaginatorTest {
|
class PaginatorTest {
|
||||||
|
|
||||||
private val testDensity = Density(density = 1f, fontScale = 1f)
|
private val testDensity = Density(density = 1f, fontScale = 1f)
|
||||||
private val pageHeight = 1000
|
private val pageHeight = 1000
|
||||||
|
|
||||||
private fun List<ContentBlock>.withoutMeasuredHeights(): List<ContentBlock> {
|
|
||||||
return map { it.withoutMeasuredHeight() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ContentBlock.withoutMeasuredHeight(): ContentBlock {
|
|
||||||
return when (this) {
|
|
||||||
is ParagraphBlock -> copy(expectedHeight = 0)
|
|
||||||
is ImageBlock -> copy(expectedHeight = 0)
|
|
||||||
is HeaderBlock -> copy(expectedHeight = 0)
|
|
||||||
is SpacerBlock -> copy(expectedHeight = 0)
|
|
||||||
is QuoteBlock -> copy(expectedHeight = 0)
|
|
||||||
is ListItemBlock -> copy(expectedHeight = 0)
|
|
||||||
is TableBlock -> copy(
|
|
||||||
rows = rows.map { row ->
|
|
||||||
row.map { cell ->
|
|
||||||
cell.copy(content = cell.content.withoutMeasuredHeights())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
expectedHeight = 0
|
|
||||||
)
|
|
||||||
is MathBlock -> copy(expectedHeight = 0)
|
|
||||||
is WrappingContentBlock -> copy(
|
|
||||||
floatedImage = floatedImage.copy(expectedHeight = 0),
|
|
||||||
paragraphsToWrap = paragraphsToWrap.map { it.copy(expectedHeight = 0) },
|
|
||||||
expectedHeight = 0
|
|
||||||
)
|
|
||||||
is FlexContainerBlock -> copy(
|
|
||||||
children = children.withoutMeasuredHeights(),
|
|
||||||
expectedHeight = 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun paginate_givenEmptyBlocks_createsZeroPages() = runTest {
|
fun paginate_givenEmptyBlocks_createsZeroPages() = runTest {
|
||||||
val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity)
|
val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity)
|
||||||
|
|
@ -125,53 +85,8 @@ class PaginatorTest {
|
||||||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||||
|
|
||||||
assertThat(pages).hasSize(2)
|
assertThat(pages).hasSize(2)
|
||||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
assertThat(pages[0].content).containsExactly(block1)
|
||||||
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
|
assertThat(pages[1].content).containsExactly(block2)
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun paginate_honorsBreakBeforePage() = runTest {
|
|
||||||
val block1 = ParagraphBlock(content = AnnotatedString("Before"), blockIndex = 0)
|
|
||||||
val block2 = ParagraphBlock(
|
|
||||||
content = AnnotatedString("After"),
|
|
||||||
style = BlockStyle(breakBefore = "page"),
|
|
||||||
blockIndex = 1
|
|
||||||
)
|
|
||||||
|
|
||||||
val pages = paginate(
|
|
||||||
listOf(block1, block2),
|
|
||||||
pageHeight,
|
|
||||||
FakeSplittableMeasurementProvider(mapOf(block1 to 100, block2 to 100)),
|
|
||||||
testDensity
|
|
||||||
)
|
|
||||||
|
|
||||||
assertThat(pages).hasSize(2)
|
|
||||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
|
||||||
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun paginate_breakInsideAvoidPreventsParagraphSplit() = runTest {
|
|
||||||
val block1 = ParagraphBlock(
|
|
||||||
content = AnnotatedString("Keep together"),
|
|
||||||
style = BlockStyle(breakInside = "avoid"),
|
|
||||||
blockIndex = 0
|
|
||||||
)
|
|
||||||
val part1 = block1.copy(content = AnnotatedString("Keep"))
|
|
||||||
val part2 = block1.copy(content = AnnotatedString("together"))
|
|
||||||
|
|
||||||
val pages = paginate(
|
|
||||||
listOf(block1),
|
|
||||||
pageHeight = 400,
|
|
||||||
measurementProvider = FakeSplittableMeasurementProvider(
|
|
||||||
heights = mapOf(block1 to 800, part1 to 300, part2 to 500),
|
|
||||||
splittableParagraphs = mapOf(block1 to (part1 to part2))
|
|
||||||
),
|
|
||||||
density = testDensity
|
|
||||||
)
|
|
||||||
|
|
||||||
assertThat(pages).hasSize(1)
|
|
||||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -195,8 +110,8 @@ class PaginatorTest {
|
||||||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||||
|
|
||||||
assertThat(pages).hasSize(2)
|
assertThat(pages).hasSize(2)
|
||||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1, part1).inOrder()
|
assertThat(pages[0].content).containsExactly(block1, part1).inOrder()
|
||||||
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(part2)
|
assertThat(pages[1].content).containsExactly(part2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -221,8 +136,8 @@ class PaginatorTest {
|
||||||
val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity)
|
val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity)
|
||||||
|
|
||||||
assertThat(pages).hasSize(2)
|
assertThat(pages).hasSize(2)
|
||||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(splitWrapper)
|
assertThat(pages[0].content).containsExactly(splitWrapper)
|
||||||
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(para2)
|
assertThat(pages[1].content).containsExactly(para2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -243,8 +158,8 @@ class PaginatorTest {
|
||||||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||||
|
|
||||||
assertThat(pages).hasSize(2)
|
assertThat(pages).hasSize(2)
|
||||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
assertThat(pages[0].content).containsExactly(block1)
|
||||||
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(unsplittableBlock)
|
assertThat(pages[1].content).containsExactly(unsplittableBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -257,7 +172,7 @@ class PaginatorTest {
|
||||||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||||
|
|
||||||
assertThat(pages).hasSize(1)
|
assertThat(pages).hasSize(1)
|
||||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(oversizedBlock)
|
assertThat(pages[0].content).containsExactly(oversizedBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -333,8 +248,8 @@ class PaginatorTest {
|
||||||
|
|
||||||
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
|
||||||
assertThat(pages).hasSize(2)
|
assertThat(pages).hasSize(2)
|
||||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
|
assertThat(pages[0].content).containsExactly(block1)
|
||||||
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(splittableBlock) // Was not split
|
assertThat(pages[1].content).containsExactly(splittableBlock) // Was not split
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -356,7 +271,8 @@ class PaginatorTest {
|
||||||
// Set page height so that a split is attempted.
|
// Set page height so that a split is attempted.
|
||||||
val pages = paginate(blocks, 150, measurementProvider, testDensity)
|
val pages = paginate(blocks, 150, measurementProvider, testDensity)
|
||||||
assertThat(pages).hasSize(1)
|
assertThat(pages).hasSize(1)
|
||||||
// Empty split heads are skipped so pagination keeps only the remaining content.
|
// The page should be empty because part1 was empty, and the original block was re-added
|
||||||
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(part2)
|
// to the remaining list. The next page then contains the full block.
|
||||||
|
assertThat(pages[0].content).containsExactly(part2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// StyleUtilsTest.kt
|
// StyleUtilsTest.kt
|
||||||
package org.dueattendant149.bookreader.paginatedreader
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.isUnspecified
|
import androidx.compose.ui.unit.isUnspecified
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// MainDispatcherRule.kt
|
// MainDispatcherRule.kt
|
||||||
package org.dueattendant149.bookreader.paginatedreader
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// PdfAnnotationTest.kt
|
// PdfAnnotationTest.kt
|
||||||
package org.dueattendant149.bookreader.pdf
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
|
@ -11,7 +11,6 @@ import androidx.compose.ui.test.assertIsSelected
|
||||||
import androidx.compose.ui.test.assertIsNotSelected
|
import androidx.compose.ui.test.assertIsNotSelected
|
||||||
import androidx.compose.ui.test.click
|
import androidx.compose.ui.test.click
|
||||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
|
|
@ -20,9 +19,7 @@ import androidx.core.content.FileProvider
|
||||||
import androidx.test.core.app.ActivityScenario
|
import androidx.test.core.app.ActivityScenario
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import org.dueattendant149.bookreader.MainActivity
|
import com.aryan.reader.MainActivity
|
||||||
import org.dueattendant149.bookreader.R
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
|
|
@ -41,12 +38,6 @@ class PdfAnnotationTest {
|
||||||
private var currentPdfFile: File? = null
|
private var currentPdfFile: File? = null
|
||||||
private var scenario: ActivityScenario<MainActivity>? = null
|
private var scenario: ActivityScenario<MainActivity>? = null
|
||||||
private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
|
private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
|
||||||
private fun text(resId: Int): String = context.getString(resId)
|
|
||||||
private fun dockTag(resId: Int): String = "DockItem_${text(resId)}"
|
|
||||||
|
|
||||||
private fun assertNoNodeWithTag(tag: String) {
|
|
||||||
assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
|
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
|
||||||
return Intent(context, MainActivity::class.java).apply {
|
return Intent(context, MainActivity::class.java).apply {
|
||||||
|
|
@ -64,7 +55,7 @@ class PdfAnnotationTest {
|
||||||
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
||||||
.edit().clear().commit()
|
.edit().clear().commit()
|
||||||
|
|
||||||
scenario = ActivityScenario.launch<MainActivity>(createPdfViewIntent(context, samplePdfUri))
|
scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri))
|
||||||
waitForDocumentLoad()
|
waitForDocumentLoad()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,11 +75,11 @@ class PdfAnnotationTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enterEditMode() {
|
private fun enterEditMode() {
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode))
|
composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode")
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
.performClick()
|
.performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).assertIsDisplayed()
|
composeTestRule.onNodeWithContentDescription("Close Edit Mode").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tapOutsidePopup() {
|
private fun tapOutsidePopup() {
|
||||||
|
|
@ -120,13 +111,13 @@ class PdfAnnotationTest {
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
// Verify Dock Items exist using new Tags
|
// Verify Dock Items exist using new Tags
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
|
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsDisplayed()
|
composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsDisplayed()
|
composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsDisplayed()
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick()
|
composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode)).assertIsDisplayed()
|
composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- TOOL LOGIC TESTS ---
|
// --- TOOL LOGIC TESTS ---
|
||||||
|
|
@ -136,38 +127,38 @@ class PdfAnnotationTest {
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
// 1. Select Highlighter
|
// 1. Select Highlighter
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).performClick()
|
composeTestRule.onNodeWithTag("DockItem_Highlighter").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// 2. Verify selection state
|
// 2. Verify selection state
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected()
|
composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsNotSelected()
|
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsNotSelected()
|
||||||
|
|
||||||
// 3. Exit Edit Mode
|
// 3. Exit Edit Mode
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick()
|
composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// 4. Re-enter Edit Mode
|
// 4. Re-enter Edit Mode
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
// 5. Verify Highlighter is STILL selected (Persistence)
|
// 5. Verify Highlighter is STILL selected (Persistence)
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected()
|
composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testEraserSettingsPopupOpensWhenAlreadySelected() {
|
fun testEraserHasNoPopup() {
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
// Select Eraser
|
// Select Eraser
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick()
|
composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsSelected()
|
composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsSelected()
|
||||||
|
|
||||||
// Click Eraser again to open its settings popup.
|
// Click Eraser AGAIN (Should NOT open popup)
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick()
|
composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertIsDisplayed()
|
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SETTINGS POPUP TESTS ---
|
// --- SETTINGS POPUP TESTS ---
|
||||||
|
|
@ -178,7 +169,7 @@ class PdfAnnotationTest {
|
||||||
|
|
||||||
// 1. Pen is default. Click Pen ONCE to open Settings.
|
// 1. Pen is default. Click Pen ONCE to open Settings.
|
||||||
// (Clicking twice would toggle it off, which caused previous failures)
|
// (Clicking twice would toggle it off, which caused previous failures)
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick()
|
composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// 2. Verify Popup Displayed
|
// 2. Verify Popup Displayed
|
||||||
|
|
@ -195,7 +186,7 @@ class PdfAnnotationTest {
|
||||||
|
|
||||||
// 5. Dismiss Settings
|
// 5. Dismiss Settings
|
||||||
tapOutsidePopup()
|
tapOutsidePopup()
|
||||||
assertNoNodeWithTag("ToolSettingsPopup")
|
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -203,7 +194,7 @@ class PdfAnnotationTest {
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
// Open Settings for Pen (Default selected, so one click opens settings)
|
// Open Settings for Pen (Default selected, so one click opens settings)
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick()
|
composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// Test Palette Click (Index 1)
|
// Test Palette Click (Index 1)
|
||||||
|
|
@ -217,7 +208,7 @@ class PdfAnnotationTest {
|
||||||
tapOutsidePopup()
|
tapOutsidePopup()
|
||||||
|
|
||||||
// Quick verification that settings didn't crash app
|
// Quick verification that settings didn't crash app
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
|
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- UNDO/REDO TESTS ---
|
// --- UNDO/REDO TESTS ---
|
||||||
|
|
@ -226,7 +217,7 @@ class PdfAnnotationTest {
|
||||||
fun testDrawingEnablesUndo() {
|
fun testDrawingEnablesUndo() {
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo))
|
composeTestRule.onNodeWithContentDescription("Undo")
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
.assertIsNotEnabled()
|
.assertIsNotEnabled()
|
||||||
|
|
||||||
|
|
@ -236,7 +227,7 @@ class PdfAnnotationTest {
|
||||||
}
|
}
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo)).assertIsEnabled()
|
composeTestRule.onNodeWithContentDescription("Undo").assertIsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -249,8 +240,8 @@ class PdfAnnotationTest {
|
||||||
}
|
}
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
val undoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo))
|
val undoNode = composeTestRule.onNodeWithContentDescription("Undo")
|
||||||
val redoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_redo))
|
val redoNode = composeTestRule.onNodeWithContentDescription("Redo")
|
||||||
|
|
||||||
undoNode.assertIsEnabled()
|
undoNode.assertIsEnabled()
|
||||||
redoNode.assertIsNotEnabled()
|
redoNode.assertIsNotEnabled()
|
||||||
|
|
@ -277,7 +268,7 @@ class PdfAnnotationTest {
|
||||||
enterEditMode()
|
enterEditMode()
|
||||||
|
|
||||||
// 1. Drag Dock to make it floating (using Pen icon as handle)
|
// 1. Drag Dock to make it floating (using Pen icon as handle)
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performTouchInput {
|
composeTestRule.onNodeWithTag("DockItem_Pen").performTouchInput {
|
||||||
down(center)
|
down(center)
|
||||||
advanceEventTime(600) // Long press
|
advanceEventTime(600) // Long press
|
||||||
// Drag UP significantly
|
// Drag UP significantly
|
||||||
|
|
@ -287,20 +278,20 @@ class PdfAnnotationTest {
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// 2. Minimize (Eye icon)
|
// 2. Minimize (Eye icon)
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_visibility)).performClick()
|
composeTestRule.onNodeWithContentDescription("Toggle Visibility").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// 3. Verify Dock items are hidden
|
// 3. Verify Dock items are hidden
|
||||||
assertNoNodeWithTag(dockTag(R.string.content_desc_pen))
|
composeTestRule.onNodeWithTag("DockItem_Pen").assertDoesNotExist()
|
||||||
|
|
||||||
// 4. Verify "Show Dock" floating button is visible
|
// 4. Verify "Show Dock" floating button is visible
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).assertIsDisplayed()
|
composeTestRule.onNodeWithContentDescription("Show Dock").assertIsDisplayed()
|
||||||
|
|
||||||
// 5. Restore
|
// 5. Restore
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).performClick()
|
composeTestRule.onNodeWithContentDescription("Show Dock").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
// 6. Verify Dock items return
|
// 6. Verify Dock items return
|
||||||
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
|
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// app/src/androidTest/java/com/aryan/reader/pdf/PdfCoverGeneratorTest.kt
|
// app/src/androidTest/java/com/aryan/reader/pdf/PdfCoverGeneratorTest.kt
|
||||||
package org.dueattendant149.bookreader.pdf
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// app/src/androidTest/java/com/aryan/reader/pdf/PdfHelperTest.kt
|
// app/src/androidTest/java/com/aryan/reader/pdf/PdfHelperTest.kt
|
||||||
package org.dueattendant149.bookreader.pdf
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
@ -0,0 +1,313 @@
|
||||||
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.ui.test.assert
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertTextContains
|
||||||
|
import androidx.compose.ui.test.hasTestTag
|
||||||
|
import androidx.compose.ui.test.hasText
|
||||||
|
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performTextInput
|
||||||
|
import androidx.compose.ui.test.performTouchInput
|
||||||
|
import androidx.compose.ui.test.swipe
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.rules.ActivityScenarioRule
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.rule.GrantPermissionRule
|
||||||
|
import com.aryan.reader.MainActivity
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PdfViewerScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createEmptyComposeRule()
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
|
||||||
|
@org.junit.Before
|
||||||
|
fun setup() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.clear()
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
|
||||||
|
return Intent(context, MainActivity::class.java).apply {
|
||||||
|
action = Intent.ACTION_VIEW
|
||||||
|
data = uri
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
private var currentPdfFile: File? = null
|
||||||
|
|
||||||
|
private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val activityRule = ActivityScenarioRule<MainActivity>(createPdfViewIntent(context, samplePdfUri))
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
currentPdfFile?.let {
|
||||||
|
if (it.exists()) it.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForDocumentLoad(pageText: String = "Page 1 of 4") {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 15_000) {
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithText(pageText)
|
||||||
|
.fetchSemanticsNodes().size == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensurePaginationMode() {
|
||||||
|
composeTestRule.onNodeWithContentDescription("More Options").performClick()
|
||||||
|
composeTestRule.onNodeWithText("Reading Mode: Paginated").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun documentLoadsAndDisplaysCorrectPageCount() {
|
||||||
|
waitForDocumentLoad()
|
||||||
|
composeTestRule.onNodeWithTag("PageNumberIndicator")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tableOfContents_displaysEmptyState() {
|
||||||
|
waitForDocumentLoad()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("TocButton").performClick()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Chapters are not available for this book.").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("SameParameterValue")
|
||||||
|
private fun copyAssetToCache(context: Context, assetName: String): Uri {
|
||||||
|
val uniqueName = "${UUID.randomUUID()}_$assetName"
|
||||||
|
val file = File(context.cacheDir, uniqueName)
|
||||||
|
|
||||||
|
currentPdfFile = file
|
||||||
|
|
||||||
|
if (file.exists()) file.delete()
|
||||||
|
context.assets.open(assetName).use { inputStream ->
|
||||||
|
file.outputStream().use { outputStream ->
|
||||||
|
inputStream.copyTo(outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.provider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bookmarkFunctionality_addNavigateAndDelete() {
|
||||||
|
waitForDocumentLoad()
|
||||||
|
|
||||||
|
ensurePaginationMode()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Page 1 of 4").assertIsDisplayed()
|
||||||
|
|
||||||
|
try {
|
||||||
|
composeTestRule.onRoot().performTouchInput { swipe(start = this.centerRight, end = this.centerLeft, durationMillis = 300) }
|
||||||
|
composeTestRule.onRoot().performClick()
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodesWithText("Page 2 of 4").fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Page 2 of 4").assertIsDisplayed()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
composeTestRule.onNodeWithContentDescription("More Options").performClick()
|
||||||
|
composeTestRule.onNodeWithText("Bookmark this page").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
composeTestRule.onRoot().performTouchInput { swipe(start = this.centerRight, end = this.centerLeft, durationMillis = 300) }
|
||||||
|
composeTestRule.onRoot().performClick()
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodesWithText("Page 3 of 4").fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Page 3 of 4").assertIsDisplayed()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
composeTestRule.onNodeWithTag("TocButton").performClick()
|
||||||
|
composeTestRule.onNodeWithTag("BookmarksTab").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
composeTestRule.onNodeWithTag("BookmarkItem_1").assertIsDisplayed()
|
||||||
|
.assert(hasText("Page 2", substring = true))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
composeTestRule.onNodeWithTag("BookmarkItem_1").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodes(hasTestTag("PageNumberIndicator").and(hasText("Page 2 of 4"))).fetchSemanticsNodes().size == 1
|
||||||
|
}
|
||||||
|
composeTestRule.onNode(hasTestTag("PageNumberIndicator").and(hasText("Page 2 of 4"))).assertIsDisplayed()
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
composeTestRule.onNodeWithTag("TocButton").performClick()
|
||||||
|
composeTestRule.onNodeWithTag("BookmarksTab").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
composeTestRule.onNodeWithContentDescription("More options for bookmark").performClick()
|
||||||
|
composeTestRule.onNodeWithText("Delete").performClick()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
composeTestRule.onNodeWithText("Delete", useUnmergedTree = true).performClick()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
composeTestRule.onNodeWithTag("BookmarkItem_1").assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithText("You haven't added any bookmarks yet.").assertIsDisplayed()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sliderNavigation_opensAndDisplaysCorrectly() {
|
||||||
|
waitForDocumentLoad()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithContentDescription("Navigate with slider").performClick()
|
||||||
|
composeTestRule.onNodeWithContentDescription("Exit slider navigation").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("1 / 4").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun displayMode_switchesToVerticalScroll() {
|
||||||
|
waitForDocumentLoad()
|
||||||
|
|
||||||
|
// Ensure we are in Pagination mode first to test the switch
|
||||||
|
ensurePaginationMode()
|
||||||
|
|
||||||
|
// Verify Vertical Scroll component is NOT displayed initially
|
||||||
|
composeTestRule.onNodeWithTag("PdfVerticalScroll").assertDoesNotExist()
|
||||||
|
|
||||||
|
// Switch to Vertical Scroll
|
||||||
|
composeTestRule.onNodeWithContentDescription("More Options").performClick()
|
||||||
|
composeTestRule.onNodeWithText("Reading Mode: Vertical scroll").performClick()
|
||||||
|
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
// Verify Vertical Scroll component IS displayed
|
||||||
|
composeTestRule.onNodeWithTag("PdfVerticalScroll").assertIsDisplayed()
|
||||||
|
|
||||||
|
// Switch back to Pagination
|
||||||
|
ensurePaginationMode()
|
||||||
|
|
||||||
|
// Verify Vertical Scroll component is gone
|
||||||
|
composeTestRule.onNodeWithTag("PdfVerticalScroll").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun search_uiOpensAndAcceptsQuery() {
|
||||||
|
waitForDocumentLoad()
|
||||||
|
|
||||||
|
// Click search button
|
||||||
|
composeTestRule.onNodeWithTag("SearchButton").performClick()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("English, Spanish, French, etc.").performClick()
|
||||||
|
|
||||||
|
// Verify text field appears
|
||||||
|
composeTestRule.onNodeWithTag("SearchTextField").assertIsDisplayed()
|
||||||
|
|
||||||
|
// Enter text
|
||||||
|
composeTestRule.onNodeWithTag("SearchTextField").performTextInput("test query")
|
||||||
|
|
||||||
|
// Verify text exists in the field
|
||||||
|
composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("test query")
|
||||||
|
|
||||||
|
// Close search
|
||||||
|
composeTestRule.onNodeWithContentDescription("Close Search").performClick()
|
||||||
|
|
||||||
|
// Verify text field is gone
|
||||||
|
composeTestRule.onNodeWithTag("SearchTextField").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullScreen_togglesVisibility() {
|
||||||
|
waitForDocumentLoad()
|
||||||
|
|
||||||
|
// Click enter full screen button
|
||||||
|
composeTestRule.onNodeWithContentDescription("Enter Full Screen").performClick()
|
||||||
|
|
||||||
|
// Verify exit full screen button appears
|
||||||
|
composeTestRule.onNodeWithContentDescription("Exit Full Screen").assertIsDisplayed()
|
||||||
|
|
||||||
|
// Click exit full screen
|
||||||
|
composeTestRule.onNodeWithContentDescription("Exit Full Screen").performClick()
|
||||||
|
|
||||||
|
// Verify exit button is gone and enter button returns
|
||||||
|
composeTestRule.onNodeWithContentDescription("Exit Full Screen").assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithContentDescription("Enter Full Screen").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun darkMode_togglesState() {
|
||||||
|
waitForDocumentLoad()
|
||||||
|
|
||||||
|
// Initial state: Light mode (default from cleared prefs), so button says "Enable Dark Mode"
|
||||||
|
composeTestRule.onNodeWithContentDescription("Enable Dark Mode").assertIsDisplayed()
|
||||||
|
|
||||||
|
// Toggle On
|
||||||
|
composeTestRule.onNodeWithContentDescription("Enable Dark Mode").performClick()
|
||||||
|
|
||||||
|
// State changed: Now button says "Disable Dark Mode"
|
||||||
|
composeTestRule.onNodeWithContentDescription("Disable Dark Mode").assertIsDisplayed()
|
||||||
|
|
||||||
|
// Toggle Off
|
||||||
|
composeTestRule.onNodeWithContentDescription("Disable Dark Mode").performClick()
|
||||||
|
|
||||||
|
// State changed back
|
||||||
|
composeTestRule.onNodeWithContentDescription("Enable Dark Mode").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// BaseTtsSynthesizerTest.kt
|
// BaseTtsSynthesizerTest.kt
|
||||||
package org.dueattendant149.bookreader.tts
|
package com.aryan.reader.tts
|
||||||
|
|
||||||
import android.speech.tts.TextToSpeech
|
import android.speech.tts.TextToSpeech
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader.epubreader
|
package com.aryan.reader.tts
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// TtsUtilsTest.kt
|
// TtsUtilsTest.kt
|
||||||
package org.dueattendant149.bookreader.tts
|
package com.aryan.reader.tts
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
package org.dueattendant149.bookreader
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import org.dueattendant149.bookreader.shared.FileType
|
|
||||||
import org.dueattendant149.bookreader.shared.ReaderFeatureSurface
|
|
||||||
import org.dueattendant149.bookreader.shared.ReaderPlatform
|
|
||||||
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class AppNavigationTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun appDestinations_useStableReaderRoutes() {
|
|
||||||
assertThat(AppDestinations.MAIN_ROUTE).isEqualTo("main")
|
|
||||||
assertThat(AppDestinations.PDF_VIEWER_ROUTE).isEqualTo("pdf_viewer")
|
|
||||||
assertThat(AppDestinations.EPUB_READER_ROUTE).isEqualTo("epub_reader")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun androidReaderSurface_mapsPdfBackedTypesToPdfViewer() {
|
|
||||||
val mappedSurfaces = listOf(
|
|
||||||
FileType.PDF,
|
|
||||||
FileType.CBZ,
|
|
||||||
FileType.CBR,
|
|
||||||
FileType.CB7,
|
|
||||||
FileType.CBT,
|
|
||||||
FileType.PPTX
|
|
||||||
).associateWith { it.readerSurfaceOnAndroid() }
|
|
||||||
|
|
||||||
assertThat(mappedSurfaces).containsExactly(
|
|
||||||
FileType.PDF, ReaderFeatureSurface.PDF_VIEWER,
|
|
||||||
FileType.CBZ, ReaderFeatureSurface.PDF_VIEWER,
|
|
||||||
FileType.CBR, ReaderFeatureSurface.PDF_VIEWER,
|
|
||||||
FileType.CB7, ReaderFeatureSurface.PDF_VIEWER,
|
|
||||||
FileType.CBT, ReaderFeatureSurface.PDF_VIEWER,
|
|
||||||
FileType.PPTX, ReaderFeatureSurface.PDF_VIEWER
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun androidReaderSurface_mapsTextBackedTypesToEpubReader() {
|
|
||||||
val mappedSurfaces = listOf(
|
|
||||||
FileType.EPUB,
|
|
||||||
FileType.MOBI,
|
|
||||||
FileType.MD,
|
|
||||||
FileType.TXT,
|
|
||||||
FileType.HTML,
|
|
||||||
FileType.FB2,
|
|
||||||
FileType.DOCX,
|
|
||||||
FileType.ODT,
|
|
||||||
FileType.FODT
|
|
||||||
).associateWith { it.readerSurfaceOnAndroid() }
|
|
||||||
|
|
||||||
assertThat(mappedSurfaces).containsExactly(
|
|
||||||
FileType.EPUB, ReaderFeatureSurface.EPUB_READER,
|
|
||||||
FileType.MOBI, ReaderFeatureSurface.EPUB_READER,
|
|
||||||
FileType.MD, ReaderFeatureSurface.EPUB_READER,
|
|
||||||
FileType.TXT, ReaderFeatureSurface.EPUB_READER,
|
|
||||||
FileType.HTML, ReaderFeatureSurface.EPUB_READER,
|
|
||||||
FileType.FB2, ReaderFeatureSurface.EPUB_READER,
|
|
||||||
FileType.DOCX, ReaderFeatureSurface.EPUB_READER,
|
|
||||||
FileType.ODT, ReaderFeatureSurface.EPUB_READER,
|
|
||||||
FileType.FODT, ReaderFeatureSurface.EPUB_READER
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun androidReaderSurface_returnsNullForUnknownFileType() {
|
|
||||||
assertThat(FileType.UNKNOWN.readerSurfaceOnAndroid()).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun appNavBackInterceptor_onlyHandlesResumedNonReaderBackStackEntries() {
|
|
||||||
assertThat(
|
|
||||||
shouldInterceptAppNavBack(
|
|
||||||
currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
|
|
||||||
hasPreviousBackStackEntry = true,
|
|
||||||
isCurrentEntryResumed = true
|
|
||||||
)
|
|
||||||
).isTrue()
|
|
||||||
assertThat(
|
|
||||||
shouldInterceptAppNavBack(
|
|
||||||
currentRoute = AppDestinations.MAIN_ROUTE,
|
|
||||||
hasPreviousBackStackEntry = true,
|
|
||||||
isCurrentEntryResumed = true
|
|
||||||
)
|
|
||||||
).isFalse()
|
|
||||||
assertThat(
|
|
||||||
shouldInterceptAppNavBack(
|
|
||||||
currentRoute = AppDestinations.PDF_VIEWER_ROUTE,
|
|
||||||
hasPreviousBackStackEntry = true,
|
|
||||||
isCurrentEntryResumed = true
|
|
||||||
)
|
|
||||||
).isFalse()
|
|
||||||
assertThat(
|
|
||||||
shouldInterceptAppNavBack(
|
|
||||||
currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
|
|
||||||
hasPreviousBackStackEntry = false,
|
|
||||||
isCurrentEntryResumed = true
|
|
||||||
)
|
|
||||||
).isFalse()
|
|
||||||
assertThat(
|
|
||||||
shouldInterceptAppNavBack(
|
|
||||||
currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
|
|
||||||
hasPreviousBackStackEntry = true,
|
|
||||||
isCurrentEntryResumed = false
|
|
||||||
)
|
|
||||||
).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? {
|
|
||||||
return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
package org.dueattendant149.bookreader
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.compose.ui.test.performClick
|
|
||||||
import androidx.compose.ui.test.performTouchInput
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class HomeRecentFileCardTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val composeTestRule = createComposeRule()
|
|
||||||
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun recentFileCardShowsProgressAndUnavailableState() {
|
|
||||||
val item = recentBook(
|
|
||||||
bookId = "home_unavailable_epub",
|
|
||||||
title = "Unavailable Field Guide",
|
|
||||||
author = "Casey Example",
|
|
||||||
progress = 42f,
|
|
||||||
isAvailable = false
|
|
||||||
)
|
|
||||||
|
|
||||||
setRecentFileCard(item = item)
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("HomeRecentFileCard_home_unavailable_epub").assertIsDisplayed()
|
|
||||||
composeTestRule.onNodeWithText("Unavailable Field Guide").assertIsDisplayed()
|
|
||||||
composeTestRule.onNodeWithText("Casey Example").assertIsDisplayed()
|
|
||||||
composeTestRule.onNodeWithText("42%").assertIsDisplayed()
|
|
||||||
composeTestRule.onAllNodesWithContentDescription(text(R.string.not_available_locally))[0]
|
|
||||||
.assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun recentFileCardClickLongClickAndSelectedOverlayWork() {
|
|
||||||
val item = recentBook(
|
|
||||||
bookId = "home_selected_pdf",
|
|
||||||
title = "Selected Position Notes",
|
|
||||||
author = "Morgan Example",
|
|
||||||
progress = 7f
|
|
||||||
)
|
|
||||||
var clicked = false
|
|
||||||
var longClicked = false
|
|
||||||
|
|
||||||
setRecentFileCard(
|
|
||||||
item = item,
|
|
||||||
isSelected = true,
|
|
||||||
onClick = { clicked = true },
|
|
||||||
onLongClick = { longClicked = true }
|
|
||||||
)
|
|
||||||
|
|
||||||
composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_selected))[0]
|
|
||||||
.assertIsDisplayed()
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performClick()
|
|
||||||
composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performTouchInput {
|
|
||||||
down(center)
|
|
||||||
advanceEventTime(600)
|
|
||||||
up()
|
|
||||||
}
|
|
||||||
|
|
||||||
assertThat(clicked).isTrue()
|
|
||||||
assertThat(longClicked).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setRecentFileCard(
|
|
||||||
item: RecentFileItem,
|
|
||||||
isSelected: Boolean = false,
|
|
||||||
isPinned: Boolean = false,
|
|
||||||
onClick: () -> Unit = {},
|
|
||||||
onLongClick: () -> Unit = {}
|
|
||||||
) {
|
|
||||||
composeTestRule.setContent {
|
|
||||||
MaterialTheme {
|
|
||||||
RecentFileCard(
|
|
||||||
item = item,
|
|
||||||
isSelected = isSelected,
|
|
||||||
isPinned = isPinned,
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
isDownloading = false,
|
|
||||||
usePdfFileNameAsDisplayName = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recentBook(
|
|
||||||
bookId: String,
|
|
||||||
title: String,
|
|
||||||
author: String,
|
|
||||||
progress: Float,
|
|
||||||
isAvailable: Boolean = true
|
|
||||||
): RecentFileItem {
|
|
||||||
return RecentFileItem(
|
|
||||||
bookId = bookId,
|
|
||||||
uriString = "content://home-test/$bookId",
|
|
||||||
type = FileType.EPUB,
|
|
||||||
displayName = "$bookId.epub",
|
|
||||||
timestamp = 1_000L,
|
|
||||||
title = title,
|
|
||||||
author = author,
|
|
||||||
progressPercentage = progress,
|
|
||||||
isRecent = true,
|
|
||||||
isAvailable = isAvailable
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun text(resId: Int): String {
|
|
||||||
return context.getString(resId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,400 +0,0 @@
|
||||||
package org.dueattendant149.bookreader
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.compose.ui.test.performClick
|
|
||||||
import androidx.compose.ui.test.performTextInput
|
|
||||||
import androidx.compose.ui.test.performTouchInput
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
|
||||||
import org.dueattendant149.bookreader.data.TagEntity
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class LibraryScreenContentTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val composeTestRule = createComposeRule()
|
|
||||||
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
private val focusTag = TagEntity(id = "tag_focus", name = "Focus", color = null, createdAt = 1L)
|
|
||||||
private val libraryBooks = listOf(
|
|
||||||
libraryBook(
|
|
||||||
bookId = "pdf_beta",
|
|
||||||
type = FileType.PDF,
|
|
||||||
displayName = "beta.pdf",
|
|
||||||
title = "Beta Manual",
|
|
||||||
author = "Mira Example",
|
|
||||||
timestamp = 3_000L,
|
|
||||||
progress = 84f
|
|
||||||
),
|
|
||||||
libraryBook(
|
|
||||||
bookId = "epub_gamma",
|
|
||||||
type = FileType.EPUB,
|
|
||||||
displayName = "gamma.epub",
|
|
||||||
title = "Gamma Field Notes",
|
|
||||||
author = "Nora Example",
|
|
||||||
timestamp = 2_000L,
|
|
||||||
progress = 47f,
|
|
||||||
tags = listOf(focusTag)
|
|
||||||
),
|
|
||||||
libraryBook(
|
|
||||||
bookId = "epub_alpha",
|
|
||||||
type = FileType.EPUB,
|
|
||||||
displayName = "alpha.epub",
|
|
||||||
title = "Alpha Orchard",
|
|
||||||
author = "Zara Example",
|
|
||||||
timestamp = 1_000L,
|
|
||||||
progress = 12f,
|
|
||||||
tags = listOf(focusTag)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun searchFiltersAndClearRestoresLibraryList() {
|
|
||||||
setLibraryContent()
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithText("Beta Manual").assertIsDisplayed()
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick()
|
|
||||||
composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Gamma")
|
|
||||||
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
assertNoText("Alpha Orchard")
|
|
||||||
assertNoText("Beta Manual")
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick()
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() &&
|
|
||||||
composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_search)).performClick()
|
|
||||||
composeTestRule.onNodeWithText(text(R.string.library_title)).assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun searchMatchesAuthorAndTagNames() {
|
|
||||||
setLibraryContent()
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick()
|
|
||||||
composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Zara")
|
|
||||||
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
assertNoText("Gamma Field Notes")
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick()
|
|
||||||
composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Focus")
|
|
||||||
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() &&
|
|
||||||
composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
assertNoText("Beta Manual")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun activeFileTypeFilterChipCanBeCleared() {
|
|
||||||
setLibraryContent(initialFilters = LibraryFilters(fileTypes = setOf(FileType.EPUB)))
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed()
|
|
||||||
composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed()
|
|
||||||
assertNoText("Beta Manual")
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithText(text(R.string.filter_types, FileType.EPUB.name)).performClick()
|
|
||||||
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tagAndReadStatusFiltersUseSharedLibraryRules() {
|
|
||||||
setLibraryContent(
|
|
||||||
initialFilters = LibraryFilters(
|
|
||||||
tagIds = setOf(focusTag.id),
|
|
||||||
readStatus = ReadStatusFilter.IN_PROGRESS
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed()
|
|
||||||
composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed()
|
|
||||||
assertNoText("Beta Manual")
|
|
||||||
composeTestRule.onNodeWithText(text(R.string.filter_tags, focusTag.name)).assertIsDisplayed()
|
|
||||||
composeTestRule.onNodeWithText(
|
|
||||||
text(R.string.filter_status, text(ReadStatusFilter.IN_PROGRESS.labelRes))
|
|
||||||
).assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun clearSelectionReturnsToNormalToolbar() {
|
|
||||||
setLibraryContent()
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput {
|
|
||||||
down(center)
|
|
||||||
advanceEventTime(600)
|
|
||||||
up()
|
|
||||||
}
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.clear_selection)).performClick()
|
|
||||||
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodesWithText(text(R.string.library_title)).fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sortMenuSelectionReordersLibraryItems() {
|
|
||||||
setLibraryContent()
|
|
||||||
|
|
||||||
assertBookAbove("pdf_beta", "epub_alpha")
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("LibrarySortButton").performClick()
|
|
||||||
composeTestRule.onNodeWithText(text(SortOrder.TITLE_ASC.labelRes)).performClick()
|
|
||||||
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
runCatching {
|
|
||||||
bookTop("epub_alpha") < bookTop("pdf_beta")
|
|
||||||
}.getOrDefault(false)
|
|
||||||
}
|
|
||||||
assertBookAbove("epub_alpha", "pdf_beta")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun longPressBookShowsContextualToolbarActions() {
|
|
||||||
var tagClicked = false
|
|
||||||
var pinClicked = false
|
|
||||||
var infoClicked = false
|
|
||||||
var selectAllClicked = false
|
|
||||||
var deleteClicked = false
|
|
||||||
|
|
||||||
setLibraryContent(
|
|
||||||
onTagClick = { tagClicked = true },
|
|
||||||
onPinClick = { pinClicked = true },
|
|
||||||
onInfoClick = { infoClicked = true },
|
|
||||||
onSelectAllClick = { selectAllClicked = true },
|
|
||||||
onDeleteClick = { deleteClicked = true }
|
|
||||||
)
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput {
|
|
||||||
down(center)
|
|
||||||
advanceEventTime(600)
|
|
||||||
up()
|
|
||||||
}
|
|
||||||
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_tag)).performClick()
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.pin_unpin)).performClick()
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.info)).performClick()
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.select_all)).performClick()
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.action_delete)).performClick()
|
|
||||||
|
|
||||||
assertThat(tagClicked).isTrue()
|
|
||||||
assertThat(pinClicked).isTrue()
|
|
||||||
assertThat(infoClicked).isTrue()
|
|
||||||
assertThat(selectAllClicked).isTrue()
|
|
||||||
assertThat(deleteClicked).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shelvesTabShowsShelfRowsAndNewShelfAction() {
|
|
||||||
val shelf = Shelf(
|
|
||||||
id = "manual_favorites",
|
|
||||||
name = "Manual Favorites",
|
|
||||||
type = ShelfType.MANUAL,
|
|
||||||
books = listOf(libraryBooks[0], libraryBooks[1])
|
|
||||||
)
|
|
||||||
var clickedShelfId: String? = null
|
|
||||||
var longClickedShelfId: String? = null
|
|
||||||
var newShelfClicked = false
|
|
||||||
|
|
||||||
setLibraryContent(
|
|
||||||
initialPage = 1,
|
|
||||||
shelves = listOf(shelf),
|
|
||||||
onShelfClick = { clickedShelfId = it.id },
|
|
||||||
onShelfLongClick = { longClickedShelfId = it.id },
|
|
||||||
onNewShelfClick = { newShelfClicked = true }
|
|
||||||
)
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").assertIsDisplayed()
|
|
||||||
composeTestRule.onNodeWithText("Manual Favorites").assertIsDisplayed()
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performClick()
|
|
||||||
composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performTouchInput {
|
|
||||||
down(center)
|
|
||||||
advanceEventTime(600)
|
|
||||||
up()
|
|
||||||
}
|
|
||||||
composeTestRule.onNodeWithTag("LibraryNewShelfFab").performClick()
|
|
||||||
|
|
||||||
assertThat(clickedShelfId).isEqualTo("manual_favorites")
|
|
||||||
assertThat(longClickedShelfId).isEqualTo("manual_favorites")
|
|
||||||
assertThat(newShelfClicked).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setLibraryContent(
|
|
||||||
initialFilters: LibraryFilters = LibraryFilters(),
|
|
||||||
initialPage: Int = 0,
|
|
||||||
shelves: List<Shelf> = emptyList(),
|
|
||||||
onTagClick: () -> Unit = {},
|
|
||||||
onPinClick: () -> Unit = {},
|
|
||||||
onInfoClick: () -> Unit = {},
|
|
||||||
onSelectAllClick: () -> Unit = {},
|
|
||||||
onDeleteClick: () -> Unit = {},
|
|
||||||
onShelfClick: (Shelf) -> Unit = {},
|
|
||||||
onShelfLongClick: (Shelf) -> Unit = {},
|
|
||||||
onNewShelfClick: () -> Unit = {}
|
|
||||||
) {
|
|
||||||
val searchQuery = mutableStateOf("")
|
|
||||||
val isSearchActive = mutableStateOf(false)
|
|
||||||
val filters = mutableStateOf(initialFilters)
|
|
||||||
val sortOrder = mutableStateOf(SortOrder.RECENT)
|
|
||||||
val selectedItems = mutableStateOf(emptySet<RecentFileItem>())
|
|
||||||
|
|
||||||
composeTestRule.setContent {
|
|
||||||
val pagerState = rememberPagerState(
|
|
||||||
initialPage = initialPage,
|
|
||||||
pageCount = { 3 }
|
|
||||||
)
|
|
||||||
val visibleBooks = sortFiles(
|
|
||||||
applyLibraryFilters(
|
|
||||||
filterBySearch(libraryBooks, searchQuery.value),
|
|
||||||
filters.value
|
|
||||||
),
|
|
||||||
sortOrder.value
|
|
||||||
)
|
|
||||||
|
|
||||||
MaterialTheme {
|
|
||||||
LibraryScreenContent(
|
|
||||||
tabTitles = listOf(
|
|
||||||
text(R.string.tab_all_books),
|
|
||||||
text(R.string.tab_shelves),
|
|
||||||
text(R.string.tab_folders)
|
|
||||||
),
|
|
||||||
recentFiles = visibleBooks,
|
|
||||||
rawLibraryFiles = libraryBooks,
|
|
||||||
shelves = shelves,
|
|
||||||
selectedItems = selectedItems.value,
|
|
||||||
selectedShelves = emptySet(),
|
|
||||||
sortOrder = sortOrder.value,
|
|
||||||
libraryFilters = filters.value,
|
|
||||||
allTags = listOf(focusTag),
|
|
||||||
pinnedLibraryBookIds = emptySet(),
|
|
||||||
pagerState = pagerState,
|
|
||||||
scope = rememberCoroutineScope(),
|
|
||||||
searchQuery = searchQuery.value,
|
|
||||||
isSearchActive = isSearchActive.value,
|
|
||||||
onSearchQueryChange = { searchQuery.value = it },
|
|
||||||
onSearchActiveChange = { isSearchActive.value = it },
|
|
||||||
onSortOrderChange = { sortOrder.value = it },
|
|
||||||
onFilterClick = {},
|
|
||||||
onClearFilters = { filters.value = LibraryFilters() },
|
|
||||||
onRemoveFilter = { filters.value = it },
|
|
||||||
onTagClick = onTagClick,
|
|
||||||
onPinClick = onPinClick,
|
|
||||||
onClearSelection = { selectedItems.value = emptySet() },
|
|
||||||
onItemClick = {},
|
|
||||||
onItemLongClick = { item -> selectedItems.value = setOf(item) },
|
|
||||||
onInfoClick = onInfoClick,
|
|
||||||
onSaveClick = null,
|
|
||||||
onShareClick = null,
|
|
||||||
onDeleteClick = onDeleteClick,
|
|
||||||
onSelectAllClick = onSelectAllClick,
|
|
||||||
onShelfClick = onShelfClick,
|
|
||||||
onShelfLongClick = onShelfLongClick,
|
|
||||||
onClearShelfSelection = {},
|
|
||||||
onDeleteShelves = {},
|
|
||||||
onNewShelfClick = onNewShelfClick,
|
|
||||||
onSelectFileClick = {},
|
|
||||||
onScanNowClick = {},
|
|
||||||
onSyncMetadataClick = {},
|
|
||||||
onSelectSyncFolderClick = {},
|
|
||||||
onEditFolderFiltersClick = { _, _ -> },
|
|
||||||
onDisconnectSyncFolderClick = {},
|
|
||||||
downloadingBookIds = emptySet(),
|
|
||||||
lastFolderScanTime = null,
|
|
||||||
isLoading = false,
|
|
||||||
isRefreshing = false,
|
|
||||||
syncedFolders = emptyList(),
|
|
||||||
onRemoveFolderClick = {},
|
|
||||||
onFolderLocalSyncChange = { _, _, _ -> },
|
|
||||||
onOpdsBookDownloaded = { _, _ -> },
|
|
||||||
onStreamOpdsBook = { _, _ -> },
|
|
||||||
onDeleteCatalogStreams = {},
|
|
||||||
onSettingsClick = {},
|
|
||||||
usePdfFileNameAsDisplayName = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun libraryBook(
|
|
||||||
bookId: String,
|
|
||||||
type: FileType,
|
|
||||||
displayName: String,
|
|
||||||
title: String,
|
|
||||||
author: String,
|
|
||||||
timestamp: Long,
|
|
||||||
progress: Float,
|
|
||||||
tags: List<TagEntity> = emptyList()
|
|
||||||
): RecentFileItem {
|
|
||||||
return RecentFileItem(
|
|
||||||
bookId = bookId,
|
|
||||||
uriString = "content://library-test/$bookId",
|
|
||||||
type = type,
|
|
||||||
displayName = displayName,
|
|
||||||
timestamp = timestamp,
|
|
||||||
title = title,
|
|
||||||
author = author,
|
|
||||||
progressPercentage = progress,
|
|
||||||
isRecent = true,
|
|
||||||
isAvailable = true,
|
|
||||||
fileSize = timestamp * 10,
|
|
||||||
tags = tags
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun assertBookAbove(upperBookId: String, lowerBookId: String) {
|
|
||||||
assertThat(bookTop(upperBookId)).isLessThan(bookTop(lowerBookId))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun assertNoText(value: String) {
|
|
||||||
assertThat(composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes()).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bookTop(bookId: String): Float {
|
|
||||||
return composeTestRule
|
|
||||||
.onNodeWithTag("LibraryBookItem_$bookId")
|
|
||||||
.fetchSemanticsNode()
|
|
||||||
.boundsInRoot
|
|
||||||
.top
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun text(resId: Int, vararg args: Any): String {
|
|
||||||
return context.getString(resId, *args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.epubreader
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import org.dueattendant149.bookreader.epub.EpubBook
|
|
||||||
import org.dueattendant149.bookreader.epub.EpubChapter
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class EpubReaderLogicTest {
|
|
||||||
|
|
||||||
private lateinit var context: Context
|
|
||||||
private lateinit var testDir: File
|
|
||||||
private lateinit var testBook: EpubBook
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
context = ApplicationProvider.getApplicationContext()
|
|
||||||
testDir = File(context.cacheDir, "test_epub_search").apply {
|
|
||||||
deleteRecursively()
|
|
||||||
mkdirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
val chapter1File = File(testDir, "chapter1.html").apply {
|
|
||||||
writeText("<html><body><p>A simple Test case.</p></body></html>")
|
|
||||||
}
|
|
||||||
val chapter2File = File(testDir, "chapter2.html").apply {
|
|
||||||
writeText("<html><body><p>Another test case here.</p><p>The word Test appears twice.</p></body></html>")
|
|
||||||
}
|
|
||||||
|
|
||||||
testBook = EpubBook(
|
|
||||||
fileName = "test.epub",
|
|
||||||
title = "Test Book",
|
|
||||||
author = "Tester",
|
|
||||||
language = "en",
|
|
||||||
coverImage = null,
|
|
||||||
extractionBasePath = testDir.absolutePath,
|
|
||||||
chapters = listOf(
|
|
||||||
EpubChapter(
|
|
||||||
chapterId = "ch1",
|
|
||||||
absPath = chapter1File.absolutePath,
|
|
||||||
title = "Chapter 1",
|
|
||||||
htmlFilePath = "chapter1.html",
|
|
||||||
plainTextContent = "",
|
|
||||||
htmlContent = ""
|
|
||||||
),
|
|
||||||
EpubChapter(
|
|
||||||
chapterId = "ch2",
|
|
||||||
absPath = chapter2File.absolutePath,
|
|
||||||
title = "Chapter 2",
|
|
||||||
htmlFilePath = "chapter2.html",
|
|
||||||
plainTextContent = "",
|
|
||||||
htmlContent = ""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
testDir.deleteRecursively()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun search_findsCorrectResults() = runTest {
|
|
||||||
val results = createEpubSearcher(testBook)("case")
|
|
||||||
|
|
||||||
assertThat(results).hasSize(2)
|
|
||||||
assertThat(results.count { it.locationTitle == "Chapter 1" }).isEqualTo(1)
|
|
||||||
assertThat(results.count { it.locationTitle == "Chapter 2" }).isEqualTo(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun search_isCaseInsensitive() = runTest {
|
|
||||||
val results = createEpubSearcher(testBook)("test")
|
|
||||||
|
|
||||||
assertThat(results).hasSize(3)
|
|
||||||
assertThat(results[0].locationTitle).isEqualTo("Chapter 1")
|
|
||||||
assertThat(results[1].locationTitle).isEqualTo("Chapter 2")
|
|
||||||
assertThat(results[2].locationTitle).isEqualTo("Chapter 2")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun search_noResultsFound() = runTest {
|
|
||||||
val results = createEpubSearcher(testBook)("nonexistent")
|
|
||||||
|
|
||||||
assertThat(results).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun search_createsCorrectSnippetHighlight() = runTest {
|
|
||||||
val result = createEpubSearcher(testBook)("Test").first()
|
|
||||||
val boldRanges = result.snippet.spanStyles.filter { it.item == SpanStyle(fontWeight = FontWeight.Bold) }
|
|
||||||
|
|
||||||
assertThat(boldRanges).hasSize(1)
|
|
||||||
val highlight = boldRanges.first()
|
|
||||||
assertThat(result.snippet.text.substring(highlight.start, highlight.end)).isEqualTo("Test")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,818 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.epubreader
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.assertTextContains
|
|
||||||
import androidx.compose.ui.test.click
|
|
||||||
import androidx.compose.ui.test.hasSetTextAction
|
|
||||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.compose.ui.test.onRoot
|
|
||||||
import androidx.compose.ui.test.performClick
|
|
||||||
import androidx.compose.ui.test.performTextClearance
|
|
||||||
import androidx.compose.ui.test.performTextInput
|
|
||||||
import androidx.compose.ui.test.performTouchInput
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import androidx.test.core.app.ActivityScenario
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import org.dueattendant149.bookreader.FileType
|
|
||||||
import org.dueattendant149.bookreader.FileHasher
|
|
||||||
import org.dueattendant149.bookreader.MainActivity
|
|
||||||
import org.dueattendant149.bookreader.R
|
|
||||||
import org.dueattendant149.bookreader.RenderMode
|
|
||||||
import org.dueattendant149.bookreader.data.AppDatabase
|
|
||||||
import org.dueattendant149.bookreader.data.RecentFileEntity
|
|
||||||
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
|
|
||||||
import org.dueattendant149.bookreader.shared.ReaderLocator
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import java.io.File
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class EpubReaderScreenTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val composeTestRule = createEmptyComposeRule()
|
|
||||||
|
|
||||||
private val fixtureAssetName = "epub/reader_test_book.epub"
|
|
||||||
private val fixtureBookTitle = "Reader Android UI Test Book"
|
|
||||||
private val sanitizedFixtureBookTitle = "ReaderAndroidUITestBook"
|
|
||||||
private val targetContext: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
private val instrumentationContext: Context = InstrumentationRegistry.getInstrumentation().context
|
|
||||||
private var currentEpubFile: File? = null
|
|
||||||
private var scenario: ActivityScenario<MainActivity>? = null
|
|
||||||
private lateinit var fixtureBookId: String
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
clearReaderPrefs()
|
|
||||||
fixtureBookId = requireNotNull(
|
|
||||||
runBlocking {
|
|
||||||
FileHasher.calculateSha256 {
|
|
||||||
instrumentationContext.assets.open(fixtureAssetName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
runBlocking {
|
|
||||||
AppDatabase.getDatabase(targetContext)
|
|
||||||
.recentFileDao()
|
|
||||||
.deleteFilePermanently(listOf(fixtureBookId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
scenario?.close()
|
|
||||||
currentEpubFile?.let {
|
|
||||||
if (it.exists()) it.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_opensReaderAndShowsBookTitle() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
showReaderChrome()
|
|
||||||
|
|
||||||
waitForText(fixtureBookTitle)
|
|
||||||
composeTestRule.onNodeWithText(fixtureBookTitle).assertIsDisplayed()
|
|
||||||
assertThat(hasContentDescription(text(R.string.tooltip_search))).isTrue()
|
|
||||||
assertThat(hasContentDescription(text(R.string.content_desc_chapters_menu))).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_recordsInitialReadingPosition() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
|
|
||||||
recentFile?.lastChapterIndex != null &&
|
|
||||||
recentFile.locatorBlockIndex != null &&
|
|
||||||
recentFile.locatorCharOffset != null &&
|
|
||||||
recentFile.progressPercentage != null
|
|
||||||
}
|
|
||||||
|
|
||||||
val recentFile = readFixtureRecentFile()
|
|
||||||
assertThat(recentFile?.lastChapterIndex).isAtLeast(0)
|
|
||||||
assertThat(recentFile?.locatorBlockIndex).isAtLeast(0)
|
|
||||||
assertThat(recentFile?.locatorCharOffset).isAtLeast(0)
|
|
||||||
assertThat(recentFile?.progressPercentage).isAtLeast(0f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_restoresSeededReadingPositionWithoutResettingToStart() {
|
|
||||||
launchFixtureReader { fixtureUri ->
|
|
||||||
seedFixtureRecentFile(
|
|
||||||
uriString = fixtureUri.toString(),
|
|
||||||
chapterIndex = 1,
|
|
||||||
blockIndex = 1,
|
|
||||||
charOffset = 0,
|
|
||||||
progress = 45f
|
|
||||||
)
|
|
||||||
}
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
|
|
||||||
recentFile?.lastChapterIndex == 1 &&
|
|
||||||
recentFile.locatorBlockIndex == 1 &&
|
|
||||||
recentFile.locatorCharOffset == 0 &&
|
|
||||||
(recentFile.progressPercentage ?: 0f) >= 45f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_drawerShowsFixtureChapters() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
|
||||||
|
|
||||||
waitForText(text(R.string.tab_chapters))
|
|
||||||
waitForTextContaining("Chapter One")
|
|
||||||
waitForTextContaining("Chapter Two")
|
|
||||||
waitForTextContaining("Chapter Three")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_drawerShowsFixtureImageCatalog() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
|
||||||
clickText(text(R.string.tab_images))
|
|
||||||
|
|
||||||
waitForText("Fixture diagram")
|
|
||||||
waitForTextContaining("Chapter Three")
|
|
||||||
assertThat(hasContentDescription(text(R.string.content_desc_download_image))).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_searchFindsUniqueFixtureMarker() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
clickReaderControl(text(R.string.tooltip_search))
|
|
||||||
waitForTag("SearchTextField")
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("SearchTextField").performTextInput("SEARCH_TARGET_DELTA")
|
|
||||||
composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("SEARCH_TARGET_DELTA")
|
|
||||||
|
|
||||||
waitForTag("SearchResultItem_1", timeoutMillis = 20_000)
|
|
||||||
composeTestRule.onNodeWithTag("SearchResultItem_1").assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_searchNavigationPositionSurvivesReadingModeSwitches() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
navigateToFixtureSearchResult("SEARCH_TARGET_DELTA", expectedChapterIndex = 1)
|
|
||||||
clickContentDescription(text(R.string.tooltip_close_search))
|
|
||||||
|
|
||||||
waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
|
|
||||||
recentFile?.lastChapterIndex == 1 &&
|
|
||||||
recentFile.locatorBlockIndex != null &&
|
|
||||||
recentFile.locatorCharOffset != null &&
|
|
||||||
recentFile.progressPercentage != null
|
|
||||||
}
|
|
||||||
|
|
||||||
openOverflowMenu()
|
|
||||||
clickText(text(R.string.menu_change_reading_mode))
|
|
||||||
clickText(text(R.string.menu_reading_mode_paginated))
|
|
||||||
waitForRenderMode(RenderMode.PAGINATED)
|
|
||||||
|
|
||||||
waitForRecentFile(timeoutMillis = 20_000) { recentFile ->
|
|
||||||
recentFile?.lastChapterIndex == 1 &&
|
|
||||||
(recentFile.progressPercentage ?: 0f) > 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
openOverflowMenu()
|
|
||||||
clickText(text(R.string.menu_change_reading_mode))
|
|
||||||
clickText(text(R.string.menu_reading_mode_vertical_webview))
|
|
||||||
waitForRenderMode(RenderMode.VERTICAL_SCROLL)
|
|
||||||
|
|
||||||
waitForRecentFile(timeoutMillis = 20_000) { recentFile ->
|
|
||||||
recentFile?.lastChapterIndex == 1 &&
|
|
||||||
(recentFile.progressPercentage ?: 0f) > 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_addsCurrentPageBookmarkPersistsAndDeletes() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
openOverflowMenu()
|
|
||||||
clickText(text(R.string.menu_bookmark_this_page))
|
|
||||||
|
|
||||||
waitForFixtureBookmarks(timeoutMillis = 20_000) { bookmarksJson ->
|
|
||||||
parseBookmarksJson(bookmarksJson).isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
|
||||||
clickText(text(R.string.tab_bookmarks))
|
|
||||||
waitForTextContaining("Chapter One")
|
|
||||||
assertThat(hasContentDescription(text(R.string.content_desc_more_options_bookmark))).isTrue()
|
|
||||||
|
|
||||||
clickContentDescription(text(R.string.content_desc_more_options_bookmark))
|
|
||||||
clickText(text(R.string.action_delete))
|
|
||||||
waitForText(text(R.string.dialog_delete_bookmark))
|
|
||||||
clickText(text(R.string.action_delete))
|
|
||||||
|
|
||||||
waitForText(text(R.string.no_bookmarks_yet))
|
|
||||||
waitForFixtureBookmarks(timeoutMillis = 10_000) { bookmarksJson ->
|
|
||||||
parseBookmarksJson(bookmarksJson).isEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_drawerShowsSeededBookmarkAndSupportsRenameDelete() {
|
|
||||||
seedFixtureBookmark()
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
|
||||||
clickText(text(R.string.tab_bookmarks))
|
|
||||||
|
|
||||||
waitForText("BOOKMARK_TARGET_ECHO")
|
|
||||||
waitForText("Chapter Two")
|
|
||||||
|
|
||||||
clickContentDescription(text(R.string.content_desc_more_options_bookmark))
|
|
||||||
clickText(text(R.string.action_rename))
|
|
||||||
waitForText(text(R.string.dialog_rename_bookmark))
|
|
||||||
composeTestRule.onNode(hasSetTextAction()).performTextInput("Renamed fixture bookmark")
|
|
||||||
clickText(text(R.string.action_save))
|
|
||||||
waitForText("Renamed fixture bookmark")
|
|
||||||
|
|
||||||
clickContentDescription(text(R.string.content_desc_more_options_bookmark))
|
|
||||||
clickText(text(R.string.action_delete))
|
|
||||||
waitForText(text(R.string.dialog_delete_bookmark))
|
|
||||||
clickText(text(R.string.action_delete))
|
|
||||||
waitForText(text(R.string.no_bookmarks_yet))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_drawerShowsSeededAnnotationWithNoteAndFilter() {
|
|
||||||
seedFixtureHighlight()
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
|
||||||
clickText(text(R.string.tab_annotations))
|
|
||||||
|
|
||||||
waitForText("ANNOTATION_TARGET_GOLF")
|
|
||||||
waitForText("Fixture note survives startup")
|
|
||||||
waitForTextContaining("Chapter Three")
|
|
||||||
|
|
||||||
clickText(text(R.string.filter_with_notes))
|
|
||||||
waitForText("ANNOTATION_TARGET_GOLF")
|
|
||||||
waitForText("Fixture note survives startup")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_seededAnnotationSupportsColorNoteAndDeletePersistence() {
|
|
||||||
seedFixtureHighlight()
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
|
||||||
clickText(text(R.string.tab_annotations))
|
|
||||||
|
|
||||||
waitForText("ANNOTATION_TARGET_GOLF")
|
|
||||||
clickContentDescription(text(R.string.content_desc_options))
|
|
||||||
composeTestRule.onNodeWithTag("HighlightColor_blue").performClick()
|
|
||||||
|
|
||||||
waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
|
|
||||||
parseHighlightsJson(highlightsJson).singleOrNull()?.color == HighlightColor.BLUE
|
|
||||||
}
|
|
||||||
|
|
||||||
clickContentDescription(text(R.string.content_desc_options))
|
|
||||||
clickText(text(R.string.menu_edit_note))
|
|
||||||
waitForText(text(R.string.action_save_note))
|
|
||||||
composeTestRule.onNode(hasSetTextAction())
|
|
||||||
.performTextClearance()
|
|
||||||
composeTestRule.onNode(hasSetTextAction())
|
|
||||||
.performTextInput("Updated fixture note")
|
|
||||||
clickText(text(R.string.action_save_note))
|
|
||||||
|
|
||||||
waitForText("Updated fixture note")
|
|
||||||
waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
|
|
||||||
parseHighlightsJson(highlightsJson).singleOrNull()?.note == "Updated fixture note"
|
|
||||||
}
|
|
||||||
|
|
||||||
clickContentDescription(text(R.string.content_desc_options))
|
|
||||||
clickText(text(R.string.action_delete))
|
|
||||||
waitForText(text(R.string.dialog_delete_highlight))
|
|
||||||
clickText(text(R.string.action_delete))
|
|
||||||
|
|
||||||
waitForText(text(R.string.no_highlights_yet))
|
|
||||||
waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
|
|
||||||
parseHighlightsJson(highlightsJson).isEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_overflowSwitchesReadingModeAndTogglesPageOptions() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
openOverflowMenu()
|
|
||||||
clickText(text(R.string.menu_change_reading_mode))
|
|
||||||
clickText(text(R.string.menu_reading_mode_paginated))
|
|
||||||
waitForRenderMode(RenderMode.PAGINATED)
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
|
|
||||||
openOverflowMenu()
|
|
||||||
clickText(text(R.string.menu_tap_to_turn_pages))
|
|
||||||
assertReaderSettingEventually(
|
|
||||||
prefsName = "epub_reader_settings",
|
|
||||||
key = "tap_to_navigate_enabled",
|
|
||||||
expected = true
|
|
||||||
)
|
|
||||||
|
|
||||||
openOverflowMenu()
|
|
||||||
clickText(text(R.string.menu_realistic_page_turns))
|
|
||||||
assertReaderSettingEventually(
|
|
||||||
prefsName = "reader_prefs",
|
|
||||||
key = "page_turn_animation_enabled",
|
|
||||||
expected = true
|
|
||||||
)
|
|
||||||
|
|
||||||
openOverflowMenu()
|
|
||||||
clickText(text(R.string.menu_keep_screen_on))
|
|
||||||
assertReaderSettingEventually(
|
|
||||||
prefsName = "reader_prefs",
|
|
||||||
key = "keep_screen_on_enabled",
|
|
||||||
expected = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_visualOptionsSheetPersistsProgressPosition() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
openOverflowMenu()
|
|
||||||
clickText(text(R.string.menu_visual_options))
|
|
||||||
|
|
||||||
waitForText(text(R.string.visual_options_system_ui))
|
|
||||||
waitForText(text(R.string.visual_options_progress_bar))
|
|
||||||
waitForText(text(R.string.visual_options_progress_bar_position))
|
|
||||||
clickText(text(R.string.label_top))
|
|
||||||
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
|
||||||
.getInt("reader_page_info_position", 0) == 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_formatPanelShowsControlsAndPersistsLocalFontSize() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
clickReaderControl(text(R.string.content_desc_text_formatting))
|
|
||||||
waitForText(text(R.string.section_font_alignment))
|
|
||||||
waitForText(text(R.string.section_layout_spacing))
|
|
||||||
waitForText(text(R.string.label_font_size))
|
|
||||||
waitForText(text(R.string.label_line_height))
|
|
||||||
waitForText(text(R.string.label_paragraph_gap))
|
|
||||||
waitForText(text(R.string.label_image_size))
|
|
||||||
waitForText(text(R.string.label_horizontal_margin))
|
|
||||||
waitForText(text(R.string.label_vertical_margin))
|
|
||||||
|
|
||||||
composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_select_mode))[0]
|
|
||||||
.performClick()
|
|
||||||
clickText(text(R.string.format_local))
|
|
||||||
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
|
||||||
.getBoolean("format_is_local_$fixtureBookId", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_increase))[0]
|
|
||||||
.performClick()
|
|
||||||
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
|
||||||
.getFloat("local_font_size_$fixtureBookId", 1.0f) > 1.0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_fontSelectionSheetPersistsFontFamily() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
clickReaderControl(text(R.string.content_desc_text_formatting))
|
|
||||||
waitForText(text(R.string.section_font_alignment))
|
|
||||||
clickContentDescription(text(R.string.content_desc_select_font_family))
|
|
||||||
|
|
||||||
waitForText(text(R.string.select_font))
|
|
||||||
waitForText(text(R.string.tab_presets))
|
|
||||||
waitForText(text(R.string.tab_imported))
|
|
||||||
clickText("Lato")
|
|
||||||
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
|
||||||
.getString("reader_font_family", "original") == "lato"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_themePanelShowsThemesAndPersistsSelection() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
clickReaderControl(text(R.string.tooltip_theme_desc))
|
|
||||||
|
|
||||||
waitForText(text(R.string.reading_themes))
|
|
||||||
waitForText(text(R.string.theme_solid_colors))
|
|
||||||
waitForText("Light")
|
|
||||||
waitForText("Dark")
|
|
||||||
clickText("Sepia")
|
|
||||||
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
targetContext.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
|
||||||
.getString(PREF_READER_THEME, "system") == "sepia"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_drawerShowsEmptyBookmarkAndAnnotationStates() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
clickReaderControl(text(R.string.content_desc_chapters_menu))
|
|
||||||
clickText(text(R.string.tab_bookmarks))
|
|
||||||
waitForText(text(R.string.no_bookmarks_yet))
|
|
||||||
|
|
||||||
clickText(text(R.string.tab_annotations))
|
|
||||||
waitForText(text(R.string.no_highlights_yet))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_searchCanClearAndClose() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
clickReaderControl(text(R.string.tooltip_search))
|
|
||||||
waitForTag("SearchTextField")
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("SearchTextField")
|
|
||||||
.performTextInput("SEARCH_TARGET_DELTA")
|
|
||||||
waitForTextContaining("SEARCH_TARGET_DELTA")
|
|
||||||
|
|
||||||
clickContentDescription(text(R.string.tooltip_clear_search))
|
|
||||||
waitForNoContentDescription(text(R.string.tooltip_clear_search))
|
|
||||||
|
|
||||||
clickContentDescription(text(R.string.tooltip_close_search))
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
hasContentDescription(text(R.string.tooltip_search))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_dictionarySettingsShowsLookupSections() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
clickReaderControl(text(R.string.content_desc_dictionary_settings))
|
|
||||||
|
|
||||||
waitForText(text(R.string.dict_lookup_settings))
|
|
||||||
waitForText(text(R.string.tooltip_dictionary))
|
|
||||||
waitForText(text(R.string.dict_translate))
|
|
||||||
waitForText(text(R.string.tooltip_search))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixtureEpub_ttsReplacementSheetShowsGlobalAndBookScopes() {
|
|
||||||
launchFixtureReader()
|
|
||||||
waitForReader()
|
|
||||||
|
|
||||||
openOverflowMenu()
|
|
||||||
clickText(text(R.string.menu_tts_settings))
|
|
||||||
clickText(text(R.string.menu_tts_word_replacements))
|
|
||||||
|
|
||||||
waitForText(text(R.string.menu_tts_word_replacements))
|
|
||||||
waitForText(fixtureBookTitle)
|
|
||||||
waitForText(text(R.string.tts_replacements_tab_global))
|
|
||||||
waitForText(text(R.string.tts_replacements_tab_this_book))
|
|
||||||
waitForText(text(R.string.tts_replacements_enable))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun launchFixtureReader(beforeLaunch: (Uri) -> Unit = {}) {
|
|
||||||
val fixtureUri = copyAndroidTestAssetToCache(fixtureAssetName)
|
|
||||||
beforeLaunch(fixtureUri)
|
|
||||||
scenario = ActivityScenario.launch<MainActivity>(createEpubViewIntent(fixtureUri))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearReaderPrefs() {
|
|
||||||
listOf(
|
|
||||||
"epub_reader_settings",
|
|
||||||
"epub_reader_bookmarks",
|
|
||||||
"reader_prefs"
|
|
||||||
).forEach { prefsName ->
|
|
||||||
targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
|
|
||||||
.edit()
|
|
||||||
.clear()
|
|
||||||
.commit()
|
|
||||||
}
|
|
||||||
targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
|
||||||
.edit()
|
|
||||||
.putString("render_mode", "VERTICAL_SCROLL")
|
|
||||||
.commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun text(resId: Int): String = targetContext.getString(resId)
|
|
||||||
|
|
||||||
private fun seedFixtureBookmark() {
|
|
||||||
val bookmark = org.json.JSONObject().apply {
|
|
||||||
put("cfi", "android-locator:1:1:0")
|
|
||||||
put("chapterTitle", "Chapter Two")
|
|
||||||
put("label", org.json.JSONObject.NULL)
|
|
||||||
put("snippet", "BOOKMARK_TARGET_ECHO")
|
|
||||||
put("pageInChapter", 1)
|
|
||||||
put("totalPagesInChapter", 3)
|
|
||||||
put("chapterIndex", 1)
|
|
||||||
put(
|
|
||||||
"locator",
|
|
||||||
org.json.JSONObject().apply {
|
|
||||||
put("chapterIndex", 1)
|
|
||||||
put("chapterId", "chapter-02")
|
|
||||||
put("pageIndex", 0)
|
|
||||||
put("blockIndex", 1)
|
|
||||||
put("charOffset", 0)
|
|
||||||
put("textQuote", "BOOKMARK_TARGET_ECHO")
|
|
||||||
put("cfi", "android-locator:1:1:0")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
targetContext.getSharedPreferences("epub_reader_bookmarks", Context.MODE_PRIVATE)
|
|
||||||
.edit()
|
|
||||||
.putStringSet("bookmarks_cfi_$sanitizedFixtureBookTitle", setOf(bookmark.toString()))
|
|
||||||
.commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun seedFixtureHighlight() {
|
|
||||||
val highlight = UserHighlight(
|
|
||||||
id = "fixture_annotation_golf",
|
|
||||||
cfi = "android-locator:2:1:0",
|
|
||||||
text = "ANNOTATION_TARGET_GOLF",
|
|
||||||
color = HighlightColor.GREEN,
|
|
||||||
chapterIndex = 2,
|
|
||||||
note = "Fixture note survives startup",
|
|
||||||
locator = ReaderLocator(
|
|
||||||
chapterIndex = 2,
|
|
||||||
chapterId = "chapter-03",
|
|
||||||
blockIndex = 1,
|
|
||||||
charOffset = 0,
|
|
||||||
textQuote = "ANNOTATION_TARGET_GOLF",
|
|
||||||
cfi = "android-locator:2:1:0"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
|
||||||
.edit()
|
|
||||||
.putString("highlights_data_$sanitizedFixtureBookTitle", highlightsToJson(listOf(highlight)))
|
|
||||||
.commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun seedFixtureRecentFile(
|
|
||||||
uriString: String,
|
|
||||||
chapterIndex: Int,
|
|
||||||
blockIndex: Int,
|
|
||||||
charOffset: Int,
|
|
||||||
progress: Float
|
|
||||||
) {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
runBlocking {
|
|
||||||
AppDatabase.getDatabase(targetContext)
|
|
||||||
.recentFileDao()
|
|
||||||
.insertOrUpdateFile(
|
|
||||||
RecentFileEntity(
|
|
||||||
bookId = fixtureBookId,
|
|
||||||
uriString = uriString,
|
|
||||||
type = FileType.EPUB,
|
|
||||||
displayName = fixtureBookTitle,
|
|
||||||
timestamp = now,
|
|
||||||
coverImagePath = null,
|
|
||||||
title = fixtureBookTitle,
|
|
||||||
author = "Fixture Author",
|
|
||||||
lastChapterIndex = chapterIndex,
|
|
||||||
lastPage = null,
|
|
||||||
lastPositionCfi = "android-locator:$chapterIndex:$blockIndex:$charOffset",
|
|
||||||
progressPercentage = progress,
|
|
||||||
isRecent = true,
|
|
||||||
isAvailable = true,
|
|
||||||
lastModifiedTimestamp = now,
|
|
||||||
isDeleted = false,
|
|
||||||
locatorBlockIndex = blockIndex,
|
|
||||||
locatorCharOffset = charOffset,
|
|
||||||
bookmarks = null,
|
|
||||||
sourceFolderUri = null,
|
|
||||||
isReflowPreferred = false,
|
|
||||||
customName = null,
|
|
||||||
highlights = null,
|
|
||||||
fileSize = 0L,
|
|
||||||
fileContentModifiedTimestamp = 0L,
|
|
||||||
seriesName = null,
|
|
||||||
seriesIndex = null,
|
|
||||||
description = null,
|
|
||||||
folderTextMetadataParsed = false,
|
|
||||||
folderCoverMetadataParsed = false,
|
|
||||||
originalTitle = fixtureBookTitle,
|
|
||||||
originalAuthor = "Fixture Author",
|
|
||||||
originalSeriesName = null,
|
|
||||||
originalSeriesIndex = null,
|
|
||||||
originalDescription = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createEpubViewIntent(uri: Uri): Intent {
|
|
||||||
return Intent(targetContext, MainActivity::class.java).apply {
|
|
||||||
action = Intent.ACTION_VIEW
|
|
||||||
setDataAndType(uri, "application/epub+zip")
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun copyAndroidTestAssetToCache(assetName: String): Uri {
|
|
||||||
val file = File(targetContext.cacheDir, "${UUID.randomUUID()}_reader_test_book.epub")
|
|
||||||
currentEpubFile = file
|
|
||||||
|
|
||||||
instrumentationContext.assets.open(assetName).use { inputStream ->
|
|
||||||
file.outputStream().use { outputStream ->
|
|
||||||
inputStream.copyTo(outputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return FileProvider.getUriForFile(
|
|
||||||
targetContext,
|
|
||||||
"${targetContext.packageName}.provider",
|
|
||||||
file
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun navigateToFixtureSearchResult(query: String, expectedChapterIndex: Int) {
|
|
||||||
clickReaderControl(text(R.string.tooltip_search))
|
|
||||||
waitForTag("SearchTextField")
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("SearchTextField").performTextInput(query)
|
|
||||||
waitForTag("SearchResultItem_$expectedChapterIndex", timeoutMillis = 20_000)
|
|
||||||
composeTestRule.onNodeWithTag("SearchResultItem_$expectedChapterIndex").performClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun waitForReader() {
|
|
||||||
waitForTag("ReaderContainer", timeoutMillis = 30_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showReaderChrome() {
|
|
||||||
if (hasAnyReaderControl()) return
|
|
||||||
|
|
||||||
composeTestRule.onRoot().performTouchInput { click(center) }
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
hasAnyReaderControl()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clickReaderControl(contentDescription: String) {
|
|
||||||
showReaderChrome()
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
hasContentDescription(contentDescription)
|
|
||||||
}
|
|
||||||
composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clickContentDescription(contentDescription: String) {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
hasContentDescription(contentDescription)
|
|
||||||
}
|
|
||||||
composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clickText(value: String) {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 10_000) {
|
|
||||||
composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
composeTestRule.onAllNodesWithText(value)[0].performClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openOverflowMenu() {
|
|
||||||
clickReaderControl(text(R.string.content_desc_more_options))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasAnyReaderControl(): Boolean {
|
|
||||||
return hasContentDescription(text(R.string.tooltip_search)) ||
|
|
||||||
hasContentDescription(text(R.string.content_desc_chapters_menu)) ||
|
|
||||||
hasContentDescription(text(R.string.content_desc_more_options))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasContentDescription(contentDescription: String): Boolean {
|
|
||||||
return composeTestRule
|
|
||||||
.onAllNodesWithContentDescription(contentDescription)
|
|
||||||
.fetchSemanticsNodes()
|
|
||||||
.isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun waitForTag(tag: String, timeoutMillis: Long = 10_000) {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
|
||||||
composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun waitForText(value: String, timeoutMillis: Long = 10_000) {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
|
||||||
composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun waitForTextContaining(value: String, timeoutMillis: Long = 10_000) {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
|
||||||
composeTestRule.onAllNodesWithText(value, substring = true).fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun waitForNoContentDescription(contentDescription: String, timeoutMillis: Long = 5_000) {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
|
||||||
composeTestRule
|
|
||||||
.onAllNodesWithContentDescription(contentDescription)
|
|
||||||
.fetchSemanticsNodes()
|
|
||||||
.isEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun waitForRenderMode(expected: RenderMode) {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 20_000) {
|
|
||||||
targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
|
||||||
.getString("render_mode", RenderMode.VERTICAL_SCROLL.name) == expected.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun assertReaderSettingEventually(
|
|
||||||
prefsName: String,
|
|
||||||
key: String,
|
|
||||||
expected: Boolean
|
|
||||||
) {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
|
|
||||||
.getBoolean(key, !expected) == expected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readFixtureRecentFile(): RecentFileEntity? {
|
|
||||||
return runBlocking {
|
|
||||||
AppDatabase.getDatabase(targetContext)
|
|
||||||
.recentFileDao()
|
|
||||||
.getFileByBookId(fixtureBookId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun waitForRecentFile(
|
|
||||||
timeoutMillis: Long = 10_000,
|
|
||||||
predicate: (RecentFileEntity?) -> Boolean
|
|
||||||
) {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
|
||||||
predicate(readFixtureRecentFile())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun waitForFixtureBookmarks(
|
|
||||||
timeoutMillis: Long = 10_000,
|
|
||||||
predicate: (String?) -> Boolean
|
|
||||||
) {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
|
||||||
predicate(readFixtureRecentFile()?.bookmarks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun waitForFixtureHighlights(
|
|
||||||
timeoutMillis: Long = 10_000,
|
|
||||||
predicate: (String?) -> Boolean
|
|
||||||
) {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
|
|
||||||
predicate(readFixtureRecentFile()?.highlights)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseBookmarksJson(rawJson: String?): Set<Bookmark> {
|
|
||||||
return EpubAnnotationSerializer.parseBookmarksJson(rawJson)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.paginatedreader
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.snapshots.Snapshot
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.TextMeasurer
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.unit.Constraints
|
|
||||||
import androidx.compose.ui.unit.Density
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.SdkSuppress
|
|
||||||
import org.dueattendant149.bookreader.SearchResult
|
|
||||||
import org.dueattendant149.bookreader.epub.EpubBook
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import io.mockk.mockk
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
private class FakePaginator(
|
|
||||||
initiallyLoading: Boolean,
|
|
||||||
initialPageCount: Int,
|
|
||||||
initialGeneration: Int
|
|
||||||
) : IPaginator {
|
|
||||||
override var isLoading by mutableStateOf(initiallyLoading)
|
|
||||||
override var totalPageCount by mutableIntStateOf(initialPageCount)
|
|
||||||
override var generation by mutableIntStateOf(initialGeneration)
|
|
||||||
override val pageShiftRequest: Flow<Int> = emptyFlow()
|
|
||||||
|
|
||||||
var lastNavigatedHref: String? = null
|
|
||||||
var lastNavigatedChapter: String? = null
|
|
||||||
|
|
||||||
override fun getPageContent(pageIndex: Int): Page? = null
|
|
||||||
override fun getChapterPathForPage(pageIndex: Int): String? = null
|
|
||||||
override fun getPlainTextForChapter(chapterIndex: Int): String? = null
|
|
||||||
|
|
||||||
override fun navigateToHref(
|
|
||||||
currentChapterAbsPath: String,
|
|
||||||
href: String,
|
|
||||||
onNavigationComplete: (pageIndex: Int) -> Unit
|
|
||||||
) {
|
|
||||||
lastNavigatedChapter = currentChapterAbsPath
|
|
||||||
lastNavigatedHref = href
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun findPageForSearchResult(
|
|
||||||
result: SearchResult,
|
|
||||||
onResult: (pageIndex: Int) -> Unit
|
|
||||||
) = Unit
|
|
||||||
|
|
||||||
override fun findPageForAnchor(
|
|
||||||
chapterIndex: Int,
|
|
||||||
anchor: String?,
|
|
||||||
onResult: (pageIndex: Int) -> Unit
|
|
||||||
) = Unit
|
|
||||||
|
|
||||||
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) = Unit
|
|
||||||
override fun findPageForCfiAndOffset(chapterIndex: Int, cfi: String, charOffset: Int): Int? = null
|
|
||||||
override fun findChapterIndexForPage(pageIndex: Int): Int? = null
|
|
||||||
override fun getCfiForPage(pageIndex: Int): String? = null
|
|
||||||
override fun onUserScrolledTo(pageIndex: Int) = Unit
|
|
||||||
override fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List<String>): String? = null
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
|
||||||
class PaginatedReaderViewModelTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val mainDispatcherRule = MainDispatcherRule()
|
|
||||||
|
|
||||||
private lateinit var viewModel: PaginatedReaderViewModel
|
|
||||||
private lateinit var fakePaginator: FakePaginator
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
viewModel = PaginatedReaderViewModel()
|
|
||||||
fakePaginator = FakePaginator(
|
|
||||||
initiallyLoading = true,
|
|
||||||
initialPageCount = 0,
|
|
||||||
initialGeneration = 0
|
|
||||||
)
|
|
||||||
viewModel.setPaginatorForTest(fakePaginator)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun uiState_reflectsPaginatorInitialState() = runTest {
|
|
||||||
val initialState = viewModel.uiState.value
|
|
||||||
assertThat(initialState.isLoading).isTrue()
|
|
||||||
assertThat(initialState.totalPageCount).isEqualTo(0)
|
|
||||||
assertThat(initialState.generation).isEqualTo(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun uiState_updatesWhenPaginatorIsLoadingChanges() = runTest {
|
|
||||||
assertThat(viewModel.uiState.value.isLoading).isTrue()
|
|
||||||
|
|
||||||
fakePaginator.isLoading = false
|
|
||||||
Snapshot.sendApplyNotifications()
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(viewModel.uiState.value.isLoading).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun uiState_updatesWhenPaginatorTotalPageCountChanges() = runTest {
|
|
||||||
assertThat(viewModel.uiState.value.totalPageCount).isEqualTo(0)
|
|
||||||
|
|
||||||
fakePaginator.totalPageCount = 123
|
|
||||||
Snapshot.sendApplyNotifications()
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(viewModel.uiState.value.totalPageCount).isEqualTo(123)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun uiState_updatesWhenPaginatorGenerationChanges() = runTest {
|
|
||||||
assertThat(viewModel.uiState.value.generation).isEqualTo(0)
|
|
||||||
|
|
||||||
fakePaginator.generation = 5
|
|
||||||
Snapshot.sendApplyNotifications()
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(viewModel.uiState.value.generation).isEqualTo(5)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun onLinkClick_callsPaginatorNavigateToHrefWithCorrectArguments() {
|
|
||||||
val currentChapter = "chapter1.xhtml"
|
|
||||||
val href = "#section2"
|
|
||||||
|
|
||||||
viewModel.onLinkClick(currentChapter, href) {}
|
|
||||||
|
|
||||||
assertThat(fakePaginator.lastNavigatedChapter).isEqualTo(currentChapter)
|
|
||||||
assertThat(fakePaginator.lastNavigatedHref).isEqualTo(href)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun initialize_whenPaginatorAlreadySet_keepsExistingPaginator() = runTest {
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
val existingPaginator = viewModel.paginator
|
|
||||||
|
|
||||||
viewModel.initialize(
|
|
||||||
book = EpubBook(
|
|
||||||
fileName = "test.epub",
|
|
||||||
title = "Test Book",
|
|
||||||
author = "Test Author",
|
|
||||||
language = "en",
|
|
||||||
coverImage = null
|
|
||||||
),
|
|
||||||
textMeasurer = mockk<TextMeasurer>(relaxed = true),
|
|
||||||
textConstraints = Constraints(maxWidth = 1080, maxHeight = 1920),
|
|
||||||
textStyle = TextStyle.Default,
|
|
||||||
density = Density(1f),
|
|
||||||
isDarkTheme = false,
|
|
||||||
themeBackgroundColor = Color.White,
|
|
||||||
themeTextColor = Color.Black,
|
|
||||||
context = context,
|
|
||||||
initialChapterToPaginate = 0,
|
|
||||||
mathMLRenderer = mockk<MathMLRenderer>(relaxed = true),
|
|
||||||
paragraphGapMultiplier = 1.0f
|
|
||||||
)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(viewModel.paginator).isSameInstanceAs(existingPaginator)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.paginatedreader
|
|
||||||
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
|
||||||
import androidx.compose.ui.unit.Constraints
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class ReaderLinkHitTest {
|
|
||||||
@get:Rule
|
|
||||||
val composeTestRule = createComposeRule()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun urlAnnotationAtPositionIgnoresSameLineSpaceAfterLink() {
|
|
||||||
lateinit var text: AnnotatedString
|
|
||||||
var layoutResult: TextLayoutResult? = null
|
|
||||||
|
|
||||||
composeTestRule.setContent {
|
|
||||||
val textMeasurer = rememberTextMeasurer()
|
|
||||||
text = linkText("Open")
|
|
||||||
layoutResult = textMeasurer.measure(
|
|
||||||
text = text,
|
|
||||||
style = TextStyle(fontSize = 24.sp),
|
|
||||||
constraints = Constraints.fixedWidth(500)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
|
|
||||||
val layout = checkNotNull(layoutResult)
|
|
||||||
val firstBox = layout.getBoundingBox(0)
|
|
||||||
val lastBox = layout.getBoundingBox(text.length - 1)
|
|
||||||
val y = (firstBox.top + firstBox.bottom) / 2f
|
|
||||||
|
|
||||||
assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(firstBox.left + 1f, y)))
|
|
||||||
.isEqualTo(HREF)
|
|
||||||
assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(lastBox.right + 60f, y)))
|
|
||||||
.isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun urlAnnotationAtPositionAppliesTextStartOffsetForWrappedLineLayouts() {
|
|
||||||
lateinit var fullText: AnnotatedString
|
|
||||||
var lineLayoutResult: TextLayoutResult? = null
|
|
||||||
val prefix = "Before "
|
|
||||||
val label = "Open"
|
|
||||||
|
|
||||||
composeTestRule.setContent {
|
|
||||||
val textMeasurer = rememberTextMeasurer()
|
|
||||||
fullText = buildAnnotatedString {
|
|
||||||
append(prefix)
|
|
||||||
append(label)
|
|
||||||
addStringAnnotation("URL", HREF, prefix.length, prefix.length + label.length)
|
|
||||||
}
|
|
||||||
lineLayoutResult = textMeasurer.measure(
|
|
||||||
text = fullText.subSequence(prefix.length, fullText.length),
|
|
||||||
style = TextStyle(fontSize = 24.sp),
|
|
||||||
constraints = Constraints.fixedWidth(500)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
|
|
||||||
val layout = checkNotNull(lineLayoutResult)
|
|
||||||
val firstBox = layout.getBoundingBox(0)
|
|
||||||
val lastBox = layout.getBoundingBox(label.length - 1)
|
|
||||||
val y = (firstBox.top + firstBox.bottom) / 2f
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
fullText.readerUrlAnnotationAtPosition(
|
|
||||||
layout = layout,
|
|
||||||
position = Offset(firstBox.left + 1f, y),
|
|
||||||
textStartOffset = prefix.length
|
|
||||||
)
|
|
||||||
).isEqualTo(HREF)
|
|
||||||
assertThat(
|
|
||||||
fullText.readerUrlAnnotationAtPosition(
|
|
||||||
layout = layout,
|
|
||||||
position = Offset(lastBox.right + 60f, y),
|
|
||||||
textStartOffset = prefix.length
|
|
||||||
)
|
|
||||||
).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun linkText(label: String) = buildAnnotatedString {
|
|
||||||
append(label)
|
|
||||||
addStringAnnotation("URL", HREF, 0, label.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val HREF = "chapter.xhtml#target"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,258 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.pdf
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.ui.test.assert
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.assertTextContains
|
|
||||||
import androidx.compose.ui.test.hasText
|
|
||||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.compose.ui.test.performClick
|
|
||||||
import androidx.compose.ui.test.performTextInput
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import androidx.test.core.app.ActivityScenario
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.rule.GrantPermissionRule
|
|
||||||
import org.dueattendant149.bookreader.MainActivity
|
|
||||||
import org.dueattendant149.bookreader.R
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import java.io.File
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class PdfViewerScreenTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val composeTestRule = createEmptyComposeRule()
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
private var currentPdfFile: File? = null
|
|
||||||
private var scenario: ActivityScenario<MainActivity>? = null
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
|
|
||||||
.edit()
|
|
||||||
.clear()
|
|
||||||
.commit()
|
|
||||||
|
|
||||||
val samplePdfUri = copyAssetToCache(context, "sample.pdf")
|
|
||||||
scenario = ActivityScenario.launch<MainActivity>(createPdfViewIntent(context, samplePdfUri))
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
scenario?.close()
|
|
||||||
currentPdfFile?.let {
|
|
||||||
if (it.exists()) it.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun text(resId: Int, vararg args: Any): String = context.getString(resId, *args)
|
|
||||||
|
|
||||||
private fun assertNoNodeWithTag(tag: String) {
|
|
||||||
assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
|
|
||||||
return Intent(context, MainActivity::class.java).apply {
|
|
||||||
action = Intent.ACTION_VIEW
|
|
||||||
data = uri
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun waitForDocumentLoad(pageText: String = text(R.string.page_of_pages, 1, 4)) {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 15_000) {
|
|
||||||
composeTestRule
|
|
||||||
.onAllNodesWithText(pageText)
|
|
||||||
.fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openMoreOptions() {
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.tooltip_more_options)).performClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openNavigationDrawer() {
|
|
||||||
composeTestRule.onNodeWithTag("TocButton").performClick()
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodesWithText(text(R.string.tab_chapters)).fetchSemanticsNodes().isNotEmpty() &&
|
|
||||||
composeTestRule.onAllNodesWithTag("BookmarksTab").fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun selectChaptersTabIfTabsPaneIsFirst() {
|
|
||||||
if (composeTestRule.onAllNodesWithTag("TabsTab").fetchSemanticsNodes().isNotEmpty()) {
|
|
||||||
composeTestRule.onNodeWithText(text(R.string.tab_chapters)).performClick()
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun selectBookmarksTab() {
|
|
||||||
composeTestRule.onNodeWithTag("BookmarksTab").performClick()
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun selectReadingMode(modeText: String) {
|
|
||||||
openMoreOptions()
|
|
||||||
composeTestRule.onNodeWithText(text(R.string.menu_change_reading_mode)).performClick()
|
|
||||||
composeTestRule.onNodeWithText(modeText).performClick()
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensurePaginationMode() {
|
|
||||||
selectReadingMode(text(R.string.menu_reading_mode_paginated))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("SameParameterValue")
|
|
||||||
private fun copyAssetToCache(context: Context, assetName: String): Uri {
|
|
||||||
val uniqueName = "${UUID.randomUUID()}_$assetName"
|
|
||||||
val file = File(context.cacheDir, uniqueName)
|
|
||||||
|
|
||||||
currentPdfFile = file
|
|
||||||
|
|
||||||
if (file.exists()) file.delete()
|
|
||||||
context.assets.open(assetName).use { inputStream ->
|
|
||||||
file.outputStream().use { outputStream ->
|
|
||||||
inputStream.copyTo(outputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return FileProvider.getUriForFile(
|
|
||||||
context,
|
|
||||||
"${context.packageName}.provider",
|
|
||||||
file
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun documentLoadsAndDisplaysCorrectPageCount() {
|
|
||||||
waitForDocumentLoad()
|
|
||||||
composeTestRule.onNodeWithTag("PageNumberIndicator")
|
|
||||||
.assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tableOfContentsButton_handlesTabsPaneAndOpensChaptersTab() {
|
|
||||||
waitForDocumentLoad()
|
|
||||||
|
|
||||||
openNavigationDrawer()
|
|
||||||
selectChaptersTabIfTabsPaneIsFirst()
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithText(text(R.string.tab_chapters)).assertIsDisplayed()
|
|
||||||
composeTestRule.onNodeWithTag("BookmarksTab").assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun bookmarkFunctionality_addAndDeleteCurrentPage() {
|
|
||||||
waitForDocumentLoad()
|
|
||||||
|
|
||||||
openMoreOptions()
|
|
||||||
composeTestRule.onNodeWithText(text(R.string.menu_bookmark_this_page)).performClick()
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
|
|
||||||
openNavigationDrawer()
|
|
||||||
selectBookmarksTab()
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodesWithTag("BookmarkItem_0").fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
composeTestRule.onNodeWithTag("BookmarkItem_0").assertIsDisplayed()
|
|
||||||
.assert(hasText(text(R.string.pdf_page_short, 1), substring = true))
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_more_options_bookmark)).performClick()
|
|
||||||
composeTestRule.onNodeWithText(text(R.string.action_delete)).performClick()
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
composeTestRule.onNodeWithText(text(R.string.action_delete), useUnmergedTree = true).performClick()
|
|
||||||
|
|
||||||
composeTestRule.waitUntil(5_000) {
|
|
||||||
composeTestRule.onAllNodesWithTag("BookmarkItem_0").fetchSemanticsNodes().isEmpty()
|
|
||||||
}
|
|
||||||
assertNoNodeWithTag("BookmarkItem_0")
|
|
||||||
composeTestRule.onNodeWithText(text(R.string.no_bookmarks_yet)).assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sliderNavigation_opensAndDisplaysCorrectly() {
|
|
||||||
waitForDocumentLoad()
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_navigate_slider)).performClick()
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithText(text(R.string.page_format, 1, 4)).assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun displayMode_switchesToVerticalScroll() {
|
|
||||||
waitForDocumentLoad()
|
|
||||||
|
|
||||||
ensurePaginationMode()
|
|
||||||
|
|
||||||
assertNoNodeWithTag("PdfVerticalScroll")
|
|
||||||
|
|
||||||
selectReadingMode(text(R.string.menu_reading_mode_vertical))
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("PdfVerticalScroll").assertIsDisplayed()
|
|
||||||
|
|
||||||
ensurePaginationMode()
|
|
||||||
|
|
||||||
assertNoNodeWithTag("PdfVerticalScroll")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun displayModeSelectionPersistsReaderPreference() {
|
|
||||||
waitForDocumentLoad()
|
|
||||||
|
|
||||||
selectReadingMode(text(R.string.menu_reading_mode_paginated))
|
|
||||||
waitForDisplayModePreference(DisplayMode.PAGINATION)
|
|
||||||
|
|
||||||
selectReadingMode(text(R.string.menu_reading_mode_vertical))
|
|
||||||
waitForDisplayModePreference(DisplayMode.VERTICAL_SCROLL)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun search_uiOpensAndAcceptsQuery() {
|
|
||||||
waitForDocumentLoad()
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("SearchButton").performClick()
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
|
|
||||||
val ocrLanguageText = text(R.string.ocr_language_latin)
|
|
||||||
if (composeTestRule.onAllNodesWithText(ocrLanguageText).fetchSemanticsNodes().isNotEmpty()) {
|
|
||||||
composeTestRule.onNodeWithText(ocrLanguageText).performClick()
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
}
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("SearchTextField").assertIsDisplayed()
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("SearchTextField").performTextInput("test query")
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("test query")
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithContentDescription(text(R.string.tooltip_close_search)).performClick()
|
|
||||||
|
|
||||||
assertNoNodeWithTag("SearchTextField")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun waitForDisplayModePreference(expected: DisplayMode) {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
.getString(DISPLAY_MODE_KEY, DisplayMode.VERTICAL_SCROLL.name) == expected.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
package org.dueattendant149.bookreader.epubreader
|
package com.aryan.reader.epubreader
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import org.dueattendant149.bookreader.RenderMode
|
import com.aryan.reader.RenderMode
|
||||||
import org.dueattendant149.bookreader.epub.EpubBook
|
import com.aryan.reader.epub.EpubBook
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
class EpubTestActivity : ComponentActivity() {
|
class EpubTestActivity : ComponentActivity() {
|
||||||
|
|
@ -31,7 +31,7 @@ class EpubTestActivity : ComponentActivity() {
|
||||||
coverImagePath = null,
|
coverImagePath = null,
|
||||||
onRenderModeChange = {},
|
onRenderModeChange = {},
|
||||||
customFonts = TODO(),
|
customFonts = TODO(),
|
||||||
onImportFonts = TODO(), viewModel = TODO()
|
onImportFont = TODO(), viewModel = TODO()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader.epubreader
|
package com.aryan.reader.epubreader
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader.ml
|
package com.aryan.reader.ml
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
|
|
@ -55,22 +55,7 @@
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".TemporaryExternalFileActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/Theme.Reader"
|
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
|
||||||
android:excludeFromRecents="true"
|
|
||||||
android:launchMode="standard" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".ExternalFileOpenRouterActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:theme="@style/Theme.App.Starting"
|
|
||||||
android:noHistory="true"
|
|
||||||
android:excludeFromRecents="true">
|
|
||||||
<!-- PDF -->
|
<!-- PDF -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
@ -169,19 +154,6 @@
|
||||||
<data android:mimeType="application/x-cb7" />
|
<data android:mimeType="application/x-cb7" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- CBT known MIME types -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:scheme="content" />
|
|
||||||
<data android:scheme="file" />
|
|
||||||
<data android:mimeType="application/x-cbt" />
|
|
||||||
<data android:mimeType="application/vnd.comicbook+tar" />
|
|
||||||
<data android:mimeType="application/x-tar" />
|
|
||||||
<data android:mimeType="application/tar" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- DOCX -->
|
<!-- DOCX -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
|
||||||
|
|
@ -128,15 +128,8 @@
|
||||||
background-color: rgba(255, 236, 179, 0.8);
|
background-color: rgba(255, 236, 179, 0.8);
|
||||||
/* Semi-transparent Gold */
|
/* Semi-transparent Gold */
|
||||||
color: black !important;
|
color: black !important;
|
||||||
display: inline !important;
|
|
||||||
text-align: initial !important;
|
|
||||||
text-align-last: auto !important;
|
|
||||||
letter-spacing: normal !important;
|
|
||||||
word-spacing: normal !important;
|
|
||||||
padding: 0.1em 0;
|
padding: 0.1em 0;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
-webkit-box-decoration-break: clone;
|
|
||||||
box-decoration-break: clone;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark-theme span.tts-highlight {
|
html.dark-theme span.tts-highlight {
|
||||||
|
|
@ -1503,16 +1496,6 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS";
|
const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS";
|
||||||
const TTS_HIGHLIGHT_BLOCK_SELECTOR = "p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th";
|
|
||||||
|
|
||||||
function getTtsHighlightBlock(node) {
|
|
||||||
if (!node) {
|
|
||||||
return document.body;
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
||||||
return (element && element.closest(TTS_HIGHLIGHT_BLOCK_SELECTOR)) || document.body;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.highlightFromCfi = function (cfi, textToHighlight, startOffset) {
|
window.highlightFromCfi = function (cfi, textToHighlight, startOffset) {
|
||||||
console.log(`$ {
|
console.log(`$ {
|
||||||
|
|
@ -1573,10 +1556,9 @@
|
||||||
, Text content: '${(location.node.textContent || "").substring(0, 50)}...' `);
|
, Text content: '${(location.node.textContent || "").substring(0, 50)}...' `);
|
||||||
|
|
||||||
const baseNode = location.node;
|
const baseNode = location.node;
|
||||||
const highlightRoot = getTtsHighlightBlock(baseNode);
|
|
||||||
let remainingOffset = startOffset;
|
let remainingOffset = startOffset;
|
||||||
|
|
||||||
const treeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
|
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||||
treeWalker.currentNode = baseNode;
|
treeWalker.currentNode = baseNode;
|
||||||
|
|
||||||
let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode();
|
let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode();
|
||||||
|
|
@ -1644,7 +1626,7 @@
|
||||||
} else {
|
} else {
|
||||||
remainingTextLength -= availableLength;
|
remainingTextLength -= availableLength;
|
||||||
// Important: We need a fresh walker starting from the endNode to find the *next* text node reliably
|
// Important: We need a fresh walker starting from the endNode to find the *next* text node reliably
|
||||||
const nextNodeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
|
const nextNodeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||||
nextNodeWalker.currentNode = endNode;
|
nextNodeWalker.currentNode = endNode;
|
||||||
endNode = nextNodeWalker.nextNode();
|
endNode = nextNodeWalker.nextNode();
|
||||||
endOffset = 0; // Start from the beginning of the next node
|
endOffset = 0; // Start from the beginning of the next node
|
||||||
|
|
@ -1695,14 +1677,11 @@
|
||||||
TTS_HIGHLIGHT_LOG_TAG
|
TTS_HIGHLIGHT_LOG_TAG
|
||||||
}
|
}
|
||||||
|
|
||||||
: surroundContents failed, using same-block fallback. Error: $ {
|
: surroundContents failed, using fallback. Error: $ {
|
||||||
e.message
|
e.message
|
||||||
}
|
}
|
||||||
|
|
||||||
`);
|
`);
|
||||||
if (!highlightRoot.contains(range.commonAncestorContainer)) {
|
|
||||||
return "JS: Highlight range escaped current block.";
|
|
||||||
}
|
|
||||||
const contents = range.extractContents();
|
const contents = range.extractContents();
|
||||||
highlightSpan.appendChild(contents);
|
highlightSpan.appendChild(contents);
|
||||||
range.insertNode(highlightSpan);
|
range.insertNode(highlightSpan);
|
||||||
|
|
@ -2945,29 +2924,6 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const HL_LOG_TAG = "HIGHLIGHT_DEBUG";
|
const HL_LOG_TAG = "HIGHLIGHT_DEBUG";
|
||||||
const HL_RENDER_LOG_TAG = "AndroidHighlightRenderDiag";
|
|
||||||
|
|
||||||
function hlRenderPreview(value, maxLength) {
|
|
||||||
if (value === undefined || value === null) return "";
|
|
||||||
var text = String(value).replace(/\s+/g, " ");
|
|
||||||
var max = maxLength || 100;
|
|
||||||
return text.length > max ? text.substring(0, max) : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hlRenderLocatorLabel(locator) {
|
|
||||||
locator = locator || {};
|
|
||||||
return "locatorChapter=" + (locator.chapterIndex === undefined || locator.chapterIndex === null ? "null" : locator.chapterIndex) +
|
|
||||||
" locatorPage=" + (locator.pageIndex === undefined || locator.pageIndex === null ? "null" : locator.pageIndex) +
|
|
||||||
" locatorOffsets=" + (locator.startOffset === undefined || locator.startOffset === null ? "null" : locator.startOffset) +
|
|
||||||
".." + (locator.endOffset === undefined || locator.endOffset === null ? "null" : locator.endOffset) +
|
|
||||||
" locatorBlock=" + (locator.blockIndex === undefined || locator.blockIndex === null ? "null" : locator.blockIndex) +
|
|
||||||
" locatorChar=" + (locator.charOffset === undefined || locator.charOffset === null ? "null" : locator.charOffset) +
|
|
||||||
" locatorCfi=" + hlRenderPreview(locator.cfi || "", 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hlRenderLog(message) {
|
|
||||||
console.log(HL_RENDER_LOG_TAG + ": " + message);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.HighlightBridgeHelper = {
|
window.HighlightBridgeHelper = {
|
||||||
updateHighlightStyle: function (cfi, newColorClass, colorId) {
|
updateHighlightStyle: function (cfi, newColorClass, colorId) {
|
||||||
|
|
@ -3324,13 +3280,11 @@
|
||||||
try {
|
try {
|
||||||
var highlights = JSON.parse(jsonArrayString);
|
var highlights = JSON.parse(jsonArrayString);
|
||||||
var self = this;
|
var self = this;
|
||||||
hlRenderLog("webview_restore_start count=" + highlights.length);
|
|
||||||
|
|
||||||
highlights.forEach(function (h) {
|
highlights.forEach(function (h) {
|
||||||
self.applyHighlightObject(h);
|
self.applyHighlight(h.cfi, h.text, h.cssClass);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
hlRenderLog("webview_restore_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
|
|
||||||
console.log(
|
console.log(
|
||||||
`$ {
|
`$ {
|
||||||
HL_LOG_TAG
|
HL_LOG_TAG
|
||||||
|
|
@ -3341,376 +3295,136 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
applyHighlightObject: function (highlight) {
|
applyHighlight: function (cfi, text, cssClass) {
|
||||||
if (!highlight) return;
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_apply_object id=" + (highlight.id || "") +
|
|
||||||
" cfi=" + hlRenderPreview(highlight.cfi || "", 120) +
|
|
||||||
" textLen=" + String(highlight.text || "").length +
|
|
||||||
" text='" + hlRenderPreview(highlight.text || "", 80) + "' " +
|
|
||||||
hlRenderLocatorLabel(highlight.locator || {})
|
|
||||||
);
|
|
||||||
this.applyHighlight(highlight.cfi, highlight.text, highlight.cssClass, highlight.locator || null);
|
|
||||||
},
|
|
||||||
|
|
||||||
highlightTextRoot: function () {
|
|
||||||
return document.getElementById("content-container") || document.body;
|
|
||||||
},
|
|
||||||
|
|
||||||
highlightTextNodes: function (root) {
|
|
||||||
var nodes = [];
|
|
||||||
if (!root) return nodes;
|
|
||||||
var walker = document.createTreeWalker(
|
|
||||||
root,
|
|
||||||
NodeFilter.SHOW_TEXT,
|
|
||||||
{
|
|
||||||
acceptNode: function (node) {
|
|
||||||
if (!node || !node.nodeValue) return NodeFilter.FILTER_REJECT;
|
|
||||||
var parent = node.parentElement;
|
|
||||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
|
||||||
if (parent.closest && parent.closest("script, style, noscript")) return NodeFilter.FILTER_REJECT;
|
|
||||||
return NodeFilter.FILTER_ACCEPT;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
||||||
return nodes;
|
|
||||||
},
|
|
||||||
|
|
||||||
rangeFromTextOffsets: function (root, startOffset, endOffset) {
|
|
||||||
var start = parseInt(startOffset, 10);
|
|
||||||
var end = parseInt(endOffset, 10);
|
|
||||||
if (!root || !isFinite(start) || !isFinite(end) || end <= start) {
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_range_offsets_skip reason=invalid_offsets start=" + startOffset +
|
|
||||||
" end=" + endOffset + " hasRoot=" + !!root
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var nodes = this.highlightTextNodes(root);
|
|
||||||
var cursor = 0;
|
|
||||||
var startNode = null;
|
|
||||||
var startInNode = 0;
|
|
||||||
var endNode = null;
|
|
||||||
var endInNode = 0;
|
|
||||||
|
|
||||||
for (var i = 0; i < nodes.length; i++) {
|
|
||||||
var value = nodes[i].nodeValue || "";
|
|
||||||
var next = cursor + value.length;
|
|
||||||
if (!startNode && start >= cursor && start <= next) {
|
|
||||||
startNode = nodes[i];
|
|
||||||
startInNode = Math.max(0, Math.min(value.length, start - cursor));
|
|
||||||
}
|
|
||||||
if (startNode && end >= cursor && end <= next) {
|
|
||||||
endNode = nodes[i];
|
|
||||||
endInNode = Math.max(0, Math.min(value.length, end - cursor));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
cursor = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!startNode || !endNode) {
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_range_offsets_skip reason=missing_boundary start=" + start +
|
|
||||||
" end=" + end + " nodes=" + nodes.length + " textCursor=" + cursor
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var range = document.createRange();
|
|
||||||
range.setStart(startNode, startInNode);
|
|
||||||
range.setEnd(endNode, endInNode);
|
|
||||||
if (range.collapsed) {
|
|
||||||
hlRenderLog("webview_range_offsets_skip reason=collapsed start=" + start + " end=" + end);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_range_offsets_result start=" + start + " end=" + end +
|
|
||||||
" text='" + hlRenderPreview(range.toString(), 80) + "'"
|
|
||||||
);
|
|
||||||
return range;
|
|
||||||
},
|
|
||||||
|
|
||||||
rangeMatchesText: function (range, text) {
|
|
||||||
if (!range || !text) return true;
|
|
||||||
var actual = (range.toString() || "").trim();
|
|
||||||
var expected = String(text || "").trim();
|
|
||||||
if (!expected) return true;
|
|
||||||
return actual === expected || actual.replace(/\s+/g, " ") === expected.replace(/\s+/g, " ");
|
|
||||||
},
|
|
||||||
|
|
||||||
rangeFromVisibleTextSearch: function (text, locator) {
|
|
||||||
if (!text) {
|
|
||||||
hlRenderLog("webview_text_search_skip reason=empty_text " + hlRenderLocatorLabel(locator));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var root = this.highlightTextRoot();
|
|
||||||
var nodes = this.highlightTextNodes(root);
|
|
||||||
if (!nodes.length) {
|
|
||||||
hlRenderLog("webview_text_search_skip reason=no_nodes " + hlRenderLocatorLabel(locator));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var fullText = nodes.map(function (node) { return node.nodeValue || ""; }).join("");
|
|
||||||
var expected = String(text);
|
|
||||||
var candidates = [];
|
|
||||||
var index = fullText.indexOf(expected);
|
|
||||||
while (index !== -1) {
|
|
||||||
candidates.push(index);
|
|
||||||
index = fullText.indexOf(expected, index + 1);
|
|
||||||
}
|
|
||||||
if (!candidates.length) {
|
|
||||||
var lowerFull = fullText.toLowerCase();
|
|
||||||
var lowerExpected = expected.toLowerCase();
|
|
||||||
index = lowerFull.indexOf(lowerExpected);
|
|
||||||
while (index !== -1) {
|
|
||||||
candidates.push(index);
|
|
||||||
index = lowerFull.indexOf(lowerExpected, index + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!candidates.length && expected.length > 20) {
|
|
||||||
var partial = expected.substring(0, Math.min(expected.length, 40));
|
|
||||||
index = fullText.indexOf(partial);
|
|
||||||
while (index !== -1) {
|
|
||||||
candidates.push(index);
|
|
||||||
index = fullText.indexOf(partial, index + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!candidates.length) {
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_text_search_skip reason=no_candidates textLen=" + expected.length +
|
|
||||||
" text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator)
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var preferred = locator && locator.startOffset !== undefined && locator.startOffset !== null
|
|
||||||
? parseInt(locator.startOffset, 10)
|
|
||||||
: candidates[0];
|
|
||||||
if (!isFinite(preferred)) preferred = candidates[0];
|
|
||||||
var best = candidates.reduce(function (currentBest, candidate) {
|
|
||||||
return Math.abs(candidate - preferred) < Math.abs(currentBest - preferred) ? candidate : currentBest;
|
|
||||||
}, candidates[0]);
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_text_search_candidate count=" + candidates.length +
|
|
||||||
" preferred=" + preferred + " best=" + best +
|
|
||||||
" text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator)
|
|
||||||
);
|
|
||||||
return this.rangeFromTextOffsets(root, best, best + expected.length);
|
|
||||||
},
|
|
||||||
|
|
||||||
rangeFromLocator: function (locator, text) {
|
|
||||||
if (!locator) {
|
|
||||||
hlRenderLog("webview_locator_skip reason=missing_locator");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (locator.cfi && String(locator.cfi).charAt(0) === "/") {
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_locator_skip reason=structural_cfi_prefers_cfi " +
|
|
||||||
hlRenderLocatorLabel(locator)
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var root = this.highlightTextRoot();
|
|
||||||
var range = this.rangeFromTextOffsets(root, locator.startOffset, locator.endOffset);
|
|
||||||
if (range && this.rangeMatchesText(range, text)) {
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_locator_result matched=true text='" + hlRenderPreview(range.toString(), 80) + "' " +
|
|
||||||
hlRenderLocatorLabel(locator)
|
|
||||||
);
|
|
||||||
return range;
|
|
||||||
}
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_locator_skip reason=" + (range ? "text_mismatch" : "range_missing") +
|
|
||||||
" rangeText='" + hlRenderPreview(range ? range.toString() : "", 80) + "' " +
|
|
||||||
"expected='" + hlRenderPreview(text || "", 80) + "' " + hlRenderLocatorLabel(locator)
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
|
|
||||||
rangeFromCfi: function (cfi, text) {
|
|
||||||
if (!cfi || cfi.indexOf("desktop:") === 0) {
|
|
||||||
hlRenderLog("webview_cfi_skip reason=unsupported cfi=" + hlRenderPreview(cfi || "", 120));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var sourceCfi = String(cfi).split("|")[0];
|
|
||||||
const location = window.getNodeAndOffsetFromCfi(sourceCfi);
|
|
||||||
if (!location || !location.node) {
|
|
||||||
hlRenderLog("webview_cfi_skip reason=missing_location cfi=" + hlRenderPreview(cfi, 120));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let startNode = location.node;
|
|
||||||
let startOffset = location.offset;
|
|
||||||
|
|
||||||
if (startNode.nodeType === Node.TEXT_NODE) {
|
|
||||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
|
||||||
walker.currentNode = startNode;
|
|
||||||
|
|
||||||
while (startNode && startOffset >= startNode.nodeValue.length) {
|
|
||||||
if (startOffset === startNode.nodeValue.length) {
|
|
||||||
const next = walker.nextNode();
|
|
||||||
|
|
||||||
if (next) {
|
|
||||||
startOffset -= startNode.nodeValue.length;
|
|
||||||
startNode = next;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
startOffset -= startNode.nodeValue.length;
|
|
||||||
startNode = walker.nextNode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) {
|
|
||||||
const nodeVal = startNode.nodeValue;
|
|
||||||
const substring = nodeVal.substring(startOffset, startOffset + text.length);
|
|
||||||
|
|
||||||
if (substring !== text && substring.trim() !== text.trim()) {
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_cfi_text_mismatch cfi=" + hlRenderPreview(cfi, 120) +
|
|
||||||
" startOffset=" + startOffset +
|
|
||||||
" actual='" + hlRenderPreview(substring, 80) + "'" +
|
|
||||||
" expected='" + hlRenderPreview(text, 80) + "'"
|
|
||||||
);
|
|
||||||
console.log("HIGHLIGHT_DEBUG: Text mismatch at CFI. Searching nearby.");
|
|
||||||
const foundIndex = nodeVal.indexOf(text);
|
|
||||||
|
|
||||||
if (foundIndex !== -1) {
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_cfi_text_adjust reason=full_match oldStartOffset=" + startOffset +
|
|
||||||
" newStartOffset=" + foundIndex + " cfi=" + hlRenderPreview(cfi, 120)
|
|
||||||
);
|
|
||||||
startOffset = foundIndex;
|
|
||||||
} else {
|
|
||||||
const partial = text.substring(0, Math.min(text.length, 20));
|
|
||||||
const partialIndex = nodeVal.indexOf(partial);
|
|
||||||
|
|
||||||
if (partialIndex !== -1) {
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_cfi_text_adjust reason=partial_match oldStartOffset=" + startOffset +
|
|
||||||
" newStartOffset=" + partialIndex + " cfi=" + hlRenderPreview(cfi, 120)
|
|
||||||
);
|
|
||||||
startOffset = partialIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!startNode) {
|
|
||||||
hlRenderLog("webview_cfi_skip reason=missing_start_node cfi=" + hlRenderPreview(cfi, 120));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const range = document.createRange();
|
|
||||||
|
|
||||||
if (startNode.nodeType === Node.TEXT_NODE && startOffset > startNode.nodeValue.length) {
|
|
||||||
startOffset = Math.max(0, startNode.nodeValue.length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
range.setStart(startNode, startOffset);
|
|
||||||
|
|
||||||
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
|
||||||
treeWalker.currentNode = startNode;
|
|
||||||
|
|
||||||
let remainingLen = text.length;
|
|
||||||
let endNode = treeWalker.currentNode;
|
|
||||||
let endOffset = startOffset;
|
|
||||||
|
|
||||||
while (remainingLen > 0 && endNode) {
|
|
||||||
let avail = endNode.nodeValue.length - endOffset;
|
|
||||||
|
|
||||||
if (avail >= remainingLen) {
|
|
||||||
endOffset += remainingLen;
|
|
||||||
remainingLen = 0;
|
|
||||||
} else {
|
|
||||||
remainingLen -= avail;
|
|
||||||
endNode = treeWalker.nextNode();
|
|
||||||
endOffset = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!endNode) {
|
|
||||||
hlRenderLog("webview_cfi_skip reason=missing_end_node cfi=" + hlRenderPreview(cfi, 120));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
range.setEnd(endNode, endOffset);
|
|
||||||
if (range.collapsed) {
|
|
||||||
hlRenderLog("webview_cfi_skip reason=collapsed cfi=" + hlRenderPreview(cfi, 120));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_cfi_result cfi=" + hlRenderPreview(cfi, 120) +
|
|
||||||
" text='" + hlRenderPreview(range.toString(), 80) + "'"
|
|
||||||
);
|
|
||||||
return range;
|
|
||||||
},
|
|
||||||
|
|
||||||
applyHighlight: function (cfi, text, cssClass, locator) {
|
|
||||||
try {
|
try {
|
||||||
cssClass = cssClass || "user-highlight-yellow";
|
|
||||||
var alreadyApplied = false;
|
var alreadyApplied = false;
|
||||||
var spans = document.querySelectorAll(`span[data-cfi]`);
|
var spans = document.querySelectorAll(`span[data-cfi]`);
|
||||||
for (var i = 0; i < spans.length; i++) {
|
for (var i = 0; i < spans.length; i++) {
|
||||||
if (cfi && (spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) {
|
if ((spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) {
|
||||||
alreadyApplied = true;
|
alreadyApplied = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (alreadyApplied) {
|
if (alreadyApplied) return;
|
||||||
hlRenderLog(
|
|
||||||
"webview_apply_skip reason=already_applied cfi=" + hlRenderPreview(cfi || "", 120) +
|
const location = window.getNodeAndOffsetFromCfi(cfi);
|
||||||
" textLen=" + String(text || "").length + " " + hlRenderLocatorLabel(locator)
|
if (!location || !location.node) return;
|
||||||
);
|
|
||||||
return;
|
let startNode = location.node;
|
||||||
|
let startOffset = location.offset;
|
||||||
|
|
||||||
|
if (startNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||||
|
walker.currentNode = startNode;
|
||||||
|
|
||||||
|
while (startNode && startOffset >= startNode.nodeValue.length) {
|
||||||
|
if (startOffset === startNode.nodeValue.length) {
|
||||||
|
const next = walker.nextNode();
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
startOffset -= startNode.nodeValue.length;
|
||||||
|
startNode = next;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
startOffset -= startNode.nodeValue.length;
|
||||||
|
startNode = walker.nextNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasPreciseLocator = locator &&
|
// 1. Text Verification / Healing
|
||||||
locator.startOffset !== undefined && locator.startOffset !== null &&
|
if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) {
|
||||||
locator.endOffset !== undefined && locator.endOffset !== null &&
|
const nodeVal = startNode.nodeValue;
|
||||||
parseInt(locator.endOffset, 10) > parseInt(locator.startOffset, 10);
|
// Check if text matches at exact offset
|
||||||
hlRenderLog(
|
const substring = nodeVal.substring(startOffset, startOffset + text.length);
|
||||||
"webview_apply_start cfi=" + hlRenderPreview(cfi || "", 120) +
|
|
||||||
" textLen=" + String(text || "").length +
|
// Allow for some whitespace looseness (trim comparison)
|
||||||
" cssClass=" + cssClass +
|
if (substring !== text && substring.trim() !== text.trim()) {
|
||||||
" hasPreciseLocator=" + !!hasPreciseLocator + " " +
|
console.log(`$ {
|
||||||
hlRenderLocatorLabel(locator)
|
HL_LOG_TAG
|
||||||
);
|
}
|
||||||
var rangeSource = "locator";
|
|
||||||
var sourceCfi = (locator && locator.cfi) || cfi;
|
: Text mismatch at CFI. Searching nearby... Expected: '${text.substring(0, 10)}...', Found: '${substring.substring(0, 10)}...' `);
|
||||||
var hasSourceCfi = sourceCfi && String(sourceCfi).charAt(0) === "/";
|
|
||||||
var range = hasSourceCfi ? null : this.rangeFromLocator(locator, text);
|
// Try finding the text in the whole node
|
||||||
if (!range && hasSourceCfi) {
|
const foundIndex = nodeVal.indexOf(text);
|
||||||
rangeSource = "cfi";
|
|
||||||
range = this.rangeFromCfi(sourceCfi, text || "");
|
if (foundIndex !== -1) {
|
||||||
|
console.log(`$ {
|
||||||
|
HL_LOG_TAG
|
||||||
|
}
|
||||||
|
|
||||||
|
: Found text elsewhere in node. Adjusting offset from $ {
|
||||||
|
startOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
to $ {
|
||||||
|
foundIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
.`);
|
||||||
|
startOffset = foundIndex;
|
||||||
|
} else {
|
||||||
|
// Simple fuzzy: Try finding first 20 chars
|
||||||
|
const partial = text.substring(0, Math.min(text.length, 20));
|
||||||
|
const partialIndex = nodeVal.indexOf(partial);
|
||||||
|
|
||||||
|
if (partialIndex !== -1) {
|
||||||
|
console.log(`$ {
|
||||||
|
HL_LOG_TAG
|
||||||
|
}
|
||||||
|
|
||||||
|
: Found partial match. Adjusting offset.`);
|
||||||
|
startOffset = partialIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!range) {
|
|
||||||
rangeSource = "locator";
|
if (!startNode) return;
|
||||||
range = this.rangeFromLocator(locator, text);
|
|
||||||
|
const range = document.createRange();
|
||||||
|
|
||||||
|
// Set Start
|
||||||
|
if (startNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
// Ensure offset is valid
|
||||||
|
if (startOffset > startNode.nodeValue.length) {
|
||||||
|
startOffset = Math.max(0, startNode.nodeValue.length - 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!range && !hasPreciseLocator && !hasSourceCfi) {
|
|
||||||
rangeSource = "text_search";
|
range.setStart(startNode, startOffset);
|
||||||
range = this.rangeFromVisibleTextSearch(text || "", locator);
|
|
||||||
|
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||||
|
treeWalker.currentNode = startNode;
|
||||||
|
|
||||||
|
let currentNode = treeWalker.currentNode;
|
||||||
|
let remainingOffset = startOffset;
|
||||||
|
let remainingLen = text.length;
|
||||||
|
let endNode = currentNode;
|
||||||
|
let endOffset = startOffset;
|
||||||
|
|
||||||
|
while (remainingLen > 0 && endNode) {
|
||||||
|
let avail = endNode.nodeValue.length - endOffset;
|
||||||
|
|
||||||
|
if (avail >= remainingLen) {
|
||||||
|
endOffset += remainingLen;
|
||||||
|
remainingLen = 0;
|
||||||
|
} else {
|
||||||
|
remainingLen -= avail;
|
||||||
|
endNode = treeWalker.nextNode();
|
||||||
|
endOffset = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!range) {
|
|
||||||
hlRenderLog(
|
if (endNode) {
|
||||||
"webview_apply_skip reason=no_range cfi=" + hlRenderPreview(cfi || "", 120) +
|
range.setEnd(endNode, endOffset);
|
||||||
" hasPreciseLocator=" + !!hasPreciseLocator + " " + hlRenderLocatorLabel(locator)
|
var normalizedRange = this.normalizeRangeBoundaries(range);
|
||||||
);
|
this.highlightRangeSafe(normalizedRange, cssClass, cfi);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
var normalizedRange = this.normalizeRangeBoundaries(range);
|
|
||||||
this.highlightRangeSafe(normalizedRange, cssClass, cfi);
|
|
||||||
hlRenderLog(
|
|
||||||
"webview_apply_result applied=true cfi=" + hlRenderPreview(cfi || "", 120) +
|
|
||||||
" source=" + rangeSource +
|
|
||||||
" renderedText='" + hlRenderPreview(normalizedRange.toString(), 80) + "'"
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
hlRenderLog("webview_apply_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
|
|
||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
class AndroidFolderPathResolver : FolderPathResolver {
|
class AndroidFolderPathResolver : FolderPathResolver {
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import org.dueattendant149.bookreader.shared.SharedFeaturePolicy
|
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||||
import org.dueattendant149.bookreader.shared.SharedSettingsHubInput
|
import com.aryan.reader.shared.SharedSettingsHubInput
|
||||||
import org.dueattendant149.bookreader.shared.SharedSettingsPlatform
|
import com.aryan.reader.shared.SharedSettingsPlatform
|
||||||
|
|
||||||
fun androidSettingsHubInput(
|
fun androidSettingsHubInput(
|
||||||
uiState: ReaderScreenState,
|
uiState: ReaderScreenState,
|
||||||
|
|
@ -15,8 +15,6 @@ fun androidSettingsHubInput(
|
||||||
val supportsOssAiKeys = isOssBuild && !isOfflineBuild
|
val supportsOssAiKeys = isOssBuild && !isOfflineBuild
|
||||||
val featurePolicy = if (isOfflineBuild) {
|
val featurePolicy = if (isOfflineBuild) {
|
||||||
SharedFeaturePolicy.OssOffline
|
SharedFeaturePolicy.OssOffline
|
||||||
} else if (isOssBuild) {
|
|
||||||
SharedFeaturePolicy.OssOnline
|
|
||||||
} else {
|
} else {
|
||||||
SharedFeaturePolicy.Standard
|
SharedFeaturePolicy.Standard
|
||||||
}
|
}
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import org.dueattendant149.bookreader.data.TagEntity
|
import com.aryan.reader.data.TagEntity
|
||||||
import org.dueattendant149.bookreader.shared.AppAction as SharedAppAction
|
import com.aryan.reader.shared.AppAction as SharedAppAction
|
||||||
import org.dueattendant149.bookreader.shared.LibraryAction as SharedLibraryAction
|
import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
|
||||||
import org.dueattendant149.bookreader.shared.SharedFolderPathResolver
|
import com.aryan.reader.shared.SharedFolderPathResolver
|
||||||
import org.dueattendant149.bookreader.shared.SharedLibraryProjectionInput
|
import com.aryan.reader.shared.SharedLibraryProjectionInput
|
||||||
import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector
|
import com.aryan.reader.shared.SharedLibraryStateProjector
|
||||||
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
import com.aryan.reader.shared.SharedReaderScreenState
|
||||||
import org.dueattendant149.bookreader.shared.reduce
|
import com.aryan.reader.shared.reduce
|
||||||
|
|
||||||
internal object AndroidSharedStateBridge {
|
internal object AndroidSharedStateBridge {
|
||||||
fun prepareLibraryProjection(
|
fun prepareLibraryProjection(
|
||||||
|
|
@ -23,8 +23,7 @@ internal object AndroidSharedStateBridge {
|
||||||
val sharedInput = SharedLibraryProjectionInput(
|
val sharedInput = SharedLibraryProjectionInput(
|
||||||
state = projectionState.toSharedReaderScreenState(
|
state = projectionState.toSharedReaderScreenState(
|
||||||
rawBooks = taggedBooks,
|
rawBooks = taggedBooks,
|
||||||
dbTags = input.dbTags,
|
dbTags = input.dbTags
|
||||||
includeReaderAnnotations = false
|
|
||||||
),
|
),
|
||||||
booksFromStore = taggedBooks
|
booksFromStore = taggedBooks
|
||||||
.filterNot { it.bookId.endsWith("_reflow") }
|
.filterNot { it.bookId.endsWith("_reflow") }
|
||||||
|
|
@ -196,8 +195,7 @@ internal object AndroidSharedStateBridge {
|
||||||
private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState {
|
private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState {
|
||||||
return toSharedReaderScreenState(
|
return toSharedReaderScreenState(
|
||||||
rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles },
|
rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles },
|
||||||
dbTags = projectedState.allTags.ifEmpty { allTags },
|
dbTags = projectedState.allTags.ifEmpty { allTags }
|
||||||
includeReaderAnnotations = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import org.dueattendant149.bookreader.data.CustomFontEntity
|
import com.aryan.reader.data.CustomFontEntity
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
fun AppFontPreference.toAndroidAppFontFamily(customFonts: List<CustomFontEntity>): FontFamily? {
|
fun AppFontPreference.toAndroidAppFontFamily(customFonts: List<CustomFontEntity>): FontFamily? {
|
||||||
|
|
@ -17,11 +17,10 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
|
|
@ -59,14 +58,14 @@ import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import org.dueattendant149.bookreader.epubreader.EpubReaderScreen
|
import com.aryan.reader.epubreader.EpubReaderScreen
|
||||||
import org.dueattendant149.bookreader.feedback.FeedbackScreen
|
import com.aryan.reader.feedback.FeedbackScreen
|
||||||
import org.dueattendant149.bookreader.feedback.SupportProjectScreen
|
import com.aryan.reader.feedback.SupportProjectScreen
|
||||||
import org.dueattendant149.bookreader.pdf.PdfViewerScreen
|
import com.aryan.reader.pdf.PdfViewerScreen
|
||||||
import org.dueattendant149.bookreader.shared.ReaderFeatureSurface
|
import com.aryan.reader.shared.ReaderFeatureSurface
|
||||||
import org.dueattendant149.bookreader.tts.ReaderTtsMiniBar
|
import com.aryan.reader.tts.ReaderTtsMiniBar
|
||||||
import org.dueattendant149.bookreader.tts.readerTtsMiniBarBottomPaddingDp
|
import com.aryan.reader.tts.readerTtsMiniBarBottomPaddingDp
|
||||||
import org.dueattendant149.bookreader.tts.shouldShowReaderTtsMiniBar
|
import com.aryan.reader.tts.shouldShowReaderTtsMiniBar
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
object AppDestinations {
|
object AppDestinations {
|
||||||
|
|
@ -81,18 +80,6 @@ object AppDestinations {
|
||||||
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
|
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shouldInterceptAppNavBack(
|
|
||||||
currentRoute: String?,
|
|
||||||
hasPreviousBackStackEntry: Boolean,
|
|
||||||
isCurrentEntryResumed: Boolean
|
|
||||||
): Boolean {
|
|
||||||
if (!hasPreviousBackStackEntry || !isCurrentEntryResumed) return false
|
|
||||||
return currentRoute != null &&
|
|
||||||
currentRoute != AppDestinations.MAIN_ROUTE &&
|
|
||||||
currentRoute != AppDestinations.PDF_VIEWER_ROUTE &&
|
|
||||||
currentRoute != AppDestinations.EPUB_READER_ROUTE
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun NavHostController.isReadyForBackStackChange(): Boolean {
|
private fun NavHostController.isReadyForBackStackChange(): Boolean {
|
||||||
return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
|
return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
|
||||||
}
|
}
|
||||||
|
|
@ -179,11 +166,6 @@ fun AppNavigation(
|
||||||
val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp(
|
val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp(
|
||||||
isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE
|
isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE
|
||||||
).dp
|
).dp
|
||||||
val shouldInterceptBack = shouldInterceptAppNavBack(
|
|
||||||
currentRoute = currentRoute,
|
|
||||||
hasPreviousBackStackEntry = navController.previousBackStackEntry != null,
|
|
||||||
isCurrentEntryResumed = currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
|
|
||||||
)
|
|
||||||
|
|
||||||
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
||||||
if (!uiState.isLoading) {
|
if (!uiState.isLoading) {
|
||||||
|
|
@ -213,24 +195,14 @@ fun AppNavigation(
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
BackHandler(enabled = shouldInterceptBack) {
|
|
||||||
navController.popBackStackIfReady()
|
|
||||||
}
|
|
||||||
|
|
||||||
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
|
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
|
||||||
composable(AppDestinations.MAIN_ROUTE) {
|
composable(AppDestinations.MAIN_ROUTE) {
|
||||||
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
|
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
|
||||||
if (uiState.isTemporaryExternalOpen) {
|
MainScreen(
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
viewModel = viewModel,
|
||||||
CircularProgressIndicator()
|
windowSizeClass = windowSizeClass,
|
||||||
}
|
navController = navController
|
||||||
} else {
|
)
|
||||||
MainScreen(
|
|
||||||
viewModel = viewModel,
|
|
||||||
windowSizeClass = windowSizeClass,
|
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDF Viewer Screen Composable
|
// PDF Viewer Screen Composable
|
||||||
|
|
@ -343,7 +315,7 @@ fun AppNavigation(
|
||||||
},
|
},
|
||||||
onRenderModeChange = viewModel::setRenderMode,
|
onRenderModeChange = viewModel::setRenderMode,
|
||||||
customFonts = customFonts,
|
customFonts = customFonts,
|
||||||
onImportFonts = viewModel::importFonts,
|
onImportFont = viewModel::importFont,
|
||||||
viewModel = viewModel
|
viewModel = viewModel
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import org.dueattendant149.bookreader.data.TagEntity
|
import com.aryan.reader.data.TagEntity
|
||||||
import org.dueattendant149.bookreader.epub.CalibreBundleResult
|
import com.aryan.reader.epub.CalibreBundleResult
|
||||||
import org.dueattendant149.bookreader.epub.EpubBook
|
import com.aryan.reader.epub.EpubBook
|
||||||
import org.dueattendant149.bookreader.paginatedreader.Locator
|
import com.aryan.reader.paginatedreader.Locator
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
typealias BannerMessage = org.dueattendant149.bookreader.shared.BannerMessage
|
typealias BannerMessage = com.aryan.reader.shared.BannerMessage
|
||||||
typealias UserData = org.dueattendant149.bookreader.shared.UserData
|
typealias UserData = com.aryan.reader.shared.UserData
|
||||||
typealias AppThemeMode = org.dueattendant149.bookreader.shared.AppThemeMode
|
typealias AppThemeMode = com.aryan.reader.shared.AppThemeMode
|
||||||
typealias AppContrastOption = org.dueattendant149.bookreader.shared.AppContrastOption
|
typealias AppContrastOption = com.aryan.reader.shared.AppContrastOption
|
||||||
typealias AppFontPreference = org.dueattendant149.bookreader.shared.AppFontPreference
|
typealias AppFontPreference = com.aryan.reader.shared.AppFontPreference
|
||||||
typealias AppFontPreferenceKind = org.dueattendant149.bookreader.shared.AppFontPreferenceKind
|
typealias AppFontPreferenceKind = com.aryan.reader.shared.AppFontPreferenceKind
|
||||||
typealias CustomAppTheme = org.dueattendant149.bookreader.shared.CustomAppTheme
|
typealias CustomAppTheme = com.aryan.reader.shared.CustomAppTheme
|
||||||
|
|
||||||
data class ImportResult(
|
data class ImportResult(
|
||||||
val internalUri: Uri,
|
val internalUri: Uri,
|
||||||
|
|
@ -43,7 +43,6 @@ data class ReaderScreenState(
|
||||||
val selectedEpubUri: Uri? = null,
|
val selectedEpubUri: Uri? = null,
|
||||||
val selectedFileType: FileType? = null,
|
val selectedFileType: FileType? = null,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isTemporaryExternalOpen: Boolean = false,
|
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val contextualActionItems: Set<RecentFileItem> = emptySet(),
|
val contextualActionItems: Set<RecentFileItem> = emptySet(),
|
||||||
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
|
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
@ -29,7 +29,7 @@ import java.io.FileOutputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
import com.aryan.reader.shared.SharedFileCapabilities
|
||||||
|
|
||||||
private const val BOOKS_DIR = "books"
|
private const val BOOKS_DIR = "books"
|
||||||
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Common.kt
|
// Common.kt
|
||||||
@file:OptIn(ExperimentalMaterial3Api::class) @file:Suppress("KotlinConstantConditions")
|
@file:OptIn(ExperimentalMaterial3Api::class) @file:Suppress("KotlinConstantConditions")
|
||||||
|
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
|
@ -138,7 +138,6 @@ import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
|
@ -166,23 +165,23 @@ import androidx.compose.ui.window.PopupProperties
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.graphics.toColorInt
|
import androidx.core.graphics.toColorInt
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import org.dueattendant149.bookreader.epubreader.PREF_CUSTOM_THEMES
|
import com.aryan.reader.epubreader.PREF_CUSTOM_THEMES
|
||||||
import org.dueattendant149.bookreader.epubreader.PREF_READER_THEME
|
import com.aryan.reader.epubreader.PREF_READER_THEME
|
||||||
import org.dueattendant149.bookreader.paginatedreader.TtsChunk
|
import com.aryan.reader.paginatedreader.TtsChunk
|
||||||
import org.dueattendant149.bookreader.pdf.PdfHighlightColor
|
import com.aryan.reader.pdf.PdfHighlightColor
|
||||||
import org.dueattendant149.bookreader.shared.BuiltInReaderThemes
|
import com.aryan.reader.shared.BuiltInReaderThemes
|
||||||
import org.dueattendant149.bookreader.shared.ReaderTextureFilePrefix
|
import com.aryan.reader.shared.ReaderTextureFilePrefix
|
||||||
import org.dueattendant149.bookreader.shared.normalizeReaderTextureExtension
|
import com.aryan.reader.shared.normalizeReaderTextureExtension
|
||||||
import org.dueattendant149.bookreader.shared.readerTextureDisplayName as sharedReaderTextureDisplayName
|
import com.aryan.reader.shared.readerTextureDisplayName as sharedReaderTextureDisplayName
|
||||||
import org.dueattendant149.bookreader.shared.readerTextureMimeTypeForExtension
|
import com.aryan.reader.shared.readerTextureMimeTypeForExtension
|
||||||
import org.dueattendant149.bookreader.tts.GEMINI_TTS_SPEAKERS
|
import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
|
||||||
import org.dueattendant149.bookreader.tts.SpeakerSamplePlayer
|
import com.aryan.reader.tts.SpeakerSamplePlayer
|
||||||
import org.dueattendant149.bookreader.tts.TtsCacheManager
|
import com.aryan.reader.tts.TtsCacheManager
|
||||||
import org.dueattendant149.bookreader.tts.TtsPlaybackManager
|
import com.aryan.reader.tts.TtsPlaybackManager
|
||||||
import org.dueattendant149.bookreader.tts.formatBytes
|
import com.aryan.reader.tts.formatBytes
|
||||||
import org.dueattendant149.bookreader.tts.loadTtsMode
|
import com.aryan.reader.tts.loadTtsMode
|
||||||
import org.dueattendant149.bookreader.tts.rememberTtsController
|
import com.aryan.reader.tts.rememberTtsController
|
||||||
import org.dueattendant149.bookreader.tts.splitTextIntoChunks
|
import com.aryan.reader.tts.splitTextIntoChunks
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
@ -218,8 +217,8 @@ import kotlin.math.min
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
typealias ReaderTexture = org.dueattendant149.bookreader.shared.ReaderTexture
|
typealias ReaderTexture = com.aryan.reader.shared.ReaderTexture
|
||||||
typealias ReaderTheme = org.dueattendant149.bookreader.shared.ReaderTheme
|
typealias ReaderTheme = com.aryan.reader.shared.ReaderTheme
|
||||||
|
|
||||||
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
|
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
|
||||||
const val summarizeEndpoint = "/summarize"
|
const val summarizeEndpoint = "/summarize"
|
||||||
|
|
@ -474,9 +473,9 @@ data class SearchResult(
|
||||||
val chunkIndex: Int
|
val chunkIndex: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
typealias AiDefinitionResult = org.dueattendant149.bookreader.shared.AiDefinitionResult
|
typealias AiDefinitionResult = com.aryan.reader.shared.AiDefinitionResult
|
||||||
|
|
||||||
typealias SummarizationResult = org.dueattendant149.bookreader.shared.SummarizationResult
|
typealias SummarizationResult = com.aryan.reader.shared.SummarizationResult
|
||||||
|
|
||||||
data class CachedSummaryItem(
|
data class CachedSummaryItem(
|
||||||
val chapterIndex: Int,
|
val chapterIndex: Int,
|
||||||
|
|
@ -863,13 +862,6 @@ fun AiDefinitionPopup(
|
||||||
val ttsController = rememberTtsController()
|
val ttsController = rememberTtsController()
|
||||||
val ttsState by ttsController.ttsState.collectAsState()
|
val ttsState by ttsController.ttsState.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val configuration = LocalConfiguration.current
|
|
||||||
val maxPopupHeight = readerModalMaxHeightDp(
|
|
||||||
screenHeightDp = configuration.screenHeightDp,
|
|
||||||
fraction = 0.65f,
|
|
||||||
verticalMarginDp = 48,
|
|
||||||
preferredMinHeightDp = 180
|
|
||||||
).dp
|
|
||||||
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
|
@ -890,7 +882,7 @@ fun AiDefinitionPopup(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 5.dp)
|
.padding(horizontal = 16.dp, vertical = 5.dp)
|
||||||
.heightIn(max = maxPopupHeight),
|
.heightIn(min = 150.dp, max = 400.dp),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||||
) {
|
) {
|
||||||
|
|
@ -2957,14 +2949,7 @@ private fun ThemeGridItem(
|
||||||
Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
text = theme.name,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.clickable { onThemeSelected(theme.id) }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (theme.isCustom && onEdit != null && onDelete != null) {
|
if (theme.isCustom && onEdit != null && onDelete != null) {
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
@ -3318,16 +3303,12 @@ fun ThemeColorPickerDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
) {
|
) {
|
||||||
val configuration = LocalConfiguration.current
|
|
||||||
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(24.dp),
|
shape = RoundedCornerShape(24.dp),
|
||||||
color = Color(0xFF2C2C2C),
|
color = Color(0xFF2C2C2C),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.9f)
|
.fillMaxWidth(0.9f)
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.heightIn(max = maxDialogHeight)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -3507,16 +3488,12 @@ fun HighlightColorPickerDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
) {
|
) {
|
||||||
val configuration = LocalConfiguration.current
|
|
||||||
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(24.dp),
|
shape = RoundedCornerShape(24.dp),
|
||||||
color = Color(0xFF2C2C2C),
|
color = Color(0xFF2C2C2C),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.9f)
|
.fillMaxWidth(0.9f)
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.heightIn(max = maxDialogHeight)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -3845,7 +3822,7 @@ fun AiResultContentView(
|
||||||
result: SummarizationResult?,
|
result: SummarizationResult?,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
isMainTtsActive: Boolean,
|
isMainTtsActive: Boolean,
|
||||||
ttsController: org.dueattendant149.bookreader.tts.TtsController,
|
ttsController: com.aryan.reader.tts.TtsController,
|
||||||
ttsState: TtsPlaybackManager.TtsState,
|
ttsState: TtsPlaybackManager.TtsState,
|
||||||
getAuthToken: suspend () -> String?,
|
getAuthToken: suspend () -> String?,
|
||||||
onRegenerate: (() -> Unit)? = null,
|
onRegenerate: (() -> Unit)? = null,
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.util.Xml
|
import android.util.Xml
|
||||||
import org.xmlpull.v1.XmlPullParser
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import org.dueattendant149.bookreader.data.BookMetadataEdit
|
import com.aryan.reader.data.BookMetadataEdit
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataEditor
|
import com.aryan.reader.shared.reader.SharedEpubMetadataEditor
|
||||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataSnapshot
|
import com.aryan.reader.shared.reader.SharedEpubMetadataSnapshot
|
||||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataUpdate
|
import com.aryan.reader.shared.reader.SharedEpubMetadataUpdate
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -17,11 +17,10 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
|
@ -51,7 +50,8 @@ object FileHasher {
|
||||||
}
|
}
|
||||||
hexString.toString()
|
hexString.toString()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to calculate SHA-256 hash")
|
// In a real app, you'd want to log this error
|
||||||
|
e.printStackTrace()
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
import com.aryan.reader.shared.SharedFileCapabilities
|
||||||
|
|
||||||
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
|
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
|
||||||
return SharedFileCapabilities.resolveFileTypeForName(fileName)
|
return SharedFileCapabilities.resolveFileTypeForName(fileName)
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
// FolderSyncWorker.kt
|
// FolderSyncWorker.kt
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -29,25 +29,25 @@ import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import org.dueattendant149.bookreader.data.RecentFilesRepository
|
import com.aryan.reader.data.RecentFilesRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import org.dueattendant149.bookreader.data.LocalSyncUtils
|
import com.aryan.reader.data.LocalSyncUtils
|
||||||
import org.dueattendant149.bookreader.data.FolderBookMetadata
|
import com.aryan.reader.data.FolderBookMetadata
|
||||||
import org.dueattendant149.bookreader.data.toSharedFolderBookMetadata
|
import com.aryan.reader.data.toSharedFolderBookMetadata
|
||||||
import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem
|
import com.aryan.reader.shared.BookItem as SharedBookItem
|
||||||
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
|
import com.aryan.reader.shared.EpubAnnotationSerializer
|
||||||
import org.dueattendant149.bookreader.shared.EpubBookmark
|
import com.aryan.reader.shared.EpubBookmark
|
||||||
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||||
import org.dueattendant149.bookreader.shared.LocalFolderSyncEngine
|
import com.aryan.reader.shared.LocalFolderSyncEngine
|
||||||
import org.dueattendant149.bookreader.shared.ReaderLocator
|
import com.aryan.reader.shared.ReaderLocator
|
||||||
import org.dueattendant149.bookreader.shared.SharedFolderScannedFile
|
import com.aryan.reader.shared.SharedFolderScannedFile
|
||||||
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
import com.aryan.reader.shared.SharedReaderScreenState
|
||||||
import org.dueattendant149.bookreader.shared.reader.ReaderBookmark
|
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
|
|
||||||
|
|
@ -72,27 +72,45 @@ class FolderSyncWorker(
|
||||||
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
|
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
|
||||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null)
|
val jsonString = prefs.getString("synced_folders_list_json", null)
|
||||||
val folders = SyncedFolderPrefs.decodeSyncedFolders(
|
val folders = mutableListOf<Pair<String, Set<FileType>>>()
|
||||||
jsonString = jsonString,
|
|
||||||
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
|
if (jsonString != null) {
|
||||||
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
|
try {
|
||||||
)
|
val array = org.json.JSONArray(jsonString)
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val obj = array.getJSONObject(i)
|
||||||
|
val uri = obj.getString("uri")
|
||||||
|
val allowedFileTypes = mutableSetOf<FileType>()
|
||||||
|
if (obj.has("allowedFileTypes")) {
|
||||||
|
val typesArray = obj.getJSONArray("allowedFileTypes")
|
||||||
|
for (j in 0 until typesArray.length()) {
|
||||||
|
try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES)
|
||||||
|
}
|
||||||
|
folders.add(Pair(uri, allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { Timber.e(e) }
|
||||||
|
} else {
|
||||||
|
val single = prefs.getString("synced_folder_uri", null)
|
||||||
|
if (single != null) folders.add(Pair(single, ANDROID_SYNCABLE_FILE_TYPES))
|
||||||
|
}
|
||||||
|
|
||||||
if (folders.isEmpty()) {
|
if (folders.isEmpty()) {
|
||||||
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
|
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
val enabledFolders = folders.filter { it.localSyncEnabled }
|
|
||||||
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
|
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
|
||||||
enabledFolders
|
folders
|
||||||
} else {
|
} else {
|
||||||
enabledFolders.filter { it.uriString == targetFolderUri }
|
folders.filter { it.first == targetFolderUri }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foldersToProcess.isEmpty()) {
|
if (foldersToProcess.isEmpty()) {
|
||||||
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri")
|
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked target=$targetFolderUri")
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,8 +123,8 @@ class FolderSyncWorker(
|
||||||
syncMutex.withLock {
|
syncMutex.withLock {
|
||||||
var allSuccess = true
|
var allSuccess = true
|
||||||
|
|
||||||
for (folderConfig in foldersToProcess) {
|
for ((uriString, allowedTypes) in foldersToProcess) {
|
||||||
val success = performSyncForFolder(folderConfig, isMetadataOnly)
|
val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
|
||||||
if (!success) allSuccess = false
|
if (!success) allSuccess = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,14 +132,13 @@ class FolderSyncWorker(
|
||||||
try {
|
try {
|
||||||
val array = org.json.JSONArray(jsonString)
|
val array = org.json.JSONArray(jsonString)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString }
|
|
||||||
for (i in 0 until array.length()) {
|
for (i in 0 until array.length()) {
|
||||||
val obj = array.getJSONObject(i)
|
val obj = array.getJSONObject(i)
|
||||||
if (obj.optString("uri") in processedUris) {
|
if (targetFolderUri.isNullOrBlank() || obj.optString("uri") == targetFolderUri) {
|
||||||
obj.put("lastScanTime", now)
|
obj.put("lastScanTime", now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) }
|
prefs.edit { putString("synced_folders_list_json", array.toString()) }
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,9 +153,7 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean {
|
private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean {
|
||||||
val folderUriString = folderConfig.uriString
|
|
||||||
val allowedFileTypes = folderConfig.allowedFileTypes
|
|
||||||
if (folderUriString.isBlank()) return true
|
if (folderUriString.isBlank()) return true
|
||||||
val folderUri = folderUriString.toUri()
|
val folderUri = folderUriString.toUri()
|
||||||
val folderStart = ReaderPerfLog.nowNanos()
|
val folderStart = ReaderPerfLog.nowNanos()
|
||||||
|
|
@ -220,10 +235,9 @@ class FolderSyncWorker(
|
||||||
val nowMillis = System.currentTimeMillis()
|
val nowMillis = System.currentTimeMillis()
|
||||||
val folder = SyncedFolder(
|
val folder = SyncedFolder(
|
||||||
uriString = folderUriString,
|
uriString = folderUriString,
|
||||||
name = documentTree.name ?: folderConfig.name,
|
name = documentTree.name ?: "Local Folder",
|
||||||
lastScanTime = nowMillis,
|
lastScanTime = nowMillis,
|
||||||
allowedFileTypes = allowedFileTypes,
|
allowedFileTypes = allowedFileTypes
|
||||||
localSyncEnabled = true
|
|
||||||
)
|
)
|
||||||
val sharedState = SharedReaderScreenState(
|
val sharedState = SharedReaderScreenState(
|
||||||
rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
|
rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
|
||||||
|
|
@ -550,8 +564,7 @@ class FolderSyncWorker(
|
||||||
lastPageIndex = lastPage,
|
lastPageIndex = lastPage,
|
||||||
readerPosition = readerPositionOrNull(),
|
readerPosition = readerPositionOrNull(),
|
||||||
readerBookmarks = parseReaderBookmarks(),
|
readerBookmarks = parseReaderBookmarks(),
|
||||||
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson),
|
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
|
||||||
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -600,8 +613,8 @@ class FolderSyncWorker(
|
||||||
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
|
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
|
||||||
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
|
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
|
||||||
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
|
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
|
||||||
locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
|
locatorBlockIndex = appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
|
||||||
locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
|
locatorCharOffset = appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
|
||||||
progressPercentage = progressPercentage,
|
progressPercentage = progressPercentage,
|
||||||
isRecent = isRecent,
|
isRecent = isRecent,
|
||||||
isAvailable = true,
|
isAvailable = true,
|
||||||
|
|
@ -624,7 +637,6 @@ class FolderSyncWorker(
|
||||||
originalDescription = originalDescription,
|
originalDescription = originalDescription,
|
||||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||||
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
|
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
|
||||||
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
|
|
||||||
tags = existing?.tags.orEmpty()
|
tags = existing?.tags.orEmpty()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -708,12 +720,18 @@ class FolderSyncWorker(
|
||||||
|
|
||||||
private fun isFolderStillLinked(folderUriString: String): Boolean {
|
private fun isFolderStillLinked(folderUriString: String): Boolean {
|
||||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
return SyncedFolderPrefs.isLocalSyncEnabled(
|
val jsonString = prefs.getString("synced_folders_list_json", null)
|
||||||
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
|
if (jsonString != null) {
|
||||||
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
|
return try {
|
||||||
folderUriString = folderUriString,
|
val array = org.json.JSONArray(jsonString)
|
||||||
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
|
(0 until array.length()).any { index ->
|
||||||
)
|
array.getJSONObject(index).optString("uri") == folderUriString
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefs.getString("synced_folder_uri", null) == folderUriString
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFileType(name: String, mimeType: String?): FileType? {
|
private fun getFileType(name: String, mimeType: String?): FileType? {
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
// FontsScreen.kt
|
// FontsScreen.kt
|
||||||
@file:Suppress("KotlinConstantConditions")
|
@file:Suppress("KotlinConstantConditions")
|
||||||
|
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -30,12 +27,10 @@ import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.CloudDownload
|
import androidx.compose.material.icons.filled.CloudDownload
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.SelectAll
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
|
@ -51,7 +46,6 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -64,21 +58,14 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.dueattendant149.bookreader.shared.CustomFontItem
|
import com.aryan.reader.shared.CustomFontItem
|
||||||
import org.dueattendant149.bookreader.shared.CustomFontFamilyItem
|
import com.aryan.reader.shared.ui.SharedAppFontSelector
|
||||||
import org.dueattendant149.bookreader.shared.CustomFontVariantItem
|
import com.aryan.reader.shared.ui.SharedFontSettingsSection
|
||||||
import org.dueattendant149.bookreader.shared.fontFaceLabel
|
import com.aryan.reader.shared.ui.SharedFontSettingsTabs
|
||||||
import org.dueattendant149.bookreader.shared.fontFaceSummary
|
import com.aryan.reader.data.CustomFontEntity
|
||||||
import org.dueattendant149.bookreader.shared.groupByFamily
|
|
||||||
import org.dueattendant149.bookreader.shared.hasVariableWeightFace
|
|
||||||
import org.dueattendant149.bookreader.shared.ui.SharedAppFontSelector
|
|
||||||
import org.dueattendant149.bookreader.shared.ui.SharedFontSettingsSection
|
|
||||||
import org.dueattendant149.bookreader.shared.ui.SharedFontSettingsTabs
|
|
||||||
import org.dueattendant149.bookreader.data.CustomFontEntity
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -93,68 +80,37 @@ fun FontsScreen(
|
||||||
|
|
||||||
val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE)
|
val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE)
|
||||||
|
|
||||||
var fontsPendingDelete by remember { mutableStateOf<List<CustomFontEntity>>(emptyList()) }
|
// Dialog state
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) }
|
||||||
var showGoogleFontsSheet by remember { mutableStateOf(false) }
|
var showGoogleFontsSheet by remember { mutableStateOf(false) }
|
||||||
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
|
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
|
||||||
var selectedFontIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
|
||||||
|
|
||||||
val pickFontLauncher = rememberFilePickerLauncher(viewModel::importFonts)
|
val pickFontLauncher = rememberFilePickerLauncher { uris ->
|
||||||
val fontMimeTypes = remember { supportedFontMimeTypes() }
|
uris.firstOrNull()?.let { viewModel.importFont(it) }
|
||||||
val allFontIds = remember(fonts) { fonts.mapTo(mutableSetOf()) { it.id } }
|
|
||||||
val selectedFonts = remember(fonts, selectedFontIds) {
|
|
||||||
fonts.filter { it.id in selectedFontIds }
|
|
||||||
}
|
|
||||||
val fontEntitiesById = remember(fonts) { fonts.associateBy { it.id } }
|
|
||||||
val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty()
|
|
||||||
|
|
||||||
LaunchedEffect(fonts) {
|
|
||||||
selectedFontIds = selectedFontIds.intersect(allFontIds)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = isFontSelectionMode) {
|
val fontMimeTypes = arrayOf(
|
||||||
selectedFontIds = emptySet()
|
"font/ttf", "font/otf", "font/woff2",
|
||||||
}
|
"application/x-font-ttf", "application/x-font-otf",
|
||||||
|
"application/font-woff2", "application/vnd.ms-opentype",
|
||||||
|
"application/x-font-opentype"
|
||||||
|
)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.statusBarsPadding(),
|
modifier = Modifier.statusBarsPadding(),
|
||||||
topBar = {
|
topBar = {
|
||||||
if (isFontSelectionMode) {
|
CustomTopAppBar(
|
||||||
ContextualTopAppBar(
|
title = { Text(stringResource(R.string.custom_fonts)) },
|
||||||
selectedItemCount = selectedFonts.size,
|
navigationIcon = {
|
||||||
onNavIconClick = { selectedFontIds = emptySet() },
|
IconButton(onClick = onBackClick) {
|
||||||
onSelectAllClick = {
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||||
selectedFontIds = if (selectedFontIds.containsAll(allFontIds)) {
|
|
||||||
emptySet()
|
|
||||||
} else {
|
|
||||||
allFontIds
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDeleteClick = {
|
|
||||||
if (selectedFonts.isNotEmpty()) {
|
|
||||||
fontsPendingDelete = selectedFonts
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
} else {
|
)
|
||||||
CustomTopAppBar(
|
|
||||||
title = { Text(stringResource(R.string.custom_fonts)) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBackClick) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
|
|
||||||
IconButton(onClick = { selectedFontIds = allFontIds }) {
|
|
||||||
Icon(Icons.Default.SelectAll, contentDescription = stringResource(R.string.select_all))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty() && !isFontSelectionMode) {
|
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.End,
|
horizontalAlignment = Alignment.End,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
|
@ -180,14 +136,10 @@ fun FontsScreen(
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||||
val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
|
val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
|
||||||
val fontFamilies = remember(sharedFonts) { sharedFonts.groupByFamily() }
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
SharedFontSettingsTabs(
|
SharedFontSettingsTabs(
|
||||||
selectedSection = selectedSection,
|
selectedSection = selectedSection,
|
||||||
onSectionChange = {
|
onSectionChange = { selectedSection = it },
|
||||||
selectedFontIds = emptySet()
|
|
||||||
selectedSection = it
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -211,20 +163,12 @@ fun FontsScreen(
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
items(fontFamilies, key = { family -> family.variants.joinToString("|") { it.font.id } }) { family ->
|
items(fonts, key = { it.id }) { font ->
|
||||||
FontFamilyListItem(
|
FontListItem(
|
||||||
family = family,
|
font = font,
|
||||||
selectedFontIds = selectedFontIds,
|
onDelete = {
|
||||||
isSelectionMode = isFontSelectionMode,
|
fontToDelete = font
|
||||||
fontEntityForId = { id -> fontEntitiesById[id] },
|
showDeleteDialog = true
|
||||||
onVariantSelectionToggle = { id ->
|
|
||||||
selectedFontIds = selectedFontIds.toggle(id)
|
|
||||||
},
|
|
||||||
onFamilySelectionToggle = {
|
|
||||||
selectedFontIds = selectedFontIds.toggleAll(family.variants.map { it.font.id })
|
|
||||||
},
|
|
||||||
onDeleteVariant = { id ->
|
|
||||||
fontEntitiesById[id]?.let { fontsPendingDelete = listOf(it) }
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -264,17 +208,17 @@ fun FontsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fontsPendingDelete.isNotEmpty()) {
|
if (showDeleteDialog && fontToDelete != null) {
|
||||||
DeleteFontsConfirmationDialog(
|
DeleteFontConfirmationDialog(
|
||||||
fonts = fontsPendingDelete,
|
fontName = fontToDelete!!.displayName,
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
val pendingIds = fontsPendingDelete.map { it.id }
|
fontToDelete?.let { viewModel.deleteFont(it.id) }
|
||||||
viewModel.deleteFonts(pendingIds)
|
showDeleteDialog = false
|
||||||
selectedFontIds = selectedFontIds - pendingIds.toSet()
|
fontToDelete = null
|
||||||
fontsPendingDelete = emptyList()
|
|
||||||
},
|
},
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
fontsPendingDelete = emptyList()
|
showDeleteDialog = false
|
||||||
|
fontToDelete = null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -444,13 +388,9 @@ fun GoogleFontsBottomSheet(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existing unchanged components
|
// Existing unchanged components
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FontListItem(
|
fun FontListItem(
|
||||||
font: CustomFontEntity,
|
font: CustomFontEntity,
|
||||||
isSelected: Boolean,
|
|
||||||
isSelectionMode: Boolean,
|
|
||||||
onSelectionToggle: () -> Unit,
|
|
||||||
onDelete: () -> Unit
|
onDelete: () -> Unit
|
||||||
) {
|
) {
|
||||||
val customTypeface = remember(font.path) {
|
val customTypeface = remember(font.path) {
|
||||||
|
|
@ -462,23 +402,8 @@ fun FontListItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||||
.combinedClickable(
|
|
||||||
onClick = {
|
|
||||||
if (isSelectionMode) {
|
|
||||||
onSelectionToggle()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongClick = onSelectionToggle
|
|
||||||
),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = if (isSelected) {
|
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surface
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -486,27 +411,17 @@ fun FontListItem(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
if (isSelectionMode) {
|
|
||||||
Checkbox(
|
|
||||||
checked = isSelected,
|
|
||||||
onCheckedChange = { onSelectionToggle() },
|
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = font.displayName,
|
text = font.displayName,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
)
|
||||||
if (!isSelectionMode) {
|
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
|
||||||
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
|
Icon(
|
||||||
Icon(
|
Icons.Default.Delete,
|
||||||
Icons.Default.Delete,
|
contentDescription = stringResource(R.string.action_delete),
|
||||||
contentDescription = stringResource(R.string.action_delete),
|
tint = MaterialTheme.colorScheme.error
|
||||||
tint = MaterialTheme.colorScheme.error
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -544,188 +459,6 @@ fun FontListItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun FontFamilyListItem(
|
|
||||||
family: CustomFontFamilyItem,
|
|
||||||
selectedFontIds: Set<String>,
|
|
||||||
isSelectionMode: Boolean,
|
|
||||||
fontEntityForId: (String) -> CustomFontEntity?,
|
|
||||||
onVariantSelectionToggle: (String) -> Unit,
|
|
||||||
onFamilySelectionToggle: () -> Unit,
|
|
||||||
onDeleteVariant: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val baseFont = remember(family) {
|
|
||||||
family.variants.firstOrNull { it.fontFaceLabel() == "Regular" }?.font ?: family.variants.first().font
|
|
||||||
}
|
|
||||||
val customTypeface = remember(baseFont.path) {
|
|
||||||
try {
|
|
||||||
FontFamily(Font(File(baseFont.path)))
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val familyFontIds = remember(family) { family.variants.map { it.font.id }.toSet() }
|
|
||||||
val isSelected = familyFontIds.any { it in selectedFontIds }
|
|
||||||
val allSelected = familyFontIds.all { it in selectedFontIds }
|
|
||||||
val faceSummary = remember(family) {
|
|
||||||
buildString {
|
|
||||||
append(family.fontFaceSummary())
|
|
||||||
if (family.hasVariableWeightFace()) append(" - Variable weight")
|
|
||||||
append(" - ${family.variants.size} file")
|
|
||||||
if (family.variants.size != 1) append("s")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.combinedClickable(
|
|
||||||
onClick = {
|
|
||||||
if (isSelectionMode) {
|
|
||||||
onFamilySelectionToggle()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongClick = onFamilySelectionToggle
|
|
||||||
),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = if (isSelected) {
|
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surface
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
if (isSelectionMode) {
|
|
||||||
Checkbox(
|
|
||||||
checked = allSelected,
|
|
||||||
onCheckedChange = { onFamilySelectionToggle() },
|
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = family.familyName,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = faceSummary,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), MaterialTheme.shapes.small)
|
|
||||||
.padding(12.dp)
|
|
||||||
) {
|
|
||||||
if (customTypeface != null) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.font_preview_text),
|
|
||||||
fontFamily = customTypeface,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.font_preview_error),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
family.variants.forEachIndexed { index, variant ->
|
|
||||||
FontVariantRow(
|
|
||||||
variant = variant,
|
|
||||||
entity = fontEntityForId(variant.font.id),
|
|
||||||
isSelected = variant.font.id in selectedFontIds,
|
|
||||||
isSelectionMode = isSelectionMode,
|
|
||||||
onSelectionToggle = { onVariantSelectionToggle(variant.font.id) },
|
|
||||||
onDelete = { onDeleteVariant(variant.font.id) }
|
|
||||||
)
|
|
||||||
if (index != family.variants.lastIndex) {
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(start = if (isSelectionMode) 48.dp else 0.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FontVariantRow(
|
|
||||||
variant: CustomFontVariantItem,
|
|
||||||
entity: CustomFontEntity?,
|
|
||||||
isSelected: Boolean,
|
|
||||||
isSelectionMode: Boolean,
|
|
||||||
onSelectionToggle: () -> Unit,
|
|
||||||
onDelete: () -> Unit
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(6.dp))
|
|
||||||
.clickable(enabled = isSelectionMode) { onSelectionToggle() }
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
if (isSelectionMode) {
|
|
||||||
Checkbox(
|
|
||||||
checked = isSelected,
|
|
||||||
onCheckedChange = { onSelectionToggle() },
|
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = variant.fontFaceLabel(),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = entity?.fileName ?: variant.font.fileName,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = variant.font.fileExtension.uppercase(),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp)
|
|
||||||
)
|
|
||||||
if (!isSelectionMode) {
|
|
||||||
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Delete,
|
|
||||||
contentDescription = stringResource(R.string.action_delete),
|
|
||||||
tint = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
|
private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
|
||||||
return filterNot { it.isDeleted }
|
return filterNot { it.isDeleted }
|
||||||
.sortedBy { it.displayName.lowercase() }
|
.sortedBy { it.displayName.lowercase() }
|
||||||
|
|
@ -743,32 +476,15 @@ private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontIte
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeleteFontsConfirmationDialog(
|
fun DeleteFontConfirmationDialog(
|
||||||
fonts: List<CustomFontEntity>,
|
fontName: String,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
val isSingleFont = fonts.size == 1
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = {
|
title = { Text(stringResource(R.string.dialog_delete_font)) },
|
||||||
Text(
|
text = { Text(stringResource(R.string.dialog_delete_font_desc, fontName)) },
|
||||||
if (isSingleFont) {
|
|
||||||
stringResource(R.string.dialog_delete_font)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.dialog_delete_fonts)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
if (isSingleFont) {
|
|
||||||
stringResource(R.string.dialog_delete_font_desc, fonts.first().displayName)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.dialog_delete_fonts_desc, fonts.size)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onConfirm,
|
onClick = onConfirm,
|
||||||
|
|
@ -782,12 +498,3 @@ fun DeleteFontsConfirmationDialog(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Set<String>.toggle(id: String): Set<String> {
|
|
||||||
return if (id in this) this - id else this + id
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Set<String>.toggleAll(ids: List<String>): Set<String> {
|
|
||||||
val idSet = ids.toSet()
|
|
||||||
return if (containsAll(idSet)) this - idSet else this + idSet
|
|
||||||
}
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
// HomeScreen
|
// HomeScreen
|
||||||
@file:Suppress("DEPRECATION")
|
@file:Suppress("DEPRECATION")
|
||||||
|
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
|
@ -130,7 +130,6 @@ import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -139,13 +138,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
|
@ -194,7 +192,6 @@ fun HomeScreen(
|
||||||
var showAboutDialog by remember { mutableStateOf(false) }
|
var showAboutDialog by remember { mutableStateOf(false) }
|
||||||
var showInfoDialog by remember { mutableStateOf(false) }
|
var showInfoDialog by remember { mutableStateOf(false) }
|
||||||
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||||
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
|
|
||||||
var showBehaviorDialog by remember { mutableStateOf(false) }
|
var showBehaviorDialog by remember { mutableStateOf(false) }
|
||||||
var showStrictFilterDialog by remember { mutableStateOf(false) }
|
var showStrictFilterDialog by remember { mutableStateOf(false) }
|
||||||
var showClearBookCacheDialog by remember { mutableStateOf(false) }
|
var showClearBookCacheDialog by remember { mutableStateOf(false) }
|
||||||
|
|
@ -223,35 +220,6 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val saveOriginalLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
|
|
||||||
) { uri ->
|
|
||||||
val item = pendingSaveOriginalItem
|
|
||||||
pendingSaveOriginalItem = null
|
|
||||||
if (uri != null && item?.uriString != null) {
|
|
||||||
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveOriginalItem(item: RecentFileItem) {
|
|
||||||
if (!item.canExportOriginalFile()) return
|
|
||||||
pendingSaveOriginalItem = item
|
|
||||||
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shareOriginalItem(item: RecentFileItem) {
|
|
||||||
val uriString = item.uriString ?: return
|
|
||||||
if (!item.canExportOriginalFile()) return
|
|
||||||
scope.launch {
|
|
||||||
viewModel.shareOriginalFile(
|
|
||||||
activityContext = context,
|
|
||||||
sourceUri = uriString.toUri(),
|
|
||||||
fileType = item.type,
|
|
||||||
filename = item.suggestedOriginalFileName()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(uiState.isRequestingDrivePermission) {
|
LaunchedEffect(uiState.isRequestingDrivePermission) {
|
||||||
if (uiState.isRequestingDrivePermission) {
|
if (uiState.isRequestingDrivePermission) {
|
||||||
val intent = viewModel.getDriveSignInIntent(context)
|
val intent = viewModel.getDriveSignInIntent(context)
|
||||||
|
|
@ -421,12 +389,6 @@ fun HomeScreen(
|
||||||
showInfoDialog = true
|
showInfoDialog = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSaveClick = selectedContextItems.singleOrNull()
|
|
||||||
?.takeIf { it.canExportOriginalFile() }
|
|
||||||
?.let { item -> { saveOriginalItem(item) } },
|
|
||||||
onShareClick = selectedContextItems.singleOrNull()
|
|
||||||
?.takeIf { it.canExportOriginalFile() }
|
|
||||||
?.let { item -> { shareOriginalItem(item) } },
|
|
||||||
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
|
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
|
||||||
onDeleteClick = { showDeleteConfirmDialog = true },
|
onDeleteClick = { showDeleteConfirmDialog = true },
|
||||||
onSelectAllClick = { viewModel.selectAllRecentFiles() })
|
onSelectAllClick = { viewModel.selectAllRecentFiles() })
|
||||||
|
|
@ -474,7 +436,7 @@ fun HomeScreen(
|
||||||
onRefresh = { viewModel.refreshLibrary() },
|
onRefresh = { viewModel.refreshLibrary() },
|
||||||
isRefreshing = uiState.isRefreshing,
|
isRefreshing = uiState.isRefreshing,
|
||||||
isSyncEnabled = uiState.isSyncEnabled,
|
isSyncEnabled = uiState.isSyncEnabled,
|
||||||
hasSyncedFolder = uiState.syncedFolders.any { it.localSyncEnabled },
|
hasSyncedFolder = uiState.syncedFolders.isNotEmpty(),
|
||||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
|
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -539,17 +501,28 @@ fun HomeScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
HydratedFileInfoDialog(
|
itemForInfoDialog?.let { item ->
|
||||||
item = itemForInfoDialog,
|
if (showInfoDialog) {
|
||||||
isVisible = showInfoDialog,
|
FileInfoDialog(
|
||||||
uiState = uiState,
|
item = item,
|
||||||
viewModel = viewModel,
|
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
showInfoDialog = false
|
showInfoDialog = false
|
||||||
itemForInfoDialog = null
|
itemForInfoDialog = null
|
||||||
},
|
},
|
||||||
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
|
onSaveMetadata = { metadata ->
|
||||||
)
|
viewModel.updateBookMetadata(item.bookId, metadata)
|
||||||
|
},
|
||||||
|
onSaveDisplayName = { name ->
|
||||||
|
viewModel.updateCustomName(item.bookId, name)
|
||||||
|
},
|
||||||
|
onRestoreMetadata = {
|
||||||
|
viewModel.restoreOriginalBookMetadata(item.bookId)
|
||||||
|
},
|
||||||
|
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showClearReflowCacheDialog) {
|
if (showClearReflowCacheDialog) {
|
||||||
DangerousFolderActionDialog(
|
DangerousFolderActionDialog(
|
||||||
|
|
@ -794,8 +767,7 @@ private fun RecentFilesGrid(
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
modifier = Modifier.testTag("HomeTab_${tab.bookId}")
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -845,7 +817,6 @@ fun RecentFileCard(
|
||||||
|
|
||||||
androidx.compose.material3.ElevatedCard(
|
androidx.compose.material3.ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.testTag("HomeRecentFileCard_${item.bookId}")
|
|
||||||
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
|
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
||||||
|
|
@ -1514,7 +1485,7 @@ private fun AppDrawerContent(
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
// legal links
|
// legal links
|
||||||
if (uiState.currentUser != null || (isOss && !BuildConfig.IS_OFFLINE)) {
|
if (uiState.currentUser != null && !isOss) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val baseStyle = MaterialTheme.typography.labelMedium
|
val baseStyle = MaterialTheme.typography.labelMedium
|
||||||
var scaledTextStyle by remember { mutableStateOf(baseStyle) }
|
var scaledTextStyle by remember { mutableStateOf(baseStyle) }
|
||||||
|
|
@ -1812,14 +1783,9 @@ fun ExternalFileBehaviorDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(stringResource(R.string.options_external_file_behavior)) },
|
title = { Text(stringResource(R.string.options_external_file_behavior)) },
|
||||||
text = {
|
text = {
|
||||||
Column(modifier = Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) {
|
Column {
|
||||||
val options = listOf(
|
val options = listOf("ASK" to R.string.external_file_behavior_ask, "KEEP" to R.string.external_file_behavior_keep, "DELETE" to R.string.external_file_behavior_delete)
|
||||||
Triple("ASK", R.string.external_file_behavior_ask, R.string.external_file_behavior_ask_desc),
|
options.forEach { (value, labelRes) ->
|
||||||
Triple("KEEP", R.string.external_file_behavior_keep, R.string.external_file_behavior_keep_desc),
|
|
||||||
Triple("DELETE", R.string.external_file_behavior_delete, R.string.external_file_behavior_delete_desc),
|
|
||||||
Triple("TEMPORARY", R.string.external_file_behavior_temporary, R.string.external_file_behavior_temporary_desc)
|
|
||||||
)
|
|
||||||
options.forEach { (value, labelRes, descriptionRes) ->
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -1829,14 +1795,7 @@ fun ExternalFileBehaviorDialog(
|
||||||
) {
|
) {
|
||||||
RadioButton(selected = currentBehavior == value, onClick = null)
|
RadioButton(selected = currentBehavior == value, onClick = null)
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Text(stringResource(labelRes))
|
||||||
Text(stringResource(labelRes))
|
|
||||||
Text(
|
|
||||||
text = stringResource(descriptionRes),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
41
app/src/main/java/com/aryan/reader/LibraryModels.kt
Normal file
41
app/src/main/java/com/aryan/reader/LibraryModels.kt
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import com.aryan.reader.shared.ReaderFeatureSurface
|
||||||
|
import com.aryan.reader.shared.ReaderPlatform
|
||||||
|
import com.aryan.reader.shared.SharedFileCapabilities
|
||||||
|
|
||||||
|
typealias AddBooksSource = com.aryan.reader.shared.AddBooksSource
|
||||||
|
typealias FileType = com.aryan.reader.shared.FileType
|
||||||
|
typealias RenderMode = com.aryan.reader.shared.RenderMode
|
||||||
|
typealias SortOrder = com.aryan.reader.shared.SortOrder
|
||||||
|
typealias ReadStatusFilter = com.aryan.reader.shared.ReadStatusFilter
|
||||||
|
typealias LibraryFilters = com.aryan.reader.shared.LibraryFilters
|
||||||
|
typealias SyncedFolder = com.aryan.reader.shared.SyncedFolder
|
||||||
|
typealias ShelfType = com.aryan.reader.shared.ShelfType
|
||||||
|
|
||||||
|
internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
|
||||||
|
internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID)
|
||||||
|
internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES
|
||||||
|
internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES
|
||||||
|
|
||||||
|
internal fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? {
|
||||||
|
return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Shelf(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val type: ShelfType,
|
||||||
|
val books: List<RecentFileItem>,
|
||||||
|
val directBooks: List<RecentFileItem> = books,
|
||||||
|
val parentShelfId: String? = null,
|
||||||
|
val childShelfIds: List<String> = emptyList(),
|
||||||
|
val depth: Int = 0,
|
||||||
|
val sortKey: String = name.lowercase()
|
||||||
|
) {
|
||||||
|
val bookCount: Int get() = books.size
|
||||||
|
val topBook: RecentFileItem? by lazy(LazyThreadSafetyMode.NONE) { books.maxByOrNull { it.timestamp } }
|
||||||
|
val directBookCount: Int get() = directBooks.size
|
||||||
|
val childShelfCount: Int get() = childShelfIds.size
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
// LibraryScreen.kt
|
// LibraryScreen.kt
|
||||||
@file:Suppress("KotlinConstantConditions")
|
@file:Suppress("KotlinConstantConditions")
|
||||||
|
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
@ -105,7 +105,6 @@ import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -121,7 +120,6 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -133,24 +131,19 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import coil.ImageLoader
|
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.decode.SvgDecoder
|
import coil.request.ImageRequest
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import org.dueattendant149.bookreader.data.TagEntity
|
import com.aryan.reader.data.TagEntity
|
||||||
import org.dueattendant149.bookreader.opds.OpdsAcquisition
|
import com.aryan.reader.opds.OpdsAcquisition
|
||||||
import org.dueattendant149.bookreader.opds.OpdsCatalog
|
import com.aryan.reader.opds.OpdsCatalog
|
||||||
import org.dueattendant149.bookreader.opds.OpdsDownloadState
|
import com.aryan.reader.opds.OpdsDownloadState
|
||||||
import org.dueattendant149.bookreader.opds.OpdsEntry
|
import com.aryan.reader.opds.OpdsEntry
|
||||||
import org.dueattendant149.bookreader.opds.OpdsRepository
|
import com.aryan.reader.opds.OpdsViewModel
|
||||||
import org.dueattendant149.bookreader.opds.OpdsViewModel
|
|
||||||
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
|
||||||
import org.dueattendant149.bookreader.shared.opds.SharedOpdsLocalBookMatcher
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
@ -272,36 +265,6 @@ fun LibraryScreen(
|
||||||
var showDeleteShelvesDialog by remember { mutableStateOf(false) }
|
var showDeleteShelvesDialog by remember { mutableStateOf(false) }
|
||||||
var showInfoDialog by remember { mutableStateOf(false) }
|
var showInfoDialog by remember { mutableStateOf(false) }
|
||||||
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||||
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
|
|
||||||
|
|
||||||
val saveOriginalLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
|
|
||||||
) { uri ->
|
|
||||||
val item = pendingSaveOriginalItem
|
|
||||||
pendingSaveOriginalItem = null
|
|
||||||
if (uri != null && item?.uriString != null) {
|
|
||||||
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveOriginalItem(item: RecentFileItem) {
|
|
||||||
if (!item.canExportOriginalFile()) return
|
|
||||||
pendingSaveOriginalItem = item
|
|
||||||
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shareOriginalItem(item: RecentFileItem) {
|
|
||||||
val uriString = item.uriString ?: return
|
|
||||||
if (!item.canExportOriginalFile()) return
|
|
||||||
scope.launch {
|
|
||||||
viewModel.shareOriginalFile(
|
|
||||||
activityContext = context,
|
|
||||||
sourceUri = uriString.toUri(),
|
|
||||||
fileType = item.type,
|
|
||||||
filename = item.suggestedOriginalFileName()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BackHandler(enabled = isContextualModeActive) {
|
BackHandler(enabled = isContextualModeActive) {
|
||||||
viewModel.clearContextualAction()
|
viewModel.clearContextualAction()
|
||||||
|
|
@ -348,12 +311,6 @@ fun LibraryScreen(
|
||||||
showInfoDialog = true
|
showInfoDialog = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSaveClick = selectedItems.singleOrNull()
|
|
||||||
?.takeIf { it.canExportOriginalFile() }
|
|
||||||
?.let { item -> { saveOriginalItem(item) } },
|
|
||||||
onShareClick = selectedItems.singleOrNull()
|
|
||||||
?.takeIf { it.canExportOriginalFile() }
|
|
||||||
?.let { item -> { shareOriginalItem(item) } },
|
|
||||||
onDeleteClick = { showDeleteConfirmDialog = true },
|
onDeleteClick = { showDeleteConfirmDialog = true },
|
||||||
onSelectAllClick = { viewModel.selectAllLibraryFiles() },
|
onSelectAllClick = { viewModel.selectAllLibraryFiles() },
|
||||||
onShelfClick = viewModel::onShelfClick,
|
onShelfClick = viewModel::onShelfClick,
|
||||||
|
|
@ -368,7 +325,6 @@ fun LibraryScreen(
|
||||||
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
|
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
|
||||||
syncedFolders = uiState.syncedFolders,
|
syncedFolders = uiState.syncedFolders,
|
||||||
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
|
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
|
||||||
onFolderLocalSyncChange = viewModel::setFolderLocalSyncEnabled,
|
|
||||||
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
|
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
|
||||||
downloadingBookIds = uiState.downloadingBookIds,
|
downloadingBookIds = uiState.downloadingBookIds,
|
||||||
lastFolderScanTime = uiState.lastFolderScanTime,
|
lastFolderScanTime = uiState.lastFolderScanTime,
|
||||||
|
|
@ -434,17 +390,28 @@ fun LibraryScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
HydratedFileInfoDialog(
|
itemForInfoDialog?.let { item ->
|
||||||
item = itemForInfoDialog,
|
if (showInfoDialog) {
|
||||||
isVisible = showInfoDialog,
|
FileInfoDialog(
|
||||||
uiState = uiState,
|
item = item,
|
||||||
viewModel = viewModel,
|
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
showInfoDialog = false
|
showInfoDialog = false
|
||||||
itemForInfoDialog = null
|
itemForInfoDialog = null
|
||||||
},
|
},
|
||||||
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
|
onSaveMetadata = { metadata ->
|
||||||
)
|
viewModel.updateBookMetadata(item.bookId, metadata)
|
||||||
|
},
|
||||||
|
onSaveDisplayName = { name ->
|
||||||
|
viewModel.updateCustomName(item.bookId, name)
|
||||||
|
},
|
||||||
|
onRestoreMetadata = {
|
||||||
|
viewModel.restoreOriginalBookMetadata(item.bookId)
|
||||||
|
},
|
||||||
|
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -462,42 +429,10 @@ fun ShelfScreen(
|
||||||
val sortOrder = uiState.sortOrder
|
val sortOrder = uiState.sortOrder
|
||||||
val showRenameDialogFor = uiState.showRenameShelfDialogFor
|
val showRenameDialogFor = uiState.showRenameShelfDialogFor
|
||||||
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
|
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
|
var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
|
||||||
var showInfoDialog by remember { mutableStateOf(false) }
|
var showInfoDialog by remember { mutableStateOf(false) }
|
||||||
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||||
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
|
|
||||||
|
|
||||||
val saveOriginalLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
|
|
||||||
) { uri ->
|
|
||||||
val item = pendingSaveOriginalItem
|
|
||||||
pendingSaveOriginalItem = null
|
|
||||||
if (uri != null && item?.uriString != null) {
|
|
||||||
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveOriginalItem(item: RecentFileItem) {
|
|
||||||
if (!item.canExportOriginalFile()) return
|
|
||||||
pendingSaveOriginalItem = item
|
|
||||||
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shareOriginalItem(item: RecentFileItem) {
|
|
||||||
val uriString = item.uriString ?: return
|
|
||||||
if (!item.canExportOriginalFile()) return
|
|
||||||
scope.launch {
|
|
||||||
viewModel.shareOriginalFile(
|
|
||||||
activityContext = context,
|
|
||||||
sourceUri = uriString.toUri(),
|
|
||||||
fileType = item.type,
|
|
||||||
filename = item.suggestedOriginalFileName()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BackHandler(enabled = true) {
|
BackHandler(enabled = true) {
|
||||||
when {
|
when {
|
||||||
|
|
@ -549,12 +484,6 @@ fun ShelfScreen(
|
||||||
showInfoDialog = true
|
showInfoDialog = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSaveClick = selectedItems.singleOrNull()
|
|
||||||
?.takeIf { it.canExportOriginalFile() }
|
|
||||||
?.let { item -> { saveOriginalItem(item) } },
|
|
||||||
onShareClick = selectedItems.singleOrNull()
|
|
||||||
?.takeIf { it.canExportOriginalFile() }
|
|
||||||
?.let { item -> { shareOriginalItem(item) } },
|
|
||||||
onDeleteClick = { showRemoveFromShelfDialog = true },
|
onDeleteClick = { showRemoveFromShelfDialog = true },
|
||||||
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
|
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
|
||||||
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
|
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
|
||||||
|
|
@ -595,14 +524,19 @@ fun ShelfScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
HydratedFileInfoDialog(
|
itemForInfoDialog?.let { item ->
|
||||||
item = itemForInfoDialog,
|
if (showInfoDialog) {
|
||||||
isVisible = showInfoDialog,
|
FileInfoDialog(
|
||||||
uiState = uiState,
|
item = item,
|
||||||
viewModel = viewModel,
|
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||||
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
|
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
|
||||||
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
|
onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) },
|
||||||
)
|
onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
|
||||||
|
onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) },
|
||||||
|
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -637,8 +571,6 @@ fun LibraryScreenContent(
|
||||||
onItemClick: (RecentFileItem) -> Unit,
|
onItemClick: (RecentFileItem) -> Unit,
|
||||||
onItemLongClick: (RecentFileItem) -> Unit,
|
onItemLongClick: (RecentFileItem) -> Unit,
|
||||||
onInfoClick: () -> Unit,
|
onInfoClick: () -> Unit,
|
||||||
onSaveClick: (() -> Unit)?,
|
|
||||||
onShareClick: (() -> Unit)?,
|
|
||||||
onDeleteClick: () -> Unit,
|
onDeleteClick: () -> Unit,
|
||||||
onSelectAllClick: () -> Unit,
|
onSelectAllClick: () -> Unit,
|
||||||
onShelfClick: (Shelf) -> Unit,
|
onShelfClick: (Shelf) -> Unit,
|
||||||
|
|
@ -658,7 +590,6 @@ fun LibraryScreenContent(
|
||||||
isRefreshing: Boolean,
|
isRefreshing: Boolean,
|
||||||
syncedFolders: List<SyncedFolder>,
|
syncedFolders: List<SyncedFolder>,
|
||||||
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
||||||
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
|
|
||||||
onOpdsBookDownloaded: (Uri, String) -> Unit,
|
onOpdsBookDownloaded: (Uri, String) -> Unit,
|
||||||
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
||||||
onDeleteCatalogStreams: (String) -> Unit,
|
onDeleteCatalogStreams: (String) -> Unit,
|
||||||
|
|
@ -701,8 +632,6 @@ fun LibraryScreenContent(
|
||||||
onTagClick = onTagClick,
|
onTagClick = onTagClick,
|
||||||
onPinClick = onPinClick,
|
onPinClick = onPinClick,
|
||||||
onInfoClick = onInfoClick,
|
onInfoClick = onInfoClick,
|
||||||
onSaveClick = onSaveClick,
|
|
||||||
onShareClick = onShareClick,
|
|
||||||
onDeleteClick = onDeleteClick,
|
onDeleteClick = onDeleteClick,
|
||||||
onSelectAllClick = onSelectAllClick
|
onSelectAllClick = onSelectAllClick
|
||||||
)
|
)
|
||||||
|
|
@ -737,8 +666,7 @@ fun LibraryScreenContent(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.focusRequester(searchFocusRequester)
|
.focusRequester(searchFocusRequester),
|
||||||
.testTag("LibrarySearchTextField"),
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
colors = TextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedContainerColor = Color.Transparent,
|
focusedContainerColor = Color.Transparent,
|
||||||
|
|
@ -765,10 +693,7 @@ fun LibraryScreenContent(
|
||||||
Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter))
|
Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter))
|
||||||
}
|
}
|
||||||
Box {
|
Box {
|
||||||
TextButton(
|
TextButton(onClick = { showSortMenu = true }) {
|
||||||
onClick = { showSortMenu = true },
|
|
||||||
modifier = Modifier.testTag("LibrarySortButton")
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.sort),
|
painter = painterResource(id = R.drawable.sort),
|
||||||
contentDescription = stringResource(R.string.content_desc_sort),
|
contentDescription = stringResource(R.string.content_desc_sort),
|
||||||
|
|
@ -897,9 +822,7 @@ fun LibraryScreenContent(
|
||||||
text = { Text(stringResource(R.string.fab_new_shelf)) },
|
text = { Text(stringResource(R.string.fab_new_shelf)) },
|
||||||
icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) },
|
icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) },
|
||||||
onClick = onNewShelfClick,
|
onClick = onNewShelfClick,
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(16.dp)
|
||||||
.padding(16.dp)
|
|
||||||
.testTag("LibraryNewShelfFab")
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -965,7 +888,6 @@ fun LibraryScreenContent(
|
||||||
allRecentFiles = rawLibraryFiles,
|
allRecentFiles = rawLibraryFiles,
|
||||||
onAddFolderClick = onSelectSyncFolderClick,
|
onAddFolderClick = onSelectSyncFolderClick,
|
||||||
onRemoveFolderClick = onRemoveFolderClick,
|
onRemoveFolderClick = onRemoveFolderClick,
|
||||||
onFolderLocalSyncChange = onFolderLocalSyncChange,
|
|
||||||
onEditFolderFiltersClick = onEditFolderFiltersClick,
|
onEditFolderFiltersClick = onEditFolderFiltersClick,
|
||||||
onScanNowClick = onScanNowClick,
|
onScanNowClick = onScanNowClick,
|
||||||
onSyncMetadataClick = onSyncMetadataClick,
|
onSyncMetadataClick = onSyncMetadataClick,
|
||||||
|
|
@ -1109,8 +1031,6 @@ private fun ShelfDetailScreen(
|
||||||
onClearSelection: () -> Unit,
|
onClearSelection: () -> Unit,
|
||||||
onTagClick: () -> Unit,
|
onTagClick: () -> Unit,
|
||||||
onInfoClick: () -> Unit,
|
onInfoClick: () -> Unit,
|
||||||
onSaveClick: (() -> Unit)?,
|
|
||||||
onShareClick: (() -> Unit)?,
|
|
||||||
onDeleteClick: () -> Unit,
|
onDeleteClick: () -> Unit,
|
||||||
onRenameShelf: () -> Unit,
|
onRenameShelf: () -> Unit,
|
||||||
onDeleteShelf: () -> Unit,
|
onDeleteShelf: () -> Unit,
|
||||||
|
|
@ -1193,8 +1113,6 @@ private fun ShelfDetailScreen(
|
||||||
onNavIconClick = onClearSelection,
|
onNavIconClick = onClearSelection,
|
||||||
onTagClick = onTagClick,
|
onTagClick = onTagClick,
|
||||||
onInfoClick = onInfoClick,
|
onInfoClick = onInfoClick,
|
||||||
onSaveClick = onSaveClick,
|
|
||||||
onShareClick = onShareClick,
|
|
||||||
onDeleteClick = onDeleteClick
|
onDeleteClick = onDeleteClick
|
||||||
)
|
)
|
||||||
} else if (isSearchActive) {
|
} else if (isSearchActive) {
|
||||||
|
|
@ -1222,8 +1140,7 @@ private fun ShelfDetailScreen(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.focusRequester(searchFocusRequester)
|
.focusRequester(searchFocusRequester),
|
||||||
.testTag("ShelfSearchTextField"),
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
colors = TextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedContainerColor = Color.Transparent,
|
focusedContainerColor = Color.Transparent,
|
||||||
|
|
@ -1276,10 +1193,7 @@ private fun ShelfDetailScreen(
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
Box {
|
Box {
|
||||||
TextButton(
|
TextButton(onClick = { showSortMenu = true }) {
|
||||||
onClick = { showSortMenu = true },
|
|
||||||
modifier = Modifier.testTag("ShelfSortButton")
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.sort),
|
painter = painterResource(id = R.drawable.sort),
|
||||||
contentDescription = stringResource(R.string.content_desc_sort),
|
contentDescription = stringResource(R.string.content_desc_sort),
|
||||||
|
|
@ -1461,10 +1375,7 @@ private fun AddBooksModeScreen(
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
Box {
|
Box {
|
||||||
TextButton(
|
TextButton(onClick = { showSortMenu = true }) {
|
||||||
onClick = { showSortMenu = true },
|
|
||||||
modifier = Modifier.testTag("AddBooksSortButton")
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.sort),
|
painter = painterResource(id = R.drawable.sort),
|
||||||
contentDescription = stringResource(R.string.content_desc_sort),
|
contentDescription = stringResource(R.string.content_desc_sort),
|
||||||
|
|
@ -1670,7 +1581,6 @@ private fun ShelfListItem(
|
||||||
),
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.testTag("ShelfItem_${shelf.id}")
|
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
||||||
else Modifier
|
else Modifier
|
||||||
|
|
@ -1749,7 +1659,6 @@ private fun LibraryListItem(
|
||||||
),
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.testTag("LibraryBookItem_${item.bookId}")
|
|
||||||
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
|
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
||||||
|
|
@ -2020,15 +1929,12 @@ private fun FolderSyncScreen(
|
||||||
allRecentFiles: List<RecentFileItem>,
|
allRecentFiles: List<RecentFileItem>,
|
||||||
onAddFolderClick: () -> Unit,
|
onAddFolderClick: () -> Unit,
|
||||||
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
||||||
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
|
|
||||||
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
|
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
|
||||||
onScanNowClick: () -> Unit,
|
onScanNowClick: () -> Unit,
|
||||||
onSyncMetadataClick: () -> Unit,
|
onSyncMetadataClick: () -> Unit,
|
||||||
isLoading: Boolean
|
isLoading: Boolean
|
||||||
) {
|
) {
|
||||||
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
|
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
|
||||||
var disablingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
|
|
||||||
val hasEnabledSyncFolders = syncedFolders.any { it.localSyncEnabled }
|
|
||||||
val folderStatsByUri = remember(allRecentFiles) {
|
val folderStatsByUri = remember(allRecentFiles) {
|
||||||
allRecentFiles
|
allRecentFiles
|
||||||
.asSequence()
|
.asSequence()
|
||||||
|
|
@ -2067,7 +1973,7 @@ private fun FolderSyncScreen(
|
||||||
) {
|
) {
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = onScanNowClick,
|
onClick = onScanNowClick,
|
||||||
enabled = !isLoading && hasEnabledSyncFolders,
|
enabled = !isLoading,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
shape = MaterialTheme.shapes.small
|
shape = MaterialTheme.shapes.small
|
||||||
) {
|
) {
|
||||||
|
|
@ -2082,7 +1988,7 @@ private fun FolderSyncScreen(
|
||||||
|
|
||||||
androidx.compose.material3.OutlinedButton(
|
androidx.compose.material3.OutlinedButton(
|
||||||
onClick = onSyncMetadataClick,
|
onClick = onSyncMetadataClick,
|
||||||
enabled = !isLoading && hasEnabledSyncFolders,
|
enabled = !isLoading,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
shape = MaterialTheme.shapes.small
|
shape = MaterialTheme.shapes.small
|
||||||
) {
|
) {
|
||||||
|
|
@ -2110,13 +2016,6 @@ private fun FolderSyncScreen(
|
||||||
folder = folder,
|
folder = folder,
|
||||||
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
|
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
|
||||||
onRemoveClick = onRemoveFolderClick,
|
onRemoveClick = onRemoveFolderClick,
|
||||||
onLocalSyncToggleClick = { selectedFolder ->
|
|
||||||
if (selectedFolder.localSyncEnabled) {
|
|
||||||
disablingFolder = selectedFolder
|
|
||||||
} else {
|
|
||||||
onFolderLocalSyncChange(selectedFolder, true, false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onEditFiltersClick = { editingFolder = folder }
|
onEditFiltersClick = { editingFolder = folder }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -2134,46 +2033,6 @@ private fun FolderSyncScreen(
|
||||||
onDismiss = { editingFolder = null }
|
onDismiss = { editingFolder = null }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
disablingFolder?.let { folder ->
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { disablingFolder = null },
|
|
||||||
title = { Text(stringResource(R.string.dialog_disable_folder_local_sync_title)) },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string.dialog_disable_folder_local_sync_desc,
|
|
||||||
LOCAL_FOLDER_SYNC_DATA_DIR
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
onFolderLocalSyncChange(folder, false, true)
|
|
||||||
disablingFolder = null
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_disable_remove_sync_data))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
Row {
|
|
||||||
TextButton(onClick = { disablingFolder = null }) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
onFolderLocalSyncChange(folder, false, false)
|
|
||||||
disablingFolder = null
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_disable_keep_sync_data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class FolderFileStats(
|
private data class FolderFileStats(
|
||||||
|
|
@ -2191,7 +2050,6 @@ private fun FolderCard(
|
||||||
folder: SyncedFolder,
|
folder: SyncedFolder,
|
||||||
stats: FolderFileStats,
|
stats: FolderFileStats,
|
||||||
onRemoveClick: (SyncedFolder) -> Unit,
|
onRemoveClick: (SyncedFolder) -> Unit,
|
||||||
onLocalSyncToggleClick: (SyncedFolder) -> Unit,
|
|
||||||
onEditFiltersClick: (SyncedFolder) -> Unit
|
onEditFiltersClick: (SyncedFolder) -> Unit
|
||||||
) {
|
) {
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
@ -2217,22 +2075,13 @@ private fun FolderCard(
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Text(
|
||||||
Text(
|
text = folder.name,
|
||||||
text = folder.name,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
fontWeight = FontWeight.Bold,
|
||||||
fontWeight = FontWeight.Bold,
|
maxLines = 1,
|
||||||
maxLines = 1,
|
overflow = TextOverflow.Ellipsis
|
||||||
overflow = TextOverflow.Ellipsis
|
)
|
||||||
)
|
|
||||||
if (!folder.localSyncEnabled) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.folder_local_sync_disabled),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Box {
|
Box {
|
||||||
|
|
@ -2247,21 +2096,6 @@ private fun FolderCard(
|
||||||
onEditFiltersClick(folder)
|
onEditFiltersClick(folder)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
if (folder.localSyncEnabled) {
|
|
||||||
stringResource(R.string.menu_disable_folder_local_sync)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.menu_enable_folder_local_sync)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
showMenu = false
|
|
||||||
onLocalSyncToggleClick(folder)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_remove_folder)) },
|
text = { Text(stringResource(R.string.menu_remove_folder)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
@ -2543,7 +2377,6 @@ fun OpdsTab(
|
||||||
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val downloadingState = uiState.downloadingState
|
val downloadingState = uiState.downloadingState
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val coverImageLoader = rememberOpdsCoverImageLoader(uiState.currentCatalog)
|
|
||||||
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
|
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
|
||||||
var showCatalogDialog by remember { mutableStateOf(false) }
|
var showCatalogDialog by remember { mutableStateOf(false) }
|
||||||
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
|
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
|
||||||
|
|
@ -2768,7 +2601,6 @@ fun OpdsTab(
|
||||||
entry = entry,
|
entry = entry,
|
||||||
localLibraryFiles = localLibraryFiles,
|
localLibraryFiles = localLibraryFiles,
|
||||||
downloadState = downloadingState[entry.id],
|
downloadState = downloadingState[entry.id],
|
||||||
coverImageLoader = coverImageLoader,
|
|
||||||
onDownloadClick = { acquisition ->
|
onDownloadClick = { acquisition ->
|
||||||
opdsViewModel.downloadBook(
|
opdsViewModel.downloadBook(
|
||||||
entry, acquisition, context
|
entry, acquisition, context
|
||||||
|
|
@ -2817,7 +2649,6 @@ fun OpdsTab(
|
||||||
entry = selectedEntry!!,
|
entry = selectedEntry!!,
|
||||||
localLibraryFiles = localLibraryFiles,
|
localLibraryFiles = localLibraryFiles,
|
||||||
downloadState = downloadingState[selectedEntry!!.id],
|
downloadState = downloadingState[selectedEntry!!.id],
|
||||||
coverImageLoader = coverImageLoader,
|
|
||||||
onDownloadFormat = { acquisition ->
|
onDownloadFormat = { acquisition ->
|
||||||
opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
|
opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
|
||||||
onBookDownloaded(downloadedUri, selectedEntry!!.title)
|
onBookDownloaded(downloadedUri, selectedEntry!!.title)
|
||||||
|
|
@ -2945,29 +2776,6 @@ fun OpdsTab(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun rememberOpdsCoverImageLoader(catalog: OpdsCatalog?): ImageLoader {
|
|
||||||
val context = LocalContext.current.applicationContext
|
|
||||||
val username = catalog?.username
|
|
||||||
val password = catalog?.password
|
|
||||||
val imageLoader = remember(context, username, password) {
|
|
||||||
ImageLoader.Builder(context)
|
|
||||||
.okHttpClient {
|
|
||||||
OpdsRepository.sharedHttpClient.newBuilder()
|
|
||||||
.authenticator(OpdsRepository.OpdsAuthenticator(username, password))
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
.components {
|
|
||||||
add(SvgDecoder.Factory())
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
DisposableEffect(imageLoader) {
|
|
||||||
onDispose { imageLoader.shutdown() }
|
|
||||||
}
|
|
||||||
return imageLoader
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
|
fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
|
||||||
Surface(
|
Surface(
|
||||||
|
|
@ -3045,20 +2853,13 @@ fun OpdsBookCard(
|
||||||
entry: OpdsEntry,
|
entry: OpdsEntry,
|
||||||
localLibraryFiles: List<RecentFileItem>,
|
localLibraryFiles: List<RecentFileItem>,
|
||||||
downloadState: OpdsDownloadState?,
|
downloadState: OpdsDownloadState?,
|
||||||
coverImageLoader: ImageLoader,
|
|
||||||
onDownloadClick: (OpdsAcquisition) -> Unit,
|
onDownloadClick: (OpdsAcquisition) -> Unit,
|
||||||
onReadClick: (RecentFileItem) -> Unit,
|
onReadClick: (RecentFileItem) -> Unit,
|
||||||
onStreamClick: () -> Unit,
|
onStreamClick: () -> Unit,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val libraryItem = remember(entry, localLibraryFiles) {
|
val libraryItem = remember(entry, localLibraryFiles) {
|
||||||
SharedOpdsLocalBookMatcher.find(
|
localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
|
||||||
entry = entry,
|
|
||||||
books = localLibraryFiles,
|
|
||||||
title = { it.title },
|
|
||||||
displayName = { it.displayName },
|
|
||||||
path = { it.uriString }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
val isDownloading = downloadState?.isDownloading == true
|
val isDownloading = downloadState?.isDownloading == true
|
||||||
val progress = downloadState?.progress
|
val progress = downloadState?.progress
|
||||||
|
|
@ -3077,7 +2878,6 @@ fun OpdsBookCard(
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = entry.coverUrl,
|
model = entry.coverUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
imageLoader = coverImageLoader,
|
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 70.dp, height = 100.dp)
|
.size(width = 70.dp, height = 100.dp)
|
||||||
|
|
@ -3184,7 +2984,6 @@ fun OpdsBookDetailsSheet(
|
||||||
entry: OpdsEntry,
|
entry: OpdsEntry,
|
||||||
localLibraryFiles: List<RecentFileItem>,
|
localLibraryFiles: List<RecentFileItem>,
|
||||||
downloadState: OpdsDownloadState?,
|
downloadState: OpdsDownloadState?,
|
||||||
coverImageLoader: ImageLoader,
|
|
||||||
onDownloadFormat: (OpdsAcquisition) -> Unit,
|
onDownloadFormat: (OpdsAcquisition) -> Unit,
|
||||||
onReadClick: (RecentFileItem) -> Unit,
|
onReadClick: (RecentFileItem) -> Unit,
|
||||||
onStreamClick: () -> Unit,
|
onStreamClick: () -> Unit,
|
||||||
|
|
@ -3193,13 +2992,7 @@ fun OpdsBookDetailsSheet(
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val libraryItem = remember(entry, localLibraryFiles) {
|
val libraryItem = remember(entry, localLibraryFiles) {
|
||||||
SharedOpdsLocalBookMatcher.find(
|
localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
|
||||||
entry = entry,
|
|
||||||
books = localLibraryFiles,
|
|
||||||
title = { it.title },
|
|
||||||
displayName = { it.displayName },
|
|
||||||
path = { it.uriString }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
val isDownloading = downloadState?.isDownloading == true
|
val isDownloading = downloadState?.isDownloading == true
|
||||||
val progress = downloadState?.progress
|
val progress = downloadState?.progress
|
||||||
|
|
@ -3219,7 +3012,6 @@ fun OpdsBookDetailsSheet(
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = entry.coverUrl,
|
model = entry.coverUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
imageLoader = coverImageLoader,
|
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 110.dp, height = 160.dp)
|
.size(width = 110.dp, height = 160.dp)
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import org.dueattendant149.bookreader.data.BookShelfCrossRef
|
import com.aryan.reader.data.BookShelfCrossRef
|
||||||
import org.dueattendant149.bookreader.data.BookTagCrossRef
|
import com.aryan.reader.data.BookTagCrossRef
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import org.dueattendant149.bookreader.data.ShelfEntity
|
import com.aryan.reader.data.ShelfEntity
|
||||||
import org.dueattendant149.bookreader.data.TagEntity
|
import com.aryan.reader.data.TagEntity
|
||||||
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
import com.aryan.reader.shared.SharedReaderScreenState
|
||||||
import org.dueattendant149.bookreader.shared.applyLibraryFilters as sharedApplyLibraryFilters
|
import com.aryan.reader.shared.applyLibraryFilters as sharedApplyLibraryFilters
|
||||||
import org.dueattendant149.bookreader.shared.filterBySearch as sharedFilterBySearch
|
import com.aryan.reader.shared.filterBySearch as sharedFilterBySearch
|
||||||
import org.dueattendant149.bookreader.shared.sortBooks as sharedSortBooks
|
import com.aryan.reader.shared.sortBooks as sharedSortBooks
|
||||||
|
|
||||||
fun interface FolderPathResolver {
|
fun interface FolderPathResolver {
|
||||||
fun relativeFolderSegments(item: RecentFileItem): List<String>
|
fun relativeFolderSegments(item: RecentFileItem): List<String>
|
||||||
|
|
@ -151,7 +151,7 @@ fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFil
|
||||||
return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder))
|
return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<RecentFileItem>.mapSharedResults(sharedBooks: List<org.dueattendant149.bookreader.shared.BookItem>): List<RecentFileItem> {
|
private fun List<RecentFileItem>.mapSharedResults(sharedBooks: List<com.aryan.reader.shared.BookItem>): List<RecentFileItem> {
|
||||||
val byId = associateBy { it.bookId }
|
val byId = associateBy { it.bookId }
|
||||||
return sharedBooks.mapNotNull { byId[it.id] }
|
return sharedBooks.mapNotNull { byId[it.id] }
|
||||||
}
|
}
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
|
@ -41,8 +41,8 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import org.dueattendant149.bookreader.data.PlatformFeaturesRepository
|
import com.aryan.reader.data.PlatformFeaturesRepository
|
||||||
import org.dueattendant149.bookreader.ui.theme.AppTheme
|
import com.aryan.reader.ui.theme.AppTheme
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
|
@ -50,20 +50,18 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import org.dueattendant149.bookreader.tts.ACTION_OPEN_TTS_SESSION
|
import com.aryan.reader.tts.ACTION_OPEN_TTS_SESSION
|
||||||
import org.dueattendant149.bookreader.tts.EXTRA_TTS_BOOK_ID
|
import com.aryan.reader.tts.EXTRA_TTS_BOOK_ID
|
||||||
import org.dueattendant149.bookreader.tts.EXTRA_TTS_CHAPTER_INDEX
|
import com.aryan.reader.tts.EXTRA_TTS_CHAPTER_INDEX
|
||||||
import org.dueattendant149.bookreader.tts.EXTRA_TTS_PAGE_INDEX
|
import com.aryan.reader.tts.EXTRA_TTS_PAGE_INDEX
|
||||||
import org.dueattendant149.bookreader.tts.EXTRA_TTS_SOURCE_CFI
|
import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI
|
||||||
import org.dueattendant149.bookreader.tts.EXTRA_TTS_START_OFFSET
|
import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
open class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private val viewModel: MainViewModel by viewModels()
|
private val viewModel: MainViewModel by viewModels()
|
||||||
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
|
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
|
||||||
private val isTemporaryExternalOpen: Boolean
|
|
||||||
get() = intent?.getBooleanExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, false) == true
|
|
||||||
|
|
||||||
private val updateLauncher = registerForActivityResult(
|
private val updateLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.StartIntentSenderForResult()
|
ActivityResultContracts.StartIntentSenderForResult()
|
||||||
|
|
@ -88,14 +86,6 @@ open class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
viewModel.temporaryExternalOpenFinished.collect {
|
|
||||||
if (isTemporaryExternalOpen) {
|
|
||||||
finishAndRemoveTask()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
@ -170,12 +160,7 @@ open class MainActivity : AppCompatActivity() {
|
||||||
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
|
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
|
||||||
Timber.d("Received VIEW intent with URI: ${intent.data}")
|
Timber.d("Received VIEW intent with URI: ${intent.data}")
|
||||||
val uri = intent.data!!
|
val uri = intent.data!!
|
||||||
viewModel.onFileSelected(
|
viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true)
|
||||||
uri,
|
|
||||||
isFromRecent = false,
|
|
||||||
isExternalIntent = true,
|
|
||||||
isTemporaryExternalIntent = isTemporaryExternalOpen
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
internal const val KEY_RENDER_MODE = "render_mode"
|
internal const val KEY_RENDER_MODE = "render_mode"
|
||||||
internal const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
|
internal const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
// MainScreen.kt
|
// MainScreen.kt
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
||||||
// MetadataExtractionWorker.kt
|
// MetadataExtractionWorker.kt
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
|
|
@ -10,10 +10,10 @@ import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import org.dueattendant149.bookreader.data.RecentFilesRepository
|
import com.aryan.reader.data.RecentFilesRepository
|
||||||
import org.dueattendant149.bookreader.pdf.PdfiumCoreProvider
|
import com.aryan.reader.pdf.PdfiumCoreProvider
|
||||||
import org.dueattendant149.bookreader.pdf.PdfiumEngineProvider
|
import com.aryan.reader.pdf.PdfiumEngineProvider
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.xmlpull.v1.XmlPullParser
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
|
@ -50,20 +50,11 @@ class MetadataExtractionWorker(
|
||||||
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
|
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
|
||||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
val linkedFolders = SyncedFolderPrefs.decodeSyncedFolders(
|
val hasLegacy = prefs.contains("synced_folder_uri")
|
||||||
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
|
val hasNew = prefs.contains("synced_folders_list_json")
|
||||||
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null)
|
|
||||||
)
|
|
||||||
val enabledFolderUris = linkedFolders
|
|
||||||
.filter { it.localSyncEnabled }
|
|
||||||
.mapTo(mutableSetOf()) { it.uriString }
|
|
||||||
|
|
||||||
if (enabledFolderUris.isEmpty()) {
|
if (!hasLegacy && !hasNew) {
|
||||||
ReaderPerfLog.d("MetadataWorker skipped: no linked folders with sync enabled")
|
ReaderPerfLog.d("MetadataWorker skipped: no linked folders")
|
||||||
return@withContext Result.success()
|
|
||||||
}
|
|
||||||
if (!sourceFolderUri.isNullOrBlank() && sourceFolderUri !in enabledFolderUris) {
|
|
||||||
ReaderPerfLog.d("MetadataWorker skipped: folder sync disabled folder=$sourceFolderUri")
|
|
||||||
return@withContext Result.success()
|
return@withContext Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,9 +62,7 @@ class MetadataExtractionWorker(
|
||||||
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
|
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
|
||||||
sourceFolderUri = sourceFolderUri,
|
sourceFolderUri = sourceFolderUri,
|
||||||
limit = METADATA_WORKER_BOOK_BATCH_SIZE
|
limit = METADATA_WORKER_BOOK_BATCH_SIZE
|
||||||
).filter { item ->
|
)
|
||||||
item.sourceFolderUri != null && item.sourceFolderUri in enabledFolderUris
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filesToProcess.isEmpty()) {
|
if (filesToProcess.isEmpty()) {
|
||||||
ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")
|
ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")
|
||||||
|
|
@ -17,14 +17,14 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.ImageLoaderFactory
|
import coil.ImageLoaderFactory
|
||||||
import coil.decode.SvgDecoder
|
import coil.decode.SvgDecoder
|
||||||
import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher
|
import com.aryan.reader.paginatedreader.SvgStringFetcher
|
||||||
import timber.log.Timber // Add this
|
import timber.log.Timber // Add this
|
||||||
|
|
||||||
class MyApplication : Application(), ImageLoaderFactory {
|
class MyApplication : Application(), ImageLoaderFactory {
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -61,7 +61,7 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import org.dueattendant149.bookreader.data.ProductDetailsEntity
|
import com.aryan.reader.data.ProductDetailsEntity
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.text.NumberFormat
|
import java.text.NumberFormat
|
||||||
import java.util.Currency
|
import java.util.Currency
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.Window
|
import android.view.Window
|
||||||
|
|
@ -10,16 +10,11 @@ import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.Remove
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
|
@ -33,37 +28,20 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import org.dueattendant149.bookreader.shared.ui.ReaderMinimalSlider
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private const val READER_PREFS_NAME = "reader_prefs"
|
private const val READER_PREFS_NAME = "reader_prefs"
|
||||||
private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system"
|
private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system"
|
||||||
private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value"
|
private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value"
|
||||||
private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f
|
private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f
|
||||||
private const val MIN_CUSTOM_BRIGHTNESS_PERCENT = 1
|
private const val MIN_CUSTOM_BRIGHTNESS = 0.05f
|
||||||
private const val MAX_CUSTOM_BRIGHTNESS_PERCENT = 100
|
|
||||||
private const val CUSTOM_BRIGHTNESS_STEP_PERCENT = 1
|
|
||||||
private const val MIN_CUSTOM_BRIGHTNESS = 0.01f
|
|
||||||
|
|
||||||
data class ReaderBrightnessSettings(
|
data class ReaderBrightnessSettings(
|
||||||
val useSystemBrightness: Boolean = true,
|
val useSystemBrightness: Boolean = true,
|
||||||
val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS
|
val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS
|
||||||
) {
|
) {
|
||||||
val safeCustomBrightness: Float
|
val safeCustomBrightness: Float
|
||||||
get() = normalizeReaderBrightness(customBrightness)
|
get() = customBrightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
|
||||||
}
|
|
||||||
|
|
||||||
internal fun normalizeReaderBrightness(brightness: Float): Float {
|
|
||||||
val percent = (brightness * 100f).roundToInt()
|
|
||||||
.coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
|
|
||||||
return percent / 100f
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun stepReaderBrightness(brightness: Float, percentDelta: Int): Float {
|
|
||||||
val currentPercent = (normalizeReaderBrightness(brightness) * 100f).roundToInt()
|
|
||||||
val nextPercent = (currentPercent + percentDelta)
|
|
||||||
.coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
|
|
||||||
return nextPercent / 100f
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
|
fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
|
||||||
|
|
@ -71,7 +49,7 @@ fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
|
||||||
return ReaderBrightnessSettings(
|
return ReaderBrightnessSettings(
|
||||||
useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true),
|
useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true),
|
||||||
customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS)
|
customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS)
|
||||||
.let(::normalizeReaderBrightness)
|
.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,9 +162,17 @@ fun ReaderBrightnessSheet(
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ReaderBrightnessControl(
|
Slider(
|
||||||
settings = settings,
|
value = settings.safeCustomBrightness,
|
||||||
onSettingsChange = onSettingsChange
|
onValueChange = { brightness ->
|
||||||
|
onSettingsChange(
|
||||||
|
settings.copy(
|
||||||
|
useSystemBrightness = false,
|
||||||
|
customBrightness = brightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
valueRange = MIN_CUSTOM_BRIGHTNESS..1f
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.reader_brightness_custom_desc),
|
text = stringResource(R.string.reader_brightness_custom_desc),
|
||||||
|
|
@ -200,67 +186,6 @@ fun ReaderBrightnessSheet(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ReaderBrightnessControl(
|
|
||||||
settings: ReaderBrightnessSettings,
|
|
||||||
onSettingsChange: (ReaderBrightnessSettings) -> Unit
|
|
||||||
) {
|
|
||||||
val brightness = settings.safeCustomBrightness
|
|
||||||
val canDecrease = brightness > MIN_CUSTOM_BRIGHTNESS
|
|
||||||
val canIncrease = brightness < 1f
|
|
||||||
|
|
||||||
fun updateBrightness(value: Float) {
|
|
||||||
onSettingsChange(
|
|
||||||
settings.copy(
|
|
||||||
useSystemBrightness = false,
|
|
||||||
customBrightness = normalizeReaderBrightness(value)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
updateBrightness(stepReaderBrightness(brightness, -CUSTOM_BRIGHTNESS_STEP_PERCENT))
|
|
||||||
},
|
|
||||||
enabled = canDecrease,
|
|
||||||
modifier = Modifier.size(36.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Remove,
|
|
||||||
contentDescription = stringResource(R.string.content_desc_decrease),
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ReaderMinimalSlider(
|
|
||||||
value = brightness,
|
|
||||||
onValueChange = ::updateBrightness,
|
|
||||||
valueRange = MIN_CUSTOM_BRIGHTNESS..1f,
|
|
||||||
activeColor = MaterialTheme.colorScheme.primary,
|
|
||||||
inactiveColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
|
||||||
thumbColor = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
updateBrightness(stepReaderBrightness(brightness, CUSTOM_BRIGHTNESS_STEP_PERCENT))
|
|
||||||
},
|
|
||||||
enabled = canIncrease,
|
|
||||||
modifier = Modifier.size(36.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Add,
|
|
||||||
contentDescription = stringResource(R.string.content_desc_increase),
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Window.setReaderBrightness(brightness: Float) {
|
private fun Window.setReaderBrightness(brightness: Float) {
|
||||||
attributes = attributes.apply {
|
attributes = attributes.apply {
|
||||||
screenBrightness = brightness
|
screenBrightness = brightness
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberReaderFileInfoItem(
|
private fun rememberReaderFileInfoItem(
|
||||||
|
|
@ -67,17 +64,28 @@ internal fun ReaderFileInfoDialogs(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HydratedFileInfoDialog(
|
item?.let { fileInfoItem ->
|
||||||
item = item,
|
if (isFileInfoVisible) {
|
||||||
isVisible = isFileInfoVisible,
|
FileInfoDialog(
|
||||||
uiState = uiState,
|
item = fileInfoItem,
|
||||||
viewModel = viewModel,
|
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||||
onDismiss = { onFileInfoVisibleChange(false) },
|
onDismiss = { onFileInfoVisibleChange(false) },
|
||||||
onOpenTags = { bookId ->
|
onSaveMetadata = { metadata ->
|
||||||
onFileInfoVisibleChange(false)
|
viewModel.updateBookMetadata(fileInfoItem.bookId, metadata)
|
||||||
viewModel.openTagSelection(setOf(bookId))
|
},
|
||||||
|
onSaveDisplayName = { name ->
|
||||||
|
viewModel.updateCustomName(fileInfoItem.bookId, name)
|
||||||
|
},
|
||||||
|
onRestoreMetadata = {
|
||||||
|
viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId)
|
||||||
|
},
|
||||||
|
onOpenTags = {
|
||||||
|
onFileInfoVisibleChange(false)
|
||||||
|
viewModel.openTagSelection(setOf(fileInfoItem.bookId))
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
|
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
|
||||||
TagSelectionBottomSheet(
|
TagSelectionBottomSheet(
|
||||||
|
|
@ -94,49 +102,3 @@ internal fun ReaderFileInfoDialogs(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun HydratedFileInfoDialog(
|
|
||||||
item: RecentFileItem?,
|
|
||||||
isVisible: Boolean,
|
|
||||||
uiState: ReaderScreenState,
|
|
||||||
viewModel: MainViewModel,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onOpenTags: (String) -> Unit
|
|
||||||
) {
|
|
||||||
var fileInfoItem by remember(item?.bookId) { mutableStateOf(item) }
|
|
||||||
var hasResolvedFullItem by remember(item?.bookId) { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(item) {
|
|
||||||
fileInfoItem = item
|
|
||||||
hasResolvedFullItem = false
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(isVisible, item?.bookId) {
|
|
||||||
if (isVisible && item != null) {
|
|
||||||
fileInfoItem = viewModel.getFileInfoItem(item.bookId)?.copy(tags = item.tags) ?: item
|
|
||||||
hasResolvedFullItem = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val resolvedItem = fileInfoItem
|
|
||||||
if (isVisible && resolvedItem != null && hasResolvedFullItem) {
|
|
||||||
FileInfoDialog(
|
|
||||||
item = resolvedItem,
|
|
||||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
onSaveMetadata = { metadata ->
|
|
||||||
viewModel.updateBookMetadata(resolvedItem.bookId, metadata)
|
|
||||||
},
|
|
||||||
onSaveDisplayName = { name ->
|
|
||||||
viewModel.updateCustomName(resolvedItem.bookId, name)
|
|
||||||
},
|
|
||||||
onRestoreMetadata = {
|
|
||||||
viewModel.restoreOriginalBookMetadata(resolvedItem.bookId)
|
|
||||||
},
|
|
||||||
onOpenTags = {
|
|
||||||
onOpenTags(resolvedItem.bookId)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -56,21 +56,6 @@ internal fun shouldRenderReaderSlider(
|
||||||
isSearchActive: Boolean
|
isSearchActive: Boolean
|
||||||
): Boolean = isToggledOn && isBottomChromeVisible && !isSearchActive
|
): Boolean = isToggledOn && isBottomChromeVisible && !isSearchActive
|
||||||
|
|
||||||
internal fun readerSliderStepPage(
|
|
||||||
currentPage: Int,
|
|
||||||
delta: Int,
|
|
||||||
minPage: Int,
|
|
||||||
maxPage: Int
|
|
||||||
): Int {
|
|
||||||
val lowerBound = min(minPage, maxPage)
|
|
||||||
val upperBound = max(minPage, maxPage)
|
|
||||||
val nextPage = currentPage.toLong() + delta.toLong()
|
|
||||||
|
|
||||||
return nextPage
|
|
||||||
.coerceIn(lowerBound.toLong(), upperBound.toLong())
|
|
||||||
.toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun readerSliderTogglePreferenceKey(bookId: String): String =
|
internal fun readerSliderTogglePreferenceKey(bookId: String): String =
|
||||||
READER_SLIDER_TOGGLE_PREFIX + bookId
|
READER_SLIDER_TOGGLE_PREFIX + bookId
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
|
@ -27,45 +27,45 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import org.dueattendant149.bookreader.data.CustomFontEntity
|
import com.aryan.reader.data.CustomFontEntity
|
||||||
import org.dueattendant149.bookreader.epubreader.FormatSettings as AndroidFormatSettings
|
import com.aryan.reader.epubreader.FormatSettings as AndroidFormatSettings
|
||||||
import org.dueattendant149.bookreader.epubreader.ReaderFont as AndroidReaderFont
|
import com.aryan.reader.epubreader.ReaderFont as AndroidReaderFont
|
||||||
import org.dueattendant149.bookreader.epubreader.ReaderTextAlign as AndroidReaderTextAlign
|
import com.aryan.reader.epubreader.ReaderTextAlign as AndroidReaderTextAlign
|
||||||
import org.dueattendant149.bookreader.epubreader.loadFormatSettings
|
import com.aryan.reader.epubreader.loadFormatSettings
|
||||||
import org.dueattendant149.bookreader.epubreader.loadPageInfoMode
|
import com.aryan.reader.epubreader.loadPageInfoMode
|
||||||
import org.dueattendant149.bookreader.epubreader.loadPageInfoPosition
|
import com.aryan.reader.epubreader.loadPageInfoPosition
|
||||||
import org.dueattendant149.bookreader.epubreader.loadPullToTurn
|
import com.aryan.reader.epubreader.loadPullToTurn
|
||||||
import org.dueattendant149.bookreader.epubreader.loadPullToTurnMultiplier
|
import com.aryan.reader.epubreader.loadPullToTurnMultiplier
|
||||||
import org.dueattendant149.bookreader.epubreader.loadSystemUiMode
|
import com.aryan.reader.epubreader.loadSystemUiMode
|
||||||
import org.dueattendant149.bookreader.epubreader.savePageInfoMode
|
import com.aryan.reader.epubreader.savePageInfoMode
|
||||||
import org.dueattendant149.bookreader.epubreader.savePageInfoPosition
|
import com.aryan.reader.epubreader.savePageInfoPosition
|
||||||
import org.dueattendant149.bookreader.epubreader.savePullToTurn
|
import com.aryan.reader.epubreader.savePullToTurn
|
||||||
import org.dueattendant149.bookreader.epubreader.savePullToTurnMultiplier
|
import com.aryan.reader.epubreader.savePullToTurnMultiplier
|
||||||
import org.dueattendant149.bookreader.epubreader.saveReaderSettings
|
import com.aryan.reader.epubreader.saveReaderSettings
|
||||||
import org.dueattendant149.bookreader.epubreader.saveSystemUiMode
|
import com.aryan.reader.epubreader.saveSystemUiMode
|
||||||
import org.dueattendant149.bookreader.pdf.savePdfSystemUiMode
|
import com.aryan.reader.pdf.savePdfSystemUiMode
|
||||||
import org.dueattendant149.bookreader.pdf.savePdfThemeId
|
import com.aryan.reader.pdf.savePdfThemeId
|
||||||
import org.dueattendant149.bookreader.pdf.savePdfVerticalPageGapVisible
|
import com.aryan.reader.pdf.savePdfVerticalPageGapVisible
|
||||||
import org.dueattendant149.bookreader.pdf.savePdfPageNumberOverlayVisible
|
import com.aryan.reader.pdf.savePdfPageNumberOverlayVisible
|
||||||
import org.dueattendant149.bookreader.pdf.loadPdfSystemUiMode
|
import com.aryan.reader.pdf.loadPdfSystemUiMode
|
||||||
import org.dueattendant149.bookreader.pdf.loadPdfThemeId
|
import com.aryan.reader.pdf.loadPdfThemeId
|
||||||
import org.dueattendant149.bookreader.pdf.loadPdfVerticalPageGapVisible
|
import com.aryan.reader.pdf.loadPdfVerticalPageGapVisible
|
||||||
import org.dueattendant149.bookreader.pdf.loadPdfPageNumberOverlayVisible
|
import com.aryan.reader.pdf.loadPdfPageNumberOverlayVisible
|
||||||
import org.dueattendant149.bookreader.shared.BuiltInPdfReaderThemes
|
import com.aryan.reader.shared.BuiltInPdfReaderThemes
|
||||||
import org.dueattendant149.bookreader.shared.CustomFontItem
|
import com.aryan.reader.shared.CustomFontItem
|
||||||
import org.dueattendant149.bookreader.shared.SharedSettingsAction
|
import com.aryan.reader.shared.SharedSettingsAction
|
||||||
import org.dueattendant149.bookreader.shared.SharedSettingsDestination
|
import com.aryan.reader.shared.SharedSettingsDestination
|
||||||
import org.dueattendant149.bookreader.shared.parentDestination
|
import com.aryan.reader.shared.parentDestination
|
||||||
import org.dueattendant149.bookreader.shared.toReaderSettingsFontFamily
|
import com.aryan.reader.shared.toReaderSettingsFontFamily
|
||||||
import org.dueattendant149.bookreader.shared.toSharedReaderTextAlign
|
import com.aryan.reader.shared.toSharedReaderTextAlign
|
||||||
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
|
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||||
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
import com.aryan.reader.shared.reader.ReaderSettings
|
||||||
import org.dueattendant149.bookreader.shared.reader.SharedReaderTextAlign
|
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||||
import org.dueattendant149.bookreader.shared.readerThemeById
|
import com.aryan.reader.shared.readerThemeById
|
||||||
import org.dueattendant149.bookreader.shared.sharedSettingsHubModel
|
import com.aryan.reader.shared.sharedSettingsHubModel
|
||||||
import org.dueattendant149.bookreader.shared.toReaderSettings
|
import com.aryan.reader.shared.toReaderSettings
|
||||||
import org.dueattendant149.bookreader.shared.ui.SharedSettingsHub
|
import com.aryan.reader.shared.ui.SharedSettingsHub
|
||||||
import org.dueattendant149.bookreader.tts.loadTtsMode
|
import com.aryan.reader.tts.loadTtsMode
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
// SharedComposables.kt
|
// SharedComposables.kt
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
|
@ -43,7 +43,7 @@ import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import org.dueattendant149.bookreader.data.TagEntity
|
import com.aryan.reader.data.TagEntity
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -87,7 +87,6 @@ import androidx.compose.material.icons.filled.PushPin
|
||||||
import androidx.compose.material.icons.filled.Restore
|
import androidx.compose.material.icons.filled.Restore
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.material.icons.filled.Save
|
||||||
import androidx.compose.material.icons.filled.SelectAll
|
import androidx.compose.material.icons.filled.SelectAll
|
||||||
import androidx.compose.material.icons.filled.Share
|
|
||||||
import androidx.compose.material.icons.outlined.FileOpen
|
import androidx.compose.material.icons.outlined.FileOpen
|
||||||
import androidx.compose.material.icons.outlined.Gavel
|
import androidx.compose.material.icons.outlined.Gavel
|
||||||
import androidx.compose.material.icons.outlined.Policy
|
import androidx.compose.material.icons.outlined.Policy
|
||||||
|
|
@ -143,14 +142,10 @@ import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.core.text.HtmlCompat
|
import androidx.core.text.HtmlCompat
|
||||||
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
import com.aryan.reader.data.BookMetadataEdit
|
||||||
import org.dueattendant149.bookreader.shared.SharedLegalLinks
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import org.dueattendant149.bookreader.shared.SharedLegalProfile
|
import com.aryan.reader.shared.SharedText
|
||||||
import org.dueattendant149.bookreader.data.BookMetadataEdit
|
import com.aryan.reader.shared.ui.SharedMarkdownText
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
|
||||||
import org.dueattendant149.bookreader.shared.SharedText
|
|
||||||
import org.dueattendant149.bookreader.shared.sharedLegalLinksForProfile
|
|
||||||
import org.dueattendant149.bookreader.shared.ui.SharedMarkdownText
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
@ -159,25 +154,9 @@ import kotlin.math.log10
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
internal fun legalLinksForAndroidFlavor(flavor: String = BuildConfig.FLAVOR): SharedLegalLinks {
|
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
|
||||||
val profile = if (flavor == "oss") SharedLegalProfile.OSS else SharedLegalProfile.STANDARD
|
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
|
||||||
return sharedLegalLinksForProfile(profile)
|
internal const val LICENSES_URL = "https://aryan-raj3112.github.io/reader-policy/licenses.html"
|
||||||
}
|
|
||||||
|
|
||||||
internal val PRIVACY_POLICY_URL: String get() = legalLinksForAndroidFlavor().privacyPolicyUrl
|
|
||||||
internal val TERMS_URL: String get() = legalLinksForAndroidFlavor().termsUrl
|
|
||||||
internal val LICENSES_URL: String get() = legalLinksForAndroidFlavor().licensesUrl
|
|
||||||
|
|
||||||
fun supportedFontMimeTypes(): Array<String> = arrayOf(
|
|
||||||
"font/ttf",
|
|
||||||
"font/otf",
|
|
||||||
"font/woff2",
|
|
||||||
"application/x-font-ttf",
|
|
||||||
"application/x-font-otf",
|
|
||||||
"application/font-woff2",
|
|
||||||
"application/vnd.ms-opentype",
|
|
||||||
"application/x-font-opentype"
|
|
||||||
)
|
|
||||||
|
|
||||||
class CustomTabUriHandler(private val context: Context) : UriHandler {
|
class CustomTabUriHandler(private val context: Context) : UriHandler {
|
||||||
override fun openUri(uri: String) {
|
override fun openUri(uri: String) {
|
||||||
|
|
@ -276,8 +255,6 @@ fun ContextualTopAppBar(
|
||||||
selectedItemCount: Int,
|
selectedItemCount: Int,
|
||||||
onNavIconClick: () -> Unit,
|
onNavIconClick: () -> Unit,
|
||||||
onInfoClick: (() -> Unit)? = null,
|
onInfoClick: (() -> Unit)? = null,
|
||||||
onSaveClick: (() -> Unit)? = null,
|
|
||||||
onShareClick: (() -> Unit)? = null,
|
|
||||||
onTagClick: (() -> Unit)? = null,
|
onTagClick: (() -> Unit)? = null,
|
||||||
onSelectAllClick: (() -> Unit)? = null,
|
onSelectAllClick: (() -> Unit)? = null,
|
||||||
onPinClick: (() -> Unit)? = null,
|
onPinClick: (() -> Unit)? = null,
|
||||||
|
|
@ -306,16 +283,6 @@ fun ContextualTopAppBar(
|
||||||
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.info))
|
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.info))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (selectedItemCount == 1 && onSaveClick != null) {
|
|
||||||
IconButton(onClick = onSaveClick) {
|
|
||||||
Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.action_save_copy_to_device))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (selectedItemCount == 1 && onShareClick != null) {
|
|
||||||
IconButton(onClick = onShareClick) {
|
|
||||||
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.action_share))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (onSelectAllClick != null) {
|
if (onSelectAllClick != null) {
|
||||||
IconButton(onClick = onSelectAllClick) {
|
IconButton(onClick = onSelectAllClick) {
|
||||||
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all))
|
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all))
|
||||||
|
|
@ -1270,55 +1237,53 @@ fun AboutDialog(onDismiss: () -> Unit) {
|
||||||
subtitle = stringResource(R.string.about_github_desc),
|
subtitle = stringResource(R.string.about_github_desc),
|
||||||
onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme") }
|
onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme") }
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
AboutInfoRow(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Policy,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = stringResource(R.string.legal_privacy_policy),
|
||||||
|
subtitle = stringResource(R.string.about_privacy_desc),
|
||||||
|
onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
AboutInfoRow(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Gavel,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = stringResource(R.string.legal_terms_of_service),
|
||||||
|
subtitle = stringResource(R.string.about_terms_desc),
|
||||||
|
onClick = { uriHandler.openUri(TERMS_URL) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
AboutInfoRow(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.FileOpen,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = stringResource(R.string.legal_licenses),
|
||||||
|
subtitle = stringResource(R.string.about_licenses_desc),
|
||||||
|
onClick = { uriHandler.openUri(LICENSES_URL) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
AboutInfoRow(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Policy,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = stringResource(R.string.legal_privacy_policy),
|
|
||||||
subtitle = stringResource(R.string.about_privacy_desc),
|
|
||||||
onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
|
|
||||||
AboutInfoRow(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Gavel,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = stringResource(R.string.legal_terms_of_service),
|
|
||||||
subtitle = stringResource(R.string.about_terms_desc),
|
|
||||||
onClick = { uriHandler.openUri(TERMS_URL) }
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
|
|
||||||
AboutInfoRow(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.FileOpen,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = stringResource(R.string.legal_licenses),
|
|
||||||
subtitle = stringResource(R.string.about_licenses_desc),
|
|
||||||
onClick = { uriHandler.openUri(LICENSES_URL) }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
|
|
@ -1598,31 +1563,6 @@ fun RecentFileItem.isOpdsStream(): Boolean {
|
||||||
return uriString?.startsWith("opds-pse://") == true
|
return uriString?.startsWith("opds-pse://") == true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun RecentFileItem.canExportOriginalFile(): Boolean {
|
|
||||||
return uriString != null && !isOpdsStream()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun RecentFileItem.suggestedOriginalFileName(): String {
|
|
||||||
val fallbackExtension = SharedFileCapabilities.primaryExtensionFor(type)
|
|
||||||
val baseName = displayName
|
|
||||||
.takeIf { it.isNotBlank() }
|
|
||||||
?: title?.takeIf { it.isNotBlank() }
|
|
||||||
?: "book"
|
|
||||||
val sanitized = baseName
|
|
||||||
.replace(Regex("""[\\/:*?"<>|]+"""), "_")
|
|
||||||
.trim()
|
|
||||||
.take(120)
|
|
||||||
.ifBlank { "book" }
|
|
||||||
return if (
|
|
||||||
fallbackExtension != null &&
|
|
||||||
!sanitized.endsWith(".$fallbackExtension", ignoreCase = true)
|
|
||||||
) {
|
|
||||||
"$sanitized.$fallbackExtension"
|
|
||||||
} else {
|
|
||||||
sanitized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun statusBadgeColors(overlay: Boolean): Pair<Color, Color> {
|
private fun statusBadgeColors(overlay: Boolean): Pair<Color, Color> {
|
||||||
val container = if (overlay) {
|
val container = if (overlay) {
|
||||||
|
|
@ -1,22 +1,20 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import org.dueattendant149.bookreader.data.BookShelfCrossRef
|
import com.aryan.reader.data.BookShelfCrossRef
|
||||||
import org.dueattendant149.bookreader.data.BookTagCrossRef
|
import com.aryan.reader.data.BookTagCrossRef
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import org.dueattendant149.bookreader.data.ShelfEntity
|
import com.aryan.reader.data.ShelfEntity
|
||||||
import org.dueattendant149.bookreader.data.TagEntity
|
import com.aryan.reader.data.TagEntity
|
||||||
import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem
|
import com.aryan.reader.shared.BookItem as SharedBookItem
|
||||||
import org.dueattendant149.bookreader.shared.BookShelfRef as SharedBookShelfRef
|
import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
|
||||||
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
|
import com.aryan.reader.shared.EpubAnnotationSerializer
|
||||||
import org.dueattendant149.bookreader.shared.FileType as SharedFileType
|
import com.aryan.reader.shared.FileType as SharedFileType
|
||||||
import org.dueattendant149.bookreader.shared.LibraryFilters as SharedLibraryFilters
|
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
|
||||||
import org.dueattendant149.bookreader.shared.ReaderLocator as SharedReaderLocator
|
import com.aryan.reader.shared.SharedReaderScreenState
|
||||||
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
import com.aryan.reader.shared.Shelf as SharedShelf
|
||||||
import org.dueattendant149.bookreader.shared.Shelf as SharedShelf
|
import com.aryan.reader.shared.ShelfRecord
|
||||||
import org.dueattendant149.bookreader.shared.ShelfRecord
|
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
|
||||||
import org.dueattendant149.bookreader.shared.SyncedFolder as SharedSyncedFolder
|
import com.aryan.reader.shared.Tag as SharedTag
|
||||||
import org.dueattendant149.bookreader.shared.Tag as SharedTag
|
|
||||||
import org.dueattendant149.bookreader.shared.toStablePositionCfi
|
|
||||||
|
|
||||||
fun FileType.toSharedFileType(): SharedFileType = this
|
fun FileType.toSharedFileType(): SharedFileType = this
|
||||||
|
|
||||||
|
|
@ -31,21 +29,11 @@ fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder = this
|
||||||
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this
|
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this
|
||||||
|
|
||||||
fun RecentFileItem.toSharedBookItem(): SharedBookItem {
|
fun RecentFileItem.toSharedBookItem(): SharedBookItem {
|
||||||
return toSharedBookItem(
|
|
||||||
displayName = customName ?: displayName,
|
|
||||||
includeReaderAnnotations = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun RecentFileItem.toSharedBookItem(
|
|
||||||
displayName: String,
|
|
||||||
includeReaderAnnotations: Boolean
|
|
||||||
): SharedBookItem {
|
|
||||||
return SharedBookItem(
|
return SharedBookItem(
|
||||||
id = bookId,
|
id = bookId,
|
||||||
path = uriString,
|
path = uriString,
|
||||||
type = type,
|
type = type,
|
||||||
displayName = displayName,
|
displayName = customName ?: displayName,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
coverImagePath = coverImagePath,
|
coverImagePath = coverImagePath,
|
||||||
title = title,
|
title = title,
|
||||||
|
|
@ -65,22 +53,13 @@ private fun RecentFileItem.toSharedBookItem(
|
||||||
seriesName = seriesName,
|
seriesName = seriesName,
|
||||||
seriesIndex = seriesIndex,
|
seriesIndex = seriesIndex,
|
||||||
lastPageIndex = lastPage,
|
lastPageIndex = lastPage,
|
||||||
readerPosition = toSharedReaderLocatorOrNull(),
|
|
||||||
tags = tags.map { it.toSharedTag() },
|
tags = tags.map { it.toSharedTag() },
|
||||||
readerHighlights = if (includeReaderAnnotations) {
|
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
|
||||||
EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
|
|
||||||
} else {
|
|
||||||
emptyList()
|
|
||||||
},
|
|
||||||
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem {
|
fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem {
|
||||||
return toSharedBookItem(
|
return toSharedBookItem().copy(displayName = displayName)
|
||||||
displayName = displayName,
|
|
||||||
includeReaderAnnotations = false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun SharedBookItem.toRecentFileItem(
|
fun SharedBookItem.toRecentFileItem(
|
||||||
|
|
@ -88,49 +67,36 @@ fun SharedBookItem.toRecentFileItem(
|
||||||
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
||||||
): RecentFileItem {
|
): RecentFileItem {
|
||||||
val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
|
val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
|
||||||
val positionCfi = readerPosition?.toSharedPositionCfi()
|
return androidBooksById[id]?.copy(tags = resolvedTags)
|
||||||
androidBooksById[id]?.let { existing ->
|
?.copy(
|
||||||
val mappedLastChapterIndex = readerPosition?.chapterIndex ?: existing.lastChapterIndex
|
|
||||||
val mappedLastPositionCfi = positionCfi ?: existing.lastPositionCfi
|
|
||||||
val mappedLocatorBlockIndex = readerPosition?.blockIndex ?: existing.locatorBlockIndex
|
|
||||||
val mappedLocatorCharOffset = readerPosition?.charOffset ?: existing.locatorCharOffset
|
|
||||||
|
|
||||||
if (
|
|
||||||
existing.uriString == path &&
|
|
||||||
existing.type == type &&
|
|
||||||
existing.timestamp == timestamp &&
|
|
||||||
existing.coverImagePath == coverImagePath &&
|
|
||||||
existing.title == title &&
|
|
||||||
existing.author == author &&
|
|
||||||
existing.description == description &&
|
|
||||||
existing.originalTitle == originalTitle &&
|
|
||||||
existing.originalAuthor == originalAuthor &&
|
|
||||||
existing.originalSeriesName == originalSeriesName &&
|
|
||||||
existing.originalSeriesIndex == originalSeriesIndex &&
|
|
||||||
existing.originalDescription == originalDescription &&
|
|
||||||
existing.lastPage == lastPageIndex &&
|
|
||||||
existing.progressPercentage == progressPercentage &&
|
|
||||||
existing.isRecent == isRecent &&
|
|
||||||
existing.sourceFolderUri == sourceFolder &&
|
|
||||||
existing.fileSize == fileSize &&
|
|
||||||
existing.fileContentModifiedTimestamp == fileContentModifiedTimestamp &&
|
|
||||||
existing.seriesName == seriesName &&
|
|
||||||
existing.seriesIndex == seriesIndex &&
|
|
||||||
existing.folderTextMetadataParsed == folderTextMetadataParsed &&
|
|
||||||
existing.lastChapterIndex == mappedLastChapterIndex &&
|
|
||||||
existing.lastPositionCfi == mappedLastPositionCfi &&
|
|
||||||
existing.locatorBlockIndex == mappedLocatorBlockIndex &&
|
|
||||||
existing.locatorCharOffset == mappedLocatorCharOffset &&
|
|
||||||
existing.readingPositionModifiedTimestamp == readingPositionModifiedTimestamp &&
|
|
||||||
existing.tags == resolvedTags
|
|
||||||
) {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
return existing.copy(
|
|
||||||
uriString = path,
|
uriString = path,
|
||||||
type = type,
|
type = type,
|
||||||
displayName = existing.displayName,
|
displayName = androidBooksById[id]?.displayName ?: displayName,
|
||||||
|
timestamp = timestamp,
|
||||||
|
coverImagePath = coverImagePath,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
description = description,
|
||||||
|
originalTitle = originalTitle,
|
||||||
|
originalAuthor = originalAuthor,
|
||||||
|
originalSeriesName = originalSeriesName,
|
||||||
|
originalSeriesIndex = originalSeriesIndex,
|
||||||
|
originalDescription = originalDescription,
|
||||||
|
lastPage = lastPageIndex,
|
||||||
|
progressPercentage = progressPercentage,
|
||||||
|
isRecent = isRecent,
|
||||||
|
sourceFolderUri = sourceFolder,
|
||||||
|
fileSize = fileSize,
|
||||||
|
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||||
|
seriesName = seriesName,
|
||||||
|
seriesIndex = seriesIndex,
|
||||||
|
folderTextMetadataParsed = folderTextMetadataParsed
|
||||||
|
)
|
||||||
|
?: RecentFileItem(
|
||||||
|
bookId = id,
|
||||||
|
uriString = path,
|
||||||
|
type = type,
|
||||||
|
displayName = displayName,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
coverImagePath = coverImagePath,
|
coverImagePath = coverImagePath,
|
||||||
title = title,
|
title = title,
|
||||||
|
|
@ -150,70 +116,8 @@ fun SharedBookItem.toRecentFileItem(
|
||||||
seriesName = seriesName,
|
seriesName = seriesName,
|
||||||
seriesIndex = seriesIndex,
|
seriesIndex = seriesIndex,
|
||||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||||
lastChapterIndex = mappedLastChapterIndex,
|
|
||||||
lastPositionCfi = mappedLastPositionCfi,
|
|
||||||
locatorBlockIndex = mappedLocatorBlockIndex,
|
|
||||||
locatorCharOffset = mappedLocatorCharOffset,
|
|
||||||
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
|
|
||||||
tags = resolvedTags
|
tags = resolvedTags
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
return RecentFileItem(
|
|
||||||
bookId = id,
|
|
||||||
uriString = path,
|
|
||||||
type = type,
|
|
||||||
displayName = displayName,
|
|
||||||
timestamp = timestamp,
|
|
||||||
coverImagePath = coverImagePath,
|
|
||||||
title = title,
|
|
||||||
author = author,
|
|
||||||
description = description,
|
|
||||||
originalTitle = originalTitle,
|
|
||||||
originalAuthor = originalAuthor,
|
|
||||||
originalSeriesName = originalSeriesName,
|
|
||||||
originalSeriesIndex = originalSeriesIndex,
|
|
||||||
originalDescription = originalDescription,
|
|
||||||
lastPage = lastPageIndex,
|
|
||||||
progressPercentage = progressPercentage,
|
|
||||||
isRecent = isRecent,
|
|
||||||
sourceFolderUri = sourceFolder,
|
|
||||||
fileSize = fileSize,
|
|
||||||
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
|
||||||
seriesName = seriesName,
|
|
||||||
seriesIndex = seriesIndex,
|
|
||||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
|
||||||
lastChapterIndex = readerPosition?.chapterIndex,
|
|
||||||
lastPositionCfi = positionCfi,
|
|
||||||
locatorBlockIndex = readerPosition?.blockIndex,
|
|
||||||
locatorCharOffset = readerPosition?.charOffset,
|
|
||||||
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
|
|
||||||
tags = resolvedTags
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun RecentFileItem.toSharedReaderLocatorOrNull(): SharedReaderLocator? {
|
|
||||||
if (
|
|
||||||
lastChapterIndex == null &&
|
|
||||||
lastPage == null &&
|
|
||||||
lastPositionCfi.isNullOrBlank() &&
|
|
||||||
locatorBlockIndex == null &&
|
|
||||||
locatorCharOffset == null
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return SharedReaderLocator.fromLegacy(
|
|
||||||
chapterIndex = lastChapterIndex,
|
|
||||||
cfi = lastPositionCfi,
|
|
||||||
pageIndex = lastPage
|
|
||||||
).withFallbacks(
|
|
||||||
blockIndex = locatorBlockIndex,
|
|
||||||
charOffset = locatorCharOffset
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun SharedReaderLocator.toSharedPositionCfi(): String? {
|
|
||||||
return toStablePositionCfi()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun TagEntity.toSharedTag(): SharedTag {
|
fun TagEntity.toSharedTag(): SharedTag {
|
||||||
|
|
@ -263,16 +167,8 @@ fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
|
||||||
|
|
||||||
fun ReaderScreenState.toSharedReaderScreenState(
|
fun ReaderScreenState.toSharedReaderScreenState(
|
||||||
rawBooks: List<RecentFileItem> = rawLibraryFiles,
|
rawBooks: List<RecentFileItem> = rawLibraryFiles,
|
||||||
dbTags: List<TagEntity> = allTags,
|
dbTags: List<TagEntity> = allTags
|
||||||
includeReaderAnnotations: Boolean = true
|
|
||||||
): SharedReaderScreenState {
|
): SharedReaderScreenState {
|
||||||
fun RecentFileItem.toStateSharedBookItem(): SharedBookItem {
|
|
||||||
return toSharedBookItem(
|
|
||||||
displayName = customName ?: displayName,
|
|
||||||
includeReaderAnnotations = includeReaderAnnotations
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return SharedReaderScreenState(
|
return SharedReaderScreenState(
|
||||||
selectedBookId = selectedBookId,
|
selectedBookId = selectedBookId,
|
||||||
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
|
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
|
||||||
|
|
@ -306,16 +202,16 @@ fun ReaderScreenState.toSharedReaderScreenState(
|
||||||
isSearchActive = isSearchActive,
|
isSearchActive = isSearchActive,
|
||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
reflowProgress = reflowProgress,
|
reflowProgress = reflowProgress,
|
||||||
recentBooks = recentFiles.map { it.toStateSharedBookItem() },
|
recentBooks = recentFiles.map { it.toSharedBookItem() },
|
||||||
libraryBooks = allRecentFiles.map { it.toStateSharedBookItem() },
|
libraryBooks = allRecentFiles.map { it.toSharedBookItem() },
|
||||||
rawLibraryBooks = rawBooks.map { it.toStateSharedBookItem() },
|
rawLibraryBooks = rawBooks.map { it.toSharedBookItem() },
|
||||||
pinnedHomeBookIds = pinnedHomeBookIds,
|
pinnedHomeBookIds = pinnedHomeBookIds,
|
||||||
pinnedLibraryBookIds = pinnedLibraryBookIds,
|
pinnedLibraryBookIds = pinnedLibraryBookIds,
|
||||||
libraryFilters = libraryFilters,
|
libraryFilters = libraryFilters,
|
||||||
recentFilesLimit = recentFilesLimit,
|
recentFilesLimit = recentFilesLimit,
|
||||||
isTabsEnabled = isTabsEnabled,
|
isTabsEnabled = isTabsEnabled,
|
||||||
openTabIds = openTabIds,
|
openTabIds = openTabIds,
|
||||||
openTabs = openTabs.map { it.toStateSharedBookItem() },
|
openTabs = openTabs.map { it.toSharedBookItem() },
|
||||||
activeTabBookId = activeTabBookId,
|
activeTabBookId = activeTabBookId,
|
||||||
showExternalFileSavePromptFor = showExternalFileSavePromptFor,
|
showExternalFileSavePromptFor = showExternalFileSavePromptFor,
|
||||||
externalFileBehavior = externalFileBehavior,
|
externalFileBehavior = externalFileBehavior,
|
||||||
|
|
@ -341,14 +237,7 @@ fun List<RecentFileItem>.withResolvedTags(
|
||||||
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
|
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
|
||||||
entry.value.mapNotNull { tagsById[it.tagId] }
|
entry.value.mapNotNull { tagsById[it.tagId] }
|
||||||
}
|
}
|
||||||
return map { item ->
|
return map { item -> item.copy(tags = bookTagsMap[item.bookId].orEmpty()) }
|
||||||
val resolvedTags = bookTagsMap[item.bookId].orEmpty()
|
|
||||||
if (item.tags == resolvedTags) {
|
|
||||||
item
|
|
||||||
} else {
|
|
||||||
item.copy(tags = resolvedTags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun SharedReaderScreenState.toAndroidReaderScreenState(
|
fun SharedReaderScreenState.toAndroidReaderScreenState(
|
||||||
|
|
@ -357,11 +246,8 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
|
||||||
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
||||||
): ReaderScreenState {
|
): ReaderScreenState {
|
||||||
val fallbackBooksById = rawLibraryBooks.associateBy { it.id }
|
val fallbackBooksById = rawLibraryBooks.associateBy { it.id }
|
||||||
val mappedBooksById = LinkedHashMap<String, RecentFileItem>()
|
|
||||||
fun SharedBookItem.toAndroidBook(): RecentFileItem {
|
fun SharedBookItem.toAndroidBook(): RecentFileItem {
|
||||||
return mappedBooksById.getOrPut(id) {
|
return toRecentFileItem(androidBooksById, tagEntitiesById)
|
||||||
toRecentFileItem(androidBooksById, tagEntitiesById)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fun bookById(bookId: String): RecentFileItem? {
|
fun bookById(bookId: String): RecentFileItem? {
|
||||||
return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook()
|
return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook()
|
||||||
|
|
@ -374,7 +260,7 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
|
||||||
isAddingBooksToShelf = isAddingBooksToShelf,
|
isAddingBooksToShelf = isAddingBooksToShelf,
|
||||||
contextualActionShelfIds = selectedShelfIds,
|
contextualActionShelfIds = selectedShelfIds,
|
||||||
contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) },
|
contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) },
|
||||||
shelves = shelves.map { shelf -> shelf.toAndroidShelf { book -> book.toAndroidBook() } },
|
shelves = shelves.map { it.toAndroidShelf(androidBooksById, tagEntitiesById) },
|
||||||
openTabs = openTabs.map { it.toAndroidBook() },
|
openTabs = openTabs.map { it.toAndroidBook() },
|
||||||
openTabIds = openTabIds,
|
openTabIds = openTabIds,
|
||||||
activeTabBookId = activeTabBookId,
|
activeTabBookId = activeTabBookId,
|
||||||
|
|
@ -386,19 +272,13 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
|
||||||
fun SharedShelf.toAndroidShelf(
|
fun SharedShelf.toAndroidShelf(
|
||||||
androidBooksById: Map<String, RecentFileItem> = emptyMap(),
|
androidBooksById: Map<String, RecentFileItem> = emptyMap(),
|
||||||
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
||||||
): Shelf {
|
|
||||||
return toAndroidShelf { it.toRecentFileItem(androidBooksById, tagEntitiesById) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun SharedShelf.toAndroidShelf(
|
|
||||||
resolveBook: (SharedBookItem) -> RecentFileItem
|
|
||||||
): Shelf {
|
): Shelf {
|
||||||
return Shelf(
|
return Shelf(
|
||||||
id = id,
|
id = id,
|
||||||
name = name,
|
name = name,
|
||||||
type = type,
|
type = type,
|
||||||
books = books.map(resolveBook),
|
books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
|
||||||
directBooks = directBooks.map(resolveBook),
|
directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
|
||||||
parentShelfId = parentShelfId,
|
parentShelfId = parentShelfId,
|
||||||
childShelfIds = childShelfIds,
|
childShelfIds = childShelfIds,
|
||||||
depth = depth,
|
depth = depth,
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// StorageTracker.kt
|
// StorageTracker.kt
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
|
@ -31,7 +31,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import org.dueattendant149.bookreader.paginatedreader.TtsChunk
|
import com.aryan.reader.paginatedreader.TtsChunk
|
||||||
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementEngine
|
import com.aryan.reader.shared.ReaderTtsReplacementEngine
|
||||||
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences
|
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||||
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferencesJson
|
import com.aryan.reader.shared.ReaderTtsReplacementPreferencesJson
|
||||||
|
|
||||||
private const val READER_PREFS_NAME = "reader_prefs"
|
private const val READER_PREFS_NAME = "reader_prefs"
|
||||||
private const val TTS_REPLACEMENTS_KEY = "tts_word_replacements_json"
|
private const val TTS_REPLACEMENTS_KEY = "tts_word_replacements_json"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -56,11 +56,11 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementBookSettings
|
import com.aryan.reader.shared.ReaderTtsReplacementBookSettings
|
||||||
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementEngine
|
import com.aryan.reader.shared.ReaderTtsReplacementEngine
|
||||||
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences
|
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||||
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementRule
|
import com.aryan.reader.shared.ReaderTtsReplacementRule
|
||||||
import org.dueattendant149.bookreader.shared.ReaderTtsReplacementSuggestions
|
import com.aryan.reader.shared.ReaderTtsReplacementSuggestions
|
||||||
|
|
||||||
private enum class TtsReplacementScope {
|
private enum class TtsReplacementScope {
|
||||||
Global,
|
Global,
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader
|
package com.aryan.reader
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import java.text.Normalizer
|
import java.text.Normalizer
|
||||||
|
|
@ -67,8 +67,7 @@ val supportedAppLanguageOptions = listOf(
|
||||||
"中文",
|
"中文",
|
||||||
"简体中文",
|
"简体中文",
|
||||||
)
|
)
|
||||||
),
|
)
|
||||||
AppLanguageOption("et", R.string.language_estonian, listOf("estonian", "eesti"))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions
|
val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
package org.dueattendant149.bookreader.data
|
package com.aryan.reader.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
|
|
@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
TagEntity::class,
|
TagEntity::class,
|
||||||
BookTagCrossRef::class
|
BookTagCrossRef::class
|
||||||
],
|
],
|
||||||
version = 23,
|
version = 22,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(FileTypeConverter::class)
|
@TypeConverters(FileTypeConverter::class)
|
||||||
|
|
@ -288,25 +288,6 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val MIGRATION_22_23 = object : Migration(22, 23) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE recent_files ADD COLUMN readingPositionModifiedTimestamp INTEGER NOT NULL DEFAULT 0")
|
|
||||||
db.execSQL("""
|
|
||||||
UPDATE recent_files
|
|
||||||
SET readingPositionModifiedTimestamp = lastModifiedTimestamp
|
|
||||||
WHERE lastModifiedTimestamp > 0
|
|
||||||
AND (
|
|
||||||
lastChapterIndex IS NOT NULL OR
|
|
||||||
lastPage IS NOT NULL OR
|
|
||||||
lastPositionCfi IS NOT NULL OR
|
|
||||||
locatorBlockIndex IS NOT NULL OR
|
|
||||||
locatorCharOffset IS NOT NULL OR
|
|
||||||
COALESCE(progressPercentage, 0) > 0
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDatabase(context: Context): AppDatabase {
|
fun getDatabase(context: Context): AppDatabase {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
val instance = Room.databaseBuilder(
|
val instance = Room.databaseBuilder(
|
||||||
|
|
@ -320,7 +301,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
||||||
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
|
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
|
||||||
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20,
|
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20,
|
||||||
MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23
|
MIGRATION_20_21, MIGRATION_21_22
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration(false)
|
.fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.dueattendant149.bookreader.data
|
package com.aryan.reader.data
|
||||||
|
|
||||||
data class BookMetadataEdit(
|
data class BookMetadataEdit(
|
||||||
val title: String?,
|
val title: String?,
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
package org.dueattendant149.bookreader.data
|
package com.aryan.reader.data
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
package org.dueattendant149.bookreader.data
|
package com.aryan.reader.data
|
||||||
|
|
||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
|
|
@ -17,10 +17,10 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
package org.dueattendant149.bookreader.data
|
package com.aryan.reader.data
|
||||||
|
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import org.dueattendant149.bookreader.FileType
|
import com.aryan.reader.FileType
|
||||||
|
|
||||||
class FileTypeConverter {
|
class FileTypeConverter {
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue