Linux support (#381)
* Add desktop release CI and support for Arch Linux packaging * Make Gradle wrapper executable in desktop-release workflow * Make Gradle wrapper executable in desktop-release workflow * Make Gradle wrapper executable in desktop-release workflow * Configure Gradle and update Java environment in desktop-release workflow * Update Java setup and AUR packaging in desktop release workflow * Update Java setup and AUR packaging in desktop release workflow * Add MSIX packaging support for Windows desktop distribution * Update AUR packaging metadata and validation * Use spine toc attribute for NCX resolution * crash fixes * Implement automatic discovery and injection of EPUB font face siblings * Enhance custom font support with family grouping and variable font handling * Optimize metadata loading and improve TTS highlighting * Add keyboard navigation support for EPUB reader * Refine PDF spread page sizing to respect aspect ratios * Implement responsive maximum height for reader popups and sheets * Handle TTS generation failures by skipping problematic chunks * Refactor PDF tile rendering logic and zoom indicator behavior * Prefer block and offset locators over page index in native vertical flow * Implement save and share actions for original book files * Add Estonian language support * Implement temporary viewing mode for external files * Implement direct opening for temporary external files without library persistence * fix failing tests * Import SharedFileCapabilities in DesktopLibraryUi * Improve native vertical reader progress, persistence, and image support * Center target in viewport for native vertical reader and support animated scrolling
This commit is contained in:
parent
a13d6599d1
commit
625a4d5d2e
102 changed files with 6012 additions and 687 deletions
409
.github/workflows/desktop-release.yml
vendored
Normal file
409
.github/workflows/desktop-release.yml
vendored
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
name: Desktop release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Desktop version, for example 1.0.1
|
||||||
|
required: true
|
||||||
|
default: "1.0.1"
|
||||||
|
release_tag:
|
||||||
|
description: GitHub release tag for desktop artifacts
|
||||||
|
required: true
|
||||||
|
default: "v1.0.1"
|
||||||
|
pdfium_tag:
|
||||||
|
description: GitHub release tag containing Pdfium zip assets
|
||||||
|
required: true
|
||||||
|
default: "pdfium-desktop-v1"
|
||||||
|
draft:
|
||||||
|
description: Create or update the desktop release as a draft
|
||||||
|
required: true
|
||||||
|
default: "true"
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- "true"
|
||||||
|
- "false"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
DESKTOP_FIREBASE_PROJECT_ID: ${{ secrets.DESKTOP_FIREBASE_PROJECT_ID }}
|
||||||
|
DESKTOP_FIREBASE_WEB_API_KEY: ${{ secrets.DESKTOP_FIREBASE_WEB_API_KEY }}
|
||||||
|
DESKTOP_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.DESKTOP_GOOGLE_OAUTH_CLIENT_ID }}
|
||||||
|
DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
windows-msi:
|
||||||
|
name: Windows MSI (${{ matrix.flavor.label }})
|
||||||
|
runs-on: windows-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
flavor:
|
||||||
|
- label: standard
|
||||||
|
gradle: standard
|
||||||
|
- label: oss
|
||||||
|
gradle: oss
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure Gradle
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
@'
|
||||||
|
kotlin.code.style=official
|
||||||
|
kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
|
||||||
|
kotlin.mpp.applyDefaultHierarchyTemplate=false
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.configuration-cache=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.workers.max=2
|
||||||
|
'@ | Set-Content -Path gradle.properties -NoNewline -Encoding UTF8
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
|
||||||
|
- name: Download Pdfium
|
||||||
|
shell: pwsh
|
||||||
|
run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Install WiX
|
||||||
|
shell: pwsh
|
||||||
|
run: choco install wixtoolset --no-progress -y
|
||||||
|
|
||||||
|
- name: Build MSI
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
DESKTOP_FLAVOR: ${{ matrix.flavor.gradle }}
|
||||||
|
DESKTOP_VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
$gradleArgs = @(
|
||||||
|
"-PdesktopOnly=true",
|
||||||
|
"-PdesktopFlavor=$env:DESKTOP_FLAVOR",
|
||||||
|
"-PdesktopVersion=$env:DESKTOP_VERSION",
|
||||||
|
"-PdesktopPackageVersion=$env:DESKTOP_VERSION",
|
||||||
|
":desktopApp:packageReleaseMsi"
|
||||||
|
)
|
||||||
|
./gradlew.bat @gradleArgs
|
||||||
|
|
||||||
|
- name: Stage MSI
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Force release-assets | Out-Null
|
||||||
|
$source = Get-ChildItem desktopApp/build/compose/binaries/main-release/msi -Filter *.msi | Select-Object -First 1
|
||||||
|
if ($null -eq $source) { throw "MSI output not found" }
|
||||||
|
Copy-Item $source.FullName "release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-windows-x64.msi"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-msi-${{ matrix.flavor.label }}
|
||||||
|
path: release-assets/*
|
||||||
|
|
||||||
|
ubuntu-deb-tar:
|
||||||
|
name: Ubuntu DEB and tar (${{ matrix.flavor.label }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
flavor:
|
||||||
|
- label: standard
|
||||||
|
gradle: standard
|
||||||
|
- label: oss
|
||||||
|
gradle: oss
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure Gradle
|
||||||
|
run: |
|
||||||
|
cat > gradle.properties <<'EOF'
|
||||||
|
kotlin.code.style=official
|
||||||
|
kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
|
||||||
|
kotlin.mpp.applyDefaultHierarchyTemplate=false
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.configuration-cache=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.workers.max=2
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
|
||||||
|
- name: Make Gradle wrapper executable
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: Install packaging tools
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y fakeroot dpkg-dev binutils
|
||||||
|
|
||||||
|
- name: Download Pdfium
|
||||||
|
shell: pwsh
|
||||||
|
run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Build DEB and Linux tarball
|
||||||
|
run: >
|
||||||
|
./gradlew
|
||||||
|
-PdesktopOnly=true
|
||||||
|
-PdesktopFlavor=${{ matrix.flavor.gradle }}
|
||||||
|
-PdesktopVersion=${{ inputs.version }}
|
||||||
|
-PdesktopPackageVersion=${{ inputs.version }}
|
||||||
|
:desktopApp:packageDeb
|
||||||
|
:desktopApp:packageLinuxTar
|
||||||
|
|
||||||
|
- name: Stage DEB and tarball
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p release-assets
|
||||||
|
cp "$(find desktopApp/build/compose/binaries/main/deb -name '*.deb' -print -quit)" \
|
||||||
|
"release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-amd64.deb"
|
||||||
|
cp "$(find desktopApp/build/compose/binaries/main/linux-tar -name '*.tar.gz' -print -quit)" \
|
||||||
|
"release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x64.tar.gz"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ubuntu-deb-tar-${{ matrix.flavor.label }}
|
||||||
|
path: release-assets/*
|
||||||
|
|
||||||
|
fedora-rpm:
|
||||||
|
name: Fedora RPM (${{ matrix.flavor.label }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
flavor:
|
||||||
|
- label: standard
|
||||||
|
gradle: standard
|
||||||
|
- label: oss
|
||||||
|
gradle: oss
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure Gradle
|
||||||
|
run: |
|
||||||
|
cat > gradle.properties <<'EOF'
|
||||||
|
kotlin.code.style=official
|
||||||
|
kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
|
||||||
|
kotlin.mpp.applyDefaultHierarchyTemplate=false
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.configuration-cache=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.workers.max=2
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
|
||||||
|
- name: Make Gradle wrapper executable
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: Download Pdfium
|
||||||
|
shell: pwsh
|
||||||
|
run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Build RPM in Fedora
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/workspace" \
|
||||||
|
-v "$JAVA_HOME:/host-jdk-21:ro" \
|
||||||
|
-w /workspace \
|
||||||
|
-e DESKTOP_FIREBASE_PROJECT_ID \
|
||||||
|
-e DESKTOP_FIREBASE_WEB_API_KEY \
|
||||||
|
-e DESKTOP_GOOGLE_OAUTH_CLIENT_ID \
|
||||||
|
-e DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET \
|
||||||
|
fedora:44 bash -lc '
|
||||||
|
set -euo pipefail
|
||||||
|
dnf install -y ca-certificates findutils git rpm-build unzip which
|
||||||
|
update-ca-trust
|
||||||
|
export JAVA_HOME=/host-jdk-21
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
if [ -f /etc/pki/java/cacerts ]; then
|
||||||
|
export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/etc/pki/java/cacerts ${JAVA_TOOL_OPTIONS:-}"
|
||||||
|
elif [ -f /etc/pki/ca-trust/extracted/java/cacerts ]; then
|
||||||
|
export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/etc/pki/ca-trust/extracted/java/cacerts ${JAVA_TOOL_OPTIONS:-}"
|
||||||
|
fi
|
||||||
|
JPACKAGE_HOME="$JAVA_HOME"
|
||||||
|
./gradlew \
|
||||||
|
-PdesktopOnly=true \
|
||||||
|
-PdesktopFlavor=${{ matrix.flavor.gradle }} \
|
||||||
|
-PdesktopVersion=${{ inputs.version }} \
|
||||||
|
-PdesktopPackageVersion=${{ inputs.version }} \
|
||||||
|
-PdesktopPackagingJavaHome="$JPACKAGE_HOME" \
|
||||||
|
:desktopApp:packageRpm
|
||||||
|
'
|
||||||
|
sudo chown -R "$USER:$USER" desktopApp/build
|
||||||
|
|
||||||
|
- name: Stage RPM
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p release-assets
|
||||||
|
cp "$(find desktopApp/build/compose/binaries/main/rpm -name '*.rpm' -print -quit)" \
|
||||||
|
"release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x86_64.rpm"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: fedora-rpm-${{ matrix.flavor.label }}
|
||||||
|
path: release-assets/*
|
||||||
|
|
||||||
|
arch-package:
|
||||||
|
name: Arch package (${{ matrix.flavor.label }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
flavor:
|
||||||
|
- label: standard
|
||||||
|
gradle: standard
|
||||||
|
aur: episteme-bin
|
||||||
|
- label: oss
|
||||||
|
gradle: oss
|
||||||
|
aur: episteme-oss-bin
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure Gradle
|
||||||
|
run: |
|
||||||
|
cat > gradle.properties <<'EOF'
|
||||||
|
kotlin.code.style=official
|
||||||
|
kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
|
||||||
|
kotlin.mpp.applyDefaultHierarchyTemplate=false
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.configuration-cache=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.workers.max=2
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Make Gradle wrapper executable
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: Download Pdfium
|
||||||
|
shell: pwsh
|
||||||
|
run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Build Arch package
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/workspace" \
|
||||||
|
-w /workspace \
|
||||||
|
-e DESKTOP_FIREBASE_PROJECT_ID \
|
||||||
|
-e DESKTOP_FIREBASE_WEB_API_KEY \
|
||||||
|
-e DESKTOP_GOOGLE_OAUTH_CLIENT_ID \
|
||||||
|
-e DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET \
|
||||||
|
archlinux:latest bash -lc '
|
||||||
|
set -euo pipefail
|
||||||
|
pacman -Syu --noconfirm
|
||||||
|
pacman -S --needed --noconfirm base-devel desktop-file-utils git jdk21-openjdk namcap unzip
|
||||||
|
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
useradd -m builder
|
||||||
|
chown -R builder:builder /workspace
|
||||||
|
runuser -u builder -- env \
|
||||||
|
JAVA_HOME="$JAVA_HOME" \
|
||||||
|
PATH="$PATH" \
|
||||||
|
DESKTOP_FIREBASE_PROJECT_ID="$DESKTOP_FIREBASE_PROJECT_ID" \
|
||||||
|
DESKTOP_FIREBASE_WEB_API_KEY="$DESKTOP_FIREBASE_WEB_API_KEY" \
|
||||||
|
DESKTOP_GOOGLE_OAUTH_CLIENT_ID="$DESKTOP_GOOGLE_OAUTH_CLIENT_ID" \
|
||||||
|
DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET="$DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET" \
|
||||||
|
bash -lc "cd /workspace && ./gradlew \
|
||||||
|
-PdesktopOnly=true \
|
||||||
|
-PdesktopFlavor=${{ matrix.flavor.gradle }} \
|
||||||
|
-PdesktopVersion=${{ inputs.version }} \
|
||||||
|
-PdesktopPackageVersion=${{ inputs.version }} \
|
||||||
|
-PdesktopAurPackageRelease=1 \
|
||||||
|
:desktopApp:prepareAurPackage && \
|
||||||
|
cd desktopApp/build/aur/${{ matrix.flavor.aur }} && \
|
||||||
|
makepkg -sf --cleanbuild --nodeps && \
|
||||||
|
makepkg --printsrcinfo > .SRCINFO.generated && \
|
||||||
|
cmp -s .SRCINFO .SRCINFO.generated && \
|
||||||
|
pkgfile=\$(find . -maxdepth 1 -name '*.pkg.tar.zst' -print -quit) && \
|
||||||
|
desktop_file=${{ matrix.flavor.aur }} && \
|
||||||
|
desktop_file=\${desktop_file%-bin}.desktop && \
|
||||||
|
tmpdir=\$(mktemp -d) && \
|
||||||
|
bsdtar -xOf \"\$pkgfile\" \"usr/share/applications/\$desktop_file\" > \"\$tmpdir/\$desktop_file\" && \
|
||||||
|
desktop-file-validate \"\$tmpdir/\$desktop_file\" && \
|
||||||
|
namcap PKGBUILD && \
|
||||||
|
namcap \"\$pkgfile\" && \
|
||||||
|
cd /workspace && \
|
||||||
|
mkdir -p release-assets && \
|
||||||
|
cp \$(find desktopApp/build/aur/${{ matrix.flavor.aur }} -maxdepth 1 -name '*.pkg.tar.zst' -print -quit) \
|
||||||
|
release-assets/${{ matrix.flavor.aur }}-${{ inputs.version }}-1-x86_64.pkg.tar.zst && \
|
||||||
|
./gradlew \
|
||||||
|
-PdesktopOnly=true \
|
||||||
|
-PdesktopFlavor=${{ matrix.flavor.gradle }} \
|
||||||
|
-PdesktopVersion=${{ inputs.version }} \
|
||||||
|
-PdesktopPackageVersion=${{ inputs.version }} \
|
||||||
|
-PdesktopAurPackageRelease=1 \
|
||||||
|
-PdesktopAurSourceUrl=https://github.com/${{ github.repository }}/releases/download/${{ inputs.release_tag }}/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x64.tar.gz \
|
||||||
|
:desktopApp:prepareAurPackage && \
|
||||||
|
cd desktopApp/build/aur/${{ matrix.flavor.aur }} && \
|
||||||
|
makepkg --printsrcinfo > .SRCINFO.generated && \
|
||||||
|
cmp -s .SRCINFO .SRCINFO.generated"
|
||||||
|
'
|
||||||
|
sudo chown -R "$USER:$USER" desktopApp/build release-assets
|
||||||
|
|
||||||
|
- name: Stage Arch package and AUR metadata
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p release-assets
|
||||||
|
tar -C "desktopApp/build/aur/${{ matrix.flavor.aur }}" \
|
||||||
|
-czf "release-assets/aur-${{ matrix.flavor.aur }}-${{ inputs.version }}.tar.gz" \
|
||||||
|
PKGBUILD .SRCINFO
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: arch-package-${{ matrix.flavor.label }}
|
||||||
|
path: release-assets/*
|
||||||
|
|
||||||
|
publish-release:
|
||||||
|
name: Publish draft release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- windows-msi
|
||||||
|
- ubuntu-deb-tar
|
||||||
|
- fedora-rpm
|
||||||
|
- arch-package
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: downloaded-artifacts
|
||||||
|
|
||||||
|
- name: Collect release assets
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p release-assets
|
||||||
|
find downloaded-artifacts -type f -exec cp {} release-assets/ \;
|
||||||
|
(cd release-assets && sha256sum * > SHA256SUMS.txt)
|
||||||
|
|
||||||
|
- name: Create or update GitHub release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if ! gh release view "${{ inputs.release_tag }}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||||
|
draft_flag=""
|
||||||
|
if [ "${{ inputs.draft }}" = "true" ]; then
|
||||||
|
draft_flag="--draft"
|
||||||
|
fi
|
||||||
|
gh release create "${{ inputs.release_tag }}" \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--title "Episteme ${{ inputs.version }}" \
|
||||||
|
--notes "Desktop release ${{ inputs.version }}" \
|
||||||
|
$draft_flag
|
||||||
|
fi
|
||||||
|
gh release upload "${{ inputs.release_tag }}" release-assets/* \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--clobber
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -25,3 +25,5 @@ cache/
|
||||||
worker/
|
worker/
|
||||||
output/
|
output/
|
||||||
policies/
|
policies/
|
||||||
|
episteme-bin/
|
||||||
|
episteme-oss-bin/
|
||||||
|
|
|
||||||
12
.idea/vcs.xml
generated
Normal file
12
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2/brotli" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2/brotli/research/esaxx" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2/brotli/research/libdivsufsort" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$/episteme-bin" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$/episteme-oss-bin" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -171,6 +171,7 @@ android {
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
unitTests.all {
|
unitTests.all {
|
||||||
|
it.maxHeapSize = "4g"
|
||||||
it.jvmArgs("-Xss2m")
|
it.jvmArgs("-Xss2m")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,8 @@ class LibraryScreenContentTest {
|
||||||
onItemClick = {},
|
onItemClick = {},
|
||||||
onItemLongClick = { item -> selectedItems.value = setOf(item) },
|
onItemLongClick = { item -> selectedItems.value = setOf(item) },
|
||||||
onInfoClick = onInfoClick,
|
onInfoClick = onInfoClick,
|
||||||
|
onSaveClick = null,
|
||||||
|
onShareClick = null,
|
||||||
onDeleteClick = onDeleteClick,
|
onDeleteClick = onDeleteClick,
|
||||||
onSelectAllClick = onSelectAllClick,
|
onSelectAllClick = onSelectAllClick,
|
||||||
onShelfClick = onShelfClick,
|
onShelfClick = onShelfClick,
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,22 @@
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".TemporaryExternalFileActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.Reader"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:launchMode="standard" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".ExternalFileOpenRouterActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.App.Starting"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:excludeFromRecents="true">
|
||||||
<!-- PDF -->
|
<!-- PDF -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
|
||||||
|
|
@ -128,8 +128,15 @@
|
||||||
background-color: rgba(255, 236, 179, 0.8);
|
background-color: rgba(255, 236, 179, 0.8);
|
||||||
/* Semi-transparent Gold */
|
/* Semi-transparent Gold */
|
||||||
color: black !important;
|
color: black !important;
|
||||||
|
display: inline !important;
|
||||||
|
text-align: initial !important;
|
||||||
|
text-align-last: auto !important;
|
||||||
|
letter-spacing: normal !important;
|
||||||
|
word-spacing: normal !important;
|
||||||
padding: 0.1em 0;
|
padding: 0.1em 0;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
box-decoration-break: clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark-theme span.tts-highlight {
|
html.dark-theme span.tts-highlight {
|
||||||
|
|
@ -1496,6 +1503,16 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS";
|
const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS";
|
||||||
|
const TTS_HIGHLIGHT_BLOCK_SELECTOR = "p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th";
|
||||||
|
|
||||||
|
function getTtsHighlightBlock(node) {
|
||||||
|
if (!node) {
|
||||||
|
return document.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||||||
|
return (element && element.closest(TTS_HIGHLIGHT_BLOCK_SELECTOR)) || document.body;
|
||||||
|
}
|
||||||
|
|
||||||
window.highlightFromCfi = function (cfi, textToHighlight, startOffset) {
|
window.highlightFromCfi = function (cfi, textToHighlight, startOffset) {
|
||||||
console.log(`$ {
|
console.log(`$ {
|
||||||
|
|
@ -1556,9 +1573,10 @@
|
||||||
, Text content: '${(location.node.textContent || "").substring(0, 50)}...' `);
|
, Text content: '${(location.node.textContent || "").substring(0, 50)}...' `);
|
||||||
|
|
||||||
const baseNode = location.node;
|
const baseNode = location.node;
|
||||||
|
const highlightRoot = getTtsHighlightBlock(baseNode);
|
||||||
let remainingOffset = startOffset;
|
let remainingOffset = startOffset;
|
||||||
|
|
||||||
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
const treeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
|
||||||
treeWalker.currentNode = baseNode;
|
treeWalker.currentNode = baseNode;
|
||||||
|
|
||||||
let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode();
|
let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode();
|
||||||
|
|
@ -1626,7 +1644,7 @@
|
||||||
} else {
|
} else {
|
||||||
remainingTextLength -= availableLength;
|
remainingTextLength -= availableLength;
|
||||||
// Important: We need a fresh walker starting from the endNode to find the *next* text node reliably
|
// Important: We need a fresh walker starting from the endNode to find the *next* text node reliably
|
||||||
const nextNodeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
const nextNodeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
|
||||||
nextNodeWalker.currentNode = endNode;
|
nextNodeWalker.currentNode = endNode;
|
||||||
endNode = nextNodeWalker.nextNode();
|
endNode = nextNodeWalker.nextNode();
|
||||||
endOffset = 0; // Start from the beginning of the next node
|
endOffset = 0; // Start from the beginning of the next node
|
||||||
|
|
@ -1677,11 +1695,14 @@
|
||||||
TTS_HIGHLIGHT_LOG_TAG
|
TTS_HIGHLIGHT_LOG_TAG
|
||||||
}
|
}
|
||||||
|
|
||||||
: surroundContents failed, using fallback. Error: $ {
|
: surroundContents failed, using same-block fallback. Error: $ {
|
||||||
e.message
|
e.message
|
||||||
}
|
}
|
||||||
|
|
||||||
`);
|
`);
|
||||||
|
if (!highlightRoot.contains(range.commonAncestorContainer)) {
|
||||||
|
return "JS: Highlight range escaped current block.";
|
||||||
|
}
|
||||||
const contents = range.extractContents();
|
const contents = range.extractContents();
|
||||||
highlightSpan.appendChild(contents);
|
highlightSpan.appendChild(contents);
|
||||||
range.insertNode(highlightSpan);
|
range.insertNode(highlightSpan);
|
||||||
|
|
|
||||||
|
|
@ -220,12 +220,18 @@ fun AppNavigation(
|
||||||
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) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
windowSizeClass = windowSizeClass,
|
windowSizeClass = windowSizeClass,
|
||||||
navController = navController
|
navController = navController
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PDF Viewer Screen Composable
|
// PDF Viewer Screen Composable
|
||||||
composable(route = AppDestinations.PDF_VIEWER_ROUTE) {
|
composable(route = AppDestinations.PDF_VIEWER_ROUTE) {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ data class ReaderScreenState(
|
||||||
val selectedEpubUri: Uri? = null,
|
val selectedEpubUri: Uri? = null,
|
||||||
val selectedFileType: FileType? = null,
|
val selectedFileType: FileType? = null,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
|
val isTemporaryExternalOpen: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val contextualActionItems: Set<RecentFileItem> = emptySet(),
|
val contextualActionItems: Set<RecentFileItem> = emptySet(),
|
||||||
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
|
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
|
||||||
|
|
|
||||||
31
app/src/main/java/com/aryan/reader/ClipboardUtils.kt
Normal file
31
app/src/main/java/com/aryan/reader/ClipboardUtils.kt
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
internal fun copyPlainTextToClipboard(
|
||||||
|
context: Context,
|
||||||
|
label: String,
|
||||||
|
text: String
|
||||||
|
): Boolean {
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
val clip = ClipData.newPlainText(label, text)
|
||||||
|
return setPrimaryClipSafely {
|
||||||
|
clipboard.setPrimaryClip(clip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal inline fun setPrimaryClipSafely(setPrimaryClip: () -> Unit): Boolean {
|
||||||
|
return try {
|
||||||
|
setPrimaryClip()
|
||||||
|
true
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Timber.w(e, "Clipboard write rejected by system policy")
|
||||||
|
false
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
Timber.w(e, "Clipboard write failed")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -138,6 +138,7 @@ import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
|
@ -862,6 +863,13 @@ fun AiDefinitionPopup(
|
||||||
val ttsController = rememberTtsController()
|
val ttsController = rememberTtsController()
|
||||||
val ttsState by ttsController.ttsState.collectAsState()
|
val ttsState by ttsController.ttsState.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val maxPopupHeight = readerModalMaxHeightDp(
|
||||||
|
screenHeightDp = configuration.screenHeightDp,
|
||||||
|
fraction = 0.65f,
|
||||||
|
verticalMarginDp = 48,
|
||||||
|
preferredMinHeightDp = 180
|
||||||
|
).dp
|
||||||
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
|
@ -882,7 +890,7 @@ fun AiDefinitionPopup(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 5.dp)
|
.padding(horizontal = 16.dp, vertical = 5.dp)
|
||||||
.heightIn(min = 150.dp, max = 400.dp),
|
.heightIn(max = maxPopupHeight),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||||
) {
|
) {
|
||||||
|
|
@ -3310,12 +3318,16 @@ fun ThemeColorPickerDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
) {
|
) {
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(24.dp),
|
shape = RoundedCornerShape(24.dp),
|
||||||
color = Color(0xFF2C2C2C),
|
color = Color(0xFF2C2C2C),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.9f)
|
.fillMaxWidth(0.9f)
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
|
.heightIn(max = maxDialogHeight)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -3495,12 +3507,16 @@ fun HighlightColorPickerDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
) {
|
) {
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(24.dp),
|
shape = RoundedCornerShape(24.dp),
|
||||||
color = Color(0xFF2C2C2C),
|
color = Color(0xFF2C2C2C),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.9f)
|
.fillMaxWidth(0.9f)
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
|
.heightIn(max = maxDialogHeight)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
57
app/src/main/java/com/aryan/reader/ExternalFileOpenRouter.kt
Normal file
57
app/src/main/java/com/aryan/reader/ExternalFileOpenRouter.kt
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
|
||||||
|
const val EXTRA_TEMPORARY_EXTERNAL_OPEN = "com.aryan.reader.extra.TEMPORARY_EXTERNAL_OPEN"
|
||||||
|
|
||||||
|
object ExternalFileOpenRouteDecider {
|
||||||
|
const val BEHAVIOR_TEMPORARY = "TEMPORARY"
|
||||||
|
|
||||||
|
fun shouldOpenTemporary(externalFileBehavior: String?): Boolean {
|
||||||
|
return externalFileBehavior == BEHAVIOR_TEMPORARY
|
||||||
|
}
|
||||||
|
|
||||||
|
fun targetActivityClass(externalFileBehavior: String?): Class<out Activity> {
|
||||||
|
return if (shouldOpenTemporary(externalFileBehavior)) {
|
||||||
|
TemporaryExternalFileActivity::class.java
|
||||||
|
} else {
|
||||||
|
MainActivity::class.java
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExternalFileOpenRouterActivity : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
routeExternalOpen(intent)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
routeExternalOpen(intent)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun routeExternalOpen(sourceIntent: Intent?) {
|
||||||
|
if (sourceIntent?.action != Intent.ACTION_VIEW || sourceIntent.data == null) return
|
||||||
|
|
||||||
|
val prefs = getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
val behavior = prefs.getString("external_file_behavior", "ASK")
|
||||||
|
val temporary = ExternalFileOpenRouteDecider.shouldOpenTemporary(behavior)
|
||||||
|
val targetIntent = Intent(sourceIntent).apply {
|
||||||
|
setClass(this@ExternalFileOpenRouterActivity, ExternalFileOpenRouteDecider.targetActivityClass(behavior))
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
if (temporary) {
|
||||||
|
putExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, true)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startActivity(targetIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TemporaryExternalFileActivity : MainActivity()
|
||||||
|
|
@ -64,10 +64,17 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.aryan.reader.shared.CustomFontItem
|
import com.aryan.reader.shared.CustomFontItem
|
||||||
|
import com.aryan.reader.shared.CustomFontFamilyItem
|
||||||
|
import com.aryan.reader.shared.CustomFontVariantItem
|
||||||
|
import com.aryan.reader.shared.fontFaceLabel
|
||||||
|
import com.aryan.reader.shared.fontFaceSummary
|
||||||
|
import com.aryan.reader.shared.groupByFamily
|
||||||
|
import com.aryan.reader.shared.hasVariableWeightFace
|
||||||
import com.aryan.reader.shared.ui.SharedAppFontSelector
|
import com.aryan.reader.shared.ui.SharedAppFontSelector
|
||||||
import com.aryan.reader.shared.ui.SharedFontSettingsSection
|
import com.aryan.reader.shared.ui.SharedFontSettingsSection
|
||||||
import com.aryan.reader.shared.ui.SharedFontSettingsTabs
|
import com.aryan.reader.shared.ui.SharedFontSettingsTabs
|
||||||
|
|
@ -97,6 +104,7 @@ fun FontsScreen(
|
||||||
val selectedFonts = remember(fonts, selectedFontIds) {
|
val selectedFonts = remember(fonts, selectedFontIds) {
|
||||||
fonts.filter { it.id in selectedFontIds }
|
fonts.filter { it.id in selectedFontIds }
|
||||||
}
|
}
|
||||||
|
val fontEntitiesById = remember(fonts) { fonts.associateBy { it.id } }
|
||||||
val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty()
|
val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty()
|
||||||
|
|
||||||
LaunchedEffect(fonts) {
|
LaunchedEffect(fonts) {
|
||||||
|
|
@ -172,6 +180,7 @@ 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,
|
||||||
|
|
@ -202,16 +211,20 @@ fun FontsScreen(
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
items(fonts, key = { it.id }) { font ->
|
items(fontFamilies, key = { family -> family.variants.joinToString("|") { it.font.id } }) { family ->
|
||||||
FontListItem(
|
FontFamilyListItem(
|
||||||
font = font,
|
family = family,
|
||||||
isSelected = font.id in selectedFontIds,
|
selectedFontIds = selectedFontIds,
|
||||||
isSelectionMode = isFontSelectionMode,
|
isSelectionMode = isFontSelectionMode,
|
||||||
onSelectionToggle = {
|
fontEntityForId = { id -> fontEntitiesById[id] },
|
||||||
selectedFontIds = selectedFontIds.toggle(font.id)
|
onVariantSelectionToggle = { id ->
|
||||||
|
selectedFontIds = selectedFontIds.toggle(id)
|
||||||
},
|
},
|
||||||
onDelete = {
|
onFamilySelectionToggle = {
|
||||||
fontsPendingDelete = listOf(font)
|
selectedFontIds = selectedFontIds.toggleAll(family.variants.map { it.font.id })
|
||||||
|
},
|
||||||
|
onDeleteVariant = { id ->
|
||||||
|
fontEntitiesById[id]?.let { fontsPendingDelete = listOf(it) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -531,6 +544,188 @@ fun FontListItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun FontFamilyListItem(
|
||||||
|
family: CustomFontFamilyItem,
|
||||||
|
selectedFontIds: Set<String>,
|
||||||
|
isSelectionMode: Boolean,
|
||||||
|
fontEntityForId: (String) -> CustomFontEntity?,
|
||||||
|
onVariantSelectionToggle: (String) -> Unit,
|
||||||
|
onFamilySelectionToggle: () -> Unit,
|
||||||
|
onDeleteVariant: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val baseFont = remember(family) {
|
||||||
|
family.variants.firstOrNull { it.fontFaceLabel() == "Regular" }?.font ?: family.variants.first().font
|
||||||
|
}
|
||||||
|
val customTypeface = remember(baseFont.path) {
|
||||||
|
try {
|
||||||
|
FontFamily(Font(File(baseFont.path)))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val familyFontIds = remember(family) { family.variants.map { it.font.id }.toSet() }
|
||||||
|
val isSelected = familyFontIds.any { it in selectedFontIds }
|
||||||
|
val allSelected = familyFontIds.all { it in selectedFontIds }
|
||||||
|
val faceSummary = remember(family) {
|
||||||
|
buildString {
|
||||||
|
append(family.fontFaceSummary())
|
||||||
|
if (family.hasVariableWeightFace()) append(" - Variable weight")
|
||||||
|
append(" - ${family.variants.size} file")
|
||||||
|
if (family.variants.size != 1) append("s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = {
|
||||||
|
if (isSelectionMode) {
|
||||||
|
onFamilySelectionToggle()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = onFamilySelectionToggle
|
||||||
|
),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (isSelectionMode) {
|
||||||
|
Checkbox(
|
||||||
|
checked = allSelected,
|
||||||
|
onCheckedChange = { onFamilySelectionToggle() },
|
||||||
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = family.familyName,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = faceSummary,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), MaterialTheme.shapes.small)
|
||||||
|
.padding(12.dp)
|
||||||
|
) {
|
||||||
|
if (customTypeface != null) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.font_preview_text),
|
||||||
|
fontFamily = customTypeface,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.font_preview_error),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
family.variants.forEachIndexed { index, variant ->
|
||||||
|
FontVariantRow(
|
||||||
|
variant = variant,
|
||||||
|
entity = fontEntityForId(variant.font.id),
|
||||||
|
isSelected = variant.font.id in selectedFontIds,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onSelectionToggle = { onVariantSelectionToggle(variant.font.id) },
|
||||||
|
onDelete = { onDeleteVariant(variant.font.id) }
|
||||||
|
)
|
||||||
|
if (index != family.variants.lastIndex) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(start = if (isSelectionMode) 48.dp else 0.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FontVariantRow(
|
||||||
|
variant: CustomFontVariantItem,
|
||||||
|
entity: CustomFontEntity?,
|
||||||
|
isSelected: Boolean,
|
||||||
|
isSelectionMode: Boolean,
|
||||||
|
onSelectionToggle: () -> Unit,
|
||||||
|
onDelete: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.clickable(enabled = isSelectionMode) { onSelectionToggle() }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (isSelectionMode) {
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = { onSelectionToggle() },
|
||||||
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = variant.fontFaceLabel(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = entity?.fileName ?: variant.font.fileName,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = variant.font.fileExtension.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp)
|
||||||
|
)
|
||||||
|
if (!isSelectionMode) {
|
||||||
|
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(R.string.action_delete),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
|
private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
|
||||||
return filterNot { it.isDeleted }
|
return filterNot { it.isDeleted }
|
||||||
.sortedBy { it.displayName.lowercase() }
|
.sortedBy { it.displayName.lowercase() }
|
||||||
|
|
@ -591,3 +786,8 @@ fun DeleteFontsConfirmationDialog(
|
||||||
private fun Set<String>.toggle(id: String): Set<String> {
|
private fun Set<String>.toggle(id: String): Set<String> {
|
||||||
return if (id in this) this - id else this + id
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ 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
|
||||||
|
|
@ -193,6 +194,7 @@ fun HomeScreen(
|
||||||
var showAboutDialog by remember { mutableStateOf(false) }
|
var showAboutDialog by remember { mutableStateOf(false) }
|
||||||
var showInfoDialog by remember { mutableStateOf(false) }
|
var showInfoDialog by remember { mutableStateOf(false) }
|
||||||
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||||
|
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||||
var showBehaviorDialog by remember { mutableStateOf(false) }
|
var showBehaviorDialog by remember { mutableStateOf(false) }
|
||||||
var showStrictFilterDialog by remember { mutableStateOf(false) }
|
var showStrictFilterDialog by remember { mutableStateOf(false) }
|
||||||
var showClearBookCacheDialog by remember { mutableStateOf(false) }
|
var showClearBookCacheDialog by remember { mutableStateOf(false) }
|
||||||
|
|
@ -221,6 +223,35 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val saveOriginalLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||||
|
) { uri ->
|
||||||
|
val item = pendingSaveOriginalItem
|
||||||
|
pendingSaveOriginalItem = null
|
||||||
|
if (uri != null && item?.uriString != null) {
|
||||||
|
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveOriginalItem(item: RecentFileItem) {
|
||||||
|
if (!item.canExportOriginalFile()) return
|
||||||
|
pendingSaveOriginalItem = item
|
||||||
|
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareOriginalItem(item: RecentFileItem) {
|
||||||
|
val uriString = item.uriString ?: return
|
||||||
|
if (!item.canExportOriginalFile()) return
|
||||||
|
scope.launch {
|
||||||
|
viewModel.shareOriginalFile(
|
||||||
|
activityContext = context,
|
||||||
|
sourceUri = uriString.toUri(),
|
||||||
|
fileType = item.type,
|
||||||
|
filename = item.suggestedOriginalFileName()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(uiState.isRequestingDrivePermission) {
|
LaunchedEffect(uiState.isRequestingDrivePermission) {
|
||||||
if (uiState.isRequestingDrivePermission) {
|
if (uiState.isRequestingDrivePermission) {
|
||||||
val intent = viewModel.getDriveSignInIntent(context)
|
val intent = viewModel.getDriveSignInIntent(context)
|
||||||
|
|
@ -390,6 +421,12 @@ fun HomeScreen(
|
||||||
showInfoDialog = true
|
showInfoDialog = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSaveClick = selectedContextItems.singleOrNull()
|
||||||
|
?.takeIf { it.canExportOriginalFile() }
|
||||||
|
?.let { item -> { saveOriginalItem(item) } },
|
||||||
|
onShareClick = selectedContextItems.singleOrNull()
|
||||||
|
?.takeIf { it.canExportOriginalFile() }
|
||||||
|
?.let { item -> { shareOriginalItem(item) } },
|
||||||
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
|
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
|
||||||
onDeleteClick = { showDeleteConfirmDialog = true },
|
onDeleteClick = { showDeleteConfirmDialog = true },
|
||||||
onSelectAllClick = { viewModel.selectAllRecentFiles() })
|
onSelectAllClick = { viewModel.selectAllRecentFiles() })
|
||||||
|
|
@ -502,28 +539,17 @@ fun HomeScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
itemForInfoDialog?.let { item ->
|
HydratedFileInfoDialog(
|
||||||
if (showInfoDialog) {
|
item = itemForInfoDialog,
|
||||||
FileInfoDialog(
|
isVisible = showInfoDialog,
|
||||||
item = item,
|
uiState = uiState,
|
||||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
viewModel = viewModel,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
showInfoDialog = false
|
showInfoDialog = false
|
||||||
itemForInfoDialog = null
|
itemForInfoDialog = null
|
||||||
},
|
},
|
||||||
onSaveMetadata = { metadata ->
|
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
|
||||||
viewModel.updateBookMetadata(item.bookId, metadata)
|
|
||||||
},
|
|
||||||
onSaveDisplayName = { name ->
|
|
||||||
viewModel.updateCustomName(item.bookId, name)
|
|
||||||
},
|
|
||||||
onRestoreMetadata = {
|
|
||||||
viewModel.restoreOriginalBookMetadata(item.bookId)
|
|
||||||
},
|
|
||||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showClearReflowCacheDialog) {
|
if (showClearReflowCacheDialog) {
|
||||||
DangerousFolderActionDialog(
|
DangerousFolderActionDialog(
|
||||||
|
|
@ -1786,9 +1812,14 @@ fun ExternalFileBehaviorDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(stringResource(R.string.options_external_file_behavior)) },
|
title = { Text(stringResource(R.string.options_external_file_behavior)) },
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column(modifier = Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) {
|
||||||
val options = listOf("ASK" to R.string.external_file_behavior_ask, "KEEP" to R.string.external_file_behavior_keep, "DELETE" to R.string.external_file_behavior_delete)
|
val options = listOf(
|
||||||
options.forEach { (value, labelRes) ->
|
Triple("ASK", R.string.external_file_behavior_ask, R.string.external_file_behavior_ask_desc),
|
||||||
|
Triple("KEEP", R.string.external_file_behavior_keep, R.string.external_file_behavior_keep_desc),
|
||||||
|
Triple("DELETE", R.string.external_file_behavior_delete, R.string.external_file_behavior_delete_desc),
|
||||||
|
Triple("TEMPORARY", R.string.external_file_behavior_temporary, R.string.external_file_behavior_temporary_desc)
|
||||||
|
)
|
||||||
|
options.forEach { (value, labelRes, descriptionRes) ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -1798,7 +1829,14 @@ fun ExternalFileBehaviorDialog(
|
||||||
) {
|
) {
|
||||||
RadioButton(selected = currentBehavior == value, onClick = null)
|
RadioButton(selected = currentBehavior == value, onClick = null)
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(stringResource(labelRes))
|
Text(stringResource(labelRes))
|
||||||
|
Text(
|
||||||
|
text = stringResource(descriptionRes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ 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
|
||||||
|
|
@ -271,6 +272,36 @@ fun LibraryScreen(
|
||||||
var showDeleteShelvesDialog by remember { mutableStateOf(false) }
|
var showDeleteShelvesDialog by remember { mutableStateOf(false) }
|
||||||
var showInfoDialog by remember { mutableStateOf(false) }
|
var showInfoDialog by remember { mutableStateOf(false) }
|
||||||
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||||
|
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||||
|
|
||||||
|
val saveOriginalLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||||
|
) { uri ->
|
||||||
|
val item = pendingSaveOriginalItem
|
||||||
|
pendingSaveOriginalItem = null
|
||||||
|
if (uri != null && item?.uriString != null) {
|
||||||
|
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveOriginalItem(item: RecentFileItem) {
|
||||||
|
if (!item.canExportOriginalFile()) return
|
||||||
|
pendingSaveOriginalItem = item
|
||||||
|
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareOriginalItem(item: RecentFileItem) {
|
||||||
|
val uriString = item.uriString ?: return
|
||||||
|
if (!item.canExportOriginalFile()) return
|
||||||
|
scope.launch {
|
||||||
|
viewModel.shareOriginalFile(
|
||||||
|
activityContext = context,
|
||||||
|
sourceUri = uriString.toUri(),
|
||||||
|
fileType = item.type,
|
||||||
|
filename = item.suggestedOriginalFileName()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = isContextualModeActive) {
|
BackHandler(enabled = isContextualModeActive) {
|
||||||
viewModel.clearContextualAction()
|
viewModel.clearContextualAction()
|
||||||
|
|
@ -317,6 +348,12 @@ fun LibraryScreen(
|
||||||
showInfoDialog = true
|
showInfoDialog = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSaveClick = selectedItems.singleOrNull()
|
||||||
|
?.takeIf { it.canExportOriginalFile() }
|
||||||
|
?.let { item -> { saveOriginalItem(item) } },
|
||||||
|
onShareClick = selectedItems.singleOrNull()
|
||||||
|
?.takeIf { it.canExportOriginalFile() }
|
||||||
|
?.let { item -> { shareOriginalItem(item) } },
|
||||||
onDeleteClick = { showDeleteConfirmDialog = true },
|
onDeleteClick = { showDeleteConfirmDialog = true },
|
||||||
onSelectAllClick = { viewModel.selectAllLibraryFiles() },
|
onSelectAllClick = { viewModel.selectAllLibraryFiles() },
|
||||||
onShelfClick = viewModel::onShelfClick,
|
onShelfClick = viewModel::onShelfClick,
|
||||||
|
|
@ -397,28 +434,17 @@ fun LibraryScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
itemForInfoDialog?.let { item ->
|
HydratedFileInfoDialog(
|
||||||
if (showInfoDialog) {
|
item = itemForInfoDialog,
|
||||||
FileInfoDialog(
|
isVisible = showInfoDialog,
|
||||||
item = item,
|
uiState = uiState,
|
||||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
viewModel = viewModel,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
showInfoDialog = false
|
showInfoDialog = false
|
||||||
itemForInfoDialog = null
|
itemForInfoDialog = null
|
||||||
},
|
},
|
||||||
onSaveMetadata = { metadata ->
|
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
|
||||||
viewModel.updateBookMetadata(item.bookId, metadata)
|
|
||||||
},
|
|
||||||
onSaveDisplayName = { name ->
|
|
||||||
viewModel.updateCustomName(item.bookId, name)
|
|
||||||
},
|
|
||||||
onRestoreMetadata = {
|
|
||||||
viewModel.restoreOriginalBookMetadata(item.bookId)
|
|
||||||
},
|
|
||||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -436,10 +462,42 @@ fun ShelfScreen(
|
||||||
val sortOrder = uiState.sortOrder
|
val sortOrder = uiState.sortOrder
|
||||||
val showRenameDialogFor = uiState.showRenameShelfDialogFor
|
val showRenameDialogFor = uiState.showRenameShelfDialogFor
|
||||||
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
|
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
|
var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
|
||||||
var showInfoDialog by remember { mutableStateOf(false) }
|
var showInfoDialog by remember { mutableStateOf(false) }
|
||||||
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||||
|
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||||
|
|
||||||
|
val saveOriginalLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||||
|
) { uri ->
|
||||||
|
val item = pendingSaveOriginalItem
|
||||||
|
pendingSaveOriginalItem = null
|
||||||
|
if (uri != null && item?.uriString != null) {
|
||||||
|
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveOriginalItem(item: RecentFileItem) {
|
||||||
|
if (!item.canExportOriginalFile()) return
|
||||||
|
pendingSaveOriginalItem = item
|
||||||
|
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareOriginalItem(item: RecentFileItem) {
|
||||||
|
val uriString = item.uriString ?: return
|
||||||
|
if (!item.canExportOriginalFile()) return
|
||||||
|
scope.launch {
|
||||||
|
viewModel.shareOriginalFile(
|
||||||
|
activityContext = context,
|
||||||
|
sourceUri = uriString.toUri(),
|
||||||
|
fileType = item.type,
|
||||||
|
filename = item.suggestedOriginalFileName()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = true) {
|
BackHandler(enabled = true) {
|
||||||
when {
|
when {
|
||||||
|
|
@ -491,6 +549,12 @@ fun ShelfScreen(
|
||||||
showInfoDialog = true
|
showInfoDialog = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSaveClick = selectedItems.singleOrNull()
|
||||||
|
?.takeIf { it.canExportOriginalFile() }
|
||||||
|
?.let { item -> { saveOriginalItem(item) } },
|
||||||
|
onShareClick = selectedItems.singleOrNull()
|
||||||
|
?.takeIf { it.canExportOriginalFile() }
|
||||||
|
?.let { item -> { shareOriginalItem(item) } },
|
||||||
onDeleteClick = { showRemoveFromShelfDialog = true },
|
onDeleteClick = { showRemoveFromShelfDialog = true },
|
||||||
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
|
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
|
||||||
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
|
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
|
||||||
|
|
@ -531,19 +595,14 @@ fun ShelfScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
itemForInfoDialog?.let { item ->
|
HydratedFileInfoDialog(
|
||||||
if (showInfoDialog) {
|
item = itemForInfoDialog,
|
||||||
FileInfoDialog(
|
isVisible = showInfoDialog,
|
||||||
item = item,
|
uiState = uiState,
|
||||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
viewModel = viewModel,
|
||||||
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
|
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
|
||||||
onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) },
|
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
|
||||||
onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
|
|
||||||
onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) },
|
|
||||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -578,6 +637,8 @@ fun LibraryScreenContent(
|
||||||
onItemClick: (RecentFileItem) -> Unit,
|
onItemClick: (RecentFileItem) -> Unit,
|
||||||
onItemLongClick: (RecentFileItem) -> Unit,
|
onItemLongClick: (RecentFileItem) -> Unit,
|
||||||
onInfoClick: () -> Unit,
|
onInfoClick: () -> Unit,
|
||||||
|
onSaveClick: (() -> Unit)?,
|
||||||
|
onShareClick: (() -> Unit)?,
|
||||||
onDeleteClick: () -> Unit,
|
onDeleteClick: () -> Unit,
|
||||||
onSelectAllClick: () -> Unit,
|
onSelectAllClick: () -> Unit,
|
||||||
onShelfClick: (Shelf) -> Unit,
|
onShelfClick: (Shelf) -> Unit,
|
||||||
|
|
@ -640,6 +701,8 @@ fun LibraryScreenContent(
|
||||||
onTagClick = onTagClick,
|
onTagClick = onTagClick,
|
||||||
onPinClick = onPinClick,
|
onPinClick = onPinClick,
|
||||||
onInfoClick = onInfoClick,
|
onInfoClick = onInfoClick,
|
||||||
|
onSaveClick = onSaveClick,
|
||||||
|
onShareClick = onShareClick,
|
||||||
onDeleteClick = onDeleteClick,
|
onDeleteClick = onDeleteClick,
|
||||||
onSelectAllClick = onSelectAllClick
|
onSelectAllClick = onSelectAllClick
|
||||||
)
|
)
|
||||||
|
|
@ -1046,6 +1109,8 @@ private fun ShelfDetailScreen(
|
||||||
onClearSelection: () -> Unit,
|
onClearSelection: () -> Unit,
|
||||||
onTagClick: () -> Unit,
|
onTagClick: () -> Unit,
|
||||||
onInfoClick: () -> Unit,
|
onInfoClick: () -> Unit,
|
||||||
|
onSaveClick: (() -> Unit)?,
|
||||||
|
onShareClick: (() -> Unit)?,
|
||||||
onDeleteClick: () -> Unit,
|
onDeleteClick: () -> Unit,
|
||||||
onRenameShelf: () -> Unit,
|
onRenameShelf: () -> Unit,
|
||||||
onDeleteShelf: () -> Unit,
|
onDeleteShelf: () -> Unit,
|
||||||
|
|
@ -1128,6 +1193,8 @@ private fun ShelfDetailScreen(
|
||||||
onNavIconClick = onClearSelection,
|
onNavIconClick = onClearSelection,
|
||||||
onTagClick = onTagClick,
|
onTagClick = onTagClick,
|
||||||
onInfoClick = onInfoClick,
|
onInfoClick = onInfoClick,
|
||||||
|
onSaveClick = onSaveClick,
|
||||||
|
onShareClick = onShareClick,
|
||||||
onDeleteClick = onDeleteClick
|
onDeleteClick = onDeleteClick
|
||||||
)
|
)
|
||||||
} else if (isSearchActive) {
|
} else if (isSearchActive) {
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,12 @@ import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI
|
||||||
import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET
|
import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class MainActivity : AppCompatActivity() {
|
open class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private val viewModel: MainViewModel by viewModels()
|
private val viewModel: MainViewModel by viewModels()
|
||||||
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
|
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
|
||||||
|
private val isTemporaryExternalOpen: Boolean
|
||||||
|
get() = intent?.getBooleanExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, false) == true
|
||||||
|
|
||||||
private val updateLauncher = registerForActivityResult(
|
private val updateLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.StartIntentSenderForResult()
|
ActivityResultContracts.StartIntentSenderForResult()
|
||||||
|
|
@ -86,6 +88,14 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.temporaryExternalOpenFinished.collect {
|
||||||
|
if (isTemporaryExternalOpen) {
|
||||||
|
finishAndRemoveTask()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +170,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
|
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
|
||||||
Timber.d("Received VIEW intent with URI: ${intent.data}")
|
Timber.d("Received VIEW intent with URI: ${intent.data}")
|
||||||
val uri = intent.data!!
|
val uri = intent.data!!
|
||||||
viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true)
|
viewModel.onFileSelected(
|
||||||
|
uri,
|
||||||
|
isFromRecent = false,
|
||||||
|
isExternalIntent = true,
|
||||||
|
isTemporaryExternalIntent = isTemporaryExternalOpen
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -222,10 +222,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
private val _navigationEvent = Channel<NavigationEvent>(Channel.BUFFERED)
|
private val _navigationEvent = Channel<NavigationEvent>(Channel.BUFFERED)
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
val navigationEvent = _navigationEvent.receiveAsFlow()
|
val navigationEvent = _navigationEvent.receiveAsFlow()
|
||||||
|
private val _temporaryExternalOpenFinished = Channel<Unit>(Channel.BUFFERED)
|
||||||
|
val temporaryExternalOpenFinished = _temporaryExternalOpenFinished.receiveAsFlow()
|
||||||
private var bannerDismissJob: Job? = null
|
private var bannerDismissJob: Job? = null
|
||||||
private var bannerDismissGeneration = 0L
|
private var bannerDismissGeneration = 0L
|
||||||
private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null
|
private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null
|
||||||
private var externalOpenedBookId: String? = null
|
private var externalOpenedBookId: String? = null
|
||||||
|
private var temporaryExternalSessionBookId: String? = null
|
||||||
private var cloudContentRetryJob: Job? = null
|
private var cloudContentRetryJob: Job? = null
|
||||||
private val cloudMetadataUploadJobs = ConcurrentHashMap<String, Job>()
|
private val cloudMetadataUploadJobs = ConcurrentHashMap<String, Job>()
|
||||||
|
|
||||||
|
|
@ -804,6 +807,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
_internalState.update { it.copy(showTagSelectionDialogFor = emptySet()) }
|
_internalState.update { it.copy(showTagSelectionDialogFor = emptySet()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getFileInfoItem(bookId: String): RecentFileItem? {
|
||||||
|
return recentFilesRepository.getFileByBookId(bookId)
|
||||||
|
}
|
||||||
|
|
||||||
fun createAndAssignTag(name: String, bookIds: Set<String>) {
|
fun createAndAssignTag(name: String, bookIds: Set<String>) {
|
||||||
val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
|
val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
|
||||||
if (sanitizedBookIds.isEmpty()) return
|
if (sanitizedBookIds.isEmpty()) return
|
||||||
|
|
@ -2089,6 +2096,48 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun trackExternalOpenForClose(
|
||||||
|
bookId: String,
|
||||||
|
importedCopyUriString: String?,
|
||||||
|
isTemporaryExternalIntent: Boolean
|
||||||
|
) {
|
||||||
|
if (isTemporaryExternalIntent) {
|
||||||
|
temporaryExternalSessionBookId = bookId
|
||||||
|
if (importedCopyUriString != null) {
|
||||||
|
externalOpenedBookId = bookId
|
||||||
|
markPendingExternalFileRemoval(bookId, importedCopyUriString)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
externalOpenedBookId = bookId
|
||||||
|
if (
|
||||||
|
importedCopyUriString != null &&
|
||||||
|
prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, EXTERNAL_FILE_BEHAVIOR_ASK) == "DELETE"
|
||||||
|
) {
|
||||||
|
markPendingExternalFileRemoval(bookId, importedCopyUriString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveOriginalFile(sourceUri: Uri, destUri: Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_internalState.update {
|
||||||
|
it.copy(isLoading = true, bannerMessage = BannerMessage(appContext.getString(R.string.banner_saving_original_file)))
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
copyUriBytes(sourceUri, destUri)
|
||||||
|
}
|
||||||
|
showBanner(appContext.getString(R.string.banner_original_file_saved))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to save original file")
|
||||||
|
showBanner(appContext.getString(R.string.error_saving_file, e.localizedMessage ?: e.message.orEmpty()), isError = true)
|
||||||
|
} finally {
|
||||||
|
_internalState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun togglePinForContextualItems(isHome: Boolean) {
|
fun togglePinForContextualItems(isHome: Boolean) {
|
||||||
if (_internalState.value.contextualActionItems.isEmpty()) return
|
if (_internalState.value.contextualActionItems.isEmpty()) return
|
||||||
|
|
||||||
|
|
@ -2206,6 +2255,68 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun shareOriginalFile(
|
||||||
|
activityContext: Context,
|
||||||
|
sourceUri: Uri,
|
||||||
|
fileType: FileType,
|
||||||
|
filename: String
|
||||||
|
) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val shareDir = File(appContext.cacheDir, "shared_files")
|
||||||
|
if (shareDir.exists()) {
|
||||||
|
shareDir.listFiles()?.forEach { file ->
|
||||||
|
try {
|
||||||
|
file.delete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
Timber.w("Failed to delete temp share file: ${file.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shareDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val destFile = File(shareDir, filename)
|
||||||
|
FileOutputStream(destFile).use { output ->
|
||||||
|
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
|
||||||
|
input.copyTo(output)
|
||||||
|
} ?: error("Could not open source file.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val authority = "${appContext.packageName}.provider"
|
||||||
|
val contentUri = androidx.core.content.FileProvider.getUriForFile(
|
||||||
|
appContext, authority, destFile
|
||||||
|
)
|
||||||
|
val mimeType = SharedFileCapabilities.mimeTypeFor(fileType) ?: "application/octet-stream"
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = mimeType
|
||||||
|
putExtra(Intent.EXTRA_STREAM, contentUri)
|
||||||
|
putExtra(Intent.EXTRA_TITLE, filename)
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, appContext.getString(R.string.share_subject, filename))
|
||||||
|
clipData = ClipData.newRawUri(filename, contentUri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
val chooser = Intent.createChooser(shareIntent, appContext.getString(R.string.share_file_chooser_title))
|
||||||
|
if (activityContext !is android.app.Activity) {
|
||||||
|
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) { activityContext.startActivity(chooser) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Share original file failed")
|
||||||
|
showBanner(appContext.getString(R.string.error_share_failed, e.localizedMessage ?: e.message.orEmpty()), isError = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyUriBytes(sourceUri: Uri, destUri: Uri) {
|
||||||
|
val contentResolver = appContext.contentResolver
|
||||||
|
contentResolver.openInputStream(sourceUri)?.use { input ->
|
||||||
|
contentResolver.openOutputStream(destUri)?.use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
} ?: error("Could not open destination file.")
|
||||||
|
} ?: error("Could not open source file.")
|
||||||
|
}
|
||||||
|
|
||||||
private fun queueCloudMetadataUpload(bookId: String, reason: String, debounce: Boolean = true) {
|
private fun queueCloudMetadataUpload(bookId: String, reason: String, debounce: Boolean = true) {
|
||||||
if (!uiState.value.isSyncEnabled) return
|
if (!uiState.value.isSyncEnabled) return
|
||||||
cloudMetadataUploadJobs.remove(bookId)?.cancel()
|
cloudMetadataUploadJobs.remove(bookId)?.cancel()
|
||||||
|
|
@ -2761,6 +2872,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val closingBookId = _internalState.value.selectedBookId
|
val closingBookId = _internalState.value.selectedBookId
|
||||||
val uriString = _internalState.value.selectedPdfUri?.toString()
|
val uriString = _internalState.value.selectedPdfUri?.toString()
|
||||||
?: _internalState.value.selectedEpubUri?.toString()
|
?: _internalState.value.selectedEpubUri?.toString()
|
||||||
|
val isTemporaryExternalSession = closingBookId != null && closingBookId == temporaryExternalSessionBookId
|
||||||
logCloudSyncTrace {
|
logCloudSyncTrace {
|
||||||
"android.reader.close_request book=$closingBookId uri=${uriString.cloudSyncPreview()} sync=${uiState.value.isSyncEnabled}"
|
"android.reader.close_request book=$closingBookId uri=${uriString.cloudSyncPreview()} sync=${uiState.value.isSyncEnabled}"
|
||||||
}
|
}
|
||||||
|
|
@ -2787,6 +2899,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
selectedEpubBook = null,
|
selectedEpubBook = null,
|
||||||
selectedFileType = null,
|
selectedFileType = null,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
isTemporaryExternalOpen = false,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
initialLocator = null,
|
initialLocator = null,
|
||||||
initialPageInBook = null,
|
initialPageInBook = null,
|
||||||
|
|
@ -2794,20 +2907,40 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
isOpeningFromTtsNotification = false
|
isOpeningFromTtsNotification = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (!isTemporaryExternalSession) {
|
||||||
clearPersistedReaderSession()
|
clearPersistedReaderSession()
|
||||||
|
}
|
||||||
|
|
||||||
var removesExternalFileOnClose = false
|
var removesExternalFileOnClose = false
|
||||||
if (closingBookId != null && closingBookId == externalOpenedBookId) {
|
if (closingBookId != null && (closingBookId == externalOpenedBookId || isTemporaryExternalSession)) {
|
||||||
val behavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK"
|
val behavior = if (isTemporaryExternalSession) {
|
||||||
|
EXTERNAL_FILE_BEHAVIOR_TEMPORARY
|
||||||
|
} else {
|
||||||
|
prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, EXTERNAL_FILE_BEHAVIOR_ASK) ?: EXTERNAL_FILE_BEHAVIOR_ASK
|
||||||
|
}
|
||||||
if (behavior == "ASK") {
|
if (behavior == "ASK") {
|
||||||
_internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) }
|
_internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) }
|
||||||
} else if (behavior == "DELETE") {
|
} else if (behavior == "DELETE") {
|
||||||
removesExternalFileOnClose = true
|
removesExternalFileOnClose = true
|
||||||
deletePendingExternalFileRemoval(closingBookId, uriString)
|
deletePendingExternalFileRemoval(closingBookId, uriString)
|
||||||
|
} else if (behavior == EXTERNAL_FILE_BEHAVIOR_TEMPORARY) {
|
||||||
|
removesExternalFileOnClose = true
|
||||||
|
val shouldDeleteImportedCopy = closingBookId == externalOpenedBookId
|
||||||
|
if (shouldDeleteImportedCopy) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
deletePendingExternalFileRemoval(PendingExternalFileRemoval(closingBookId, uriString))
|
||||||
|
_temporaryExternalOpenFinished.send(Unit)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_temporaryExternalOpenFinished.send(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
clearPendingExternalFileRemovals(setOf(closingBookId))
|
clearPendingExternalFileRemovals(setOf(closingBookId))
|
||||||
}
|
}
|
||||||
externalOpenedBookId = null
|
externalOpenedBookId = null
|
||||||
|
temporaryExternalSessionBookId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uriString != null && !removesExternalFileOnClose) {
|
if (uriString != null && !removesExternalFileOnClose) {
|
||||||
|
|
@ -4802,7 +4935,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onFileSelected(uri: Uri, isFromRecent: Boolean = false, isExternalIntent: Boolean = false) {
|
fun onFileSelected(
|
||||||
|
uri: Uri,
|
||||||
|
isFromRecent: Boolean = false,
|
||||||
|
isExternalIntent: Boolean = false,
|
||||||
|
isTemporaryExternalIntent: Boolean = false
|
||||||
|
) {
|
||||||
if (isFromRecent) {
|
if (isFromRecent) {
|
||||||
Timber.i("Opening recent file: $uri")
|
Timber.i("Opening recent file: $uri")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -4815,7 +4953,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.i("Importing new file: $uri")
|
Timber.i("Importing new file: $uri")
|
||||||
importExternalFile(uri, isExternalIntent)
|
importExternalFile(uri, isExternalIntent, isTemporaryExternalIntent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4865,9 +5003,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importExternalFile(externalUri: Uri, isExternalIntent: Boolean = false) {
|
private fun importExternalFile(
|
||||||
|
externalUri: Uri,
|
||||||
|
isExternalIntent: Boolean = false,
|
||||||
|
isTemporaryExternalIntent: Boolean = false
|
||||||
|
) {
|
||||||
|
if (isTemporaryExternalIntent) {
|
||||||
|
openTemporaryExternalFile(externalUri)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
_internalState.update {
|
_internalState.update {
|
||||||
it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet())
|
it.copy(
|
||||||
|
isLoading = true,
|
||||||
|
errorMessage = null,
|
||||||
|
contextualActionItems = emptySet()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -4877,10 +5028,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
if (importResult != null) {
|
if (importResult != null) {
|
||||||
val (internalUri, bookId, type) = importResult
|
val (internalUri, bookId, type) = importResult
|
||||||
if (isExternalIntent) {
|
if (isExternalIntent) {
|
||||||
externalOpenedBookId = bookId
|
trackExternalOpenForClose(
|
||||||
if (prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") == "DELETE") {
|
bookId = bookId,
|
||||||
markPendingExternalFileRemoval(bookId, internalUri.toString())
|
importedCopyUriString = internalUri.toString(),
|
||||||
}
|
isTemporaryExternalIntent = isTemporaryExternalIntent
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File"
|
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File"
|
||||||
openBook(
|
openBook(
|
||||||
|
|
@ -4895,6 +5047,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val existingItem = recentFilesRepository.getFileByBookId(hash)
|
val existingItem = recentFilesRepository.getFileByBookId(hash)
|
||||||
if (existingItem != null) {
|
if (existingItem != null) {
|
||||||
Timber.i("Re-selected an existing book. Opening it.")
|
Timber.i("Re-selected an existing book. Opening it.")
|
||||||
|
if (isTemporaryExternalIntent) {
|
||||||
|
trackExternalOpenForClose(
|
||||||
|
bookId = existingItem.bookId,
|
||||||
|
importedCopyUriString = null,
|
||||||
|
isTemporaryExternalIntent = true
|
||||||
|
)
|
||||||
|
}
|
||||||
onRecentFileClicked(existingItem)
|
onRecentFileClicked(existingItem)
|
||||||
_internalState.update { it.copy(isLoading = false) }
|
_internalState.update { it.copy(isLoading = false) }
|
||||||
return@launch
|
return@launch
|
||||||
|
|
@ -4928,6 +5087,45 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun openTemporaryExternalFile(externalUri: Uri) {
|
||||||
|
_internalState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = true,
|
||||||
|
isTemporaryExternalOpen = true,
|
||||||
|
errorMessage = null,
|
||||||
|
contextualActionItems = emptySet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
val type = getFileTypeFromUri(externalUri, appContext)
|
||||||
|
if (type == null) {
|
||||||
|
_internalState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isTemporaryExternalOpen = false,
|
||||||
|
errorMessage = appContext.getString(R.string.error_unsupported_file_type)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Temporary File"
|
||||||
|
val bookId = "temporary-${UUID.randomUUID()}"
|
||||||
|
trackExternalOpenForClose(
|
||||||
|
bookId = bookId,
|
||||||
|
importedCopyUriString = null,
|
||||||
|
isTemporaryExternalIntent = true
|
||||||
|
)
|
||||||
|
openBook(
|
||||||
|
uri = externalUri,
|
||||||
|
bookId = bookId,
|
||||||
|
type = type,
|
||||||
|
originalDisplayName = displayName,
|
||||||
|
persistToLibrary = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun saveHighlights(bookId: String, highlightsJson: String) {
|
fun saveHighlights(bookId: String, highlightsJson: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val currentBookUri = _internalState.value.selectedPdfUri ?: _internalState.value.selectedEpubUri
|
val currentBookUri = _internalState.value.selectedPdfUri ?: _internalState.value.selectedEpubUri
|
||||||
|
|
@ -5154,7 +5352,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun cleanupBookDataLocally(bookId: String) {
|
protected open suspend fun cleanupBookDataLocally(bookId: String) {
|
||||||
pdfTextRepository.clearBookText(bookId)
|
pdfTextRepository.clearBookText(bookId)
|
||||||
clearImportedFileCache(bookId)
|
clearImportedFileCache(bookId)
|
||||||
bookCacheDao.deleteEntireBookCache(bookId)
|
bookCacheDao.deleteEntireBookCache(bookId)
|
||||||
|
|
@ -5199,7 +5397,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
isInitialPageExplicit: Boolean = false,
|
isInitialPageExplicit: Boolean = false,
|
||||||
initialLocatorOverride: Locator? = null,
|
initialLocatorOverride: Locator? = null,
|
||||||
initialCfiOverride: String? = null,
|
initialCfiOverride: String? = null,
|
||||||
preserveTtsOnOpen: Boolean = false
|
preserveTtsOnOpen: Boolean = false,
|
||||||
|
persistToLibrary: Boolean = true
|
||||||
) {
|
) {
|
||||||
val openBookStartTime = System.currentTimeMillis()
|
val openBookStartTime = System.currentTimeMillis()
|
||||||
ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type")
|
ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type")
|
||||||
|
|
@ -5293,6 +5492,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
||||||
|
if (persistToLibrary) {
|
||||||
persistReaderSession(bookId, type)
|
persistReaderSession(bookId, type)
|
||||||
addFileToRecent(
|
addFileToRecent(
|
||||||
uri,
|
uri,
|
||||||
|
|
@ -5303,6 +5503,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
sourceFolderUri = null,
|
sourceFolderUri = null,
|
||||||
bundleResult = bundleResult
|
bundleResult = bundleResult
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!suppressNavigation) {
|
if (!suppressNavigation) {
|
||||||
Timber.tag("FileSwitch").d("PDF state updated, emitting navigation event")
|
Timber.tag("FileSwitch").d("PDF state updated, emitting navigation event")
|
||||||
|
|
@ -5343,7 +5544,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
||||||
|
if (persistToLibrary) {
|
||||||
persistReaderSession(bookId, type)
|
persistReaderSession(bookId, type)
|
||||||
|
}
|
||||||
|
|
||||||
if (!suppressNavigation) {
|
if (!suppressNavigation) {
|
||||||
Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event")
|
Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event")
|
||||||
|
|
@ -5352,22 +5555,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
when (type) {
|
when (type) {
|
||||||
FileType.EPUB -> {
|
FileType.EPUB -> {
|
||||||
loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
|
loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
|
||||||
}
|
}
|
||||||
|
|
||||||
FileType.MOBI -> {
|
FileType.MOBI -> {
|
||||||
loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
|
loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
|
||||||
}
|
}
|
||||||
|
|
||||||
FileType.FB2 -> {
|
FileType.FB2 -> {
|
||||||
loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
|
loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
|
||||||
}
|
}
|
||||||
FileType.ODT, FileType.FODT -> {
|
FileType.ODT, FileType.FODT -> {
|
||||||
loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult)
|
loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
loadSingleFile(
|
loadSingleFile(
|
||||||
uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult
|
uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5390,7 +5593,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadFb2(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
|
private fun loadFb2(
|
||||||
|
uri: Uri,
|
||||||
|
bookId: String,
|
||||||
|
customDisplayName: String? = null,
|
||||||
|
bundleResult: CalibreBundleResult? = null,
|
||||||
|
persistToLibrary: Boolean = true
|
||||||
|
) {
|
||||||
val loadStart = System.currentTimeMillis()
|
val loadStart = System.currentTimeMillis()
|
||||||
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 START")
|
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 START")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -5412,9 +5621,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
Timber.i("FB2 parsing successful. Title: ${fb2Book.title}")
|
Timber.i("FB2 parsing successful. Title: ${fb2Book.title}")
|
||||||
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 completed | chapters=${fb2Book.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
|
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 completed | chapters=${fb2Book.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
|
||||||
|
|
||||||
|
if (persistToLibrary) {
|
||||||
addFileToRecent(
|
addFileToRecent(
|
||||||
uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
|
uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
_internalState.update { it.copy(selectedEpubBook = fb2Book, isLoading = false) }
|
_internalState.update { it.copy(selectedEpubBook = fb2Book, isLoading = false) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -5426,7 +5637,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadOdt(uri: Uri, bookId: String, isFlat: Boolean, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
|
private fun loadOdt(
|
||||||
|
uri: Uri,
|
||||||
|
bookId: String,
|
||||||
|
isFlat: Boolean,
|
||||||
|
customDisplayName: String? = null,
|
||||||
|
bundleResult: CalibreBundleResult? = null,
|
||||||
|
persistToLibrary: Boolean = true
|
||||||
|
) {
|
||||||
val loadStart = System.currentTimeMillis()
|
val loadStart = System.currentTimeMillis()
|
||||||
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt START | isFlat=$isFlat")
|
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt START | isFlat=$isFlat")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -5449,9 +5667,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
Timber.i("ODT parsing successful. Title: ${odtBook.title}")
|
Timber.i("ODT parsing successful. Title: ${odtBook.title}")
|
||||||
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt completed | chapters=${odtBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
|
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt completed | chapters=${odtBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
|
||||||
|
|
||||||
|
if (persistToLibrary) {
|
||||||
addFileToRecent(
|
addFileToRecent(
|
||||||
uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
|
uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
_internalState.update { it.copy(selectedEpubBook = odtBook, isLoading = false) }
|
_internalState.update { it.copy(selectedEpubBook = odtBook, isLoading = false) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -5468,7 +5688,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
bookId: String,
|
bookId: String,
|
||||||
type: FileType,
|
type: FileType,
|
||||||
customDisplayName: String? = null,
|
customDisplayName: String? = null,
|
||||||
bundleResult: CalibreBundleResult? = null
|
bundleResult: CalibreBundleResult? = null,
|
||||||
|
persistToLibrary: Boolean = true
|
||||||
) {
|
) {
|
||||||
val loadStart = System.currentTimeMillis()
|
val loadStart = System.currentTimeMillis()
|
||||||
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type")
|
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type")
|
||||||
|
|
@ -5506,6 +5727,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
Timber.tag("FileOpenPerf")
|
Timber.tag("FileOpenPerf")
|
||||||
.d("[$bookId] loadSingleFile: importSingleFile completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
|
.d("[$bookId] loadSingleFile: importSingleFile completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
|
||||||
Timber.i("Import successful ($type). Title: ${epubBook.title}")
|
Timber.i("Import successful ($type). Title: ${epubBook.title}")
|
||||||
|
if (persistToLibrary) {
|
||||||
addFileToRecent(
|
addFileToRecent(
|
||||||
uri,
|
uri,
|
||||||
type,
|
type,
|
||||||
|
|
@ -5516,6 +5738,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
sourceFolderUri = null,
|
sourceFolderUri = null,
|
||||||
bundleResult = bundleResult
|
bundleResult = bundleResult
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
|
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
|
||||||
Timber.tag("FileOpenPerf")
|
Timber.tag("FileOpenPerf")
|
||||||
|
|
@ -5554,7 +5777,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
return resolveFileTypeFromMetadata(fileName, mimeType)
|
return resolveFileTypeFromMetadata(fileName, mimeType)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadMobi(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
|
private fun loadMobi(
|
||||||
|
uri: Uri,
|
||||||
|
bookId: String,
|
||||||
|
customDisplayName: String? = null,
|
||||||
|
bundleResult: CalibreBundleResult? = null,
|
||||||
|
persistToLibrary: Boolean = true
|
||||||
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (!_internalState.value.isLoading) {
|
if (!_internalState.value.isLoading) {
|
||||||
_internalState.update { it.copy(isLoading = true, errorMessage = null) }
|
_internalState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
|
@ -5576,6 +5805,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
if (mobiAsEpubBook != null) {
|
if (mobiAsEpubBook != null) {
|
||||||
Timber.i("MOBI parsing successful. Title: ${mobiAsEpubBook.title}")
|
Timber.i("MOBI parsing successful. Title: ${mobiAsEpubBook.title}")
|
||||||
|
if (persistToLibrary) {
|
||||||
addFileToRecent(
|
addFileToRecent(
|
||||||
uri,
|
uri,
|
||||||
FileType.MOBI,
|
FileType.MOBI,
|
||||||
|
|
@ -5586,6 +5816,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
sourceFolderUri = null,
|
sourceFolderUri = null,
|
||||||
bundleResult = bundleResult
|
bundleResult = bundleResult
|
||||||
)
|
)
|
||||||
|
}
|
||||||
_internalState.update {
|
_internalState.update {
|
||||||
it.copy(selectedEpubBook = mobiAsEpubBook, isLoading = false)
|
it.copy(selectedEpubBook = mobiAsEpubBook, isLoading = false)
|
||||||
}
|
}
|
||||||
|
|
@ -5607,7 +5838,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadEpub(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
|
private fun loadEpub(
|
||||||
|
uri: Uri,
|
||||||
|
bookId: String,
|
||||||
|
customDisplayName: String? = null,
|
||||||
|
bundleResult: CalibreBundleResult? = null,
|
||||||
|
persistToLibrary: Boolean = true
|
||||||
|
) {
|
||||||
val loadStart = System.currentTimeMillis()
|
val loadStart = System.currentTimeMillis()
|
||||||
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START")
|
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -5633,6 +5870,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
Timber.tag("FileOpenPerf")
|
Timber.tag("FileOpenPerf")
|
||||||
.d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
|
.d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
|
||||||
|
|
||||||
|
if (persistToLibrary) {
|
||||||
addFileToRecent(
|
addFileToRecent(
|
||||||
uri,
|
uri,
|
||||||
FileType.EPUB,
|
FileType.EPUB,
|
||||||
|
|
@ -5643,6 +5881,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
sourceFolderUri = null,
|
sourceFolderUri = null,
|
||||||
bundleResult = bundleResult
|
bundleResult = bundleResult
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
|
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
|
||||||
Timber.tag("FileOpenPerf")
|
Timber.tag("FileOpenPerf")
|
||||||
|
|
@ -7081,6 +7320,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
private const val KEY_LAST_OPEN_BOOK_ID = "last_open_book_id"
|
private const val KEY_LAST_OPEN_BOOK_ID = "last_open_book_id"
|
||||||
private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type"
|
private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type"
|
||||||
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior"
|
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior"
|
||||||
|
private const val EXTERNAL_FILE_BEHAVIOR_ASK = "ASK"
|
||||||
|
private const val EXTERNAL_FILE_BEHAVIOR_TEMPORARY = "TEMPORARY"
|
||||||
private const val KEY_PENDING_EXTERNAL_FILE_REMOVALS = "pending_external_file_removals"
|
private const val KEY_PENDING_EXTERNAL_FILE_REMOVALS = "pending_external_file_removals"
|
||||||
private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter"
|
private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter"
|
||||||
private const val KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME = "use_pdf_file_name_as_display_name"
|
private const val KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME = "use_pdf_file_name_as_display_name"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ 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 com.aryan.reader.data.RecentFileItem
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -64,28 +67,17 @@ internal fun ReaderFileInfoDialogs(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item?.let { fileInfoItem ->
|
HydratedFileInfoDialog(
|
||||||
if (isFileInfoVisible) {
|
item = item,
|
||||||
FileInfoDialog(
|
isVisible = isFileInfoVisible,
|
||||||
item = fileInfoItem,
|
uiState = uiState,
|
||||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
viewModel = viewModel,
|
||||||
onDismiss = { onFileInfoVisibleChange(false) },
|
onDismiss = { onFileInfoVisibleChange(false) },
|
||||||
onSaveMetadata = { metadata ->
|
onOpenTags = { bookId ->
|
||||||
viewModel.updateBookMetadata(fileInfoItem.bookId, metadata)
|
|
||||||
},
|
|
||||||
onSaveDisplayName = { name ->
|
|
||||||
viewModel.updateCustomName(fileInfoItem.bookId, name)
|
|
||||||
},
|
|
||||||
onRestoreMetadata = {
|
|
||||||
viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId)
|
|
||||||
},
|
|
||||||
onOpenTags = {
|
|
||||||
onFileInfoVisibleChange(false)
|
onFileInfoVisibleChange(false)
|
||||||
viewModel.openTagSelection(setOf(fileInfoItem.bookId))
|
viewModel.openTagSelection(setOf(bookId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
|
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
|
||||||
TagSelectionBottomSheet(
|
TagSelectionBottomSheet(
|
||||||
|
|
@ -102,3 +94,49 @@ internal fun ReaderFileInfoDialogs(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun HydratedFileInfoDialog(
|
||||||
|
item: RecentFileItem?,
|
||||||
|
isVisible: Boolean,
|
||||||
|
uiState: ReaderScreenState,
|
||||||
|
viewModel: MainViewModel,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onOpenTags: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var fileInfoItem by remember(item?.bookId) { mutableStateOf(item) }
|
||||||
|
var hasResolvedFullItem by remember(item?.bookId) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(item) {
|
||||||
|
fileInfoItem = item
|
||||||
|
hasResolvedFullItem = false
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isVisible, item?.bookId) {
|
||||||
|
if (isVisible && item != null) {
|
||||||
|
fileInfoItem = viewModel.getFileInfoItem(item.bookId)?.copy(tags = item.tags) ?: item
|
||||||
|
hasResolvedFullItem = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolvedItem = fileInfoItem
|
||||||
|
if (isVisible && resolvedItem != null && hasResolvedFullItem) {
|
||||||
|
FileInfoDialog(
|
||||||
|
item = resolvedItem,
|
||||||
|
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
onSaveMetadata = { metadata ->
|
||||||
|
viewModel.updateBookMetadata(resolvedItem.bookId, metadata)
|
||||||
|
},
|
||||||
|
onSaveDisplayName = { name ->
|
||||||
|
viewModel.updateCustomName(resolvedItem.bookId, name)
|
||||||
|
},
|
||||||
|
onRestoreMetadata = {
|
||||||
|
viewModel.restoreOriginalBookMetadata(resolvedItem.bookId)
|
||||||
|
},
|
||||||
|
onOpenTags = {
|
||||||
|
onOpenTags(resolvedItem.bookId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
15
app/src/main/java/com/aryan/reader/ReaderFontDiagnostics.kt
Normal file
15
app/src/main/java/com/aryan/reader/ReaderFontDiagnostics.kt
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.detectFontVariant
|
||||||
|
import com.aryan.reader.shared.familyFilenameSignature
|
||||||
|
import com.aryan.reader.shared.supportsVariableWeightAxis
|
||||||
|
|
||||||
|
const val ReaderFontDiagnosticsTag = "ReaderFontDiag"
|
||||||
|
|
||||||
|
fun readerFontDiagnosticSummary(nameWithoutExtension: String): String {
|
||||||
|
val variant = nameWithoutExtension.detectFontVariant()
|
||||||
|
return "name='$nameWithoutExtension' " +
|
||||||
|
"signature='${nameWithoutExtension.familyFilenameSignature()}' " +
|
||||||
|
"variant=$variant " +
|
||||||
|
"variableWght=${nameWithoutExtension.supportsVariableWeightAxis()}"
|
||||||
|
}
|
||||||
19
app/src/main/java/com/aryan/reader/ReaderPopupSizing.kt
Normal file
19
app/src/main/java/com/aryan/reader/ReaderPopupSizing.kt
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
fun readerModalMaxHeightDp(
|
||||||
|
screenHeightDp: Int,
|
||||||
|
fraction: Float = 0.85f,
|
||||||
|
verticalMarginDp: Int = 32,
|
||||||
|
preferredMinHeightDp: Int = 220
|
||||||
|
): Int {
|
||||||
|
val usableHeight = (screenHeightDp - verticalMarginDp).coerceAtLeast(1)
|
||||||
|
val proportionalHeight = (screenHeightDp * fraction).roundToInt().coerceAtLeast(1)
|
||||||
|
val cappedHeight = minOf(usableHeight, proportionalHeight)
|
||||||
|
return if (usableHeight >= preferredMinHeightDp) {
|
||||||
|
cappedHeight.coerceAtLeast(preferredMinHeightDp)
|
||||||
|
} else {
|
||||||
|
usableHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,7 @@ 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
|
||||||
|
|
@ -142,6 +143,7 @@ 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 com.aryan.reader.shared.SharedFileCapabilities
|
||||||
import com.aryan.reader.shared.SharedLegalLinks
|
import com.aryan.reader.shared.SharedLegalLinks
|
||||||
import com.aryan.reader.shared.SharedLegalProfile
|
import com.aryan.reader.shared.SharedLegalProfile
|
||||||
import com.aryan.reader.data.BookMetadataEdit
|
import com.aryan.reader.data.BookMetadataEdit
|
||||||
|
|
@ -274,6 +276,8 @@ 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,
|
||||||
|
|
@ -302,6 +306,16 @@ 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))
|
||||||
|
|
@ -1584,6 +1598,31 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,8 @@ val supportedAppLanguageOptions = listOf(
|
||||||
"中文",
|
"中文",
|
||||||
"简体中文",
|
"简体中文",
|
||||||
)
|
)
|
||||||
)
|
),
|
||||||
|
AppLanguageOption("et", R.string.language_estonian, listOf("estonian", "eesti"))
|
||||||
)
|
)
|
||||||
|
|
||||||
val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions
|
val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,15 @@ import android.provider.OpenableColumns
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import com.aryan.reader.ReaderFontDiagnosticsTag
|
||||||
|
import com.aryan.reader.readerFontDiagnosticSummary
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
private const val FONTS_DIR = "custom_fonts"
|
private const val FONTS_DIR = "custom_fonts"
|
||||||
|
private const val MAX_IMPORTED_FONT_BASENAME_LENGTH = 120
|
||||||
|
|
||||||
class FontsRepository(private val context: Context) {
|
class FontsRepository(private val context: Context) {
|
||||||
private val fontDao = AppDatabase.getDatabase(context).customFontDao()
|
private val fontDao = AppDatabase.getDatabase(context).customFontDao()
|
||||||
|
|
@ -73,14 +76,23 @@ class FontsRepository(private val context: Context) {
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
val originalName = getFileName(uri) ?: "unknown.ttf"
|
val originalName = getFileName(uri) ?: "unknown.ttf"
|
||||||
val extension = originalName.substringAfterLast('.', "").lowercase()
|
val extension = originalName.substringAfterLast('.', "").lowercase()
|
||||||
|
val displayName = originalName.substringBeforeLast('.')
|
||||||
|
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"import.start originalName='$originalName' extension='$extension' " +
|
||||||
|
readerFontDiagnosticSummary(displayName)
|
||||||
|
)
|
||||||
|
|
||||||
if (extension !in listOf("ttf", "otf", "woff2")) {
|
if (extension !in listOf("ttf", "otf", "woff2")) {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).w(
|
||||||
|
"import.unsupported originalName='$originalName' extension='$extension'"
|
||||||
|
)
|
||||||
return@withContext Result.failure(Exception("Unsupported font format. Please use TTF, OTF, or WOFF2."))
|
return@withContext Result.failure(Exception("Unsupported font format. Please use TTF, OTF, or WOFF2."))
|
||||||
}
|
}
|
||||||
|
|
||||||
val fontId = UUID.randomUUID().toString()
|
val fontId = UUID.randomUUID().toString()
|
||||||
val internalFileName = "font_${fontId}.$extension"
|
val destinationFile = uniqueImportedFontFile(displayName, extension, fontId)
|
||||||
val destinationFile = File(fontsDir, internalFileName)
|
val internalFileName = destinationFile.name
|
||||||
|
|
||||||
contentResolver.openInputStream(uri)?.use { input ->
|
contentResolver.openInputStream(uri)?.use { input ->
|
||||||
FileOutputStream(destinationFile).use { output ->
|
FileOutputStream(destinationFile).use { output ->
|
||||||
|
|
@ -88,8 +100,6 @@ class FontsRepository(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val displayName = originalName.substringBeforeLast('.')
|
|
||||||
|
|
||||||
val entity = CustomFontEntity(
|
val entity = CustomFontEntity(
|
||||||
id = fontId,
|
id = fontId,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
|
|
@ -100,10 +110,16 @@ class FontsRepository(private val context: Context) {
|
||||||
)
|
)
|
||||||
|
|
||||||
fontDao.insertFont(entity)
|
fontDao.insertFont(entity)
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"import.saved displayName='$displayName' internalFileName='$internalFileName' " +
|
||||||
|
"exists=${destinationFile.exists()} bytes=${destinationFile.length()} " +
|
||||||
|
"path='${destinationFile.absolutePath}'"
|
||||||
|
)
|
||||||
Timber.d("Imported font: $displayName to ${destinationFile.absolutePath}")
|
Timber.d("Imported font: $displayName to ${destinationFile.absolutePath}")
|
||||||
|
|
||||||
Result.success(entity)
|
Result.success(entity)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).e(e, "import.failed uri='$uri'")
|
||||||
Timber.e(e, "Failed to import font")
|
Timber.e(e, "Failed to import font")
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
|
|
@ -130,6 +146,18 @@ class FontsRepository(private val context: Context) {
|
||||||
fontDao.deletePermanently(fontId)
|
fontDao.deletePermanently(fontId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun uniqueImportedFontFile(displayName: String, extension: String, fontId: String): File {
|
||||||
|
val preferredFileName = importedFontFileName(displayName, extension)
|
||||||
|
val preferredFile = File(fontsDir, preferredFileName)
|
||||||
|
if (!preferredFile.exists()) return preferredFile
|
||||||
|
|
||||||
|
val fallbackFileName = importedFontFileName(
|
||||||
|
displayName = "${displayName}_${fontId.take(8)}",
|
||||||
|
extension = extension
|
||||||
|
)
|
||||||
|
return File(fontsDir, fallbackFileName)
|
||||||
|
}
|
||||||
|
|
||||||
private fun getFileName(uri: Uri): String? {
|
private fun getFileName(uri: Uri): String? {
|
||||||
var result: String? = null
|
var result: String? = null
|
||||||
if (uri.scheme == "content") {
|
if (uri.scheme == "content") {
|
||||||
|
|
@ -153,3 +181,17 @@ class FontsRepository(private val context: Context) {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun importedFontFileName(displayName: String, extension: String): String {
|
||||||
|
val safeBaseName = displayName
|
||||||
|
.replace(Regex("""[\\/:*?"<>|\p{Cntrl}]"""), "_")
|
||||||
|
.replace(Regex("""\s+"""), " ")
|
||||||
|
.trim(' ', '.')
|
||||||
|
.take(MAX_IMPORTED_FONT_BASENAME_LENGTH)
|
||||||
|
.ifBlank { "font" }
|
||||||
|
val safeExtension = extension
|
||||||
|
.lowercase()
|
||||||
|
.replace(Regex("""[^a-z0-9]"""), "")
|
||||||
|
.ifBlank { "ttf" }
|
||||||
|
return "$safeBaseName.$safeExtension"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ interface RecentFileDao {
|
||||||
@Upsert
|
@Upsert
|
||||||
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
|
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
|
||||||
|
|
||||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
|
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, substr(description, 1, 4096) AS description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, substr(originalDescription, 1, 4096) AS originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
|
||||||
fun getRecentFiles(): Flow<List<RecentFileSummary>>
|
fun getRecentFiles(): Flow<List<RecentFileSummary>>
|
||||||
|
|
||||||
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
|
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
|
||||||
|
|
@ -45,7 +45,7 @@ interface RecentFileDao {
|
||||||
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
|
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
|
||||||
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
|
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
|
||||||
|
|
||||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
|
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, substr(description, 1, 4096) AS description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, substr(originalDescription, 1, 4096) AS originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
|
||||||
fun getRecentFilesList(limit: Int): List<RecentFileSummary>
|
fun getRecentFilesList(limit: Int): List<RecentFileSummary>
|
||||||
|
|
||||||
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
|
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
|
||||||
|
|
|
||||||
|
|
@ -457,19 +457,17 @@ class EpubParser(private val context: Context) {
|
||||||
var pageTargets: List<EpubPageTarget> = emptyList()
|
var pageTargets: List<EpubPageTarget> = emptyList()
|
||||||
val ncxMetadataMap = mutableMapOf<String, NcxMetadata>()
|
val ncxMetadataMap = mutableMapOf<String, NcxMetadata>()
|
||||||
val extractionRoot = File(extractionBasePath)
|
val extractionRoot = File(extractionBasePath)
|
||||||
|
val tocFileItem = if (shouldUseToc) resolveTocFileItem(document.spine, manifestItems) else null
|
||||||
|
val tocDocumentNode = tocFileItem?.let { item ->
|
||||||
|
val ncxData = filesContentMap[item.absPath]?.data?.takeIf { it.isNotEmpty() }
|
||||||
|
?: File(extractionRoot, item.absPath).takeIf { it.exists() }?.readBytes()
|
||||||
|
ncxData?.let { parseXMLFile(it) }
|
||||||
|
}
|
||||||
|
val ncxParentDir = tocFileItem?.let { File(it.absPath).parentFile ?: File("") }
|
||||||
|
|
||||||
if (shouldUseToc) {
|
if (shouldUseToc) {
|
||||||
Timber.d("shouldUseToc is true. Attempting to parse NCX.")
|
Timber.d("shouldUseToc is true. Attempting to parse NCX.")
|
||||||
val tocFileItem = manifestItems.values.firstOrNull {
|
if (tocFileItem != null && ncxParentDir != null) {
|
||||||
it.absPath.endsWith(".ncx", ignoreCase = true)
|
|
||||||
}
|
|
||||||
if (tocFileItem != null) {
|
|
||||||
val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
|
|
||||||
val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
|
|
||||||
?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
|
|
||||||
|
|
||||||
val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
|
|
||||||
|
|
||||||
if (tocDocumentNode != null) {
|
if (tocDocumentNode != null) {
|
||||||
Timber.d("Successfully parsed NCX file: ${tocFileItem.absPath}")
|
Timber.d("Successfully parsed NCX file: ${tocFileItem.absPath}")
|
||||||
val pageListElement = tocDocumentNode.selectFirstTag("pageList") as Element?
|
val pageListElement = tocDocumentNode.selectFirstTag("pageList") as Element?
|
||||||
|
|
@ -495,15 +493,8 @@ class EpubParser(private val context: Context) {
|
||||||
Timber.d("Parsing chapters based on OPF spine for rendering order. NCX titles/depth will be used if available.")
|
Timber.d("Parsing chapters based on OPF spine for rendering order. NCX titles/depth will be used if available.")
|
||||||
|
|
||||||
val tableOfContents = if (shouldUseToc) {
|
val tableOfContents = if (shouldUseToc) {
|
||||||
val tocFileItem = manifestItems.values.firstOrNull {
|
if (tocDocumentNode != null && ncxParentDir != null) {
|
||||||
it.absPath.endsWith(".ncx", ignoreCase = true)
|
val navMapElement = tocDocumentNode.selectFirstTag("navMap") as Element?
|
||||||
}
|
|
||||||
if (tocFileItem != null) {
|
|
||||||
val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
|
|
||||||
val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
|
|
||||||
?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
|
|
||||||
val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
|
|
||||||
val navMapElement = tocDocumentNode?.selectFirstTag("navMap") as Element?
|
|
||||||
|
|
||||||
if (navMapElement != null) {
|
if (navMapElement != null) {
|
||||||
parseTableOfContents(navMapElement, ncxParentDir)
|
parseTableOfContents(navMapElement, ncxParentDir)
|
||||||
|
|
@ -592,6 +583,21 @@ class EpubParser(private val context: Context) {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveTocFileItem(
|
||||||
|
spine: Node,
|
||||||
|
manifestItems: Map<String, EpubManifestItem>
|
||||||
|
): EpubManifestItem? {
|
||||||
|
spine.getAttributeValue("toc")
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { tocId -> manifestItems[tocId] }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
return manifestItems.values.firstOrNull {
|
||||||
|
it.mediaType.equals("application/x-dtbncx+xml", ignoreCase = true)
|
||||||
|
} ?: manifestItems.values.firstOrNull {
|
||||||
|
it.absPath.endsWith(".ncx", ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Throws(EpubParserException::class)
|
@Throws(EpubParserException::class)
|
||||||
private fun createEpubDocument(files: Map<String, EpubFile>): EpubDocument {
|
private fun createEpubDocument(files: Map<String, EpubFile>): EpubDocument {
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ class OdtParser(private val context: Context) {
|
||||||
|
|
||||||
val mathJaxFileName = "tex-mml-chtml.js"
|
val mathJaxFileName = "tex-mml-chtml.js"
|
||||||
val mathJaxFile = File(extractionDir, mathJaxFileName)
|
val mathJaxFile = File(extractionDir, mathJaxFileName)
|
||||||
if (!mathJaxFile.exists()) {
|
if (parseContent && !mathJaxFile.exists()) {
|
||||||
try {
|
try {
|
||||||
context.assets.open("mathjax/$mathJaxFileName").use { input ->
|
context.assets.open("mathjax/$mathJaxFileName").use { input ->
|
||||||
FileOutputStream(mathJaxFile).use { output ->
|
FileOutputStream(mathJaxFile).use { output ->
|
||||||
|
|
@ -170,12 +170,12 @@ class OdtParser(private val context: Context) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!isFlat) {
|
if (!isFlat) {
|
||||||
val zis = ZipInputStream(inputStream)
|
|
||||||
var entry = zis.nextEntry
|
|
||||||
var contentXmlBytes: ByteArray? = null
|
var contentXmlBytes: ByteArray? = null
|
||||||
var stylesXmlBytes: ByteArray? = null
|
var stylesXmlBytes: ByteArray? = null
|
||||||
val ignoredFiles = setOf("meta.xml", "settings.xml", "META-INF/manifest.xml")
|
val ignoredFiles = setOf("meta.xml", "settings.xml", "META-INF/manifest.xml")
|
||||||
|
|
||||||
|
ZipInputStream(inputStream).use { zis ->
|
||||||
|
var entry = zis.nextEntry
|
||||||
while (entry != null) {
|
while (entry != null) {
|
||||||
if (!entry.isDirectory) {
|
if (!entry.isDirectory) {
|
||||||
when (entry.name) {
|
when (entry.name) {
|
||||||
|
|
@ -197,6 +197,7 @@ class OdtParser(private val context: Context) {
|
||||||
}
|
}
|
||||||
entry = zis.nextEntry
|
entry = zis.nextEntry
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-parse styles if available
|
// Pre-parse styles if available
|
||||||
stylesXmlBytes?.let { extractStyles(it.inputStream()) }
|
stylesXmlBytes?.let { extractStyles(it.inputStream()) }
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,6 @@ package com.aryan.reader.epubreader
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
|
@ -38,8 +36,6 @@ import android.widget.Toast
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
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
|
||||||
|
|
@ -52,8 +48,10 @@ import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -76,6 +74,7 @@ import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntRect
|
import androidx.compose.ui.unit.IntRect
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
|
@ -86,7 +85,13 @@ import androidx.compose.ui.window.Popup
|
||||||
import androidx.compose.ui.window.PopupPositionProvider
|
import androidx.compose.ui.window.PopupPositionProvider
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
|
import com.aryan.reader.ReaderFontDiagnosticsTag
|
||||||
|
import com.aryan.reader.copyPlainTextToClipboard
|
||||||
import com.aryan.reader.getReaderTextureDataUri
|
import com.aryan.reader.getReaderTextureDataUri
|
||||||
|
import com.aryan.reader.readerFontDiagnosticSummary
|
||||||
|
import com.aryan.reader.shared.detectFontVariant
|
||||||
|
import com.aryan.reader.shared.familyFilenameSignature
|
||||||
|
import com.aryan.reader.shared.fontWeightCssDescriptor
|
||||||
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
|
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
|
||||||
import com.aryan.reader.shared.ui.SharedSelectionMenuSize
|
import com.aryan.reader.shared.ui.SharedSelectionMenuSize
|
||||||
import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
|
import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
|
||||||
|
|
@ -96,6 +101,7 @@ import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
|
import java.io.File
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
|
|
||||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||||
|
|
@ -202,6 +208,49 @@ private fun getFontCssInjection(): String {
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildCustomFontCssForWebView(customFontPath: String?, phase: String): String {
|
||||||
|
if (customFontPath == null) return ""
|
||||||
|
|
||||||
|
val fontFile = File(customFontPath)
|
||||||
|
val signature = fontFile.nameWithoutExtension.familyFilenameSignature()
|
||||||
|
val siblings = fontFile.parentFile?.listFiles()?.filter {
|
||||||
|
it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
|
||||||
|
it.nameWithoutExtension.familyFilenameSignature() == signature
|
||||||
|
} ?: listOf(fontFile)
|
||||||
|
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"webview.$phase.customCss.start basePath='${fontFile.absolutePath}' " +
|
||||||
|
"exists=${fontFile.exists()} bytes=${fontFile.length()} " +
|
||||||
|
readerFontDiagnosticSummary(fontFile.nameWithoutExtension) +
|
||||||
|
" siblings=${siblings.joinToString { it.name }}"
|
||||||
|
)
|
||||||
|
|
||||||
|
val css = siblings.mapNotNull { sibling ->
|
||||||
|
val variant = sibling.nameWithoutExtension.detectFontVariant()
|
||||||
|
if (variant == null) {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).w(
|
||||||
|
"webview.$phase.customCss.skipNoVariant file='${sibling.name}' " +
|
||||||
|
readerFontDiagnosticSummary(sibling.nameWithoutExtension)
|
||||||
|
)
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
|
val weight = sibling.nameWithoutExtension.fontWeightCssDescriptor(variant.weight)
|
||||||
|
val style = if (variant.style == FontStyle.Italic) "italic" else "normal"
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"webview.$phase.customCss.face file='${sibling.name}' fontWeight='$weight' fontStyle='$style' " +
|
||||||
|
readerFontDiagnosticSummary(sibling.nameWithoutExtension)
|
||||||
|
)
|
||||||
|
"@font-face { font-family: 'CustomFont'; src: url('file://${sibling.absolutePath}'); font-weight: $weight; font-style: $style; }"
|
||||||
|
}.joinToString(" ")
|
||||||
|
|
||||||
|
val faceCount = Regex("@font-face").findAll(css).count()
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"webview.$phase.customCss.done faceCount=$faceCount cssLength=${css.length}"
|
||||||
|
)
|
||||||
|
return css
|
||||||
|
}
|
||||||
|
|
||||||
private fun getJsToInject(context: Context): String {
|
private fun getJsToInject(context: Context): String {
|
||||||
return try {
|
return try {
|
||||||
context.assets.open("epub_reader.js").use { inputStream ->
|
context.assets.open("epub_reader.js").use { inputStream ->
|
||||||
|
|
@ -514,6 +563,7 @@ fun ChapterWebView(
|
||||||
onFootnoteRequested: (String) -> Unit,
|
onFootnoteRequested: (String) -> Unit,
|
||||||
currentFontFamily: ReaderFont,
|
currentFontFamily: ReaderFont,
|
||||||
customFontPath: String? = null,
|
customFontPath: String? = null,
|
||||||
|
epubFontFaceCss: String = "",
|
||||||
currentTextAlign: ReaderTextAlign,
|
currentTextAlign: ReaderTextAlign,
|
||||||
onHighlightClicked: () -> Unit,
|
onHighlightClicked: () -> Unit,
|
||||||
onAutoScrollChapterEnd: () -> Unit = {},
|
onAutoScrollChapterEnd: () -> Unit = {},
|
||||||
|
|
@ -578,10 +628,14 @@ fun ChapterWebView(
|
||||||
showExternalLinkDialog = null
|
showExternalLinkDialog = null
|
||||||
}) { Text(stringResource(R.string.action_open)) }
|
}) { Text(stringResource(R.string.action_open)) }
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
val clipboard =
|
val copied = copyPlainTextToClipboard(
|
||||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
context = context,
|
||||||
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow)
|
label = context.getString(R.string.clip_label_copied_link),
|
||||||
clipboard.setPrimaryClip(clip)
|
text = urlToShow
|
||||||
|
)
|
||||||
|
if (!copied) {
|
||||||
|
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
showExternalLinkDialog = null
|
showExternalLinkDialog = null
|
||||||
}) { Text(stringResource(R.string.action_copy)) }
|
}) { Text(stringResource(R.string.action_copy)) }
|
||||||
}
|
}
|
||||||
|
|
@ -927,13 +981,14 @@ fun ChapterWebView(
|
||||||
)
|
)
|
||||||
|
|
||||||
val fontCss = getFontCssInjection().replace("\n", " ")
|
val fontCss = getFontCssInjection().replace("\n", " ")
|
||||||
val customFontCss = if (customFontPath != null) {
|
val customFontCss = buildCustomFontCssForWebView(customFontPath, "initial")
|
||||||
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
|
val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss)
|
||||||
} else ""
|
.filter { it.isNotBlank() }
|
||||||
val combinedCss = "$fontCss $customFontCss"
|
.joinToString(separator = " ")
|
||||||
|
val escapedCombinedCss = escapeJsString(combinedCss)
|
||||||
|
|
||||||
val injectFontJs =
|
val injectFontJs =
|
||||||
"var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$combinedCss\"; document.head.appendChild(style);"
|
"var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$escapedCombinedCss\"; document.head.appendChild(style);"
|
||||||
view?.evaluateJavascript("javascript:$injectFontJs") {
|
view?.evaluateJavascript("javascript:$injectFontJs") {
|
||||||
Timber.d("CSS Injection result: $it")
|
Timber.d("CSS Injection result: $it")
|
||||||
}
|
}
|
||||||
|
|
@ -1125,10 +1180,10 @@ fun ChapterWebView(
|
||||||
runtimeApplierState.logPending(chapterTitle)
|
runtimeApplierState.logPending(chapterTitle)
|
||||||
} else {
|
} else {
|
||||||
val fontCss = getFontCssInjection().replace("\n", " ")
|
val fontCss = getFontCssInjection().replace("\n", " ")
|
||||||
val customFontCss = if (customFontPath != null) {
|
val customFontCss = buildCustomFontCssForWebView(customFontPath, "runtime")
|
||||||
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
|
val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss)
|
||||||
} else ""
|
.filter { it.isNotBlank() }
|
||||||
val combinedCss = "$fontCss $customFontCss"
|
.joinToString(separator = " ")
|
||||||
val fontNameForJs = if (customFontPath != null) {
|
val fontNameForJs = if (customFontPath != null) {
|
||||||
"CustomFont"
|
"CustomFont"
|
||||||
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
|
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
|
||||||
|
|
@ -1166,8 +1221,9 @@ fun ChapterWebView(
|
||||||
|
|
||||||
if (fontCssChanged) {
|
if (fontCssChanged) {
|
||||||
runtimeApplierState.fontCss = combinedCss
|
runtimeApplierState.fontCss = combinedCss
|
||||||
|
val escapedCombinedCss = escapeJsString(combinedCss)
|
||||||
val injectFontJs =
|
val injectFontJs =
|
||||||
"var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";"
|
"var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$escapedCombinedCss\";"
|
||||||
webView.evaluateJavascript("javascript:$injectFontJs", null)
|
webView.evaluateJavascript("javascript:$injectFontJs", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1304,10 +1360,14 @@ fun ChapterWebView(
|
||||||
) {
|
) {
|
||||||
PaginatedTextSelectionMenu(
|
PaginatedTextSelectionMenu(
|
||||||
onCopy = {
|
onCopy = {
|
||||||
val clipboard =
|
val copied = copyPlainTextToClipboard(
|
||||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
context = context,
|
||||||
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), state.selectedText)
|
label = context.getString(R.string.clip_label_copied_text),
|
||||||
clipboard.setPrimaryClip(clip)
|
text = state.selectedText
|
||||||
|
)
|
||||||
|
if (!copied) {
|
||||||
|
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
state.finishActionModeCallback()
|
state.finishActionModeCallback()
|
||||||
localWebViewRef?.clearFocus()
|
localWebViewRef?.clearFocus()
|
||||||
localWebViewRef?.evaluateJavascript(
|
localWebViewRef?.evaluateJavascript(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
|
@ -20,12 +23,14 @@ import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.MenuAnchorType
|
import androidx.compose.material3.MenuAnchorType
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
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.getValue
|
||||||
|
|
@ -35,14 +40,15 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
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
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.areReaderAiFeaturesEnabled
|
import com.aryan.reader.areReaderAiFeaturesEnabled
|
||||||
|
import com.aryan.reader.readerModalMaxHeightDp
|
||||||
|
|
||||||
@Suppress("KotlinConstantConditions")
|
@Suppress("KotlinConstantConditions")
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -65,22 +71,25 @@ fun DictionarySettingsDialog(
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var dictionaryApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
|
var dictionaryApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
|
||||||
var searchApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
|
var searchApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
dictionaryApps = ExternalDictionaryHelper.getAvailableDictionaries(context)
|
dictionaryApps = ExternalDictionaryHelper.getAvailableDictionaries(context)
|
||||||
searchApps = ExternalDictionaryHelper.getAvailableSearchApps(context)
|
searchApps = ExternalDictionaryHelper.getAvailableSearchApps(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
ModalBottomSheet(
|
||||||
Surface(
|
onDismissRequest = onDismiss,
|
||||||
shape = RoundedCornerShape(24.dp),
|
sheetState = sheetState,
|
||||||
color = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
tonalElevation = 6.dp,
|
contentWindowInsets = { WindowInsets.navigationBars }
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = maxSheetHeight)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(24.dp)
|
.padding(24.dp)
|
||||||
) {
|
) {
|
||||||
|
|
@ -215,7 +224,6 @@ fun DictionarySettingsDialog(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionDivider() {
|
private fun SectionDivider() {
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,6 @@ package com.aryan.reader.epubreader
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
|
|
@ -140,6 +138,7 @@ import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
|
@ -154,6 +153,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.aryan.reader.AiDefinitionResult
|
import com.aryan.reader.AiDefinitionResult
|
||||||
import com.aryan.reader.BuildConfig
|
import com.aryan.reader.BuildConfig
|
||||||
|
import com.aryan.reader.copyPlainTextToClipboard
|
||||||
import com.aryan.reader.BookWordReplacementsSheet
|
import com.aryan.reader.BookWordReplacementsSheet
|
||||||
import com.aryan.reader.BuiltInThemes
|
import com.aryan.reader.BuiltInThemes
|
||||||
import com.aryan.reader.MainViewModel
|
import com.aryan.reader.MainViewModel
|
||||||
|
|
@ -191,6 +191,7 @@ import com.aryan.reader.loadTtsReplacementPreferences
|
||||||
import com.aryan.reader.readerSliderBookmarkPosition
|
import com.aryan.reader.readerSliderBookmarkPosition
|
||||||
import com.aryan.reader.readerSliderChromeColors
|
import com.aryan.reader.readerSliderChromeColors
|
||||||
import com.aryan.reader.readerSliderToggleState
|
import com.aryan.reader.readerSliderToggleState
|
||||||
|
import com.aryan.reader.paginatedreader.CssParser
|
||||||
import com.aryan.reader.paginatedreader.BookPaginator
|
import com.aryan.reader.paginatedreader.BookPaginator
|
||||||
import com.aryan.reader.paginatedreader.HeaderBlock
|
import com.aryan.reader.paginatedreader.HeaderBlock
|
||||||
import com.aryan.reader.paginatedreader.IPaginator
|
import com.aryan.reader.paginatedreader.IPaginator
|
||||||
|
|
@ -204,7 +205,10 @@ import com.aryan.reader.paginatedreader.ParagraphBlock
|
||||||
import com.aryan.reader.paginatedreader.QuoteBlock
|
import com.aryan.reader.paginatedreader.QuoteBlock
|
||||||
import com.aryan.reader.paginatedreader.TextContentBlock
|
import com.aryan.reader.paginatedreader.TextContentBlock
|
||||||
import com.aryan.reader.paginatedreader.TtsChunk
|
import com.aryan.reader.paginatedreader.TtsChunk
|
||||||
|
import com.aryan.reader.paginatedreader.buildEpubFontFaceCss
|
||||||
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
||||||
|
import com.aryan.reader.paginatedreader.locatorForPersistence
|
||||||
|
import com.aryan.reader.paginatedreader.nativeVerticalChapterPageInfo
|
||||||
import com.aryan.reader.paginatedreader.nativeVerticalProgressForCompatPage
|
import com.aryan.reader.paginatedreader.nativeVerticalProgressForCompatPage
|
||||||
import com.aryan.reader.paginatedreader.semanticBlockModule
|
import com.aryan.reader.paginatedreader.semanticBlockModule
|
||||||
import com.aryan.reader.rememberSearchState
|
import com.aryan.reader.rememberSearchState
|
||||||
|
|
@ -243,6 +247,7 @@ import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.protobuf.ProtoBuf
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
|
import org.jsoup.Jsoup
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -1127,6 +1132,20 @@ fun EpubReaderHost(
|
||||||
var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
|
var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
|
||||||
var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
|
var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
|
||||||
var chapterHead by remember(currentChapterIndex) { mutableStateOf("") }
|
var chapterHead by remember(currentChapterIndex) { mutableStateOf("") }
|
||||||
|
val epubFontFaceCss = remember(epubBook.css, epubBook.extractionBasePath) {
|
||||||
|
val fontFaces = epubBook.css.flatMap { (path, content) ->
|
||||||
|
CssParser.parse(
|
||||||
|
cssContent = content,
|
||||||
|
cssPath = path,
|
||||||
|
baseFontSizeSp = 16f,
|
||||||
|
density = 1f,
|
||||||
|
constraints = Constraints(maxWidth = 1, maxHeight = 1),
|
||||||
|
isDarkTheme = false,
|
||||||
|
adaptThemeColors = false
|
||||||
|
).fontFaces
|
||||||
|
}
|
||||||
|
buildEpubFontFaceCss(fontFaces, epubBook.extractionBasePath)
|
||||||
|
}
|
||||||
var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) }
|
var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) }
|
||||||
|
|
||||||
var cfiToLoad by remember { mutableStateOf(initialCfi) }
|
var cfiToLoad by remember { mutableStateOf(initialCfi) }
|
||||||
|
|
@ -1175,7 +1194,7 @@ fun EpubReaderHost(
|
||||||
fun currentNativeVerticalLocator(): Locator? {
|
fun currentNativeVerticalLocator(): Locator? {
|
||||||
val bookPaginator = paginator as? BookPaginator
|
val bookPaginator = paginator as? BookPaginator
|
||||||
val pageChapterIndex = bookPaginator?.findChapterIndexForPage(nativeVerticalCurrentPage)
|
val pageChapterIndex = bookPaginator?.findChapterIndexForPage(nativeVerticalCurrentPage)
|
||||||
return nativeVerticalLocation?.locator
|
return nativeVerticalLocation?.locatorForPersistence()
|
||||||
?: lastKnownLocator?.takeIf { pageChapterIndex == null || it.chapterIndex == pageChapterIndex }
|
?: lastKnownLocator?.takeIf { pageChapterIndex == null || it.chapterIndex == pageChapterIndex }
|
||||||
?: bookPaginator?.getLocatorForPage(nativeVerticalCurrentPage)
|
?: bookPaginator?.getLocatorForPage(nativeVerticalCurrentPage)
|
||||||
}
|
}
|
||||||
|
|
@ -1187,6 +1206,8 @@ fun EpubReaderHost(
|
||||||
keepVisible: Boolean = false
|
keepVisible: Boolean = false
|
||||||
) {
|
) {
|
||||||
if (locator != null) {
|
if (locator != null) {
|
||||||
|
nativeVerticalScrollRequest = null
|
||||||
|
nativeVerticalProgressScrollRequest = null
|
||||||
nativeVerticalLocatorScrollRequest = locator
|
nativeVerticalLocatorScrollRequest = locator
|
||||||
nativeVerticalLocatorScrollRequestId += 1L
|
nativeVerticalLocatorScrollRequestId += 1L
|
||||||
nativeVerticalLocatorScrollKeepVisible = keepVisible
|
nativeVerticalLocatorScrollKeepVisible = keepVisible
|
||||||
|
|
@ -2110,6 +2131,44 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val nativeVerticalDisplayPageInfo = remember(
|
||||||
|
isNativeVerticalMode,
|
||||||
|
nativeVerticalLocation,
|
||||||
|
nativeVerticalCurrentPage,
|
||||||
|
nativeVerticalTotalPages,
|
||||||
|
currentChapterIndex,
|
||||||
|
lastKnownLocator,
|
||||||
|
paginator
|
||||||
|
) {
|
||||||
|
if (!isNativeVerticalMode) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
nativeVerticalLocation?.chapterPageInfo ?: run {
|
||||||
|
val bookPaginator = paginator as? BookPaginator
|
||||||
|
val locationLocator = nativeVerticalLocation?.locator
|
||||||
|
val chapterIndex = nativeVerticalLocation?.chapterIndex
|
||||||
|
?: locationLocator?.chapterIndex
|
||||||
|
?: lastKnownLocator?.chapterIndex
|
||||||
|
?: currentChapterIndex
|
||||||
|
val locatorForChapter = locationLocator
|
||||||
|
?.takeIf { it.chapterIndex == chapterIndex }
|
||||||
|
?: lastKnownLocator?.takeIf { it.chapterIndex == chapterIndex }
|
||||||
|
val chapterLengthChars = chapters
|
||||||
|
.getOrNull(chapterIndex)
|
||||||
|
?.plainTextCharacterCount()
|
||||||
|
?: 0
|
||||||
|
|
||||||
|
nativeVerticalChapterPageInfo(
|
||||||
|
chapterCharOffset = locatorForChapter?.charOffset,
|
||||||
|
chapterLengthChars = chapterLengthChars,
|
||||||
|
chapterPageCount = bookPaginator?.chapterPageCounts?.get(chapterIndex),
|
||||||
|
compatPageIndex = nativeVerticalCurrentPage,
|
||||||
|
chapterStartPageIndex = bookPaginator?.chapterStartPageIndices?.get(chapterIndex)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun currentEpubSliderPage(): Int {
|
fun currentEpubSliderPage(): Int {
|
||||||
return when (currentRenderMode) {
|
return when (currentRenderMode) {
|
||||||
RenderMode.VERTICAL_SCROLL -> if (isNativeVerticalMode) {
|
RenderMode.VERTICAL_SCROLL -> if (isNativeVerticalMode) {
|
||||||
|
|
@ -4280,6 +4339,74 @@ fun EpubReaderHost(
|
||||||
val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel()
|
val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel()
|
||||||
val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel()
|
val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel()
|
||||||
val isEpubJumpHistoryVisible = showBars && !searchState.isSearchActive && (epubJumpBackLabel != null || epubJumpForwardLabel != null)
|
val isEpubJumpHistoryVisible = showBars && !searchState.isSearchActive && (epubJumpBackLabel != null || epubJumpForwardLabel != null)
|
||||||
|
val keyboardLineScrollPx = with(density) {
|
||||||
|
(configuration.screenHeightDp.dp.toPx() * 0.16f).roundToInt().coerceAtLeast(96)
|
||||||
|
}
|
||||||
|
val keyboardPageScrollPx = with(density) {
|
||||||
|
(configuration.screenHeightDp.dp.toPx() * 0.82f).roundToInt().coerceAtLeast(keyboardLineScrollPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scrollVerticalReaderBy(deltaPx: Int) {
|
||||||
|
if (isNativeVerticalMode) {
|
||||||
|
nativeVerticalScrollDeltaRequestId += 1L
|
||||||
|
nativeVerticalScrollDeltaAnimated = false
|
||||||
|
nativeVerticalScrollDeltaRequest = deltaPx.toFloat()
|
||||||
|
} else {
|
||||||
|
webViewRefForTts?.evaluateJavascript(
|
||||||
|
"window.scrollBy({ top: $deltaPx, behavior: 'smooth' });",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateReaderPage(targetPage: Int) {
|
||||||
|
when {
|
||||||
|
isNativeVerticalMode -> {
|
||||||
|
val lastPage = (nativeVerticalTotalPages - 1).coerceAtLeast(0)
|
||||||
|
nativeVerticalScrollRequest = targetPage.coerceIn(0, lastPage)
|
||||||
|
}
|
||||||
|
currentRenderMode == RenderMode.VERTICAL_SCROLL -> {
|
||||||
|
scrollVerticalReaderBy((targetPage - nativeVerticalCurrentPage).coerceIn(-1, 1) * keyboardPageScrollPx)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
scope.launch {
|
||||||
|
val pageCount = paginatedPagerState.pageCount
|
||||||
|
if (pageCount <= 0) return@launch
|
||||||
|
val page = targetPage.coerceIn(0, pageCount - 1)
|
||||||
|
if (page != paginatedPagerState.currentPage) {
|
||||||
|
if (isPageTurnAnimationEnabled) {
|
||||||
|
paginatedPagerState.animateScrollToPage(page, animationSpec = tween(700))
|
||||||
|
} else {
|
||||||
|
paginatedPagerState.scrollToPage(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateReaderPageBy(delta: Int) {
|
||||||
|
when {
|
||||||
|
isNativeVerticalMode -> navigateReaderPage(nativeVerticalCurrentPage + delta)
|
||||||
|
currentRenderMode == RenderMode.VERTICAL_SCROLL -> scrollVerticalReaderBy(delta * keyboardPageScrollPx)
|
||||||
|
else -> navigateReaderPage(paginatedPagerState.currentPage + delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateReaderBoundary(first: Boolean) {
|
||||||
|
when {
|
||||||
|
isNativeVerticalMode -> navigateReaderPage(if (first) 0 else nativeVerticalTotalPages - 1)
|
||||||
|
currentRenderMode == RenderMode.VERTICAL_SCROLL -> {
|
||||||
|
val script = if (first) {
|
||||||
|
"window.scrollTo({ top: 0, behavior: 'smooth' });"
|
||||||
|
} else {
|
||||||
|
"window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });"
|
||||||
|
}
|
||||||
|
webViewRefForTts?.evaluateJavascript(script, null)
|
||||||
|
}
|
||||||
|
else -> navigateReaderPage(if (first) 0 else paginatedPagerState.pageCount - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -4363,6 +4490,17 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.epubReaderKeyboardNavigationHandler(
|
||||||
|
enabled = !searchState.isSearchActive,
|
||||||
|
renderMode = currentRenderMode,
|
||||||
|
isRightToLeftPagination = rightToLeftPagination,
|
||||||
|
verticalLineScrollPx = keyboardLineScrollPx,
|
||||||
|
onVerticalScrollBy = ::scrollVerticalReaderBy,
|
||||||
|
onNextPage = { navigateReaderPageBy(1) },
|
||||||
|
onPreviousPage = { navigateReaderPageBy(-1) },
|
||||||
|
onFirstPage = { navigateReaderBoundary(first = true) },
|
||||||
|
onLastPage = { navigateReaderBoundary(first = false) }
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
when (currentRenderMode) {
|
when (currentRenderMode) {
|
||||||
RenderMode.VERTICAL_SCROLL -> {
|
RenderMode.VERTICAL_SCROLL -> {
|
||||||
|
|
@ -4460,6 +4598,7 @@ fun EpubReaderHost(
|
||||||
},
|
},
|
||||||
onLocationChanged = { location ->
|
onLocationChanged = { location ->
|
||||||
nativeVerticalLocation = location
|
nativeVerticalLocation = location
|
||||||
|
location.locatorForPersistence()?.let { lastKnownLocator = it }
|
||||||
},
|
},
|
||||||
onTap = {
|
onTap = {
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
|
|
@ -4613,6 +4752,27 @@ fun EpubReaderHost(
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val chapterToRender = chapters[targetChapterIndex]
|
val chapterToRender = chapters[targetChapterIndex]
|
||||||
|
val chapterFontFaceCss = remember(
|
||||||
|
chapterHead,
|
||||||
|
chapterToRender.absPath,
|
||||||
|
epubBook.extractionBasePath
|
||||||
|
) {
|
||||||
|
val fontFaces = Jsoup.parse("<head>$chapterHead</head>")
|
||||||
|
.head()
|
||||||
|
.getElementsByTag("style")
|
||||||
|
.flatMap { styleElement ->
|
||||||
|
CssParser.parse(
|
||||||
|
cssContent = styleElement.data(),
|
||||||
|
cssPath = chapterToRender.absPath,
|
||||||
|
baseFontSizeSp = 16f,
|
||||||
|
density = 1f,
|
||||||
|
constraints = Constraints(maxWidth = 1, maxHeight = 1),
|
||||||
|
isDarkTheme = false,
|
||||||
|
adaptThemeColors = false
|
||||||
|
).fontFaces
|
||||||
|
}
|
||||||
|
buildEpubFontFaceCss(fontFaces, epubBook.extractionBasePath)
|
||||||
|
}
|
||||||
fun isCurrentRenderedChapter(): Boolean =
|
fun isCurrentRenderedChapter(): Boolean =
|
||||||
targetChapterIndex == currentChapterIndex
|
targetChapterIndex == currentChapterIndex
|
||||||
|
|
||||||
|
|
@ -4975,6 +5135,9 @@ fun EpubReaderHost(
|
||||||
currentVerticalMargin = currentVerticalMargin,
|
currentVerticalMargin = currentVerticalMargin,
|
||||||
currentFontFamily = currentFontFamily,
|
currentFontFamily = currentFontFamily,
|
||||||
customFontPath = currentCustomFontPath,
|
customFontPath = currentCustomFontPath,
|
||||||
|
epubFontFaceCss = listOf(epubFontFaceCss, chapterFontFaceCss)
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString(separator = " "),
|
||||||
currentTextAlign = currentTextAlign,
|
currentTextAlign = currentTextAlign,
|
||||||
activeTextureId = activeTextureId,
|
activeTextureId = activeTextureId,
|
||||||
activeTextureAlpha = activeTextureAlpha,
|
activeTextureAlpha = activeTextureAlpha,
|
||||||
|
|
@ -6057,8 +6220,8 @@ fun EpubReaderHost(
|
||||||
?: "Chapter"
|
?: "Chapter"
|
||||||
|
|
||||||
val displayPageInfo = when {
|
val displayPageInfo = when {
|
||||||
isNativeVerticalMode && nativeVerticalTotalPages > 0 ->
|
isNativeVerticalMode && nativeVerticalDisplayPageInfo != null ->
|
||||||
" (${nativeVerticalCurrentPage + 1}/$nativeVerticalTotalPages)"
|
" (${nativeVerticalDisplayPageInfo.currentPage}/${nativeVerticalDisplayPageInfo.totalPages})"
|
||||||
currentScrollHeightValue <= 0 || isChapterParsing -> ""
|
currentScrollHeightValue <= 0 || isChapterParsing -> ""
|
||||||
else -> " ($currentPageInChapter/$totalPagesInCurrentChapter)"
|
else -> " ($currentPageInChapter/$totalPagesInCurrentChapter)"
|
||||||
}
|
}
|
||||||
|
|
@ -7059,9 +7222,14 @@ fun EpubReaderHost(
|
||||||
highlightToNoteCfi = null
|
highlightToNoteCfi = null
|
||||||
},
|
},
|
||||||
onCopy = {
|
onCopy = {
|
||||||
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val copied = copyPlainTextToClipboard(
|
||||||
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), targetHighlight.text)
|
context = context,
|
||||||
clipboardManager.setPrimaryClip(clip)
|
label = context.getString(R.string.clip_label_copied_text),
|
||||||
|
text = targetHighlight.text
|
||||||
|
)
|
||||||
|
if (!copied) {
|
||||||
|
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
highlightToNoteCfi = null
|
highlightToNoteCfi = null
|
||||||
},
|
},
|
||||||
onDictionary = {
|
onDictionary = {
|
||||||
|
|
|
||||||
|
|
@ -107,8 +107,19 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
|
import com.aryan.reader.ReaderFontDiagnosticsTag
|
||||||
import com.aryan.reader.data.CustomFontEntity
|
import com.aryan.reader.data.CustomFontEntity
|
||||||
|
import com.aryan.reader.readerModalMaxHeightDp
|
||||||
|
import com.aryan.reader.readerFontDiagnosticSummary
|
||||||
import com.aryan.reader.supportedFontMimeTypes
|
import com.aryan.reader.supportedFontMimeTypes
|
||||||
|
import com.aryan.reader.shared.CustomFontItem
|
||||||
|
import com.aryan.reader.shared.detectFontVariant
|
||||||
|
import com.aryan.reader.shared.fontFaceSummary
|
||||||
|
import com.aryan.reader.shared.familyFilenameSignature
|
||||||
|
import com.aryan.reader.shared.groupByFamily
|
||||||
|
import com.aryan.reader.shared.hasVariableWeightFace
|
||||||
|
import com.aryan.reader.shared.supportsVariableWeightAxis
|
||||||
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
|
@ -412,8 +423,64 @@ fun getComposeFontFamily(
|
||||||
): FontFamily {
|
): FontFamily {
|
||||||
if (customFontPath != null) {
|
if (customFontPath != null) {
|
||||||
return try {
|
return try {
|
||||||
FontFamily(Font(File(customFontPath)))
|
val baseFile = File(customFontPath)
|
||||||
} catch (_: Exception) {
|
val signature = baseFile.nameWithoutExtension.familyFilenameSignature()
|
||||||
|
val siblings = baseFile.parentFile?.listFiles()?.filter {
|
||||||
|
it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
|
||||||
|
it.nameWithoutExtension.familyFilenameSignature() == signature
|
||||||
|
} ?: listOf(baseFile)
|
||||||
|
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"compose.custom.start basePath='${baseFile.absolutePath}' " +
|
||||||
|
"exists=${baseFile.exists()} bytes=${baseFile.length()} " +
|
||||||
|
readerFontDiagnosticSummary(baseFile.nameWithoutExtension) +
|
||||||
|
" siblings=${siblings.joinToString { it.name }}"
|
||||||
|
)
|
||||||
|
|
||||||
|
val seenVariants = mutableSetOf<String>()
|
||||||
|
val fontList = siblings.flatMap { sibling ->
|
||||||
|
try {
|
||||||
|
val variant = sibling.nameWithoutExtension.detectFontVariant()
|
||||||
|
val weights = if (sibling.nameWithoutExtension.supportsVariableWeightAxis()) {
|
||||||
|
variableReaderFontWeights
|
||||||
|
} else {
|
||||||
|
listOf(variant?.weight ?: FontWeight.Normal)
|
||||||
|
}
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"compose.custom.candidate file='${sibling.name}' " +
|
||||||
|
readerFontDiagnosticSummary(sibling.nameWithoutExtension) +
|
||||||
|
" style=${variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal} " +
|
||||||
|
"weights=${weights.joinToString { it.weight.toString() }}"
|
||||||
|
)
|
||||||
|
weights.mapNotNull { weight ->
|
||||||
|
val style = variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal
|
||||||
|
if (seenVariants.add("${weight.weight}|$style")) {
|
||||||
|
Font(sibling, weight, style)
|
||||||
|
} else {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"compose.custom.skipDuplicate file='${sibling.name}' weight=${weight.weight} style=$style"
|
||||||
|
)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).e(e, "compose.custom.candidateFailed file='${sibling.name}'")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fontList.isNotEmpty()) {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"compose.custom.loaded base='${baseFile.name}' registeredVariants=${seenVariants.joinToString()}"
|
||||||
|
)
|
||||||
|
FontFamily(fontList)
|
||||||
|
} else {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).w(
|
||||||
|
"compose.custom.fallbackSingle base='${baseFile.name}' no inferred variants loaded"
|
||||||
|
)
|
||||||
|
FontFamily(Font(baseFile))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).e(e, "compose.custom.failed path='$customFontPath'")
|
||||||
FontFamily.Default
|
FontFamily.Default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -436,6 +503,18 @@ fun getComposeFontFamily(
|
||||||
return FontFamily.Default
|
return FontFamily.Default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val variableReaderFontWeights = listOf(
|
||||||
|
FontWeight.Thin,
|
||||||
|
FontWeight.ExtraLight,
|
||||||
|
FontWeight.Light,
|
||||||
|
FontWeight.Normal,
|
||||||
|
FontWeight.Medium,
|
||||||
|
FontWeight.SemiBold,
|
||||||
|
FontWeight.Bold,
|
||||||
|
FontWeight.ExtraBold,
|
||||||
|
FontWeight.Black
|
||||||
|
)
|
||||||
|
|
||||||
fun saveReaderSettings(
|
fun saveReaderSettings(
|
||||||
context: Context,
|
context: Context,
|
||||||
fontSize: Float,
|
fontSize: Float,
|
||||||
|
|
@ -853,16 +932,56 @@ fun FontSelectionSheetContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
|
val customFontFamilies = remember(customFonts) {
|
||||||
items(customFonts) { fontEntity ->
|
val grouped = customFonts
|
||||||
val isSelected = currentCustomFontPath == fontEntity.path
|
.filterNot { it.isDeleted }
|
||||||
val fontFamily = remember(fontEntity.path) {
|
.map { it.toSharedCustomFontItem() }
|
||||||
try { FontFamily(Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default }
|
.groupByFamily()
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"picker.grouped count=${grouped.size} families=${
|
||||||
|
grouped.joinToString { family ->
|
||||||
|
"${family.familyName} -> [" +
|
||||||
|
family.variants.joinToString { variantItem ->
|
||||||
|
val font = variantItem.font
|
||||||
|
"${font.displayName}:${variantItem.variant}"
|
||||||
|
} + "]"
|
||||||
}
|
}
|
||||||
|
}"
|
||||||
|
)
|
||||||
|
grouped
|
||||||
|
}
|
||||||
|
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
|
||||||
|
items(customFontFamilies) { family ->
|
||||||
|
val baseFont = family.variants.firstOrNull {
|
||||||
|
val variant = it.variant
|
||||||
|
variant != null &&
|
||||||
|
variant.weight == FontWeight.Normal &&
|
||||||
|
variant.style == androidx.compose.ui.text.font.FontStyle.Normal
|
||||||
|
}?.font ?: family.variants.first().font
|
||||||
|
val isSelected = family.variants.any { it.font.path == currentCustomFontPath }
|
||||||
|
val fontFamily = remember(baseFont.path) {
|
||||||
|
getComposeFontFamily(ReaderFont.ORIGINAL, baseFont.path)
|
||||||
|
}
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"picker.row family='${family.familyName}' base='${baseFont.displayName}' " +
|
||||||
|
"selected=$isSelected variants=${
|
||||||
|
family.variants.joinToString { "${it.font.displayName}:${it.variant}" }
|
||||||
|
}"
|
||||||
|
)
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(fontEntity.displayName, fontFamily = fontFamily)
|
Text(family.familyName, fontFamily = fontFamily)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
buildString {
|
||||||
|
append(family.fontFaceSummary())
|
||||||
|
if (family.hasVariableWeightFace()) append(" - Variable weight")
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
|
|
@ -873,7 +992,12 @@ fun FontSelectionSheetContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.clickable { onFontSelected(ReaderFont.ORIGINAL, fontEntity.path) },
|
modifier = Modifier.clickable {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"picker.selected family='${family.familyName}' base='${baseFont.displayName}' path='${baseFont.path}'"
|
||||||
|
)
|
||||||
|
onFontSelected(ReaderFont.ORIGINAL, baseFont.path)
|
||||||
|
},
|
||||||
colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors()
|
colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors()
|
||||||
)
|
)
|
||||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||||
|
|
@ -887,6 +1011,18 @@ fun FontSelectionSheetContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun CustomFontEntity.toSharedCustomFontItem(): CustomFontItem {
|
||||||
|
return CustomFontItem(
|
||||||
|
id = id,
|
||||||
|
displayName = displayName,
|
||||||
|
fileName = fileName,
|
||||||
|
fileExtension = fileExtension,
|
||||||
|
path = path,
|
||||||
|
timestamp = timestamp,
|
||||||
|
isDeleted = isDeleted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private const val REMOVE_EDGE_PADDING_KEY = "reader_remove_edge_padding"
|
private const val REMOVE_EDGE_PADDING_KEY = "reader_remove_edge_padding"
|
||||||
|
|
||||||
fun saveRemoveEdgePadding(context: Context, enabled: Boolean) {
|
fun saveRemoveEdgePadding(context: Context, enabled: Boolean) {
|
||||||
|
|
@ -915,6 +1051,8 @@ fun VisualOptionsSheet(
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
|
|
@ -924,6 +1062,8 @@ fun VisualOptionsSheet(
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = maxSheetHeight)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 24.dp, vertical = 8.dp)
|
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ import androidx.compose.ui.input.key.type
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import com.aryan.reader.paginatedreader.AndroidEpubKeyCommand
|
||||||
|
import com.aryan.reader.paginatedreader.androidEpubKeyCommandOrNull
|
||||||
import com.aryan.reader.RenderMode
|
import com.aryan.reader.RenderMode
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -150,3 +152,45 @@ fun Modifier.volumeScrollHandler(
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Modifier.epubReaderKeyboardNavigationHandler(
|
||||||
|
enabled: Boolean,
|
||||||
|
renderMode: RenderMode,
|
||||||
|
isRightToLeftPagination: Boolean,
|
||||||
|
verticalLineScrollPx: Int,
|
||||||
|
onVerticalScrollBy: (Int) -> Unit,
|
||||||
|
onNextPage: () -> Unit,
|
||||||
|
onPreviousPage: () -> Unit,
|
||||||
|
onFirstPage: () -> Unit,
|
||||||
|
onLastPage: () -> Unit
|
||||||
|
): Modifier = this.onPreviewKeyEvent { keyEvent ->
|
||||||
|
if (!enabled) return@onPreviewKeyEvent false
|
||||||
|
val command = androidEpubKeyCommandOrNull(
|
||||||
|
keyCode = keyEvent.nativeKeyEvent.keyCode,
|
||||||
|
rightToLeftPagination = isRightToLeftPagination,
|
||||||
|
isCtrlPressed = keyEvent.nativeKeyEvent.isCtrlPressed
|
||||||
|
) ?: return@onPreviewKeyEvent false
|
||||||
|
if (keyEvent.type != KeyEventType.KeyDown) {
|
||||||
|
return@onPreviewKeyEvent when (command) {
|
||||||
|
AndroidEpubKeyCommand.SCROLL_UP,
|
||||||
|
AndroidEpubKeyCommand.SCROLL_DOWN -> renderMode == RenderMode.VERTICAL_SCROLL
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (command) {
|
||||||
|
AndroidEpubKeyCommand.SCROLL_UP -> {
|
||||||
|
if (renderMode != RenderMode.VERTICAL_SCROLL) return@onPreviewKeyEvent false
|
||||||
|
onVerticalScrollBy(-verticalLineScrollPx)
|
||||||
|
}
|
||||||
|
AndroidEpubKeyCommand.SCROLL_DOWN -> {
|
||||||
|
if (renderMode != RenderMode.VERTICAL_SCROLL) return@onPreviewKeyEvent false
|
||||||
|
onVerticalScrollBy(verticalLineScrollPx)
|
||||||
|
}
|
||||||
|
AndroidEpubKeyCommand.PREVIOUS_PAGE -> onPreviousPage()
|
||||||
|
AndroidEpubKeyCommand.NEXT_PAGE -> onNextPage()
|
||||||
|
AndroidEpubKeyCommand.FIRST_PAGE -> onFirstPage()
|
||||||
|
AndroidEpubKeyCommand.LAST_PAGE -> onLastPage()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -328,16 +328,15 @@ private fun handleVerticalAutoAdvance(
|
||||||
val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex)
|
val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex)
|
||||||
|
|
||||||
if (!nativeChunks.isNullOrEmpty()) {
|
if (!nativeChunks.isNullOrEmpty()) {
|
||||||
val resumeIdx = findTtsChunkResumeIndex(
|
val startChunkIndex = resolveTtsContinuationStartIndex(
|
||||||
chunks = nativeChunks,
|
chunks = nativeChunks,
|
||||||
|
loadedChunkCount = loadedChunkCount,
|
||||||
sourceCfi = lastReadCfi,
|
sourceCfi = lastReadCfi,
|
||||||
startOffsetInSource = currentState.startOffsetInSource,
|
startOffsetInSource = currentState.startOffsetInSource,
|
||||||
currentText = currentState.currentText,
|
currentText = currentState.currentText
|
||||||
currentChunkIndexFallback = currentState.currentChunkIndex
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (resumeIdx != null && resumeIdx + 1 < nativeChunks.size) {
|
if (startChunkIndex != null) {
|
||||||
val startChunkIndex = resumeIdx + 1
|
|
||||||
val token = getAuthToken()
|
val token = getAuthToken()
|
||||||
ttsController.start(
|
ttsController.start(
|
||||||
chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
|
chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,29 @@ internal fun findTtsChunkResumeIndex(
|
||||||
return currentChunkIndexFallback.takeIf { it in chunks.indices }
|
return currentChunkIndexFallback.takeIf { it in chunks.indices }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun resolveTtsContinuationStartIndex(
|
||||||
|
chunks: List<TtsChunk>,
|
||||||
|
loadedChunkCount: Int,
|
||||||
|
sourceCfi: String?,
|
||||||
|
startOffsetInSource: Int,
|
||||||
|
currentText: String?
|
||||||
|
): Int? {
|
||||||
|
val matchedResumeIndex = findTtsChunkResumeIndex(
|
||||||
|
chunks = chunks,
|
||||||
|
sourceCfi = sourceCfi,
|
||||||
|
startOffsetInSource = startOffsetInSource,
|
||||||
|
currentText = currentText,
|
||||||
|
currentChunkIndexFallback = -1
|
||||||
|
)
|
||||||
|
|
||||||
|
val matchedNextIndex = matchedResumeIndex?.plus(1)
|
||||||
|
if (matchedNextIndex != null && matchedNextIndex in chunks.indices) {
|
||||||
|
return matchedNextIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadedChunkCount.takeIf { it in chunks.indices }
|
||||||
|
}
|
||||||
|
|
||||||
private fun cfiPathContains(parentPath: String, childPath: String): Boolean {
|
private fun cfiPathContains(parentPath: String, childPath: String): Boolean {
|
||||||
if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false
|
if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false
|
||||||
val parentParts = parentPath.split('/').filter { it.isNotEmpty() }
|
val parentParts = parentPath.split('/').filter { it.isNotEmpty() }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
|
|
||||||
|
internal enum class AndroidEpubKeyCommand {
|
||||||
|
PREVIOUS_PAGE,
|
||||||
|
NEXT_PAGE,
|
||||||
|
SCROLL_UP,
|
||||||
|
SCROLL_DOWN,
|
||||||
|
FIRST_PAGE,
|
||||||
|
LAST_PAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun androidEpubKeyCommandOrNull(
|
||||||
|
keyCode: Int,
|
||||||
|
rightToLeftPagination: Boolean = false,
|
||||||
|
isCtrlPressed: Boolean = false
|
||||||
|
): AndroidEpubKeyCommand? {
|
||||||
|
if (isCtrlPressed) return null
|
||||||
|
return when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> if (rightToLeftPagination) {
|
||||||
|
AndroidEpubKeyCommand.NEXT_PAGE
|
||||||
|
} else {
|
||||||
|
AndroidEpubKeyCommand.PREVIOUS_PAGE
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> if (rightToLeftPagination) {
|
||||||
|
AndroidEpubKeyCommand.PREVIOUS_PAGE
|
||||||
|
} else {
|
||||||
|
AndroidEpubKeyCommand.NEXT_PAGE
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> AndroidEpubKeyCommand.SCROLL_UP
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> AndroidEpubKeyCommand.SCROLL_DOWN
|
||||||
|
KeyEvent.KEYCODE_PAGE_UP -> AndroidEpubKeyCommand.PREVIOUS_PAGE
|
||||||
|
KeyEvent.KEYCODE_PAGE_DOWN -> AndroidEpubKeyCommand.NEXT_PAGE
|
||||||
|
KeyEvent.KEYCODE_MOVE_HOME -> AndroidEpubKeyCommand.FIRST_PAGE
|
||||||
|
KeyEvent.KEYCODE_MOVE_END -> AndroidEpubKeyCommand.LAST_PAGE
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -202,6 +202,7 @@ class BookPaginator(
|
||||||
private val chapterTextRangeIndex = ConcurrentHashMap<Int, List<TextRangeIndex>>()
|
private val chapterTextRangeIndex = ConcurrentHashMap<Int, List<TextRangeIndex>>()
|
||||||
private val chapterPageNavigationIndex = ConcurrentHashMap<Int, List<PageNavigationEntry>>()
|
private val chapterPageNavigationIndex = ConcurrentHashMap<Int, List<PageNavigationEntry>>()
|
||||||
private val chapterAnchorPageIndex = ConcurrentHashMap<Int, Map<String, Int>>()
|
private val chapterAnchorPageIndex = ConcurrentHashMap<Int, Map<String, Int>>()
|
||||||
|
private val expandedAllFontFaces = expandFontFacesWithSiblings(allFontFaces, extractionBasePath)
|
||||||
|
|
||||||
private var pageCountsAreAccurate by mutableStateOf(false)
|
private var pageCountsAreAccurate by mutableStateOf(false)
|
||||||
private val finalizedChapterCounts = ConcurrentHashMap.newKeySet<Int>()
|
private val finalizedChapterCounts = ConcurrentHashMap.newKeySet<Int>()
|
||||||
|
|
@ -385,7 +386,7 @@ class BookPaginator(
|
||||||
append("-pageCache:$LATEST_PAGE_CACHE_VERSION")
|
append("-pageCache:$LATEST_PAGE_CACHE_VERSION")
|
||||||
append("-ua:${userAgentStylesheet.hashCode()}")
|
append("-ua:${userAgentStylesheet.hashCode()}")
|
||||||
append("-css:${bookCss.hashCode()}")
|
append("-css:${bookCss.hashCode()}")
|
||||||
append("-fonts:${allFontFaces.hashCode()}")
|
append("-fonts:${expandedAllFontFaces.hashCode()}")
|
||||||
}
|
}
|
||||||
val hash = configString.hashCode()
|
val hash = configString.hashCode()
|
||||||
return hash
|
return hash
|
||||||
|
|
@ -872,7 +873,7 @@ class BookPaginator(
|
||||||
density = density.density,
|
density = density.density,
|
||||||
constraintsMaxWidth = constraints.maxWidth,
|
constraintsMaxWidth = constraints.maxWidth,
|
||||||
constraintsMaxHeight = constraints.maxHeight,
|
constraintsMaxHeight = constraints.maxHeight,
|
||||||
fontFaces = this.allFontFaces,
|
fontFaces = expandedAllFontFaces,
|
||||||
styleConfigHash = currentConfigHash,
|
styleConfigHash = currentConfigHash,
|
||||||
bookReplacementPreferencesJson = ReaderBookReplacementPreferencesJson.encode(
|
bookReplacementPreferencesJson = ReaderBookReplacementPreferencesJson.encode(
|
||||||
bookReplacementPreferences.scopedToFile(bookReplacementFileId),
|
bookReplacementPreferences.scopedToFile(bookReplacementFileId),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import com.aryan.reader.ReaderFontDiagnosticsTag
|
||||||
|
import com.aryan.reader.readerFontDiagnosticSummary
|
||||||
|
import com.aryan.reader.shared.detectFontVariant
|
||||||
|
import com.aryan.reader.shared.familyFilenameSignature
|
||||||
|
import com.aryan.reader.shared.fontWeightCssDescriptor
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
private val supportedEpubFontExtensions = setOf("ttf", "otf", "woff", "woff2")
|
||||||
|
|
||||||
|
fun expandFontFacesWithSiblings(
|
||||||
|
fontFaces: List<FontFaceInfo>,
|
||||||
|
extractionPath: String
|
||||||
|
): List<FontFaceInfo> {
|
||||||
|
if (fontFaces.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"epub.siblings.start inputCount=${fontFaces.size} extractionPath='$extractionPath'"
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = fontFaces.toMutableList()
|
||||||
|
val existingKeys = result.mapTo(mutableSetOf()) { it.variantKey() }
|
||||||
|
val extractionRoot = File(extractionPath)
|
||||||
|
|
||||||
|
fontFaces.forEach { fontFace ->
|
||||||
|
val sourceFile = fontFace.resolvedFile(extractionRoot).takeIf { it.isFile } ?: return@forEach
|
||||||
|
val sourceSignature = sourceFile.familyFilenameSignature()
|
||||||
|
if (sourceSignature.isBlank()) return@forEach
|
||||||
|
val parent = sourceFile.parentFile ?: return@forEach
|
||||||
|
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"epub.siblings.source family='${fontFace.fontFamily}' src='${fontFace.src}' " +
|
||||||
|
"file='${sourceFile.name}' " +
|
||||||
|
readerFontDiagnosticSummary(sourceFile.nameWithoutExtension)
|
||||||
|
)
|
||||||
|
|
||||||
|
parent.listFiles()
|
||||||
|
?.asSequence()
|
||||||
|
?.filter { candidate ->
|
||||||
|
candidate.isFile &&
|
||||||
|
candidate.extension.lowercase() in supportedEpubFontExtensions &&
|
||||||
|
candidate.nameWithoutExtension.familyFilenameSignature() == sourceSignature
|
||||||
|
}
|
||||||
|
?.forEach { candidate ->
|
||||||
|
val variant = candidate.nameWithoutExtension.detectFontVariant()
|
||||||
|
if (variant == null) {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).w(
|
||||||
|
"epub.siblings.skipNoVariant file='${candidate.name}' " +
|
||||||
|
readerFontDiagnosticSummary(candidate.nameWithoutExtension)
|
||||||
|
)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val src = candidate.toFontFaceSrc(extractionRoot)
|
||||||
|
val inferred = fontFace.copy(
|
||||||
|
src = src,
|
||||||
|
fontWeight = variant.weight,
|
||||||
|
fontStyle = variant.style
|
||||||
|
)
|
||||||
|
if (existingKeys.add(inferred.variantKey())) {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"epub.siblings.add family='${fontFace.fontFamily}' src='$src' variant=$variant"
|
||||||
|
)
|
||||||
|
result += inferred
|
||||||
|
} else {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"epub.siblings.skipDuplicate family='${fontFace.fontFamily}' src='$src' variant=$variant"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i("epub.siblings.done outputCount=${result.size}")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildEpubFontFaceCss(
|
||||||
|
fontFaces: List<FontFaceInfo>,
|
||||||
|
extractionPath: String
|
||||||
|
): String {
|
||||||
|
val extractionRoot = File(extractionPath)
|
||||||
|
return expandFontFacesWithSiblings(fontFaces, extractionPath)
|
||||||
|
.distinctBy { it.variantKey() }
|
||||||
|
.mapNotNull { fontFace ->
|
||||||
|
val file = fontFace.resolvedFile(extractionRoot).takeIf { it.isFile } ?: return@mapNotNull null
|
||||||
|
val family = fontFace.fontFamily.cssString()
|
||||||
|
val url = file.toURI().toString().cssUrlString()
|
||||||
|
val weight = file.nameWithoutExtension.fontWeightCssDescriptor(fontFace.fontWeight ?: FontWeight.Normal)
|
||||||
|
val style = if (fontFace.fontStyle == FontStyle.Italic) "italic" else "normal"
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"epub.css.face family='$family' file='${file.name}' fontWeight='$weight' fontStyle='$style' " +
|
||||||
|
readerFontDiagnosticSummary(file.nameWithoutExtension)
|
||||||
|
)
|
||||||
|
"@font-face { font-family: '$family'; src: url('$url'); font-weight: $weight; font-style: $style; }"
|
||||||
|
}
|
||||||
|
.joinToString(separator = " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FontFaceInfo.resolvedFile(extractionRoot: File): File {
|
||||||
|
val source = File(src)
|
||||||
|
return if (source.isAbsolute) source else File(extractionRoot, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FontFaceInfo.variantKey(): String {
|
||||||
|
return listOf(
|
||||||
|
fontFamily.trim().lowercase(),
|
||||||
|
src.replace('\\', '/').lowercase(),
|
||||||
|
fontWeight?.weight ?: FontWeight.Normal.weight,
|
||||||
|
fontStyle ?: FontStyle.Normal
|
||||||
|
).joinToString(separator = "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.toFontFaceSrc(extractionRoot: File): String {
|
||||||
|
val relative = runCatching {
|
||||||
|
extractionRoot.toPath().relativize(toPath()).toString()
|
||||||
|
}.getOrNull()
|
||||||
|
return relative
|
||||||
|
?.takeIf { !it.startsWith("..") && it.isNotBlank() }
|
||||||
|
?.replace(File.separatorChar, '/')
|
||||||
|
?: absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.familyFilenameSignature(): String {
|
||||||
|
return nameWithoutExtension.familyFilenameSignature()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.cssString(): String = replace("\\", "\\\\").replace("'", "\\'")
|
||||||
|
|
||||||
|
private fun String.cssUrlString(): String = replace("\\", "\\\\").replace("'", "%27")
|
||||||
|
|
@ -24,6 +24,9 @@ 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.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import com.aryan.reader.ReaderFontDiagnosticsTag
|
||||||
|
import com.aryan.reader.readerFontDiagnosticSummary
|
||||||
|
import com.aryan.reader.shared.supportsVariableWeightAxis
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
|
@ -41,7 +44,11 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
|
||||||
if (fontFaces.isEmpty()) {
|
if (fontFaces.isEmpty()) {
|
||||||
return emptyMap()
|
return emptyMap()
|
||||||
}
|
}
|
||||||
Timber.d("Loading ${fontFaces.size} font faces from extraction path: $extractionPath")
|
val expandedFontFaces = expandFontFacesWithSiblings(fontFaces, extractionPath)
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"native.load.start inputCount=${fontFaces.size} expandedCount=${expandedFontFaces.size} extractionPath='$extractionPath'"
|
||||||
|
)
|
||||||
|
Timber.d("Loading ${expandedFontFaces.size} font faces from extraction path: $extractionPath")
|
||||||
|
|
||||||
// 1. Define a stable, global font cache directory.
|
// 1. Define a stable, global font cache directory.
|
||||||
// This assumes the parent of the extraction path is a stable base directory for epubs.
|
// This assumes the parent of the extraction path is a stable base directory for epubs.
|
||||||
|
|
@ -55,21 +62,39 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
|
||||||
// e.g., "d0e205bf-65cc-4ab4-93cc-cd2d613a7bb3.epub" from a longer temp path.
|
// e.g., "d0e205bf-65cc-4ab4-93cc-cd2d613a7bb3.epub" from a longer temp path.
|
||||||
val bookId = File(extractionPath).name.substringBeforeLast("_")
|
val bookId = File(extractionPath).name.substringBeforeLast("_")
|
||||||
|
|
||||||
val fontsByFamily = fontFaces.groupBy {
|
val fontsByFamily = expandedFontFaces.groupBy {
|
||||||
it.fontFamily.trim().removeSurrounding("'").removeSurrounding("\"").lowercase()
|
it.fontFamily.trim().removeSurrounding("'").removeSurrounding("\"").lowercase()
|
||||||
}
|
}
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"native.load.grouped families=${
|
||||||
|
fontsByFamily.mapValues { (_, infos) ->
|
||||||
|
infos.joinToString { "${it.src}:${it.fontWeight}:${it.fontStyle}" }
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
)
|
||||||
Timber.d("Grouped font faces by normalized family: ${fontsByFamily.keys}")
|
Timber.d("Grouped font faces by normalized family: ${fontsByFamily.keys}")
|
||||||
|
|
||||||
return fontsByFamily.mapValues { (familyName, fontInfos) ->
|
return fontsByFamily.mapValues { (familyName, fontInfos) ->
|
||||||
val fontList = fontInfos.mapNotNull { fontInfo ->
|
val seenVariants = mutableSetOf<String>()
|
||||||
|
val fontList = fontInfos.flatMap { fontInfo ->
|
||||||
try {
|
try {
|
||||||
Timber.d("Attempting to load font '$familyName' from resolved src path: '${fontInfo.src}'")
|
Timber.d("Attempting to load font '$familyName' from resolved src path: '${fontInfo.src}'")
|
||||||
var fontFile = File(extractionPath, fontInfo.src)
|
var fontFile = File(fontInfo.src).let { source ->
|
||||||
|
if (source.isAbsolute) source else File(extractionPath, fontInfo.src)
|
||||||
|
}
|
||||||
|
|
||||||
if (!fontFile.exists()) {
|
if (!fontFile.exists()) {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).w(
|
||||||
|
"native.load.missing family='$familyName' src='${fontInfo.src}' resolved='${fontFile.absolutePath}'"
|
||||||
|
)
|
||||||
Timber.w("Font file not found at: ${fontFile.absolutePath}")
|
Timber.w("Font file not found at: ${fontFile.absolutePath}")
|
||||||
return@mapNotNull null
|
return@flatMap emptyList()
|
||||||
}
|
}
|
||||||
|
val sourceName = fontFile.nameWithoutExtension
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"native.load.candidate family='$familyName' src='${fontInfo.src}' file='${fontFile.name}' " +
|
||||||
|
readerFontDiagnosticSummary(sourceName)
|
||||||
|
)
|
||||||
|
|
||||||
// Handle WOFF2 conversion and global caching
|
// Handle WOFF2 conversion and global caching
|
||||||
if (fontFile.extension.equals("woff2", ignoreCase = true)) {
|
if (fontFile.extension.equals("woff2", ignoreCase = true)) {
|
||||||
|
|
@ -80,9 +105,15 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
|
||||||
if (cachedTtfFile.exists()) {
|
if (cachedTtfFile.exists()) {
|
||||||
// Use the globally cached TTF file if it exists
|
// Use the globally cached TTF file if it exists
|
||||||
fontFile = cachedTtfFile
|
fontFile = cachedTtfFile
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"native.load.woff2CacheHit src='${fontInfo.src}' cached='${cachedTtfFile.absolutePath}'"
|
||||||
|
)
|
||||||
Timber.d("Using globally cached TTF for '${fontInfo.src}'")
|
Timber.d("Using globally cached TTF for '${fontInfo.src}'")
|
||||||
} else {
|
} else {
|
||||||
// Convert and save the TTF to the global cache if it doesn't exist
|
// Convert and save the TTF to the global cache if it doesn't exist
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"native.load.woff2Convert src='${fontInfo.src}' source='${fontFile.absolutePath}'"
|
||||||
|
)
|
||||||
Timber.d("Converting woff2 font: ${fontFile.name}")
|
Timber.d("Converting woff2 font: ${fontFile.name}")
|
||||||
val woff2Data = fontFile.readBytes()
|
val woff2Data = fontFile.readBytes()
|
||||||
val ttfData = Woff2Converter.convertWoff2ToTtf(woff2Data)
|
val ttfData = Woff2Converter.convertWoff2ToTtf(woff2Data)
|
||||||
|
|
@ -90,31 +121,69 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
|
||||||
if (ttfData != null) {
|
if (ttfData != null) {
|
||||||
cachedTtfFile.writeBytes(ttfData)
|
cachedTtfFile.writeBytes(ttfData)
|
||||||
fontFile = cachedTtfFile // Use the newly created TTF file
|
fontFile = cachedTtfFile // Use the newly created TTF file
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"native.load.woff2Converted src='${fontInfo.src}' cached='${cachedTtfFile.absolutePath}' bytes=${cachedTtfFile.length()}"
|
||||||
|
)
|
||||||
Timber.d("Successfully converted and globally cached woff2 as '${cachedTtfFile.name}'")
|
Timber.d("Successfully converted and globally cached woff2 as '${cachedTtfFile.name}'")
|
||||||
} else {
|
} else {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).e(
|
||||||
|
"native.load.woff2ConvertFailed src='${fontInfo.src}' source='${fontFile.absolutePath}'"
|
||||||
|
)
|
||||||
Timber.e("Failed to convert woff2 font: ${fontFile.name}")
|
Timber.e("Failed to convert woff2 font: ${fontFile.name}")
|
||||||
return@mapNotNull null
|
return@flatMap emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Font(
|
val weights = if (sourceName.supportsVariableWeightAxis()) {
|
||||||
fontFile,
|
variableEpubFontWeights
|
||||||
fontInfo.fontWeight ?: FontWeight.Normal,
|
} else {
|
||||||
fontInfo.fontStyle ?: FontStyle.Normal
|
listOf(fontInfo.fontWeight ?: FontWeight.Normal)
|
||||||
|
}
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"native.load.registerPlan family='$familyName' file='${fontFile.name}' " +
|
||||||
|
"style=${fontInfo.fontStyle ?: FontStyle.Normal} weights=${weights.joinToString { it.weight.toString() }}"
|
||||||
|
)
|
||||||
|
weights.mapNotNull { weight ->
|
||||||
|
val style = fontInfo.fontStyle ?: FontStyle.Normal
|
||||||
|
if (seenVariants.add("${weight.weight}|$style")) {
|
||||||
|
Font(fontFile, weight, style)
|
||||||
|
} else {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"native.load.skipDuplicate family='$familyName' file='${fontFile.name}' weight=${weight.weight} style=$style"
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Error loading font: ${fontInfo.src}")
|
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).e(e, "native.load.failed family='$familyName' src='${fontInfo.src}'")
|
||||||
|
Timber.e(e, "Error loading font: ${fontInfo.src}")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (fontList.isNotEmpty()) {
|
if (fontList.isNotEmpty()) {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||||
|
"native.load.loaded family='$familyName' registeredVariants=${seenVariants.joinToString()}"
|
||||||
|
)
|
||||||
Timber.d("Loaded family '$familyName' with ${fontList.size} font styles.")
|
Timber.d("Loaded family '$familyName' with ${fontList.size} font styles.")
|
||||||
FontFamily(fontList)
|
FontFamily(fontList)
|
||||||
} else {
|
} else {
|
||||||
|
Timber.tag(ReaderFontDiagnosticsTag).w("native.load.empty family='$familyName'")
|
||||||
Timber.w("Could not load any font styles for family '$familyName'.")
|
Timber.w("Could not load any font styles for family '$familyName'.")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}.filterValues { it != null }.mapValues { it.value!! }
|
}.filterValues { it != null }.mapValues { it.value!! }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val variableEpubFontWeights = listOf(
|
||||||
|
FontWeight.Thin,
|
||||||
|
FontWeight.ExtraLight,
|
||||||
|
FontWeight.Light,
|
||||||
|
FontWeight.Normal,
|
||||||
|
FontWeight.Medium,
|
||||||
|
FontWeight.SemiBold,
|
||||||
|
FontWeight.Bold,
|
||||||
|
FontWeight.ExtraBold,
|
||||||
|
FontWeight.Black
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,13 @@ package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import com.aryan.reader.BuildConfig
|
import com.aryan.reader.BuildConfig
|
||||||
|
import com.aryan.reader.copyPlainTextToClipboard
|
||||||
import androidx.compose.ui.unit.isSpecified
|
import androidx.compose.ui.unit.isSpecified
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
|
@ -175,6 +174,7 @@ import com.aryan.reader.epubreader.UserHighlight
|
||||||
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
||||||
import com.aryan.reader.shared.ReaderBookReplacementPreferences
|
import com.aryan.reader.shared.ReaderBookReplacementPreferences
|
||||||
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
|
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
|
||||||
|
import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -192,6 +192,8 @@ import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.Base64
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
@ -243,7 +245,8 @@ data class NativeVerticalLocation(
|
||||||
val firstVisibleItemSize: Int,
|
val firstVisibleItemSize: Int,
|
||||||
val isAtStart: Boolean,
|
val isAtStart: Boolean,
|
||||||
val isAtEnd: Boolean,
|
val isAtEnd: Boolean,
|
||||||
val visibleTextRanges: List<NativeVerticalVisibleTextRange> = emptyList()
|
val visibleTextRanges: List<NativeVerticalVisibleTextRange> = emptyList(),
|
||||||
|
val chapterPageInfo: NativeVerticalChapterPageInfo? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class NativeVerticalVisibleTextRange(
|
data class NativeVerticalVisibleTextRange(
|
||||||
|
|
@ -253,6 +256,34 @@ data class NativeVerticalVisibleTextRange(
|
||||||
val endCharOffset: Int
|
val endCharOffset: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun NativeVerticalLocation.locatorForPersistence(): Locator? {
|
||||||
|
val visibleRange = visibleTextRanges.firstOrNull()
|
||||||
|
return if (visibleRange != null) {
|
||||||
|
Locator(
|
||||||
|
chapterIndex = visibleRange.chapterIndex,
|
||||||
|
blockIndex = visibleRange.blockIndex,
|
||||||
|
charOffset = visibleRange.startCharOffset
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
locator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun shouldFallbackNativeVerticalInitialScrollToCompatPage(
|
||||||
|
hasInitialLocator: Boolean,
|
||||||
|
didLocatorScroll: Boolean
|
||||||
|
): Boolean = !hasInitialLocator && !didLocatorScroll
|
||||||
|
|
||||||
|
internal fun nativeVerticalCenteredScrollDelta(
|
||||||
|
targetOffsetInViewport: Float,
|
||||||
|
viewportHeight: Float
|
||||||
|
): Float = targetOffsetInViewport - (viewportHeight * 0.5f)
|
||||||
|
|
||||||
|
data class NativeVerticalChapterPageInfo(
|
||||||
|
val currentPage: Int,
|
||||||
|
val totalPages: Int
|
||||||
|
)
|
||||||
|
|
||||||
private data class SelectionBlockKey(
|
private data class SelectionBlockKey(
|
||||||
val pageIndex: Int,
|
val pageIndex: Int,
|
||||||
val blockIndex: Int,
|
val blockIndex: Int,
|
||||||
|
|
@ -318,7 +349,7 @@ internal fun nativeVerticalInitialChapterPrefetchOrder(
|
||||||
chapterCount: Int,
|
chapterCount: Int,
|
||||||
initialChapter: Int,
|
initialChapter: Int,
|
||||||
forwardCount: Int = 2,
|
forwardCount: Int = 2,
|
||||||
backwardCount: Int = 1
|
backwardCount: Int = 0
|
||||||
): List<Int> {
|
): List<Int> {
|
||||||
if (chapterCount <= 0) return emptyList()
|
if (chapterCount <= 0) return emptyList()
|
||||||
val start = initialChapter.coerceIn(0, chapterCount - 1)
|
val start = initialChapter.coerceIn(0, chapterCount - 1)
|
||||||
|
|
@ -1026,6 +1057,68 @@ internal fun nativeVerticalProgressForCompatPage(pageIndex: Int, totalPageCount:
|
||||||
.coerceIn(0f, 100f)
|
.coerceIn(0f, 100f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun nativeVerticalChapterPageInfo(
|
||||||
|
chapterCharOffset: Int?,
|
||||||
|
chapterLengthChars: Int,
|
||||||
|
chapterPageCount: Int?,
|
||||||
|
compatPageIndex: Int,
|
||||||
|
chapterStartPageIndex: Int?
|
||||||
|
): NativeVerticalChapterPageInfo? {
|
||||||
|
val total = chapterPageCount?.takeIf { it > 0 } ?: return null
|
||||||
|
val pageIndexInChapter = if (chapterCharOffset != null && chapterLengthChars > 0) {
|
||||||
|
((chapterCharOffset.coerceIn(0, chapterLengthChars).toFloat() / chapterLengthChars.toFloat()) * (total - 1))
|
||||||
|
.roundToInt()
|
||||||
|
} else if (chapterStartPageIndex != null) {
|
||||||
|
compatPageIndex - chapterStartPageIndex
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}.coerceIn(0, total - 1)
|
||||||
|
return NativeVerticalChapterPageInfo(
|
||||||
|
currentPage = pageIndexInChapter + 1,
|
||||||
|
totalPages = total
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun nativeVerticalChapterPageInfoForScroll(
|
||||||
|
itemChapterIndices: List<Int>,
|
||||||
|
itemWeights: List<Int>,
|
||||||
|
firstVisibleItemIndex: Int,
|
||||||
|
firstVisibleItemScrollOffset: Int,
|
||||||
|
firstVisibleItemSize: Int,
|
||||||
|
chapterPageCount: Int?
|
||||||
|
): NativeVerticalChapterPageInfo? {
|
||||||
|
val total = chapterPageCount?.takeIf { it > 0 } ?: return null
|
||||||
|
if (itemChapterIndices.isEmpty() || itemWeights.isEmpty()) {
|
||||||
|
return NativeVerticalChapterPageInfo(currentPage = 1, totalPages = total)
|
||||||
|
}
|
||||||
|
val safeIndex = firstVisibleItemIndex.coerceIn(0, minOf(itemChapterIndices.lastIndex, itemWeights.lastIndex))
|
||||||
|
val chapterIndex = itemChapterIndices[safeIndex]
|
||||||
|
val chapterItems = itemChapterIndices.indices.filter { index ->
|
||||||
|
index < itemWeights.size && itemChapterIndices[index] == chapterIndex
|
||||||
|
}
|
||||||
|
val totalChapterWeight = chapterItems.sumOf { itemWeights[it].coerceAtLeast(0) }
|
||||||
|
if (totalChapterWeight <= 0) {
|
||||||
|
return NativeVerticalChapterPageInfo(currentPage = 1, totalPages = total)
|
||||||
|
}
|
||||||
|
val completedWeight = chapterItems
|
||||||
|
.filter { it < safeIndex }
|
||||||
|
.sumOf { itemWeights[it].coerceAtLeast(0) }
|
||||||
|
val currentWeight = itemWeights[safeIndex].coerceAtLeast(0)
|
||||||
|
val currentFraction = if (firstVisibleItemSize > 0) {
|
||||||
|
(firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat())
|
||||||
|
.coerceIn(0f, 1f)
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
val chapterProgress = ((completedWeight + currentWeight * currentFraction) / totalChapterWeight.toFloat())
|
||||||
|
.coerceIn(0f, 1f)
|
||||||
|
val pageIndexInChapter = (chapterProgress * (total - 1)).roundToInt().coerceIn(0, total - 1)
|
||||||
|
return NativeVerticalChapterPageInfo(
|
||||||
|
currentPage = pageIndexInChapter + 1,
|
||||||
|
totalPages = total
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
internal fun nativeVerticalProgressToItemIndex(
|
internal fun nativeVerticalProgressToItemIndex(
|
||||||
itemWeights: List<Int>,
|
itemWeights: List<Int>,
|
||||||
progressPercent: Float
|
progressPercent: Float
|
||||||
|
|
@ -1119,31 +1212,45 @@ private fun findNativeVerticalFlowItemIndexForProgress(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun estimateNativeVerticalScrollProgressPercent(
|
internal fun estimateNativeVerticalWeightedScrollProgressPercent(
|
||||||
items: List<NativeVerticalFlowItem>,
|
itemWeights: List<Int>,
|
||||||
firstVisibleItemIndex: Int,
|
firstVisibleItemIndex: Int,
|
||||||
firstVisibleItemScrollOffset: Int,
|
firstVisibleItemScrollOffset: Int,
|
||||||
firstVisibleItemSize: Int
|
firstVisibleItemSize: Int
|
||||||
): Float? {
|
): Float? {
|
||||||
if (items.isEmpty()) return null
|
if (itemWeights.isEmpty()) return null
|
||||||
val totalWeight = items.sumOf { it.locationWeight }.takeIf { it > 0 } ?: return null
|
val totalWeight = itemWeights.sumOf { it }.takeIf { it > 0 } ?: return null
|
||||||
val safeIndex = firstVisibleItemIndex.coerceIn(0, items.lastIndex)
|
val safeIndex = firstVisibleItemIndex.coerceIn(0, itemWeights.lastIndex)
|
||||||
val completedWeight = items
|
val completedWeight = itemWeights
|
||||||
.take(safeIndex)
|
.take(safeIndex)
|
||||||
.sumOf { it.locationWeight }
|
.sum()
|
||||||
val currentItem = items[safeIndex]
|
val currentItemWeight = itemWeights[safeIndex]
|
||||||
val currentFraction = if (firstVisibleItemSize > 0) {
|
val currentFraction = if (firstVisibleItemSize > 0) {
|
||||||
(firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat())
|
(firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat())
|
||||||
.coerceIn(0f, 1f)
|
.coerceIn(0f, 1f)
|
||||||
} else {
|
} else {
|
||||||
0f
|
0f
|
||||||
}
|
}
|
||||||
val weightedPosition = completedWeight + (currentItem.locationWeight * currentFraction)
|
val weightedPosition = completedWeight + (currentItemWeight * currentFraction)
|
||||||
return ((weightedPosition.toDouble() / totalWeight.toDouble()) * 100.0)
|
return ((weightedPosition.toDouble() / totalWeight.toDouble()) * 100.0)
|
||||||
.toFloat()
|
.toFloat()
|
||||||
.coerceIn(0f, 100f)
|
.coerceIn(0f, 100f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun estimateNativeVerticalScrollProgressPercent(
|
||||||
|
items: List<NativeVerticalFlowItem>,
|
||||||
|
firstVisibleItemIndex: Int,
|
||||||
|
firstVisibleItemScrollOffset: Int,
|
||||||
|
firstVisibleItemSize: Int
|
||||||
|
): Float? {
|
||||||
|
return estimateNativeVerticalWeightedScrollProgressPercent(
|
||||||
|
itemWeights = items.map { it.locationWeight },
|
||||||
|
firstVisibleItemIndex = firstVisibleItemIndex,
|
||||||
|
firstVisibleItemScrollOffset = firstVisibleItemScrollOffset,
|
||||||
|
firstVisibleItemSize = firstVisibleItemSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun findNativeVerticalFlowItemIndexForLocator(
|
private fun findNativeVerticalFlowItemIndexForLocator(
|
||||||
items: List<NativeVerticalFlowItem>,
|
items: List<NativeVerticalFlowItem>,
|
||||||
chapters: List<NativeVerticalFlowChapter>,
|
chapters: List<NativeVerticalFlowChapter>,
|
||||||
|
|
@ -1333,7 +1440,7 @@ private fun resolveNativeVerticalVisibleTextRanges(
|
||||||
|
|
||||||
val start = blockStart + (firstVisibleOffset ?: 0)
|
val start = blockStart + (firstVisibleOffset ?: 0)
|
||||||
val end = blockStart + (lastVisibleOffset ?: block.content.text.length)
|
val end = blockStart + (lastVisibleOffset ?: block.content.text.length)
|
||||||
NativeVerticalVisibleTextRange(
|
bounds.top to NativeVerticalVisibleTextRange(
|
||||||
chapterIndex = chapterIndex,
|
chapterIndex = chapterIndex,
|
||||||
blockIndex = block.blockIndex,
|
blockIndex = block.blockIndex,
|
||||||
startCharOffset = start,
|
startCharOffset = start,
|
||||||
|
|
@ -1341,6 +1448,8 @@ private fun resolveNativeVerticalVisibleTextRanges(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sortedBy { it.first }
|
||||||
|
.map { it.second }
|
||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1903,6 +2012,37 @@ private fun imageContentScale(style: BlockStyle): ContentScale {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun nativeVerticalSvgContentFromDataUri(source: String): String? {
|
||||||
|
if (!source.startsWith("data:image/svg+xml", ignoreCase = true)) return null
|
||||||
|
val commaIndex = source.indexOf(',')
|
||||||
|
if (commaIndex < 0) return null
|
||||||
|
val metadata = source.substring(0, commaIndex)
|
||||||
|
val payload = source.substring(commaIndex + 1)
|
||||||
|
return runCatching {
|
||||||
|
if (metadata.contains(";base64", ignoreCase = true)) {
|
||||||
|
String(Base64.getDecoder().decode(payload), StandardCharsets.UTF_8)
|
||||||
|
} else {
|
||||||
|
URLDecoder.decode(payload.replace("+", "%2B"), "UTF-8")
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun nativeVerticalImageModelData(source: String): Any {
|
||||||
|
val trimmed = source.trim()
|
||||||
|
return when {
|
||||||
|
trimmed.startsWith("<svg", ignoreCase = true) -> SvgData(trimmed)
|
||||||
|
trimmed.startsWith("data:image/svg+xml", ignoreCase = true) ->
|
||||||
|
nativeVerticalSvgContentFromDataUri(trimmed)?.let { SvgData(it) } ?: trimmed
|
||||||
|
trimmed.startsWith("file:", ignoreCase = true) ||
|
||||||
|
trimmed.startsWith("content:", ignoreCase = true) ||
|
||||||
|
trimmed.startsWith("android.resource:", ignoreCase = true) ||
|
||||||
|
trimmed.startsWith("http://", ignoreCase = true) ||
|
||||||
|
trimmed.startsWith("https://", ignoreCase = true) -> trimmed.toUri()
|
||||||
|
trimmed.startsWith("data:", ignoreCase = true) -> trimmed
|
||||||
|
else -> File(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun tableCellImageModifier(
|
private fun tableCellImageModifier(
|
||||||
block: ImageBlock,
|
block: ImageBlock,
|
||||||
density: Density,
|
density: Density,
|
||||||
|
|
@ -2023,7 +2163,9 @@ private fun WrappingContentLayout(
|
||||||
|
|
||||||
Layout(content = {
|
Layout(content = {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = Builder(LocalContext.current).data(File(block.floatedImage.path)).build(),
|
model = Builder(LocalContext.current)
|
||||||
|
.data(nativeVerticalImageModelData(block.floatedImage.path))
|
||||||
|
.build(),
|
||||||
contentDescription = block.floatedImage.altText,
|
contentDescription = block.floatedImage.altText,
|
||||||
contentScale = imageContentScale(block.floatedImage.style)
|
contentScale = imageContentScale(block.floatedImage.style)
|
||||||
)
|
)
|
||||||
|
|
@ -2971,7 +3113,7 @@ fun PaginatedReaderScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||||
@OptIn(ExperimentalSerializationApi::class, FlowPreview::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NativeVerticalReaderScreen(
|
fun NativeVerticalReaderScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -3342,20 +3484,20 @@ fun NativeVerticalReaderScreen(
|
||||||
)
|
)
|
||||||
if (exactDelta != null) {
|
if (exactDelta != null) {
|
||||||
val scrollDelta = if (keepVisible) {
|
val scrollDelta = if (keepVisible) {
|
||||||
val viewportHeight = rootWindowBounds.height
|
nativeVerticalCenteredScrollDelta(
|
||||||
val comfortableTop = viewportHeight * 0.24f
|
targetOffsetInViewport = exactDelta,
|
||||||
val comfortableBottom = viewportHeight * 0.76f
|
viewportHeight = rootWindowBounds.height
|
||||||
if (exactDelta in comfortableTop..comfortableBottom) {
|
)
|
||||||
0f
|
|
||||||
} else {
|
|
||||||
exactDelta - (viewportHeight * 0.38f)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
exactDelta
|
exactDelta
|
||||||
}
|
}
|
||||||
if (abs(scrollDelta) > 1f) {
|
if (abs(scrollDelta) > 1f) {
|
||||||
|
if (animate) {
|
||||||
|
listState.animateScrollBy(scrollDelta)
|
||||||
|
} else {
|
||||||
listState.scrollBy(scrollDelta)
|
listState.scrollBy(scrollDelta)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (keepVisible || abs(exactDelta) > 1f) return true
|
if (keepVisible || abs(exactDelta) > 1f) return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3364,7 +3506,11 @@ fun NativeVerticalReaderScreen(
|
||||||
chapters = chapters,
|
chapters = chapters,
|
||||||
locator = locator
|
locator = locator
|
||||||
) ?: return false
|
) ?: return false
|
||||||
|
if (animate) {
|
||||||
|
listState.animateScrollToItem(targetIndex)
|
||||||
|
} else {
|
||||||
listState.scrollToItem(targetIndex)
|
listState.scrollToItem(targetIndex)
|
||||||
|
}
|
||||||
repeat(4) {
|
repeat(4) {
|
||||||
withFrameNanos { }
|
withFrameNanos { }
|
||||||
val refinedDelta = resolveNativeVerticalScrollDeltaForLocator(
|
val refinedDelta = resolveNativeVerticalScrollDeltaForLocator(
|
||||||
|
|
@ -3379,14 +3525,20 @@ fun NativeVerticalReaderScreen(
|
||||||
)
|
)
|
||||||
if (refinedDelta != null) {
|
if (refinedDelta != null) {
|
||||||
val scrollDelta = if (keepVisible) {
|
val scrollDelta = if (keepVisible) {
|
||||||
val viewportHeight = rootWindowBounds.height
|
nativeVerticalCenteredScrollDelta(
|
||||||
refinedDelta - (viewportHeight * 0.38f)
|
targetOffsetInViewport = refinedDelta,
|
||||||
|
viewportHeight = rootWindowBounds.height
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
refinedDelta
|
refinedDelta
|
||||||
}
|
}
|
||||||
if (abs(scrollDelta) > 1f) {
|
if (abs(scrollDelta) > 1f) {
|
||||||
|
if (animate) {
|
||||||
|
listState.animateScrollBy(scrollDelta)
|
||||||
|
} else {
|
||||||
listState.scrollBy(scrollDelta)
|
listState.scrollBy(scrollDelta)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3441,8 +3593,11 @@ fun NativeVerticalReaderScreen(
|
||||||
|
|
||||||
prefetchOrder.forEach { chapterIndex ->
|
prefetchOrder.forEach { chapterIndex ->
|
||||||
if (!isActive) return@LaunchedEffect
|
if (!isActive) return@LaunchedEffect
|
||||||
|
while (isActive && listState.isScrollInProgress) {
|
||||||
|
delay(80L)
|
||||||
|
}
|
||||||
loadFlowChapter(chapterIndex)
|
loadFlowChapter(chapterIndex)
|
||||||
delay(16L)
|
delay(80L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3453,9 +3608,18 @@ fun NativeVerticalReaderScreen(
|
||||||
didInitialScroll = true
|
didInitialScroll = true
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
val didScroll = scrollToFlowLocator(targetLocator, animate = false) ||
|
val didLocatorScroll = scrollToFlowLocator(targetLocator, animate = false)
|
||||||
|
val didScroll = didLocatorScroll ||
|
||||||
|
if (shouldFallbackNativeVerticalInitialScrollToCompatPage(
|
||||||
|
hasInitialLocator = initialNativeLocator != null,
|
||||||
|
didLocatorScroll = didLocatorScroll
|
||||||
|
)
|
||||||
|
) {
|
||||||
scrollToCompatPage(initialNativePageIndex, animate = false)
|
scrollToCompatPage(initialNativePageIndex, animate = false)
|
||||||
if (didScroll) {
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (didScroll || initialNativeLocator != null) {
|
||||||
didInitialScroll = true
|
didInitialScroll = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3471,7 +3635,12 @@ fun NativeVerticalReaderScreen(
|
||||||
LaunchedEffect(scrollRequestLocatorId, scrollRequestLocator, scrollRequestLocatorKeepVisible, flowChapters, rootWindowBounds) {
|
LaunchedEffect(scrollRequestLocatorId, scrollRequestLocator, scrollRequestLocatorKeepVisible, flowChapters, rootWindowBounds) {
|
||||||
val requestedLocator = scrollRequestLocator ?: return@LaunchedEffect
|
val requestedLocator = scrollRequestLocator ?: return@LaunchedEffect
|
||||||
if (flowChapters == null || rootWindowBounds == Rect.Zero) return@LaunchedEffect
|
if (flowChapters == null || rootWindowBounds == Rect.Zero) return@LaunchedEffect
|
||||||
if (scrollToFlowLocator(requestedLocator, animate = false, keepVisible = scrollRequestLocatorKeepVisible)) {
|
if (scrollToFlowLocator(
|
||||||
|
locator = requestedLocator,
|
||||||
|
animate = scrollRequestLocatorKeepVisible,
|
||||||
|
keepVisible = scrollRequestLocatorKeepVisible
|
||||||
|
)
|
||||||
|
) {
|
||||||
paginator.onUserScrolledTo(
|
paginator.onUserScrolledTo(
|
||||||
nativeVerticalCompatPageForProgress(
|
nativeVerticalCompatPageForProgress(
|
||||||
estimateNativeVerticalProgressPercent(book, requestedLocator) ?: 0f,
|
estimateNativeVerticalProgressPercent(book, requestedLocator) ?: 0f,
|
||||||
|
|
@ -3506,6 +3675,7 @@ fun NativeVerticalReaderScreen(
|
||||||
var lastReportedTotalPageCount by remember { mutableIntStateOf(0) }
|
var lastReportedTotalPageCount by remember { mutableIntStateOf(0) }
|
||||||
var lastReportedProgressPercent by remember { mutableFloatStateOf(-1f) }
|
var lastReportedProgressPercent by remember { mutableFloatStateOf(-1f) }
|
||||||
var lastReportedLocator by remember { mutableStateOf<Locator?>(null) }
|
var lastReportedLocator by remember { mutableStateOf<Locator?>(null) }
|
||||||
|
var lastReportedChapterPageInfo by remember { mutableStateOf<NativeVerticalChapterPageInfo?>(null) }
|
||||||
var lastReportedVisibleTextRanges by remember { mutableStateOf<List<NativeVerticalVisibleTextRange>>(emptyList()) }
|
var lastReportedVisibleTextRanges by remember { mutableStateOf<List<NativeVerticalVisibleTextRange>>(emptyList()) }
|
||||||
|
|
||||||
LaunchedEffect(paginator, totalPageCount, rootWindowBounds, blockLayoutMap, flowChapters, flowItems) {
|
LaunchedEffect(paginator, totalPageCount, rootWindowBounds, blockLayoutMap, flowChapters, flowItems) {
|
||||||
|
|
@ -3531,7 +3701,6 @@ fun NativeVerticalReaderScreen(
|
||||||
initialScrollComplete = didInitialScroll
|
initialScrollComplete = didInitialScroll
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.debounce(80)
|
|
||||||
.collectLatest { sample ->
|
.collectLatest { sample ->
|
||||||
if (!sample.initialScrollComplete) return@collectLatest
|
if (!sample.initialScrollComplete) return@collectLatest
|
||||||
val total = sample.totalPageCount
|
val total = sample.totalPageCount
|
||||||
|
|
@ -3561,18 +3730,32 @@ fun NativeVerticalReaderScreen(
|
||||||
}
|
}
|
||||||
val compatPage = nativeVerticalCompatPageForProgress(progressPercent, total)
|
val compatPage = nativeVerticalCompatPageForProgress(progressPercent, total)
|
||||||
paginator.onUserScrolledTo(compatPage)
|
paginator.onUserScrolledTo(compatPage)
|
||||||
|
val visibleChapterIndex = locator?.chapterIndex
|
||||||
|
?: flowItems.getOrNull(sample.firstVisiblePageIndex)?.chapterIndex
|
||||||
|
val chapterPageInfo = visibleChapterIndex?.let { chapterIndex ->
|
||||||
|
nativeVerticalChapterPageInfoForScroll(
|
||||||
|
itemChapterIndices = flowItems.map { it.chapterIndex },
|
||||||
|
itemWeights = flowItems.map { it.locationWeight },
|
||||||
|
firstVisibleItemIndex = sample.firstVisiblePageIndex,
|
||||||
|
firstVisibleItemScrollOffset = sample.firstVisiblePageScrollOffset,
|
||||||
|
firstVisibleItemSize = sample.firstVisibleItemSize,
|
||||||
|
chapterPageCount = paginator.chapterPageCounts[chapterIndex]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
compatPage != lastReportedVisiblePage ||
|
compatPage != lastReportedVisiblePage ||
|
||||||
total != lastReportedTotalPageCount ||
|
total != lastReportedTotalPageCount ||
|
||||||
abs(progressPercent - lastReportedProgressPercent) >= 0.05f ||
|
abs(progressPercent - lastReportedProgressPercent) >= 0.05f ||
|
||||||
locator != lastReportedLocator ||
|
locator != lastReportedLocator ||
|
||||||
|
chapterPageInfo != lastReportedChapterPageInfo ||
|
||||||
visibleTextRanges != lastReportedVisibleTextRanges
|
visibleTextRanges != lastReportedVisibleTextRanges
|
||||||
) {
|
) {
|
||||||
lastReportedVisiblePage = compatPage
|
lastReportedVisiblePage = compatPage
|
||||||
lastReportedTotalPageCount = total
|
lastReportedTotalPageCount = total
|
||||||
lastReportedProgressPercent = progressPercent
|
lastReportedProgressPercent = progressPercent
|
||||||
lastReportedLocator = locator
|
lastReportedLocator = locator
|
||||||
|
lastReportedChapterPageInfo = chapterPageInfo
|
||||||
lastReportedVisibleTextRanges = visibleTextRanges
|
lastReportedVisibleTextRanges = visibleTextRanges
|
||||||
onLocationChanged(
|
onLocationChanged(
|
||||||
NativeVerticalLocation(
|
NativeVerticalLocation(
|
||||||
|
|
@ -3586,7 +3769,8 @@ fun NativeVerticalReaderScreen(
|
||||||
firstVisibleItemSize = sample.firstVisibleItemSize,
|
firstVisibleItemSize = sample.firstVisibleItemSize,
|
||||||
isAtStart = sample.isAtStart,
|
isAtStart = sample.isAtStart,
|
||||||
isAtEnd = sample.isAtEnd,
|
isAtEnd = sample.isAtEnd,
|
||||||
visibleTextRanges = visibleTextRanges
|
visibleTextRanges = visibleTextRanges,
|
||||||
|
chapterPageInfo = chapterPageInfo
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
onProgressChanged(compatPage, total, progressPercent)
|
onProgressChanged(compatPage, total, progressPercent)
|
||||||
|
|
@ -3644,11 +3828,14 @@ fun NativeVerticalReaderScreen(
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
val clipboardManager =
|
val copied = copyPlainTextToClipboard(
|
||||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
context = context,
|
||||||
clipboardManager.setPrimaryClip(
|
label = context.getString(R.string.clip_label_copied_text),
|
||||||
ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), urlToShow)
|
text = urlToShow
|
||||||
)
|
)
|
||||||
|
if (!copied) {
|
||||||
|
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
showExternalLinkDialog = null
|
showExternalLinkDialog = null
|
||||||
}) { Text(stringResource(R.string.action_copy)) }
|
}) { Text(stringResource(R.string.action_copy)) }
|
||||||
}
|
}
|
||||||
|
|
@ -3673,7 +3860,8 @@ fun NativeVerticalReaderScreen(
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize(),
|
.fillMaxSize()
|
||||||
|
.sharedAcceleratedLazyWheelScroll(listState),
|
||||||
contentPadding = PaddingValues(top = verticalPadding, bottom = verticalPadding)
|
contentPadding = PaddingValues(top = verticalPadding, bottom = verticalPadding)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
|
|
@ -3820,11 +4008,14 @@ fun NativeVerticalReaderScreen(
|
||||||
) {
|
) {
|
||||||
PaginatedTextSelectionMenu(
|
PaginatedTextSelectionMenu(
|
||||||
onCopy = {
|
onCopy = {
|
||||||
val clipboardManager =
|
val copied = copyPlainTextToClipboard(
|
||||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
context = context,
|
||||||
clipboardManager.setPrimaryClip(
|
label = context.getString(R.string.clip_label_copied_text),
|
||||||
ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), sel.text)
|
text = sel.text
|
||||||
)
|
)
|
||||||
|
if (!copied) {
|
||||||
|
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
activeSelection = null
|
activeSelection = null
|
||||||
},
|
},
|
||||||
onSelectAll = null,
|
onSelectAll = null,
|
||||||
|
|
@ -5806,10 +5997,14 @@ internal fun PaginatedReaderContent(
|
||||||
Row(horizontalArrangement = Arrangement.End) {
|
Row(horizontalArrangement = Arrangement.End) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val clipboard =
|
val copied = copyPlainTextToClipboard(
|
||||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
context = context,
|
||||||
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow)
|
label = context.getString(R.string.clip_label_copied_link),
|
||||||
clipboard.setPrimaryClip(clip)
|
text = urlToShow
|
||||||
|
)
|
||||||
|
if (!copied) {
|
||||||
|
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
showExternalLinkDialog = null
|
showExternalLinkDialog = null
|
||||||
}) { Text(stringResource(R.string.action_copy)) }
|
}) { Text(stringResource(R.string.action_copy)) }
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|
@ -7535,10 +7730,14 @@ internal fun PaginatedReaderContent(
|
||||||
) {
|
) {
|
||||||
PaginatedTextSelectionMenu(
|
PaginatedTextSelectionMenu(
|
||||||
onCopy = {
|
onCopy = {
|
||||||
val clipboardManager =
|
val copied = copyPlainTextToClipboard(
|
||||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
context = context,
|
||||||
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), sel.text)
|
label = context.getString(R.string.clip_label_copied_text),
|
||||||
clipboardManager.setPrimaryClip(clip)
|
text = sel.text
|
||||||
|
)
|
||||||
|
if (!copied) {
|
||||||
|
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
activeSelection = null
|
activeSelection = null
|
||||||
},
|
},
|
||||||
onSelectAll = null,
|
onSelectAll = null,
|
||||||
|
|
@ -8107,15 +8306,15 @@ private fun RenderFlexChildBlock(
|
||||||
searchHighlighted
|
searchHighlighted
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply block specific styles (like header font weight)
|
val finalStyle = when (block) {
|
||||||
val finalStyle = if (block is HeaderBlock) {
|
is HeaderBlock -> createHeaderTextStyle(
|
||||||
createHeaderTextStyle(
|
|
||||||
baseStyle = textStyle,
|
baseStyle = textStyle,
|
||||||
level = block.level,
|
level = block.level,
|
||||||
textAlign = block.textAlign
|
textAlign = block.textAlign
|
||||||
)
|
)
|
||||||
} else {
|
is ParagraphBlock -> textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign)
|
||||||
textStyle
|
is QuoteBlock -> textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign)
|
||||||
|
is ListItemBlock -> textStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
TextWithEmphasis(
|
TextWithEmphasis(
|
||||||
|
|
@ -8157,7 +8356,7 @@ private fun RenderFlexChildBlock(
|
||||||
|
|
||||||
if (itemMarkerImage != null) {
|
if (itemMarkerImage != null) {
|
||||||
val imageRequest =
|
val imageRequest =
|
||||||
Builder(LocalContext.current).data(File(itemMarkerImage))
|
Builder(LocalContext.current).data(nativeVerticalImageModelData(itemMarkerImage))
|
||||||
.crossfade(true).build()
|
.crossfade(true).build()
|
||||||
val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
|
val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
|
||||||
|
|
||||||
|
|
@ -8227,7 +8426,7 @@ private fun RenderFlexChildBlock(
|
||||||
} else if (style.width != Dp.Unspecified && style.width > 0.dp) {
|
} else if (style.width != Dp.Unspecified && style.width > 0.dp) {
|
||||||
Modifier.width(style.width)
|
Modifier.width(style.width)
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier.fillMaxWidth()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(
|
.then(
|
||||||
|
|
@ -8250,7 +8449,7 @@ private fun RenderFlexChildBlock(
|
||||||
)
|
)
|
||||||
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = Builder(LocalContext.current).data(File(childBlock.path)).crossfade(true)
|
model = Builder(LocalContext.current).data(nativeVerticalImageModelData(childBlock.path)).crossfade(true)
|
||||||
.build(),
|
.build(),
|
||||||
contentDescription = childBlock.altText,
|
contentDescription = childBlock.altText,
|
||||||
modifier = imageModifier,
|
modifier = imageModifier,
|
||||||
|
|
@ -8338,9 +8537,7 @@ private fun RenderFlexChildBlock(
|
||||||
} else if (blockInCell is ImageBlock) {
|
} else if (blockInCell is ImageBlock) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = Builder(LocalContext.current).data(
|
model = Builder(LocalContext.current).data(
|
||||||
File(
|
nativeVerticalImageModelData(blockInCell.path)
|
||||||
blockInCell.path
|
|
||||||
)
|
|
||||||
).build(),
|
).build(),
|
||||||
contentDescription = blockInCell.altText,
|
contentDescription = blockInCell.altText,
|
||||||
contentScale = imageContentScale(blockInCell.style),
|
contentScale = imageContentScale(blockInCell.style),
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
@ -30,6 +31,8 @@ import kotlin.random.Random
|
||||||
private const val PDF_PREVIEW_MAX_WIDTH_PX = 1080
|
private const val PDF_PREVIEW_MAX_WIDTH_PX = 1080
|
||||||
private const val PDF_PREVIEW_MAX_HEIGHT_PX = 2048
|
private const val PDF_PREVIEW_MAX_HEIGHT_PX = 2048
|
||||||
private const val PDF_PREVIEW_MAX_BYTES = 16L * 1024L * 1024L
|
private const val PDF_PREVIEW_MAX_BYTES = 16L * 1024L * 1024L
|
||||||
|
private const val PDF_ENCRYPT_MARKER_TAIL_BYTES = 512 * 1024
|
||||||
|
private val PDF_ENCRYPT_MARKER = "/Encrypt".toByteArray(Charsets.US_ASCII)
|
||||||
|
|
||||||
object PdfiumCoreProvider {
|
object PdfiumCoreProvider {
|
||||||
val core: PdfiumCoreKt by lazy {
|
val core: PdfiumCoreKt by lazy {
|
||||||
|
|
@ -42,7 +45,8 @@ internal data class DocumentCacheItem(
|
||||||
val pfd: ParcelFileDescriptor?,
|
val pfd: ParcelFileDescriptor?,
|
||||||
val totalPages: Int,
|
val totalPages: Int,
|
||||||
val pageAspectRatios: List<Float>,
|
val pageAspectRatios: List<Float>,
|
||||||
val flatTableOfContents: List<TocEntry>
|
val flatTableOfContents: List<TocEntry>,
|
||||||
|
val isPasswordProtectedPdf: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
internal class DocumentCache(val maxSize: Int = 3) {
|
internal class DocumentCache(val maxSize: Int = 3) {
|
||||||
|
|
@ -123,6 +127,68 @@ class PdfPrintDocumentAdapter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun pdfBytesContainEncryptMarker(bytes: ByteArray): Boolean {
|
||||||
|
for (index in 0..bytes.size - PDF_ENCRYPT_MARKER.size) {
|
||||||
|
var matches = true
|
||||||
|
for (offset in PDF_ENCRYPT_MARKER.indices) {
|
||||||
|
if (bytes[index + offset] != PDF_ENCRYPT_MARKER[offset]) {
|
||||||
|
matches = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches && bytes.getOrNull(index + PDF_ENCRYPT_MARKER.size)?.isPdfNameDelimiter() != false) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isPdfLikelyEncryptedForPrint(context: Context, uri: Uri): Boolean {
|
||||||
|
return try {
|
||||||
|
context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||||
|
FileInputStream(pfd.fileDescriptor).use { input ->
|
||||||
|
val knownSize = pfd.statSize.takeIf { it >= 0L }
|
||||||
|
?: runCatching { input.channel.size() }.getOrNull()?.takeIf { it >= 0L }
|
||||||
|
val tailBytes = if (knownSize != null && knownSize > PDF_ENCRYPT_MARKER_TAIL_BYTES) {
|
||||||
|
input.channel.position(knownSize - PDF_ENCRYPT_MARKER_TAIL_BYTES)
|
||||||
|
input.readBytes()
|
||||||
|
} else if (knownSize != null) {
|
||||||
|
input.readBytes()
|
||||||
|
} else {
|
||||||
|
input.readLastBytes(PDF_ENCRYPT_MARKER_TAIL_BYTES)
|
||||||
|
}
|
||||||
|
pdfBytesContainEncryptMarker(tailBytes)
|
||||||
|
}
|
||||||
|
} ?: false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("PdfPrint").w(e, "Could not inspect PDF encryption marker before print")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Byte.isPdfNameDelimiter(): Boolean {
|
||||||
|
return when (toInt().toChar()) {
|
||||||
|
'\u0000', '\t', '\n', '\u000C', '\r', ' ', '(', ')', '<', '>', '[', ']', '{', '}', '/', '%' -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FileInputStream.readLastBytes(maxBytes: Int): ByteArray {
|
||||||
|
val output = ByteArrayOutputStream(maxBytes)
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
var bytesRead: Int
|
||||||
|
while (read(buffer).also { bytesRead = it } > 0) {
|
||||||
|
if (output.size() + bytesRead <= maxBytes) {
|
||||||
|
output.write(buffer, 0, bytesRead)
|
||||||
|
} else {
|
||||||
|
val combined = output.toByteArray() + buffer.copyOf(bytesRead)
|
||||||
|
output.reset()
|
||||||
|
output.write(combined, combined.size - maxBytes, maxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
internal fun generateShortId(): String {
|
internal fun generateShortId(): String {
|
||||||
return Random.nextInt(1000, 9999).toString()
|
return Random.nextInt(1000, 9999).toString()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ private fun Throwable.readablePdfErrorDetail(): String {
|
||||||
|
|
||||||
private const val PDF_TILE_SIZE_DP = 256
|
private const val PDF_TILE_SIZE_DP = 256
|
||||||
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
|
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
|
||||||
private const val PDF_TILE_SCALE_TOLERANCE = 0.06f
|
private const val PDF_TILE_SCALE_TOLERANCE = 0.03f
|
||||||
private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 60L
|
private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 60L
|
||||||
private const val PDF_TILE_RENDER_IDLE_COOLDOWN_MS = 220L
|
private const val PDF_TILE_RENDER_IDLE_COOLDOWN_MS = 220L
|
||||||
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
|
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
|
||||||
|
|
@ -462,7 +462,13 @@ internal fun PdfPageComposable(
|
||||||
var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) }
|
var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) }
|
||||||
var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) }
|
var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) }
|
||||||
|
|
||||||
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
|
val needsTilingNow = shouldRenderPdfHighResTiles(
|
||||||
|
effectiveScale = effectiveScale,
|
||||||
|
targetWidthPx = actualBitmapWidthPx,
|
||||||
|
targetHeightPx = actualBitmapHeightPx,
|
||||||
|
isVerticalScroll = isVerticalScroll,
|
||||||
|
isActivePage = isActivePage
|
||||||
|
)
|
||||||
|
|
||||||
val canvasWidthPx = remember { mutableFloatStateOf(0f) }
|
val canvasWidthPx = remember { mutableFloatStateOf(0f) }
|
||||||
val canvasHeightPx = remember { mutableFloatStateOf(0f) }
|
val canvasHeightPx = remember { mutableFloatStateOf(0f) }
|
||||||
|
|
@ -1241,7 +1247,7 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (latestShouldPauseHighResTileRendering && renderScale > 1f) {
|
if (latestShouldPauseHighResTileRendering) {
|
||||||
if (shouldLogTileSample) {
|
if (shouldLogTileSample) {
|
||||||
PdfVerticalPerfLog.d(
|
PdfVerticalPerfLog.d(
|
||||||
"tile-render-paused mode=$tileLogMode page=$pageIndex reason=motion scale=${PdfVerticalPerfLog.f(renderScale)} " +
|
"tile-render-paused mode=$tileLogMode page=$pageIndex reason=motion scale=${PdfVerticalPerfLog.f(renderScale)} " +
|
||||||
|
|
@ -1260,7 +1266,7 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
delay(PDF_TILE_IDLE_RENDER_DELAY_MS)
|
delay(PDF_TILE_IDLE_RENDER_DELAY_MS)
|
||||||
if (!isActive) return@collectLatest
|
if (!isActive) return@collectLatest
|
||||||
if (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) {
|
if (latestShouldPauseHighResTileRendering) {
|
||||||
if (shouldLogHighResTile) {
|
if (shouldLogHighResTile) {
|
||||||
PdfVerticalPerfLog.d(
|
PdfVerticalPerfLog.d(
|
||||||
"tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-resumed missing=${tilesToRenderIds.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
|
"tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-resumed missing=${tilesToRenderIds.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
|
||||||
|
|
@ -1323,7 +1329,7 @@ internal fun PdfPageComposable(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!isActive) return@withLock
|
if (!isActive) return@withLock
|
||||||
if (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) {
|
if (latestShouldPauseHighResTileRendering) {
|
||||||
if (shouldLogHighResTile) {
|
if (shouldLogHighResTile) {
|
||||||
PdfVerticalPerfLog.d(
|
PdfVerticalPerfLog.d(
|
||||||
"tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-started-before-native tile=$tileId scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
|
"tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-started-before-native tile=$tileId scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
|
||||||
|
|
@ -1380,7 +1386,7 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
return@collectLatest
|
return@collectLatest
|
||||||
}
|
}
|
||||||
if (renderedTiles.isNotEmpty() && latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) {
|
if (renderedTiles.isNotEmpty() && latestShouldPauseHighResTileRendering) {
|
||||||
if (shouldLogHighResTile) {
|
if (shouldLogHighResTile) {
|
||||||
PdfVerticalPerfLog.d(
|
PdfVerticalPerfLog.d(
|
||||||
"tile-render-discarded mode=$tileLogMode page=$pageIndex reason=motion-before-commit rendered=${renderedTiles.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
|
"tile-render-discarded mode=$tileLogMode page=$pageIndex reason=motion-before-commit rendered=${renderedTiles.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
|
||||||
|
|
@ -4040,9 +4046,9 @@ internal fun PdfPageComposable(
|
||||||
val stableTiles = remember(tiles) { StableHolder(tiles) }
|
val stableTiles = remember(tiles) { StableHolder(tiles) }
|
||||||
val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) }
|
val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) }
|
||||||
val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) }
|
val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) }
|
||||||
val shouldDrawHighResTiles = !shouldPauseHighResTileRendering
|
val shouldDrawHighResTiles = !shouldPauseHighResTileRendering && needsTilingNow
|
||||||
LaunchedEffect(shouldDrawHighResTiles, stableTiles.item.size, effectiveScale) {
|
LaunchedEffect(shouldDrawHighResTiles, stableTiles.item.size, effectiveScale) {
|
||||||
if (stableTiles.item.isNotEmpty() && effectiveScale > 1f) {
|
if (stableTiles.item.isNotEmpty() && shouldDrawHighResTiles) {
|
||||||
PdfVerticalPerfLog.d(
|
PdfVerticalPerfLog.d(
|
||||||
"tile-display mode=${if (isVerticalScroll) "vertical" else "pagination"} page=$pageIndex " +
|
"tile-display mode=${if (isVerticalScroll) "vertical" else "pagination"} page=$pageIndex " +
|
||||||
"visible=$shouldDrawHighResTiles tiles=${stableTiles.item.size} pause=$shouldPauseHighResTileRendering " +
|
"visible=$shouldDrawHighResTiles tiles=${stableTiles.item.size} pause=$shouldPauseHighResTileRendering " +
|
||||||
|
|
@ -4513,8 +4519,7 @@ private fun PdfBitmapLayer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
|
if (shouldDrawHighResTiles) {
|
||||||
if (needsTiling && shouldDrawHighResTiles) {
|
|
||||||
tiles.forEach { tile ->
|
tiles.forEach { tile ->
|
||||||
if (
|
if (
|
||||||
tile.bitmap.isCanvasSafeBitmap(
|
tile.bitmap.isCanvasSafeBitmap(
|
||||||
|
|
@ -5353,7 +5358,7 @@ private fun PdfPageRenderer(
|
||||||
) {
|
) {
|
||||||
MagnifierComposable(
|
MagnifierComposable(
|
||||||
sourceBitmap = staticData.bitmap.item.asImageBitmap(),
|
sourceBitmap = staticData.bitmap.item.asImageBitmap(),
|
||||||
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(),
|
tiles = if (staticData.shouldDrawHighResTiles) staticData.tiles.item else emptyList(),
|
||||||
currentScale = effectiveScale,
|
currentScale = effectiveScale,
|
||||||
magnifierCenterOnBitmap = magnifierCenterTarget,
|
magnifierCenterOnBitmap = magnifierCenterTarget,
|
||||||
contentWidthPx = staticData.targetWidth,
|
contentWidthPx = staticData.targetWidth,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
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.heightIn
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
|
@ -28,8 +29,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
|
@ -64,6 +67,7 @@ import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.boundsInWindow
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
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
|
||||||
|
|
@ -74,6 +78,7 @@ import com.aryan.reader.R
|
||||||
import com.aryan.reader.epubreader.OptionSegmentedControl
|
import com.aryan.reader.epubreader.OptionSegmentedControl
|
||||||
import com.aryan.reader.epubreader.SystemUiMode
|
import com.aryan.reader.epubreader.SystemUiMode
|
||||||
import com.aryan.reader.epubreader.titleRes
|
import com.aryan.reader.epubreader.titleRes
|
||||||
|
import com.aryan.reader.readerModalMaxHeightDp
|
||||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -568,6 +573,8 @@ fun PdfVisualOptionsSheet(
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
|
|
@ -577,6 +584,8 @@ fun PdfVisualOptionsSheet(
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = maxSheetHeight)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 24.dp, vertical = 8.dp)
|
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||||
.padding(bottom = 32.dp)
|
.padding(bottom = 32.dp)
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,8 @@ internal fun pdfOverflowMenuSections(
|
||||||
hasHiddenToolbarTools: Boolean,
|
hasHiddenToolbarTools: Boolean,
|
||||||
isPro: Boolean,
|
isPro: Boolean,
|
||||||
effectiveFileType: FileType,
|
effectiveFileType: FileType,
|
||||||
hasFileInfo: Boolean = true
|
hasFileInfo: Boolean = true,
|
||||||
|
canPrintDocument: Boolean = true
|
||||||
): List<PdfOverflowMenuSection> = buildList {
|
): List<PdfOverflowMenuSection> = buildList {
|
||||||
add(PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR)
|
add(PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR)
|
||||||
if (hasHiddenToolbarTools) add(PdfOverflowMenuSection.HIDDEN_TOOLS)
|
if (hasHiddenToolbarTools) add(PdfOverflowMenuSection.HIDDEN_TOOLS)
|
||||||
|
|
@ -96,7 +97,7 @@ internal fun pdfOverflowMenuSections(
|
||||||
if (
|
if (
|
||||||
!hiddenTools.contains(PdfReaderTool.SHARE.name) ||
|
!hiddenTools.contains(PdfReaderTool.SHARE.name) ||
|
||||||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) ||
|
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) ||
|
||||||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name))
|
(effectiveFileType == FileType.PDF && canPrintDocument && !hiddenTools.contains(PdfReaderTool.PRINT.name))
|
||||||
) {
|
) {
|
||||||
add(PdfOverflowMenuSection.FILE_ACTIONS)
|
add(PdfOverflowMenuSection.FILE_ACTIONS)
|
||||||
}
|
}
|
||||||
|
|
@ -136,6 +137,7 @@ internal fun PdfTopBar(
|
||||||
isReflowingThisBook: Boolean,
|
isReflowingThisBook: Boolean,
|
||||||
hasReflowFile: Boolean,
|
hasReflowFile: Boolean,
|
||||||
isPdfDocumentLoaded: Boolean,
|
isPdfDocumentLoaded: Boolean,
|
||||||
|
canPrintDocument: Boolean = true,
|
||||||
isTabsEnabled: Boolean,
|
isTabsEnabled: Boolean,
|
||||||
openTabs: List<RecentFileItem>,
|
openTabs: List<RecentFileItem>,
|
||||||
activeTabBookId: String?,
|
activeTabBookId: String?,
|
||||||
|
|
@ -395,12 +397,13 @@ internal fun PdfTopBar(
|
||||||
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
|
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
|
||||||
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
|
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
|
||||||
val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)
|
val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)
|
||||||
val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)
|
val showPrintAction = effectiveFileType == FileType.PDF && canPrintDocument && !hiddenTools.contains(PdfReaderTool.PRINT.name)
|
||||||
pdfOverflowMenuSections(
|
pdfOverflowMenuSections(
|
||||||
hiddenTools = hiddenTools,
|
hiddenTools = hiddenTools,
|
||||||
hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(),
|
hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(),
|
||||||
isPro = BuildConfig.IS_PRO,
|
isPro = BuildConfig.IS_PRO,
|
||||||
effectiveFileType = effectiveFileType
|
effectiveFileType = effectiveFileType,
|
||||||
|
canPrintDocument = canPrintDocument
|
||||||
).forEachIndexed { index, section ->
|
).forEachIndexed { index, section ->
|
||||||
if (index > 0) HorizontalDivider()
|
if (index > 0) HorizontalDivider()
|
||||||
when (section) {
|
when (section) {
|
||||||
|
|
|
||||||
|
|
@ -378,7 +378,6 @@ fun PdfViewerScreen(
|
||||||
var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) }
|
var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) }
|
||||||
var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) }
|
var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) }
|
||||||
var showScreenOrientationSheet by remember { mutableStateOf(false) }
|
var showScreenOrientationSheet by remember { mutableStateOf(false) }
|
||||||
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
|
|
||||||
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
|
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
|
||||||
var isScrollLocked by remember { mutableStateOf(false) }
|
var isScrollLocked by remember { mutableStateOf(false) }
|
||||||
var lockedState by remember { mutableStateOf<Triple<Float, Float, Float>?>(null) }
|
var lockedState by remember { mutableStateOf<Triple<Float, Float, Float>?>(null) }
|
||||||
|
|
@ -454,6 +453,8 @@ fun PdfViewerScreen(
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri
|
val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri
|
||||||
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
|
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
|
||||||
|
var documentPassword by rememberSaveable(effectivePdfUri.toString()) { mutableStateOf<String?>(null) }
|
||||||
|
var isPrintBlockedForPasswordProtectedPdf by rememberSaveable(effectivePdfUri.toString()) { mutableStateOf(false) }
|
||||||
val isComicFile = effectiveFileType in COMIC_ARCHIVE_FILE_TYPES
|
val isComicFile = effectiveFileType in COMIC_ARCHIVE_FILE_TYPES
|
||||||
|
|
||||||
var showNewTabSheet by remember { mutableStateOf(false) }
|
var showNewTabSheet by remember { mutableStateOf(false) }
|
||||||
|
|
@ -598,7 +599,11 @@ fun PdfViewerScreen(
|
||||||
isAutoScrollLocal = loadPdfAutoScrollLocalMode(context, bookId)
|
isAutoScrollLocal = loadPdfAutoScrollLocalMode(context, bookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
val onPrintDocument: () -> Unit = {
|
val onPrintDocument: () -> Unit = onPrintDocument@{
|
||||||
|
if (isPrintBlockedForPasswordProtectedPdf) {
|
||||||
|
showBanner(context.getString(R.string.error_print_password_protected), isError = true)
|
||||||
|
return@onPrintDocument
|
||||||
|
}
|
||||||
val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager
|
val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager
|
||||||
val jobName = "${context.getString(R.string.app_name)} - $originalFileName"
|
val jobName = "${context.getString(R.string.app_name)} - $originalFileName"
|
||||||
|
|
||||||
|
|
@ -2423,8 +2428,9 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(currentPageScale) {
|
val zoomIndicatorPercentage = pdfZoomIndicatorPercent(currentPageScale)
|
||||||
if (currentPageScale != 1f) {
|
LaunchedEffect(zoomIndicatorPercentage) {
|
||||||
|
if (shouldShowPdfZoomIndicator(zoomIndicatorPercentage)) {
|
||||||
showZoomIndicator = true
|
showZoomIndicator = true
|
||||||
delay(1500)
|
delay(1500)
|
||||||
showZoomIndicator = false
|
showZoomIndicator = false
|
||||||
|
|
@ -3518,6 +3524,7 @@ fun PdfViewerScreen(
|
||||||
isDocumentReady = false
|
isDocumentReady = false
|
||||||
errorMessage = null
|
errorMessage = null
|
||||||
documentMetadataTitle = null
|
documentMetadataTitle = null
|
||||||
|
isPrintBlockedForPasswordProtectedPdf = false
|
||||||
currentBookId = null
|
currentBookId = null
|
||||||
areAnnotationsLoaded = false
|
areAnnotationsLoaded = false
|
||||||
loadedSidecarBookId = null
|
loadedSidecarBookId = null
|
||||||
|
|
@ -3591,6 +3598,7 @@ fun PdfViewerScreen(
|
||||||
totalPages = cachedItem.totalPages
|
totalPages = cachedItem.totalPages
|
||||||
pageAspectRatios = cachedItem.pageAspectRatios
|
pageAspectRatios = cachedItem.pageAspectRatios
|
||||||
flatTableOfContents = cachedItem.flatTableOfContents
|
flatTableOfContents = cachedItem.flatTableOfContents
|
||||||
|
isPrintBlockedForPasswordProtectedPdf = cachedItem.isPasswordProtectedPdf
|
||||||
|
|
||||||
val mapPage = tabStateMap[currentBookId!!]
|
val mapPage = tabStateMap[currentBookId!!]
|
||||||
val uiPage = uiState.initialPageInBook
|
val uiPage = uiState.initialPageInBook
|
||||||
|
|
@ -3634,6 +3642,8 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF
|
val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF
|
||||||
val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore)
|
val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore)
|
||||||
|
val loadedPasswordProtectedPdf = selectedDocumentType == FileType.PDF &&
|
||||||
|
(documentPassword != null || isPdfLikelyEncryptedForPrint(context, effectivePdfUri))
|
||||||
|
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
doc.close()
|
doc.close()
|
||||||
|
|
@ -3641,6 +3651,7 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
pdfDocument = doc
|
pdfDocument = doc
|
||||||
|
isPrintBlockedForPasswordProtectedPdf = loadedPasswordProtectedPdf
|
||||||
documentMetadataTitle = (doc as? PdfDocumentWrapper)?.let { wrapper ->
|
documentMetadataTitle = (doc as? PdfDocumentWrapper)?.let { wrapper ->
|
||||||
PdfiumEngineProvider.withPdfium {
|
PdfiumEngineProvider.withPdfium {
|
||||||
wrapper.pdfDocument.getDocumentMeta().title?.takeIf { it.isNotBlank() }
|
wrapper.pdfDocument.getDocumentMeta().title?.takeIf { it.isNotBlank() }
|
||||||
|
|
@ -3737,7 +3748,8 @@ fun PdfViewerScreen(
|
||||||
pfd = null,
|
pfd = null,
|
||||||
totalPages = pagesCount,
|
totalPages = pagesCount,
|
||||||
pageAspectRatios = ratios,
|
pageAspectRatios = ratios,
|
||||||
flatTableOfContents = flatTableOfContents
|
flatTableOfContents = flatTableOfContents,
|
||||||
|
isPasswordProtectedPdf = loadedPasswordProtectedPdf
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -4558,6 +4570,8 @@ fun PdfViewerScreen(
|
||||||
val latestSpreadScale = rememberUpdatedState(currentActiveScale)
|
val latestSpreadScale = rememberUpdatedState(currentActiveScale)
|
||||||
val latestSpreadOffset = rememberUpdatedState(currentActiveOffset)
|
val latestSpreadOffset = rememberUpdatedState(currentActiveOffset)
|
||||||
val spreadPageGap = if (showVerticalPageGap) 8.dp else 0.dp
|
val spreadPageGap = if (showVerticalPageGap) 8.dp else 0.dp
|
||||||
|
val spreadPageGapPx = with(density) { spreadPageGap.toPx() }
|
||||||
|
val spreadPageCount = spreadPageIndices.size
|
||||||
var spreadPanFlingJob by remember { mutableStateOf<Job?>(null) }
|
var spreadPanFlingJob by remember { mutableStateOf<Job?>(null) }
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -4930,6 +4944,20 @@ fun PdfViewerScreen(
|
||||||
) {
|
) {
|
||||||
spreadPageIndices.forEach { pageIndex ->
|
spreadPageIndices.forEach { pageIndex ->
|
||||||
key(pageIndex) {
|
key(pageIndex) {
|
||||||
|
val spreadPageWidth = if (spreadPageCount > 1) {
|
||||||
|
val pageAspectRatio = displayPageRatios.getOrElse(pageIndex) { 1f }
|
||||||
|
with(density) {
|
||||||
|
pdfSpreadPageSlotWidth(
|
||||||
|
containerWidth = boxMaxWidthFloat,
|
||||||
|
containerHeight = boxMaxHeightFloat,
|
||||||
|
pageGap = spreadPageGapPx,
|
||||||
|
spreadPageCount = spreadPageCount,
|
||||||
|
pageAspectRatio = pageAspectRatio
|
||||||
|
).toDp()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
with(density) { boxMaxWidthFloat.toDp() }
|
||||||
|
}
|
||||||
val isPageBookmarked by remember(bookmarks, pageIndex) {
|
val isPageBookmarked by remember(bookmarks, pageIndex) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
bookmarks.any { it.pageIndex == pageIndex }
|
bookmarks.any { it.pageIndex == pageIndex }
|
||||||
|
|
@ -5130,7 +5158,7 @@ fun PdfViewerScreen(
|
||||||
ocrHoverHighlights = stableOcrRects,
|
ocrHoverHighlights = stableOcrRects,
|
||||||
modifier = if (spreadPageIndices.size > 1) {
|
modifier = if (spreadPageIndices.size > 1) {
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.width(spreadPageWidth)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
} else {
|
} else {
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
|
|
@ -6278,6 +6306,7 @@ fun PdfViewerScreen(
|
||||||
isReflowingThisBook = isReflowingThisBook,
|
isReflowingThisBook = isReflowingThisBook,
|
||||||
hasReflowFile = hasReflowFile,
|
hasReflowFile = hasReflowFile,
|
||||||
isPdfDocumentLoaded = pdfDocument != null,
|
isPdfDocumentLoaded = pdfDocument != null,
|
||||||
|
canPrintDocument = !isPrintBlockedForPasswordProtectedPdf,
|
||||||
isTabsEnabled = isPdfTabStripVisible,
|
isTabsEnabled = isPdfTabStripVisible,
|
||||||
openTabs = openTabs,
|
openTabs = openTabs,
|
||||||
activeTabBookId = activeTabBookId,
|
activeTabBookId = activeTabBookId,
|
||||||
|
|
@ -7100,9 +7129,8 @@ fun PdfViewerScreen(
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut()
|
exit = fadeOut()
|
||||||
) {
|
) {
|
||||||
val percentage = (currentPageScale * 100).roundToInt()
|
|
||||||
ZoomPercentageIndicator(
|
ZoomPercentageIndicator(
|
||||||
percentage = percentage,
|
percentage = zoomIndicatorPercentage,
|
||||||
onResetZoomClick = {
|
onResetZoomClick = {
|
||||||
resetZoomTrigger = System.currentTimeMillis()
|
resetZoomTrigger = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.aryan.reader.pdf
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import com.aryan.reader.shared.pdf.PdfSpreadLayout
|
import com.aryan.reader.shared.pdf.PdfSpreadLayout
|
||||||
import com.aryan.reader.shared.reader.ReaderSettings
|
import com.aryan.reader.shared.reader.ReaderSettings
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
internal fun resolveEraserStrokeWidth(
|
internal fun resolveEraserStrokeWidth(
|
||||||
isEraserOverride: Boolean,
|
isEraserOverride: Boolean,
|
||||||
|
|
@ -88,6 +89,22 @@ internal fun clampPdfSpreadCameraOffset(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun pdfSpreadPageSlotWidth(
|
||||||
|
containerWidth: Float,
|
||||||
|
containerHeight: Float,
|
||||||
|
pageGap: Float,
|
||||||
|
spreadPageCount: Int,
|
||||||
|
pageAspectRatio: Float
|
||||||
|
): Float {
|
||||||
|
if (containerWidth <= 0f || containerHeight <= 0f || spreadPageCount <= 0) return 0f
|
||||||
|
val safeGap = pageGap.coerceAtLeast(0f)
|
||||||
|
val safeAspectRatio = pageAspectRatio.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||||
|
val availableWidth = (containerWidth - (safeGap * (spreadPageCount - 1))).coerceAtLeast(0f)
|
||||||
|
val maxPageWidth = availableWidth / spreadPageCount
|
||||||
|
val heightFittedPageWidth = containerHeight * safeAspectRatio
|
||||||
|
return heightFittedPageWidth.coerceAtMost(maxPageWidth).coerceAtLeast(0f)
|
||||||
|
}
|
||||||
|
|
||||||
internal fun activePdfCameraAfterLockPreferenceLoad(
|
internal fun activePdfCameraAfterLockPreferenceLoad(
|
||||||
isScrollLocked: Boolean,
|
isScrollLocked: Boolean,
|
||||||
lockedState: Triple<Float, Float, Float>?
|
lockedState: Triple<Float, Float, Float>?
|
||||||
|
|
@ -139,3 +156,32 @@ internal fun shouldResetPdfZoomAfterBubbleZoomCleanup(
|
||||||
isZoomEnabled &&
|
isZoomEnabled &&
|
||||||
!isScrollLocked
|
!isScrollLocked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun shouldRenderPdfHighResTiles(
|
||||||
|
effectiveScale: Float,
|
||||||
|
targetWidthPx: Int,
|
||||||
|
targetHeightPx: Int,
|
||||||
|
isVerticalScroll: Boolean,
|
||||||
|
isActivePage: Boolean,
|
||||||
|
largePageThresholdPx: Int = 3000,
|
||||||
|
verticalScaleTolerance: Float = 0.01f
|
||||||
|
): Boolean {
|
||||||
|
val hasLargePage = targetWidthPx > largePageThresholdPx || targetHeightPx > largePageThresholdPx
|
||||||
|
val isPageEligible = isVerticalScroll || isActivePage
|
||||||
|
if (!isPageEligible) return false
|
||||||
|
if (hasLargePage) return true
|
||||||
|
|
||||||
|
val safeScale = effectiveScale.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||||
|
return if (isVerticalScroll) {
|
||||||
|
kotlin.math.abs(safeScale - 1f) > verticalScaleTolerance
|
||||||
|
} else {
|
||||||
|
safeScale > 1f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun pdfZoomIndicatorPercent(scale: Float): Int {
|
||||||
|
val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||||
|
return (safeScale * 100f).roundToInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun shouldShowPdfZoomIndicator(percentage: Int): Boolean = percentage != 100
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,14 @@ import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
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.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -69,6 +72,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||||
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.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.selected
|
import androidx.compose.ui.semantics.selected
|
||||||
|
|
@ -84,6 +88,7 @@ import com.aryan.reader.HexInput
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.RgbInputColumn
|
import com.aryan.reader.RgbInputColumn
|
||||||
import com.aryan.reader.SpectrumBox
|
import com.aryan.reader.SpectrumBox
|
||||||
|
import com.aryan.reader.readerModalMaxHeightDp
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -151,18 +156,28 @@ fun ToolSettingsPopup(
|
||||||
}
|
}
|
||||||
|
|
||||||
val circleSize = 28.dp
|
val circleSize = 28.dp
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val maxPopupHeight = readerModalMaxHeightDp(
|
||||||
|
screenHeightDp = configuration.screenHeightDp,
|
||||||
|
fraction = 0.8f,
|
||||||
|
verticalMarginDp = 64,
|
||||||
|
preferredMinHeightDp = 240
|
||||||
|
).dp
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(360.dp)
|
.width(360.dp)
|
||||||
.padding(12.dp),
|
.padding(12.dp)
|
||||||
|
.heightIn(max = maxPopupHeight),
|
||||||
shape = RoundedCornerShape(28.dp),
|
shape = RoundedCornerShape(28.dp),
|
||||||
color = Color(0xFF1E1E1E),
|
color = Color(0xFF1E1E1E),
|
||||||
shadowElevation = 12.dp,
|
shadowElevation = 12.dp,
|
||||||
tonalElevation = 0.dp
|
tonalElevation = 0.dp
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(20.dp),
|
modifier = Modifier
|
||||||
|
.padding(20.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
if (isEraser) {
|
if (isEraser) {
|
||||||
|
|
@ -450,15 +465,21 @@ private fun ColorPickerDialog(
|
||||||
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.85f)
|
.fillMaxWidth(0.85f)
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
|
.heightIn(max = maxDialogHeight)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(20.dp),
|
modifier = Modifier
|
||||||
|
.padding(20.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,17 @@ class BaseTtsSynthesizer(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun stopEngineForRetryLocked() {
|
||||||
|
Timber.w("BaseTts: Stopping current TTS utterance before retry.")
|
||||||
|
try {
|
||||||
|
tts?.stop()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "BaseTts: Failed to stop TTS during retry recovery")
|
||||||
|
} finally {
|
||||||
|
delay(350)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun applyPreferredVoice() {
|
private fun applyPreferredVoice() {
|
||||||
if (tts == null) return
|
if (tts == null) return
|
||||||
|
|
||||||
|
|
@ -302,7 +313,7 @@ class BaseTtsSynthesizer(private val context: Context) {
|
||||||
requests.remove(utteranceId)
|
requests.remove(utteranceId)
|
||||||
|
|
||||||
if (attempt < MAX_RETRY_ATTEMPTS) {
|
if (attempt < MAX_RETRY_ATTEMPTS) {
|
||||||
shutdownEngineLocked()
|
stopEngineForRetryLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,15 @@ import com.aryan.reader.paginatedreader.TtsChunk
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
internal fun stableSortedIntSnapshot(values: Collection<Int>): List<Int> {
|
||||||
|
return try {
|
||||||
|
values.toTypedArray().sorted()
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).w(e, "Failed to snapshot TTS cache keys")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val START_TTS_COMMAND: SessionCommand
|
val START_TTS_COMMAND: SessionCommand
|
||||||
get() = ttsSessionCommand("com.aryan.reader.tts.START")
|
get() = ttsSessionCommand("com.aryan.reader.tts.START")
|
||||||
val STOP_TTS_COMMAND: SessionCommand
|
val STOP_TTS_COMMAND: SessionCommand
|
||||||
|
|
@ -107,6 +116,7 @@ private const val TTS_NOTIFICATION_TRAILING_BUFFER_MS = 2_000L
|
||||||
private const val TTS_NOTIFICATION_AVERAGE_WORD_MS = 550L
|
private const val TTS_NOTIFICATION_AVERAGE_WORD_MS = 550L
|
||||||
private const val TTS_NOTIFICATION_PUNCTUATION_PAUSE_MS = 120L
|
private const val TTS_NOTIFICATION_PUNCTUATION_PAUSE_MS = 120L
|
||||||
private const val NO_DEFERRED_TRANSITION_PREFETCH_GENERATION = -1
|
private const val NO_DEFERRED_TRANSITION_PREFETCH_GENERATION = -1
|
||||||
|
internal const val MAX_CHUNK_GENERATION_FAILURES = 2
|
||||||
private val TTS_NOTIFICATION_WORD_PATTERN = Regex("""\S+""")
|
private val TTS_NOTIFICATION_WORD_PATTERN = Regex("""\S+""")
|
||||||
|
|
||||||
private fun ttsSessionCommand(action: String): SessionCommand {
|
private fun ttsSessionCommand(action: String): SessionCommand {
|
||||||
|
|
@ -143,9 +153,14 @@ internal fun resolveReusableTtsPlaylistIndex(
|
||||||
|
|
||||||
internal fun shouldAdvanceToTtsPlaylistChunk(
|
internal fun shouldAdvanceToTtsPlaylistChunk(
|
||||||
currentChunkIndex: Int,
|
currentChunkIndex: Int,
|
||||||
playlistChunkIndex: Int?
|
playlistChunkIndex: Int?,
|
||||||
|
skippedChunkIndices: Set<Int> = emptySet()
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return playlistChunkIndex == currentChunkIndex + 1
|
return playlistChunkIndex == resolveNextPlayableTtsChunkIndex(
|
||||||
|
currentChunkIndex = currentChunkIndex,
|
||||||
|
totalChunks = maxOf(playlistChunkIndex?.plus(1) ?: 0, currentChunkIndex + 2),
|
||||||
|
skippedChunkIndices = skippedChunkIndices
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun shouldStartTtsTransitionPrefetch(
|
internal fun shouldStartTtsTransitionPrefetch(
|
||||||
|
|
@ -162,6 +177,22 @@ internal fun shouldStopTtsPrefetchAfterMissingChunk(
|
||||||
return !isLoaded && playlistIndex == null
|
return !isLoaded && playlistIndex == null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun resolveNextPlayableTtsChunkIndex(
|
||||||
|
currentChunkIndex: Int,
|
||||||
|
totalChunks: Int,
|
||||||
|
skippedChunkIndices: Set<Int>
|
||||||
|
): Int? {
|
||||||
|
if (totalChunks <= 0 || currentChunkIndex !in -1 until totalChunks) return null
|
||||||
|
return ((currentChunkIndex + 1) until totalChunks).firstOrNull { it !in skippedChunkIndices }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun shouldGiveUpTtsChunkGeneration(
|
||||||
|
failureCount: Int,
|
||||||
|
maxFailures: Int = MAX_CHUNK_GENERATION_FAILURES
|
||||||
|
): Boolean {
|
||||||
|
return failureCount >= maxFailures
|
||||||
|
}
|
||||||
|
|
||||||
internal fun resolveTtsStreamPcmDurationMs(totalBytes: Long): Long? {
|
internal fun resolveTtsStreamPcmDurationMs(totalBytes: Long): Long? {
|
||||||
if (totalBytes <= TTS_STREAM_WAV_HEADER_BYTES) return null
|
if (totalBytes <= TTS_STREAM_WAV_HEADER_BYTES) return null
|
||||||
return ((totalBytes - TTS_STREAM_WAV_HEADER_BYTES) / TTS_STREAM_PCM_BYTES_PER_MS)
|
return ((totalBytes - TTS_STREAM_WAV_HEADER_BYTES) / TTS_STREAM_PCM_BYTES_PER_MS)
|
||||||
|
|
@ -241,6 +272,8 @@ class TtsPlaybackManager(
|
||||||
private var currentAuthToken: String? = null
|
private var currentAuthToken: String? = null
|
||||||
private val loadedChunks: MutableSet<Int> = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
|
private val loadedChunks: MutableSet<Int> = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
|
||||||
private val chunkStreamIds = java.util.concurrent.ConcurrentHashMap<Int, String>()
|
private val chunkStreamIds = java.util.concurrent.ConcurrentHashMap<Int, String>()
|
||||||
|
private val skippedChunks: MutableSet<Int> = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
|
||||||
|
private val chunkGenerationFailures = java.util.concurrent.ConcurrentHashMap<Int, AtomicInteger>()
|
||||||
|
|
||||||
enum class TtsMode {
|
enum class TtsMode {
|
||||||
CLOUD, BASE
|
CLOUD, BASE
|
||||||
|
|
@ -346,7 +379,7 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancelPrefetchWork() {
|
private fun cancelPrefetchWork() {
|
||||||
logChunkNav("prefetch-cancel", "activePrefetching=${prefetchingJobs.keys.sorted()} lastPrefetch=$lastPrefetchIndex")
|
logChunkNav("prefetch-cancel", "activePrefetching=${stableSortedIntSnapshot(prefetchingJobs.keys)} lastPrefetch=$lastPrefetchIndex")
|
||||||
prefetchLoopJob?.cancel()
|
prefetchLoopJob?.cancel()
|
||||||
prefetchingJobs.values.forEach { it.cancel() }
|
prefetchingJobs.values.forEach { it.cancel() }
|
||||||
prefetchingJobs.clear()
|
prefetchingJobs.clear()
|
||||||
|
|
@ -386,7 +419,7 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cacheSnapshot(): String {
|
private fun cacheSnapshot(): String {
|
||||||
return "generation=${currentPlaybackGeneration()} deferredTransitionPrefetch=${deferredTransitionPrefetchGeneration.get()} lastPrefetch=$lastPrefetchIndex loaded=${loadedChunks.sorted()} audio=${audioFiles.keys.sorted()} streams=${chunkStreamIds.keys.sorted()} prefetching=${prefetchingJobs.keys.sorted()}"
|
return "generation=${currentPlaybackGeneration()} deferredTransitionPrefetch=${deferredTransitionPrefetchGeneration.get()} lastPrefetch=$lastPrefetchIndex loaded=${stableSortedIntSnapshot(loadedChunks)} skipped=${stableSortedIntSnapshot(skippedChunks)} audio=${stableSortedIntSnapshot(audioFiles.keys)} streams=${stableSortedIntSnapshot(chunkStreamIds.keys)} prefetching=${stableSortedIntSnapshot(prefetchingJobs.keys)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConnect(
|
override fun onConnect(
|
||||||
|
|
@ -826,6 +859,8 @@ class TtsPlaybackManager(
|
||||||
this.pageIndex = pageIndex
|
this.pageIndex = pageIndex
|
||||||
|
|
||||||
loadedChunks.clear()
|
loadedChunks.clear()
|
||||||
|
skippedChunks.clear()
|
||||||
|
chunkGenerationFailures.clear()
|
||||||
lastPrefetchIndex = -1
|
lastPrefetchIndex = -1
|
||||||
|
|
||||||
_ttsState.value = TtsState(
|
_ttsState.value = TtsState(
|
||||||
|
|
@ -914,7 +949,11 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun advanceToNextChunkMediaItem(currentChunkIndex: Int): Boolean {
|
private fun advanceToNextChunkMediaItem(currentChunkIndex: Int): Boolean {
|
||||||
val nextChunkIndex = resolveTtsChunkSkipTarget(currentChunkIndex, textChunks.size, direction = 1)
|
val nextPlayableChunkIndex = resolveNextPlayableTtsChunkIndex(
|
||||||
|
currentChunkIndex = currentChunkIndex,
|
||||||
|
totalChunks = textChunks.size,
|
||||||
|
skippedChunkIndices = skippedChunks
|
||||||
|
)
|
||||||
?: run {
|
?: run {
|
||||||
logChunkNavMain(
|
logChunkNavMain(
|
||||||
"advance-next-no-target",
|
"advance-next-no-target",
|
||||||
|
|
@ -922,16 +961,16 @@ class TtsPlaybackManager(
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val nextPlaylistIndex = findPlaylistIndexForChunk(nextChunkIndex)
|
val nextPlaylistIndex = findPlaylistIndexForChunk(nextPlayableChunkIndex)
|
||||||
?: run {
|
?: run {
|
||||||
logChunkNavMain(
|
logChunkNavMain(
|
||||||
"advance-next-missing-playlist-item",
|
"advance-next-missing-playlist-item",
|
||||||
"currentChunk=$currentChunkIndex expectedNextChunk=$nextChunkIndex"
|
"currentChunk=$currentChunkIndex expectedNextChunk=$nextPlayableChunkIndex skipped=${stableSortedIntSnapshot(skippedChunks)}"
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val nextPlaylistChunkIndex = player.getMediaItemAt(nextPlaylistIndex).mediaId.toIntOrNull()
|
val nextPlaylistChunkIndex = player.getMediaItemAt(nextPlaylistIndex).mediaId.toIntOrNull()
|
||||||
if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex)) {
|
if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex, skippedChunks)) {
|
||||||
logChunkNavWarnMain(
|
logChunkNavWarnMain(
|
||||||
"advance-next-refused-non-contiguous",
|
"advance-next-refused-non-contiguous",
|
||||||
"Refusing non-contiguous TTS advance. current=$currentChunkIndex, nextPlaylistChunk=$nextPlaylistChunkIndex"
|
"Refusing non-contiguous TTS advance. current=$currentChunkIndex, nextPlaylistChunk=$nextPlaylistChunkIndex"
|
||||||
|
|
@ -940,7 +979,7 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
logChunkNavMain(
|
logChunkNavMain(
|
||||||
"advance-next-seek",
|
"advance-next-seek",
|
||||||
"currentChunk=$currentChunkIndex nextChunk=$nextChunkIndex nextPlaylistIndex=$nextPlaylistIndex"
|
"currentChunk=$currentChunkIndex nextChunk=$nextPlayableChunkIndex nextPlaylistIndex=$nextPlaylistIndex"
|
||||||
)
|
)
|
||||||
player.seekTo(nextPlaylistIndex, 0L)
|
player.seekTo(nextPlaylistIndex, 0L)
|
||||||
return true
|
return true
|
||||||
|
|
@ -1028,6 +1067,8 @@ class TtsPlaybackManager(
|
||||||
audioFiles.clear()
|
audioFiles.clear()
|
||||||
chunkStreamIds.clear()
|
chunkStreamIds.clear()
|
||||||
loadedChunks.clear()
|
loadedChunks.clear()
|
||||||
|
skippedChunks.clear()
|
||||||
|
chunkGenerationFailures.clear()
|
||||||
|
|
||||||
lastPrefetchIndex = -1
|
lastPrefetchIndex = -1
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
|
@ -1097,6 +1138,8 @@ class TtsPlaybackManager(
|
||||||
val serverText = ttsAudioData.serverText
|
val serverText = ttsAudioData.serverText
|
||||||
|
|
||||||
if ((audioFile != null || streamUri != null) && serverText != null) {
|
if ((audioFile != null || streamUri != null) && serverText != null) {
|
||||||
|
chunkGenerationFailures.remove(startAtIndex)
|
||||||
|
skippedChunks.remove(startAtIndex)
|
||||||
if (audioFile != null) {
|
if (audioFile != null) {
|
||||||
audioFiles[startAtIndex] = audioFile
|
audioFiles[startAtIndex] = audioFile
|
||||||
}
|
}
|
||||||
|
|
@ -1167,10 +1210,30 @@ class TtsPlaybackManager(
|
||||||
prefetchNextChunkAudio(startAtIndex)
|
prefetchNextChunkAudio(startAtIndex)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
val failureCount = recordChunkGenerationFailure(startAtIndex)
|
||||||
logChunkNav(
|
logChunkNav(
|
||||||
"prepare-first-failed",
|
"prepare-first-failed",
|
||||||
"chunk=$startAtIndex error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
|
"chunk=$startAtIndex failureCount=$failureCount error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
|
||||||
)
|
)
|
||||||
|
val nextPlayableChunk = resolveNextPlayableTtsChunkIndex(
|
||||||
|
currentChunkIndex = startAtIndex,
|
||||||
|
totalChunks = textChunks.size,
|
||||||
|
skippedChunkIndices = skippedChunks + startAtIndex
|
||||||
|
)
|
||||||
|
if (shouldGiveUpTtsChunkGeneration(failureCount) && nextPlayableChunk != null) {
|
||||||
|
skippedChunks.add(startAtIndex)
|
||||||
|
logChunkNav(
|
||||||
|
"prepare-first-skip-failed-chunk",
|
||||||
|
"chunk=$startAtIndex nextChunk=$nextPlayableChunk failureCount=$failureCount"
|
||||||
|
)
|
||||||
|
prepareAndPlayFirstChunk(
|
||||||
|
startAtIndex = nextPlayableChunk,
|
||||||
|
playWhenReady = playWhenReady,
|
||||||
|
startAtPosition = 0L,
|
||||||
|
prefetchAfterPrepare = prefetchAfterPrepare
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
_ttsState.value = _ttsState.value.copy(
|
_ttsState.value = _ttsState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = ttsAudioData.error ?: appContext.getString(R.string.tts_error_load_audio)
|
errorMessage = ttsAudioData.error ?: appContext.getString(R.string.tts_error_load_audio)
|
||||||
|
|
@ -1243,6 +1306,8 @@ class TtsPlaybackManager(
|
||||||
pageIndex = null
|
pageIndex = null
|
||||||
cancelPrefetchWork()
|
cancelPrefetchWork()
|
||||||
loadedChunks.clear()
|
loadedChunks.clear()
|
||||||
|
skippedChunks.clear()
|
||||||
|
chunkGenerationFailures.clear()
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
clearAudioFiles()
|
clearAudioFiles()
|
||||||
|
|
@ -1434,6 +1499,10 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
val targetIndex = currentIndex + i
|
val targetIndex = currentIndex + i
|
||||||
if (targetIndex < textChunks.size) {
|
if (targetIndex < textChunks.size) {
|
||||||
|
if (skippedChunks.contains(targetIndex)) {
|
||||||
|
logChunkNav("prefetch-target-skip-marked", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation")
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (prefetchingJobs.containsKey(targetIndex)) {
|
if (prefetchingJobs.containsKey(targetIndex)) {
|
||||||
logChunkNav("prefetch-target-skip-inflight", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation")
|
logChunkNav("prefetch-target-skip-inflight", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation")
|
||||||
continue
|
continue
|
||||||
|
|
@ -1496,6 +1565,8 @@ class TtsPlaybackManager(
|
||||||
val serverText = ttsAudioData.serverText
|
val serverText = ttsAudioData.serverText
|
||||||
|
|
||||||
if ((audioFile != null || streamUri != null) && serverText != null) {
|
if ((audioFile != null || streamUri != null) && serverText != null) {
|
||||||
|
chunkGenerationFailures.remove(targetIndex)
|
||||||
|
skippedChunks.remove(targetIndex)
|
||||||
val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings)
|
val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings)
|
||||||
val pathToUse = streamUri ?: audioFile!!.absolutePath
|
val pathToUse = streamUri ?: audioFile!!.absolutePath
|
||||||
val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk)
|
val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk)
|
||||||
|
|
@ -1560,7 +1631,11 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentChunkIndex = currentChunkIndexFromPlayer()
|
val currentChunkIndex = currentChunkIndexFromPlayer()
|
||||||
val isImmediateNextChunk = targetIndex == currentChunkIndex + 1
|
val isImmediateNextChunk = targetIndex == resolveNextPlayableTtsChunkIndex(
|
||||||
|
currentChunkIndex = currentChunkIndex,
|
||||||
|
totalChunks = textChunks.size,
|
||||||
|
skippedChunkIndices = skippedChunks
|
||||||
|
)
|
||||||
|
|
||||||
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) {
|
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) {
|
||||||
logChunkNavMain(
|
logChunkNavMain(
|
||||||
|
|
@ -1579,11 +1654,19 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
val failureCount = recordChunkGenerationFailure(targetIndex)
|
||||||
Timber.e("Prefetch: Failed to download chunk $targetIndex")
|
Timber.e("Prefetch: Failed to download chunk $targetIndex")
|
||||||
logChunkNav(
|
logChunkNav(
|
||||||
"prefetch-generate-failed",
|
"prefetch-generate-failed",
|
||||||
"targetChunk=$targetIndex generation=$generation error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
|
"targetChunk=$targetIndex generation=$generation failureCount=$failureCount error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
|
||||||
)
|
)
|
||||||
|
if (shouldGiveUpTtsChunkGeneration(failureCount)) {
|
||||||
|
skippedChunks.add(targetIndex)
|
||||||
|
logChunkNav(
|
||||||
|
"prefetch-skip-failed-chunk",
|
||||||
|
"targetChunk=$targetIndex generation=$generation failureCount=$failureCount"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prefetchingJobs[targetIndex] = job
|
prefetchingJobs[targetIndex] = job
|
||||||
|
|
@ -1599,6 +1682,13 @@ class TtsPlaybackManager(
|
||||||
)
|
)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
if (skippedChunks.contains(targetIndex)) {
|
||||||
|
logChunkNav(
|
||||||
|
"prefetch-after-join-skipped",
|
||||||
|
"targetChunk=$targetIndex generation=$generation"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
val shouldStopAfterMissingChunk = withContext(Dispatchers.Main) {
|
val shouldStopAfterMissingChunk = withContext(Dispatchers.Main) {
|
||||||
val playlistIndex = findPlaylistIndexForChunk(targetIndex)
|
val playlistIndex = findPlaylistIndexForChunk(targetIndex)
|
||||||
shouldStopTtsPrefetchAfterMissingChunk(
|
shouldStopTtsPrefetchAfterMissingChunk(
|
||||||
|
|
@ -1625,6 +1715,12 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun recordChunkGenerationFailure(chunkIndex: Int): Int {
|
||||||
|
return chunkGenerationFailures
|
||||||
|
.getOrPut(chunkIndex) { AtomicInteger(0) }
|
||||||
|
.incrementAndGet()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun trackWordByWord() {
|
private suspend fun trackWordByWord() {
|
||||||
var loopCount = 0
|
var loopCount = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -1821,6 +1917,8 @@ class TtsPlaybackManager(
|
||||||
chunkStreamIds.values.forEach { StreamRegistry.remove(it) } // ADDED
|
chunkStreamIds.values.forEach { StreamRegistry.remove(it) } // ADDED
|
||||||
chunkStreamIds.clear() // ADDED
|
chunkStreamIds.clear() // ADDED
|
||||||
loadedChunks.clear()
|
loadedChunks.clear()
|
||||||
|
skippedChunks.clear()
|
||||||
|
chunkGenerationFailures.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,99 +22,99 @@
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="shelf_count">
|
<plurals name="shelf_count">
|
||||||
<item quantity="one">%1$d riiul</item>
|
<item quantity="one">%1$d riiul</item>
|
||||||
<item quantity="other">%1$d riiulid</item>
|
<item quantity="other">%1$d riiulit</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="dialog_permanently_delete_desc">
|
<plurals name="dialog_permanently_delete_desc">
|
||||||
<item quantity="one">Kas soovite %1$d jäädavalt kustutada valitud faili oma seadmest? Seda toimingut ei saa tagasi võtta.</item>
|
<item quantity="one">Kas kustutada %1$d valitud fail seadmest jäädavalt? Seda toimingut ei saa tagasi võtta.</item>
|
||||||
<item quantity="other">Kas soovite %1$d jäädavalt kustutada teie seadmest valitud failid? Seda toimingut ei saa tagasi võtta.</item>
|
<item quantity="other">Kas kustutada %1$d valitud faili seadmest jäädavalt? Seda toimingut ei saa tagasi võtta.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="dialog_remove_recents_desc">
|
<plurals name="dialog_remove_recents_desc">
|
||||||
<item quantity="one">Kas soovite eemaldada %1$d valitud faili viimaste failide loendist? See kuvatakse uuesti, kui avate selle uuesti raamatukogust.</item>
|
<item quantity="one">Kas eemaldada %1$d valitud fail viimaste failide loendist? See ilmub uuesti, kui avad selle raamatukogust.</item>
|
||||||
<item quantity="other">Kas soovite eemaldada %1$d valitud failid viimaste failide loendist? See kuvatakse uuesti, kui avate selle uuesti raamatukogust.</item>
|
<item quantity="other">Kas eemaldada %1$d valitud faili viimaste failide loendist? Need ilmuvad uuesti, kui avad need raamatukogust.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="dialog_remove_from_shelf_desc">
|
<plurals name="dialog_remove_from_shelf_desc">
|
||||||
<item quantity="one">Kas soovite kindlasti eemaldada %1$d raamat \'%2$s\' riiul? Raamat jääb teie kogusse ja kuvatakse jaotises Riiulita.</item>
|
<item quantity="one">Kas eemaldada %1$d raamat riiulilt "%2$s"? Raamat jääb raamatukokku ja kuvatakse riiulita raamatute all.</item>
|
||||||
<item quantity="other">Kas soovite kindlasti eemaldada %1$d raamatud \'%2$s\' riiul? Raamatud jäävad teie kogusse ja kuvatakse jaotises Riiulita.</item>
|
<item quantity="other">Kas eemaldada %1$d raamatut riiulilt "%2$s"? Raamatud jäävad raamatukokku ja kuvatakse riiulita raamatute all.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="banner_books_removed_library">
|
<plurals name="banner_books_removed_library">
|
||||||
<item quantity="one">%1$d raamat raamatukogust eemaldatud.</item>
|
<item quantity="one">%1$d raamat raamatukogust eemaldatud.</item>
|
||||||
<item quantity="other">%1$d raamatud raamatukogust eemaldatud.</item>
|
<item quantity="other">%1$d raamatut eemaldati raamatukogust.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="banner_importing_books_count">
|
<plurals name="banner_importing_books_count">
|
||||||
<item quantity="one">Importimine %1$d raamat… See ilmub peagi teie teegis.</item>
|
<item quantity="one">Impordin %1$d raamatut… See ilmub peagi raamatukogusse.</item>
|
||||||
<item quantity="other">Importimine %1$d raamatud… Need ilmuvad peagi teie kogusse.</item>
|
<item quantity="other">Impordin %1$d raamatut… Need ilmuvad peagi raamatukogusse.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="banner_books_imported_library_tab">
|
<plurals name="banner_books_imported_library_tab">
|
||||||
<item quantity="one">Imporditud %1$d raamat. Selle leiate vahekaardilt Raamatukogu.</item>
|
<item quantity="one">Imporditi %1$d raamat. Leiad selle vahekaardilt Raamatukogu.</item>
|
||||||
<item quantity="other">Imporditud %1$d raamatuid. Leiate need vahekaardilt Raamatukogu.</item>
|
<item quantity="other">Imporditi %1$d raamatut. Leiad need vahekaardilt Raamatukogu.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="banner_books_added_to_shelf">
|
<plurals name="banner_books_added_to_shelf">
|
||||||
<item quantity="one">%1$d raamat lisatud riiulile.</item>
|
<item quantity="one">%1$d raamat lisatud riiulile.</item>
|
||||||
<item quantity="other">%1$d raamatud lisatud riiulile.</item>
|
<item quantity="other">%1$d raamatut lisati riiulile.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="banner_books_tagged_with_tag">
|
<plurals name="banner_books_tagged_with_tag">
|
||||||
<item quantity="one">%1$d raamat sildiga "%2$s".</item>
|
<item quantity="one">%1$d raamat sildiga "%2$s".</item>
|
||||||
<item quantity="other">%1$d raamatud sildiga "%2$s".</item>
|
<item quantity="other">%1$d raamatut märgiti sildiga "%2$s".</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="banner_folder_removed_with_book_count">
|
<plurals name="banner_folder_removed_with_book_count">
|
||||||
<item quantity="one">Eemaldatud kaust "%1$s" ja %2$d raamat rakendusest.</item>
|
<item quantity="one">Eemaldati kaust "%1$s" ja %2$d raamat rakendusest.</item>
|
||||||
<item quantity="other">Eemaldatud kaust "%1$s" ja %2$d raamatud rakendusest.</item>
|
<item quantity="other">Eemaldati kaust "%1$s" ja %2$d raamatut rakendusest.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="folder_count">
|
<plurals name="folder_count">
|
||||||
<item quantity="one">%1$d kausta</item>
|
<item quantity="one">%1$d kaust</item>
|
||||||
<item quantity="other">%1$d kaustad</item>
|
<item quantity="other">%1$d kausta</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="file_count">
|
<plurals name="file_count">
|
||||||
<item quantity="one">%1$d faili</item>
|
<item quantity="one">%1$d fail</item>
|
||||||
<item quantity="other">%1$d failid</item>
|
<item quantity="other">%1$d faili</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="desktop_drop_import_file_count">
|
<plurals name="desktop_drop_import_file_count">
|
||||||
<item quantity="one">Langetage import %1$d faili</item>
|
<item quantity="one">Lohista importimiseks %1$d fail</item>
|
||||||
<item quantity="other">Langetage import %1$d failid</item>
|
<item quantity="other">Lohista importimiseks %1$d faili</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="desktop_unsupported_import_file_count">
|
<plurals name="desktop_unsupported_import_file_count">
|
||||||
<item quantity="one">%1$d toetamata fail jäetakse vahele.</item>
|
<item quantity="one">%1$d toetamata fail jäetakse vahele.</item>
|
||||||
<item quantity="other">%1$d toetamata failid jäetakse vahele.</item>
|
<item quantity="other">%1$d toetamata faili jäetakse vahele.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="desktop_importing_file_count">
|
<plurals name="desktop_importing_file_count">
|
||||||
<item quantity="one">Importimine %1$d fail…</item>
|
<item quantity="one">Impordin %1$d faili…</item>
|
||||||
<item quantity="other">Importimine %1$d failid…</item>
|
<item quantity="other">Impordin %1$d faili…</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="desktop_imported_file_count">
|
<plurals name="desktop_imported_file_count">
|
||||||
<item quantity="one">Imporditud %1$d faili.</item>
|
<item quantity="one">Imporditi %1$d fail.</item>
|
||||||
<item quantity="other">Imporditud %1$d failid.</item>
|
<item quantity="other">Imporditi %1$d faili.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="desktop_imported_file_count_reader_support_later">
|
<plurals name="desktop_imported_file_count_reader_support_later">
|
||||||
<item quantity="one">Imporditud %1$d faili. Lugeja tugi tuleb hiljem.</item>
|
<item quantity="one">Imporditi %1$d fail. Lugeja tugi tuleb hiljem.</item>
|
||||||
<item quantity="other">Imporditud %1$d failid. Lugeja tugi tuleb hiljem.</item>
|
<item quantity="other">Imporditi %1$d faili. Lugeja tugi tuleb hiljem.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="desktop_import_failed_file_count">
|
<plurals name="desktop_import_failed_file_count">
|
||||||
<item quantity="one">Ei saanud importida %1$d faili.</item>
|
<item quantity="one">%1$d faili importimine nurjus.</item>
|
||||||
<item quantity="other">Ei saanud importida %1$d failid.</item>
|
<item quantity="other">%1$d faili importimine nurjus.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="desktop_skipped_file_count">
|
<plurals name="desktop_skipped_file_count">
|
||||||
<item quantity="one">Vahele jäetud %1$d faili.</item>
|
<item quantity="one">%1$d fail jäeti vahele.</item>
|
||||||
<item quantity="other">Vahele jäetud %1$d failid.</item>
|
<item quantity="other">%1$d faili jäeti vahele.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="desktop_remove_folder_desc_with_book_count">
|
<plurals name="desktop_remove_folder_desc_with_book_count">
|
||||||
<item quantity="one">Eemalda "%1$s" ja selle %2$d raamatut rakendusest? Ketta faile ei kustutata.</item>
|
<item quantity="one">Kas eemaldada "%1$s" ja selle %2$d raamat rakendusest? Kettal olevaid faile ei kustutata.</item>
|
||||||
<item quantity="other">Eemalda "%1$s" ja selle %2$d raamatud rakendusest? Ketta faile ei kustutata.</item>
|
<item quantity="other">Kas eemaldada "%1$s" ja selle %2$d raamatut rakendusest? Kettal olevaid faile ei kustutata.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="desktop_folder_sync_failed_folder_count">
|
<plurals name="desktop_folder_sync_failed_folder_count">
|
||||||
<item quantity="one">Kausta sünkroonimine ebaõnnestus %1$d kausta.</item>
|
<item quantity="one">%1$d kausta sünkroonimine nurjus.</item>
|
||||||
<item quantity="other">Kausta sünkroonimine ebaõnnestus %1$d kaustad.</item>
|
<item quantity="other">%1$d kausta sünkroonimine nurjus.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="desktop_folder_sync_skipped_folder_count">
|
<plurals name="desktop_folder_sync_skipped_folder_count">
|
||||||
<item quantity="one">Kausta sünkroonimine on lõpetatud %1$d kaust jäi vahele.</item>
|
<item quantity="one">Kausta sünkroonimine lõppes, %1$d kaust jäeti vahele.</item>
|
||||||
<item quantity="other">Kausta sünkroonimine on lõpetatud %1$d kaustad vahele jäetud.</item>
|
<item quantity="other">Kausta sünkroonimine lõppes, %1$d kausta jäeti vahele.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="desktop_opds_removed_stream_book_count">
|
<plurals name="desktop_opds_removed_stream_book_count">
|
||||||
<item quantity="one">Eemaldatud %1$d voogesitatud OPDS raamat sellest kataloogist.</item>
|
<item quantity="one">Sellest kataloogist eemaldati %1$d voogedastatud OPDS-raamat.</item>
|
||||||
<item quantity="other">Eemaldatud %1$d voogesitatud OPDS raamatud sellest kataloogist.</item>
|
<item quantity="other">Sellest kataloogist eemaldati %1$d voogedastatud OPDS-raamatut.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="tag_count">
|
<plurals name="tag_count">
|
||||||
<item quantity="one">%1$d tag</item>
|
<item quantity="one">%1$d silt</item>
|
||||||
<item quantity="other">%1$d sildid</item>
|
<item quantity="other">%1$d silti</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="desktop_library_tab_books_count">
|
<plurals name="desktop_library_tab_books_count">
|
||||||
<item quantity="one">Kõik raamatud %1$d</item>
|
<item quantity="one">Kõik raamatud %1$d</item>
|
||||||
|
|
@ -133,7 +133,7 @@
|
||||||
<item quantity="other">Kaustad %1$d</item>
|
<item quantity="other">Kaustad %1$d</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="tts_cache_chunk_count_parenthetical">
|
<plurals name="tts_cache_chunk_count_parenthetical">
|
||||||
<item quantity="one">(%1$d tükk)</item>
|
<item quantity="one">(%1$d osa)</item>
|
||||||
<item quantity="other">(%1$d tükid)</item>
|
<item quantity="other">(%1$d osa)</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -4,25 +4,25 @@
|
||||||
<string name="action_save">Salvesta</string>
|
<string name="action_save">Salvesta</string>
|
||||||
<string name="action_delete">Kustuta</string>
|
<string name="action_delete">Kustuta</string>
|
||||||
<string name="action_remove">Eemalda</string>
|
<string name="action_remove">Eemalda</string>
|
||||||
<string name="action_ok">Sobib</string>
|
<string name="action_ok">OK</string>
|
||||||
<string name="action_close">Sulge</string>
|
<string name="action_close">Sulge</string>
|
||||||
<string name="action_add">Lisa</string>
|
<string name="action_add">Lisa</string>
|
||||||
<string name="action_rename">Muuda nime</string>
|
<string name="action_rename">Muuda nime</string>
|
||||||
<string name="action_back">Tagasi</string>
|
<string name="action_back">Tagasi</string>
|
||||||
<string name="action_search">Otsi</string>
|
<string name="action_search">Otsi</string>
|
||||||
<string name="action_clear">Selge</string>
|
<string name="action_clear">Tühjenda</string>
|
||||||
<string name="action_apply">Rakenda</string>
|
<string name="action_apply">Rakenda</string>
|
||||||
<string name="action_enable">Luba</string>
|
<string name="action_enable">Luba</string>
|
||||||
<string name="error_message_format">Viga: %1$s</string>
|
<string name="error_message_format">Viga: %1$s</string>
|
||||||
<string name="action_go_back">Mine tagasi</string>
|
<string name="action_go_back">Mine tagasi</string>
|
||||||
<string name="tab_free">Tasuta</string>
|
<string name="tab_free">Vaba</string>
|
||||||
<string name="active_tabs">Aktiivsed vahelehed</string>
|
<string name="active_tabs">Aktiivsed vahelehed</string>
|
||||||
<string name="pdf_tabs_show_top_app_bar_tabs">Kuva vahekaardid ülemisel rakenduseribal</string>
|
<string name="pdf_tabs_show_top_app_bar_tabs">Kuva vahekaardid ülemisel rakenduseribal</string>
|
||||||
<string name="close_tab">Sule vahekaart</string>
|
<string name="close_tab">Sule vahekaart</string>
|
||||||
<string name="close_all_tabs">Sulgege kõik vahelehed</string>
|
<string name="close_all_tabs">Sulge kõik vahelehed</string>
|
||||||
<string name="dialog_close_all_tabs">Kas sulgeda kõik vahelehed?</string>
|
<string name="dialog_close_all_tabs">Kas sulgeda kõik vahelehed?</string>
|
||||||
<string name="dialog_close_all_tabs_desc">Kas olete kindel, et soovite sulgeda kõik aktiivsed vahelehed?</string>
|
<string name="dialog_close_all_tabs_desc">Kas sulgeda kõik aktiivsed vahelehed?</string>
|
||||||
<string name="legal_agreement_full">%1$s nõustute meie %2$s ja kinnitage, et olete lugenud meie %3$s.</string>
|
<string name="legal_agreement_full">%1$s nõustud meie %2$s ja kinnitad, et oled lugenud meie %3$s.</string>
|
||||||
<string name="legal_terms_of_service">Kasutustingimused</string>
|
<string name="legal_terms_of_service">Kasutustingimused</string>
|
||||||
<string name="legal_privacy_policy">Privaatsuspoliitika</string>
|
<string name="legal_privacy_policy">Privaatsuspoliitika</string>
|
||||||
<string name="legal_licenses">Litsentsid</string>
|
<string name="legal_licenses">Litsentsid</string>
|
||||||
|
|
@ -30,58 +30,58 @@
|
||||||
<string name="clear_selection">Tühjenda valik</string>
|
<string name="clear_selection">Tühjenda valik</string>
|
||||||
<string name="pin_unpin">Kinnita/vabasta</string>
|
<string name="pin_unpin">Kinnita/vabasta</string>
|
||||||
<string name="info">Info</string>
|
<string name="info">Info</string>
|
||||||
<string name="select_all">Valige Kõik</string>
|
<string name="select_all">Vali kõik</string>
|
||||||
<string name="dialog_remove_from_recents">Eemalda hiljutiste hulgast</string>
|
<string name="dialog_remove_from_recents">Eemalda hiljutiste hulgast</string>
|
||||||
<string name="dialog_warning_folder_sync_delete">Warning: Some selected items are synced from a local folder. Proceeding will delete the actual files from your device storage.\n\nThis action cannot be undone.</string>
|
<string name="dialog_warning_folder_sync_delete">Hoiatus: osa valitud üksusi on sünkroonitud kohalikust kaustast. Jätkamisel kustutatakse tegelikud failid seadme mälust.\n\nSeda toimingut ei saa tagasi võtta.</string>
|
||||||
<string name="file_information">Faili teave</string>
|
<string name="file_information">Faili teave</string>
|
||||||
<string name="book_name">Raamatu nimi</string>
|
<string name="book_name">Raamatu nimi</string>
|
||||||
<string name="copy_name">Kopeeri nimi</string>
|
<string name="copy_name">Kopeeri nimi</string>
|
||||||
<string name="original_name">Algne nimi: %1$s</string>
|
<string name="original_name">Algne nimi: %1$s</string>
|
||||||
<string name="revert_to_original">Taastage originaal</string>
|
<string name="revert_to_original">Taasta algne nimi</string>
|
||||||
<string name="file_name">Faili nimi: %1$s</string>
|
<string name="file_name">Faili nimi: %1$s</string>
|
||||||
<string name="author">Autor</string>
|
<string name="author">Autor</string>
|
||||||
<string name="format">Vorming</string>
|
<string name="format">Vorming</string>
|
||||||
<string name="size">Suurus</string>
|
<string name="size">Suurus</string>
|
||||||
<string name="added">Lisatud</string>
|
<string name="added">Lisatud</string>
|
||||||
<string name="location">Asukoht</string>
|
<string name="location">Asukoht</string>
|
||||||
<string name="source_opds">Allikas: OPDS Voog</string>
|
<string name="source_opds">Allikas: OPDS-voog</string>
|
||||||
<string name="source_in_app">Rakendusesisene salvestusruum</string>
|
<string name="source_in_app">Rakendusesisene salvestusruum</string>
|
||||||
<string name="internal_storage">Sisemälu</string>
|
<string name="internal_storage">Sisemälu</string>
|
||||||
<string name="about_title">Umbes Episteme</string>
|
<string name="about_title">Teave Episteme kohta</string>
|
||||||
<string name="about_version">Versioon: %1$s (Järgmine: %2$d)</string>
|
<string name="about_version">Versioon: %1$s (järk: %2$d)</string>
|
||||||
<string name="empty_select_file">Valige fail</string>
|
<string name="empty_select_file">Vali fail</string>
|
||||||
<string name="clear_cloud_data_title">Kas kustutada kõik sünkroonitud andmed?</string>
|
<string name="clear_cloud_data_title">Kas kustutada kõik sünkroonitud andmed?</string>
|
||||||
<string name="clear_cloud_data_desc">Kas olete kindel, et soovite kõik oma raamatuandmed pilvest jäädavalt kustutada? See kustutab uuesti sünkroonimise vältimiseks ka teie kohaliku raamatukogu. Seda toimingut ei saa tagasi võtta.</string>
|
<string name="clear_cloud_data_desc">Kas kustutada kõik raamatuandmed pilvest jäädavalt? See tühjendab uuesti sünkroonimise vältimiseks ka kohaliku raamatukogu. Seda toimingut ei saa tagasi võtta.</string>
|
||||||
<string name="delete_all_data">KUSTUTA KÕIK ANDMED</string>
|
<string name="delete_all_data">KUSTUTA KÕIK ANDMED</string>
|
||||||
<string name="nav_home">Kodu</string>
|
<string name="nav_home">Kodu</string>
|
||||||
<string name="nav_library">Raamatukogu</string>
|
<string name="nav_library">Raamatukogu</string>
|
||||||
<string name="recent_files">Viimased failid</string>
|
<string name="recent_files">Viimased failid</string>
|
||||||
<string name="your_library_empty">Teie raamatukogu on tühi</string>
|
<string name="your_library_empty">Raamatukogu on tühi</string>
|
||||||
<string name="your_library_empty_desc">Valige lugemiseks fail või sünkroonige kohalik kaust, et raamatuid automaatselt importida.</string>
|
<string name="your_library_empty_desc">Vali lugemiseks fail või sünkrooni kohalik kaust, et raamatud automaatselt importida.</string>
|
||||||
<string name="no_recent_files">Viimaseid faile pole</string>
|
<string name="no_recent_files">Viimaseid faile pole</string>
|
||||||
<string name="no_recent_files_desc">Avage fail oma teegist, et seda siin näha.</string>
|
<string name="no_recent_files_desc">Ava fail raamatukogust, et seda siin näha.</string>
|
||||||
<string name="setup_folder_sync">Kausta sünkroonimise seadistamine</string>
|
<string name="setup_folder_sync">Kausta sünkroonimise seadistamine</string>
|
||||||
<string name="sync_folder">Sünkrooni kaust</string>
|
<string name="sync_folder">Sünkrooni kaust</string>
|
||||||
<string name="local_folder">Kohalik kaust</string>
|
<string name="local_folder">Kohalik kaust</string>
|
||||||
<string name="pinned">Kinnitatud</string>
|
<string name="pinned">Kinnitatud</string>
|
||||||
<string name="progress_complete">%1$d%% täielik</string>
|
<string name="progress_complete">%1$d%% täielik</string>
|
||||||
<string name="not_available_locally">Pole kohapeal saadaval</string>
|
<string name="not_available_locally">Pole kohapeal saadaval</string>
|
||||||
<string name="drawer_sign_in">Logige sisse rakendusega Google</string>
|
<string name="drawer_sign_in">Logi Google’iga sisse</string>
|
||||||
<string name="drawer_by_signing_in">Sisse logides</string>
|
<string name="drawer_by_signing_in">Sisse logides</string>
|
||||||
<string name="drawer_pro_unlocked">Episteme Pro</string>
|
<string name="drawer_pro_unlocked">Episteme Pro</string>
|
||||||
<string name="drawer_upgrade_pro">Uuenda versioonile Episteme Pro</string>
|
<string name="drawer_upgrade_pro">Uuenda versioonile Episteme Pro</string>
|
||||||
<string name="drawer_sync_library">Sünkrooni raamatukogu</string>
|
<string name="drawer_sync_library">Sünkrooni raamatukogu</string>
|
||||||
<string name="drawer_backup_local_folders">Pilvesünkroonimine kohalike kaustade jaoks</string>
|
<string name="drawer_backup_local_folders">Pilvesünkroonimine kohalike kaustade jaoks</string>
|
||||||
<string name="drawer_backup_desc">Laadige raamatud oma sünkroonitud kaustadest üles kausta Google Drive.</string>
|
<string name="drawer_backup_desc">Laadi sünkroonitud kaustade raamatud Google Drive’i üles.</string>
|
||||||
<string name="drawer_custom_fonts">Kohandatud fondid</string>
|
<string name="drawer_custom_fonts">Kohandatud fondid</string>
|
||||||
<string name="drawer_support_project">Toetage projekti</string>
|
<string name="drawer_support_project">Toeta projekti</string>
|
||||||
<string name="drawer_help_feedback">Abi ja tagasiside</string>
|
<string name="drawer_help_feedback">Abi ja tagasiside</string>
|
||||||
<string name="drawer_sign_out">Logi välja</string>
|
<string name="drawer_sign_out">Logi välja</string>
|
||||||
<string name="options_recent_limit">Viimaste failide limiit</string>
|
<string name="options_recent_limit">Viimaste failide limiit</string>
|
||||||
<string name="options_no_limit">Piiramata</string>
|
<string name="options_no_limit">Piiramata</string>
|
||||||
<string name="options_files_limit">%1$d failid</string>
|
<string name="options_files_limit">%1$d failid</string>
|
||||||
<string name="options_clear_book_cache">Tühjenda raamatu vahemälu</string>
|
<string name="options_clear_book_cache">Tühjenda raamatu vahemälu</string>
|
||||||
<string name="options_clear_reflow_cache">Tühjendage reflow vahemälu</string>
|
<string name="options_clear_reflow_cache">Tühjenda reflow-vahemälu</string>
|
||||||
<string name="library_title">Raamatukogu</string>
|
<string name="library_title">Raamatukogu</string>
|
||||||
<string name="search_placeholder">Otsi pealkirja või autorit…</string>
|
<string name="search_placeholder">Otsi pealkirja või autorit…</string>
|
||||||
<string name="filter_types">Tüübid: %1$s</string>
|
<string name="filter_types">Tüübid: %1$s</string>
|
||||||
|
|
@ -92,28 +92,28 @@
|
||||||
<string name="tab_folders">Kaustad</string>
|
<string name="tab_folders">Kaustad</string>
|
||||||
<string name="tab_catalogs">Kataloogid</string>
|
<string name="tab_catalogs">Kataloogid</string>
|
||||||
<string name="no_results_found">Päringule \"%1$s\" ei leitud tulemusi</string>
|
<string name="no_results_found">Päringule \"%1$s\" ei leitud tulemusi</string>
|
||||||
<string name="library_empty_desc">Valige PDF, EPUB, MOBI või AZW3 alustamiseks oma seadmest faili.</string>
|
<string name="library_empty_desc">Alustamiseks vali seadmest PDF-, EPUB-, MOBI- või AZW3-fail.</string>
|
||||||
<string name="fab_add_file">Lisa fail</string>
|
<string name="fab_add_file">Lisa fail</string>
|
||||||
<string name="fab_new_shelf">Uus riiul</string>
|
<string name="fab_new_shelf">Uus riiul</string>
|
||||||
<string name="create_new_shelf">Looge uus riiul</string>
|
<string name="create_new_shelf">Loo uus riiul</string>
|
||||||
<string name="shelf_name_hint">Riiuli nimi</string>
|
<string name="shelf_name_hint">Riiuli nimi</string>
|
||||||
<string name="action_create">Loo</string>
|
<string name="action_create">Loo</string>
|
||||||
<string name="menu_rename_shelf">Nimetage riiul ümber</string>
|
<string name="menu_rename_shelf">Nimeta riiul ümber</string>
|
||||||
<string name="menu_delete_shelf">Kustuta riiul</string>
|
<string name="menu_delete_shelf">Kustuta riiul</string>
|
||||||
<string name="fab_add_books">Lisage raamatuid</string>
|
<string name="fab_add_books">Lisa raamatuid</string>
|
||||||
<string name="shelf_empty">See riiul on tühi</string>
|
<string name="shelf_empty">See riiul on tühi</string>
|
||||||
<string name="add_to_shelf">Lisa %1$s</string>
|
<string name="add_to_shelf">Lisa %1$s</string>
|
||||||
<string name="fab_add_count">LISA (%1$d)</string>
|
<string name="fab_add_count">LISA (%1$d)</string>
|
||||||
<string name="no_unshelved_books">Pole ühtegi riiulita raamatut, mida lisada</string>
|
<string name="no_unshelved_books">Pole ühtegi riiulita raamatut, mida lisada</string>
|
||||||
<string name="all_books_in_shelf">Kõik raamatud on juba sellel riiulil</string>
|
<string name="all_books_in_shelf">Kõik raamatud on juba sellel riiulil</string>
|
||||||
<string name="dialog_rename_shelf">Nimetage riiul ümber</string>
|
<string name="dialog_rename_shelf">Nimeta riiul ümber</string>
|
||||||
<string name="dialog_delete_shelf">Kas kustutada riiul?</string>
|
<string name="dialog_delete_shelf">Kas kustutada riiul?</string>
|
||||||
<string name="dialog_delete_shelf_desc">Kas soovite kindlasti kustutada faili \'%1$s\' riiul? Kõik raamatud teisaldatakse riiulitele.</string>
|
<string name="dialog_delete_shelf_desc">Kas kustutada riiul "%1$s"? Kõik raamatud teisaldatakse riiulita raamatute alla.</string>
|
||||||
<string name="dialog_remove_from_shelf">Kas eemaldada riiulist?</string>
|
<string name="dialog_remove_from_shelf">Kas eemaldada riiulist?</string>
|
||||||
<string name="dialog_delete_shelves">Kustuta %1$s?</string>
|
<string name="dialog_delete_shelves">Kustuta %1$s?</string>
|
||||||
<string name="dialog_delete_shelves_desc">Kas soovite kindlasti kustutada %1$d valitud %2$s? Kõik sees olevad raamatud teisaldatakse jaotisesse Riiulita.</string>
|
<string name="dialog_delete_shelves_desc">Kas kustutada %1$d valitud %2$s? Kõik sees olevad raamatud teisaldatakse riiulita raamatute alla.</string>
|
||||||
<string name="sync_local_folders">Sünkroonige kohalikud kaustad</string>
|
<string name="sync_local_folders">Sünkrooni kohalikud kaustad</string>
|
||||||
<string name="sync_folders_desc">Reaalajas teegi loomiseks ühendage kohalikud kaustad. Episteme jälgib faile ja sünkroonimise edenemist.</string>
|
<string name="sync_folders_desc">Ühenda kohalikud kaustad, et luua reaalajas raamatukogu. Episteme jälgib faile ja sünkroonib lugemisjärge.</string>
|
||||||
<string name="fab_add_folder">Lisa kaust</string>
|
<string name="fab_add_folder">Lisa kaust</string>
|
||||||
<string name="scan_all">Skanni kõik</string>
|
<string name="scan_all">Skanni kõik</string>
|
||||||
<string name="scanning">Skannimine…</string>
|
<string name="scanning">Skannimine…</string>
|
||||||
|
|
@ -126,24 +126,24 @@
|
||||||
<string name="menu_enable_folder_local_sync">Luba kohalik sünkroonimine</string>
|
<string name="menu_enable_folder_local_sync">Luba kohalik sünkroonimine</string>
|
||||||
<string name="folder_local_sync_disabled">Kohalik sünkroonimine on keelatud</string>
|
<string name="folder_local_sync_disabled">Kohalik sünkroonimine on keelatud</string>
|
||||||
<string name="dialog_disable_folder_local_sync_title">Kas keelata kohaliku kausta sünkroonimine?</string>
|
<string name="dialog_disable_folder_local_sync_title">Kas keelata kohaliku kausta sünkroonimine?</string>
|
||||||
<string name="dialog_disable_folder_local_sync_desc">Episteme lõpetab selle kausta skannimise ja kirjutamise JSON failide sünkroonimine. Eemaldage %1$s kaust ka sellest kaustast?</string>
|
<string name="dialog_disable_folder_local_sync_desc">Episteme lõpetab selle kausta skannimise ja sünkroonimise JSON-failidesse kirjutamise. Kas eemaldada kaustast ka %1$s kaust?</string>
|
||||||
<string name="action_disable_keep_sync_data">Sünkrooni andmed</string>
|
<string name="action_disable_keep_sync_data">Hoia sünkroonimisandmed</string>
|
||||||
<string name="action_disable_remove_sync_data">Eemaldage sünkroonimisandmed</string>
|
<string name="action_disable_remove_sync_data">Eemalda sünkroonimisandmed</string>
|
||||||
<string name="filter_file_types">Filtreeri failitüübid</string>
|
<string name="filter_file_types">Filtreeri failitüübid</string>
|
||||||
<string name="filter_file_types_desc">Valige failitüübid, mida soovite sellest kaustast sünkroonida:</string>
|
<string name="filter_file_types_desc">Vali failitüübid, mida sellest kaustast sünkroonida:</string>
|
||||||
<string name="filter_library">Filtri raamatukogu</string>
|
<string name="filter_library">Filtreeri raamatukogu</string>
|
||||||
<string name="filter_file_type">Faili tüüp</string>
|
<string name="filter_file_type">Faili tüüp</string>
|
||||||
<string name="filter_source_folder">Allikakaust</string>
|
<string name="filter_source_folder">Allikakaust</string>
|
||||||
<string name="filter_read_status">Loe olekut</string>
|
<string name="filter_read_status">Lugemise olek</string>
|
||||||
<string name="clear_all">Kustuta kõik</string>
|
<string name="clear_all">Tühjenda kõik</string>
|
||||||
<string name="filter_in_app_storage">Rakendusesisene salvestusruum</string>
|
<string name="filter_in_app_storage">Rakendusesisene salvestusruum</string>
|
||||||
<string name="external_file_prompt_title">Kas salvestada fail?</string>
|
<string name="external_file_prompt_title">Kas salvestada fail?</string>
|
||||||
<string name="external_file_prompt_desc">Do you want to save this external file in the app\'s library? If not, it will be removed.\n\n(You can change this default behavior anytime from the Home Screen > More Options > External File Behavior).</string>
|
<string name="external_file_prompt_desc">Kas salvestada see väline fail rakenduse raamatukokku? Kui mitte, eemaldatakse see.\n\n(Seda vaikekäitumist saad igal ajal muuta: avakuva > Rohkem valikuid > Välise faili käitumine.)</string>
|
||||||
<string name="external_file_dont_ask">Ära\'ära küsi uuesti</string>
|
<string name="external_file_dont_ask">Ära küsi uuesti</string>
|
||||||
<string name="external_file_keep">Hoidke raamatukogus</string>
|
<string name="external_file_keep">Hoia raamatukogus</string>
|
||||||
<string name="external_file_delete">Eemalda</string>
|
<string name="external_file_delete">Eemalda</string>
|
||||||
<string name="external_file_behavior_ask">Küsi iga kord</string>
|
<string name="external_file_behavior_ask">Küsi iga kord</string>
|
||||||
<string name="external_file_behavior_keep">Hoidke alati</string>
|
<string name="external_file_behavior_keep">Hoia alati</string>
|
||||||
<string name="external_file_behavior_delete">Eemalda alati</string>
|
<string name="external_file_behavior_delete">Eemalda alati</string>
|
||||||
<string name="options_external_file_behavior">Välise faili käitumine</string>
|
<string name="options_external_file_behavior">Välise faili käitumine</string>
|
||||||
<string name="fab_add_catalog">Lisa kataloog</string>
|
<string name="fab_add_catalog">Lisa kataloog</string>
|
||||||
|
|
@ -155,29 +155,29 @@
|
||||||
<string name="action_unavailable">Pole saadaval</string>
|
<string name="action_unavailable">Pole saadaval</string>
|
||||||
<string name="action_download">Laadi alla</string>
|
<string name="action_download">Laadi alla</string>
|
||||||
<string name="download_format">Laadi alla vorming</string>
|
<string name="download_format">Laadi alla vorming</string>
|
||||||
<string name="action_stream_now">Voogesitage kohe</string>
|
<string name="action_stream_now">Voogedasta kohe</string>
|
||||||
<string name="action_read">Lugege</string>
|
<string name="action_read">Loe</string>
|
||||||
<string name="no_supported_formats">Toetatud vorminguid pole saadaval.</string>
|
<string name="no_supported_formats">Toetatud vorminguid pole saadaval.</string>
|
||||||
<string name="publisher">VÄLJAANDJA</string>
|
<string name="publisher">VÄLJAANDJA</string>
|
||||||
<string name="published">AVALDATUD</string>
|
<string name="published">AVALDATUD</string>
|
||||||
<string name="language">KEEL</string>
|
<string name="language">KEEL</string>
|
||||||
<string name="synopsis">Sisukokkuvõte</string>
|
<string name="synopsis">Sisukokkuvõte</string>
|
||||||
<string name="edit_catalog">Redigeeri kataloogi</string>
|
<string name="edit_catalog">Redigeeri kataloogi</string>
|
||||||
<string name="add_opds_catalog">Lisa OPDS Kataloog</string>
|
<string name="add_opds_catalog">Lisa OPDS-kataloog</string>
|
||||||
<string name="catalog_name">Kataloogi nimi</string>
|
<string name="catalog_name">Kataloogi nimi</string>
|
||||||
<string name="url">URL</string>
|
<string name="url">URL</string>
|
||||||
<string name="auth_optional">Autentimine (valikuline)</string>
|
<string name="auth_optional">Autentimine (valikuline)</string>
|
||||||
<string name="username">Kasutajanimi</string>
|
<string name="username">Kasutajanimi</string>
|
||||||
<string name="password">Parool</string>
|
<string name="password">Parool</string>
|
||||||
<string name="delete_catalog">Kustuta kataloog</string>
|
<string name="delete_catalog">Kustuta kataloog</string>
|
||||||
<string name="delete_catalog_desc">Kas soovite kindlasti kustutada \'%1$s\'?</string>
|
<string name="delete_catalog_desc">Kas oled kindel, et soovid kustutada \'%1$s\'?</string>
|
||||||
<string name="delete_catalog_warning">Selle kataloogi kustutamisel eemaldatakse jäädavalt ka %1$d sellega seotud raamatute voogesitamine teie kogust.</string>
|
<string name="delete_catalog_warning">Selle kataloogi kustutamisel eemaldatakse raamatukogust jäädavalt ka %1$d sellega seotud voogedastatud raamatut.</string>
|
||||||
<string name="preset_label">Eelseadistatud</string>
|
<string name="preset_label">Eelseadistatud</string>
|
||||||
<string name="free_plan">Tasuta plaan</string>
|
<string name="free_plan">Tasuta plaan</string>
|
||||||
<string name="forever_free">Igavesti tasuta</string>
|
<string name="forever_free">Igavesti tasuta</string>
|
||||||
<string name="feature_multiple_formats">Mitu vormingut</string>
|
<string name="feature_multiple_formats">Mitu vormingut</string>
|
||||||
<string name="feature_multiple_formats_desc">Toed PDF, EPUB, MOBI, AZW3</string>
|
<string name="feature_multiple_formats_desc">Toetab vorminguid PDF, EPUB, MOBI ja AZW3</string>
|
||||||
<string name="feature_tts">Android Tekst kõneks</string>
|
<string name="feature_tts">Androidi tekst kõneks</string>
|
||||||
<string name="feature_tts_desc">Kuulake oma raamatuid sisseehitatud TTS</string>
|
<string name="feature_tts_desc">Kuulake oma raamatuid sisseehitatud TTS</string>
|
||||||
<string name="feature_dict">Põhisõnastik</string>
|
<string name="feature_dict">Põhisõnastik</string>
|
||||||
<string name="feature_dict_desc">Otsige kiiresti üles üksikud sõnad</string>
|
<string name="feature_dict_desc">Otsige kiiresti üles üksikud sõnad</string>
|
||||||
|
|
@ -188,25 +188,25 @@
|
||||||
<string name="early_access_sale">Varajase juurdepääsu müük</string>
|
<string name="early_access_sale">Varajase juurdepääsu müük</string>
|
||||||
<string name="pro_includes">Omadused:</string>
|
<string name="pro_includes">Omadused:</string>
|
||||||
<string name="feature_cloud_sync">Pilvesünkroonimine seadmete vahel</string>
|
<string name="feature_cloud_sync">Pilvesünkroonimine seadmete vahel</string>
|
||||||
<string name="feature_cloud_sync_desc">Hoidke kogu oma kogu, sealhulgas raamatufailid ja lugemised, sünkroonituna kuni neljas seadmes.</string>
|
<string name="feature_cloud_sync_desc">Hoia kogu oma kogu, sealhulgas raamatufailid ja lugemised, sünkroonituna kuni neljas seadmes.</string>
|
||||||
<string name="feature_summarize">Kokkuvõte</string>
|
<string name="feature_summarize">Kokkuvõte</string>
|
||||||
<string name="feature_summarize_desc">Saate päevas 10 tasuta kokkuvõtet peatükkide või lehtede kohta</string>
|
<string name="feature_summarize_desc">Saate päevas 10 tasuta kokkuvõtet peatükkide või lehtede kohta</string>
|
||||||
<string name="feature_smart_dict">Nutikas sõnastik</string>
|
<string name="feature_smart_dict">Nutikas sõnastik</string>
|
||||||
<string name="feature_smart_dict_desc">Otsige fraase ja isegi lõike, mitte ainult üksikuid sõnu</string>
|
<string name="feature_smart_dict_desc">Otsige fraase ja isegi lõike, mitte ainult üksikuid sõnu</string>
|
||||||
<string name="feature_priority">Prioriteetsete funktsioonide taotlused</string>
|
<string name="feature_priority">Prioriteetsete funktsioonide taotlused</string>
|
||||||
<string name="feature_priority_desc">Teie ettepanekud seatakse prioriteediks</string>
|
<string name="feature_priority_desc">Sinu ettepanekud seatakse prioriteediks</string>
|
||||||
<string name="pro_unlocked">Pro funktsioonid on lukustamata!</string>
|
<string name="pro_unlocked">Pro funktsioonid on lukustamata!</string>
|
||||||
<string name="sign_in_required">Sisselogimine Nõutav</string>
|
<string name="sign_in_required">Sisselogimine Nõutav</string>
|
||||||
<string name="verifying_purchase">Ostu kinnitamine…</string>
|
<string name="verifying_purchase">Ostu kinnitamine…</string>
|
||||||
<string name="existing_purchase_found">Olemasolev ost leitud</string>
|
<string name="existing_purchase_found">Olemasolev ost leitud</string>
|
||||||
<string name="get_lifetime_access">Hankige eluaegne juurdepääs</string>
|
<string name="get_lifetime_access">Hangi eluaegne juurdepääs</string>
|
||||||
<string name="upgrade_unavailable">Uuendamine pole praegu saadaval. Kontrollige oma Internetti ja proovige uuesti.</string>
|
<string name="upgrade_unavailable">Uuendamine pole praegu saadaval. Kontrollige oma Internetti ja proovige uuesti.</string>
|
||||||
<string name="sign_in_to_purchase">Logige sisse oma Google konto ostmiseks Episteme Pro.</string>
|
<string name="sign_in_to_purchase">Logi sisse oma Google konto ostmiseks Episteme Pro.</string>
|
||||||
<string name="sign_in_to_purchase_credits">Logige sisse oma Google konto krediidi ostmiseks.</string>
|
<string name="sign_in_to_purchase_credits">Logi sisse oma Google konto krediidi ostmiseks.</string>
|
||||||
<string name="verifying_purchase_desc">See võib võtta mõne hetke. Teie Pro staatust värskendatakse automaatselt.</string>
|
<string name="verifying_purchase_desc">See võib võtta mõne hetke. Pro-olekut värskendatakse automaatselt.</string>
|
||||||
<string name="dialog_existing_purchase_desc">Sellel seadmel on juba Pro-ost, kuid see\' on lingitud teise kontoga. Pro funktsioonide taastamiseks logige sisse kontole, mida kasutati algsel ostul.</string>
|
<string name="dialog_existing_purchase_desc">Selles seadmes on juba Pro-ost, kuid see on seotud teise kontoga. Pro-funktsioonide taastamiseks logi sisse kontoga, millega algne ost tehti.</string>
|
||||||
<string name="dialog_early_access_desc">Te\'toodate Episteme Pro meie varase juurdepääsu perioodil erisoodushinnaga! See on piiratud aja pakkumine.</string>
|
<string name="dialog_early_access_desc">Te\'toodate Episteme Pro meie varase juurdepääsu perioodil erisoodushinnaga! See on piiratud aja pakkumine.</string>
|
||||||
<string name="dialog_sign_in_required_desc">Logige sisse oma Google konto ostmiseks Episteme Pro ja avage kõik esmaklassilised funktsioonid.</string>
|
<string name="dialog_sign_in_required_desc">Logi Google’i kontoga sisse, et osta Episteme Pro ja avada kõik premium-funktsioonid.</string>
|
||||||
<string name="action_not_now">Mitte praegu</string>
|
<string name="action_not_now">Mitte praegu</string>
|
||||||
<string name="action_got_it">Selge!</string>
|
<string name="action_got_it">Selge!</string>
|
||||||
<string name="custom_fonts">Kohandatud fondid</string>
|
<string name="custom_fonts">Kohandatud fondid</string>
|
||||||
|
|
@ -218,40 +218,40 @@
|
||||||
<string name="google_fonts_no_matches">No fonts found matching \'%1$s\'</string>
|
<string name="google_fonts_no_matches">No fonts found matching \'%1$s\'</string>
|
||||||
<string name="content_desc_already_downloaded">Juba alla laaditud</string>
|
<string name="content_desc_already_downloaded">Juba alla laaditud</string>
|
||||||
<string name="no_custom_fonts">Kohandatud fonte pole</string>
|
<string name="no_custom_fonts">Kohandatud fonte pole</string>
|
||||||
<string name="import_fonts_desc">Importige TTF- või OTF-faile, et neid oma raamatutes kasutada.</string>
|
<string name="import_fonts_desc">Impordi TTF- või OTF-faile, et neid oma raamatutes kasutada.</string>
|
||||||
<string name="font_preview_error">Eelvaade pole saadaval (kehtetu fondifail)</string>
|
<string name="font_preview_error">Eelvaade pole saadaval (kehtetu fondifail)</string>
|
||||||
<string name="dialog_delete_font">Kas kustutada font?</string>
|
<string name="dialog_delete_font">Kas kustutada font?</string>
|
||||||
<string name="dialog_delete_font_desc">Kas soovite kindlasti kustutada \'%1$s\'? Kui sünkroonimine on sisse lülitatud, eemaldatakse see kõigist teie seadmetest.</string>
|
<string name="dialog_delete_font_desc">Kas kustutada "%1$s"? Kui sünkroonimine on sisse lülitatud, eemaldatakse see kõigist seadmetest.</string>
|
||||||
<string name="dialog_delete_fonts">Kas kustutada fondid?</string>
|
<string name="dialog_delete_fonts">Kas kustutada fondid?</string>
|
||||||
<string name="dialog_delete_fonts_desc">Kas soovite kindlasti kustutada %1$d valitud fonte? Kui sünkroonimine on sisse lülitatud, eemaldatakse need kõigist teie seadmetest.</string>
|
<string name="dialog_delete_fonts_desc">Kas kustutada %1$d valitud fonti? Kui sünkroonimine on sisse lülitatud, eemaldatakse need kõigist seadmetest.</string>
|
||||||
<string name="get_in_touch">Võtke ühendust</string>
|
<string name="get_in_touch">Võtke ühendust</string>
|
||||||
<string name="feedback_desc">Kas leidsite vea, teil on funktsioonitaotlus või soovite lihtsalt tere öelda? Andke meile teada GitHubis või saatke meile e-kiri.</string>
|
<string name="feedback_desc">Leidsid vea, sul on funktsioonisoov või tahad lihtsalt tere öelda? Anna GitHubis teada või saada meile e-kiri.</string>
|
||||||
<string name="github_issues">GitHubi probleemid</string>
|
<string name="github_issues">GitHubi probleemid</string>
|
||||||
<string name="github_issues_desc">Teatage vigadest, taotlege funktsioone ja jälgige arenduse edenemist.</string>
|
<string name="github_issues_desc">Teatage vigadest, taotlege funktsioone ja jälgige arenduse edenemist.</string>
|
||||||
<string name="email_support">Meili tugi</string>
|
<string name="email_support">Meili tugi</string>
|
||||||
<string name="email_support_desc">Muude päringute korral võtke meiega otse e-posti teel ühendust.</string>
|
<string name="email_support_desc">Muude päringute korral võtke meiega otse e-posti teel ühendust.</string>
|
||||||
<string name="support_project_title">Toetage projekti</string>
|
<string name="support_project_title">Toeta projekti</string>
|
||||||
<string name="support_project_heading">Aidake hoida Episteme liigub</string>
|
<string name="support_project_heading">Aidake hoida Episteme liigub</string>
|
||||||
<string name="support_project_desc">Teie tugi aitab mul hoida ja täiustada Episteme kõigile!!!</string>
|
<string name="support_project_desc">Sinu tugi aitab Epistemet kõigi jaoks hoida ja täiustada.</string>
|
||||||
<string name="support_github_sponsor">Sponsor GitHubis</string>
|
<string name="support_github_sponsor">Sponsor GitHubis</string>
|
||||||
<string name="support_github_sponsor_desc">Toetage arendust otse GitHubi sponsorite kaudu. Tänutäheks saate projekti repos README hüüdlause.</string>
|
<string name="support_github_sponsor_desc">Toeta arendust otse GitHubi sponsorite kaudu. Tänutäheks saate projekti repos README hüüdlause.</string>
|
||||||
<string name="support_patreon">Liituge Patreoniga</string>
|
<string name="support_patreon">Liituge Patreoniga</string>
|
||||||
<string name="support_patreon_desc">Tänutäheks rakenduse toetamise eest saavad Patreoni toetajad lisasisu ja -hüvesid: pilkupüüre sellest, millega ma töötan, varasemaid ekraanipilte ja värskendusi, hääli, mis aitavad kujundada, kuidas uued funktsioonid peaksid välja nägema ja töötama, ning README-hüüde projekti repos.</string>
|
<string name="support_patreon_desc">Tänutäheks rakenduse toetamise eest saavad Patreoni toetajad lisasisu ja -hüvesid: pilkupüüre sellest, millega ma töötan, varasemaid ekraanipilte ja värskendusi, hääli, mis aitavad kujundada, kuidas uued funktsioonid peaksid välja nägema ja töötama, ning README-hüüde projekti repos.</string>
|
||||||
<string name="dialog_unlock_pro">Avage Episteme Pro</string>
|
<string name="dialog_unlock_pro">Ava Episteme Pro</string>
|
||||||
<string name="dialog_unlock_pro_desc">Seadmetevaheline sünkroonimine on Pro funktsioon. Avage kõik professionaalsed funktsioonid ühe ühekordse ostuga.</string>
|
<string name="dialog_unlock_pro_desc">Seadmetevaheline sünkroonimine on Pro-funktsioon. Ava kõik Pro-funktsioonid ühe ühekordse ostuga.</string>
|
||||||
<string name="action_upgrade">Uuendage</string>
|
<string name="action_upgrade">Uuendage</string>
|
||||||
<string name="dialog_confirm_sign_out">Kinnitage väljalogimine</string>
|
<string name="dialog_confirm_sign_out">Kinnitage väljalogimine</string>
|
||||||
<string name="dialog_confirm_sign_out_desc">Kas olete kindel, et soovite välja logida?</string>
|
<string name="dialog_confirm_sign_out_desc">Kas logida välja?</string>
|
||||||
<string name="device_limit_reached">Seadme limiit on saavutatud</string>
|
<string name="device_limit_reached">Seadme limiit on saavutatud</string>
|
||||||
<string name="device_limit_reached_desc">Kasutamiseks Episteme Pro selles seadmes eemaldage üks oma olemasolevatest registreeritud seadmetest.</string>
|
<string name="device_limit_reached_desc">Episteme Pro kasutamiseks selles seadmes eemalda üks olemasolevatest registreeritud seadmetest.</string>
|
||||||
<string name="last_seen">Viimati nähtud: %1$s</string>
|
<string name="last_seen">Viimati nähtud: %1$s</string>
|
||||||
<string name="dialog_destructive_action">Kinnitage hävitav tegevus</string>
|
<string name="dialog_destructive_action">Kinnitage hävitav tegevus</string>
|
||||||
<string name="dialog_destructive_action_desc">See kustutab jäädavalt kõik teie raamatud ja lugemise edenemine sellest seadmest JA teie seadmest Google Drive konto. Seda toimingut ei saa tagasi võtta. Oled sa kindel?</string>
|
<string name="dialog_destructive_action_desc">See kustutab jäädavalt kõik raamatud ja lugemisjärje sellest seadmest ning Google Drive’i kontolt. Seda toimingut ei saa tagasi võtta. Kas jätkata?</string>
|
||||||
<string name="dialog_clear_book_cache">Tühjenda raamatu vahemälu</string>
|
<string name="dialog_clear_book_cache">Tühjenda raamatu vahemälu</string>
|
||||||
<string name="dialog_clear_book_cache_desc">See kustutab kõik töödeldud lehed lehekülgede muutmise režiimis. See aitab lahendada paigutusprobleeme, kuid järgmisel korral tuleb raamatute avamisel uuesti töödelda.</string>
|
<string name="dialog_clear_book_cache_desc">See kustutab kõik töödeldud lehed lehekülgede muutmise režiimis. See aitab lahendada paigutusprobleeme, kuid järgmisel korral tuleb raamatute avamisel uuesti töödelda.</string>
|
||||||
<string name="action_confirm_clear">Kinnita ja kustuta</string>
|
<string name="action_confirm_clear">Kinnita ja kustuta</string>
|
||||||
<string name="dialog_clear_reflow_cache">Tühjendage reflow vahemälu</string>
|
<string name="dialog_clear_reflow_cache">Tühjenda reflow-vahemälu</string>
|
||||||
<string name="dialog_clear_reflow_cache_desc">See kustutab kõik loodud \'Tekstivaade\' PDF-ide versioonid ja tühjendage nendega seotud pildid/HTML-i vahemälu. Teie algsed PDF-id jäävad puutumata.</string>
|
<string name="dialog_clear_reflow_cache_desc">See kustutab kõik loodud PDF-ide tekstivaate versioonid ja tühjendab nendega seotud piltide/HTML-i vahemälu. Algsed PDF-id jäävad puutumata.</string>
|
||||||
<string name="tooltip_back">Tagasi</string>
|
<string name="tooltip_back">Tagasi</string>
|
||||||
<string name="tooltip_dictionary">Sõnastik</string>
|
<string name="tooltip_dictionary">Sõnastik</string>
|
||||||
<string name="tooltip_more_options">Rohkem valikuid</string>
|
<string name="tooltip_more_options">Rohkem valikuid</string>
|
||||||
|
|
@ -267,7 +267,7 @@
|
||||||
<string name="tooltip_dark_mode_on">Luba tume režiim</string>
|
<string name="tooltip_dark_mode_on">Luba tume režiim</string>
|
||||||
<string name="tooltip_dark_mode_off">Keela tume režiim</string>
|
<string name="tooltip_dark_mode_off">Keela tume režiim</string>
|
||||||
<string name="tooltip_lock_pan">Lukusta panoraam</string>
|
<string name="tooltip_lock_pan">Lukusta panoraam</string>
|
||||||
<string name="tooltip_unlock_pan">Avage panoraam</string>
|
<string name="tooltip_unlock_pan">Ava panoraamimine</string>
|
||||||
<string name="tooltip_fullscreen">Täisekraan</string>
|
<string name="tooltip_fullscreen">Täisekraan</string>
|
||||||
<string name="tooltip_highlights">Kuva esiletõstmised</string>
|
<string name="tooltip_highlights">Kuva esiletõstmised</string>
|
||||||
<string name="tooltip_highlights_off">Peida esiletõstmised</string>
|
<string name="tooltip_highlights_off">Peida esiletõstmised</string>
|
||||||
|
|
@ -280,37 +280,37 @@
|
||||||
<string name="tooltip_prev_result">Eelmine tulemus</string>
|
<string name="tooltip_prev_result">Eelmine tulemus</string>
|
||||||
<string name="tooltip_next_result">Järgmine tulemus</string>
|
<string name="tooltip_next_result">Järgmine tulemus</string>
|
||||||
<string name="tooltip_back_desc">Väljuge lugejast ja naaske avakuvale</string>
|
<string name="tooltip_back_desc">Väljuge lugejast ja naaske avakuvale</string>
|
||||||
<string name="tooltip_dictionary_desc">Valige sõnade otsimiseks eelistatud rakendus</string>
|
<string name="tooltip_dictionary_desc">Vali sõnade otsimiseks eelistatud rakendus</string>
|
||||||
<string name="tooltip_more_options_desc">Juurdepääs lugemisrežiimile, järjehoidjatele ja täpsematele seadetele</string>
|
<string name="tooltip_more_options_desc">Juurdepääs lugemisrežiimile, järjehoidjatele ja täpsematele seadetele</string>
|
||||||
<string name="tooltip_slider_desc">Lohistage, et hüpata kiiresti dokumendi mis tahes lehele</string>
|
<string name="tooltip_slider_desc">Lohistage, et hüpata kiiresti dokumendi mis tahes lehele</string>
|
||||||
<string name="tooltip_toc_desc">Sirvige peatükke ja navigeerige mis tahes jaotisesse</string>
|
<string name="tooltip_toc_desc">Sirvige peatükke ja navigeerige mis tahes jaotisesse</string>
|
||||||
<string name="tooltip_format_desc">Reguleerige fonti, suurust, rea kõrgust, joondamist ja kohandatud fonte</string>
|
<string name="tooltip_format_desc">Reguleerige fonti, suurust, rea kõrgust, joondamist ja kohandatud fonte</string>
|
||||||
<string name="tooltip_search_desc">Otsige sellest raamatust üles mis tahes sõna või fraas</string>
|
<string name="tooltip_search_desc">Otsige sellest raamatust üles mis tahes sõna või fraas</string>
|
||||||
<string name="tooltip_ai_desc">Tehke praegusest peatükist või leheküljest kokkuvõte, kasutades AI</string>
|
<string name="tooltip_ai_desc">Tehke praegusest peatükist või leheküljest kokkuvõte, kasutades AI</string>
|
||||||
<string name="tooltip_tts_start_desc">Lugege raamatut ette oma seadme\'s häälemootori abil</string>
|
<string name="tooltip_tts_start_desc">Loe raamatut ette oma seadme\'s häälemootori abil</string>
|
||||||
<string name="tooltip_tts_stop_desc">Peatage praegune ettelugemise seanss</string>
|
<string name="tooltip_tts_stop_desc">Peatage praegune ettelugemise seanss</string>
|
||||||
<string name="tooltip_tts_pause_desc">Peatage praegune etteloetud taasesitus</string>
|
<string name="tooltip_tts_pause_desc">Peatage praegune etteloetud taasesitus</string>
|
||||||
<string name="tooltip_tts_resume_desc">Jätkake peatatud ettelugemisega taasesitust</string>
|
<string name="tooltip_tts_resume_desc">Jätka peatatud ettelugemist</string>
|
||||||
<string name="tooltip_dark_mode_on_desc">Inverteerida PDF värvid tumeda režiimi jaoks</string>
|
<string name="tooltip_dark_mode_on_desc">Inverteerida PDF värvid tumeda režiimi jaoks</string>
|
||||||
<string name="tooltip_dark_mode_off_desc">Keela tume režiim ja taasta originaal PDF värvid</string>
|
<string name="tooltip_dark_mode_off_desc">Keela tume režiim ja taasta originaal PDF värvid</string>
|
||||||
<string name="tooltip_lock_pan_desc">Lukustage lehel horisontaalne panoraam</string>
|
<string name="tooltip_lock_pan_desc">Lukusta lehel horisontaalne panoraamimine</string>
|
||||||
<string name="tooltip_unlock_pan_desc">Avage panoraam, et uuesti lubada suumimiseks ja lohistamiseks kokkusurutud liigutused</string>
|
<string name="tooltip_unlock_pan_desc">Ava panoraamimine, et lubada uuesti suumimis- ja lohistusliigutused</string>
|
||||||
<string name="tooltip_fullscreen_desc">Peitke kõik kasutajaliidese juhtnupud, et näha kaasahaaravat ja häireteta lugemisvaadet</string>
|
<string name="tooltip_fullscreen_desc">Peitke kõik kasutajaliidese juhtnupud, et näha kaasahaaravat ja häireteta lugemisvaadet</string>
|
||||||
<string name="tooltip_highlights_desc">Märkige visuaalselt valitud tekstipiirkonnad praegusel lehel</string>
|
<string name="tooltip_highlights_desc">Märkige visuaalselt valitud tekstipiirkonnad praegusel lehel</string>
|
||||||
<string name="tooltip_highlights_off_desc">Eemaldage lehelt valitav tekstiülekate</string>
|
<string name="tooltip_highlights_off_desc">Eemalda lehelt valitav tekstiülekate</string>
|
||||||
<string name="tooltip_edit_mode_desc">Lisage tinti või tekstimärkusi</string>
|
<string name="tooltip_edit_mode_desc">Lisage tinti või tekstimärkusi</string>
|
||||||
<string name="tooltip_edit_mode_exit_desc">Lõpetage redigeerimine ja naaske tavalisse lugemisvaatesse</string>
|
<string name="tooltip_edit_mode_exit_desc">Lõpetage redigeerimine ja naaske tavalisse lugemisvaatesse</string>
|
||||||
<string name="tooltip_close_search_desc">Väljuge otsingust ja minge tagasi lugeja juurde</string>
|
<string name="tooltip_close_search_desc">Väljuge otsingust ja minge tagasi lugeja juurde</string>
|
||||||
<string name="tooltip_clear_search_desc">Kustutage praegune otsingupäring ja alustage otsast peale</string>
|
<string name="tooltip_clear_search_desc">Kustutage praegune otsingupäring ja alustage otsast peale</string>
|
||||||
<string name="tooltip_show_results_desc">Laiendage paneeli, et näha kõiki otsingu vasteid</string>
|
<string name="tooltip_show_results_desc">Laienda paneeli, et näha kõiki otsinguvastuseid</string>
|
||||||
<string name="tooltip_hide_results_desc">Ahendage otsingutulemuste paneel</string>
|
<string name="tooltip_hide_results_desc">Ahenda otsingutulemuste paneel</string>
|
||||||
<string name="tooltip_prev_result_desc">Hüppa dokumendis eelmisele otsingu vastele</string>
|
<string name="tooltip_prev_result_desc">Hüppa dokumendis eelmisele otsingu vastele</string>
|
||||||
<string name="tooltip_next_result_desc">Hüppa dokumendis järgmise otsingu vaste juurde</string>
|
<string name="tooltip_next_result_desc">Hüppa dokumendis järgmise otsingu vaste juurde</string>
|
||||||
<string name="action_sign_in">Logi sisse</string>
|
<string name="action_sign_in">Logi sisse</string>
|
||||||
<string name="action_select_folder">Valige kaust</string>
|
<string name="action_select_folder">Vali kaust</string>
|
||||||
<string name="action_select">Valige</string>
|
<string name="action_select">Vali</string>
|
||||||
<string name="legal_footer_combined">Privaatsuspoliitika • Kasutustingimused • Litsentsid</string>
|
<string name="legal_footer_combined">Privaatsuspoliitika • Kasutustingimused • Litsentsid</string>
|
||||||
<string name="error_folder_selection_unsupported">Teie seade\' ei toeta kaustade valikut. Saate endiselt faile ükshaaval importida.</string>
|
<string name="error_folder_selection_unsupported">Sinu seade ei toeta kaustade valimist. Faile saab endiselt ükshaaval importida.</string>
|
||||||
<string name="error_no_file_manager">Failihaldurit ei leitud. Installige failihalduri rakendus.</string>
|
<string name="error_no_file_manager">Failihaldurit ei leitud. Installige failihalduri rakendus.</string>
|
||||||
<string name="banner_downloaded">Allalaaditud %1$s</string>
|
<string name="banner_downloaded">Allalaaditud %1$s</string>
|
||||||
<string name="filter_facet">%1$s: %2$s</string>
|
<string name="filter_facet">%1$s: %2$s</string>
|
||||||
|
|
@ -323,7 +323,7 @@
|
||||||
<string name="error_purchase_general">Ostmisel ilmnes viga.</string>
|
<string name="error_purchase_general">Ostmisel ilmnes viga.</string>
|
||||||
<string name="banner_upgrade_success">Uuendamine õnnestus! Tere tulemast Pro-sse.</string>
|
<string name="banner_upgrade_success">Uuendamine õnnestus! Tere tulemast Pro-sse.</string>
|
||||||
<string name="error_purchase_verification">Ostu kinnitamine ebaõnnestus. Kui teilt võeti tasu, võtke ühendust klienditoega.</string>
|
<string name="error_purchase_verification">Ostu kinnitamine ebaõnnestus. Kui teilt võeti tasu, võtke ühendust klienditoega.</string>
|
||||||
<string name="banner_device_removed">See seade eemaldati teie kontolt.</string>
|
<string name="banner_device_removed">See seade eemaldati sinu kontolt.</string>
|
||||||
<string name="error_verify_device">Seda seadet ei saanud kinnitada. Palun kontrollige oma ühendust.</string>
|
<string name="error_verify_device">Seda seadet ei saanud kinnitada. Palun kontrollige oma ühendust.</string>
|
||||||
<string name="error_update_devices">Seadmete värskendamine ebaõnnestus. Palun proovi uuesti.</string>
|
<string name="error_update_devices">Seadmete värskendamine ebaõnnestus. Palun proovi uuesti.</string>
|
||||||
<string name="banner_saving_pdf">Säästmine PDF…</string>
|
<string name="banner_saving_pdf">Säästmine PDF…</string>
|
||||||
|
|
@ -332,9 +332,15 @@
|
||||||
<string name="error_saving_pdf">Viga salvestamisel PDF: %1$s</string>
|
<string name="error_saving_pdf">Viga salvestamisel PDF: %1$s</string>
|
||||||
<string name="banner_saving_original_pdf">Originaali salvestamine PDF…</string>
|
<string name="banner_saving_original_pdf">Originaali salvestamine PDF…</string>
|
||||||
<string name="banner_original_pdf_saved">Originaal PDF edukalt salvestatud.</string>
|
<string name="banner_original_pdf_saved">Originaal PDF edukalt salvestatud.</string>
|
||||||
|
<string name="banner_saving_original_file">Algse faili salvestamine…</string>
|
||||||
|
<string name="banner_original_file_saved">Algne fail salvestati.</string>
|
||||||
|
<string name="error_saving_file">Faili salvestamisel tekkis viga: %1$s</string>
|
||||||
<string name="share_subject">Jagamine: %1$s</string>
|
<string name="share_subject">Jagamine: %1$s</string>
|
||||||
<string name="share_chooser_title">Jaga PDF</string>
|
<string name="share_chooser_title">Jaga PDF</string>
|
||||||
|
<string name="share_file_chooser_title">Jaga faili</string>
|
||||||
<string name="error_share_failed">Jagamine ebaõnnestus: %1$s</string>
|
<string name="error_share_failed">Jagamine ebaõnnestus: %1$s</string>
|
||||||
|
<string name="error_copy_to_clipboard">Lõikelauale kopeerimine nurjus</string>
|
||||||
|
<string name="error_print_password_protected">Parooliga kaitstud PDF-faile ei saa printida</string>
|
||||||
<string name="error_folder_limit_reached">Limiit saavutatud: maksimaalne %1$d kaustad lubatud.</string>
|
<string name="error_folder_limit_reached">Limiit saavutatud: maksimaalne %1$d kaustad lubatud.</string>
|
||||||
<string name="error_folder_already_synced">See kaust on juba sünkroonitud.</string>
|
<string name="error_folder_already_synced">See kaust on juba sünkroonitud.</string>
|
||||||
<string name="banner_folder_added">Lisatud kaust: %1$s</string>
|
<string name="banner_folder_added">Lisatud kaust: %1$s</string>
|
||||||
|
|
@ -360,7 +366,7 @@
|
||||||
<string name="error_sign_in_failed">Sisselogimine ebaõnnestus. Palun proovi uuesti.</string>
|
<string name="error_sign_in_failed">Sisselogimine ebaõnnestus. Palun proovi uuesti.</string>
|
||||||
<string name="error_no_google_account">Ei leitud Google konto. See võib juhtuda värske installi korral, proovige mõne aja pärast uuesti.</string>
|
<string name="error_no_google_account">Ei leitud Google konto. See võib juhtuda värske installi korral, proovige mõne aja pärast uuesti.</string>
|
||||||
<string name="error_sign_in_internet">Sisselogimisel ilmnes viga. Kontrollige oma Interneti-ühendust.</string>
|
<string name="error_sign_in_internet">Sisselogimisel ilmnes viga. Kontrollige oma Interneti-ühendust.</string>
|
||||||
<string name="error_sign_in_device_management">Seadmehalduse testimiseks logige sisse.</string>
|
<string name="error_sign_in_device_management">Seadmehalduse testimiseks logi sisse.</string>
|
||||||
<string name="error_sync_pro_feature">Sünkroonimine on Episteme Pro funktsiooni.</string>
|
<string name="error_sync_pro_feature">Sünkroonimine on Episteme Pro funktsiooni.</string>
|
||||||
<string name="error_not_signed_in_sync">Pole sisse logitud, ei saa sünkroonida.</string>
|
<string name="error_not_signed_in_sync">Pole sisse logitud, ei saa sünkroonida.</string>
|
||||||
<string name="banner_cloud_sync_checking">Pilvesünkroonimine: värskenduste otsimine…</string>
|
<string name="banner_cloud_sync_checking">Pilvesünkroonimine: värskenduste otsimine…</string>
|
||||||
|
|
@ -386,7 +392,7 @@
|
||||||
<string name="search_no_results_simple">Tulemusi ei leitud.</string>
|
<string name="search_no_results_simple">Tulemusi ei leitud.</string>
|
||||||
<string name="generating_summary">Kokkuvõtte genereerimine…</string>
|
<string name="generating_summary">Kokkuvõtte genereerimine…</string>
|
||||||
<string name="action_stop">Peatus</string>
|
<string name="action_stop">Peatus</string>
|
||||||
<string name="action_read_aloud">Lugege ette</string>
|
<string name="action_read_aloud">Loe ette</string>
|
||||||
<string name="action_copy">Kopeeri</string>
|
<string name="action_copy">Kopeeri</string>
|
||||||
<string name="action_copy_thread">Kopeeri lõim</string>
|
<string name="action_copy_thread">Kopeeri lõim</string>
|
||||||
<string name="no_summary_generated">Kokkuvõtet ei saanud luua.</string>
|
<string name="no_summary_generated">Kokkuvõtet ei saanud luua.</string>
|
||||||
|
|
@ -404,7 +410,7 @@
|
||||||
<string name="tts_device_voice_settings">Seadme hääleseaded</string>
|
<string name="tts_device_voice_settings">Seadme hääleseaded</string>
|
||||||
<string name="content_desc_close_settings">Sulgege seaded</string>
|
<string name="content_desc_close_settings">Sulgege seaded</string>
|
||||||
<string name="tts_system_default">Süsteemi vaikeseade</string>
|
<string name="tts_system_default">Süsteemi vaikeseade</string>
|
||||||
<string name="tts_system_default_desc">Vastab teie Android süsteemi seaded</string>
|
<string name="tts_system_default_desc">Vastab sinu Android süsteemi seaded</string>
|
||||||
<string name="content_desc_selected">Valitud</string>
|
<string name="content_desc_selected">Valitud</string>
|
||||||
<string name="tts_loading_voices">Häälte laadimine…</string>
|
<string name="tts_loading_voices">Häälte laadimine…</string>
|
||||||
<string name="tts_no_voices">Selles seadmes pole hääli saadaval.</string>
|
<string name="tts_no_voices">Selles seadmes pole hääli saadaval.</string>
|
||||||
|
|
@ -450,7 +456,7 @@
|
||||||
<string name="dict_external_description">Kasutab valitud rakendust sõnastikust otsimiseks.</string>
|
<string name="dict_external_description">Kasutab valitud rakendust sõnastikust otsimiseks.</string>
|
||||||
<string name="dict_fallback_app">Varurakendus</string>
|
<string name="dict_fallback_app">Varurakendus</string>
|
||||||
<string name="dict_dictionary_app">Sõnastiku rakendus</string>
|
<string name="dict_dictionary_app">Sõnastiku rakendus</string>
|
||||||
<string name="dict_select_app">Valige rakendus</string>
|
<string name="dict_select_app">Vali rakendus</string>
|
||||||
<string name="dict_translate">Tõlgi</string>
|
<string name="dict_translate">Tõlgi</string>
|
||||||
<string name="dict_translate_description">Rakendus, mida kasutatakse valitud teksti tõlkimiseks.</string>
|
<string name="dict_translate_description">Rakendus, mida kasutatakse valitud teksti tõlkimiseks.</string>
|
||||||
<string name="dict_search_app">Otsi rakendust</string>
|
<string name="dict_search_app">Otsi rakendust</string>
|
||||||
|
|
@ -466,16 +472,16 @@
|
||||||
<string name="ai_generating_recap">Kokkuvõtte genereerimine…</string>
|
<string name="ai_generating_recap">Kokkuvõtte genereerimine…</string>
|
||||||
<string name="ai_chapter_summary">Peatüki kokkuvõte</string>
|
<string name="ai_chapter_summary">Peatüki kokkuvõte</string>
|
||||||
<string name="ai_story_recap_beta">Loo kokkuvõte (beeta)</string>
|
<string name="ai_story_recap_beta">Loo kokkuvõte (beeta)</string>
|
||||||
<string name="ai_unlock_summarization">Avage peatüki kokkuvõte</string>
|
<string name="ai_unlock_summarization">Ava peatüki kokkuvõte</string>
|
||||||
<string name="ai_unlock_summarization_desc">Saate mis tahes peatüki lühikokkuvõtteid kasutades Episteme Pro. Selle funktsiooni kasutamise alustamiseks uuendage.</string>
|
<string name="ai_unlock_summarization_desc">Saate mis tahes peatüki lühikokkuvõtteid kasutades Episteme Pro. Selle funktsiooni kasutamise alustamiseks uuendage.</string>
|
||||||
<string name="action_learn_more">Lisateave</string>
|
<string name="action_learn_more">Lisateave</string>
|
||||||
<string name="ai_unlock_smart_dict">Avage nutikas sõnaraamat</string>
|
<string name="ai_unlock_smart_dict">Ava nutikas sõnastik</string>
|
||||||
<string name="ai_unlock_smart_dict_desc">Tervete fraaside ja lõikude määratlemine kuni 2000 tähemärgini on Pro funktsioon. Täiendage, et saada mis tahes valitud teksti jaoks kohesed määratlused.</string>
|
<string name="ai_unlock_smart_dict_desc">Tervete fraaside ja lõikude määratlemine kuni 2000 tähemärgini on Pro funktsioon. Täiendage, et saada mis tahes valitud teksti jaoks kohesed määratlused.</string>
|
||||||
<string name="content_desc_bookmark_icon">Järjehoidja</string>
|
<string name="content_desc_bookmark_icon">Järjehoidja</string>
|
||||||
<string name="content_desc_selected_slot">Valitud pesa</string>
|
<string name="content_desc_selected_slot">Valitud pesa</string>
|
||||||
<string name="dialog_customize_palette">Kohandage palett</string>
|
<string name="dialog_customize_palette">Kohandage palett</string>
|
||||||
<string name="palette_tap_slot_to_edit">Puudutage muutmiseks pesa:</string>
|
<string name="palette_tap_slot_to_edit">Puudutage muutmiseks pesa:</string>
|
||||||
<string name="palette_select_color_for_slot">Valige pesa värv:</string>
|
<string name="palette_select_color_for_slot">Vali pesa värv:</string>
|
||||||
<string name="chapter_empty">See peatükk on tühi.</string>
|
<string name="chapter_empty">See peatükk on tühi.</string>
|
||||||
<string name="chapter_not_found">Peatükki ei leitud</string>
|
<string name="chapter_not_found">Peatükki ei leitud</string>
|
||||||
<string name="error_loading_chapter">Viga peatüki laadimisel</string>
|
<string name="error_loading_chapter">Viga peatüki laadimisel</string>
|
||||||
|
|
@ -496,7 +502,7 @@
|
||||||
<string name="menu_volume_button_scrolling">Helitugevuse nupu kerimine</string>
|
<string name="menu_volume_button_scrolling">Helitugevuse nupu kerimine</string>
|
||||||
<string name="menu_volume_button_page_turn">Helitugevuse nupp Lehekülje pööramine</string>
|
<string name="menu_volume_button_page_turn">Helitugevuse nupp Lehekülje pööramine</string>
|
||||||
<string name="menu_realistic_page_turns">Realistlikud leheküljepöörded</string>
|
<string name="menu_realistic_page_turns">Realistlikud leheküljepöörded</string>
|
||||||
<string name="menu_keep_screen_on">Hoidke ekraan sees</string>
|
<string name="menu_keep_screen_on">Hoia ekraan sees</string>
|
||||||
<string name="menu_visual_options">Visuaalsed valikud</string>
|
<string name="menu_visual_options">Visuaalsed valikud</string>
|
||||||
<string name="menu_screen_orientation">Ekraani suund</string>
|
<string name="menu_screen_orientation">Ekraani suund</string>
|
||||||
<string name="menu_change_reading_mode">Muutke lugemisrežiimi</string>
|
<string name="menu_change_reading_mode">Muutke lugemisrežiimi</string>
|
||||||
|
|
@ -526,7 +532,7 @@
|
||||||
<string name="content_desc_start_playback">Mängi</string>
|
<string name="content_desc_start_playback">Mängi</string>
|
||||||
<string name="auto_scroll_local_speed">Kohalik kiirus</string>
|
<string name="auto_scroll_local_speed">Kohalik kiirus</string>
|
||||||
<string name="auto_scroll_global_speed">Globaalne kiirus</string>
|
<string name="auto_scroll_global_speed">Globaalne kiirus</string>
|
||||||
<string name="content_desc_select_mode">Valige Režiim</string>
|
<string name="content_desc_select_mode">Vali režiim</string>
|
||||||
<string name="auto_scroll_applies_all_files">Kehtib kõikidele failidele</string>
|
<string name="auto_scroll_applies_all_files">Kehtib kõikidele failidele</string>
|
||||||
<string name="auto_scroll_saved_for_file">Salvestatud ainult selle faili jaoks</string>
|
<string name="auto_scroll_saved_for_file">Salvestatud ainult selle faili jaoks</string>
|
||||||
<string name="content_desc_disable_musician_mode">Keela muusiku režiim</string>
|
<string name="content_desc_disable_musician_mode">Keela muusiku režiim</string>
|
||||||
|
|
@ -550,25 +556,25 @@
|
||||||
<string name="action_locate">Otsige üles</string>
|
<string name="action_locate">Otsige üles</string>
|
||||||
<string name="no_bookmarks_yet">You haven\'t added any bookmarks yet.</string>
|
<string name="no_bookmarks_yet">You haven\'t added any bookmarks yet.</string>
|
||||||
<string name="no_images_found">Pilte ei leitud.</string>
|
<string name="no_images_found">Pilte ei leitud.</string>
|
||||||
<string name="content_desc_download_image">Laadige pilt alla</string>
|
<string name="content_desc_download_image">Laadi pilt alla</string>
|
||||||
<string name="content_desc_more_options_bookmark">Rohkem valikuid järjehoidja jaoks</string>
|
<string name="content_desc_more_options_bookmark">Rohkem valikuid järjehoidja jaoks</string>
|
||||||
<string name="dialog_rename_bookmark">Nimeta järjehoidja ümber</string>
|
<string name="dialog_rename_bookmark">Nimeta järjehoidja ümber</string>
|
||||||
<string name="label_new_name">Uus nimi</string>
|
<string name="label_new_name">Uus nimi</string>
|
||||||
<string name="label_new_title">Uus pealkiri</string>
|
<string name="label_new_title">Uus pealkiri</string>
|
||||||
<string name="dialog_delete_bookmark">Kas kustutada järjehoidja?</string>
|
<string name="dialog_delete_bookmark">Kas kustutada järjehoidja?</string>
|
||||||
<string name="dialog_delete_bookmark_desc">Kas olete kindel, et soovite selle järjehoidja jäädavalt kustutada?</string>
|
<string name="dialog_delete_bookmark_desc">Kas oled kindel, et soovid selle järjehoidja jäädavalt kustutada?</string>
|
||||||
<string name="no_highlights_yet">Esiletõsteid veel pole.</string>
|
<string name="no_highlights_yet">Esiletõsteid veel pole.</string>
|
||||||
<string name="unknown_chapter">Tundmatu peatükk</string>
|
<string name="unknown_chapter">Tundmatu peatükk</string>
|
||||||
<string name="content_desc_options">Valikud</string>
|
<string name="content_desc_options">Valikud</string>
|
||||||
<string name="dialog_delete_highlight">Kas kustutada esiletõst?</string>
|
<string name="dialog_delete_highlight">Kas kustutada esiletõst?</string>
|
||||||
<string name="dialog_delete_highlight_desc">Kas olete kindel, et soovite selle esiletõstmise jäädavalt kustutada?</string>
|
<string name="dialog_delete_highlight_desc">Kas kustutada see esiletõst jäädavalt?</string>
|
||||||
<string name="saved_image_message">Salvestatud %1$s</string>
|
<string name="saved_image_message">Salvestatud %1$s</string>
|
||||||
<string name="error_save_image">Pilti ei saanud salvestada.</string>
|
<string name="error_save_image">Pilti ei saanud salvestada.</string>
|
||||||
<string name="banner_original_pdf_not_found">Originaal PDF ei leitud.</string>
|
<string name="banner_original_pdf_not_found">Originaal PDF ei leitud.</string>
|
||||||
<string name="error_book_content_not_found">Viga: raamatu sisu ei leitud. Tee: %1$s</string>
|
<string name="error_book_content_not_found">Viga: raamatu sisu ei leitud. Tee: %1$s</string>
|
||||||
<string name="toast_select_dictionary_first">Valige esmalt sõnastikurakendus.</string>
|
<string name="toast_select_dictionary_first">Vali esmalt sõnastikurakendus.</string>
|
||||||
<string name="toast_select_translate_first">Valige esmalt tõlkerakendus.</string>
|
<string name="toast_select_translate_first">Vali esmalt tõlkerakendus.</string>
|
||||||
<string name="toast_select_search_first">Valige esmalt otsingurakendus.</string>
|
<string name="toast_select_search_first">Vali esmalt otsingurakendus.</string>
|
||||||
<string name="no_chapters_available">Selle raamatu jaoks pole peatükke saadaval.</string>
|
<string name="no_chapters_available">Selle raamatu jaoks pole peatükke saadaval.</string>
|
||||||
<string name="navigating_to_position">Navigeerimine asukohta…</string>
|
<string name="navigating_to_position">Navigeerimine asukohta…</string>
|
||||||
<string name="dialog_permission_required">Nõutav luba</string>
|
<string name="dialog_permission_required">Nõutav luba</string>
|
||||||
|
|
@ -578,7 +584,7 @@
|
||||||
<string name="dialog_justified_text_limitation_desc">Põhjendatud joonduse kasutamine leheküljelises režiimis võib küljenduse piirangute tõttu muuta teksti valiku ja esiletõstmised ebatäpseks.</string>
|
<string name="dialog_justified_text_limitation_desc">Põhjendatud joonduse kasutamine leheküljelises režiimis võib küljenduse piirangute tõttu muuta teksti valiku ja esiletõstmised ebatäpseks.</string>
|
||||||
<string name="action_i_understand">ma saan aru</string>
|
<string name="action_i_understand">ma saan aru</string>
|
||||||
<string name="navigating_to_chapter">Peatükki navigeerimine…</string>
|
<string name="navigating_to_chapter">Peatükki navigeerimine…</string>
|
||||||
<string name="toast_select_offline_dict_first">Valige esmalt võrguühenduseta sõnastik.</string>
|
<string name="toast_select_offline_dict_first">Vali esmalt võrguühenduseta sõnastik.</string>
|
||||||
<string name="banner_book_not_paginated">Raamat pole veel lehekülgedega varustatud.</string>
|
<string name="banner_book_not_paginated">Raamat pole veel lehekülgedega varustatud.</string>
|
||||||
<string name="banner_wait_for_load">Oodake, kuni raamat on täielikult laaditud.</string>
|
<string name="banner_wait_for_load">Oodake, kuni raamat on täielikult laaditud.</string>
|
||||||
<string name="release_for_previous_chapter">Eelmise peatüki väljalase</string>
|
<string name="release_for_previous_chapter">Eelmise peatüki väljalase</string>
|
||||||
|
|
@ -594,7 +600,7 @@
|
||||||
<string name="action_reset">Lähtesta</string>
|
<string name="action_reset">Lähtesta</string>
|
||||||
<string name="label_size">Suurus</string>
|
<string name="label_size">Suurus</string>
|
||||||
<string name="label_spacing">Vahekaugus</string>
|
<string name="label_spacing">Vahekaugus</string>
|
||||||
<string name="select_font">Valige Font</string>
|
<string name="select_font">Vali Font</string>
|
||||||
<string name="tab_presets">Eelseaded</string>
|
<string name="tab_presets">Eelseaded</string>
|
||||||
<string name="tab_imported">Imporditud</string>
|
<string name="tab_imported">Imporditud</string>
|
||||||
<string name="button_import_from_files">Import failidest</string>
|
<string name="button_import_from_files">Import failidest</string>
|
||||||
|
|
@ -606,20 +612,20 @@
|
||||||
<string name="visual_options_pdf_spread_two">Kaks lehte</string>
|
<string name="visual_options_pdf_spread_two">Kaks lehte</string>
|
||||||
<string name="visual_options_pdf_first_page_alone">Esimene leht üksi</string>
|
<string name="visual_options_pdf_first_page_alone">Esimene leht üksi</string>
|
||||||
<string name="visual_options_pdf_first_page_alone_desc">Alustab esikülje laialivalgumist pärast kaanelehte.</string>
|
<string name="visual_options_pdf_first_page_alone_desc">Alustab esikülje laialivalgumist pärast kaanelehte.</string>
|
||||||
<string name="visual_options_remove_page_gap">Eemaldage lehtede vahe</string>
|
<string name="visual_options_remove_page_gap">Eemalda lehtede vahe</string>
|
||||||
<string name="visual_options_remove_page_gap_desc">Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel.</string>
|
<string name="visual_options_remove_page_gap_desc">Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel.</string>
|
||||||
<string name="visual_options_hide_page_number_overlay">Peida lehenumbri ülekate</string>
|
<string name="visual_options_hide_page_number_overlay">Peida lehenumbri ülekate</string>
|
||||||
<string name="visual_options_hide_page_number_overlay_desc">Eemaldab igalt lehelt väikese lehekülgede arvu sildi.</string>
|
<string name="visual_options_hide_page_number_overlay_desc">Eemaldab igalt lehelt väikese lehekülgede arvu sildi.</string>
|
||||||
<string name="visual_options_system_ui">Süsteemi kasutajaliides (oleku- ja navigeerimisribad)</string>
|
<string name="visual_options_system_ui">Süsteemi kasutajaliides (oleku- ja navigeerimisribad)</string>
|
||||||
<string name="visual_options_system_ui_desc">Kontrollige seadme\'-süsteemi ribade nähtavust.</string>
|
<string name="visual_options_system_ui_desc">Kontrollige seadme\'-süsteemi ribade nähtavust.</string>
|
||||||
<string name="visual_options_screen_orientation">Ekraani suund</string>
|
<string name="visual_options_screen_orientation">Ekraani suund</string>
|
||||||
<string name="visual_options_screen_orientation_desc">Valige, kas lugeja järgib süsteemi orientatsiooni või eelistab vertikaalset või horisontaalset, kui Android lubab seda.</string>
|
<string name="visual_options_screen_orientation_desc">Vali, kas lugeja järgib süsteemi orientatsiooni või eelistab vertikaalset või horisontaalset, kui Android lubab seda.</string>
|
||||||
<string name="visual_options_progress_bar">Edenemisriba</string>
|
<string name="visual_options_progress_bar">Edenemisriba</string>
|
||||||
<string name="visual_options_progress_bar_desc">Lugemise edenemise ja peatüki indikaator lugemisekraanil.</string>
|
<string name="visual_options_progress_bar_desc">Lugemise edenemise ja peatüki indikaator lugemisekraanil.</string>
|
||||||
<string name="visual_options_progress_bar_position">positsioon</string>
|
<string name="visual_options_progress_bar_position">positsioon</string>
|
||||||
<string name="visual_options_seamless_chapter">Peatükkide sujuv üleminek</string>
|
<string name="visual_options_seamless_chapter">Peatükkide sujuv üleminek</string>
|
||||||
<string name="visual_options_seamless_chapter_desc">Laadige lõpust mööda kerides kohe järgmine/eelmine peatükk, ilma tõmmake värskendamiseks animatsioonita.</string>
|
<string name="visual_options_seamless_chapter_desc">Laadi lõpust mööda kerides kohe järgmine/eelmine peatükk, ilma tõmmake värskendamiseks animatsioonita.</string>
|
||||||
<string name="visual_options_edge_padding">Eemaldage serva polsterdus</string>
|
<string name="visual_options_edge_padding">Eemalda servapolsterdus</string>
|
||||||
<string name="visual_options_edge_padding_desc">Eemaldab horisontaalse vahe vasakust ja paremast servast.</string>
|
<string name="visual_options_edge_padding_desc">Eemaldab horisontaalse vahe vasakust ja paremast servast.</string>
|
||||||
<string name="reader_brightness_title">Heledus</string>
|
<string name="reader_brightness_title">Heledus</string>
|
||||||
<string name="reader_brightness_system">Kasutage süsteemi heledust</string>
|
<string name="reader_brightness_system">Kasutage süsteemi heledust</string>
|
||||||
|
|
@ -636,10 +642,10 @@
|
||||||
<string name="about_version_name">Versioon %1$s</string>
|
<string name="about_version_name">Versioon %1$s</string>
|
||||||
<string name="about_build_code">Ehitamine %1$s</string>
|
<string name="about_build_code">Ehitamine %1$s</string>
|
||||||
<string name="about_github_desc">Sirvige lähtekoodi, tärniga, kahvliga ja teatage probleemidest.</string>
|
<string name="about_github_desc">Sirvige lähtekoodi, tärniga, kahvliga ja teatage probleemidest.</string>
|
||||||
<string name="about_privacy_desc">Kuidas me teie andmeid käsitleme.</string>
|
<string name="about_privacy_desc">Kuidas me sinu andmeid käsitleme.</string>
|
||||||
<string name="about_terms_desc">Kasutustingimused.</string>
|
<string name="about_terms_desc">Kasutustingimused.</string>
|
||||||
<string name="about_licenses_desc">Kasutatud avatud lähtekoodiga teegid.</string>
|
<string name="about_licenses_desc">Kasutatud avatud lähtekoodiga teegid.</string>
|
||||||
<string name="banner_importing_multiple">Importimine %1$d raamatud… Need ilmuvad peagi teie kogusse.</string>
|
<string name="banner_importing_multiple">Importimine %1$d raamatud… Need ilmuvad peagi sinu kogusse.</string>
|
||||||
<string name="banner_shelf_created">Loodi riiul "%1$s".</string>
|
<string name="banner_shelf_created">Loodi riiul "%1$s".</string>
|
||||||
<string name="banner_smart_shelf_created">Loodi nutikas riiul "%1$s".</string>
|
<string name="banner_smart_shelf_created">Loodi nutikas riiul "%1$s".</string>
|
||||||
<string name="banner_shelf_renamed">Riiul nimetati ümber "%1$s".</string>
|
<string name="banner_shelf_renamed">Riiul nimetati ümber "%1$s".</string>
|
||||||
|
|
@ -678,7 +684,7 @@
|
||||||
<string name="tts_voice_adjustments">Hääle reguleerimine</string>
|
<string name="tts_voice_adjustments">Hääle reguleerimine</string>
|
||||||
<string name="tts_speed_label">Kiirus (%1$sx)</string>
|
<string name="tts_speed_label">Kiirus (%1$sx)</string>
|
||||||
<string name="tts_pitch_label">Kõrgus (%1$sx)</string>
|
<string name="tts_pitch_label">Kõrgus (%1$sx)</string>
|
||||||
<string name="tts_sample_text">Nii kõlavad teie praegused hääleseaded.</string>
|
<string name="tts_sample_text">Nii kõlavad sinu praegused hääleseaded.</string>
|
||||||
<string name="tts_pause_book">Peata raamat</string>
|
<string name="tts_pause_book">Peata raamat</string>
|
||||||
<string name="tts_resume_book">Jätkamise raamat</string>
|
<string name="tts_resume_book">Jätkamise raamat</string>
|
||||||
<string name="tts_system_settings">Süsteemi hääle/mootori sätted</string>
|
<string name="tts_system_settings">Süsteemi hääle/mootori sätted</string>
|
||||||
|
|
@ -688,7 +694,7 @@
|
||||||
<string name="auto_scroll_desc_local">Salvestatud ainult selle faili jaoks</string>
|
<string name="auto_scroll_desc_local">Salvestatud ainult selle faili jaoks</string>
|
||||||
<string name="action_scroll_to_top">Kerige üles</string>
|
<string name="action_scroll_to_top">Kerige üles</string>
|
||||||
<string name="title_customize_toolbar">Kohandage tööriistariba</string>
|
<string name="title_customize_toolbar">Kohandage tööriistariba</string>
|
||||||
<string name="desc_customize_toolbar">Valige tööriistad, mida soovite nähtavana hoida. Tööriista märke tühistamine peidab selle kasutajaliidese eest, et anda teile tähelepanu kõrvalejuhtimiseta lugemisruumi.</string>
|
<string name="desc_customize_toolbar">Vali tööriistad, mida nähtavana hoida. Tööriista märke eemaldamine peidab selle kasutajaliidesest, et lugemisruum oleks häirimatu.</string>
|
||||||
<string name="tab_annotations">Märkused</string>
|
<string name="tab_annotations">Märkused</string>
|
||||||
<string name="filter_all">Kõik</string>
|
<string name="filter_all">Kõik</string>
|
||||||
<string name="filter_with_notes">Märkmetega</string>
|
<string name="filter_with_notes">Märkmetega</string>
|
||||||
|
|
@ -706,8 +712,8 @@
|
||||||
<string name="content_desc_undo">Võta tagasi</string>
|
<string name="content_desc_undo">Võta tagasi</string>
|
||||||
<string name="content_desc_redo">Tee uuesti</string>
|
<string name="content_desc_redo">Tee uuesti</string>
|
||||||
<string name="content_desc_show_dock">Näita dokki</string>
|
<string name="content_desc_show_dock">Näita dokki</string>
|
||||||
<string name="content_desc_select_font_family">Valige Fontide perekond</string>
|
<string name="content_desc_select_font_family">Vali fondipere</string>
|
||||||
<string name="content_desc_select_font_size">Valige Fondi suurus</string>
|
<string name="content_desc_select_font_size">Vali Fondi suurus</string>
|
||||||
<string name="content_desc_font_background">Fondi taust</string>
|
<string name="content_desc_font_background">Fondi taust</string>
|
||||||
<string name="content_desc_bold">Paks</string>
|
<string name="content_desc_bold">Paks</string>
|
||||||
<string name="content_desc_italic">Kursiiv</string>
|
<string name="content_desc_italic">Kursiiv</string>
|
||||||
|
|
@ -731,7 +737,7 @@
|
||||||
<string name="menu_insert_blank_page">Sisesta tühi leht</string>
|
<string name="menu_insert_blank_page">Sisesta tühi leht</string>
|
||||||
<string name="menu_delete_page">Kustuta leht</string>
|
<string name="menu_delete_page">Kustuta leht</string>
|
||||||
<string name="generating_reflow_progress">Tekib… %1$d%%</string>
|
<string name="generating_reflow_progress">Tekib… %1$d%%</string>
|
||||||
<string name="action_open_text_view">Avage tekstivaade</string>
|
<string name="action_open_text_view">Ava tekstivaade</string>
|
||||||
<string name="action_generate_text_view">Loo tekstivaade</string>
|
<string name="action_generate_text_view">Loo tekstivaade</string>
|
||||||
<string name="action_share">Jaga</string>
|
<string name="action_share">Jaga</string>
|
||||||
<string name="action_save_copy_to_device">Salvesta koopia seadmesse</string>
|
<string name="action_save_copy_to_device">Salvesta koopia seadmesse</string>
|
||||||
|
|
@ -743,11 +749,11 @@
|
||||||
<string name="msg_search_pages_count">%1$d+ Lehekülgi</string>
|
<string name="msg_search_pages_count">%1$d+ Lehekülgi</string>
|
||||||
<string name="action_summarize_page">Lehe kokkuvõte (lehekülg %1$d)</string>
|
<string name="action_summarize_page">Lehe kokkuvõte (lehekülg %1$d)</string>
|
||||||
<string name="msg_downloading_language_pack">Allalaadimine %1$s keelepakett…</string>
|
<string name="msg_downloading_language_pack">Allalaadimine %1$s keelepakett…</string>
|
||||||
<string name="title_select_ocr_language">Valige OCR Keel</string>
|
<string name="title_select_ocr_language">Vali OCR Keel</string>
|
||||||
<string name="desc_select_ocr_language">Paremate tekstituvastustulemuste saamiseks valige selle dokumendi esmane keel/skript.</string>
|
<string name="desc_select_ocr_language">Paremate tekstituvastustulemuste saamiseks vali selle dokumendi peamine keel/kiri.</string>
|
||||||
<string name="desc_ocr_language_change_later">Saate seda hiljem muuta jaotises Rohkem valikuid > OCR Keel.</string>
|
<string name="desc_ocr_language_change_later">Saate seda hiljem muuta jaotises Rohkem valikuid > OCR Keel.</string>
|
||||||
<string name="title_reindex_document">Kas dokument uuesti indekseerida?</string>
|
<string name="title_reindex_document">Kas dokument uuesti indekseerida?</string>
|
||||||
<string name="desc_reindex_document_warning">You are changing the OCR script to %1$s.\n\nTo ensure search accuracy, we need to clear the existing index and re-scan pages that require OCR using this new language.\n\nThis will happen in the background.</string>
|
<string name="desc_reindex_document_warning">Muudad OCR-i kirja väärtuseks %1$s.\n\nOtsingu täpsuse tagamiseks peame olemasoleva indeksi tühjendama ja OCR-i vajavad lehed selle uue keelega uuesti skannima.\n\nSee toimub taustal.</string>
|
||||||
<string name="action_reindex">Indekseeri uuesti</string>
|
<string name="action_reindex">Indekseeri uuesti</string>
|
||||||
<string name="title_password_protected">Parooliga kaitstud</string>
|
<string name="title_password_protected">Parooliga kaitstud</string>
|
||||||
<string name="desc_password_protected">See dokument on krüpteeritud. Selle vaatamiseks sisestage parool.</string>
|
<string name="desc_password_protected">See dokument on krüpteeritud. Selle vaatamiseks sisestage parool.</string>
|
||||||
|
|
@ -758,13 +764,13 @@
|
||||||
<string name="desc_external_link_warning">You are about to navigate to:\n%1$s</string>
|
<string name="desc_external_link_warning">You are about to navigate to:\n%1$s</string>
|
||||||
<string name="action_visit">Külastage</string>
|
<string name="action_visit">Külastage</string>
|
||||||
<string name="title_save_to_device">Salvesta seadmesse</string>
|
<string name="title_save_to_device">Salvesta seadmesse</string>
|
||||||
<string name="desc_choose_format_save">Valige salvestamiseks vorming:</string>
|
<string name="desc_choose_format_save">Vali salvestamiseks vorming:</string>
|
||||||
<string name="action_with_annotations">Koos märkustega</string>
|
<string name="action_with_annotations">Koos märkustega</string>
|
||||||
<string name="action_original">Originaal</string>
|
<string name="action_original">Originaal</string>
|
||||||
<string name="desc_choose_format_share">Valige jagamiseks vorming:</string>
|
<string name="desc_choose_format_share">Vali jagamiseks vorming:</string>
|
||||||
<string name="msg_preparing_pdf">Ettevalmistus PDF…</string>
|
<string name="msg_preparing_pdf">Ettevalmistus PDF…</string>
|
||||||
<string name="title_add_pdf_to_tab">Lisa PDF vahekaardile</string>
|
<string name="title_add_pdf_to_tab">Lisa PDF vahekaardile</string>
|
||||||
<string name="msg_no_other_pdfs_found">Teisi PDF-e teie teegist ei leitud.</string>
|
<string name="msg_no_other_pdfs_found">Teisi PDF-e sinu teegist ei leitud.</string>
|
||||||
<string name="msg_pdf_empty_or_error">PDF on tühi või seda ei saa kuvada.</string>
|
<string name="msg_pdf_empty_or_error">PDF on tühi või seda ei saa kuvada.</string>
|
||||||
<string name="msg_page_added_at">Leht lisatud aadressil %1$d</string>
|
<string name="msg_page_added_at">Leht lisatud aadressil %1$d</string>
|
||||||
<string name="msg_page_deleted">Leht kustutatud</string>
|
<string name="msg_page_deleted">Leht kustutatud</string>
|
||||||
|
|
@ -792,23 +798,24 @@
|
||||||
<string name="dialog_strict_file_filter_title">Luba range failifilter</string>
|
<string name="dialog_strict_file_filter_title">Luba range failifilter</string>
|
||||||
<string name="dialog_strict_file_filter_desc">If you enable this, some supported file types like AZW3, CB7, and FB2 might not show up depending on your file manager.\n\nAre you sure you want to enable this filter?</string>
|
<string name="dialog_strict_file_filter_desc">If you enable this, some supported file types like AZW3, CB7, and FB2 might not show up depending on your file manager.\n\nAre you sure you want to enable this filter?</string>
|
||||||
<string name="language_system_default">Süsteemi vaikeseade</string>
|
<string name="language_system_default">Süsteemi vaikeseade</string>
|
||||||
<string name="language_english">inglise keel</string>
|
<string name="language_english">English (inglise)</string>
|
||||||
<string name="language_english_default">inglise keel (vaikimisi)</string>
|
<string name="language_english_default">English (vaikimisi)</string>
|
||||||
<string name="language_arabic">العربية (araabia)</string>
|
<string name="language_arabic">العربية (araabia)</string>
|
||||||
<string name="language_german">saksa (saksa)</string>
|
<string name="language_german">Deutsch (saksa)</string>
|
||||||
<string name="language_turkish">türkçe (türgi)</string>
|
<string name="language_turkish">türkçe (türgi)</string>
|
||||||
<string name="language_french">Français (prantsuse)</string>
|
<string name="language_french">Français (prantsuse)</string>
|
||||||
<string name="language_russian">Русский (vene)</string>
|
<string name="language_russian">Русский (vene)</string>
|
||||||
<string name="language_belarusian">Беларуская (valgevene keel)</string>
|
<string name="language_belarusian">Беларуская (valgevene keel)</string>
|
||||||
<string name="language_spanish">español (hispaania)</string>
|
<string name="language_spanish">español (hispaania)</string>
|
||||||
<string name="language_portuguese_brazilian">portugali keel (Brasiilia)</string>
|
<string name="language_portuguese_brazilian">Português (Brasiilia)</string>
|
||||||
<string name="language_italian">itaalia keel (itaalia)</string>
|
<string name="language_italian">Italiano (itaalia)</string>
|
||||||
<string name="language_polish">polski (poola)</string>
|
<string name="language_polish">polski (poola)</string>
|
||||||
<string name="language_vietnamese">Tiếng Việt (vietnami)</string>
|
<string name="language_vietnamese">Tiếng Việt (vietnami)</string>
|
||||||
<string name="language_japanese">日本語 (jaapani keel)</string>
|
<string name="language_japanese">日本語 (jaapani keel)</string>
|
||||||
<string name="language_korean">한국어 (korea)</string>
|
<string name="language_korean">한국어 (korea)</string>
|
||||||
<string name="language_hindi">हिन्दी (hindi)</string>
|
<string name="language_hindi">हिन्दी (hindi)</string>
|
||||||
<string name="language_chinese_simplified">简体中文 (hiina, lihtsustatud)</string>
|
<string name="language_chinese_simplified">简体中文 (hiina, lihtsustatud)</string>
|
||||||
|
<string name="language_estonian">Eesti</string>
|
||||||
<string name="app_theme_title">Rakenduse teema</string>
|
<string name="app_theme_title">Rakenduse teema</string>
|
||||||
<string name="app_theme_appearance">Välimus</string>
|
<string name="app_theme_appearance">Välimus</string>
|
||||||
<string name="app_theme_contrast">Kontrast</string>
|
<string name="app_theme_contrast">Kontrast</string>
|
||||||
|
|
@ -828,7 +835,7 @@
|
||||||
<string name="content_desc_app_theme">Rakenduse teema</string>
|
<string name="content_desc_app_theme">Rakenduse teema</string>
|
||||||
<string name="content_desc_app_icon">Rakenduse ikoon</string>
|
<string name="content_desc_app_icon">Rakenduse ikoon</string>
|
||||||
<string name="content_desc_device">Seade</string>
|
<string name="content_desc_device">Seade</string>
|
||||||
<string name="content_desc_open_drawer">Avage sahtel</string>
|
<string name="content_desc_open_drawer">Ava sahtel</string>
|
||||||
<string name="content_desc_profile_picture">Profiilipilt</string>
|
<string name="content_desc_profile_picture">Profiilipilt</string>
|
||||||
<string name="content_desc_profile">Profiil</string>
|
<string name="content_desc_profile">Profiil</string>
|
||||||
<string name="content_desc_pro_feature">Pro funktsioon</string>
|
<string name="content_desc_pro_feature">Pro funktsioon</string>
|
||||||
|
|
@ -874,7 +881,7 @@
|
||||||
<string name="tts_tab_cloud_voices">Pilve hääled</string>
|
<string name="tts_tab_cloud_voices">Pilve hääled</string>
|
||||||
<string name="tts_tab_device_voices">Seadme hääled</string>
|
<string name="tts_tab_device_voices">Seadme hääled</string>
|
||||||
<string name="tts_tab_cloud_cache">Pilve vahemälu</string>
|
<string name="tts_tab_cloud_cache">Pilve vahemälu</string>
|
||||||
<string name="tts_select_cloud_voice">Valige Kvaliteetne pilvehääl</string>
|
<string name="tts_select_cloud_voice">Vali Kvaliteetne pilvehääl</string>
|
||||||
<string name="tts_clear_samples">Puhasta proovid</string>
|
<string name="tts_clear_samples">Puhasta proovid</string>
|
||||||
<string name="tts_system_default_voice">Süsteemi vaikehääl</string>
|
<string name="tts_system_default_voice">Süsteemi vaikehääl</string>
|
||||||
<string name="tts_uses_device_settings">Kasutab seadme sätteid</string>
|
<string name="tts_uses_device_settings">Kasutab seadme sätteid</string>
|
||||||
|
|
@ -913,11 +920,11 @@
|
||||||
<string name="legal_by_purchasing">Ostes,</string>
|
<string name="legal_by_purchasing">Ostes,</string>
|
||||||
<string name="dialog_out_of_credits_title">Krediidid otsas</string>
|
<string name="dialog_out_of_credits_title">Krediidid otsas</string>
|
||||||
<string name="dialog_out_of_credits_desc">Teil ei ole piisavalt krediiti\' Hangi Episteme Pro 10 tasuta kokkuvõtet päevas või lisage krediiti, et kasutada kokkuvõtteid, Cloud TTS ja loo kokkuvõte.</string>
|
<string name="dialog_out_of_credits_desc">Teil ei ole piisavalt krediiti\' Hangi Episteme Pro 10 tasuta kokkuvõtet päevas või lisage krediiti, et kasutada kokkuvõtteid, Cloud TTS ja loo kokkuvõte.</string>
|
||||||
<string name="action_get_pro_or_add_credits">Hankige Pro / lisage krediiti</string>
|
<string name="action_get_pro_or_add_credits">Hangi Pro / lisa krediiti</string>
|
||||||
<string name="dialog_unlock_page_summarization">Avage lehe kokkuvõte</string>
|
<string name="dialog_unlock_page_summarization">Ava lehe kokkuvõte</string>
|
||||||
<string name="dialog_unlock_page_summarization_desc">Hankige täpseid kokkuvõtteid mis tahes lehekülje kohta, millel on Episteme Pro. Selle funktsiooni kasutamise alustamiseks uuendage.</string>
|
<string name="dialog_unlock_page_summarization_desc">Hangi Episteme Proga täpseid kokkuvõtteid mis tahes lehekülje kohta. Uuenda, et seda funktsiooni kasutada.</string>
|
||||||
<string name="dialog_download_bubble_zoom_model">Laadige alla Bubble Zoom mudel</string>
|
<string name="dialog_download_bubble_zoom_model">Laadi alla Bubble Zoom mudel</string>
|
||||||
<string name="dialog_download_bubble_zoom_model_desc">Funktsiooni Bubble Zoom kasutamiseks kasutage AI mudel tuleb alla laadida (~134 MB). Kas soovite selle kohe alla laadida?</string>
|
<string name="dialog_download_bubble_zoom_model_desc">Bubble Zoomi kasutamiseks tuleb alla laadida AI-mudel (~134 MB). Kas laadida see kohe alla?</string>
|
||||||
<string name="action_translate">Tõlgi</string>
|
<string name="action_translate">Tõlgi</string>
|
||||||
<string name="pdf_back_to_page_short">Tagasi lk %1$d</string>
|
<string name="pdf_back_to_page_short">Tagasi lk %1$d</string>
|
||||||
<string name="pdf_page_short">Lehekülg %1$d</string>
|
<string name="pdf_page_short">Lehekülg %1$d</string>
|
||||||
|
|
@ -939,7 +946,7 @@
|
||||||
<string name="content_desc_reset_zoom">Lähtestage suum</string>
|
<string name="content_desc_reset_zoom">Lähtestage suum</string>
|
||||||
<string name="content_desc_generate_demo_annotations">Loo demomärkusi</string>
|
<string name="content_desc_generate_demo_annotations">Loo demomärkusi</string>
|
||||||
<string name="tooltip_demo_annotations">Demo annotatsioonid</string>
|
<string name="tooltip_demo_annotations">Demo annotatsioonid</string>
|
||||||
<string name="content_desc_open_pen_playground">Avage pliiatsi mänguväljak</string>
|
<string name="content_desc_open_pen_playground">Ava pliiatsi mänguväljak</string>
|
||||||
<string name="content_desc_new_tab">Uus vaheleht</string>
|
<string name="content_desc_new_tab">Uus vaheleht</string>
|
||||||
<string name="content_desc_highlight_all_text">Tõstke esile kogu tekst</string>
|
<string name="content_desc_highlight_all_text">Tõstke esile kogu tekst</string>
|
||||||
<string name="content_desc_toggle_editing_mode">Lülitage redigeerimisrežiim sisse</string>
|
<string name="content_desc_toggle_editing_mode">Lülitage redigeerimisrežiim sisse</string>
|
||||||
|
|
@ -949,7 +956,7 @@
|
||||||
<string name="ocr_language_devanagari">hindi, marati, sanskriti + inglise keel</string>
|
<string name="ocr_language_devanagari">hindi, marati, sanskriti + inglise keel</string>
|
||||||
<string name="ocr_language_chinese">hiina + inglise keel</string>
|
<string name="ocr_language_chinese">hiina + inglise keel</string>
|
||||||
<string name="ocr_language_japanese">jaapani + inglise keel</string>
|
<string name="ocr_language_japanese">jaapani + inglise keel</string>
|
||||||
<string name="ocr_language_korean">Korea + inglise keel</string>
|
<string name="ocr_language_korean">korea + inglise keel</string>
|
||||||
<string name="msg_page_unavailable">Leht pole saadaval</string>
|
<string name="msg_page_unavailable">Leht pole saadaval</string>
|
||||||
<string name="default_document_title">Dokument</string>
|
<string name="default_document_title">Dokument</string>
|
||||||
<string name="generated_author">Loodud</string>
|
<string name="generated_author">Loodud</string>
|
||||||
|
|
@ -968,7 +975,7 @@
|
||||||
<string name="content_desc_play_pause">Esita/Paus</string>
|
<string name="content_desc_play_pause">Esita/Paus</string>
|
||||||
<string name="content_desc_reset_speed">Lähtestage kiirus</string>
|
<string name="content_desc_reset_speed">Lähtestage kiirus</string>
|
||||||
<string name="content_desc_reset_pitch">Lähtesta helikõrgus</string>
|
<string name="content_desc_reset_pitch">Lähtesta helikõrgus</string>
|
||||||
<string name="dialog_select_color">Valige Värv</string>
|
<string name="dialog_select_color">Vali Värv</string>
|
||||||
<string name="msg_book_no_content">Sellel raamatul pole kuvatavat sisu.</string>
|
<string name="msg_book_no_content">Sellel raamatul pole kuvatavat sisu.</string>
|
||||||
<string name="clip_label_copied_link">Kopeeritud link</string>
|
<string name="clip_label_copied_link">Kopeeritud link</string>
|
||||||
<string name="clip_label_copied_text">Kopeeritud tekst</string>
|
<string name="clip_label_copied_text">Kopeeritud tekst</string>
|
||||||
|
|
@ -1000,8 +1007,8 @@
|
||||||
<string name="pdf_error_document_not_loaded">Dokumenti ei laaditud.</string>
|
<string name="pdf_error_document_not_loaded">Dokumenti ei laaditud.</string>
|
||||||
<string name="ai_error_offline_oss">AI funktsioonid pole võrguühenduseta saadaval OSS ehitada.</string>
|
<string name="ai_error_offline_oss">AI funktsioonid pole võrguühenduseta saadaval OSS ehitada.</string>
|
||||||
<string name="ai_error_blocked_safety">Turvakaalutlustel blokeeritud.</string>
|
<string name="ai_error_blocked_safety">Turvakaalutlustel blokeeritud.</string>
|
||||||
<string name="ai_error_choose_model">Valige mudel %1$s aastal AI võtme ja mudeli sätted.</string>
|
<string name="ai_error_choose_model">Vali mudel %1$s AI võtmete ja mudelite sätetes.</string>
|
||||||
<string name="ai_error_add_provider_key">Lisage a %1$s API sisestage AI võtme ja mudeli sätted.</string>
|
<string name="ai_error_add_provider_key">Lisa %1$s API-võti AI võtmete ja mudelite sätetes.</string>
|
||||||
<string name="ai_error_provider_empty_response">AI pakkuja andis tühja vastuse.</string>
|
<string name="ai_error_provider_empty_response">AI pakkuja andis tühja vastuse.</string>
|
||||||
<string name="ai_error_provider_error">AI pakkuja viga: %1$d. %2$s</string>
|
<string name="ai_error_provider_error">AI pakkuja viga: %1$d. %2$s</string>
|
||||||
<string name="ai_error_gemini_required_for_image_summary">See kokkuvõte vajab Gemini mudelit, kuna valitud Groqi mudelid ei toeta PDF/pildisisendit.</string>
|
<string name="ai_error_gemini_required_for_image_summary">See kokkuvõte vajab Gemini mudelit, kuna valitud Groqi mudelid ei toeta PDF/pildisisendit.</string>
|
||||||
|
|
@ -1045,7 +1052,7 @@
|
||||||
<string name="ai_settings_recaps_desc">Kasutatakse lugude kokkuvõtete genereerimiseks.</string>
|
<string name="ai_settings_recaps_desc">Kasutatakse lugude kokkuvõtete genereerimiseks.</string>
|
||||||
<string name="ai_settings_cloud_tts_desc">Kasutab salvestatud Gemini võti. Ainult %1$s on praegu toetatud.</string>
|
<string name="ai_settings_cloud_tts_desc">Kasutab salvestatud Gemini võti. Ainult %1$s on praegu toetatud.</string>
|
||||||
<string name="dialog_save_provider_key">Salvesta %1$s võti?</string>
|
<string name="dialog_save_provider_key">Salvesta %1$s võti?</string>
|
||||||
<string name="dialog_save_key_desc">Pärast salvestamist on nähtavad ainult esimesed 3 ja 3 viimast tähemärki. Kui soovite seda hiljem muuta, asendage see või kustutage see.</string>
|
<string name="dialog_save_key_desc">Pärast salvestamist on nähtavad ainult esimesed 3 ja viimased 3 märki. Hiljem muutmiseks asenda või kustuta võti.</string>
|
||||||
<string name="dialog_delete_provider_key">Kustuta %1$s võti?</string>
|
<string name="dialog_delete_provider_key">Kustuta %1$s võti?</string>
|
||||||
<string name="dialog_delete_key_desc">Seda teenusepakkujat kasutavad funktsioonid lakkavad töötamast kuni uue võtme salvestamiseni.</string>
|
<string name="dialog_delete_key_desc">Seda teenusepakkujat kasutavad funktsioonid lakkavad töötamast kuni uue võtme salvestamiseni.</string>
|
||||||
<string name="ai_settings_no_key_saved">Võti pole salvestatud</string>
|
<string name="ai_settings_no_key_saved">Võti pole salvestatud</string>
|
||||||
|
|
@ -1058,7 +1065,7 @@
|
||||||
<string name="theme_textured">Tekstuuriga</string>
|
<string name="theme_textured">Tekstuuriga</string>
|
||||||
<string name="theme_custom_solid_default">Kohandatud tahke</string>
|
<string name="theme_custom_solid_default">Kohandatud tahke</string>
|
||||||
<string name="theme_custom_textured_default">Kohandatud tekstuuriga</string>
|
<string name="theme_custom_textured_default">Kohandatud tekstuuriga</string>
|
||||||
<string name="theme_select_custom_texture">Valige Kohandatud tekstuur</string>
|
<string name="theme_select_custom_texture">Vali Kohandatud tekstuur</string>
|
||||||
<string name="app_theme_text_brightness_light">Teksti heledus (hele)</string>
|
<string name="app_theme_text_brightness_light">Teksti heledus (hele)</string>
|
||||||
<string name="app_theme_text_brightness_dark">Teksti heledus (tume)</string>
|
<string name="app_theme_text_brightness_dark">Teksti heledus (tume)</string>
|
||||||
<string name="label_default">Vaikimisi</string>
|
<string name="label_default">Vaikimisi</string>
|
||||||
|
|
@ -1094,7 +1101,7 @@
|
||||||
<string name="toolbar_hidden_tools">Peidetud tööriistad</string>
|
<string name="toolbar_hidden_tools">Peidetud tööriistad</string>
|
||||||
<string name="toolbar_more_menu">Rohkem menüüd</string>
|
<string name="toolbar_more_menu">Rohkem menüüd</string>
|
||||||
<string name="toolbar_hidden_tools_menu">Varjatud tööriistad</string>
|
<string name="toolbar_hidden_tools_menu">Varjatud tööriistad</string>
|
||||||
<string name="toolbar_drop_tools_here">Pange tööriistad siia</string>
|
<string name="toolbar_drop_tools_here">Pane tööriistad siia</string>
|
||||||
<string name="content_desc_drag_to_reorder">Lohistage ümberjärjestamiseks</string>
|
<string name="content_desc_drag_to_reorder">Lohistage ümberjärjestamiseks</string>
|
||||||
<string name="tool_external_apps">Välised rakendused</string>
|
<string name="tool_external_apps">Välised rakendused</string>
|
||||||
<string name="tool_navigation_slider">Navigeerimisliugur</string>
|
<string name="tool_navigation_slider">Navigeerimisliugur</string>
|
||||||
|
|
@ -1145,7 +1152,7 @@
|
||||||
<string name="book_replacements_empty_replacement">tühi tekst</string>
|
<string name="book_replacements_empty_replacement">tühi tekst</string>
|
||||||
<string name="language_dutch">Holland (hollandi)</string>
|
<string name="language_dutch">Holland (hollandi)</string>
|
||||||
<string name="language_ukrainian">Українська (ukraina)</string>
|
<string name="language_ukrainian">Українська (ukraina)</string>
|
||||||
<string name="language_indonesian">indoneesia (indoneesia)</string>
|
<string name="language_indonesian">Bahasa Indonesia (indoneesia)</string>
|
||||||
<string name="desktop_about">Umbes</string>
|
<string name="desktop_about">Umbes</string>
|
||||||
<string name="desktop_about_subtitle">Lauaarvuti lugeja</string>
|
<string name="desktop_about_subtitle">Lauaarvuti lugeja</string>
|
||||||
<string name="desktop_access">Juurdepääs töölauale</string>
|
<string name="desktop_access">Juurdepääs töölauale</string>
|
||||||
|
|
@ -1159,10 +1166,10 @@
|
||||||
<string name="desktop_cache_format">Vahemälu: %1$s</string>
|
<string name="desktop_cache_format">Vahemälu: %1$s</string>
|
||||||
<string name="desktop_cached">Vahemällu salvestatud</string>
|
<string name="desktop_cached">Vahemällu salvestatud</string>
|
||||||
<string name="desktop_cached_summary">Vahemällu salvestatud kokkuvõte</string>
|
<string name="desktop_cached_summary">Vahemällu salvestatud kokkuvõte</string>
|
||||||
<string name="desktop_choose_cloud_tts_voice">Valige Gemini hääl, mida kasutatakse pilve ettelugemiseks.</string>
|
<string name="desktop_choose_cloud_tts_voice">Vali Gemini hääl, mida kasutatakse pilve ettelugemiseks.</string>
|
||||||
<string name="desktop_clear_book_cache_desc">Kustutage loodud töölauaraamat ja EPUB lehekülgede vahemälu failid? Järgmisel raamatute avamisel luuakse need uuesti.</string>
|
<string name="desktop_clear_book_cache_desc">Kustutage loodud töölauaraamat ja EPUB lehekülgede vahemälu failid? Järgmisel raamatute avamisel luuakse need uuesti.</string>
|
||||||
<string name="desktop_clear_voice_cache">Tühjendage hääle vahemälu</string>
|
<string name="desktop_clear_voice_cache">Tühjenda hääle vahemälu</string>
|
||||||
<string name="desktop_close_tools">Sulgege tööriistad</string>
|
<string name="desktop_close_tools">Sulge tööriistad</string>
|
||||||
<string name="desktop_cloud_sync">Pilvesünkroonimine</string>
|
<string name="desktop_cloud_sync">Pilvesünkroonimine</string>
|
||||||
<string name="desktop_cloud_tts_needs_gemini">Pilv TTS vajab Gemini</string>
|
<string name="desktop_cloud_tts_needs_gemini">Pilv TTS vajab Gemini</string>
|
||||||
<string name="desktop_cloud_tts_needs_signed_in_credits">Pilv TTS vajab sisselogitud krediiti</string>
|
<string name="desktop_cloud_tts_needs_signed_in_credits">Pilv TTS vajab sisselogitud krediiti</string>
|
||||||
|
|
@ -1179,12 +1186,12 @@
|
||||||
<string name="desktop_custom_fonts_desc">Imporditud fondid lugeja jaoks</string>
|
<string name="desktop_custom_fonts_desc">Imporditud fondid lugeja jaoks</string>
|
||||||
<string name="desktop_delete_font">Kustuta font</string>
|
<string name="desktop_delete_font">Kustuta font</string>
|
||||||
<string name="desktop_delete_font_desc">Kustuta %1$s? Seda kasutavad raamatud naasevad vaikefondile.</string>
|
<string name="desktop_delete_font_desc">Kustuta %1$s? Seda kasutavad raamatud naasevad vaikefondile.</string>
|
||||||
<string name="desktop_delete_shelf_desc">Kas kustutada \"%1$s\"? Raamatud jäävad teie raamatukogusse.</string>
|
<string name="desktop_delete_shelf_desc">Kas kustutada \"%1$s\"? Raamatud jäävad sinu raamatukogusse.</string>
|
||||||
<string name="desktop_delete_summary">Kustuta kokkuvõte</string>
|
<string name="desktop_delete_summary">Kustuta kokkuvõte</string>
|
||||||
<string name="desktop_disabled">Keelatud</string>
|
<string name="desktop_disabled">Keelatud</string>
|
||||||
<string name="desktop_drop_files_to_import">Pukseerige failid importimiseks</string>
|
<string name="desktop_drop_files_to_import">Pukseeri failid importimiseks</string>
|
||||||
<string name="desktop_drop_supported_files_to_import">Eemaldage importimiseks toetatud failid</string>
|
<string name="desktop_drop_supported_files_to_import">Eemalda importimiseks toetatud failid</string>
|
||||||
<string name="desktop_email_support_desc">Kui soovite midagi muud, võtke meiega otse e-posti teel ühendust.</string>
|
<string name="desktop_email_support_desc">Kui vajad midagi muud, võta meiega otse e-posti teel ühendust.</string>
|
||||||
<string name="desktop_equals">Võrdub</string>
|
<string name="desktop_equals">Võrdub</string>
|
||||||
<string name="desktop_extras">Lisad</string>
|
<string name="desktop_extras">Lisad</string>
|
||||||
<string name="desktop_feedback">Tagasiside</string>
|
<string name="desktop_feedback">Tagasiside</string>
|
||||||
|
|
@ -1195,36 +1202,36 @@
|
||||||
<string name="desktop_free_remaining_format">Tasuta, %1$d vasakule</string>
|
<string name="desktop_free_remaining_format">Tasuta, %1$d vasakule</string>
|
||||||
<string name="desktop_generate_recap">Loo kokkuvõte</string>
|
<string name="desktop_generate_recap">Loo kokkuvõte</string>
|
||||||
<string name="desktop_generate_summary">Loo kokkuvõte</string>
|
<string name="desktop_generate_summary">Loo kokkuvõte</string>
|
||||||
<string name="desktop_get_in_touch_desc">Teatage vigadest, taotlege funktsioone või võtke otse ühendust toega.</string>
|
<string name="desktop_get_in_touch_desc">Teata vigadest, taotle funktsioone või võta toega otse ühendust.</string>
|
||||||
<string name="desktop_github_sponsors">GitHubi sponsorid</string>
|
<string name="desktop_github_sponsors">GitHubi sponsorid</string>
|
||||||
<string name="desktop_github_sponsors_desc">Toetage arengut GitHubi sponsorite kaudu.</string>
|
<string name="desktop_github_sponsors_desc">Toeta arendust GitHub Sponsorsi kaudu.</string>
|
||||||
<string name="desktop_google_sign_in_not_configured">Google sisselogimine pole selle töölauajärgu jaoks konfigureeritud.</string>
|
<string name="desktop_google_sign_in_not_configured">Google sisselogimine pole selle töölauajärgu jaoks konfigureeritud.</string>
|
||||||
<string name="desktop_greater_than">Suurem kui</string>
|
<string name="desktop_greater_than">Suurem kui</string>
|
||||||
<string name="desktop_help">Abi</string>
|
<string name="desktop_help">Abi</string>
|
||||||
<string name="desktop_help_feedback_desc">Veaaruanded, funktsioonitaotlused ja tugi</string>
|
<string name="desktop_help_feedback_desc">Veaaruanded, funktsioonitaotlused ja tugi</string>
|
||||||
<string name="desktop_hide">Peida</string>
|
<string name="desktop_hide">Peida</string>
|
||||||
<string name="desktop_import_files">Importige faile</string>
|
<string name="desktop_import_files">Impordi faile</string>
|
||||||
<string name="desktop_issues">Probleemid</string>
|
<string name="desktop_issues">Probleemid</string>
|
||||||
<string name="desktop_issues_desc">Avage probleemide jälgija vigade ja funktsioonitaotluste jaoks.</string>
|
<string name="desktop_issues_desc">Ava probleemide jälgija vigade ja funktsioonisoovide jaoks.</string>
|
||||||
<string name="desktop_less_than">Vähem kui</string>
|
<string name="desktop_less_than">Vähem kui</string>
|
||||||
<string name="desktop_library_and_reader">Raamatukogu ja lugeja</string>
|
<string name="desktop_library_and_reader">Raamatukogu ja lugeja</string>
|
||||||
<string name="desktop_match_any">Ükskõik milline</string>
|
<string name="desktop_match_any">Ükskõik milline</string>
|
||||||
<string name="desktop_more_library_actions">Raamatukogu tegevused</string>
|
<string name="desktop_more_library_actions">Raamatukogu tegevused</string>
|
||||||
<string name="desktop_more_menu">Rohkem</string>
|
<string name="desktop_more_menu">Rohkem</string>
|
||||||
<string name="desktop_no_cached_summaries_book">Selle raamatu kohta pole veel vahemällu salvestatud kokkuvõtteid.</string>
|
<string name="desktop_no_cached_summaries_book">Selle raamatu kohta pole veel vahemällu salvestatud kokkuvõtteid.</string>
|
||||||
<string name="desktop_no_custom_fonts_desc">Importige TTF-, OTF- või WOFF2-faile, et neid raamatutes kasutada.</string>
|
<string name="desktop_no_custom_fonts_desc">Impordi TTF-, OTF- või WOFF2-faile, et neid raamatutes kasutada.</string>
|
||||||
<string name="desktop_no_fonts_matching">Ei leitud fonte, mis vastavad \"%1$s\"</string>
|
<string name="desktop_no_fonts_matching">Ei leitud fonte, mis vastavad \"%1$s\"</string>
|
||||||
<string name="desktop_no_google_account_connected">Nr Google konto on ühendatud.</string>
|
<string name="desktop_no_google_account_connected">Nr Google konto on ühendatud.</string>
|
||||||
<string name="desktop_no_summary_cached_section">Selle jaotise kohta pole vahemällu salvestatud kokkuvõtet.</string>
|
<string name="desktop_no_summary_cached_section">Selle jaotise kohta pole vahemällu salvestatud kokkuvõtet.</string>
|
||||||
<string name="desktop_offline_oss_reader">Võrguühenduseta lauaarvuti lugeja</string>
|
<string name="desktop_offline_oss_reader">Võrguühenduseta lauaarvuti lugeja</string>
|
||||||
<string name="desktop_open_readers">Avatud lugejad</string>
|
<string name="desktop_open_readers">Avatud lugejad</string>
|
||||||
<string name="desktop_opening_title">Avamine %1$s</string>
|
<string name="desktop_opening_title">Avamine %1$s</string>
|
||||||
<string name="desktop_opening_your_library">Teie raamatukogu avamine</string>
|
<string name="desktop_opening_your_library">Raamatukogu avamine</string>
|
||||||
<string name="desktop_operator">Operaator</string>
|
<string name="desktop_operator">Operaator</string>
|
||||||
<string name="desktop_page">Lehekülg</string>
|
<string name="desktop_page">Lehekülg</string>
|
||||||
<string name="desktop_password_protected_pdf">Parooliga kaitstud PDF</string>
|
<string name="desktop_password_protected_pdf">Parooliga kaitstud PDF</string>
|
||||||
<string name="desktop_patreon">Patreon</string>
|
<string name="desktop_patreon">Patreon</string>
|
||||||
<string name="desktop_patreon_desc">Toetage projekti Patreonis.</string>
|
<string name="desktop_patreon_desc">Toeta projekti Patreonis.</string>
|
||||||
<string name="desktop_paused">Peatatud</string>
|
<string name="desktop_paused">Peatatud</string>
|
||||||
<string name="desktop_pdf_password_required_desc">%1$s nõuab enne avamist parooli.</string>
|
<string name="desktop_pdf_password_required_desc">%1$s nõuab enne avamist parooli.</string>
|
||||||
<string name="desktop_pdf_password_required_or_incorrect">Parool on nõutav või vale.</string>
|
<string name="desktop_pdf_password_required_or_incorrect">Parool on nõutav või vale.</string>
|
||||||
|
|
@ -1269,13 +1276,13 @@
|
||||||
<string name="desktop_view">Vaade</string>
|
<string name="desktop_view">Vaade</string>
|
||||||
<string name="desktop_voice_cache">Hääle vahemälu</string>
|
<string name="desktop_voice_cache">Hääle vahemälu</string>
|
||||||
<string name="desktop_webview_preparing">Manustatud veebivaate ettevalmistamine…</string>
|
<string name="desktop_webview_preparing">Manustatud veebivaate ettevalmistamine…</string>
|
||||||
<string name="desktop_webview_preparing_progress">Preparing bundled embedded webview %1$d%%</string>
|
<string name="desktop_webview_preparing_progress">Pakitud manustatud veebivaate ettevalmistamine %1$d%%</string>
|
||||||
<string name="desktop_webview_restart_required">Manustatud veebivaade installitud. Taaskäivitage Episteme seadistamise lõpetamiseks.</string>
|
<string name="desktop_webview_restart_required">Manustatud veebivaade installitud. Taaskäivitage Episteme seadistamise lõpetamiseks.</string>
|
||||||
<string name="desktop_webview_start_error">Embedded webview could not start: %1$s</string>
|
<string name="desktop_webview_start_error">Manustatud veebivaadet ei saanud käivitada: %1$s</string>
|
||||||
<string name="desktop_working">Töötab…</string>
|
<string name="desktop_working">Töötab…</string>
|
||||||
<string name="desktop_workspace">Tööruum</string>
|
<string name="desktop_workspace">Tööruum</string>
|
||||||
<string name="desktop_add_to_shelf">Lisa riiulile</string>
|
<string name="desktop_add_to_shelf">Lisa riiulile</string>
|
||||||
<string name="desktop_create_shelf_first">Create a shelf first, then add selected books to it.</string>
|
<string name="desktop_create_shelf_first">Loo esmalt riiul ja lisa siis valitud raamatud sinna.</string>
|
||||||
<string name="desktop_create_theme">Loo teema</string>
|
<string name="desktop_create_theme">Loo teema</string>
|
||||||
<string name="desktop_existing_tags_format">Olemasolev: %1$s</string>
|
<string name="desktop_existing_tags_format">Olemasolev: %1$s</string>
|
||||||
<string name="desktop_external_link_desc">Klõpsasite välisel lingil.</string>
|
<string name="desktop_external_link_desc">Klõpsasite välisel lingil.</string>
|
||||||
|
|
@ -1291,20 +1298,20 @@
|
||||||
<string name="desktop_annotation_options">Märkuste valikud</string>
|
<string name="desktop_annotation_options">Märkuste valikud</string>
|
||||||
<string name="desktop_annotation_tools">Märkuste tegemise tööriistad</string>
|
<string name="desktop_annotation_tools">Märkuste tegemise tööriistad</string>
|
||||||
<string name="desktop_assist">Abi</string>
|
<string name="desktop_assist">Abi</string>
|
||||||
<string name="desktop_choose_pdf_to_save">Valige, milline PDF päästa.</string>
|
<string name="desktop_choose_pdf_to_save">Vali, milline PDF päästa.</string>
|
||||||
<string name="desktop_clear_jump_history">Tühjenda hüppeajalugu</string>
|
<string name="desktop_clear_jump_history">Tühjenda hüppeajalugu</string>
|
||||||
<string name="desktop_cloud_tts_failed">Pilv TTS ebaõnnestunud.</string>
|
<string name="desktop_cloud_tts_failed">Pilv TTS ebaõnnestunud.</string>
|
||||||
<string name="desktop_cloud_tts_needs_gemini_key_desc">Lisage a Gemini klahvi ja valige Gemini pilv TTS aastal AI võtmed ja mudelid.</string>
|
<string name="desktop_cloud_tts_needs_gemini_key_desc">Lisa Gemini võti ja vali Gemini pilve-TTS jaotises AI võtmed ja mudelid.</string>
|
||||||
<string name="desktop_cloud_tts_not_configured_desc">Pilv TTS pole selle töölaua järgu jaoks konfigureeritud.</string>
|
<string name="desktop_cloud_tts_not_configured_desc">Pilv TTS pole selle töölaua järgu jaoks konfigureeritud.</string>
|
||||||
<string name="desktop_cloud_tts_sign_in_required_desc">Logige sisse rakendusega Google to use cloud TTS.</string>
|
<string name="desktop_cloud_tts_sign_in_required_desc">Pilve-TTS-i kasutamiseks logi Google’iga sisse.</string>
|
||||||
<string name="desktop_cloud_tts_signed_in_credits_required_desc">Pilv TTS needs a signed-in account with credits. Pro ja krediite saab osta ainult veebisaidilt Android rakendus.</string>
|
<string name="desktop_cloud_tts_signed_in_credits_required_desc">Pilve-TTS vajab sisselogitud krediitidega kontot. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
|
||||||
<string name="desktop_color">Värv</string>
|
<string name="desktop_color">Värv</string>
|
||||||
<string name="desktop_comment_options">Kommentaaride valikud</string>
|
<string name="desktop_comment_options">Kommentaaride valikud</string>
|
||||||
<string name="desktop_custom_theme_default">Kohandatud</string>
|
<string name="desktop_custom_theme_default">Kohandatud</string>
|
||||||
<string name="desktop_delete_annotation_desc">See eemaldab märkuse sellelt PDF.</string>
|
<string name="desktop_delete_annotation_desc">See eemaldab märkuse sellelt PDF.</string>
|
||||||
<string name="desktop_delete_annotation_title">Kas kustutada märkus?</string>
|
<string name="desktop_delete_annotation_title">Kas kustutada märkus?</string>
|
||||||
<string name="desktop_document_text">Dokumendi tekst</string>
|
<string name="desktop_document_text">Dokumendi tekst</string>
|
||||||
<string name="desktop_embedded_pdf_comment">Manustatud PDF kommenteerida</string>
|
<string name="desktop_embedded_pdf_comment">Manustatud PDF-i kommentaar</string>
|
||||||
<string name="desktop_failed_render_page">Lehe renderdamine ebaõnnestus.</string>
|
<string name="desktop_failed_render_page">Lehe renderdamine ebaõnnestus.</string>
|
||||||
<string name="desktop_feature_unavailable">Funktsioon pole saadaval</string>
|
<string name="desktop_feature_unavailable">Funktsioon pole saadaval</string>
|
||||||
<string name="desktop_finished">Valmis</string>
|
<string name="desktop_finished">Valmis</string>
|
||||||
|
|
@ -1330,14 +1337,14 @@
|
||||||
<string name="desktop_no_text_to_summarize">There is no text to summarize.</string>
|
<string name="desktop_no_text_to_summarize">There is no text to summarize.</string>
|
||||||
<string name="desktop_open_comment">Ava kommentaar</string>
|
<string name="desktop_open_comment">Ava kommentaar</string>
|
||||||
<string name="desktop_out_of_credits_android_purchase_desc">Krediidid otsas. Pro ja krediite saab osta ainult Android rakendus.</string>
|
<string name="desktop_out_of_credits_android_purchase_desc">Krediidid otsas. Pro ja krediite saab osta ainult Android rakendus.</string>
|
||||||
<string name="desktop_out_of_credits_cloud_tts_desc">Pilve kasutamine TTS needs credits on desktop. Pro ja krediite saab osta ainult veebisaidilt Android rakendus.</string>
|
<string name="desktop_out_of_credits_cloud_tts_desc">Pilve-TTS vajab töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
|
||||||
<string name="desktop_out_of_credits_generic_feature_desc">Using this feature needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus.</string>
|
<string name="desktop_out_of_credits_generic_feature_desc">See funktsioon vajab töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
|
||||||
<string name="desktop_out_of_credits_recaps_desc">Using recaps needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus.</string>
|
<string name="desktop_out_of_credits_recaps_desc">Kokkuvõtted vajavad töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
|
||||||
<string name="desktop_out_of_credits_summaries_desc">Using summaries needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus.</string>
|
<string name="desktop_out_of_credits_summaries_desc">Kokkuvõtted vajavad töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
|
||||||
<string name="desktop_pan">Pan</string>
|
<string name="desktop_pan">Panoraami</string>
|
||||||
<string name="desktop_pdf_action_failed">PDF tegevus ebaõnnestus</string>
|
<string name="desktop_pdf_action_failed">PDF-i toiming ebaõnnestus</string>
|
||||||
<string name="desktop_pdf_action_failed_desc">PDF action could not be completed.</string>
|
<string name="desktop_pdf_action_failed_desc">PDF-i toimingut ei saanud lõpule viia.</string>
|
||||||
<string name="desktop_pdf_comment">PDF kommenteerida</string>
|
<string name="desktop_pdf_comment">PDF-i kommentaar</string>
|
||||||
<string name="desktop_pdf_compact_page_number">lk. %1$d</string>
|
<string name="desktop_pdf_compact_page_number">lk. %1$d</string>
|
||||||
<string name="desktop_pdf_page_content_desc">PDF leht %1$d</string>
|
<string name="desktop_pdf_page_content_desc">PDF leht %1$d</string>
|
||||||
<string name="desktop_pdf_page_author_format">Lehekülg %1$d - %2$s</string>
|
<string name="desktop_pdf_page_author_format">Lehekülg %1$d - %2$s</string>
|
||||||
|
|
@ -1359,15 +1366,15 @@
|
||||||
<string name="desktop_remove_gap_between_pages_desc">Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel.</string>
|
<string name="desktop_remove_gap_between_pages_desc">Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel.</string>
|
||||||
<string name="desktop_round_highlighter">Ümmargune highlighter</string>
|
<string name="desktop_round_highlighter">Ümmargune highlighter</string>
|
||||||
<string name="desktop_saved_to_path_format">Salvestatud asukohta %1$s</string>
|
<string name="desktop_saved_to_path_format">Salvestatud asukohta %1$s</string>
|
||||||
<string name="desktop_scroll">Kerige</string>
|
<string name="desktop_scroll">Keri</string>
|
||||||
<string name="desktop_search_in_pdf">Otsi: PDF</string>
|
<string name="desktop_search_in_pdf">Otsi: PDF</string>
|
||||||
<string name="desktop_select_text">Valige tekst</string>
|
<string name="desktop_select_text">Vali tekst</string>
|
||||||
<string name="desktop_selected_annotation_format">Valitud %1$s</string>
|
<string name="desktop_selected_annotation_format">Valitud %1$s</string>
|
||||||
<string name="desktop_show_search_results">Kuva otsingutulemused</string>
|
<string name="desktop_show_search_results">Kuva otsingutulemused</string>
|
||||||
<string name="desktop_sign_in_required_generic_feature_desc">Logige sisse rakendusega Google selle funktsiooni kasutamiseks töölaual.</string>
|
<string name="desktop_sign_in_required_generic_feature_desc">Logi sisse rakendusega Google selle funktsiooni kasutamiseks töölaual.</string>
|
||||||
<string name="desktop_sign_in_required_multi_word_dictionary_desc">Logige sisse rakendusega Google mitmesõnalise nutika sõnastiku kasutamiseks töölaual.</string>
|
<string name="desktop_sign_in_required_multi_word_dictionary_desc">Logi sisse rakendusega Google mitmesõnalise nutika sõnastiku kasutamiseks töölaual.</string>
|
||||||
<string name="desktop_sign_in_required_recaps_desc">Logige sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
|
<string name="desktop_sign_in_required_recaps_desc">Logi sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
|
||||||
<string name="desktop_sign_in_required_summaries_desc">Logige sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
|
<string name="desktop_sign_in_required_summaries_desc">Logi sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
|
||||||
<string name="desktop_stopped">Peatatud</string>
|
<string name="desktop_stopped">Peatatud</string>
|
||||||
<string name="desktop_text_note">Tekstimärkus</string>
|
<string name="desktop_text_note">Tekstimärkus</string>
|
||||||
<string name="desktop_text_note_lowercase">tekstimärkus</string>
|
<string name="desktop_text_note_lowercase">tekstimärkus</string>
|
||||||
|
|
@ -1394,13 +1401,13 @@
|
||||||
<string name="desktop_book_badge_folder">Kaust</string>
|
<string name="desktop_book_badge_folder">Kaust</string>
|
||||||
<string name="desktop_browse">Sirvige</string>
|
<string name="desktop_browse">Sirvige</string>
|
||||||
<string name="desktop_categories">Kategooriad</string>
|
<string name="desktop_categories">Kategooriad</string>
|
||||||
<string name="desktop_chapter_short_format">Ch. %1$d</string>
|
<string name="desktop_chapter_short_format">Ptk %1$d</string>
|
||||||
<string name="desktop_chapter_turns">Peatükk Pöörded</string>
|
<string name="desktop_chapter_turns">Peatüki pöörded</string>
|
||||||
<string name="desktop_choose_font">Valige font</string>
|
<string name="desktop_choose_font">Vali font</string>
|
||||||
<string name="desktop_choose_reader_texture">Valige lugeja tekstuur</string>
|
<string name="desktop_choose_reader_texture">Vali lugeja tekstuur</string>
|
||||||
<string name="desktop_clear_file_types">Kustuta failitüübid</string>
|
<string name="desktop_clear_file_types">Kustuta failitüübid</string>
|
||||||
<string name="desktop_clear_page_annotations">Lehekülje märkuste kustutamine</string>
|
<string name="desktop_clear_page_annotations">Lehekülje märkuste kustutamine</string>
|
||||||
<string name="desktop_clear_sources">Selged allikad</string>
|
<string name="desktop_clear_sources">Tühjenda allikad</string>
|
||||||
<string name="desktop_clear_status">Tühjenda olek</string>
|
<string name="desktop_clear_status">Tühjenda olek</string>
|
||||||
<string name="desktop_clear_tags">Tühjenda sildid</string>
|
<string name="desktop_clear_tags">Tühjenda sildid</string>
|
||||||
<string name="desktop_close_reader">Sule lugeja</string>
|
<string name="desktop_close_reader">Sule lugeja</string>
|
||||||
|
|
@ -1427,16 +1434,16 @@
|
||||||
<string name="desktop_label_pair_format">%1$s - %2$s</string>
|
<string name="desktop_label_pair_format">%1$s - %2$s</string>
|
||||||
<string name="desktop_hide_filters">Peida filtrid</string>
|
<string name="desktop_hide_filters">Peida filtrid</string>
|
||||||
<string name="desktop_hide_reader_tools">Peida lugeja tööriistad</string>
|
<string name="desktop_hide_reader_tools">Peida lugeja tööriistad</string>
|
||||||
<string name="desktop_highlight_palette_hint">Puudutage pesa ja seejärel valige värv.</string>
|
<string name="desktop_highlight_palette_hint">Puuduta pesa ja vali värv.</string>
|
||||||
<string name="desktop_home_subtitle">Jätkake lugemist ja hiljutisi raamatuid</string>
|
<string name="desktop_home_subtitle">Jätka lugemist ja vaata hiljutisi raamatuid</string>
|
||||||
<string name="desktop_import_books">Importige raamatuid</string>
|
<string name="desktop_import_books">Impordi raamatuid</string>
|
||||||
<string name="desktop_import_folder">Impordi kaust</string>
|
<string name="desktop_import_folder">Impordi kaust</string>
|
||||||
<string name="desktop_imported_fonts">Imporditud fondid</string>
|
<string name="desktop_imported_fonts">Imporditud fondid</string>
|
||||||
<string name="desktop_import_result_pair">%1$s %2$s</string>
|
<string name="desktop_import_result_pair">%1$s %2$s</string>
|
||||||
<string name="desktop_increase_format">Suurendada %1$s</string>
|
<string name="desktop_increase_format">Suurendada %1$s</string>
|
||||||
<string name="desktop_jump_history">Hüppe ajalugu</string>
|
<string name="desktop_jump_history">Hüppe ajalugu</string>
|
||||||
<string name="desktop_layout_spacing">Paigutus ja vahekaugus</string>
|
<string name="desktop_layout_spacing">Paigutus ja vahekaugus</string>
|
||||||
<string name="desktop_library_empty_desc">Importige failid rakenduste salvestusruumi või lisage failide lugemiseks kaust.</string>
|
<string name="desktop_library_empty_desc">Impordi failid rakenduse salvestusruumi või lisa failide lugemiseks kaust.</string>
|
||||||
<string name="desktop_library_subtitle">Sirvige oma kollektsiooni</string>
|
<string name="desktop_library_subtitle">Sirvige oma kollektsiooni</string>
|
||||||
<string name="desktop_ai_keys">AI võtmed</string>
|
<string name="desktop_ai_keys">AI võtmed</string>
|
||||||
<string name="desktop_library_tab_smart_shelves_count">Nutikas %1$d</string>
|
<string name="desktop_library_tab_smart_shelves_count">Nutikas %1$d</string>
|
||||||
|
|
|
||||||
|
|
@ -1512,4 +1512,6 @@
|
||||||
<string name="tts_replacements_replace_only_spoken">Chỉ thay thế nội dung được đọc</string>
|
<string name="tts_replacements_replace_only_spoken">Chỉ thay thế nội dung được đọc</string>
|
||||||
<string name="tts_replacements_replace_only_spoken_desc">Văn bản trình đọc, tô sáng và vị trí vẫn không đổi.</string>
|
<string name="tts_replacements_replace_only_spoken_desc">Văn bản trình đọc, tô sáng và vị trí vẫn không đổi.</string>
|
||||||
<string name="tts_replacements_summary_format">%1$s -> %2$s</string>
|
<string name="tts_replacements_summary_format">%1$s -> %2$s</string>
|
||||||
|
<string name="error_copy_to_clipboard">Không thể sao chép vào khay nhớ tạm</string>
|
||||||
|
<string name="error_print_password_protected">Không thể in tệp PDF được bảo vệ bằng mật khẩu</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -201,8 +201,13 @@
|
||||||
<string name="external_file_keep">Keep in Library</string>
|
<string name="external_file_keep">Keep in Library</string>
|
||||||
<string name="external_file_delete">Remove</string>
|
<string name="external_file_delete">Remove</string>
|
||||||
<string name="external_file_behavior_ask">Ask Every Time</string>
|
<string name="external_file_behavior_ask">Ask Every Time</string>
|
||||||
|
<string name="external_file_behavior_ask_desc">After closing an externally opened file, ask whether to keep it in the library or remove it.</string>
|
||||||
<string name="external_file_behavior_keep">Always Keep</string>
|
<string name="external_file_behavior_keep">Always Keep</string>
|
||||||
|
<string name="external_file_behavior_keep_desc">Externally opened files are copied into the library and kept after closing.</string>
|
||||||
<string name="external_file_behavior_delete">Always Remove</string>
|
<string name="external_file_behavior_delete">Always Remove</string>
|
||||||
|
<string name="external_file_behavior_delete_desc">Externally opened files are copied for reading, then removed after closing.</string>
|
||||||
|
<string name="external_file_behavior_temporary">Open Temporarily</string>
|
||||||
|
<string name="external_file_behavior_temporary_desc">Open directly from the source app in a temporary reader. Back returns to that app without adding the file to the library.</string>
|
||||||
<string name="options_external_file_behavior">External File Behavior</string>
|
<string name="options_external_file_behavior">External File Behavior</string>
|
||||||
|
|
||||||
<!-- OPDS — "OPDS" is a technical protocol name, do not translate it. -->
|
<!-- OPDS — "OPDS" is a technical protocol name, do not translate it. -->
|
||||||
|
|
@ -480,10 +485,14 @@
|
||||||
<string name="banner_saving_original_pdf">Saving original PDF…</string>
|
<string name="banner_saving_original_pdf">Saving original PDF…</string>
|
||||||
<!-- PDF = file format name — do not translate. -->
|
<!-- PDF = file format name — do not translate. -->
|
||||||
<string name="banner_original_pdf_saved">Original PDF saved successfully.</string>
|
<string name="banner_original_pdf_saved">Original PDF saved successfully.</string>
|
||||||
|
<string name="banner_saving_original_file">Saving original file...</string>
|
||||||
|
<string name="banner_original_file_saved">Original file saved successfully.</string>
|
||||||
|
<string name="error_saving_file">Error saving file: %1$s</string>
|
||||||
<!-- Email/share subject line. %1$s = the name of the file being shared. Example: "Sharing: Dracula.pdf". -->
|
<!-- Email/share subject line. %1$s = the name of the file being shared. Example: "Sharing: Dracula.pdf". -->
|
||||||
<string name="share_subject">Sharing: %1$s</string>
|
<string name="share_subject">Sharing: %1$s</string>
|
||||||
<!-- PDF = file format name — do not translate. -->
|
<!-- PDF = file format name — do not translate. -->
|
||||||
<string name="share_chooser_title">Share PDF</string>
|
<string name="share_chooser_title">Share PDF</string>
|
||||||
|
<string name="share_file_chooser_title">Share file</string>
|
||||||
<!-- Share failure banner. %1$s = error reason string from the system. Example: "Share failed: No app found". -->
|
<!-- Share failure banner. %1$s = error reason string from the system. Example: "Share failed: No app found". -->
|
||||||
<string name="error_share_failed">Share failed: %1$s</string>
|
<string name="error_share_failed">Share failed: %1$s</string>
|
||||||
<!-- Folder sync limit error. %1$d = the maximum number of folders allowed. Example: "Limit reached: Maximum 3 folders allowed." -->
|
<!-- Folder sync limit error. %1$d = the maximum number of folders allowed. Example: "Limit reached: Maximum 3 folders allowed." -->
|
||||||
|
|
@ -1054,6 +1063,8 @@
|
||||||
|
|
||||||
<!-- PdfViewerScreen & General Reader -->
|
<!-- PdfViewerScreen & General Reader -->
|
||||||
<string name="error_open_print_settings">Could not open print settings</string>
|
<string name="error_open_print_settings">Could not open print settings</string>
|
||||||
|
<string name="error_copy_to_clipboard">Could not copy to clipboard</string>
|
||||||
|
<string name="error_print_password_protected">Password protected PDF files cannot be printed</string>
|
||||||
<!-- PDF = file format name — do not translate. -->
|
<!-- PDF = file format name — do not translate. -->
|
||||||
<string name="loading_pdf">Loading PDF…</string>
|
<string name="loading_pdf">Loading PDF…</string>
|
||||||
<!-- PDF = file format name — do not translate. -->
|
<!-- PDF = file format name — do not translate. -->
|
||||||
|
|
@ -1194,6 +1205,7 @@
|
||||||
<string name="language_korean">한국어 (Korean)</string>
|
<string name="language_korean">한국어 (Korean)</string>
|
||||||
<string name="language_hindi">हिन्दी (Hindi)</string>
|
<string name="language_hindi">हिन्दी (Hindi)</string>
|
||||||
<string name="language_chinese_simplified">简体中文 (Chinese, Simplified)</string>
|
<string name="language_chinese_simplified">简体中文 (Chinese, Simplified)</string>
|
||||||
|
<string name="language_estonian">Eesti (Estonian)</string>
|
||||||
|
|
||||||
<!-- App-wide theme controls in HomeScreen.kt. -->
|
<!-- App-wide theme controls in HomeScreen.kt. -->
|
||||||
<string name="app_theme_title">App Theme</string>
|
<string name="app_theme_title">App Theme</string>
|
||||||
|
|
|
||||||
|
|
@ -19,4 +19,5 @@
|
||||||
<locale android:name="ko"/>
|
<locale android:name="ko"/>
|
||||||
<locale android:name="hi"/>
|
<locale android:name="hi"/>
|
||||||
<locale android:name="zh-CN"/>
|
<locale android:name="zh-CN"/>
|
||||||
|
<locale android:name="et"/>
|
||||||
</locale-config>
|
</locale-config>
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,28 @@ class AndroidStringFormatResourcesTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `vietnamese strings cover translatable base resources`() {
|
fun `vietnamese strings cover translatable base resources`() {
|
||||||
|
assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-vi", localeName = "Vietnamese")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `estonian strings cover translatable base resources`() {
|
||||||
|
assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-et", localeName = "Estonian")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertLocaleCoversTranslatableBaseResources(
|
||||||
|
localeDirectory: String,
|
||||||
|
localeName: String
|
||||||
|
) {
|
||||||
val resDirectory = findResDirectory()
|
val resDirectory = findResDirectory()
|
||||||
val baseNames = readResourceNames(
|
val baseNames = readResourceNames(
|
||||||
stringsFile = File(resDirectory, "values/strings.xml"),
|
stringsFile = File(resDirectory, "values/strings.xml"),
|
||||||
includeNonTranslatable = false
|
includeNonTranslatable = false
|
||||||
)
|
)
|
||||||
val vietnameseNames = readResourceNames(File(resDirectory, "values-vi/strings.xml"))
|
val localizedNames = readResourceNames(File(resDirectory, "$localeDirectory/strings.xml"))
|
||||||
val missingNames = baseNames.filterNot { it in vietnameseNames }
|
val missingNames = baseNames.filterNot { it in localizedNames }
|
||||||
|
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"Missing Vietnamese strings:\n${missingNames.joinToString(separator = "\n")}",
|
"Missing $localeName strings:\n${missingNames.joinToString(separator = "\n")}",
|
||||||
missingNames.isEmpty()
|
missingNames.isEmpty()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@ class AppLanguageOptionsTest {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf(
|
listOf(
|
||||||
"en", "ar", "de", "nl", "tr", "fr", "ru", "uk", "be", "es", "pt-BR", "it", "pl",
|
"en", "ar", "de", "nl", "tr", "fr", "ru", "uk", "be", "es", "pt-BR", "it", "pl",
|
||||||
"id", "vi", "ja", "ko", "hi", "zh-CN"
|
"id", "vi", "ja", "ko", "hi", "zh-CN", "et"
|
||||||
),
|
),
|
||||||
supportedAppLanguageOptions.mapNotNull { it.tag }
|
supportedAppLanguageOptions.mapNotNull { it.tag }
|
||||||
)
|
)
|
||||||
assertEquals(R.string.language_chinese_simplified, supportedAppLanguageOptions.last().labelRes)
|
assertEquals(R.string.language_estonian, supportedAppLanguageOptions.last().labelRes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -51,6 +51,7 @@ class AppLanguageOptionsTest {
|
||||||
val vietnamese = supportedAppLanguageOptions.first { it.tag == "vi" }
|
val vietnamese = supportedAppLanguageOptions.first { it.tag == "vi" }
|
||||||
val japanese = supportedAppLanguageOptions.first { it.tag == "ja" }
|
val japanese = supportedAppLanguageOptions.first { it.tag == "ja" }
|
||||||
val korean = supportedAppLanguageOptions.first { it.tag == "ko" }
|
val korean = supportedAppLanguageOptions.first { it.tag == "ko" }
|
||||||
|
val estonian = supportedAppLanguageOptions.first { it.tag == "et" }
|
||||||
|
|
||||||
assertTrue(turkish.matchesLanguageSearch(label = "Türkçe (Turkish)", query = "turkce"))
|
assertTrue(turkish.matchesLanguageSearch(label = "Türkçe (Turkish)", query = "turkce"))
|
||||||
assertTrue(dutch.matchesLanguageSearch(label = "Nederlands", query = "dutch"))
|
assertTrue(dutch.matchesLanguageSearch(label = "Nederlands", query = "dutch"))
|
||||||
|
|
@ -66,6 +67,7 @@ class AppLanguageOptionsTest {
|
||||||
assertTrue(vietnamese.matchesLanguageSearch(label = "Tiếng Việt", query = "tieng viet"))
|
assertTrue(vietnamese.matchesLanguageSearch(label = "Tiếng Việt", query = "tieng viet"))
|
||||||
assertTrue(japanese.matchesLanguageSearch(label = "日本語", query = "nihongo"))
|
assertTrue(japanese.matchesLanguageSearch(label = "日本語", query = "nihongo"))
|
||||||
assertTrue(korean.matchesLanguageSearch(label = "한국어", query = "hangul"))
|
assertTrue(korean.matchesLanguageSearch(label = "한국어", query = "hangul"))
|
||||||
|
assertTrue(estonian.matchesLanguageSearch(label = "Eesti", query = "eesti"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
17
app/src/test/java/com/aryan/reader/ClipboardUtilsTest.kt
Normal file
17
app/src/test/java/com/aryan/reader/ClipboardUtilsTest.kt
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ClipboardUtilsTest {
|
||||||
|
@Test
|
||||||
|
fun `set primary clip reports success`() {
|
||||||
|
assertTrue(setPrimaryClipSafely {})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `set primary clip handles security rejection`() {
|
||||||
|
assertFalse(setPrimaryClipSafely { throw SecurityException("denied") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ExternalFileOpenRouteDeciderTest {
|
||||||
|
@Test
|
||||||
|
fun `temporary behavior routes to temporary activity`() {
|
||||||
|
assertTrue(ExternalFileOpenRouteDecider.shouldOpenTemporary("TEMPORARY"))
|
||||||
|
assertEquals(
|
||||||
|
TemporaryExternalFileActivity::class.java,
|
||||||
|
ExternalFileOpenRouteDecider.targetActivityClass("TEMPORARY")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `existing behaviors route to main activity`() {
|
||||||
|
listOf(null, "ASK", "KEEP", "DELETE").forEach { behavior ->
|
||||||
|
assertFalse(ExternalFileOpenRouteDecider.shouldOpenTemporary(behavior))
|
||||||
|
assertEquals(
|
||||||
|
MainActivity::class.java,
|
||||||
|
ExternalFileOpenRouteDecider.targetActivityClass(behavior)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
@ -22,6 +23,7 @@ import com.aryan.reader.tts.TtsPlaybackManager
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
@ -45,6 +47,7 @@ class MainViewModelTest {
|
||||||
private lateinit var mockApplication: Application
|
private lateinit var mockApplication: Application
|
||||||
private lateinit var mockPrefs: SharedPreferences
|
private lateinit var mockPrefs: SharedPreferences
|
||||||
private lateinit var mockEditor: SharedPreferences.Editor
|
private lateinit var mockEditor: SharedPreferences.Editor
|
||||||
|
private val prefsStringSets = mutableMapOf<String, Set<String>>()
|
||||||
|
|
||||||
private val billingStateFlow = MutableStateFlow(ProUpgradeState())
|
private val billingStateFlow = MutableStateFlow(ProUpgradeState())
|
||||||
private val customFontsFlow = MutableStateFlow<List<CustomFontEntity>>(emptyList())
|
private val customFontsFlow = MutableStateFlow<List<CustomFontEntity>>(emptyList())
|
||||||
|
|
@ -64,6 +67,12 @@ class MainViewModelTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private class TestMainViewModel(application: Application) : MainViewModel(application) {
|
private class TestMainViewModel(application: Application) : MainViewModel(application) {
|
||||||
|
val locallyCleanedBookIds = mutableListOf<String>()
|
||||||
|
|
||||||
|
override suspend fun cleanupBookDataLocally(bookId: String) {
|
||||||
|
locallyCleanedBookIds += bookId
|
||||||
|
}
|
||||||
|
|
||||||
fun clearForTest() {
|
fun clearForTest() {
|
||||||
ViewModel::class.java
|
ViewModel::class.java
|
||||||
.getDeclaredMethod("clear\$lifecycle_viewmodel_release")
|
.getDeclaredMethod("clear\$lifecycle_viewmodel_release")
|
||||||
|
|
@ -83,6 +92,7 @@ class MainViewModelTest {
|
||||||
billingStateFlow.value = ProUpgradeState()
|
billingStateFlow.value = ProUpgradeState()
|
||||||
customFontsFlow.value = emptyList()
|
customFontsFlow.value = emptyList()
|
||||||
ttsStateFlow.value = TtsPlaybackManager.TtsState()
|
ttsStateFlow.value = TtsPlaybackManager.TtsState()
|
||||||
|
prefsStringSets.clear()
|
||||||
|
|
||||||
mockkStatic(Log::class)
|
mockkStatic(Log::class)
|
||||||
every { Log.isLoggable(any(), any()) } returns false
|
every { Log.isLoggable(any(), any()) } returns false
|
||||||
|
|
@ -109,9 +119,14 @@ class MainViewModelTest {
|
||||||
every { mockApplication.filesDir } returns filesDir
|
every { mockApplication.filesDir } returns filesDir
|
||||||
every { mockApplication.cacheDir } returns cacheDir
|
every { mockApplication.cacheDir } returns cacheDir
|
||||||
every { mockApplication.getExternalFilesDir(any()) } returns externalFilesDir
|
every { mockApplication.getExternalFilesDir(any()) } returns externalFilesDir
|
||||||
|
every { mockApplication.getString(any()) } answers { "res-${firstArg<Int>()}" }
|
||||||
|
every { mockApplication.getString(any(), *anyVararg()) } answers { "res-${firstArg<Int>()}" }
|
||||||
every { mockPrefs.edit() } returns mockEditor
|
every { mockPrefs.edit() } returns mockEditor
|
||||||
|
|
||||||
every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? }
|
every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? }
|
||||||
|
every { mockPrefs.getStringSet(any(), any()) } answers {
|
||||||
|
prefsStringSets[firstArg<String>()]?.toMutableSet() ?: secondArg<Set<String>?>()?.toMutableSet()
|
||||||
|
}
|
||||||
every { mockPrefs.getBoolean(any(), any()) } answers { secondArg() as Boolean }
|
every { mockPrefs.getBoolean(any(), any()) } answers { secondArg() as Boolean }
|
||||||
every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int }
|
every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int }
|
||||||
every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float }
|
every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float }
|
||||||
|
|
@ -174,6 +189,7 @@ class MainViewModelTest {
|
||||||
coEvery { anyConstructed<RecentFilesRepository>().addBooksToShelf(any(), any()) } just Runs
|
coEvery { anyConstructed<RecentFilesRepository>().addBooksToShelf(any(), any()) } just Runs
|
||||||
coEvery { anyConstructed<RecentFilesRepository>().deleteShelf(any()) } just Runs
|
coEvery { anyConstructed<RecentFilesRepository>().deleteShelf(any()) } just Runs
|
||||||
coEvery { anyConstructed<RecentFilesRepository>().deleteFilePermanently(any()) } just Runs
|
coEvery { anyConstructed<RecentFilesRepository>().deleteFilePermanently(any()) } just Runs
|
||||||
|
coEvery { anyConstructed<RecentFilesRepository>().addRecentFile(any()) } just Runs
|
||||||
coEvery { anyConstructed<BookImporter>().deleteBookByUriString(any()) } returns true
|
coEvery { anyConstructed<BookImporter>().deleteBookByUriString(any()) } returns true
|
||||||
|
|
||||||
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
|
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
|
||||||
|
|
@ -417,38 +433,39 @@ class MainViewModelTest {
|
||||||
viewModel.setStrictFileFilter(true)
|
viewModel.setStrictFileFilter(true)
|
||||||
viewModel.setUsePdfFileNameAsDisplayName(true)
|
viewModel.setUsePdfFileNameAsDisplayName(true)
|
||||||
viewModel.setExternalFileBehavior("KEEP")
|
viewModel.setExternalFileBehavior("KEEP")
|
||||||
|
viewModel.setExternalFileBehavior("TEMPORARY")
|
||||||
|
|
||||||
val state = viewModel.uiState.first {
|
val state = viewModel.uiState.first {
|
||||||
it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "KEEP"
|
it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "TEMPORARY"
|
||||||
}
|
}
|
||||||
assertTrue(state.useStrictFileFilter)
|
assertTrue(state.useStrictFileFilter)
|
||||||
assertTrue(state.usePdfFileNameAsDisplayName)
|
assertTrue(state.usePdfFileNameAsDisplayName)
|
||||||
assertEquals("KEEP", state.externalFileBehavior)
|
assertEquals("TEMPORARY", state.externalFileBehavior)
|
||||||
verify { mockEditor.putBoolean("use_strict_file_filter", true) }
|
verify { mockEditor.putBoolean("use_strict_file_filter", true) }
|
||||||
verify { mockEditor.putBoolean("use_pdf_file_name_as_display_name", true) }
|
verify { mockEditor.putBoolean("use_pdf_file_name_as_display_name", true) }
|
||||||
verify { mockEditor.putString("external_file_behavior", "KEEP") }
|
verify { mockEditor.putString("external_file_behavior", "KEEP") }
|
||||||
|
verify { mockEditor.putString("external_file_behavior", "TEMPORARY") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `startup removes pending external always-remove file before restoring session`() = runTest(testDispatcher) {
|
fun `startup removes pending external always-remove file before restoring session`() = runTest(testDispatcher) {
|
||||||
val pendingUri = "file:///data/user/0/com.aryan.reader/files/books/external.epub"
|
val pendingUri = "file:///data/user/0/com.aryan.reader/files/books/external.epub"
|
||||||
val pendingEntry = """{"bookId":"external-book","uriString":"$pendingUri"}"""
|
val pendingEntry = """{"bookId":"external-book","uriString":"$pendingUri"}"""
|
||||||
every {
|
prefsStringSets["pending_external_file_removals"] = setOf(pendingEntry)
|
||||||
mockPrefs.getStringSet("pending_external_file_removals", any())
|
|
||||||
} returns mutableSetOf(pendingEntry)
|
|
||||||
every { mockPrefs.getString("last_open_book_id", null) } returns "external-book"
|
every { mockPrefs.getString("last_open_book_id", null) } returns "external-book"
|
||||||
every { mockPrefs.getString("last_open_file_type", null) } returns FileType.EPUB.name
|
every { mockPrefs.getString("last_open_file_type", null) } returns FileType.EPUB.name
|
||||||
|
|
||||||
val restored = TestMainViewModel(mockApplication)
|
val restored = TestMainViewModel(mockApplication)
|
||||||
try {
|
try {
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
coVerify(timeout = 1_000) {
|
||||||
coVerify {
|
|
||||||
anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf("external-book"))
|
anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf("external-book"))
|
||||||
}
|
}
|
||||||
|
|
||||||
coVerify {
|
coVerify {
|
||||||
anyConstructed<BookImporter>().deleteBookByUriString(pendingUri)
|
anyConstructed<BookImporter>().deleteBookByUriString(pendingUri)
|
||||||
}
|
}
|
||||||
|
assertEquals(listOf("external-book"), restored.locallyCleanedBookIds)
|
||||||
verify(atLeast = 1) { mockEditor.remove("last_open_book_id") }
|
verify(atLeast = 1) { mockEditor.remove("last_open_book_id") }
|
||||||
verify(atLeast = 1) { mockEditor.remove("last_open_file_type") }
|
verify(atLeast = 1) { mockEditor.remove("last_open_file_type") }
|
||||||
verify { mockEditor.remove("pending_external_file_removals") }
|
verify { mockEditor.remove("pending_external_file_removals") }
|
||||||
|
|
@ -457,6 +474,60 @@ class MainViewModelTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `temporary external pdf opens directly without importing or adding to library`() = runTest(testDispatcher) {
|
||||||
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
viewModel.uiState.collect {}
|
||||||
|
}
|
||||||
|
val externalUri = mockUri("content://external/temp.pdf", path = "/temp.pdf", lastPathSegment = "temp.pdf")
|
||||||
|
val resolver = mockk<ContentResolver>()
|
||||||
|
every { mockApplication.contentResolver } returns resolver
|
||||||
|
every { resolver.getType(externalUri) } returns "application/pdf"
|
||||||
|
every { resolver.query(externalUri, null, null, null, null) } returns null
|
||||||
|
coEvery { anyConstructed<RecentFilesRepository>().getFileByBookId(match { it.startsWith("temporary-") }) } returns null
|
||||||
|
|
||||||
|
viewModel.onFileSelected(
|
||||||
|
externalUri,
|
||||||
|
isFromRecent = false,
|
||||||
|
isExternalIntent = true,
|
||||||
|
isTemporaryExternalIntent = true
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val selected = viewModel.uiState.first { it.selectedBookId?.startsWith("temporary-") == true && it.selectedPdfUri != null }
|
||||||
|
assertEquals(externalUri, selected.selectedPdfUri)
|
||||||
|
assertEquals(null, selected.showExternalFileSavePromptFor)
|
||||||
|
coVerify(exactly = 0) { anyConstructed<BookImporter>().importBook(any()) }
|
||||||
|
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().addRecentFile(any()) }
|
||||||
|
verify(exactly = 0) { mockEditor.putStringSet("pending_external_file_removals", any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `closing temporary external direct book signals activity finish without library cleanup`() = runTest(testDispatcher) {
|
||||||
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
viewModel.uiState.collect {}
|
||||||
|
}
|
||||||
|
val item = recentFile("external-book", type = FileType.PDF)
|
||||||
|
coEvery { anyConstructed<RecentFilesRepository>().getFileByBookId(item.bookId) } returns item
|
||||||
|
viewModel.trackExternalOpenForClose(
|
||||||
|
bookId = item.bookId,
|
||||||
|
importedCopyUriString = null,
|
||||||
|
isTemporaryExternalIntent = true
|
||||||
|
)
|
||||||
|
viewModel.onRecentFileClicked(item)
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.uiState.first { it.selectedBookId == item.bookId }
|
||||||
|
val finishEvent = backgroundScope.async { viewModel.temporaryExternalOpenFinished.first() }
|
||||||
|
|
||||||
|
viewModel.clearSelectedFile()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(null, viewModel.uiState.value.showExternalFileSavePromptFor)
|
||||||
|
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf(item.bookId)) }
|
||||||
|
coVerify(exactly = 0) { anyConstructed<BookImporter>().deleteBookByUriString(item.uriString!!) }
|
||||||
|
assertTrue(finishEvent.isCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `screen capture protection persists and updates state`() = runTest(testDispatcher) {
|
fun `screen capture protection persists and updates state`() = runTest(testDispatcher) {
|
||||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
|
|
||||||
22
app/src/test/java/com/aryan/reader/ReaderPopupSizingTest.kt
Normal file
22
app/src/test/java/com/aryan/reader/ReaderPopupSizingTest.kt
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ReaderPopupSizingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `modal max height leaves edge margin on landscape-height screens`() {
|
||||||
|
assertEquals(306, readerModalMaxHeightDp(screenHeightDp = 360))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `modal max height uses preferred minimum when there is room`() {
|
||||||
|
assertEquals(220, readerModalMaxHeightDp(screenHeightDp = 252))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `modal max height stays within tiny screens`() {
|
||||||
|
assertEquals(168, readerModalMaxHeightDp(screenHeightDp = 200))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.aryan.reader.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ImportedFontFileNameTest {
|
||||||
|
@Test
|
||||||
|
fun importedFontFileNamePreservesVariableFontVariantTokens() {
|
||||||
|
val fileName = importedFontFileName(
|
||||||
|
displayName = "Pliant-Italic-VariableFont_wdth,wght",
|
||||||
|
extension = "TTF"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Pliant-Italic-VariableFont_wdth,wght.ttf", fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun importedFontFileNameRemovesPathUnsafeCharacters() {
|
||||||
|
val fileName = importedFontFileName(
|
||||||
|
displayName = """Pliant/Italic:VariableFont*wdth?wght""",
|
||||||
|
extension = "t/tf"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Pliant_Italic_VariableFont_wdth_wght.ttf", fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun importedFontFileNameFallsBackForBlankNames() {
|
||||||
|
assertTrue(importedFontFileName("...", "ttf").startsWith("font."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -105,6 +105,26 @@ class RecentFileDaoReadingPositionTest {
|
||||||
assertTrue(item.isRecent)
|
assertTrue(item.isRecent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recent file summary caps oversized descriptions while full lookup keeps metadata`() = runTest {
|
||||||
|
val longDescription = "Summary ".repeat(2_000)
|
||||||
|
val longOriginalDescription = "Original ".repeat(2_000)
|
||||||
|
dao.insertOrUpdateFile(
|
||||||
|
recentFileEntity().copy(
|
||||||
|
description = longDescription,
|
||||||
|
originalDescription = longOriginalDescription
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val summary = dao.getRecentFiles().first().single()
|
||||||
|
val full = dao.getFileByBookId("book-1")!!
|
||||||
|
|
||||||
|
assertEquals(4_096, summary.description?.length)
|
||||||
|
assertEquals(4_096, summary.originalDescription?.length)
|
||||||
|
assertEquals(longDescription, full.description)
|
||||||
|
assertEquals(longOriginalDescription, full.originalDescription)
|
||||||
|
}
|
||||||
|
|
||||||
private fun recentFileEntity(lastPositionCfi: String? = null): RecentFileEntity {
|
private fun recentFileEntity(lastPositionCfi: String? = null): RecentFileEntity {
|
||||||
return RecentFileEntity(
|
return RecentFileEntity(
|
||||||
bookId = "book-1",
|
bookId = "book-1",
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,30 @@ class EpubParserUnitTest {
|
||||||
assertTrue(extractionDir.list().isNullOrEmpty())
|
assertTrue(extractionDir.list().isNullOrEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createEpubBook uses spine toc id when manifest contains volume ncx files first`() = runTest {
|
||||||
|
val cacheDir = temp.newFolder("cache-merged-toc")
|
||||||
|
val extractionDir = temp.newFolder("extract-merged-toc")
|
||||||
|
val parser = EpubParser(contextWithCache(cacheDir))
|
||||||
|
|
||||||
|
val book = parser.createEpubBook(
|
||||||
|
inputStream = ByteArrayInputStream(mergedVolumeTocEpubBytes()),
|
||||||
|
bookId = "book-id",
|
||||||
|
shouldUseToc = true,
|
||||||
|
originalBookNameHint = "merged.epub",
|
||||||
|
parseContent = true,
|
||||||
|
extractionDirOverride = extractionDir
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("Volume 1", "Chapter 1", "Volume 2", "Chapter 2"),
|
||||||
|
book.tableOfContents.map { it.label }
|
||||||
|
)
|
||||||
|
assertEquals(listOf(0, 1, 0, 1), book.tableOfContents.map { it.depth })
|
||||||
|
assertEquals("Volume 2", book.chapters[2].title)
|
||||||
|
assertEquals("Chapter 2", book.chapters[3].title)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `metadata only extraction streams images to disk without retaining image bytes`() {
|
fun `metadata only extraction streams images to disk without retaining image bytes`() {
|
||||||
val cacheDir = temp.newFolder("cache-metadata-stream")
|
val cacheDir = temp.newFolder("cache-metadata-stream")
|
||||||
|
|
@ -449,6 +473,50 @@ class EpubParserUnitTest {
|
||||||
"OEBPS/images/unlisted.png" to "not-real-image"
|
"OEBPS/images/unlisted.png" to "not-real-image"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun mergedVolumeTocEpubBytes(): ByteArray = zipBytes(
|
||||||
|
"META-INF/container.xml" to """
|
||||||
|
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
|
||||||
|
""".trimIndent(),
|
||||||
|
"OEBPS/content.opf" to """
|
||||||
|
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<metadata><dc:title>Merged Volumes</dc:title></metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="v1title" href="1/title.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="v1c1" href="1/chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="v2title" href="2/title.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="v2c1" href="2/chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="v1ncx" href="1/toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||||
|
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||||
|
</manifest>
|
||||||
|
<spine toc="ncx">
|
||||||
|
<itemref idref="v1title"/>
|
||||||
|
<itemref idref="v1c1"/>
|
||||||
|
<itemref idref="v2title"/>
|
||||||
|
<itemref idref="v2c1"/>
|
||||||
|
</spine>
|
||||||
|
</package>
|
||||||
|
""".trimIndent(),
|
||||||
|
"OEBPS/1/toc.ncx" to """
|
||||||
|
<ncx><navMap>
|
||||||
|
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="title.xhtml"/></navPoint>
|
||||||
|
</navMap></ncx>
|
||||||
|
""".trimIndent(),
|
||||||
|
"OEBPS/toc.ncx" to """
|
||||||
|
<ncx><navMap>
|
||||||
|
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="1/title.xhtml"/>
|
||||||
|
<navPoint><navLabel><text>Chapter 1</text></navLabel><content src="1/chapter1.xhtml"/></navPoint>
|
||||||
|
</navPoint>
|
||||||
|
<navPoint><navLabel><text>Volume 2</text></navLabel><content src="2/title.xhtml"/>
|
||||||
|
<navPoint><navLabel><text>Chapter 2</text></navLabel><content src="2/chapter1.xhtml"/></navPoint>
|
||||||
|
</navPoint>
|
||||||
|
</navMap></ncx>
|
||||||
|
""".trimIndent(),
|
||||||
|
"OEBPS/1/title.xhtml" to "<html><body><h1>HTML Volume 1</h1><p>Volume one.</p></body></html>",
|
||||||
|
"OEBPS/1/chapter1.xhtml" to "<html><body><h1>HTML Chapter 1</h1><p>Chapter one.</p></body></html>",
|
||||||
|
"OEBPS/2/title.xhtml" to "<html><body><h1>HTML Volume 2</h1><p>Volume two.</p></body></html>",
|
||||||
|
"OEBPS/2/chapter1.xhtml" to "<html><body><h1>HTML Chapter 2</h1><p>Chapter two.</p></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
private fun minimalEpubBytesWithoutOptionalMetadata(): ByteArray = zipBytes(
|
private fun minimalEpubBytesWithoutOptionalMetadata(): ByteArray = zipBytes(
|
||||||
"META-INF/container.xml" to """
|
"META-INF/container.xml" to """
|
||||||
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
|
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package com.aryan.reader.epubreader
|
||||||
|
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class EpubReaderTtsHighlightAssetTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tts highlight is constrained to one readable block and does not inherit spacing`() {
|
||||||
|
val js = epubReaderAsset().readText()
|
||||||
|
|
||||||
|
assertTrue(js.contains("const TTS_HIGHLIGHT_BLOCK_SELECTOR"))
|
||||||
|
assertTrue(js.contains("getTtsHighlightBlock(baseNode)"))
|
||||||
|
assertTrue(js.contains("document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT"))
|
||||||
|
assertTrue(js.contains("text-align-last: auto !important;"))
|
||||||
|
assertTrue(js.contains("letter-spacing: normal !important;"))
|
||||||
|
assertTrue(js.contains("word-spacing: normal !important;"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun epubReaderAsset(): File {
|
||||||
|
val candidates = listOf(
|
||||||
|
File("src/main/assets/epub_reader.js"),
|
||||||
|
File("app/src/main/assets/epub_reader.js")
|
||||||
|
)
|
||||||
|
return candidates.firstOrNull { it.isFile }
|
||||||
|
?: error("Unable to locate epub_reader.js from ${File(".").absolutePath}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,4 +62,45 @@ class EpubTtsChunkMatchingTest {
|
||||||
|
|
||||||
assertEquals(0, findTtsChunkStartIndex(chunks, nativeVerticalTarget))
|
assertEquals(0, findTtsChunkStartIndex(chunks, nativeVerticalTarget))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `vertical continuation falls back to loaded chunk boundary when resume match is unavailable`() {
|
||||||
|
val chunks = listOf(
|
||||||
|
TtsChunk("Loaded one", "/4/2", 0),
|
||||||
|
TtsChunk("Loaded two", "/4/4", 0),
|
||||||
|
TtsChunk("Remaining three", "/4/6", 0),
|
||||||
|
TtsChunk("Remaining four", "/4/8", 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
2,
|
||||||
|
resolveTtsContinuationStartIndex(
|
||||||
|
chunks = chunks,
|
||||||
|
loadedChunkCount = 2,
|
||||||
|
sourceCfi = "/does/not/match",
|
||||||
|
startOffsetInSource = 0,
|
||||||
|
currentText = "not present"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `vertical continuation starts after matched spoken chunk`() {
|
||||||
|
val chunks = listOf(
|
||||||
|
TtsChunk("Loaded one", "/4/2", 0),
|
||||||
|
TtsChunk("Loaded two", "/4/4", 0),
|
||||||
|
TtsChunk("Remaining three", "/4/6", 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
2,
|
||||||
|
resolveTtsContinuationStartIndex(
|
||||||
|
chunks = chunks,
|
||||||
|
loadedChunkCount = 1,
|
||||||
|
sourceCfi = "/4/4",
|
||||||
|
startOffsetInSource = 0,
|
||||||
|
currentText = "Loaded two"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AndroidEpubKeyCommandsTest {
|
||||||
|
@Test
|
||||||
|
fun `left and right map to page changes`() {
|
||||||
|
assertEquals(
|
||||||
|
AndroidEpubKeyCommand.PREVIOUS_PAGE,
|
||||||
|
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_LEFT)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AndroidEpubKeyCommand.NEXT_PAGE,
|
||||||
|
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_RIGHT)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `left and right respect right to left pagination`() {
|
||||||
|
assertEquals(
|
||||||
|
AndroidEpubKeyCommand.NEXT_PAGE,
|
||||||
|
androidEpubKeyCommandOrNull(
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
|
rightToLeftPagination = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AndroidEpubKeyCommand.PREVIOUS_PAGE,
|
||||||
|
androidEpubKeyCommandOrNull(
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||||
|
rightToLeftPagination = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `up and down map to vertical scroll`() {
|
||||||
|
assertEquals(
|
||||||
|
AndroidEpubKeyCommand.SCROLL_UP,
|
||||||
|
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_UP)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AndroidEpubKeyCommand.SCROLL_DOWN,
|
||||||
|
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_DOWN)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `page home and end keys map to reader navigation`() {
|
||||||
|
assertEquals(
|
||||||
|
AndroidEpubKeyCommand.PREVIOUS_PAGE,
|
||||||
|
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_PAGE_UP)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AndroidEpubKeyCommand.NEXT_PAGE,
|
||||||
|
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_PAGE_DOWN)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AndroidEpubKeyCommand.FIRST_PAGE,
|
||||||
|
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_MOVE_HOME)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AndroidEpubKeyCommand.LAST_PAGE,
|
||||||
|
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_MOVE_END)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ctrl shortcuts are left for reader chrome and search handling`() {
|
||||||
|
assertNull(androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_RIGHT, isCtrlPressed = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class EpubFontFaceSiblingsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun expandFontFacesWithSiblings_addsItalicAndBoldItalicVariants() {
|
||||||
|
val root = createTempRoot()
|
||||||
|
val fontsDir = File(root, "OEBPS/fonts").apply { mkdirs() }
|
||||||
|
File(fontsDir, "Literata-Regular.ttf").writeText("regular")
|
||||||
|
File(fontsDir, "Literata-Italic.ttf").writeText("italic")
|
||||||
|
File(fontsDir, "Literata-BoldItalic.ttf").writeText("bold italic")
|
||||||
|
File(fontsDir, "Other-Italic.ttf").writeText("other")
|
||||||
|
|
||||||
|
val expanded = expandFontFacesWithSiblings(
|
||||||
|
fontFaces = listOf(
|
||||||
|
FontFaceInfo(
|
||||||
|
fontFamily = "literata",
|
||||||
|
src = "OEBPS/fonts/Literata-Regular.ttf",
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontStyle = FontStyle.Normal
|
||||||
|
)
|
||||||
|
),
|
||||||
|
extractionPath = root.absolutePath
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(3, expanded.size)
|
||||||
|
assertTrue(expanded.any { it.src == "OEBPS/fonts/Literata-Italic.ttf" && it.fontStyle == FontStyle.Italic })
|
||||||
|
assertTrue(
|
||||||
|
expanded.any {
|
||||||
|
it.src == "OEBPS/fonts/Literata-BoldItalic.ttf" &&
|
||||||
|
it.fontStyle == FontStyle.Italic &&
|
||||||
|
it.fontWeight == FontWeight.Bold
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assertTrue(expanded.none { it.src.contains("Other") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildEpubFontFaceCss_emitsVariantDescriptorsForSiblings() {
|
||||||
|
val root = createTempRoot()
|
||||||
|
val fontsDir = File(root, "fonts").apply { mkdirs() }
|
||||||
|
File(fontsDir, "LoraRegular.ttf").writeText("regular")
|
||||||
|
File(fontsDir, "LoraBoldItalic.ttf").writeText("bold italic")
|
||||||
|
|
||||||
|
val css = buildEpubFontFaceCss(
|
||||||
|
fontFaces = listOf(
|
||||||
|
FontFaceInfo(
|
||||||
|
fontFamily = "lora",
|
||||||
|
src = "fonts/LoraRegular.ttf",
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontStyle = FontStyle.Normal
|
||||||
|
)
|
||||||
|
),
|
||||||
|
extractionPath = root.absolutePath
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(css.contains("font-family: 'lora'"))
|
||||||
|
assertTrue(css.contains("font-weight: 700"))
|
||||||
|
assertTrue(css.contains("font-style: italic"))
|
||||||
|
assertTrue(css.contains("LoraBoldItalic.ttf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun expandFontFacesWithSiblings_groupsVariableRegularAndItalicFiles() {
|
||||||
|
val root = createTempRoot()
|
||||||
|
val fontsDir = File(root, "fonts").apply { mkdirs() }
|
||||||
|
File(fontsDir, "Pliant-VariableFont_wdth,wght.ttf").writeText("regular variable")
|
||||||
|
File(fontsDir, "Pliant-Italic-VariableFont_wdth,wght.ttf").writeText("italic variable")
|
||||||
|
|
||||||
|
val expanded = expandFontFacesWithSiblings(
|
||||||
|
fontFaces = listOf(
|
||||||
|
FontFaceInfo(
|
||||||
|
fontFamily = "pliant",
|
||||||
|
src = "fonts/Pliant-VariableFont_wdth,wght.ttf",
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontStyle = FontStyle.Normal
|
||||||
|
)
|
||||||
|
),
|
||||||
|
extractionPath = root.absolutePath
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(2, expanded.size)
|
||||||
|
assertTrue(
|
||||||
|
expanded.any {
|
||||||
|
it.src == "fonts/Pliant-Italic-VariableFont_wdth,wght.ttf" &&
|
||||||
|
it.fontStyle == FontStyle.Italic &&
|
||||||
|
it.fontWeight == FontWeight.Normal
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildEpubFontFaceCss_usesWeightRangeForVariableWeightFonts() {
|
||||||
|
val root = createTempRoot()
|
||||||
|
val fontsDir = File(root, "fonts").apply { mkdirs() }
|
||||||
|
File(fontsDir, "Pliant-VariableFont_wdth,wght.ttf").writeText("regular variable")
|
||||||
|
File(fontsDir, "Pliant-Italic-VariableFont_wdth,wght.ttf").writeText("italic variable")
|
||||||
|
|
||||||
|
val css = buildEpubFontFaceCss(
|
||||||
|
fontFaces = listOf(
|
||||||
|
FontFaceInfo(
|
||||||
|
fontFamily = "pliant",
|
||||||
|
src = "fonts/Pliant-VariableFont_wdth,wght.ttf",
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontStyle = FontStyle.Normal
|
||||||
|
)
|
||||||
|
),
|
||||||
|
extractionPath = root.absolutePath
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(css.contains("font-weight: 100 900"))
|
||||||
|
assertTrue(css.contains("font-style: italic"))
|
||||||
|
assertTrue(css.contains("Pliant-Italic-VariableFont_wdth,wght.ttf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTempRoot(): File {
|
||||||
|
return kotlin.io.path.createTempDirectory("epub-font-siblings").toFile().also {
|
||||||
|
it.deleteOnExit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.aryan.reader.paginatedreader
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class NativeVerticalLocationTest {
|
class NativeVerticalLocationTest {
|
||||||
|
|
@ -27,4 +28,157 @@ class NativeVerticalLocationTest {
|
||||||
assertEquals(2, nativeVerticalProgressToItemIndex(weights, 25f))
|
assertEquals(2, nativeVerticalProgressToItemIndex(weights, 25f))
|
||||||
assertEquals(3, nativeVerticalProgressToItemIndex(weights, 100f))
|
assertEquals(3, nativeVerticalProgressToItemIndex(weights, 100f))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scroll progress updates within visible item offset`() {
|
||||||
|
val weights = listOf(100, 300, 600)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
25f,
|
||||||
|
estimateNativeVerticalWeightedScrollProgressPercent(
|
||||||
|
itemWeights = weights,
|
||||||
|
firstVisibleItemIndex = 1,
|
||||||
|
firstVisibleItemScrollOffset = 500,
|
||||||
|
firstVisibleItemSize = 1000
|
||||||
|
),
|
||||||
|
0.001f
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
40f,
|
||||||
|
estimateNativeVerticalWeightedScrollProgressPercent(
|
||||||
|
itemWeights = weights,
|
||||||
|
firstVisibleItemIndex = 1,
|
||||||
|
firstVisibleItemScrollOffset = 1000,
|
||||||
|
firstVisibleItemSize = 1000
|
||||||
|
),
|
||||||
|
0.001f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `chapter page info uses chapter local locator offset`() {
|
||||||
|
val pageInfo = nativeVerticalChapterPageInfo(
|
||||||
|
chapterCharOffset = 500,
|
||||||
|
chapterLengthChars = 1000,
|
||||||
|
chapterPageCount = 11,
|
||||||
|
compatPageIndex = 900,
|
||||||
|
chapterStartPageIndex = 850
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(6, pageInfo?.currentPage)
|
||||||
|
assertEquals(11, pageInfo?.totalPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `chapter page info falls back to absolute page within chapter`() {
|
||||||
|
val pageInfo = nativeVerticalChapterPageInfo(
|
||||||
|
chapterCharOffset = null,
|
||||||
|
chapterLengthChars = 0,
|
||||||
|
chapterPageCount = 7,
|
||||||
|
compatPageIndex = 24,
|
||||||
|
chapterStartPageIndex = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(5, pageInfo?.currentPage)
|
||||||
|
assertEquals(7, pageInfo?.totalPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `chapter page info follows scroll weight within current chapter`() {
|
||||||
|
val pageInfo = nativeVerticalChapterPageInfoForScroll(
|
||||||
|
itemChapterIndices = listOf(0, 0, 1, 1),
|
||||||
|
itemWeights = listOf(100, 300, 100, 300),
|
||||||
|
firstVisibleItemIndex = 1,
|
||||||
|
firstVisibleItemScrollOffset = 500,
|
||||||
|
firstVisibleItemSize = 1000,
|
||||||
|
chapterPageCount = 9
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(6, pageInfo?.currentPage)
|
||||||
|
assertEquals(9, pageInfo?.totalPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `native vertical image model decodes svg data uris for coil svg fetcher`() {
|
||||||
|
val model = nativeVerticalImageModelData(
|
||||||
|
"data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2010%2010%22%3E%3Ccircle%20r%3D%225%22%2F%3E%3C%2Fsvg%3E"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(model is SvgData)
|
||||||
|
assertEquals("""<svg viewBox="0 0 10 10"><circle r="5"/></svg>""", (model as SvgData).content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `native vertical svg data uri decoding preserves plus signs`() {
|
||||||
|
assertEquals(
|
||||||
|
"""<svg><path d="M1+2"/></svg>""",
|
||||||
|
nativeVerticalSvgContentFromDataUri(
|
||||||
|
"data:image/svg+xml,%3Csvg%3E%3Cpath%20d%3D%22M1+2%22%2F%3E%3C%2Fsvg%3E"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `native vertical persistence locator prefers visible text range`() {
|
||||||
|
val location = NativeVerticalLocation(
|
||||||
|
locator = Locator(chapterIndex = 2, blockIndex = 10, charOffset = 100),
|
||||||
|
chapterIndex = 2,
|
||||||
|
progressPercent = 42f,
|
||||||
|
compatPageIndex = 20,
|
||||||
|
compatTotalPages = 100,
|
||||||
|
firstVisibleItemIndex = 4,
|
||||||
|
firstVisibleItemScrollOffset = 250,
|
||||||
|
firstVisibleItemSize = 1000,
|
||||||
|
isAtStart = false,
|
||||||
|
isAtEnd = false,
|
||||||
|
visibleTextRanges = listOf(
|
||||||
|
NativeVerticalVisibleTextRange(
|
||||||
|
chapterIndex = 2,
|
||||||
|
blockIndex = 10,
|
||||||
|
startCharOffset = 380,
|
||||||
|
endCharOffset = 520
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(Locator(chapterIndex = 2, blockIndex = 10, charOffset = 380), location.locatorForPersistence())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `native vertical initial restore does not fallback to compat page when locator exists`() {
|
||||||
|
assertEquals(
|
||||||
|
false,
|
||||||
|
shouldFallbackNativeVerticalInitialScrollToCompatPage(
|
||||||
|
hasInitialLocator = true,
|
||||||
|
didLocatorScroll = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
true,
|
||||||
|
shouldFallbackNativeVerticalInitialScrollToCompatPage(
|
||||||
|
hasInitialLocator = false,
|
||||||
|
didLocatorScroll = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `native vertical tts follow centers target offset in viewport`() {
|
||||||
|
assertEquals(
|
||||||
|
100f,
|
||||||
|
nativeVerticalCenteredScrollDelta(
|
||||||
|
targetOffsetInViewport = 500f,
|
||||||
|
viewportHeight = 800f
|
||||||
|
),
|
||||||
|
0.001f
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
-200f,
|
||||||
|
nativeVerticalCenteredScrollDelta(
|
||||||
|
targetOffsetInViewport = 200f,
|
||||||
|
viewportHeight = 800f
|
||||||
|
),
|
||||||
|
0.001f
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ class ReaderNavigationTargetsTest {
|
||||||
@Test
|
@Test
|
||||||
fun `native vertical initial prefetch is bounded around requested chapter`() {
|
fun `native vertical initial prefetch is bounded around requested chapter`() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf(4, 5, 2),
|
listOf(4, 5),
|
||||||
nativeVerticalInitialChapterPrefetchOrder(chapterCount = 6, initialChapter = 3)
|
nativeVerticalInitialChapterPrefetchOrder(chapterCount = 6, initialChapter = 3)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,20 @@ class PdfReaderCoreLogicTest {
|
||||||
assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf")))
|
assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pdf encrypt marker detection matches trailer encrypt entry`() {
|
||||||
|
val bytes = "%PDF-1.7\ntrailer\n<< /Size 4 /Encrypt 2 0 R >>".toByteArray(Charsets.US_ASCII)
|
||||||
|
|
||||||
|
assertTrue(pdfBytesContainEncryptMarker(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pdf encrypt marker detection ignores longer pdf names`() {
|
||||||
|
val bytes = "<< /EncryptMetadata false /Size 4 >>".toByteArray(Charsets.US_ASCII)
|
||||||
|
|
||||||
|
assertFalse(pdfBytesContainEncryptMarker(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getFastFileId uses stable file name and length for file uris`() {
|
fun `getFastFileId uses stable file name and length for file uris`() {
|
||||||
val file = File("build/test-tmp/pdf-reader/fast-id-${System.nanoTime()}.pdf").apply {
|
val file = File("build/test-tmp/pdf-reader/fast-id-${System.nanoTime()}.pdf").apply {
|
||||||
|
|
@ -383,6 +397,32 @@ class PdfReaderCoreLogicTest {
|
||||||
assertTrue(limitedScale >= 0.01f)
|
assertTrue(limitedScale >= 0.01f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `spread page slot width fits page aspect instead of filling half landscape viewport`() {
|
||||||
|
val slotWidth = pdfSpreadPageSlotWidth(
|
||||||
|
containerWidth = 1920f,
|
||||||
|
containerHeight = 900f,
|
||||||
|
pageGap = 0f,
|
||||||
|
spreadPageCount = 2,
|
||||||
|
pageAspectRatio = 612f / 792f
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(695.4545f, slotWidth, 0.001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `spread page slot width caps pages to available spread width`() {
|
||||||
|
val slotWidth = pdfSpreadPageSlotWidth(
|
||||||
|
containerWidth = 1000f,
|
||||||
|
containerHeight = 900f,
|
||||||
|
pageGap = 20f,
|
||||||
|
spreadPageCount = 2,
|
||||||
|
pageAspectRatio = 1.4f
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(490f, slotWidth, 0.0001f)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `canUsePdfSidecarsForBook only accepts loaded sidecars for active book`() {
|
fun `canUsePdfSidecarsForBook only accepts loaded sidecars for active book`() {
|
||||||
assertTrue(canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = true))
|
assertTrue(canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = true))
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,23 @@ class PdfReaderSettingsAndSharedModelsTest {
|
||||||
assertEquals(PdfOverflowMenuSection.FILE_ACTIONS, sections.last())
|
assertEquals(PdfOverflowMenuSection.FILE_ACTIONS, sections.last())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pdf overflow sections hide file actions when only unavailable print remains`() {
|
||||||
|
val sections = pdfOverflowMenuSections(
|
||||||
|
hiddenTools = setOf(
|
||||||
|
PdfReaderTool.SHARE.name,
|
||||||
|
PdfReaderTool.SAVE_COPY.name
|
||||||
|
),
|
||||||
|
hasHiddenToolbarTools = false,
|
||||||
|
isPro = false,
|
||||||
|
effectiveFileType = FileType.PDF,
|
||||||
|
hasFileInfo = false,
|
||||||
|
canPrintDocument = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(PdfOverflowMenuSection.FILE_ACTIONS in sections)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `pdf overflow sections expose file info only when available and visible`() {
|
fun `pdf overflow sections expose file info only when available and visible`() {
|
||||||
val visibleSections = pdfOverflowMenuSections(
|
val visibleSections = pdfOverflowMenuSections(
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,86 @@ class PdfZoomLockStateTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `vertical pdf high res tiles render for settled zoom below one hundred percent`() {
|
||||||
|
assertTrue(
|
||||||
|
shouldRenderPdfHighResTiles(
|
||||||
|
effectiveScale = 0.82f,
|
||||||
|
targetWidthPx = 1080,
|
||||||
|
targetHeightPx = 1600,
|
||||||
|
isVerticalScroll = true,
|
||||||
|
isActivePage = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `vertical pdf high res tiles skip exact one hundred percent unless page is large`() {
|
||||||
|
assertFalse(
|
||||||
|
shouldRenderPdfHighResTiles(
|
||||||
|
effectiveScale = 1f,
|
||||||
|
targetWidthPx = 1080,
|
||||||
|
targetHeightPx = 1600,
|
||||||
|
isVerticalScroll = true,
|
||||||
|
isActivePage = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
shouldRenderPdfHighResTiles(
|
||||||
|
effectiveScale = 1f,
|
||||||
|
targetWidthPx = 3200,
|
||||||
|
targetHeightPx = 1600,
|
||||||
|
isVerticalScroll = true,
|
||||||
|
isActivePage = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `paginated pdf high res tiles keep existing zoom threshold`() {
|
||||||
|
assertFalse(
|
||||||
|
shouldRenderPdfHighResTiles(
|
||||||
|
effectiveScale = 0.82f,
|
||||||
|
targetWidthPx = 1080,
|
||||||
|
targetHeightPx = 1600,
|
||||||
|
isVerticalScroll = false,
|
||||||
|
isActivePage = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
shouldRenderPdfHighResTiles(
|
||||||
|
effectiveScale = 1.25f,
|
||||||
|
targetWidthPx = 1080,
|
||||||
|
targetHeightPx = 1600,
|
||||||
|
isVerticalScroll = false,
|
||||||
|
isActivePage = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
shouldRenderPdfHighResTiles(
|
||||||
|
effectiveScale = 1.25f,
|
||||||
|
targetWidthPx = 1080,
|
||||||
|
targetHeightPx = 1600,
|
||||||
|
isVerticalScroll = false,
|
||||||
|
isActivePage = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `zoom indicator percent rounds displayed scale`() {
|
||||||
|
assertEquals(82, pdfZoomIndicatorPercent(0.824f))
|
||||||
|
assertEquals(83, pdfZoomIndicatorPercent(0.826f))
|
||||||
|
assertEquals(100, pdfZoomIndicatorPercent(0.996f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `zoom indicator hides only at displayed one hundred percent`() {
|
||||||
|
assertFalse(shouldShowPdfZoomIndicator(100))
|
||||||
|
assertTrue(shouldShowPdfZoomIndicator(99))
|
||||||
|
assertTrue(shouldShowPdfZoomIndicator(125))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `page change preserves locked zoom scale only in paginated lock mode`() {
|
fun `page change preserves locked zoom scale only in paginated lock mode`() {
|
||||||
val lockedState = Triple(2.25f, -12f, 32f)
|
val lockedState = Triple(2.25f, -12f, 32f)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import org.junit.Test
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
class TtsChunkNavigationTest {
|
class TtsChunkNavigationTest {
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -53,6 +54,25 @@ class TtsChunkNavigationTest {
|
||||||
assertEquals(false, shouldAdvanceToTtsPlaylistChunk(currentChunkIndex = 8, playlistChunkIndex = null))
|
assertEquals(false, shouldAdvanceToTtsPlaylistChunk(currentChunkIndex = 8, playlistChunkIndex = null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `automatic playlist advance can step over chunks marked skipped after generation failures`() {
|
||||||
|
assertEquals(
|
||||||
|
true,
|
||||||
|
shouldAdvanceToTtsPlaylistChunk(
|
||||||
|
currentChunkIndex = 8,
|
||||||
|
playlistChunkIndex = 10,
|
||||||
|
skippedChunkIndices = setOf(9)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(10, resolveNextPlayableTtsChunkIndex(8, 12, setOf(9)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `chunk generation gives up after bounded failures`() {
|
||||||
|
assertEquals(false, shouldGiveUpTtsChunkGeneration(failureCount = 1, maxFailures = 2))
|
||||||
|
assertEquals(true, shouldGiveUpTtsChunkGeneration(failureCount = 2, maxFailures = 2))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `transition prefetch is deferred only for the rebuilding generation`() {
|
fun `transition prefetch is deferred only for the rebuilding generation`() {
|
||||||
assertEquals(false, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = 6))
|
assertEquals(false, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = 6))
|
||||||
|
|
@ -150,6 +170,16 @@ class TtsChunkNavigationTest {
|
||||||
assertNull(estimateTtsNotificationDurationMs(text = " "))
|
assertNull(estimateTtsNotificationDurationMs(text = " "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stable sorted snapshot copies concurrent cache keys`() {
|
||||||
|
val cache = ConcurrentHashMap<Int, String>()
|
||||||
|
cache[3] = "three"
|
||||||
|
cache[1] = "one"
|
||||||
|
cache[2] = "two"
|
||||||
|
|
||||||
|
assertEquals(listOf(1, 2, 3), stableSortedIntSnapshot(cache.keys))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `wav file duration is read from pcm byte rate`() {
|
fun `wav file duration is read from pcm byte rate`() {
|
||||||
val file = createTempWavFile(pcmBytes = 48_000)
|
val file = createTempWavFile(pcmBytes = 48_000)
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,24 @@ plugins {
|
||||||
alias(libs.plugins.compose.multiplatform) apply false
|
alias(libs.plugins.compose.multiplatform) apply false
|
||||||
alias(libs.plugins.kover) apply false
|
alias(libs.plugins.kover) apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val test by tasks.registering {
|
||||||
|
group = "verification"
|
||||||
|
description = "Runs available unit tests for the included projects."
|
||||||
|
}
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
val rootTest = rootProject.tasks.named("test")
|
||||||
|
tasks.matching {
|
||||||
|
it.name == "allTests" ||
|
||||||
|
it.name == "desktopTest" ||
|
||||||
|
it.name.endsWith("DebugUnitTest")
|
||||||
|
}.configureEach {
|
||||||
|
rootTest.configure {
|
||||||
|
dependsOn(this@configureEach)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
maxHeapSize = "4g"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,31 @@
|
||||||
import org.gradle.api.GradleException
|
import org.gradle.api.GradleException
|
||||||
import org.gradle.api.DefaultTask
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.file.DirectoryProperty
|
||||||
import org.gradle.api.file.RegularFileProperty
|
import org.gradle.api.file.RegularFileProperty
|
||||||
import org.gradle.api.provider.ListProperty
|
import org.gradle.api.provider.ListProperty
|
||||||
import org.gradle.api.provider.MapProperty
|
import org.gradle.api.provider.MapProperty
|
||||||
import org.gradle.api.provider.Property
|
import org.gradle.api.provider.Property
|
||||||
import org.gradle.api.tasks.JavaExec
|
import org.gradle.api.tasks.JavaExec
|
||||||
|
import org.gradle.api.tasks.Exec
|
||||||
import org.gradle.api.tasks.Input
|
import org.gradle.api.tasks.Input
|
||||||
|
import org.gradle.api.tasks.InputDirectory
|
||||||
|
import org.gradle.api.tasks.InputFile
|
||||||
|
import org.gradle.api.tasks.OutputDirectory
|
||||||
import org.gradle.api.tasks.OutputFile
|
import org.gradle.api.tasks.OutputFile
|
||||||
|
import org.gradle.api.tasks.PathSensitive
|
||||||
import org.gradle.api.tasks.PathSensitivity
|
import org.gradle.api.tasks.PathSensitivity
|
||||||
import org.gradle.api.tasks.Sync
|
import org.gradle.api.tasks.Sync
|
||||||
import org.gradle.api.tasks.TaskAction
|
import org.gradle.api.tasks.TaskAction
|
||||||
|
import org.gradle.api.tasks.bundling.Compression
|
||||||
|
import org.gradle.api.tasks.bundling.Tar
|
||||||
import org.gradle.jvm.tasks.Jar
|
import org.gradle.jvm.tasks.Jar
|
||||||
|
import org.gradle.process.ExecOperations
|
||||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||||
import org.gradle.work.DisableCachingByDefault
|
import org.gradle.work.DisableCachingByDefault
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.awt.RenderingHints
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
import java.nio.file.AtomicMoveNotSupportedException
|
import java.nio.file.AtomicMoveNotSupportedException
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.StandardCopyOption
|
import java.nio.file.StandardCopyOption
|
||||||
|
|
@ -21,6 +33,8 @@ import java.util.Properties
|
||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
import java.util.zip.ZipOutputStream
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
|
@ -81,6 +95,244 @@ abstract class RenameDesktopMsiOutputTask : DefaultTask() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DisableCachingByDefault(because = "Generates an MSIX manifest from package metadata.")
|
||||||
|
abstract class GenerateDesktopMsixManifestTask : DefaultTask() {
|
||||||
|
@get:Input
|
||||||
|
abstract val identityName: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val publisher: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val publisherDisplayName: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val packageName: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val packageDescription: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val packageVersion: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val architecture: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val executablePath: Property<String>
|
||||||
|
|
||||||
|
@get:OutputFile
|
||||||
|
abstract val outputFile: RegularFileProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun generate() {
|
||||||
|
fun xmlEscaped(value: String): String {
|
||||||
|
return value.replace("&", "&")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
val file = outputFile.get().asFile
|
||||||
|
file.parentFile.mkdirs()
|
||||||
|
file.writeText(
|
||||||
|
"""
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Package
|
||||||
|
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||||
|
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||||
|
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||||
|
IgnorableNamespaces="uap rescap">
|
||||||
|
<Identity
|
||||||
|
Name="${xmlEscaped(identityName.get())}"
|
||||||
|
Publisher="${xmlEscaped(publisher.get())}"
|
||||||
|
Version="${xmlEscaped(packageVersion.get())}"
|
||||||
|
ProcessorArchitecture="${xmlEscaped(architecture.get())}" />
|
||||||
|
<Properties>
|
||||||
|
<DisplayName>${xmlEscaped(packageName.get())}</DisplayName>
|
||||||
|
<PublisherDisplayName>${xmlEscaped(publisherDisplayName.get())}</PublisherDisplayName>
|
||||||
|
<Logo>Assets\StoreLogo.png</Logo>
|
||||||
|
</Properties>
|
||||||
|
<Resources>
|
||||||
|
<Resource Language="en-us" />
|
||||||
|
</Resources>
|
||||||
|
<Dependencies>
|
||||||
|
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.22621.0" />
|
||||||
|
</Dependencies>
|
||||||
|
<Applications>
|
||||||
|
<Application Id="Episteme" Executable="${xmlEscaped(executablePath.get())}" EntryPoint="Windows.FullTrustApplication">
|
||||||
|
<uap:VisualElements
|
||||||
|
DisplayName="${xmlEscaped(packageName.get())}"
|
||||||
|
Description="${xmlEscaped(packageDescription.get())}"
|
||||||
|
BackgroundColor="transparent"
|
||||||
|
Square44x44Logo="Assets\Square44x44Logo.png"
|
||||||
|
Square150x150Logo="Assets\Square150x150Logo.png" />
|
||||||
|
</Application>
|
||||||
|
</Applications>
|
||||||
|
<Capabilities>
|
||||||
|
<rescap:Capability Name="runFullTrust" />
|
||||||
|
</Capabilities>
|
||||||
|
</Package>
|
||||||
|
""".trimIndent() + "\n",
|
||||||
|
Charsets.UTF_8
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DisableCachingByDefault(because = "Generates fixed-size MSIX logo assets from the desktop icon.")
|
||||||
|
abstract class GenerateDesktopMsixAssetsTask : DefaultTask() {
|
||||||
|
@get:InputFile
|
||||||
|
@get:PathSensitive(PathSensitivity.NONE)
|
||||||
|
abstract val sourceIconFile: RegularFileProperty
|
||||||
|
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val outputDirectory: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun generate() {
|
||||||
|
val source = ImageIO.read(sourceIconFile.get().asFile)
|
||||||
|
?: throw GradleException("Could not read MSIX source icon ${sourceIconFile.get().asFile.absolutePath}.")
|
||||||
|
val output = outputDirectory.get().asFile
|
||||||
|
output.mkdirs()
|
||||||
|
writePng(source, output.resolve("Square44x44Logo.png"), 44)
|
||||||
|
writePng(source, output.resolve("Square150x150Logo.png"), 150)
|
||||||
|
writePng(source, output.resolve("StoreLogo.png"), 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writePng(source: BufferedImage, target: File, size: Int) {
|
||||||
|
val image = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB)
|
||||||
|
val graphics = image.createGraphics()
|
||||||
|
try {
|
||||||
|
graphics.setRenderingHint(
|
||||||
|
RenderingHints.KEY_INTERPOLATION,
|
||||||
|
RenderingHints.VALUE_INTERPOLATION_BICUBIC
|
||||||
|
)
|
||||||
|
graphics.setRenderingHint(
|
||||||
|
RenderingHints.KEY_RENDERING,
|
||||||
|
RenderingHints.VALUE_RENDER_QUALITY
|
||||||
|
)
|
||||||
|
graphics.drawImage(source, 0, 0, size, size, null)
|
||||||
|
} finally {
|
||||||
|
graphics.dispose()
|
||||||
|
}
|
||||||
|
ImageIO.write(image, "png", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DisableCachingByDefault(because = "Packages the staged MSIX app image with Windows SDK makeappx.")
|
||||||
|
abstract class PackageDesktopMsixTask @Inject constructor(
|
||||||
|
private val execOperations: ExecOperations
|
||||||
|
) : DefaultTask() {
|
||||||
|
@get:InputDirectory
|
||||||
|
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||||
|
abstract val packageRootDirectory: DirectoryProperty
|
||||||
|
|
||||||
|
@get:OutputFile
|
||||||
|
abstract val outputFile: RegularFileProperty
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val makeAppxPath: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val hostOsId: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val hostArchId: Property<String>
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun packageMsix() {
|
||||||
|
if (hostOsId.get() != "windows" || hostArchId.get() != "x64") {
|
||||||
|
throw GradleException(
|
||||||
|
"MSIX packaging requires a Windows x64 packaging host. " +
|
||||||
|
"Current host: ${hostOsId.get()} ${hostArchId.get()}."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val makeAppx = File(makeAppxPath.get())
|
||||||
|
if (!makeAppx.isFile) {
|
||||||
|
throw GradleException(
|
||||||
|
"Windows SDK makeappx.exe was not found at ${makeAppx.absolutePath}. " +
|
||||||
|
"Install the Windows SDK MSIX packaging tools or set " +
|
||||||
|
"-PdesktopMakeAppxPath=<path-to-makeappx.exe>."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val output = outputFile.get().asFile
|
||||||
|
output.parentFile.mkdirs()
|
||||||
|
if (output.exists() && !output.delete()) {
|
||||||
|
throw GradleException("Could not replace existing MSIX at ${output.absolutePath}.")
|
||||||
|
}
|
||||||
|
|
||||||
|
execOperations.exec {
|
||||||
|
executable = makeAppx.absolutePath
|
||||||
|
args(
|
||||||
|
"pack",
|
||||||
|
"/d",
|
||||||
|
packageRootDirectory.get().asFile.absolutePath,
|
||||||
|
"/p",
|
||||||
|
output.absolutePath,
|
||||||
|
"/o"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DisableCachingByDefault(because = "Signs the MSIX package with Windows SDK signtool.")
|
||||||
|
abstract class SignDesktopMsixTask @Inject constructor(
|
||||||
|
private val execOperations: ExecOperations
|
||||||
|
) : DefaultTask() {
|
||||||
|
@get:InputFile
|
||||||
|
@get:PathSensitive(PathSensitivity.NONE)
|
||||||
|
abstract val unsignedMsixFile: RegularFileProperty
|
||||||
|
|
||||||
|
@get:InputFile
|
||||||
|
@get:PathSensitive(PathSensitivity.NONE)
|
||||||
|
abstract val certificateFile: RegularFileProperty
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val signToolPath: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val certificatePassword: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val timestampUrl: Property<String>
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun signMsix() {
|
||||||
|
val signTool = File(signToolPath.get())
|
||||||
|
if (!signTool.isFile) {
|
||||||
|
throw GradleException(
|
||||||
|
"Windows SDK signtool.exe was not found at ${signTool.absolutePath}. " +
|
||||||
|
"Install the Windows SDK or set -PdesktopSignToolPath=<path-to-signtool.exe>."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val signArgs = mutableListOf(
|
||||||
|
"sign",
|
||||||
|
"/fd",
|
||||||
|
"SHA256",
|
||||||
|
"/f",
|
||||||
|
certificateFile.get().asFile.absolutePath
|
||||||
|
)
|
||||||
|
val password = certificatePassword.get().trim()
|
||||||
|
if (password.isNotEmpty()) {
|
||||||
|
signArgs += listOf("/p", password)
|
||||||
|
}
|
||||||
|
val timestamp = timestampUrl.get().trim()
|
||||||
|
if (timestamp.isNotEmpty()) {
|
||||||
|
signArgs += listOf("/tr", timestamp, "/td", "SHA256")
|
||||||
|
}
|
||||||
|
signArgs += unsignedMsixFile.get().asFile.absolutePath
|
||||||
|
|
||||||
|
execOperations.exec {
|
||||||
|
executable = signTool.absolutePath
|
||||||
|
args(signArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@DisableCachingByDefault(because = "Generates local desktop service config for native packages.")
|
@DisableCachingByDefault(because = "Generates local desktop service config for native packages.")
|
||||||
abstract class GenerateDesktopCloudConfigTask : DefaultTask() {
|
abstract class GenerateDesktopCloudConfigTask : DefaultTask() {
|
||||||
@get:Input
|
@get:Input
|
||||||
|
|
@ -136,6 +388,275 @@ abstract class VerifyDesktopNativePackagingTask : DefaultTask() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DisableCachingByDefault(because = "Generates AUR package metadata from the local Linux distributable.")
|
||||||
|
abstract class PrepareDesktopAurPackageTask : DefaultTask() {
|
||||||
|
@get:Input
|
||||||
|
abstract val aurPackageName: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val providedPackageName: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val packageVersion: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val packageRelease: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val packageDescription: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val appDisplayName: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val installDirectoryName: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val launcherName: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val executableName: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val sourceUrl: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val projectUrl: Property<String>
|
||||||
|
|
||||||
|
@get:InputFile
|
||||||
|
@get:PathSensitive(PathSensitivity.NONE)
|
||||||
|
abstract val linuxTarFile: RegularFileProperty
|
||||||
|
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val outputDirectory: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun prepare() {
|
||||||
|
val output = outputDirectory.get().asFile
|
||||||
|
val sourceTar = linuxTarFile.get().asFile
|
||||||
|
if (!sourceTar.isFile) {
|
||||||
|
throw GradleException("Missing Linux tarball for AUR packaging: ${sourceTar.absolutePath}")
|
||||||
|
}
|
||||||
|
|
||||||
|
output.deleteRecursively()
|
||||||
|
output.mkdirs()
|
||||||
|
|
||||||
|
val stagedTar = output.resolve(sourceTar.name)
|
||||||
|
sourceTar.copyTo(stagedTar, overwrite = true)
|
||||||
|
val sha256 = stagedTar.sha256()
|
||||||
|
val configuredSourceUrl = sourceUrl.get().trim()
|
||||||
|
val sourceEntry = if (configuredSourceUrl.isBlank()) {
|
||||||
|
stagedTar.name
|
||||||
|
} else {
|
||||||
|
"${stagedTar.name}::$configuredSourceUrl"
|
||||||
|
}
|
||||||
|
|
||||||
|
output.resolve("PKGBUILD").writeText(
|
||||||
|
aurPkgbuild(
|
||||||
|
pkgname = aurPackageName.get(),
|
||||||
|
providedPackage = providedPackageName.get(),
|
||||||
|
pkgver = packageVersion.get(),
|
||||||
|
pkgrel = packageRelease.get(),
|
||||||
|
pkgdesc = packageDescription.get(),
|
||||||
|
appName = appDisplayName.get(),
|
||||||
|
installDir = installDirectoryName.get(),
|
||||||
|
launcher = launcherName.get(),
|
||||||
|
executable = executableName.get(),
|
||||||
|
source = sourceEntry,
|
||||||
|
sha256 = sha256,
|
||||||
|
projectUrl = projectUrl.get()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output.resolve(".SRCINFO").writeText(
|
||||||
|
aurSrcInfo(
|
||||||
|
pkgname = aurPackageName.get(),
|
||||||
|
providedPackage = providedPackageName.get(),
|
||||||
|
pkgver = packageVersion.get(),
|
||||||
|
pkgrel = packageRelease.get(),
|
||||||
|
pkgdesc = packageDescription.get(),
|
||||||
|
source = sourceEntry,
|
||||||
|
sha256 = sha256,
|
||||||
|
projectUrl = projectUrl.get()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.sha256(): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
inputStream().use { input ->
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
digest.update(buffer, 0, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shellSingleQuoted(value: String): String {
|
||||||
|
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun archRuntimeDependencies(): List<String> {
|
||||||
|
return listOf(
|
||||||
|
"alsa-lib",
|
||||||
|
"atk",
|
||||||
|
"cairo",
|
||||||
|
"dbus",
|
||||||
|
"expat",
|
||||||
|
"fontconfig",
|
||||||
|
"freetype2",
|
||||||
|
"gcc-libs",
|
||||||
|
"gdk-pixbuf2",
|
||||||
|
"glib2",
|
||||||
|
"glibc",
|
||||||
|
"gtk3",
|
||||||
|
"libcups",
|
||||||
|
"libarchive",
|
||||||
|
"libsecret",
|
||||||
|
"libx11",
|
||||||
|
"libxcomposite",
|
||||||
|
"libxdamage",
|
||||||
|
"libxext",
|
||||||
|
"libxi",
|
||||||
|
"libxrandr",
|
||||||
|
"libxrender",
|
||||||
|
"libxtst",
|
||||||
|
"nss",
|
||||||
|
"pango",
|
||||||
|
"zlib"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aurPkgbuild(
|
||||||
|
pkgname: String,
|
||||||
|
providedPackage: String,
|
||||||
|
pkgver: String,
|
||||||
|
pkgrel: String,
|
||||||
|
pkgdesc: String,
|
||||||
|
appName: String,
|
||||||
|
installDir: String,
|
||||||
|
launcher: String,
|
||||||
|
executable: String,
|
||||||
|
source: String,
|
||||||
|
sha256: String,
|
||||||
|
projectUrl: String
|
||||||
|
): String {
|
||||||
|
val desktopFile = "$providedPackage.desktop"
|
||||||
|
val iconName = providedPackage
|
||||||
|
val depends = archRuntimeDependencies()
|
||||||
|
val mimeTypes = archDesktopMimeTypes()
|
||||||
|
return """
|
||||||
|
pkgname=${shellSingleQuoted(pkgname)}
|
||||||
|
pkgver=${shellSingleQuoted(pkgver)}
|
||||||
|
pkgrel=${shellSingleQuoted(pkgrel)}
|
||||||
|
pkgdesc=${shellSingleQuoted(pkgdesc)}
|
||||||
|
arch=('x86_64')
|
||||||
|
url=${shellSingleQuoted(projectUrl)}
|
||||||
|
license=('AGPL-3.0-only')
|
||||||
|
depends=(${depends.joinToString(" ") { shellSingleQuoted(it) }})
|
||||||
|
provides=(${shellSingleQuoted(providedPackage)})
|
||||||
|
conflicts=(${shellSingleQuoted(providedPackage)})
|
||||||
|
source=(${shellSingleQuoted(source)})
|
||||||
|
sha256sums=(${shellSingleQuoted(sha256)})
|
||||||
|
options=('!debug')
|
||||||
|
|
||||||
|
package() {
|
||||||
|
install -dm755 "${'$'}pkgdir/opt/$installDir"
|
||||||
|
cp -a "$installDir/." "${'$'}pkgdir/opt/$installDir/"
|
||||||
|
chmod 755 "${'$'}pkgdir/opt/$installDir/bin/$executable"
|
||||||
|
|
||||||
|
install -dm755 "${'$'}pkgdir/usr/bin"
|
||||||
|
ln -sf "/opt/$installDir/bin/$executable" "${'$'}pkgdir/usr/bin/$launcher"
|
||||||
|
|
||||||
|
install -Dm644 "${'$'}pkgdir/opt/$installDir/share/licenses/LICENSE" "${'$'}pkgdir/usr/share/licenses/${'$'}pkgname/LICENSE"
|
||||||
|
|
||||||
|
local icon_path
|
||||||
|
icon_path="${'$'}(find "${'$'}pkgdir/opt/$installDir" -name 'episteme_icon.png' -print -quit)"
|
||||||
|
if [[ -n "${'$'}icon_path" ]]; then
|
||||||
|
install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/icons/hicolor/512x512/apps/$iconName.png"
|
||||||
|
install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/pixmaps/$iconName.png"
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -Dm644 /dev/stdin "${'$'}pkgdir/usr/share/applications/$desktopFile" <<'EOF'
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=$appName
|
||||||
|
Comment=$pkgdesc
|
||||||
|
Exec=$launcher %F
|
||||||
|
Icon=$iconName
|
||||||
|
Terminal=false
|
||||||
|
Categories=Office;Viewer;
|
||||||
|
MimeType=${mimeTypes.joinToString(";")};
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
""".trimIndent() + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aurSrcInfo(
|
||||||
|
pkgname: String,
|
||||||
|
providedPackage: String,
|
||||||
|
pkgver: String,
|
||||||
|
pkgrel: String,
|
||||||
|
pkgdesc: String,
|
||||||
|
source: String,
|
||||||
|
sha256: String,
|
||||||
|
projectUrl: String
|
||||||
|
): String {
|
||||||
|
val depends = archRuntimeDependencies()
|
||||||
|
return """
|
||||||
|
pkgbase = $pkgname
|
||||||
|
pkgdesc = $pkgdesc
|
||||||
|
pkgver = $pkgver
|
||||||
|
pkgrel = $pkgrel
|
||||||
|
url = $projectUrl
|
||||||
|
arch = x86_64
|
||||||
|
license = AGPL-3.0-only
|
||||||
|
${depends.joinToString("\n") { "\tdepends = $it" }}
|
||||||
|
provides = $providedPackage
|
||||||
|
conflicts = $providedPackage
|
||||||
|
source = $source
|
||||||
|
sha256sums = $sha256
|
||||||
|
|
||||||
|
pkgname = $pkgname
|
||||||
|
""".trimIndent() + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun archDesktopMimeTypes(): List<String> {
|
||||||
|
return listOf(
|
||||||
|
"application/pdf",
|
||||||
|
"application/epub+zip",
|
||||||
|
"application/x-mobipocket-ebook",
|
||||||
|
"application/vnd.amazon.ebook",
|
||||||
|
"application/vnd.amazon.mobi8-ebook",
|
||||||
|
"text/markdown",
|
||||||
|
"text/x-markdown",
|
||||||
|
"text/plain",
|
||||||
|
"text/html",
|
||||||
|
"application/xhtml+xml",
|
||||||
|
"application/x-fictionbook+xml",
|
||||||
|
"application/x-zip-compressed-fb2",
|
||||||
|
"application/zip",
|
||||||
|
"application/vnd.comicbook+zip",
|
||||||
|
"application/x-cbz",
|
||||||
|
"application/vnd.comicbook-rar",
|
||||||
|
"application/x-cbr",
|
||||||
|
"application/x-rar-compressed",
|
||||||
|
"application/x-cb7",
|
||||||
|
"application/x-7z-compressed",
|
||||||
|
"application/vnd.comicbook+tar",
|
||||||
|
"application/x-cbt",
|
||||||
|
"application/x-tar",
|
||||||
|
"application/tar",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"application/vnd.oasis.opendocument.text",
|
||||||
|
"application/x-vnd.oasis.opendocument.text-flat-xml"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@DisableCachingByDefault(because = "Strips stale jar signatures in-place after ProGuard rewrites signed dependencies.")
|
@DisableCachingByDefault(because = "Strips stale jar signatures in-place after ProGuard rewrites signed dependencies.")
|
||||||
abstract class StripInvalidJarSignaturesTask : DefaultTask() {
|
abstract class StripInvalidJarSignaturesTask : DefaultTask() {
|
||||||
@get:Input
|
@get:Input
|
||||||
|
|
@ -546,6 +1067,79 @@ fun normalizeDesktopPackageFormats(
|
||||||
return formats
|
return formats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun normalizeDesktopMsixVersion(rawVersion: String): String {
|
||||||
|
val parts = rawVersion.trim().split('.')
|
||||||
|
if (parts.size !in 3..4 || parts.any { it.isBlank() || it.all(Char::isDigit).not() }) {
|
||||||
|
throw GradleException(
|
||||||
|
"desktopMsixVersion must be a numeric Windows package version with three or four parts, " +
|
||||||
|
"for example 1.0.1 or 1.0.1.0."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val normalized = if (parts.size == 3) parts + "0" else parts
|
||||||
|
normalized.forEach { part ->
|
||||||
|
val value = part.toIntOrNull()
|
||||||
|
if (value == null || value !in 0..65535) {
|
||||||
|
throw GradleException("desktopMsixVersion part '$part' is outside the MSIX range 0..65535.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized.joinToString(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalizeDesktopMsixIdentityName(rawName: String): String {
|
||||||
|
val normalized = rawName.trim()
|
||||||
|
if (!Regex("[A-Za-z0-9][A-Za-z0-9.-]{2,49}").matches(normalized)) {
|
||||||
|
throw GradleException(
|
||||||
|
"desktopMsixIdentityName must be 3-50 characters using letters, numbers, dots, or hyphens."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
fun desktopMsixArchitecture(osArch: String = System.getProperty("os.arch")): String {
|
||||||
|
return when (desktopArchId(osArch)) {
|
||||||
|
"x64" -> "x64"
|
||||||
|
"arm64" -> "arm64"
|
||||||
|
"x86" -> "x86"
|
||||||
|
else -> "neutral"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun latestExistingFile(candidates: List<File>): File? {
|
||||||
|
return candidates.filter { it.isFile }.maxByOrNull { it.absolutePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun windowsSdkToolCandidates(toolName: String): List<File> {
|
||||||
|
val roots = listOfNotNull(
|
||||||
|
System.getenv("WindowsSdkDir")?.let(::File),
|
||||||
|
File("C:/Program Files (x86)/Windows Kits/10"),
|
||||||
|
File("C:/Program Files/Windows Kits/10"),
|
||||||
|
File("C:/Program Files (x86)/Windows Kits/10/App Certification Kit"),
|
||||||
|
File("C:/Program Files/Windows Kits/10/App Certification Kit")
|
||||||
|
).distinctBy { it.absolutePath.lowercase() }
|
||||||
|
val sdkBins = roots.flatMap { root ->
|
||||||
|
safeChildDirectories(root.resolve("bin")).flatMap { versionDir ->
|
||||||
|
listOf(
|
||||||
|
versionDir.resolve("x64/$toolName.exe"),
|
||||||
|
versionDir.resolve("x86/$toolName.exe"),
|
||||||
|
versionDir.resolve(toolName)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val directBins = roots.map { root -> root.resolve("$toolName.exe") }
|
||||||
|
val pathBins = (System.getenv("PATH") ?: "")
|
||||||
|
.split(File.pathSeparator)
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.map { File(it).resolve("$toolName.exe") }
|
||||||
|
return sdkBins + directBins + pathBins
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findWindowsSdkTool(toolName: String, explicitPath: String?): File {
|
||||||
|
val explicit = explicitPath?.trim()?.takeIf { it.isNotEmpty() }?.let(::File)
|
||||||
|
if (explicit != null) return explicit
|
||||||
|
return latestExistingFile(windowsSdkToolCandidates(toolName))
|
||||||
|
?: File(rootProject.projectDir, "__missing_windows_sdk_tool__/$toolName.exe")
|
||||||
|
}
|
||||||
|
|
||||||
val desktopVersionName = "1.0.1"
|
val desktopVersionName = "1.0.1"
|
||||||
val desktopFlavor = providers.gradleProperty("desktopFlavor")
|
val desktopFlavor = providers.gradleProperty("desktopFlavor")
|
||||||
.orElse("standard")
|
.orElse("standard")
|
||||||
|
|
@ -565,19 +1159,49 @@ val desktopPackageVersion = providers.gradleProperty("desktopPackageVersion")
|
||||||
.orElse(desktopResolvedVersionName)
|
.orElse(desktopResolvedVersionName)
|
||||||
.map(::normalizeDesktopPackageVersion)
|
.map(::normalizeDesktopPackageVersion)
|
||||||
val desktopPackageName = if (isOssOfflineDesktop) "Episteme oss" else "Episteme"
|
val desktopPackageName = if (isOssOfflineDesktop) "Episteme oss" else "Episteme"
|
||||||
|
val desktopLinuxPackageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme"
|
||||||
val desktopPackageDescription = if (isOssOfflineDesktop) {
|
val desktopPackageDescription = if (isOssOfflineDesktop) {
|
||||||
"Episteme oss offline desktop reader"
|
"Episteme oss offline desktop reader"
|
||||||
} else {
|
} else {
|
||||||
"Episteme desktop reader"
|
"Episteme desktop reader"
|
||||||
}
|
}
|
||||||
val desktopVendor = providers.gradleProperty("desktopVendor").orElse("Aryan")
|
val desktopVendor = providers.gradleProperty("desktopVendor").orElse("Aryan")
|
||||||
|
val desktopVendorName = desktopVendor.get()
|
||||||
|
val desktopProjectUrl = providers.gradleProperty("desktopProjectUrl")
|
||||||
|
.orElse("https://github.com/Aryan-Raj3112/episteme")
|
||||||
val desktopOsName = System.getProperty("os.name")
|
val desktopOsName = System.getProperty("os.name")
|
||||||
val desktopOsArch = System.getProperty("os.arch")
|
val desktopOsArch = System.getProperty("os.arch")
|
||||||
val desktopPackageArchitecture = normalizeDesktopPackageArchitecture(desktopOsArch)
|
val desktopPackageArchitecture = normalizeDesktopPackageArchitecture(desktopOsArch)
|
||||||
|
val desktopAurPackageName = providers.gradleProperty("desktopAurPackageName")
|
||||||
|
.orElse(if (isOssOfflineDesktop) "episteme-oss-bin" else "episteme-bin")
|
||||||
|
val desktopAurPackageRelease = providers.gradleProperty("desktopAurPackageRelease")
|
||||||
|
.orElse("1")
|
||||||
|
val desktopAurSourceUrl = providers.gradleProperty("desktopAurSourceUrl")
|
||||||
|
.orElse("")
|
||||||
val desktopPackageTargetFormats = providers.gradleProperty("desktopPackageFormats")
|
val desktopPackageTargetFormats = providers.gradleProperty("desktopPackageFormats")
|
||||||
.orElse(desktopDefaultPackageFormats(desktopOsName))
|
.orElse(desktopDefaultPackageFormats(desktopOsName))
|
||||||
.map { normalizeDesktopPackageFormats(it, desktopOsName) }
|
.map { normalizeDesktopPackageFormats(it, desktopOsName) }
|
||||||
.get()
|
.get()
|
||||||
|
val desktopMsixIdentityName = providers.gradleProperty("desktopMsixIdentityName")
|
||||||
|
.orElse(if (isOssOfflineDesktop) "Aryan.EpistemeOss" else "Aryan.Episteme")
|
||||||
|
.map(::normalizeDesktopMsixIdentityName)
|
||||||
|
.get()
|
||||||
|
val desktopMsixPublisher = providers.gradleProperty("desktopMsixPublisher")
|
||||||
|
.orElse("CN=$desktopVendorName")
|
||||||
|
val desktopMsixPublisherDisplayName = providers.gradleProperty("desktopMsixPublisherDisplayName")
|
||||||
|
.orElse(desktopVendor)
|
||||||
|
val desktopMsixVersion = providers.gradleProperty("desktopMsixVersion")
|
||||||
|
.orElse(desktopPackageVersion)
|
||||||
|
.map(::normalizeDesktopMsixVersion)
|
||||||
|
.get()
|
||||||
|
val desktopMsixArchitecture = desktopMsixArchitecture(desktopOsArch)
|
||||||
|
val desktopMakeAppxPath = providers.gradleProperty("desktopMakeAppxPath").orNull
|
||||||
|
val desktopSignToolPath = providers.gradleProperty("desktopSignToolPath").orNull
|
||||||
|
val desktopMsixCertificatePath = providers.gradleProperty("desktopMsixCertificatePath").orNull
|
||||||
|
val desktopMsixCertificatePassword = providers.gradleProperty("desktopMsixCertificatePassword")
|
||||||
|
.orElse("")
|
||||||
|
val desktopMsixTimestampUrl = providers.gradleProperty("desktopMsixTimestampUrl")
|
||||||
|
.orElse("http://timestamp.digicert.com")
|
||||||
val desktopNativePackageSupportedHost = desktopOsId(desktopOsName) in setOf("windows", "linux") &&
|
val desktopNativePackageSupportedHost = desktopOsId(desktopOsName) in setOf("windows", "linux") &&
|
||||||
desktopArchId(desktopOsArch) == "x64"
|
desktopArchId(desktopOsArch) == "x64"
|
||||||
val desktopReleaseProguardEnabled = providers.gradleProperty("desktopReleaseProguard")
|
val desktopReleaseProguardEnabled = providers.gradleProperty("desktopReleaseProguard")
|
||||||
|
|
@ -688,6 +1312,127 @@ val verifyDesktopNativePackaging by tasks.registering(VerifyDesktopNativePackagi
|
||||||
missingStandardServiceConfig.set(desktopMissingStandardServiceConfig)
|
missingStandardServiceConfig.set(desktopMissingStandardServiceConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val desktopDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main/app/$desktopPackageName")
|
||||||
|
val desktopReleaseDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main-release/app/$desktopPackageName")
|
||||||
|
val desktopLinuxTarFileName = "${desktopLinuxPackageName}-${desktopPackageVersion.get()}-linux-$desktopPackageArchitecture.tar.gz"
|
||||||
|
val desktopAurOutputDir = layout.buildDirectory.dir("aur/${desktopAurPackageName.get()}")
|
||||||
|
val desktopMsixPackageDir = layout.buildDirectory.dir("msix/package")
|
||||||
|
val desktopMsixAssetsDir = layout.buildDirectory.dir("msix/generated/assets")
|
||||||
|
val desktopMsixManifestFile = layout.buildDirectory.file("msix/generated/AppxManifest.xml")
|
||||||
|
val desktopMsixOutputFile = layout.buildDirectory.file(
|
||||||
|
"compose/binaries/main-release/msix/${desktopLinuxPackageName}-${desktopPackageVersion.get()}-windows-$desktopPackageArchitecture.msix"
|
||||||
|
)
|
||||||
|
|
||||||
|
val packageLinuxTar by tasks.registering(Tar::class) {
|
||||||
|
group = "distribution"
|
||||||
|
description = "Packages the Linux desktop distributable as a tar.gz for Arch/AUR packaging."
|
||||||
|
dependsOn("createDistributable")
|
||||||
|
|
||||||
|
archiveFileName.set(desktopLinuxTarFileName)
|
||||||
|
destinationDirectory.set(layout.buildDirectory.dir("compose/binaries/main/linux-tar"))
|
||||||
|
compression = Compression.GZIP
|
||||||
|
|
||||||
|
from(desktopDistributableAppDir) {
|
||||||
|
into(desktopLinuxPackageName)
|
||||||
|
}
|
||||||
|
from(desktopLinuxIconFile) {
|
||||||
|
into("$desktopLinuxPackageName/share")
|
||||||
|
}
|
||||||
|
from(rootProject.layout.projectDirectory.file("LICENSE")) {
|
||||||
|
into("$desktopLinuxPackageName/share/licenses")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val prepareAurPackage by tasks.registering(PrepareDesktopAurPackageTask::class) {
|
||||||
|
group = "distribution"
|
||||||
|
description = "Generates a local AUR package directory with PKGBUILD and .SRCINFO."
|
||||||
|
dependsOn(packageLinuxTar)
|
||||||
|
|
||||||
|
aurPackageName.set(desktopAurPackageName)
|
||||||
|
providedPackageName.set(desktopLinuxPackageName)
|
||||||
|
packageVersion.set(desktopPackageVersion)
|
||||||
|
packageRelease.set(desktopAurPackageRelease)
|
||||||
|
packageDescription.set(desktopPackageDescription)
|
||||||
|
appDisplayName.set(desktopPackageName)
|
||||||
|
installDirectoryName.set(desktopLinuxPackageName)
|
||||||
|
launcherName.set(desktopLinuxPackageName)
|
||||||
|
executableName.set(desktopPackageName)
|
||||||
|
sourceUrl.set(desktopAurSourceUrl)
|
||||||
|
projectUrl.set(desktopProjectUrl)
|
||||||
|
linuxTarFile.set(packageLinuxTar.flatMap { it.archiveFile })
|
||||||
|
outputDirectory.set(desktopAurOutputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Exec>("packageAur") {
|
||||||
|
group = "distribution"
|
||||||
|
description = "Builds the generated AUR package with makepkg. Run this on Arch Linux."
|
||||||
|
dependsOn(prepareAurPackage)
|
||||||
|
|
||||||
|
commandLine("makepkg", "-sf", "--cleanbuild")
|
||||||
|
workingDir = desktopAurOutputDir.get().asFile
|
||||||
|
}
|
||||||
|
|
||||||
|
val generateDesktopMsixManifest by tasks.registering(GenerateDesktopMsixManifestTask::class) {
|
||||||
|
identityName.set(desktopMsixIdentityName)
|
||||||
|
publisher.set(desktopMsixPublisher)
|
||||||
|
publisherDisplayName.set(desktopMsixPublisherDisplayName)
|
||||||
|
packageName.set(desktopPackageName)
|
||||||
|
packageDescription.set(desktopPackageDescription)
|
||||||
|
packageVersion.set(desktopMsixVersion)
|
||||||
|
architecture.set(desktopMsixArchitecture)
|
||||||
|
executablePath.set("$desktopPackageName.exe")
|
||||||
|
outputFile.set(desktopMsixManifestFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
val generateDesktopMsixAssets by tasks.registering(GenerateDesktopMsixAssetsTask::class) {
|
||||||
|
sourceIconFile.set(desktopLinuxIconFile)
|
||||||
|
outputDirectory.set(desktopMsixAssetsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
val prepareReleaseMsixPackage by tasks.registering(Sync::class) {
|
||||||
|
group = "distribution"
|
||||||
|
description = "Stages the release Windows app image and MSIX metadata for makeappx."
|
||||||
|
dependsOn("createReleaseDistributable", generateDesktopMsixManifest, generateDesktopMsixAssets)
|
||||||
|
|
||||||
|
from(desktopReleaseDistributableAppDir)
|
||||||
|
from(desktopMsixManifestFile)
|
||||||
|
from(desktopMsixAssetsDir) {
|
||||||
|
into("Assets")
|
||||||
|
}
|
||||||
|
into(desktopMsixPackageDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
val packageReleaseMsix by tasks.registering(PackageDesktopMsixTask::class) {
|
||||||
|
group = "distribution"
|
||||||
|
description = "Packages the release Windows app image as an MSIX using Windows SDK makeappx."
|
||||||
|
dependsOn(prepareReleaseMsixPackage)
|
||||||
|
|
||||||
|
val makeAppx = findWindowsSdkTool("makeappx", desktopMakeAppxPath)
|
||||||
|
packageRootDirectory.set(desktopMsixPackageDir)
|
||||||
|
outputFile.set(desktopMsixOutputFile)
|
||||||
|
makeAppxPath.set(makeAppx.absolutePath)
|
||||||
|
hostOsId.set(desktopOsId(desktopOsName))
|
||||||
|
hostArchId.set(desktopArchId(desktopOsArch))
|
||||||
|
}
|
||||||
|
|
||||||
|
val signReleaseMsix = desktopMsixCertificatePath?.trim()?.takeIf { it.isNotEmpty() }?.let { certificatePath ->
|
||||||
|
tasks.register<SignDesktopMsixTask>("signReleaseMsix") {
|
||||||
|
group = "distribution"
|
||||||
|
description = "Signs the release MSIX with signtool when -PdesktopMsixCertificatePath is configured."
|
||||||
|
dependsOn(packageReleaseMsix)
|
||||||
|
|
||||||
|
val signTool = findWindowsSdkTool("signtool", desktopSignToolPath)
|
||||||
|
val resolvedCertificateFile = File(certificatePath).let { file ->
|
||||||
|
if (file.isAbsolute) file else project.file(certificatePath)
|
||||||
|
}
|
||||||
|
unsignedMsixFile.set(desktopMsixOutputFile)
|
||||||
|
certificateFile.set(resolvedCertificateFile)
|
||||||
|
signToolPath.set(signTool.absolutePath)
|
||||||
|
certificatePassword.set(desktopMsixCertificatePassword)
|
||||||
|
timestampUrl.set(desktopMsixTimestampUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvm("desktop")
|
jvm("desktop")
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
@ -771,7 +1516,7 @@ compose.desktop {
|
||||||
}
|
}
|
||||||
linux {
|
linux {
|
||||||
iconFile.set(desktopLinuxIconFile)
|
iconFile.set(desktopLinuxIconFile)
|
||||||
packageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme"
|
packageName = desktopLinuxPackageName
|
||||||
debMaintainer = "epistemereader@gmail.com"
|
debMaintainer = "epistemereader@gmail.com"
|
||||||
menuGroup = "Office"
|
menuGroup = "Office"
|
||||||
appCategory = "Office"
|
appCategory = "Office"
|
||||||
|
|
@ -818,6 +1563,7 @@ tasks.matching {
|
||||||
"packageReleaseDistributionForCurrentOS",
|
"packageReleaseDistributionForCurrentOS",
|
||||||
"packageReleaseExe",
|
"packageReleaseExe",
|
||||||
"packageReleaseMsi",
|
"packageReleaseMsi",
|
||||||
|
"packageReleaseMsix",
|
||||||
"packageReleaseDeb",
|
"packageReleaseDeb",
|
||||||
"packageReleaseRpm",
|
"packageReleaseRpm",
|
||||||
"runReleaseDistributable"
|
"runReleaseDistributable"
|
||||||
|
|
@ -853,10 +1599,16 @@ tasks.matching {
|
||||||
"packageReleaseExe",
|
"packageReleaseExe",
|
||||||
"packageMsi",
|
"packageMsi",
|
||||||
"packageReleaseMsi",
|
"packageReleaseMsi",
|
||||||
|
"prepareReleaseMsixPackage",
|
||||||
|
"packageReleaseMsix",
|
||||||
|
"signReleaseMsix",
|
||||||
"packageDeb",
|
"packageDeb",
|
||||||
"packageReleaseDeb",
|
"packageReleaseDeb",
|
||||||
"packageRpm",
|
"packageRpm",
|
||||||
"packageReleaseRpm",
|
"packageReleaseRpm",
|
||||||
|
"packageLinuxTar",
|
||||||
|
"prepareAurPackage",
|
||||||
|
"packageAur",
|
||||||
"runDistributable",
|
"runDistributable",
|
||||||
"runReleaseDistributable"
|
"runReleaseDistributable"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
229
desktopApp/packaging/README.md
Normal file
229
desktopApp/packaging/README.md
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
# Desktop package builds
|
||||||
|
|
||||||
|
Build Linux packages on the matching distro VM when testing manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Reader
|
||||||
|
./gradlew :desktopApp:packageDeb -x test
|
||||||
|
./gradlew :desktopApp:packageRpm -x test
|
||||||
|
./gradlew :desktopApp:packageAur -x test
|
||||||
|
```
|
||||||
|
|
||||||
|
Build a Windows MSIX locally on Windows with the Windows SDK installed:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\aryan\Desktop\Reader
|
||||||
|
.\gradlew.bat -PdesktopOnly=true -PdesktopAllowUnconfiguredStandardServices=true :desktopApp:packageReleaseMsix -x test
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the newest generated MSIX to your desktop:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$msix = Get-ChildItem .\desktopApp\build\compose\binaries\main-release\msix -Filter *.msix | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||||
|
Copy-Item -Force $msix.FullName "$env:USERPROFILE\Desktop\"
|
||||||
|
```
|
||||||
|
|
||||||
|
The MSIX task is separate from MSI packaging. It stages the release app image at
|
||||||
|
`desktopApp/build/msix/package`, packages it with Windows SDK `makeappx.exe`, and
|
||||||
|
writes the MSIX to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
desktopApp/build/compose/binaries/main-release/msix
|
||||||
|
```
|
||||||
|
|
||||||
|
For Microsoft Store submission, set the package identity values from Partner
|
||||||
|
Center so `AppxManifest.xml` matches the reserved app identity:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\gradlew.bat `
|
||||||
|
-PdesktopOnly=true `
|
||||||
|
-PdesktopMsixIdentityName=<Partner Center package identity name> `
|
||||||
|
-PdesktopMsixPublisher=<Partner Center publisher CN> `
|
||||||
|
-PdesktopMsixPublisherDisplayName=<Publisher display name> `
|
||||||
|
:desktopApp:packageReleaseMsix -x test
|
||||||
|
```
|
||||||
|
|
||||||
|
If Windows SDK tools are not on `PATH`, pass them explicitly:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\gradlew.bat `
|
||||||
|
-PdesktopMakeAppxPath="C:\Program Files (x86)\Windows Kits\10\bin\<sdk-version>\x64\makeappx.exe" `
|
||||||
|
:desktopApp:packageReleaseMsix -x test
|
||||||
|
```
|
||||||
|
|
||||||
|
Local signing is optional and separate:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\gradlew.bat `
|
||||||
|
-PdesktopMsixCertificatePath=C:\path\to\certificate.pfx `
|
||||||
|
-PdesktopMsixCertificatePassword=<password> `
|
||||||
|
:desktopApp:signReleaseMsix -x test
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended VM split:
|
||||||
|
|
||||||
|
- Ubuntu: `./gradlew :desktopApp:packageDeb -x test`
|
||||||
|
- Fedora: `./gradlew :desktopApp:packageRpm -x test`
|
||||||
|
- Arch: `./gradlew :desktopApp:packageAur -x test`
|
||||||
|
|
||||||
|
Desktop-only Gradle invocations automatically skip the Android app module and the
|
||||||
|
Android target in `:shared`, so desktop packaging does not require `sdk.dir`,
|
||||||
|
Android SDK installation, or Android release signing values. You can force that
|
||||||
|
mode for unusual command shapes with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew -PdesktopOnly=true :desktopApp:packageDeb -x test
|
||||||
|
```
|
||||||
|
|
||||||
|
Desktop release values are centralized in `gradle.properties`:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
desktopVersion=1.0.1
|
||||||
|
desktopPackageVersion=1.0.1
|
||||||
|
desktopAurPackageRelease=1
|
||||||
|
```
|
||||||
|
|
||||||
|
The AUR path is native Arch packaging. It does not wrap the `.deb` or `.rpm`.
|
||||||
|
`packageAur` first creates a Linux app tarball, then generates an AUR worktree at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
desktopApp/build/aur/episteme-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
On Arch, install/test the generated package with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-bin/*.pkg.tar.zst
|
||||||
|
episteme
|
||||||
|
```
|
||||||
|
|
||||||
|
For the OSS/offline flavor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :desktopApp:packageAur -PdesktopFlavor=oss -x test
|
||||||
|
sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-oss-bin/*.pkg.tar.zst
|
||||||
|
episteme-oss
|
||||||
|
```
|
||||||
|
|
||||||
|
To inspect the AUR recipe manually instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :desktopApp:prepareAurPackage -x test
|
||||||
|
cd ~/Reader/desktopApp/build/aur/episteme-bin
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
For publish-ready AUR metadata, pass the release tarball URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :desktopApp:prepareAurPackage \
|
||||||
|
-PdesktopAurSourceUrl=https://example.com/releases/episteme-1.0.1-linux-x64.tar.gz \
|
||||||
|
-x test
|
||||||
|
```
|
||||||
|
|
||||||
|
Then publish the generated `PKGBUILD` and `.SRCINFO` from the AUR directory.
|
||||||
|
|
||||||
|
The generated AUR recipes use `license=('AGPL-3.0-only')` and install the root
|
||||||
|
`LICENSE` file into `/usr/share/licenses/$pkgname/`.
|
||||||
|
|
||||||
|
## AUR repository setup
|
||||||
|
|
||||||
|
Create an account at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://aur.archlinux.org/register/
|
||||||
|
```
|
||||||
|
|
||||||
|
Add your public SSH key in the account settings, then confirm SSH works:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh aur@aur.archlinux.org
|
||||||
|
```
|
||||||
|
|
||||||
|
The command should authenticate and print AUR help text. It will not open a
|
||||||
|
normal shell.
|
||||||
|
|
||||||
|
Create the package repos by cloning their not-yet-existing names:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://aur@aur.archlinux.org/episteme-bin.git
|
||||||
|
git clone ssh://aur@aur.archlinux.org/episteme-oss-bin.git
|
||||||
|
```
|
||||||
|
|
||||||
|
If a name already exists, inspect it first. If it is abandoned, follow the AUR
|
||||||
|
orphan/adoption process instead of creating a duplicate package name.
|
||||||
|
|
||||||
|
For each release, extract the matching `aur-<package>-<version>.tar.gz` metadata
|
||||||
|
archive from the GitHub release, copy `PKGBUILD` and `.SRCINFO` into the matching
|
||||||
|
AUR clone, then commit and push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf aur-episteme-bin-1.0.1.tar.gz -C episteme-bin
|
||||||
|
cd episteme-bin
|
||||||
|
git add PKGBUILD .SRCINFO
|
||||||
|
git commit -m "Update to 1.0.1"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat the same flow for `episteme-oss-bin`.
|
||||||
|
|
||||||
|
## CI release workflow
|
||||||
|
|
||||||
|
`Desktop release` in GitHub Actions builds desktop artifacts for standard and
|
||||||
|
OSS flavors:
|
||||||
|
|
||||||
|
- Windows MSI
|
||||||
|
- Ubuntu/Debian DEB
|
||||||
|
- Fedora RPM
|
||||||
|
- Linux tarball used by AUR
|
||||||
|
- Direct Arch `.pkg.tar.zst`
|
||||||
|
- AUR metadata archives containing `PKGBUILD` and `.SRCINFO`
|
||||||
|
- `SHA256SUMS.txt`
|
||||||
|
|
||||||
|
Before running it, publish Pdfium once from a machine that has the ignored
|
||||||
|
`third_party/pdfium` folders:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\desktop\publish-pdfium-release.ps1 `
|
||||||
|
-Repository Aryan-Raj3112/episteme `
|
||||||
|
-Tag pdfium-desktop-v1
|
||||||
|
```
|
||||||
|
|
||||||
|
That release must contain:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pdfium-linux-x64-v8.zip
|
||||||
|
pdfium-win-x64-v8.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
The desktop release workflow downloads those assets with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\desktop\download-pdfium.ps1 -Tag pdfium-desktop-v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Required GitHub Secrets for standard desktop packages:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DESKTOP_FIREBASE_PROJECT_ID
|
||||||
|
DESKTOP_FIREBASE_WEB_API_KEY
|
||||||
|
DESKTOP_GOOGLE_OAUTH_CLIENT_ID
|
||||||
|
DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
`MYAPP_RELEASE_STORE_FILE` is not used by desktop packaging. Android is skipped
|
||||||
|
for `:desktopApp:*` tasks.
|
||||||
|
|
||||||
|
AUR publishing still needs the two AUR repos:
|
||||||
|
|
||||||
|
```text
|
||||||
|
episteme-bin
|
||||||
|
episteme-oss-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload the generated `PKGBUILD` and `.SRCINFO` from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
aur-episteme-bin-<version>.tar.gz
|
||||||
|
aur-episteme-oss-bin-<version>.tar.gz
|
||||||
|
```
|
||||||
|
|
@ -77,6 +77,16 @@ internal fun chooseSaveImageFile(defaultFileName: String): File? {
|
||||||
return File(directory, file)
|
return File(directory, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun chooseSaveBookFile(defaultFileName: String): File? {
|
||||||
|
val dialog = FileDialog(null as Frame?, desktopDialogString("action_save_copy_to_device", "Save copy to device"), FileDialog.SAVE).apply {
|
||||||
|
file = defaultFileName
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val directory = dialog.directory ?: return null
|
||||||
|
val file = dialog.file ?: return null
|
||||||
|
return File(directory, file)
|
||||||
|
}
|
||||||
|
|
||||||
internal fun chooseFolder(): File? {
|
internal fun chooseFolder(): File? {
|
||||||
val chooser = JFileChooser().apply {
|
val chooser = JFileChooser().apply {
|
||||||
dialogTitle = desktopDialogString("desktop_import_folder", "Import folder")
|
dialogTitle = desktopDialogString("desktop_import_folder", "Import folder")
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,8 @@ internal val DesktopLanguageOptions = listOf(
|
||||||
DesktopLanguageOption("zh-CN", "language_chinese_simplified", "Chinese, Simplified"),
|
DesktopLanguageOption("zh-CN", "language_chinese_simplified", "Chinese, Simplified"),
|
||||||
DesktopLanguageOption("nl", "language_dutch", "Dutch"),
|
DesktopLanguageOption("nl", "language_dutch", "Dutch"),
|
||||||
DesktopLanguageOption("uk", "language_ukrainian", "Ukrainian"),
|
DesktopLanguageOption("uk", "language_ukrainian", "Ukrainian"),
|
||||||
DesktopLanguageOption("id", "language_indonesian", "Indonesian")
|
DesktopLanguageOption("id", "language_indonesian", "Indonesian"),
|
||||||
|
DesktopLanguageOption("et", "language_estonian", "Estonian")
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun selectedDesktopLanguageOption(languageTag: String?): DesktopLanguageOption {
|
internal fun selectedDesktopLanguageOption(languageTag: String?): DesktopLanguageOption {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import com.aryan.reader.shared.AppAction
|
||||||
import com.aryan.reader.shared.BannerMessage
|
import com.aryan.reader.shared.BannerMessage
|
||||||
import com.aryan.reader.shared.BookItem
|
import com.aryan.reader.shared.BookItem
|
||||||
import com.aryan.reader.shared.ReaderPlatform
|
import com.aryan.reader.shared.ReaderPlatform
|
||||||
|
import com.aryan.reader.shared.SharedFileCapabilities
|
||||||
import com.aryan.reader.shared.SharedFolderPathResolver
|
import com.aryan.reader.shared.SharedFolderPathResolver
|
||||||
import com.aryan.reader.shared.SharedReaderScreenState
|
import com.aryan.reader.shared.SharedReaderScreenState
|
||||||
import com.aryan.reader.shared.Shelf
|
import com.aryan.reader.shared.Shelf
|
||||||
|
|
@ -63,6 +64,24 @@ internal fun String.toDesktopSafeFileName(): String {
|
||||||
return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" }
|
return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun BookItem.desktopSuggestedOriginalFileName(): String {
|
||||||
|
val extension = path
|
||||||
|
?.let(::File)
|
||||||
|
?.extension
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: SharedFileCapabilities.primaryExtensionFor(type)
|
||||||
|
val safeName = displayName
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
?: title?.takeIf { it.isNotBlank() }
|
||||||
|
?: "book"
|
||||||
|
val sanitized = safeName.toDesktopSafeFileName()
|
||||||
|
return if (extension != null && !sanitized.endsWith(".$extension", ignoreCase = true)) {
|
||||||
|
"$sanitized.$extension"
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun BookItem.withDesktopImportMetadata(
|
internal fun BookItem.withDesktopImportMetadata(
|
||||||
enriched: BookItem,
|
enriched: BookItem,
|
||||||
original: BookItem?
|
original: BookItem?
|
||||||
|
|
@ -203,7 +222,8 @@ internal fun LibraryScreen(
|
||||||
onImportFolder: () -> Unit,
|
onImportFolder: () -> Unit,
|
||||||
onSyncFolderMetadata: () -> Unit,
|
onSyncFolderMetadata: () -> Unit,
|
||||||
onScanFolders: () -> Unit,
|
onScanFolders: () -> Unit,
|
||||||
onTogglePinned: (BookItem) -> Unit
|
onTogglePinned: (BookItem) -> Unit,
|
||||||
|
onSaveOriginalFile: (BookItem) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
SharedLibraryScreen(
|
SharedLibraryScreen(
|
||||||
state = state,
|
state = state,
|
||||||
|
|
@ -231,6 +251,7 @@ internal fun LibraryScreen(
|
||||||
onSyncFolderMetadata = onSyncFolderMetadata,
|
onSyncFolderMetadata = onSyncFolderMetadata,
|
||||||
onScanFolders = onScanFolders,
|
onScanFolders = onScanFolders,
|
||||||
onTogglePinned = onTogglePinned,
|
onTogglePinned = onTogglePinned,
|
||||||
|
onSaveOriginalFile = onSaveOriginalFile,
|
||||||
platform = ReaderPlatform.DESKTOP,
|
platform = ReaderPlatform.DESKTOP,
|
||||||
useImportEmptyStateWhenLibraryEmpty = true
|
useImportEmptyStateWhenLibraryEmpty = true
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,44 @@ import com.aryan.reader.shared.AppFontPreferenceKind
|
||||||
import com.aryan.reader.shared.CustomFontItem
|
import com.aryan.reader.shared.CustomFontItem
|
||||||
import com.aryan.reader.shared.reader.ReaderPage
|
import com.aryan.reader.shared.reader.ReaderPage
|
||||||
import com.aryan.reader.shared.reader.ReaderSettings
|
import com.aryan.reader.shared.reader.ReaderSettings
|
||||||
|
import com.aryan.reader.shared.detectFontVariant
|
||||||
|
import com.aryan.reader.shared.familyFilenameSignature
|
||||||
|
import com.aryan.reader.shared.supportsVariableWeightAxis
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
internal fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily {
|
internal fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily {
|
||||||
customFontPath?.takeIf { it.isNotBlank() }?.let { path ->
|
customFontPath?.takeIf { it.isNotBlank() }?.let { path ->
|
||||||
runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull()?.let { return it }
|
val baseFile = File(path)
|
||||||
|
val signature = baseFile.nameWithoutExtension.familyFilenameSignature()
|
||||||
|
val siblings = baseFile.parentFile?.listFiles()?.filter {
|
||||||
|
it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
|
||||||
|
it.nameWithoutExtension.familyFilenameSignature() == signature
|
||||||
|
} ?: listOf(baseFile)
|
||||||
|
|
||||||
|
val seenVariants = mutableSetOf<String>()
|
||||||
|
val fontList = siblings.flatMap { sibling ->
|
||||||
|
try {
|
||||||
|
val variant = sibling.nameWithoutExtension.detectFontVariant()
|
||||||
|
val weights = if (sibling.nameWithoutExtension.supportsVariableWeightAxis()) {
|
||||||
|
variableDesktopReaderFontWeights
|
||||||
|
} else {
|
||||||
|
listOf(variant?.weight ?: androidx.compose.ui.text.font.FontWeight.Normal)
|
||||||
|
}
|
||||||
|
weights.mapNotNull { weight ->
|
||||||
|
val style = variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal
|
||||||
|
if (seenVariants.add("${weight.weight}|$style")) {
|
||||||
|
DesktopFont(sibling, weight, style)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fontList.isNotEmpty()) {
|
||||||
|
return FontFamily(fontList)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return fontFamily.toComposeFontFamily()
|
return fontFamily.toComposeFontFamily()
|
||||||
}
|
}
|
||||||
|
|
@ -25,6 +58,18 @@ private fun String.toComposeFontFamily(): FontFamily {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val variableDesktopReaderFontWeights = listOf(
|
||||||
|
androidx.compose.ui.text.font.FontWeight.Thin,
|
||||||
|
androidx.compose.ui.text.font.FontWeight.ExtraLight,
|
||||||
|
androidx.compose.ui.text.font.FontWeight.Light,
|
||||||
|
androidx.compose.ui.text.font.FontWeight.Normal,
|
||||||
|
androidx.compose.ui.text.font.FontWeight.Medium,
|
||||||
|
androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||||
|
androidx.compose.ui.text.font.FontWeight.Bold,
|
||||||
|
androidx.compose.ui.text.font.FontWeight.ExtraBold,
|
||||||
|
androidx.compose.ui.text.font.FontWeight.Black
|
||||||
|
)
|
||||||
|
|
||||||
internal fun List<ReaderPage>.samePageLayoutAs(other: List<ReaderPage>): Boolean {
|
internal fun List<ReaderPage>.samePageLayoutAs(other: List<ReaderPage>): Boolean {
|
||||||
if (size != other.size) return false
|
if (size != other.size) return false
|
||||||
return indices.all { index ->
|
return indices.all { index ->
|
||||||
|
|
|
||||||
|
|
@ -638,6 +638,23 @@ internal fun EpistemeDesktopApp(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveDesktopOriginalFile(book: BookItem) {
|
||||||
|
val source = book.path?.let(::File)
|
||||||
|
if (source?.isFile != true) {
|
||||||
|
updateState(state.withBanner("Original file is not available.", isError = true))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val target = chooseSaveBookFile(book.desktopSuggestedOriginalFileName()) ?: return
|
||||||
|
runCatching {
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
source.copyTo(target, overwrite = true)
|
||||||
|
}.onSuccess {
|
||||||
|
updateState(state.withBanner("Saved ${target.name}."))
|
||||||
|
}.onFailure { error ->
|
||||||
|
updateState(state.withBanner(error.message ?: "Could not save file.", isError = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun clearDesktopBookCache() {
|
fun clearDesktopBookCache() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|
@ -4200,7 +4217,8 @@ internal fun EpistemeDesktopApp(
|
||||||
onManageShelfBooks = { shelfToManageBooks = it },
|
onManageShelfBooks = { shelfToManageBooks = it },
|
||||||
onSyncFolderMetadata = { syncFolderMetadata() },
|
onSyncFolderMetadata = { syncFolderMetadata() },
|
||||||
onScanFolders = { scanSyncedFolders() },
|
onScanFolders = { scanSyncedFolders() },
|
||||||
onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }
|
onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) },
|
||||||
|
onSaveOriginalFile = ::saveDesktopOriginalFile
|
||||||
)
|
)
|
||||||
|
|
||||||
SharedAppTab.SHELVES -> LibraryScreen(
|
SharedAppTab.SHELVES -> LibraryScreen(
|
||||||
|
|
@ -4249,7 +4267,8 @@ internal fun EpistemeDesktopApp(
|
||||||
onManageShelfBooks = { shelfToManageBooks = it },
|
onManageShelfBooks = { shelfToManageBooks = it },
|
||||||
onSyncFolderMetadata = { syncFolderMetadata() },
|
onSyncFolderMetadata = { syncFolderMetadata() },
|
||||||
onScanFolders = { scanSyncedFolders() },
|
onScanFolders = { scanSyncedFolders() },
|
||||||
onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }
|
onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) },
|
||||||
|
onSaveOriginalFile = ::saveDesktopOriginalFile
|
||||||
)
|
)
|
||||||
|
|
||||||
SharedAppTab.CATALOGS -> {
|
SharedAppTab.CATALOGS -> {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class DesktopAurPackagingMetadataTest {
|
||||||
|
@Test
|
||||||
|
fun `aur metadata declares arch runtime dependencies license and desktop mime support`() {
|
||||||
|
val buildScript = desktopBuildScriptText()
|
||||||
|
|
||||||
|
assertTrue(buildScript.contains("\"libarchive\""))
|
||||||
|
assertTrue(buildScript.contains("license=('AGPL-3.0-only')"))
|
||||||
|
assertTrue(buildScript.contains("license = AGPL-3.0-only"))
|
||||||
|
assertTrue(buildScript.contains("/usr/share/licenses/${'$'}pkgname/LICENSE"))
|
||||||
|
assertTrue(buildScript.contains("application/epub+zip"))
|
||||||
|
assertTrue(buildScript.contains("application/vnd.comicbook+zip"))
|
||||||
|
assertTrue(buildScript.contains("application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun desktopBuildScriptText(): String {
|
||||||
|
val candidates = listOf(
|
||||||
|
File("build.gradle.kts"),
|
||||||
|
File("desktopApp/build.gradle.kts")
|
||||||
|
)
|
||||||
|
val buildFile = candidates.firstOrNull { file ->
|
||||||
|
file.isFile && file.readText().contains("PrepareDesktopAurPackageTask")
|
||||||
|
}
|
||||||
|
requireNotNull(buildFile) { "Could not locate desktopApp/build.gradle.kts" }
|
||||||
|
return buildFile.readText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -127,6 +127,14 @@ class DesktopStringResourcesTest {
|
||||||
assertEquals("language_portuguese_brazilian", option.labelKey)
|
assertEquals("language_portuguese_brazilian", option.labelKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolvesSelectedDesktopLanguageOptionForEstonian() {
|
||||||
|
val option = selectedDesktopLanguageOption("et")
|
||||||
|
|
||||||
|
assertEquals("et", option.normalizedTag)
|
||||||
|
assertEquals("language_estonian", option.labelKey)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun desktopLanguageSettingsStorePersistsLanguageAcrossInstances() {
|
fun desktopLanguageSettingsStorePersistsLanguageAcrossInstances() {
|
||||||
val tempDirectory = Files.createTempDirectory("episteme-desktop-language-test")
|
val tempDirectory = Files.createTempDirectory("episteme-desktop-language-test")
|
||||||
|
|
|
||||||
0
gradlew
vendored
Normal file → Executable file
0
gradlew
vendored
Normal file → Executable file
52
scripts/desktop/download-pdfium.ps1
Normal file
52
scripts/desktop/download-pdfium.ps1
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
param(
|
||||||
|
[string]$Repository = $env:GITHUB_REPOSITORY,
|
||||||
|
[string]$Tag = "pdfium-desktop-v1",
|
||||||
|
[string]$Destination = "third_party/pdfium"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Repository)) {
|
||||||
|
throw "Repository is required. Pass -Repository owner/repo or set GITHUB_REPOSITORY."
|
||||||
|
}
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$assets = @(
|
||||||
|
@{ Name = "pdfium-linux-x64-v8.zip"; Directory = "linux-x64-v8" },
|
||||||
|
@{ Name = "pdfium-win-x64-v8.zip"; Directory = "win-x64-v8" }
|
||||||
|
)
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $Destination | Out-Null
|
||||||
|
$workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("reader-pdfium-" + [System.Guid]::NewGuid().ToString("N"))
|
||||||
|
New-Item -ItemType Directory -Force -Path $workDir | Out-Null
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ($asset in $assets) {
|
||||||
|
$zipPath = Join-Path $workDir $asset.Name
|
||||||
|
gh release download $Tag --repo $Repository --pattern $asset.Name --dir $workDir --clobber
|
||||||
|
|
||||||
|
if (-not (Test-Path $zipPath)) {
|
||||||
|
throw "Pdfium release asset was not downloaded: $($asset.Name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetDir = Join-Path $Destination $asset.Directory
|
||||||
|
if (Test-Path $targetDir) {
|
||||||
|
Remove-Item -Recurse -Force $targetDir
|
||||||
|
}
|
||||||
|
|
||||||
|
$extractDir = Join-Path $workDir $asset.Directory
|
||||||
|
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
|
||||||
|
Expand-Archive -Force -Path $zipPath -DestinationPath $extractDir
|
||||||
|
|
||||||
|
$rootedDir = Join-Path $extractDir $asset.Directory
|
||||||
|
if (Test-Path $rootedDir) {
|
||||||
|
Move-Item -Path $rootedDir -Destination $targetDir
|
||||||
|
} else {
|
||||||
|
New-Item -ItemType Directory -Force -Path $targetDir | Out-Null
|
||||||
|
Move-Item -Path (Join-Path $extractDir "*") -Destination $targetDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (Test-Path $workDir) {
|
||||||
|
Remove-Item -Recurse -Force $workDir
|
||||||
|
}
|
||||||
|
}
|
||||||
51
scripts/desktop/publish-pdfium-release.ps1
Normal file
51
scripts/desktop/publish-pdfium-release.ps1
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
param(
|
||||||
|
[string]$Repository = $env:GITHUB_REPOSITORY,
|
||||||
|
[string]$Tag = "pdfium-desktop-v1",
|
||||||
|
[string]$Title = "Desktop Pdfium binaries",
|
||||||
|
[string]$PdfiumRoot = "third_party/pdfium"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Repository)) {
|
||||||
|
throw "Repository is required. Pass -Repository owner/repo or set GITHUB_REPOSITORY."
|
||||||
|
}
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$folders = @(
|
||||||
|
@{ Directory = "linux-x64-v8"; Asset = "pdfium-linux-x64-v8.zip" },
|
||||||
|
@{ Directory = "win-x64-v8"; Asset = "pdfium-win-x64-v8.zip" }
|
||||||
|
)
|
||||||
|
|
||||||
|
$workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("reader-pdfium-release-" + [System.Guid]::NewGuid().ToString("N"))
|
||||||
|
New-Item -ItemType Directory -Force -Path $workDir | Out-Null
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ($folder in $folders) {
|
||||||
|
$source = Join-Path $PdfiumRoot $folder.Directory
|
||||||
|
if (-not (Test-Path $source)) {
|
||||||
|
throw "Missing Pdfium folder: $source"
|
||||||
|
}
|
||||||
|
|
||||||
|
$assetPath = Join-Path $workDir $folder.Asset
|
||||||
|
Compress-Archive -Force -Path $source -DestinationPath $assetPath
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousErrorActionPreference = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
gh release view $Tag --repo $Repository *> $null
|
||||||
|
$releaseExists = $LASTEXITCODE -eq 0
|
||||||
|
$ErrorActionPreference = $previousErrorActionPreference
|
||||||
|
|
||||||
|
if (-not $releaseExists) {
|
||||||
|
gh release create $Tag --repo $Repository --title $Title --notes "Pdfium binaries used by desktop CI packaging."
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($folder in $folders) {
|
||||||
|
$assetPath = Join-Path $workDir $folder.Asset
|
||||||
|
gh release upload $Tag $assetPath --repo $Repository --clobber
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (Test-Path $workDir) {
|
||||||
|
Remove-Item -Recurse -Force $workDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,20 @@ dependencyResolutionManagement {
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "Reader"
|
rootProject.name = "Reader"
|
||||||
|
|
||||||
|
fun isDesktopOnlyBuild(): Boolean {
|
||||||
|
providers.gradleProperty("desktopOnly").orNull
|
||||||
|
?.let { return it.equals("true", ignoreCase = true) }
|
||||||
|
|
||||||
|
val requestedTasks = gradle.startParameter.taskNames
|
||||||
|
return requestedTasks.isNotEmpty() && requestedTasks.all { taskName ->
|
||||||
|
val normalized = taskName.removePrefix(":")
|
||||||
|
normalized.startsWith("desktopApp:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDesktopOnlyBuild()) {
|
||||||
include(":app")
|
include(":app")
|
||||||
|
}
|
||||||
include(":shared")
|
include(":shared")
|
||||||
include(":desktopApp")
|
include(":desktopApp")
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,40 @@
|
||||||
|
import com.android.build.api.dsl.LibraryExtension
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
id("com.android.library")
|
alias(libs.plugins.android.library) apply false
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.compose.multiplatform)
|
alias(libs.plugins.compose.multiplatform)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.kover)
|
alias(libs.plugins.kover)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isDesktopOnlyBuild(): Boolean {
|
||||||
|
providers.gradleProperty("desktopOnly").orNull
|
||||||
|
?.let { return it.equals("true", ignoreCase = true) }
|
||||||
|
|
||||||
|
val requestedTasks = gradle.startParameter.taskNames
|
||||||
|
return requestedTasks.isNotEmpty() && requestedTasks.all { taskName ->
|
||||||
|
val normalized = taskName.removePrefix(":")
|
||||||
|
normalized.startsWith("desktopApp:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val desktopOnlyBuild = isDesktopOnlyBuild()
|
||||||
|
|
||||||
|
if (!desktopOnlyBuild) {
|
||||||
|
apply(plugin = "com.android.library")
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
|
if (!desktopOnlyBuild) {
|
||||||
androidTarget()
|
androidTarget()
|
||||||
|
}
|
||||||
jvm("desktop")
|
jvm("desktop")
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
val commonMain by getting
|
val commonMain by getting
|
||||||
val androidMain by getting
|
|
||||||
val desktopMain by getting
|
val desktopMain by getting
|
||||||
val readerJvmMain by creating {
|
val readerJvmMain by creating {
|
||||||
dependsOn(commonMain)
|
dependsOn(commonMain)
|
||||||
|
|
@ -22,7 +42,10 @@ kotlin {
|
||||||
implementation("org.jsoup:jsoup:1.17.2")
|
implementation("org.jsoup:jsoup:1.17.2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!desktopOnlyBuild) {
|
||||||
|
val androidMain by getting
|
||||||
androidMain.dependsOn(readerJvmMain)
|
androidMain.dependsOn(readerJvmMain)
|
||||||
|
}
|
||||||
desktopMain.dependsOn(readerJvmMain)
|
desktopMain.dependsOn(readerJvmMain)
|
||||||
|
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
|
|
@ -40,7 +63,8 @@ kotlin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
if (!desktopOnlyBuild) {
|
||||||
|
extensions.configure<LibraryExtension>("android") {
|
||||||
namespace = "com.aryan.reader.shared"
|
namespace = "com.aryan.reader.shared"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
|
|
||||||
|
|
@ -52,3 +76,4 @@ android {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
package com.aryan.reader.shared
|
package com.aryan.reader.shared
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
data class CustomFontItem(
|
data class CustomFontItem(
|
||||||
val id: String,
|
val id: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
|
|
@ -10,3 +13,76 @@ data class CustomFontItem(
|
||||||
val isDeleted: Boolean = false
|
val isDeleted: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class CustomFontVariantItem(
|
||||||
|
val font: CustomFontItem,
|
||||||
|
val variant: FontVariant?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CustomFontFamilyItem(
|
||||||
|
val familyName: String,
|
||||||
|
val variants: List<CustomFontVariantItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
fun List<CustomFontItem>.groupByFamily(): List<CustomFontFamilyItem> {
|
||||||
|
val families = this.groupBy {
|
||||||
|
it.displayName.familyFilenameSignature().takeIf { s -> s.isNotBlank() } ?: it.displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
return families.map { (familyName, fonts) ->
|
||||||
|
val variants = fonts.map { font ->
|
||||||
|
CustomFontVariantItem(
|
||||||
|
font = font,
|
||||||
|
variant = font.displayName.detectFontVariant()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CustomFontFamilyItem(
|
||||||
|
familyName = familyName.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() },
|
||||||
|
variants = variants
|
||||||
|
)
|
||||||
|
}.sortedBy { it.familyName }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CustomFontVariantItem.fontFaceLabel(): String {
|
||||||
|
val variant = variant ?: return "Regular"
|
||||||
|
return when {
|
||||||
|
variant.weight.weight >= FontWeight.Bold.weight && variant.style == FontStyle.Italic -> "Bold Italic"
|
||||||
|
variant.weight.weight >= FontWeight.Bold.weight -> "Bold"
|
||||||
|
variant.style == FontStyle.Italic -> "Italic"
|
||||||
|
variant.weight == FontWeight.Normal -> "Regular"
|
||||||
|
variant.weight.weight < FontWeight.Normal.weight -> variant.weight.fontWeightLabel()
|
||||||
|
else -> variant.weight.fontWeightLabel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CustomFontFamilyItem.fontFaceSummary(): String {
|
||||||
|
return variants
|
||||||
|
.sortedWith(compareBy<CustomFontVariantItem> {
|
||||||
|
it.variant?.style == FontStyle.Italic
|
||||||
|
}.thenBy {
|
||||||
|
it.variant?.weight?.weight ?: FontWeight.Normal.weight
|
||||||
|
})
|
||||||
|
.map { it.fontFaceLabel() }
|
||||||
|
.distinct()
|
||||||
|
.joinToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CustomFontFamilyItem.hasVariableWeightFace(): Boolean {
|
||||||
|
return variants.any { variant ->
|
||||||
|
variant.font.displayName.supportsVariableWeightAxis() || variant.font.fileName.supportsVariableWeightAxis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FontWeight.fontWeightLabel(): String {
|
||||||
|
return when (this) {
|
||||||
|
FontWeight.Thin -> "Thin"
|
||||||
|
FontWeight.ExtraLight -> "Extra Light"
|
||||||
|
FontWeight.Light -> "Light"
|
||||||
|
FontWeight.Normal -> "Regular"
|
||||||
|
FontWeight.Medium -> "Medium"
|
||||||
|
FontWeight.SemiBold -> "Semi Bold"
|
||||||
|
FontWeight.Bold -> "Bold"
|
||||||
|
FontWeight.ExtraBold -> "Extra Bold"
|
||||||
|
FontWeight.Black -> "Black"
|
||||||
|
else -> weight.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
package com.aryan.reader.shared
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
|
data class FontVariant(
|
||||||
|
val weight: FontWeight,
|
||||||
|
val style: FontStyle
|
||||||
|
)
|
||||||
|
|
||||||
|
private val filenameSeparatorsRegex = Regex("""[\s._,-]+""")
|
||||||
|
|
||||||
|
fun String.familyFilenameSignature(): String {
|
||||||
|
val tokens = filenameTokens()
|
||||||
|
val variantIndexes = tokens.variantTokenIndexes()
|
||||||
|
val signatureTokens = tokens.filterIndexed { index, token ->
|
||||||
|
token.isNotBlank() && index !in variantIndexes
|
||||||
|
}
|
||||||
|
return signatureTokens.joinToString(separator = " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.supportsVariableWeightAxis(): Boolean {
|
||||||
|
return filenameTokens().any { it == "wght" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.fontWeightCssDescriptor(fallbackWeight: FontWeight = FontWeight.Normal): String {
|
||||||
|
return if (supportsVariableWeightAxis()) {
|
||||||
|
"100 900"
|
||||||
|
} else {
|
||||||
|
fallbackWeight.weight.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.variantTokenIndexes(): Set<Int> {
|
||||||
|
val indexes = mutableSetOf<Int>()
|
||||||
|
forEachIndexed { index, token ->
|
||||||
|
if (token in singleVariantTokens || token.toIntOrNull()?.isCssFontWeight() == true) {
|
||||||
|
indexes += index
|
||||||
|
}
|
||||||
|
val next = getOrNull(index + 1) ?: return@forEachIndexed
|
||||||
|
if ("$token$next" in compoundVariantTokens) {
|
||||||
|
indexes += index
|
||||||
|
indexes += index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return indexes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.isCssFontWeight(): Boolean = this in 100..900 && this % 100 == 0
|
||||||
|
|
||||||
|
private fun List<String>.containsCompoundToken(compoundTokens: Set<String>): Boolean {
|
||||||
|
return windowed(size = 2, step = 1, partialWindows = false)
|
||||||
|
.any { (first, second) -> "$first$second" in compoundTokens }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.detectedWeight(): FontWeight {
|
||||||
|
val compactTokens = buildList {
|
||||||
|
addAll(this@detectedWeight)
|
||||||
|
this@detectedWeight.windowed(size = 2, step = 1, partialWindows = false)
|
||||||
|
.forEach { (first, second) -> add("$first$second") }
|
||||||
|
}
|
||||||
|
return compactTokens.asSequence()
|
||||||
|
.mapNotNull { token ->
|
||||||
|
tokenWeightMap[token]
|
||||||
|
?: compoundTokenWeightMap[token]
|
||||||
|
?: token.toIntOrNull()?.takeIf { it.isCssFontWeight() }?.let(::FontWeight)
|
||||||
|
}
|
||||||
|
.maxByOrNull { it.weight }
|
||||||
|
?: FontWeight.Normal
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.detectedStyle(): FontStyle {
|
||||||
|
return if (any { it in italicTokens } || containsCompoundToken(compoundItalicTokens)) {
|
||||||
|
FontStyle.Italic
|
||||||
|
} else {
|
||||||
|
FontStyle.Normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.detectFontVariant(): FontVariant? {
|
||||||
|
val tokens = filenameTokens()
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
if (tokens.isEmpty()) return null
|
||||||
|
|
||||||
|
return FontVariant(weight = tokens.detectedWeight(), style = tokens.detectedStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.filenameTokens(): List<String> {
|
||||||
|
return this
|
||||||
|
.replace(Regex("""(?i)variablefont"""), " variablefont ")
|
||||||
|
.replace(Regex("""(?<=[a-z])(?=[A-Z])"""), "-")
|
||||||
|
.lowercase()
|
||||||
|
.split(filenameSeparatorsRegex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val italicTokens = setOf("italic", "ital", "oblique", "obliq", "it", "itallic", "italics", "slanted", "slant")
|
||||||
|
|
||||||
|
private val tokenWeightMap = mapOf(
|
||||||
|
"thin" to FontWeight.Thin,
|
||||||
|
"hairline" to FontWeight.Thin,
|
||||||
|
"extralight" to FontWeight.ExtraLight,
|
||||||
|
"ultralight" to FontWeight.ExtraLight,
|
||||||
|
"light" to FontWeight.Light,
|
||||||
|
"regular" to FontWeight.Normal,
|
||||||
|
"normal" to FontWeight.Normal,
|
||||||
|
"roman" to FontWeight.Normal,
|
||||||
|
"book" to FontWeight.Normal,
|
||||||
|
"medium" to FontWeight.Medium,
|
||||||
|
"semibold" to FontWeight.SemiBold,
|
||||||
|
"demibold" to FontWeight.SemiBold,
|
||||||
|
"bold" to FontWeight.Bold,
|
||||||
|
"extrabold" to FontWeight.ExtraBold,
|
||||||
|
"ultrabold" to FontWeight.ExtraBold,
|
||||||
|
"black" to FontWeight.Black,
|
||||||
|
"heavy" to FontWeight.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
private val variableFontTokens = setOf(
|
||||||
|
"variablefont",
|
||||||
|
"vf",
|
||||||
|
"variable",
|
||||||
|
"wght",
|
||||||
|
"wdth",
|
||||||
|
"opsz",
|
||||||
|
"slnt",
|
||||||
|
"grad",
|
||||||
|
"xtra",
|
||||||
|
"xopq",
|
||||||
|
"yopq",
|
||||||
|
"ytlc",
|
||||||
|
"ytuc",
|
||||||
|
"ytas",
|
||||||
|
"ytde"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val compoundItalicTokens = setOf("bolditalic", "boldital", "boldoblique", "boldobliq")
|
||||||
|
private val compoundWeightTokens = mapOf(
|
||||||
|
"extralight" to FontWeight.ExtraLight,
|
||||||
|
"ultralight" to FontWeight.ExtraLight,
|
||||||
|
"semibold" to FontWeight.SemiBold,
|
||||||
|
"demibold" to FontWeight.SemiBold,
|
||||||
|
"extrabold" to FontWeight.ExtraBold,
|
||||||
|
"ultrabold" to FontWeight.ExtraBold
|
||||||
|
)
|
||||||
|
private val compoundVariableFontTokens = setOf("variablefont")
|
||||||
|
private val compoundTokenWeightMap = compoundItalicTokens.associateWith { FontWeight.Bold } + compoundWeightTokens
|
||||||
|
|
||||||
|
private val singleVariantTokens = italicTokens + tokenWeightMap.keys + variableFontTokens
|
||||||
|
private val compoundVariantTokens = compoundItalicTokens + compoundWeightTokens.keys + compoundVariableFontTokens
|
||||||
|
|
@ -239,7 +239,9 @@ internal enum class NonReaderLibraryPrimaryAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
internal enum class NonReaderBookOverflowAction {
|
internal enum class NonReaderBookOverflowAction {
|
||||||
ADD_TO_SHELF
|
ADD_TO_SHELF,
|
||||||
|
SAVE_ORIGINAL,
|
||||||
|
SHARE_ORIGINAL
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun visibleNonReaderLibraryTabs(
|
internal fun visibleNonReaderLibraryTabs(
|
||||||
|
|
@ -266,8 +268,14 @@ internal fun bookOverflowActionsForPlatform(
|
||||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||||
): Set<NonReaderBookOverflowAction> {
|
): Set<NonReaderBookOverflowAction> {
|
||||||
return when (platform) {
|
return when (platform) {
|
||||||
ReaderPlatform.DESKTOP -> setOf(NonReaderBookOverflowAction.ADD_TO_SHELF)
|
ReaderPlatform.DESKTOP -> setOf(
|
||||||
ReaderPlatform.ANDROID -> emptySet()
|
NonReaderBookOverflowAction.ADD_TO_SHELF,
|
||||||
|
NonReaderBookOverflowAction.SAVE_ORIGINAL
|
||||||
|
)
|
||||||
|
ReaderPlatform.ANDROID -> setOf(
|
||||||
|
NonReaderBookOverflowAction.SAVE_ORIGINAL,
|
||||||
|
NonReaderBookOverflowAction.SHARE_ORIGINAL
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.PushPin
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Save
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material.icons.filled.Sync
|
import androidx.compose.material.icons.filled.Sync
|
||||||
import androidx.compose.material.icons.filled.Tag
|
import androidx.compose.material.icons.filled.Tag
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
|
|
@ -165,6 +167,8 @@ fun SharedHomeScreen(
|
||||||
onRemoveSelected: () -> Unit,
|
onRemoveSelected: () -> Unit,
|
||||||
onShowBookInfo: (BookItem) -> Unit = {},
|
onShowBookInfo: (BookItem) -> Unit = {},
|
||||||
onEditBook: (BookItem) -> Unit = {},
|
onEditBook: (BookItem) -> Unit = {},
|
||||||
|
onSaveOriginalFile: (BookItem) -> Unit = {},
|
||||||
|
onShareOriginalFile: (BookItem) -> Unit = {},
|
||||||
onTagSelectedBooks: () -> Unit = {},
|
onTagSelectedBooks: () -> Unit = {},
|
||||||
onAddSelectedBooksToShelf: () -> Unit = {},
|
onAddSelectedBooksToShelf: () -> Unit = {},
|
||||||
onOpenTab: (BookItem) -> Unit = onOpenBook,
|
onOpenTab: (BookItem) -> Unit = onOpenBook,
|
||||||
|
|
@ -173,6 +177,7 @@ fun SharedHomeScreen(
|
||||||
onRecentLimitChange: (Int) -> Unit = {},
|
onRecentLimitChange: (Int) -> Unit = {},
|
||||||
onTogglePinned: (BookItem) -> Unit = {},
|
onTogglePinned: (BookItem) -> Unit = {},
|
||||||
onOpenSettings: () -> Unit = {},
|
onOpenSettings: () -> Unit = {},
|
||||||
|
platform: ReaderPlatform = ReaderPlatform.ANDROID,
|
||||||
showActiveTabs: Boolean = true,
|
showActiveTabs: Boolean = true,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
|
@ -188,6 +193,16 @@ fun SharedHomeScreen(
|
||||||
) {
|
) {
|
||||||
state.toNonReaderHomeLayoutModel()
|
state.toNonReaderHomeLayoutModel()
|
||||||
}
|
}
|
||||||
|
val saveOriginalFileAction = if (NonReaderBookOverflowAction.SAVE_ORIGINAL in bookOverflowActionsForPlatform(platform)) {
|
||||||
|
onSaveOriginalFile
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val shareOriginalFileAction = if (NonReaderBookOverflowAction.SHARE_ORIGINAL in bookOverflowActionsForPlatform(platform)) {
|
||||||
|
onShareOriginalFile
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
NonReaderScreenScaffold(
|
NonReaderScreenScaffold(
|
||||||
title = readerString("nav_home", "Home"),
|
title = readerString("nav_home", "Home"),
|
||||||
subtitle = readerString("desktop_home_subtitle", "Continue reading and recent books"),
|
subtitle = readerString("desktop_home_subtitle", "Continue reading and recent books"),
|
||||||
|
|
@ -268,6 +283,8 @@ fun SharedHomeScreen(
|
||||||
onOpenBook = { onOpenBook(book) },
|
onOpenBook = { onOpenBook(book) },
|
||||||
onShowBookInfo = { onShowBookInfo(book) },
|
onShowBookInfo = { onShowBookInfo(book) },
|
||||||
onEditBook = { onEditBook(book) },
|
onEditBook = { onEditBook(book) },
|
||||||
|
onSaveOriginalFile = saveOriginalFileAction?.let { save -> { save(book) } },
|
||||||
|
onShareOriginalFile = shareOriginalFileAction?.let { share -> { share(book) } },
|
||||||
onTogglePinned = { onTogglePinned(book) }
|
onTogglePinned = { onTogglePinned(book) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -294,6 +311,8 @@ fun SharedHomeScreen(
|
||||||
onToggleSelection = onToggleSelection,
|
onToggleSelection = onToggleSelection,
|
||||||
onShowBookInfo = onShowBookInfo,
|
onShowBookInfo = onShowBookInfo,
|
||||||
onEditBook = onEditBook,
|
onEditBook = onEditBook,
|
||||||
|
onSaveOriginalFile = saveOriginalFileAction,
|
||||||
|
onShareOriginalFile = shareOriginalFileAction,
|
||||||
onTogglePinned = onTogglePinned
|
onTogglePinned = onTogglePinned
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -309,6 +328,8 @@ fun SharedHomeScreen(
|
||||||
onToggleSelection = onToggleSelection,
|
onToggleSelection = onToggleSelection,
|
||||||
onShowBookInfo = onShowBookInfo,
|
onShowBookInfo = onShowBookInfo,
|
||||||
onEditBook = onEditBook,
|
onEditBook = onEditBook,
|
||||||
|
onSaveOriginalFile = saveOriginalFileAction,
|
||||||
|
onShareOriginalFile = shareOriginalFileAction,
|
||||||
onTogglePinned = onTogglePinned
|
onTogglePinned = onTogglePinned
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -331,6 +352,8 @@ fun SharedLibraryScreen(
|
||||||
onRemoveSelected: () -> Unit,
|
onRemoveSelected: () -> Unit,
|
||||||
onShowBookInfo: (BookItem) -> Unit = {},
|
onShowBookInfo: (BookItem) -> Unit = {},
|
||||||
onEditBook: (BookItem) -> Unit = {},
|
onEditBook: (BookItem) -> Unit = {},
|
||||||
|
onSaveOriginalFile: (BookItem) -> Unit = {},
|
||||||
|
onShareOriginalFile: (BookItem) -> Unit = {},
|
||||||
onCreateShelf: () -> Unit = {},
|
onCreateShelf: () -> Unit = {},
|
||||||
onCreateShelfWithBooks: (String, Set<String>) -> Unit = { _, _ -> },
|
onCreateShelfWithBooks: (String, Set<String>) -> Unit = { _, _ -> },
|
||||||
onCreateSmartShelf: () -> Unit = {},
|
onCreateSmartShelf: () -> Unit = {},
|
||||||
|
|
@ -446,6 +469,8 @@ fun SharedLibraryScreen(
|
||||||
onToggleSelection = onToggleSelection,
|
onToggleSelection = onToggleSelection,
|
||||||
onShowBookInfo = onShowBookInfo,
|
onShowBookInfo = onShowBookInfo,
|
||||||
onEditBook = onEditBook,
|
onEditBook = onEditBook,
|
||||||
|
onSaveOriginalFile = onSaveOriginalFile,
|
||||||
|
onShareOriginalFile = onShareOriginalFile,
|
||||||
onTogglePinned = onTogglePinned,
|
onTogglePinned = onTogglePinned,
|
||||||
onAddBooksToShelf = onAddBooksToShelf,
|
onAddBooksToShelf = onAddBooksToShelf,
|
||||||
onManageShelfBooks = onManageShelfBooks,
|
onManageShelfBooks = onManageShelfBooks,
|
||||||
|
|
@ -495,6 +520,8 @@ fun SharedLibraryScreen(
|
||||||
onToggleSelection = onToggleSelection,
|
onToggleSelection = onToggleSelection,
|
||||||
onShowBookInfo = onShowBookInfo,
|
onShowBookInfo = onShowBookInfo,
|
||||||
onEditBook = onEditBook,
|
onEditBook = onEditBook,
|
||||||
|
onSaveOriginalFile = onSaveOriginalFile,
|
||||||
|
onShareOriginalFile = onShareOriginalFile,
|
||||||
onTogglePinned = onTogglePinned,
|
onTogglePinned = onTogglePinned,
|
||||||
onAddBooksToShelf = onAddBooksToShelf,
|
onAddBooksToShelf = onAddBooksToShelf,
|
||||||
onManageShelfBooks = onManageShelfBooks,
|
onManageShelfBooks = onManageShelfBooks,
|
||||||
|
|
@ -601,8 +628,11 @@ private fun ContinueReadingCard(
|
||||||
onOpenBook: () -> Unit,
|
onOpenBook: () -> Unit,
|
||||||
onShowBookInfo: () -> Unit,
|
onShowBookInfo: () -> Unit,
|
||||||
onEditBook: () -> Unit,
|
onEditBook: () -> Unit,
|
||||||
|
onSaveOriginalFile: (() -> Unit)?,
|
||||||
|
onShareOriginalFile: (() -> Unit)?,
|
||||||
onTogglePinned: () -> Unit
|
onTogglePinned: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val canUseOriginalFileActions = !book.isOpdsStream() && book.path != null
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(SharedUiTokens.surfaceRadius),
|
shape = RoundedCornerShape(SharedUiTokens.surfaceRadius),
|
||||||
|
|
@ -643,6 +673,16 @@ private fun ContinueReadingCard(
|
||||||
IconButton(onClick = onEditBook) {
|
IconButton(onClick = onEditBook) {
|
||||||
Icon(Icons.Default.Edit, contentDescription = readerString("action_edit", "Edit"))
|
Icon(Icons.Default.Edit, contentDescription = readerString("action_edit", "Edit"))
|
||||||
}
|
}
|
||||||
|
if (canUseOriginalFileActions && onSaveOriginalFile != null) {
|
||||||
|
IconButton(onClick = onSaveOriginalFile) {
|
||||||
|
Icon(Icons.Default.Save, contentDescription = readerString("action_save_copy_to_device", "Save copy to device"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canUseOriginalFileActions && onShareOriginalFile != null) {
|
||||||
|
IconButton(onClick = onShareOriginalFile) {
|
||||||
|
Icon(Icons.Default.Share, contentDescription = readerString("action_share", "Share"))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -659,6 +699,8 @@ private fun HomeBookShelf(
|
||||||
onToggleSelection: (String) -> Unit,
|
onToggleSelection: (String) -> Unit,
|
||||||
onShowBookInfo: (BookItem) -> Unit,
|
onShowBookInfo: (BookItem) -> Unit,
|
||||||
onEditBook: (BookItem) -> Unit,
|
onEditBook: (BookItem) -> Unit,
|
||||||
|
onSaveOriginalFile: ((BookItem) -> Unit)?,
|
||||||
|
onShareOriginalFile: ((BookItem) -> Unit)?,
|
||||||
onTogglePinned: (BookItem) -> Unit
|
onTogglePinned: (BookItem) -> Unit
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
|
@ -674,6 +716,8 @@ private fun HomeBookShelf(
|
||||||
onToggleSelection = { onToggleSelection(book.id) },
|
onToggleSelection = { onToggleSelection(book.id) },
|
||||||
onShowInfo = { onShowBookInfo(book) },
|
onShowInfo = { onShowBookInfo(book) },
|
||||||
onEdit = { onEditBook(book) },
|
onEdit = { onEditBook(book) },
|
||||||
|
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
|
||||||
|
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
|
||||||
onTogglePinned = { onTogglePinned(book) },
|
onTogglePinned = { onTogglePinned(book) },
|
||||||
modifier = Modifier.width(168.dp)
|
modifier = Modifier.width(168.dp)
|
||||||
)
|
)
|
||||||
|
|
@ -1226,6 +1270,8 @@ private fun LibraryContent(
|
||||||
onToggleSelection: (String) -> Unit,
|
onToggleSelection: (String) -> Unit,
|
||||||
onShowBookInfo: (BookItem) -> Unit,
|
onShowBookInfo: (BookItem) -> Unit,
|
||||||
onEditBook: (BookItem) -> Unit,
|
onEditBook: (BookItem) -> Unit,
|
||||||
|
onSaveOriginalFile: (BookItem) -> Unit,
|
||||||
|
onShareOriginalFile: (BookItem) -> Unit,
|
||||||
onTogglePinned: (BookItem) -> Unit,
|
onTogglePinned: (BookItem) -> Unit,
|
||||||
onAddBooksToShelf: (Set<String>) -> Unit,
|
onAddBooksToShelf: (Set<String>) -> Unit,
|
||||||
onManageShelfBooks: ((Shelf) -> Unit)?,
|
onManageShelfBooks: ((Shelf) -> Unit)?,
|
||||||
|
|
@ -1262,6 +1308,16 @@ private fun LibraryContent(
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
val saveOriginalFileAction = if (NonReaderBookOverflowAction.SAVE_ORIGINAL in bookOverflowActionsForPlatform(platform)) {
|
||||||
|
onSaveOriginalFile
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val shareOriginalFileAction = if (NonReaderBookOverflowAction.SHARE_ORIGINAL in bookOverflowActionsForPlatform(platform)) {
|
||||||
|
onShareOriginalFile
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
val manageShelfBooksAction = if (platform == ReaderPlatform.DESKTOP) onManageShelfBooks else null
|
val manageShelfBooksAction = if (platform == ReaderPlatform.DESKTOP) onManageShelfBooks else null
|
||||||
val showNewShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in
|
val showNewShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in
|
||||||
primaryLibraryActionsForTab(selectedTab, platform)
|
primaryLibraryActionsForTab(selectedTab, platform)
|
||||||
|
|
@ -1314,6 +1370,8 @@ private fun LibraryContent(
|
||||||
onToggleSelection = onToggleSelection,
|
onToggleSelection = onToggleSelection,
|
||||||
onShowBookInfo = onShowBookInfo,
|
onShowBookInfo = onShowBookInfo,
|
||||||
onEditBook = onEditBook,
|
onEditBook = onEditBook,
|
||||||
|
onSaveOriginalFile = saveOriginalFileAction,
|
||||||
|
onShareOriginalFile = shareOriginalFileAction,
|
||||||
onTogglePinned = onTogglePinned,
|
onTogglePinned = onTogglePinned,
|
||||||
onAddToShelf = addToShelfFromBookAction?.let { addToShelf -> { book -> addToShelf(setOf(book.id)) } },
|
onAddToShelf = addToShelfFromBookAction?.let { addToShelf -> { book -> addToShelf(setOf(book.id)) } },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
|
|
@ -1347,6 +1405,8 @@ private fun LibraryContent(
|
||||||
onToggleSelection = onToggleSelection,
|
onToggleSelection = onToggleSelection,
|
||||||
onShowBookInfo = onShowBookInfo,
|
onShowBookInfo = onShowBookInfo,
|
||||||
onEditBook = onEditBook,
|
onEditBook = onEditBook,
|
||||||
|
onSaveOriginalFile = saveOriginalFileAction,
|
||||||
|
onShareOriginalFile = shareOriginalFileAction,
|
||||||
onTogglePinned = onTogglePinned,
|
onTogglePinned = onTogglePinned,
|
||||||
onAddBooksToShelf = addToShelfFromBookAction,
|
onAddBooksToShelf = addToShelfFromBookAction,
|
||||||
onManageShelfBooks = manageShelfBooksAction,
|
onManageShelfBooks = manageShelfBooksAction,
|
||||||
|
|
@ -1370,6 +1430,8 @@ private fun LibraryContent(
|
||||||
onToggleSelection = onToggleSelection,
|
onToggleSelection = onToggleSelection,
|
||||||
onShowBookInfo = onShowBookInfo,
|
onShowBookInfo = onShowBookInfo,
|
||||||
onEditBook = onEditBook,
|
onEditBook = onEditBook,
|
||||||
|
onSaveOriginalFile = saveOriginalFileAction,
|
||||||
|
onShareOriginalFile = shareOriginalFileAction,
|
||||||
onTogglePinned = onTogglePinned,
|
onTogglePinned = onTogglePinned,
|
||||||
onAddBooksToShelf = addToShelfFromBookAction,
|
onAddBooksToShelf = addToShelfFromBookAction,
|
||||||
onRenameShelf = onRenameShelf,
|
onRenameShelf = onRenameShelf,
|
||||||
|
|
@ -1387,6 +1449,8 @@ private fun LibraryContent(
|
||||||
onToggleSelection = onToggleSelection,
|
onToggleSelection = onToggleSelection,
|
||||||
onShowBookInfo = onShowBookInfo,
|
onShowBookInfo = onShowBookInfo,
|
||||||
onEditBook = onEditBook,
|
onEditBook = onEditBook,
|
||||||
|
onSaveOriginalFile = saveOriginalFileAction,
|
||||||
|
onShareOriginalFile = shareOriginalFileAction,
|
||||||
onTogglePinned = onTogglePinned,
|
onTogglePinned = onTogglePinned,
|
||||||
onAddBooksToShelf = addToShelfFromBookAction,
|
onAddBooksToShelf = addToShelfFromBookAction,
|
||||||
emptyTitle = readerString("desktop_no_tags_yet", "No tags yet"),
|
emptyTitle = readerString("desktop_no_tags_yet", "No tags yet"),
|
||||||
|
|
@ -1409,6 +1473,8 @@ private fun LibraryContent(
|
||||||
onToggleSelection = onToggleSelection,
|
onToggleSelection = onToggleSelection,
|
||||||
onShowBookInfo = onShowBookInfo,
|
onShowBookInfo = onShowBookInfo,
|
||||||
onEditBook = onEditBook,
|
onEditBook = onEditBook,
|
||||||
|
onSaveOriginalFile = saveOriginalFileAction,
|
||||||
|
onShareOriginalFile = shareOriginalFileAction,
|
||||||
onTogglePinned = onTogglePinned,
|
onTogglePinned = onTogglePinned,
|
||||||
onAddBooksToShelf = addToShelfFromBookAction,
|
onAddBooksToShelf = addToShelfFromBookAction,
|
||||||
onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) },
|
onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) },
|
||||||
|
|
@ -1431,6 +1497,8 @@ private fun LibraryContent(
|
||||||
onToggleSelection = onToggleSelection,
|
onToggleSelection = onToggleSelection,
|
||||||
onShowBookInfo = onShowBookInfo,
|
onShowBookInfo = onShowBookInfo,
|
||||||
onEditBook = onEditBook,
|
onEditBook = onEditBook,
|
||||||
|
onSaveOriginalFile = saveOriginalFileAction,
|
||||||
|
onShareOriginalFile = shareOriginalFileAction,
|
||||||
onTogglePinned = onTogglePinned,
|
onTogglePinned = onTogglePinned,
|
||||||
onAddBooksToShelf = addToShelfFromBookAction,
|
onAddBooksToShelf = addToShelfFromBookAction,
|
||||||
onRemoveFolder = onRemoveFolder,
|
onRemoveFolder = onRemoveFolder,
|
||||||
|
|
@ -1690,6 +1758,8 @@ private fun BookGrid(
|
||||||
onShowBookInfo: (BookItem) -> Unit,
|
onShowBookInfo: (BookItem) -> Unit,
|
||||||
onEditBook: (BookItem) -> Unit,
|
onEditBook: (BookItem) -> Unit,
|
||||||
onTogglePinned: (BookItem) -> Unit,
|
onTogglePinned: (BookItem) -> Unit,
|
||||||
|
onSaveOriginalFile: ((BookItem) -> Unit)? = null,
|
||||||
|
onShareOriginalFile: ((BookItem) -> Unit)? = null,
|
||||||
onAddToShelf: ((BookItem) -> Unit)? = null,
|
onAddToShelf: ((BookItem) -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
|
@ -1710,6 +1780,8 @@ private fun BookGrid(
|
||||||
onShowInfo = { onShowBookInfo(book) },
|
onShowInfo = { onShowBookInfo(book) },
|
||||||
onEdit = { onEditBook(book) },
|
onEdit = { onEditBook(book) },
|
||||||
onTogglePinned = { onTogglePinned(book) },
|
onTogglePinned = { onTogglePinned(book) },
|
||||||
|
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
|
||||||
|
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
|
||||||
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
|
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1733,6 +1805,8 @@ private fun BookGrid(
|
||||||
onShowInfo = { onShowBookInfo(book) },
|
onShowInfo = { onShowBookInfo(book) },
|
||||||
onEdit = { onEditBook(book) },
|
onEdit = { onEditBook(book) },
|
||||||
onTogglePinned = { onTogglePinned(book) },
|
onTogglePinned = { onTogglePinned(book) },
|
||||||
|
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
|
||||||
|
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
|
||||||
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
|
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1752,6 +1826,8 @@ private fun BookTile(
|
||||||
onShowInfo: () -> Unit,
|
onShowInfo: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
onTogglePinned: () -> Unit,
|
onTogglePinned: () -> Unit,
|
||||||
|
onSaveOriginalFile: (() -> Unit)? = null,
|
||||||
|
onShareOriginalFile: (() -> Unit)? = null,
|
||||||
onAddToShelf: (() -> Unit)? = null,
|
onAddToShelf: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
|
@ -1803,6 +1879,8 @@ private fun BookTile(
|
||||||
onShowInfo = onShowInfo,
|
onShowInfo = onShowInfo,
|
||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
onToggleSelection = onToggleSelection,
|
onToggleSelection = onToggleSelection,
|
||||||
|
onSaveOriginalFile = onSaveOriginalFile.takeIf { !book.isOpdsStream() && book.path != null },
|
||||||
|
onShareOriginalFile = onShareOriginalFile.takeIf { !book.isOpdsStream() && book.path != null },
|
||||||
onAddToShelf = onAddToShelf
|
onAddToShelf = onAddToShelf
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1839,6 +1917,8 @@ private fun BookListItem(
|
||||||
onShowInfo: () -> Unit,
|
onShowInfo: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
onTogglePinned: () -> Unit,
|
onTogglePinned: () -> Unit,
|
||||||
|
onSaveOriginalFile: (() -> Unit)? = null,
|
||||||
|
onShareOriginalFile: (() -> Unit)? = null,
|
||||||
onAddToShelf: (() -> Unit)? = null
|
onAddToShelf: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
@ -1881,6 +1961,8 @@ private fun BookListItem(
|
||||||
onShowInfo = onShowInfo,
|
onShowInfo = onShowInfo,
|
||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
onToggleSelection = onToggleSelection,
|
onToggleSelection = onToggleSelection,
|
||||||
|
onSaveOriginalFile = onSaveOriginalFile.takeIf { !book.isOpdsStream() && book.path != null },
|
||||||
|
onShareOriginalFile = onShareOriginalFile.takeIf { !book.isOpdsStream() && book.path != null },
|
||||||
onAddToShelf = onAddToShelf
|
onAddToShelf = onAddToShelf
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1898,6 +1980,8 @@ private fun BookActionMenu(
|
||||||
onShowInfo: () -> Unit,
|
onShowInfo: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
onToggleSelection: () -> Unit,
|
onToggleSelection: () -> Unit,
|
||||||
|
onSaveOriginalFile: (() -> Unit)? = null,
|
||||||
|
onShareOriginalFile: (() -> Unit)? = null,
|
||||||
onAddToShelf: (() -> Unit)? = null
|
onAddToShelf: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||||
|
|
@ -1925,6 +2009,26 @@ private fun BookActionMenu(
|
||||||
onEdit()
|
onEdit()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if (onSaveOriginalFile != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) },
|
||||||
|
text = { Text(readerString("action_save_copy_to_device", "Save copy to device")) },
|
||||||
|
onClick = {
|
||||||
|
onDismiss()
|
||||||
|
onSaveOriginalFile()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (onShareOriginalFile != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||||
|
text = { Text(readerString("action_share", "Share")) },
|
||||||
|
onClick = {
|
||||||
|
onDismiss()
|
||||||
|
onShareOriginalFile()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
if (onAddToShelf != null) {
|
if (onAddToShelf != null) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) },
|
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) },
|
||||||
|
|
@ -2112,6 +2216,8 @@ private fun ShelfCollection(
|
||||||
onShowBookInfo: (BookItem) -> Unit,
|
onShowBookInfo: (BookItem) -> Unit,
|
||||||
onEditBook: (BookItem) -> Unit,
|
onEditBook: (BookItem) -> Unit,
|
||||||
onTogglePinned: (BookItem) -> Unit,
|
onTogglePinned: (BookItem) -> Unit,
|
||||||
|
onSaveOriginalFile: ((BookItem) -> Unit)? = null,
|
||||||
|
onShareOriginalFile: ((BookItem) -> Unit)? = null,
|
||||||
onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
|
onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
|
||||||
onManageShelfBooks: ((Shelf) -> Unit)? = null,
|
onManageShelfBooks: ((Shelf) -> Unit)? = null,
|
||||||
onRenameShelf: (Shelf) -> Unit = {},
|
onRenameShelf: (Shelf) -> Unit = {},
|
||||||
|
|
@ -2151,6 +2257,8 @@ private fun ShelfCollection(
|
||||||
onShowBookInfo = onShowBookInfo,
|
onShowBookInfo = onShowBookInfo,
|
||||||
onEditBook = onEditBook,
|
onEditBook = onEditBook,
|
||||||
onTogglePinned = onTogglePinned,
|
onTogglePinned = onTogglePinned,
|
||||||
|
onSaveOriginalFile = onSaveOriginalFile,
|
||||||
|
onShareOriginalFile = onShareOriginalFile,
|
||||||
onAddBooksToShelf = onAddBooksToShelf,
|
onAddBooksToShelf = onAddBooksToShelf,
|
||||||
onManageShelfBooks = onManageShelfBooks,
|
onManageShelfBooks = onManageShelfBooks,
|
||||||
onRenameShelf = onRenameShelf,
|
onRenameShelf = onRenameShelf,
|
||||||
|
|
@ -2172,6 +2280,8 @@ private fun ShelfSection(
|
||||||
onShowBookInfo: (BookItem) -> Unit,
|
onShowBookInfo: (BookItem) -> Unit,
|
||||||
onEditBook: (BookItem) -> Unit,
|
onEditBook: (BookItem) -> Unit,
|
||||||
onTogglePinned: (BookItem) -> Unit,
|
onTogglePinned: (BookItem) -> Unit,
|
||||||
|
onSaveOriginalFile: ((BookItem) -> Unit)?,
|
||||||
|
onShareOriginalFile: ((BookItem) -> Unit)?,
|
||||||
onAddBooksToShelf: ((Set<String>) -> Unit)?,
|
onAddBooksToShelf: ((Set<String>) -> Unit)?,
|
||||||
onManageShelfBooks: ((Shelf) -> Unit)?,
|
onManageShelfBooks: ((Shelf) -> Unit)?,
|
||||||
onRenameShelf: (Shelf) -> Unit,
|
onRenameShelf: (Shelf) -> Unit,
|
||||||
|
|
@ -2258,6 +2368,8 @@ private fun ShelfSection(
|
||||||
onShowInfo = { onShowBookInfo(book) },
|
onShowInfo = { onShowBookInfo(book) },
|
||||||
onEdit = { onEditBook(book) },
|
onEdit = { onEditBook(book) },
|
||||||
onTogglePinned = { onTogglePinned(book) },
|
onTogglePinned = { onTogglePinned(book) },
|
||||||
|
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
|
||||||
|
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
|
||||||
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } },
|
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } },
|
||||||
modifier = Modifier.width(148.dp)
|
modifier = Modifier.width(148.dp)
|
||||||
)
|
)
|
||||||
|
|
@ -2279,6 +2391,8 @@ private fun FolderShelfDetail(
|
||||||
onShowBookInfo: (BookItem) -> Unit,
|
onShowBookInfo: (BookItem) -> Unit,
|
||||||
onEditBook: (BookItem) -> Unit,
|
onEditBook: (BookItem) -> Unit,
|
||||||
onTogglePinned: (BookItem) -> Unit,
|
onTogglePinned: (BookItem) -> Unit,
|
||||||
|
onSaveOriginalFile: ((BookItem) -> Unit)? = null,
|
||||||
|
onShareOriginalFile: ((BookItem) -> Unit)? = null,
|
||||||
onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
|
onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
|
||||||
onOpenShelf: (Shelf) -> Unit,
|
onOpenShelf: (Shelf) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
|
@ -2345,6 +2459,8 @@ private fun FolderShelfDetail(
|
||||||
onShowInfo = { onShowBookInfo(book) },
|
onShowInfo = { onShowBookInfo(book) },
|
||||||
onEdit = { onEditBook(book) },
|
onEdit = { onEditBook(book) },
|
||||||
onTogglePinned = { onTogglePinned(book) },
|
onTogglePinned = { onTogglePinned(book) },
|
||||||
|
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
|
||||||
|
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
|
||||||
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } }
|
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1663,13 +1663,9 @@ private fun List<ReaderPage>.findSharedNativeVerticalPageIndexForBlock(block: Se
|
||||||
return firstOrNull()?.pageIndex
|
return firstOrNull()?.pageIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<SharedNativeVerticalFlowItem>.sharedNativeVerticalItemIndexForLocator(
|
internal fun List<SharedNativeVerticalFlowItem>.sharedNativeVerticalItemIndexForLocator(
|
||||||
locator: ReaderLocator
|
locator: ReaderLocator
|
||||||
): Int? {
|
): Int? {
|
||||||
locator.pageIndex?.let { pageIndex ->
|
|
||||||
val samePage = indexOfFirst { item -> item.page.pageIndex == pageIndex }
|
|
||||||
if (samePage >= 0) return samePage
|
|
||||||
}
|
|
||||||
val chapterIndex = locator.chapterIndex
|
val chapterIndex = locator.chapterIndex
|
||||||
if (chapterIndex != null) {
|
if (chapterIndex != null) {
|
||||||
locator.blockIndex?.let { blockIndex ->
|
locator.blockIndex?.let { blockIndex ->
|
||||||
|
|
@ -1687,6 +1683,12 @@ private fun List<SharedNativeVerticalFlowItem>.sharedNativeVerticalItemIndexForL
|
||||||
}
|
}
|
||||||
if (sameOffset >= 0) return sameOffset
|
if (sameOffset >= 0) return sameOffset
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
locator.pageIndex?.let { pageIndex ->
|
||||||
|
val samePage = indexOfFirst { item -> item.page.pageIndex == pageIndex }
|
||||||
|
if (samePage >= 0) return samePage
|
||||||
|
}
|
||||||
|
if (chapterIndex != null) {
|
||||||
val sameChapter = indexOfFirst { item -> item.page.chapterIndex == chapterIndex }
|
val sameChapter = indexOfFirst { item -> item.page.chapterIndex == chapterIndex }
|
||||||
if (sameChapter >= 0) return sameChapter
|
if (sameChapter >= 0) return sameChapter
|
||||||
}
|
}
|
||||||
|
|
@ -3170,7 +3172,7 @@ private fun SemanticTextBlock.renderedTextStyle(
|
||||||
).takeIf { it.isSpecified } ?: foreground,
|
).takeIf { it.isSpecified } ?: foreground,
|
||||||
fontSize = fontSize,
|
fontSize = fontSize,
|
||||||
lineHeight = lineHeight,
|
lineHeight = lineHeight,
|
||||||
fontFamily = fallbackFontFamily,
|
fontFamily = style.spanStyle.fontFamily ?: fallbackFontFamily,
|
||||||
fontWeight = fontWeight
|
fontWeight = fontWeight
|
||||||
?: style.spanStyle.fontWeight
|
?: style.spanStyle.fontWeight
|
||||||
?: if (this is SemanticHeader) FontWeight.Bold else MaterialTheme.typography.bodyLarge.fontWeight,
|
?: if (this is SemanticHeader) FontWeight.Bold else MaterialTheme.typography.bodyLarge.fontWeight,
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,9 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import com.aryan.reader.shared.BuiltInReaderThemes
|
import com.aryan.reader.shared.BuiltInReaderThemes
|
||||||
import com.aryan.reader.shared.CustomFontItem
|
import com.aryan.reader.shared.CustomFontItem
|
||||||
|
import com.aryan.reader.shared.fontFaceSummary
|
||||||
|
import com.aryan.reader.shared.groupByFamily
|
||||||
|
import com.aryan.reader.shared.hasVariableWeightFace
|
||||||
import com.aryan.reader.shared.HighlightColor
|
import com.aryan.reader.shared.HighlightColor
|
||||||
import com.aryan.reader.shared.PageInfoMode
|
import com.aryan.reader.shared.PageInfoMode
|
||||||
import com.aryan.reader.shared.PageInfoPosition
|
import com.aryan.reader.shared.PageInfoPosition
|
||||||
|
|
@ -1740,28 +1743,46 @@ fun SharedReaderFormatControls(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val activeCustomFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }
|
val activeCustomFontFamilies = customFonts.filterNot { it.isDeleted }.groupByFamily()
|
||||||
if (activeCustomFonts.isNotEmpty()) {
|
if (activeCustomFontFamilies.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
readerString("desktop_imported_fonts", "Imported fonts"),
|
readerString("desktop_imported_fonts", "Imported fonts"),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
SharedReaderChoiceRow {
|
SharedReaderChoiceRow {
|
||||||
activeCustomFonts.forEach { font ->
|
activeCustomFontFamilies.forEach { family ->
|
||||||
|
val isSelected = family.variants.any { it.font.path == settings.customFontPath }
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = settings.customFontPath == font.path,
|
selected = isSelected,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
val baseFont = family.variants.firstOrNull { it.variant?.weight == FontWeight.Normal && it.variant?.style == androidx.compose.ui.text.font.FontStyle.Normal }?.font ?: family.variants.first().font
|
||||||
onReaderAction(
|
onReaderAction(
|
||||||
ReaderAction.SettingsChanged(
|
ReaderAction.SettingsChanged(
|
||||||
settings.copy(
|
settings.copy(
|
||||||
fontFamily = font.displayName,
|
fontFamily = family.familyName,
|
||||||
customFontPath = font.path
|
customFontPath = baseFont.path
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
label = { Text(font.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
label = {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(family.familyName, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
val variantsStr = buildString {
|
||||||
|
append(family.fontFaceSummary())
|
||||||
|
if (family.hasVariableWeightFace()) append(" - Variable weight")
|
||||||
|
}
|
||||||
|
if (variantsStr.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
"($variantsStr)",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package com.aryan.reader.shared
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class FontVariantInferenceTest {
|
||||||
|
@Test
|
||||||
|
fun variableRegularAndItalicFilesShareFamilySignature() {
|
||||||
|
val regular = "Pliant-VariableFont_wdth,wght"
|
||||||
|
val italic = "Pliant-Italic-VariableFont_wdth,wght"
|
||||||
|
|
||||||
|
assertEquals(regular.familyFilenameSignature(), italic.familyFilenameSignature())
|
||||||
|
assertEquals("pliant", regular.familyFilenameSignature())
|
||||||
|
assertEquals(FontStyle.Italic, italic.detectFontVariant()?.style)
|
||||||
|
assertTrue(regular.supportsVariableWeightAxis())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun familyGroupingUsesBaseFamilyForVariableFontVariants() {
|
||||||
|
val fonts = listOf(
|
||||||
|
fontItem("1", "Pliant-VariableFont_wdth,wght.ttf"),
|
||||||
|
fontItem("2", "Pliant-Italic-VariableFont_wdth,wght.ttf")
|
||||||
|
)
|
||||||
|
|
||||||
|
val family = fonts.groupByFamily().single()
|
||||||
|
|
||||||
|
assertEquals("Pliant", family.familyName)
|
||||||
|
assertEquals(2, family.variants.size)
|
||||||
|
assertTrue(family.variants.any { it.variant?.style == FontStyle.Italic })
|
||||||
|
assertTrue(family.variants.any { it.variant?.weight == FontWeight.Normal })
|
||||||
|
assertEquals("Regular, Italic", family.fontFaceSummary())
|
||||||
|
assertTrue(family.hasVariableWeightFace())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun variableWeightAxisEmitsCssWeightRange() {
|
||||||
|
assertEquals(
|
||||||
|
"100 900",
|
||||||
|
"Pliant-VariableFont_wdth,wght".fontWeightCssDescriptor(FontWeight.Normal)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"700",
|
||||||
|
"Literata-Bold".fontWeightCssDescriptor(FontWeight.Bold)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fontItem(id: String, fileName: String): CustomFontItem {
|
||||||
|
return CustomFontItem(
|
||||||
|
id = id,
|
||||||
|
displayName = fileName.substringBeforeLast('.'),
|
||||||
|
fileName = fileName,
|
||||||
|
fileExtension = fileName.substringAfterLast('.'),
|
||||||
|
path = "/fonts/$fileName",
|
||||||
|
timestamp = id.toLong()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,12 +74,21 @@ class NonReaderLayoutModelsTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `desktop book overflow exposes add to shelf action without changing android`() {
|
fun `book overflow exposes platform save and share actions`() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
setOf(NonReaderBookOverflowAction.ADD_TO_SHELF),
|
setOf(
|
||||||
|
NonReaderBookOverflowAction.ADD_TO_SHELF,
|
||||||
|
NonReaderBookOverflowAction.SAVE_ORIGINAL
|
||||||
|
),
|
||||||
bookOverflowActionsForPlatform(ReaderPlatform.DESKTOP)
|
bookOverflowActionsForPlatform(ReaderPlatform.DESKTOP)
|
||||||
)
|
)
|
||||||
assertEquals(emptySet<NonReaderBookOverflowAction>(), bookOverflowActionsForPlatform(ReaderPlatform.ANDROID))
|
assertEquals(
|
||||||
|
setOf(
|
||||||
|
NonReaderBookOverflowAction.SAVE_ORIGINAL,
|
||||||
|
NonReaderBookOverflowAction.SHARE_ORIGINAL
|
||||||
|
),
|
||||||
|
bookOverflowActionsForPlatform(ReaderPlatform.ANDROID)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import com.aryan.reader.paginatedreader.SemanticImage
|
||||||
import com.aryan.reader.paginatedreader.SemanticMath
|
import com.aryan.reader.paginatedreader.SemanticMath
|
||||||
import com.aryan.reader.paginatedreader.SemanticParagraph
|
import com.aryan.reader.paginatedreader.SemanticParagraph
|
||||||
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
|
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
|
||||||
|
import com.aryan.reader.shared.ReaderLocator
|
||||||
import com.aryan.reader.shared.reader.ReaderPage
|
import com.aryan.reader.shared.reader.ReaderPage
|
||||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||||
import com.aryan.reader.shared.reader.SharedEpubChapter
|
import com.aryan.reader.shared.reader.SharedEpubChapter
|
||||||
|
|
@ -192,6 +193,57 @@ class SharedNativeVerticalReaderFlowTest {
|
||||||
assertEquals(Color.Red, paragraphItem.style.blockStyle.borderTop?.color)
|
assertEquals(Color.Red, paragraphItem.style.blockStyle.borderTop?.color)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shared native vertical restore prefers block locator before compat page`() {
|
||||||
|
val first = SemanticParagraph(
|
||||||
|
text = "First paragraph",
|
||||||
|
spans = emptyList(),
|
||||||
|
style = CssStyle(),
|
||||||
|
elementId = "p1",
|
||||||
|
cfi = "/4/2",
|
||||||
|
startCharOffsetInSource = 0,
|
||||||
|
blockIndex = 1
|
||||||
|
)
|
||||||
|
val second = SemanticParagraph(
|
||||||
|
text = "Second paragraph",
|
||||||
|
spans = emptyList(),
|
||||||
|
style = CssStyle(),
|
||||||
|
elementId = "p2",
|
||||||
|
cfi = "/4/4",
|
||||||
|
startCharOffsetInSource = 16,
|
||||||
|
blockIndex = 2
|
||||||
|
)
|
||||||
|
val book = SharedEpubBook(
|
||||||
|
id = "book",
|
||||||
|
fileName = "book.epub",
|
||||||
|
title = "Book",
|
||||||
|
chapters = listOf(
|
||||||
|
SharedEpubChapter(
|
||||||
|
id = "chapter_0",
|
||||||
|
title = "Chapter",
|
||||||
|
plainText = "First paragraph\nSecond paragraph",
|
||||||
|
semanticBlocks = listOf(first, second)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val items = buildSharedNativeVerticalFlowItems(book, pages = emptyList())
|
||||||
|
val restoredIndex = items.sharedNativeVerticalItemIndexForLocator(
|
||||||
|
ReaderLocator(
|
||||||
|
chapterIndex = 0,
|
||||||
|
pageIndex = 0,
|
||||||
|
startOffset = 16,
|
||||||
|
endOffset = 32,
|
||||||
|
blockIndex = 2,
|
||||||
|
charOffset = 16,
|
||||||
|
cfi = "/4/4:0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, restoredIndex)
|
||||||
|
assertEquals(2, items[restoredIndex!!].block?.blockIndex)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `svg math blocks stay in native vertical flow`() {
|
fun `svg math blocks stay in native vertical flow`() {
|
||||||
val math = SemanticMath(
|
val math = SemanticMath(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package com.aryan.reader.paginatedreader
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
@ -96,15 +98,53 @@ class HtmlParserLinkTest {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parse(html: String): List<SemanticBlock> {
|
@Test
|
||||||
|
fun `css font family resolves onto block and inline span styles`() {
|
||||||
|
val cssRules = CssParser.parse(
|
||||||
|
cssContent = """
|
||||||
|
p { font-family: "BodyFace"; }
|
||||||
|
i { font-style: italic; }
|
||||||
|
""".trimIndent(),
|
||||||
|
cssPath = null,
|
||||||
|
baseFontSizeSp = 16f,
|
||||||
|
density = 1f,
|
||||||
|
constraints = Constraints(maxWidth = 400, maxHeight = 800),
|
||||||
|
isDarkTheme = false
|
||||||
|
).rules
|
||||||
|
|
||||||
|
val blocks = parse(
|
||||||
|
html = """
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<p>plain <i>italic</i></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".trimIndent(),
|
||||||
|
cssRules = cssRules,
|
||||||
|
fontFamilyMap = mapOf("bodyface" to FontFamily.Serif)
|
||||||
|
)
|
||||||
|
|
||||||
|
val paragraph = blocks.single() as SemanticParagraph
|
||||||
|
val italicSpan = paragraph.spans.single { it.tag == "i" }
|
||||||
|
|
||||||
|
assertEquals(FontFamily.Serif, paragraph.style.spanStyle.fontFamily)
|
||||||
|
assertEquals(FontFamily.Serif, italicSpan.style.spanStyle.fontFamily)
|
||||||
|
assertEquals(FontStyle.Italic, italicSpan.style.spanStyle.fontStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(
|
||||||
|
html: String,
|
||||||
|
cssRules: OptimizedCssRules = OptimizedCssRules(),
|
||||||
|
fontFamilyMap: Map<String, FontFamily> = emptyMap()
|
||||||
|
): List<SemanticBlock> {
|
||||||
return htmlToSemanticBlocks(
|
return htmlToSemanticBlocks(
|
||||||
html = html,
|
html = html,
|
||||||
cssRules = OptimizedCssRules(),
|
cssRules = cssRules,
|
||||||
textStyle = TextStyle(fontSize = 16.sp),
|
textStyle = TextStyle(fontSize = 16.sp),
|
||||||
chapterAbsPath = "OEBPS/chapter1.xhtml",
|
chapterAbsPath = "OEBPS/chapter1.xhtml",
|
||||||
extractionBasePath = "",
|
extractionBasePath = "",
|
||||||
density = Density(1f),
|
density = Density(1f),
|
||||||
fontFamilyMap = emptyMap(),
|
fontFamilyMap = fontFamilyMap,
|
||||||
constraints = Constraints(maxWidth = 400, maxHeight = 800)
|
constraints = Constraints(maxWidth = 400, maxHeight = 800)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,48 @@ class SharedEpubPaginationCacheTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saving more than three configurations removes oldest page cache`() = runBlocking {
|
||||||
|
val root = Files.createTempDirectory("reader-page-cache").toFile()
|
||||||
|
try {
|
||||||
|
val book = cacheBook()
|
||||||
|
val settings = ReaderSettings()
|
||||||
|
val pages = listOf(
|
||||||
|
ReaderPage(
|
||||||
|
pageIndex = 0,
|
||||||
|
chapterIndex = 0,
|
||||||
|
chapterTitle = "One",
|
||||||
|
text = "Cached page",
|
||||||
|
startOffset = 0,
|
||||||
|
endOffset = 11
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val viewports = listOf(
|
||||||
|
ReaderViewportSpec(widthPx = 900, heightPx = 700),
|
||||||
|
ReaderViewportSpec(widthPx = 901, heightPx = 700),
|
||||||
|
ReaderViewportSpec(widthPx = 902, heightPx = 700),
|
||||||
|
ReaderViewportSpec(widthPx = 903, heightPx = 700)
|
||||||
|
)
|
||||||
|
val writer = SharedEpubPaginationCache(root)
|
||||||
|
|
||||||
|
viewports.take(3).forEachIndexed { index, viewport ->
|
||||||
|
writer.save(book, settings, viewport, pages)
|
||||||
|
val key = writer.keyFor(book, settings, viewport)
|
||||||
|
val file = root
|
||||||
|
.resolve(key.bookHash)
|
||||||
|
.resolve("${key.configHash.toUInt().toString(16)}.pages.pb")
|
||||||
|
file.setLastModified((index + 1) * 1_000L)
|
||||||
|
}
|
||||||
|
writer.save(book, settings, viewports.last(), pages)
|
||||||
|
val reader = SharedEpubPaginationCache(root)
|
||||||
|
|
||||||
|
assertNull(reader.load(book, settings, viewports.first()))
|
||||||
|
assertNotNull(reader.load(book, settings, viewports.last()))
|
||||||
|
} finally {
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun cacheBook(): SharedEpubBook {
|
private fun cacheBook(): SharedEpubBook {
|
||||||
return SharedEpubBook(
|
return SharedEpubBook(
|
||||||
id = "book-id",
|
id = "book-id",
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,22 @@ class SharedJvmBookLoaderTest {
|
||||||
assertTrue(!css.contains("data:font/woff2"))
|
assertTrue(!css.contains("data:font/woff2"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `epub loader uses spine toc id when manifest contains volume ncx files first`() = withTempDir { dir ->
|
||||||
|
val file = File(dir, "merged-volumes.epub")
|
||||||
|
writeMergedVolumeTocEpub(file)
|
||||||
|
|
||||||
|
val book = SharedJvmBookLoader.loadEpub(file)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("Volume 1", "Chapter 1", "Volume 2", "Chapter 2"),
|
||||||
|
book.tableOfContents.map { it.label }
|
||||||
|
)
|
||||||
|
assertEquals(listOf(0, 1, 0, 1), book.tableOfContents.map { it.depth })
|
||||||
|
assertEquals("2/title.xhtml", book.tableOfContents[2].href)
|
||||||
|
assertEquals(4, book.chapters.size)
|
||||||
|
}
|
||||||
|
|
||||||
private fun withTempDir(block: (File) -> Unit) {
|
private fun withTempDir(block: (File) -> Unit) {
|
||||||
val dir = Files.createTempDirectory("reader-shared-loader").toFile()
|
val dir = Files.createTempDirectory("reader-shared-loader").toFile()
|
||||||
try {
|
try {
|
||||||
|
|
@ -305,6 +321,70 @@ class SharedJvmBookLoaderTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun writeMergedVolumeTocEpub(file: File) {
|
||||||
|
writeZip(file) {
|
||||||
|
text(
|
||||||
|
"META-INF/container.xml",
|
||||||
|
"""
|
||||||
|
<container>
|
||||||
|
<rootfiles>
|
||||||
|
<rootfile full-path="content.opf"/>
|
||||||
|
</rootfiles>
|
||||||
|
</container>
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
text(
|
||||||
|
"content.opf",
|
||||||
|
"""
|
||||||
|
<package>
|
||||||
|
<metadata>
|
||||||
|
<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Merged Volumes</dc:title>
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="v1title" href="1/title.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="v1c1" href="1/chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="v2title" href="2/title.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="v2c1" href="2/chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="v1ncx" href="1/toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||||
|
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||||
|
</manifest>
|
||||||
|
<spine toc="ncx">
|
||||||
|
<itemref idref="v1title"/>
|
||||||
|
<itemref idref="v1c1"/>
|
||||||
|
<itemref idref="v2title"/>
|
||||||
|
<itemref idref="v2c1"/>
|
||||||
|
</spine>
|
||||||
|
</package>
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
text(
|
||||||
|
"1/toc.ncx",
|
||||||
|
"""
|
||||||
|
<ncx><navMap>
|
||||||
|
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="title.xhtml"/></navPoint>
|
||||||
|
</navMap></ncx>
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
text(
|
||||||
|
"toc.ncx",
|
||||||
|
"""
|
||||||
|
<ncx><navMap>
|
||||||
|
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="1/title.xhtml"/>
|
||||||
|
<navPoint><navLabel><text>Chapter 1</text></navLabel><content src="1/chapter1.xhtml"/></navPoint>
|
||||||
|
</navPoint>
|
||||||
|
<navPoint><navLabel><text>Volume 2</text></navLabel><content src="2/title.xhtml"/>
|
||||||
|
<navPoint><navLabel><text>Chapter 2</text></navLabel><content src="2/chapter1.xhtml"/></navPoint>
|
||||||
|
</navPoint>
|
||||||
|
</navMap></ncx>
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
text("1/title.xhtml", "<html><body><h1>Volume 1</h1><p>Volume one.</p></body></html>")
|
||||||
|
text("1/chapter1.xhtml", "<html><body><h1>Chapter 1</h1><p>Chapter one.</p></body></html>")
|
||||||
|
text("2/title.xhtml", "<html><body><h1>Volume 2</h1><p>Volume two.</p></body></html>")
|
||||||
|
text("2/chapter1.xhtml", "<html><body><h1>Chapter 2</h1><p>Chapter two.</p></body></html>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun writeTwoChapterEpub(file: File) {
|
private fun writeTwoChapterEpub(file: File) {
|
||||||
writeZip(file) {
|
writeZip(file) {
|
||||||
text(
|
text(
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ private class SemanticHtmlParser(
|
||||||
}
|
}
|
||||||
|
|
||||||
val body = document.body()
|
val body = document.body()
|
||||||
return parseContainer(body, getElementStyle(body))
|
return parseContainer(body, getElementStyle(body).withResolvedFontFamily())
|
||||||
}
|
}
|
||||||
|
|
||||||
private inline fun Element.anyChildElement(predicate: (Element) -> Boolean): Boolean {
|
private inline fun Element.anyChildElement(predicate: (Element) -> Boolean): Boolean {
|
||||||
|
|
@ -295,7 +295,7 @@ private class SemanticHtmlParser(
|
||||||
textEmphasis = elementOwnStyle.textEmphasis ?: inheritedStyle.textEmphasis,
|
textEmphasis = elementOwnStyle.textEmphasis ?: inheritedStyle.textEmphasis,
|
||||||
whiteSpace = elementOwnStyle.whiteSpace ?: inheritedStyle.whiteSpace,
|
whiteSpace = elementOwnStyle.whiteSpace ?: inheritedStyle.whiteSpace,
|
||||||
customProperties = inheritedStyle.customProperties + elementOwnStyle.customProperties
|
customProperties = inheritedStyle.customProperties + elementOwnStyle.customProperties
|
||||||
)
|
).withResolvedFontFamily()
|
||||||
|
|
||||||
if (finalStyle.display == "none") return emptyList()
|
if (finalStyle.display == "none") return emptyList()
|
||||||
|
|
||||||
|
|
@ -364,7 +364,19 @@ private class SemanticHtmlParser(
|
||||||
val pseudoStyle = rulesForElement(element, pseudoElement).fold(CssStyle()) { acc, rule ->
|
val pseudoStyle = rulesForElement(element, pseudoElement).fold(CssStyle()) { acc, rule ->
|
||||||
acc.merge(rule.style)
|
acc.merge(rule.style)
|
||||||
}
|
}
|
||||||
return inheritedStyle.merge(pseudoStyle)
|
return inheritedStyle.merge(pseudoStyle).withResolvedFontFamily()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CssStyle.withResolvedFontFamily(): CssStyle {
|
||||||
|
if (spanStyle.fontFamily != null) return this
|
||||||
|
val resolvedFontFamily = fontFamilies.asSequence()
|
||||||
|
.mapNotNull { name ->
|
||||||
|
val normalized = name.trim().lowercase()
|
||||||
|
currentFontFamilyMap[normalized] ?: FontFamilyMapper.nameToFontFamily(normalized)
|
||||||
|
}
|
||||||
|
.firstOrNull()
|
||||||
|
?: return this
|
||||||
|
return copy(spanStyle = spanStyle.copy(fontFamily = resolvedFontFamily))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun firstCssUrl(value: String): String? {
|
private fun firstCssUrl(value: String): String? {
|
||||||
|
|
@ -805,7 +817,7 @@ private class SemanticHtmlParser(
|
||||||
appendText("\n"); return
|
appendText("\n"); return
|
||||||
}
|
}
|
||||||
val currentElementStyle = getElementStyle(node, inheritedStyle.customProperties)
|
val currentElementStyle = getElementStyle(node, inheritedStyle.customProperties)
|
||||||
val newStyle = inheritedStyle.merge(currentElementStyle)
|
val newStyle = inheritedStyle.merge(currentElementStyle).withResolvedFontFamily()
|
||||||
if (newStyle.display == "none") return
|
if (newStyle.display == "none") return
|
||||||
val tag = node.tagName().lowercase()
|
val tag = node.tagName().lowercase()
|
||||||
val href = node.linkHrefOrNull()
|
val href = node.linkHrefOrNull()
|
||||||
|
|
@ -969,7 +981,10 @@ private class SemanticHtmlParser(
|
||||||
val isOrdered = listElement.tagName().lowercase() == "ol"
|
val isOrdered = listElement.tagName().lowercase() == "ol"
|
||||||
val items = listElement.children().mapNotNull { child ->
|
val items = listElement.children().mapNotNull { child ->
|
||||||
if (child.tagName().lowercase() != "li") return@mapNotNull null
|
if (child.tagName().lowercase() != "li") return@mapNotNull null
|
||||||
val itemStyle = listStyle.merge(getElementStyle(child, listStyle.customProperties)).withResolvedBlockResources()
|
val itemStyle = listStyle
|
||||||
|
.merge(getElementStyle(child, listStyle.customProperties))
|
||||||
|
.withResolvedFontFamily()
|
||||||
|
.withResolvedBlockResources()
|
||||||
val (text, spans) = buildSemanticTextAndSpans(child, itemStyle, inheritedLinkHref)
|
val (text, spans) = buildSemanticTextAndSpans(child, itemStyle, inheritedLinkHref)
|
||||||
val imageSrc = itemStyle.blockStyle.listStyleImage?.let { resolveImagePath(it) }
|
val imageSrc = itemStyle.blockStyle.listStyleImage?.let { resolveImagePath(it) }
|
||||||
SemanticListItem(text, spans, itemStyle, child.id().ifBlank { null }, child.getCfiPath(), 0, imageSrc, blockIndex = nextBlockIndex++)
|
SemanticListItem(text, spans, itemStyle, child.id().ifBlank { null }, child.getCfiPath(), 0, imageSrc, blockIndex = nextBlockIndex++)
|
||||||
|
|
@ -990,7 +1005,9 @@ private class SemanticHtmlParser(
|
||||||
val tagName = cellElement.tagName().lowercase()
|
val tagName = cellElement.tagName().lowercase()
|
||||||
if (tagName !in listOf("td", "th")) return@mapNotNull null
|
if (tagName !in listOf("td", "th")) return@mapNotNull null
|
||||||
|
|
||||||
var cellCssStyle = getElementStyle(cellElement, rowStyle.customProperties).withResolvedBlockResources()
|
var cellCssStyle = getElementStyle(cellElement, rowStyle.customProperties)
|
||||||
|
.withResolvedFontFamily()
|
||||||
|
.withResolvedBlockResources()
|
||||||
if (cellCssStyle.display == "none") return@mapNotNull null
|
if (cellCssStyle.display == "none") return@mapNotNull null
|
||||||
|
|
||||||
if (!cellCssStyle.blockStyle.backgroundColor.isSpecified) {
|
if (!cellCssStyle.blockStyle.backgroundColor.isSpecified) {
|
||||||
|
|
|
||||||
|
|
@ -383,20 +383,29 @@ class SharedEpubPaginationCache(
|
||||||
|
|
||||||
private fun cleanupOldConfigurations(bookHash: String) {
|
private fun cleanupOldConfigurations(bookHash: String) {
|
||||||
val bookDir = File(cacheRoot, bookHash)
|
val bookDir = File(cacheRoot, bookHash)
|
||||||
val files = bookDir.listFiles { file -> file.isFile && file.name.endsWith(".pages.pb") }
|
val files = pageCacheFiles(bookDir)
|
||||||
?.sortedByDescending { it.lastModified() }
|
|
||||||
.orEmpty()
|
|
||||||
files.drop(3).forEach { file ->
|
files.drop(3).forEach { file ->
|
||||||
file.delete()
|
file.delete()
|
||||||
File(bookDir, file.name.removeSuffix(".pages.pb") + ".chapters").deleteRecursively()
|
File(bookDir, file.name.removeSuffix(".pages.pb") + ".chapters").deleteRecursively()
|
||||||
}
|
}
|
||||||
val activeConfigNames = files.take(3).map { it.name.removeSuffix(".pages.pb") }.toSet()
|
val activeConfigNames = files.take(3).map { it.name.removeSuffix(".pages.pb") }.toSet()
|
||||||
bookDir.listFiles { file -> file.isDirectory && file.name.endsWith(".chapters") }
|
chapterCacheDirs(bookDir)
|
||||||
.orEmpty()
|
|
||||||
.filterNot { dir -> dir.name.removeSuffix(".chapters") in activeConfigNames }
|
.filterNot { dir -> dir.name.removeSuffix(".chapters") in activeConfigNames }
|
||||||
.forEach { it.deleteRecursively() }
|
.forEach { it.deleteRecursively() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun pageCacheFiles(bookDir: File): List<File> {
|
||||||
|
val files = bookDir.listFiles() ?: return emptyList()
|
||||||
|
return files
|
||||||
|
.filter { file -> file.isFile && file.name.endsWith(".pages.pb") }
|
||||||
|
.sortedByDescending { file -> file.lastModified() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chapterCacheDirs(bookDir: File): List<File> {
|
||||||
|
val files = bookDir.listFiles() ?: return emptyList()
|
||||||
|
return files.filter { file -> file.isDirectory && file.name.endsWith(".chapters") }
|
||||||
|
}
|
||||||
|
|
||||||
private fun CachedReaderPages.matches(key: SharedEpubPaginationCacheKey): Boolean {
|
private fun CachedReaderPages.matches(key: SharedEpubPaginationCacheKey): Boolean {
|
||||||
return schemaVersion == SharedEpubPaginationCacheSchemaVersion &&
|
return schemaVersion == SharedEpubPaginationCacheSchemaVersion &&
|
||||||
processingVersion == SharedEpubPaginationProcessingVersion &&
|
processingVersion == SharedEpubPaginationProcessingVersion &&
|
||||||
|
|
|
||||||
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