From 625a4d5d2eeeb025f0ec2f5c4b61acfd26acca07 Mon Sep 17 00:00:00 2001 From: Aryan Date: Sun, 14 Jun 2026 13:43:49 +0530 Subject: [PATCH] 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 --- .github/workflows/desktop-release.yml | 409 ++++++++++ .gitignore | 4 +- .idea/vcs.xml | 12 + app/build.gradle.kts | 1 + .../aryan/reader/LibraryScreenContentTest.kt | 2 + app/src/main/AndroidManifest.xml | 15 + app/src/main/assets/epub_reader.js | 27 +- .../java/com/aryan/reader/AppNavigation.kt | 16 +- .../main/java/com/aryan/reader/AppUiModels.kt | 1 + .../java/com/aryan/reader/ClipboardUtils.kt | 31 + app/src/main/java/com/aryan/reader/Common.kt | 18 +- .../aryan/reader/ExternalFileOpenRouter.kt | 57 ++ .../main/java/com/aryan/reader/FontsScreen.kt | 216 ++++- .../main/java/com/aryan/reader/HomeScreen.kt | 90 ++- .../java/com/aryan/reader/LibraryScreen.kt | 137 +++- .../java/com/aryan/reader/MainActivity.kt | 19 +- .../java/com/aryan/reader/MainViewModel.kt | 381 +++++++-- .../com/aryan/reader/ReaderFileInfoDialogs.kt | 80 +- .../com/aryan/reader/ReaderFontDiagnostics.kt | 15 + .../com/aryan/reader/ReaderPopupSizing.kt | 19 + .../com/aryan/reader/SharedComposables.kt | 39 + .../java/com/aryan/reader/UiLabelResources.kt | 3 +- .../com/aryan/reader/data/FontsRepository.kt | 52 +- .../com/aryan/reader/data/RecentFileDao.kt | 4 +- .../java/com/aryan/reader/epub/EpubParser.kt | 44 +- .../java/com/aryan/reader/epub/OdtParser.kt | 37 +- .../aryan/reader/epubreader/ChapterWebView.kt | 104 ++- .../epubreader/DictionarySettingsDialog.kt | 36 +- .../reader/epubreader/EpubReaderScreen.kt | 184 ++++- .../reader/epubreader/EpubReaderSettings.kt | 156 +++- .../reader/epubreader/EpubReaderSystem.kt | 44 + .../aryan/reader/epubreader/EpubReaderTts.kt | 9 +- .../reader/epubreader/EpubTtsChunkMatching.kt | 23 + .../paginatedreader/AndroidEpubKeyCommands.kt | 39 + .../reader/paginatedreader/BookPaginator.kt | 5 +- .../paginatedreader/EpubFontFaceSiblings.kt | 132 +++ .../reader/paginatedreader/FontLoader.kt | 91 ++- .../reader/paginatedreader/PaginatedReader.kt | 325 ++++++-- .../com/aryan/reader/pdf/PdfDocumentUtils.kt | 68 +- .../com/aryan/reader/pdf/PdfPageComposable.kt | 27 +- .../com/aryan/reader/pdf/PdfSettingsSheets.kt | 9 + .../java/com/aryan/reader/pdf/PdfToolbars.kt | 11 +- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 44 +- .../aryan/reader/pdf/PdfViewerStateLogic.kt | 46 ++ .../com/aryan/reader/pdf/ToolSettingsPopup.kt | 29 +- .../aryan/reader/tts/BaseTtsSynthesizer.kt | 13 +- .../aryan/reader/tts/TtsPlaybackManager.kt | 122 ++- app/src/main/res/values-et/plurals.xml | 90 +-- app/src/main/res/values-et/strings.xml | 373 ++++----- app/src/main/res/values-vi/strings.xml | 2 + app/src/main/res/values/strings.xml | 12 + app/src/main/res/xml/locales_config.xml | 1 + .../AndroidStringFormatResourcesTest.kt | 18 +- .../aryan/reader/AppLanguageOptionsTest.kt | 6 +- .../com/aryan/reader/ClipboardUtilsTest.kt | 17 + .../ExternalFileOpenRouteDeciderTest.kt | 28 + .../com/aryan/reader/MainViewModelTest.kt | 85 +- .../com/aryan/reader/ReaderPopupSizingTest.kt | 22 + .../reader/data/ImportedFontFileNameTest.kt | 32 + .../data/RecentFileDaoReadingPositionTest.kt | 20 + .../aryan/reader/epub/EpubParserUnitTest.kt | 68 ++ .../EpubReaderTtsHighlightAssetTest.kt | 29 + .../epubreader/EpubTtsChunkMatchingTest.kt | 41 + .../AndroidEpubKeyCommandsTest.kt | 75 ++ .../EpubFontFaceSiblingsTest.kt | 128 +++ .../NativeVerticalLocationTest.kt | 154 ++++ .../ReaderNavigationTargetsTest.kt | 2 +- .../reader/pdf/PdfReaderCoreLogicTest.kt | 40 + .../PdfReaderSettingsAndSharedModelsTest.kt | 17 + .../aryan/reader/pdf/PdfZoomLockStateTest.kt | 80 ++ .../reader/tts/TtsChunkNavigationTest.kt | 30 + build.gradle.kts | 21 + desktopApp/build.gradle.kts | 754 +++++++++++++++++- desktopApp/packaging/README.md | 229 ++++++ .../reader/desktop/DesktopFileDialogs.kt | 10 + .../reader/desktop/DesktopLanguageSettings.kt | 3 +- .../aryan/reader/desktop/DesktopLibraryUi.kt | 23 +- .../reader/desktop/DesktopReaderTypography.kt | 47 +- .../kotlin/com/aryan/reader/desktop/Main.kt | 23 +- .../DesktopAurPackagingMetadataTest.kt | 32 + .../desktop/DesktopStringResourcesTest.kt | 8 + gradlew | 0 scripts/desktop/download-pdfium.ps1 | 52 ++ scripts/desktop/publish-pdfium-release.ps1 | 51 ++ settings.gradle.kts | 16 +- shared/build.gradle.kts | 49 +- .../aryan/reader/shared/CustomFontModels.kt | 76 ++ .../reader/shared/FontVariantInference.kt | 149 ++++ .../reader/shared/ui/NonReaderLayoutModels.kt | 14 +- .../reader/shared/ui/NonReaderScreens.kt | 116 +++ .../shared/ui/SharedNativePaginatedReader.kt | 14 +- .../reader/shared/ui/SharedReaderChrome.kt | 35 +- .../reader/shared/FontVariantInferenceTest.kt | 60 ++ .../shared/ui/NonReaderLayoutModelsTest.kt | 15 +- .../ui/SharedNativeVerticalReaderFlowTest.kt | 52 ++ .../paginatedreader/HtmlParserLinkTest.kt | 46 +- .../reader/SharedEpubPaginationCacheTest.kt | 42 + .../shared/reader/SharedJvmBookLoaderTest.kt | 80 ++ .../reader/paginatedreader/HtmlParser.kt | 29 +- .../reader/SharedEpubPaginationCache.kt | 19 +- .../shared/reader/SharedJvmBookLoader.kt | 17 +- .../reader/SharedMeasuredEpubPaginator.kt | 1 + 102 files changed, 6012 insertions(+), 687 deletions(-) create mode 100644 .github/workflows/desktop-release.yml create mode 100644 .idea/vcs.xml create mode 100644 app/src/main/java/com/aryan/reader/ClipboardUtils.kt create mode 100644 app/src/main/java/com/aryan/reader/ExternalFileOpenRouter.kt create mode 100644 app/src/main/java/com/aryan/reader/ReaderFontDiagnostics.kt create mode 100644 app/src/main/java/com/aryan/reader/ReaderPopupSizing.kt create mode 100644 app/src/main/java/com/aryan/reader/paginatedreader/AndroidEpubKeyCommands.kt create mode 100644 app/src/main/java/com/aryan/reader/paginatedreader/EpubFontFaceSiblings.kt create mode 100644 app/src/test/java/com/aryan/reader/ClipboardUtilsTest.kt create mode 100644 app/src/test/java/com/aryan/reader/ExternalFileOpenRouteDeciderTest.kt create mode 100644 app/src/test/java/com/aryan/reader/ReaderPopupSizingTest.kt create mode 100644 app/src/test/java/com/aryan/reader/data/ImportedFontFileNameTest.kt create mode 100644 app/src/test/java/com/aryan/reader/epubreader/EpubReaderTtsHighlightAssetTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/AndroidEpubKeyCommandsTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/EpubFontFaceSiblingsTest.kt create mode 100644 desktopApp/packaging/README.md create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAurPackagingMetadataTest.kt mode change 100644 => 100755 gradlew create mode 100644 scripts/desktop/download-pdfium.ps1 create mode 100644 scripts/desktop/publish-pdfium-release.ps1 create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/FontVariantInference.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/FontVariantInferenceTest.kt diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 0000000..a5fb138 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 81181f9..e183ade 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,6 @@ third_party/pdfium/ cache/ worker/ output/ -policies/ \ No newline at end of file +policies/ +episteme-bin/ +episteme-oss-bin/ diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..d09f4f6 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index de561f2..2589bb8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -171,6 +171,7 @@ android { testOptions { unitTests.isReturnDefaultValues = true unitTests.all { + it.maxHeapSize = "4g" it.jvmArgs("-Xss2m") } } diff --git a/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt b/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt index a674c3f..a660a71 100644 --- a/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt +++ b/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt @@ -320,6 +320,8 @@ class LibraryScreenContentTest { onItemClick = {}, onItemLongClick = { item -> selectedItems.value = setOf(item) }, onInfoClick = onInfoClick, + onSaveClick = null, + onShareClick = null, onDeleteClick = onDeleteClick, onSelectAllClick = onSelectAllClick, onShelfClick = onShelfClick, diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bee3fda..c9a474e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,7 +55,22 @@ + + + + diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index bf37155..030e760 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -128,8 +128,15 @@ background-color: rgba(255, 236, 179, 0.8); /* Semi-transparent Gold */ color: black !important; + display: inline !important; + text-align: initial !important; + text-align-last: auto !important; + letter-spacing: normal !important; + word-spacing: normal !important; padding: 0.1em 0; border-radius: 3px; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; } html.dark-theme span.tts-highlight { @@ -1496,6 +1503,16 @@ }; const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS"; + const TTS_HIGHLIGHT_BLOCK_SELECTOR = "p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th"; + + function getTtsHighlightBlock(node) { + if (!node) { + return document.body; + } + + const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; + return (element && element.closest(TTS_HIGHLIGHT_BLOCK_SELECTOR)) || document.body; + } window.highlightFromCfi = function (cfi, textToHighlight, startOffset) { console.log(`$ { @@ -1556,9 +1573,10 @@ , Text content: '${(location.node.textContent || "").substring(0, 50)}...' `); const baseNode = location.node; + const highlightRoot = getTtsHighlightBlock(baseNode); let remainingOffset = startOffset; - const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); + const treeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false); treeWalker.currentNode = baseNode; let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode(); @@ -1626,7 +1644,7 @@ } else { remainingTextLength -= availableLength; // Important: We need a fresh walker starting from the endNode to find the *next* text node reliably - const nextNodeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); + const nextNodeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false); nextNodeWalker.currentNode = endNode; endNode = nextNodeWalker.nextNode(); endOffset = 0; // Start from the beginning of the next node @@ -1677,11 +1695,14 @@ TTS_HIGHLIGHT_LOG_TAG } - : surroundContents failed, using fallback. Error: $ { + : surroundContents failed, using same-block fallback. Error: $ { e.message } `); + if (!highlightRoot.contains(range.commonAncestorContainer)) { + return "JS: Highlight range escaped current block."; + } const contents = range.extractContents(); highlightSpan.appendChild(contents); range.insertNode(highlightSpan); diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt index 18780d4..479330b 100644 --- a/app/src/main/java/com/aryan/reader/AppNavigation.kt +++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt @@ -220,11 +220,17 @@ fun AppNavigation( NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) { composable(AppDestinations.MAIN_ROUTE) { Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).") - MainScreen( - viewModel = viewModel, - windowSizeClass = windowSizeClass, - navController = navController - ) + if (uiState.isTemporaryExternalOpen) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + MainScreen( + viewModel = viewModel, + windowSizeClass = windowSizeClass, + navController = navController + ) + } } // PDF Viewer Screen Composable diff --git a/app/src/main/java/com/aryan/reader/AppUiModels.kt b/app/src/main/java/com/aryan/reader/AppUiModels.kt index f5236d6..d4e06bd 100644 --- a/app/src/main/java/com/aryan/reader/AppUiModels.kt +++ b/app/src/main/java/com/aryan/reader/AppUiModels.kt @@ -43,6 +43,7 @@ data class ReaderScreenState( val selectedEpubUri: Uri? = null, val selectedFileType: FileType? = null, val isLoading: Boolean = false, + val isTemporaryExternalOpen: Boolean = false, val errorMessage: String? = null, val contextualActionItems: Set = emptySet(), val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL, diff --git a/app/src/main/java/com/aryan/reader/ClipboardUtils.kt b/app/src/main/java/com/aryan/reader/ClipboardUtils.kt new file mode 100644 index 0000000..d86d89a --- /dev/null +++ b/app/src/main/java/com/aryan/reader/ClipboardUtils.kt @@ -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 + } +} diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index 9fb3767..c42019a 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -138,6 +138,7 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -862,6 +863,13 @@ fun AiDefinitionPopup( val ttsController = rememberTtsController() val ttsState by ttsController.ttsState.collectAsState() val context = LocalContext.current + val configuration = LocalConfiguration.current + val maxPopupHeight = readerModalMaxHeightDp( + screenHeightDp = configuration.screenHeightDp, + fraction = 0.65f, + verticalMarginDp = 48, + preferredMinHeightDp = 180 + ).dp @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current val scope = rememberCoroutineScope() @@ -882,7 +890,7 @@ fun AiDefinitionPopup( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 5.dp) - .heightIn(min = 150.dp, max = 400.dp), + .heightIn(max = maxPopupHeight), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) ) { @@ -3310,12 +3318,16 @@ fun ThemeColorPickerDialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false) ) { + val configuration = LocalConfiguration.current + val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp + Surface( shape = RoundedCornerShape(24.dp), color = Color(0xFF2C2C2C), modifier = Modifier .fillMaxWidth(0.9f) .padding(16.dp) + .heightIn(max = maxDialogHeight) ) { Column( modifier = Modifier @@ -3495,12 +3507,16 @@ fun HighlightColorPickerDialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false) ) { + val configuration = LocalConfiguration.current + val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp + Surface( shape = RoundedCornerShape(24.dp), color = Color(0xFF2C2C2C), modifier = Modifier .fillMaxWidth(0.9f) .padding(16.dp) + .heightIn(max = maxDialogHeight) ) { Column( modifier = Modifier diff --git a/app/src/main/java/com/aryan/reader/ExternalFileOpenRouter.kt b/app/src/main/java/com/aryan/reader/ExternalFileOpenRouter.kt new file mode 100644 index 0000000..dd3508d --- /dev/null +++ b/app/src/main/java/com/aryan/reader/ExternalFileOpenRouter.kt @@ -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 { + 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() diff --git a/app/src/main/java/com/aryan/reader/FontsScreen.kt b/app/src/main/java/com/aryan/reader/FontsScreen.kt index 7c1caad..ebdd5d7 100644 --- a/app/src/main/java/com/aryan/reader/FontsScreen.kt +++ b/app/src/main/java/com/aryan/reader/FontsScreen.kt @@ -64,10 +64,17 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aryan.reader.shared.CustomFontItem +import com.aryan.reader.shared.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.SharedFontSettingsSection import com.aryan.reader.shared.ui.SharedFontSettingsTabs @@ -97,6 +104,7 @@ fun FontsScreen( val selectedFonts = remember(fonts, selectedFontIds) { fonts.filter { it.id in selectedFontIds } } + val fontEntitiesById = remember(fonts) { fonts.associateBy { it.id } } val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty() LaunchedEffect(fonts) { @@ -172,6 +180,7 @@ fun FontsScreen( ) { padding -> Box(modifier = Modifier.fillMaxSize().padding(padding)) { val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() } + val fontFamilies = remember(sharedFonts) { sharedFonts.groupByFamily() } Column(modifier = Modifier.fillMaxSize()) { SharedFontSettingsTabs( selectedSection = selectedSection, @@ -202,16 +211,20 @@ fun FontsScreen( contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items(fonts, key = { it.id }) { font -> - FontListItem( - font = font, - isSelected = font.id in selectedFontIds, + items(fontFamilies, key = { family -> family.variants.joinToString("|") { it.font.id } }) { family -> + FontFamilyListItem( + family = family, + selectedFontIds = selectedFontIds, isSelectionMode = isFontSelectionMode, - onSelectionToggle = { - selectedFontIds = selectedFontIds.toggle(font.id) + fontEntityForId = { id -> fontEntitiesById[id] }, + onVariantSelectionToggle = { id -> + selectedFontIds = selectedFontIds.toggle(id) }, - onDelete = { - fontsPendingDelete = listOf(font) + onFamilySelectionToggle = { + 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, + 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.toSharedCustomFontItems(): List { return filterNot { it.isDeleted } .sortedBy { it.displayName.lowercase() } @@ -591,3 +786,8 @@ fun DeleteFontsConfirmationDialog( private fun Set.toggle(id: String): Set { return if (id in this) this - id else this + id } + +private fun Set.toggleAll(ids: List): Set { + val idSet = ids.toSet() + return if (containsAll(idSet)) this - idSet else this + idSet +} diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 5dce10d..8b24157 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -139,6 +139,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.os.LocaleListCompat +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi import androidx.navigation.NavHostController @@ -193,6 +194,7 @@ fun HomeScreen( var showAboutDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) } var itemForInfoDialog by remember { mutableStateOf(null) } + var pendingSaveOriginalItem by remember { mutableStateOf(null) } var showBehaviorDialog by remember { mutableStateOf(false) } var showStrictFilterDialog 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) { if (uiState.isRequestingDrivePermission) { val intent = viewModel.getDriveSignInIntent(context) @@ -390,6 +421,12 @@ fun HomeScreen( showInfoDialog = true } }, + onSaveClick = selectedContextItems.singleOrNull() + ?.takeIf { it.canExportOriginalFile() } + ?.let { item -> { saveOriginalItem(item) } }, + onShareClick = selectedContextItems.singleOrNull() + ?.takeIf { it.canExportOriginalFile() } + ?.let { item -> { shareOriginalItem(item) } }, onPinClick = { viewModel.togglePinForContextualItems(isHome = true) }, onDeleteClick = { showDeleteConfirmDialog = true }, onSelectAllClick = { viewModel.selectAllRecentFiles() }) @@ -502,28 +539,17 @@ fun HomeScreen( ) } - itemForInfoDialog?.let { item -> - if (showInfoDialog) { - FileInfoDialog( - item = item, - usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, - onDismiss = { - showInfoDialog = false - itemForInfoDialog = null - }, - onSaveMetadata = { metadata -> - viewModel.updateBookMetadata(item.bookId, metadata) - }, - onSaveDisplayName = { name -> - viewModel.updateCustomName(item.bookId, name) - }, - onRestoreMetadata = { - viewModel.restoreOriginalBookMetadata(item.bookId) - }, - onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) } - ) - } - } + HydratedFileInfoDialog( + item = itemForInfoDialog, + isVisible = showInfoDialog, + uiState = uiState, + viewModel = viewModel, + onDismiss = { + showInfoDialog = false + itemForInfoDialog = null + }, + onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) } + ) if (showClearReflowCacheDialog) { DangerousFolderActionDialog( @@ -1786,9 +1812,14 @@ fun ExternalFileBehaviorDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.options_external_file_behavior)) }, text = { - Column { - val options = listOf("ASK" to R.string.external_file_behavior_ask, "KEEP" to R.string.external_file_behavior_keep, "DELETE" to R.string.external_file_behavior_delete) - options.forEach { (value, labelRes) -> + Column(modifier = Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) { + val options = listOf( + Triple("ASK", R.string.external_file_behavior_ask, R.string.external_file_behavior_ask_desc), + Triple("KEEP", R.string.external_file_behavior_keep, R.string.external_file_behavior_keep_desc), + Triple("DELETE", R.string.external_file_behavior_delete, R.string.external_file_behavior_delete_desc), + Triple("TEMPORARY", R.string.external_file_behavior_temporary, R.string.external_file_behavior_temporary_desc) + ) + options.forEach { (value, labelRes, descriptionRes) -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -1798,7 +1829,14 @@ fun ExternalFileBehaviorDialog( ) { RadioButton(selected = currentBehavior == value, onClick = null) Spacer(modifier = Modifier.width(16.dp)) - Text(stringResource(labelRes)) + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(labelRes)) + Text( + text = stringResource(descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index ae267c1..342b624 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -133,6 +133,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.util.UnstableApi @@ -271,6 +272,36 @@ fun LibraryScreen( var showDeleteShelvesDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) } var itemForInfoDialog by remember { mutableStateOf(null) } + var pendingSaveOriginalItem by remember { mutableStateOf(null) } + + val saveOriginalLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri -> + val item = pendingSaveOriginalItem + pendingSaveOriginalItem = null + if (uri != null && item?.uriString != null) { + viewModel.saveOriginalFile(item.uriString.toUri(), uri) + } + } + + fun saveOriginalItem(item: RecentFileItem) { + if (!item.canExportOriginalFile()) return + pendingSaveOriginalItem = item + saveOriginalLauncher.launch(item.suggestedOriginalFileName()) + } + + fun shareOriginalItem(item: RecentFileItem) { + val uriString = item.uriString ?: return + if (!item.canExportOriginalFile()) return + scope.launch { + viewModel.shareOriginalFile( + activityContext = context, + sourceUri = uriString.toUri(), + fileType = item.type, + filename = item.suggestedOriginalFileName() + ) + } + } BackHandler(enabled = isContextualModeActive) { viewModel.clearContextualAction() @@ -317,6 +348,12 @@ fun LibraryScreen( showInfoDialog = true } }, + onSaveClick = selectedItems.singleOrNull() + ?.takeIf { it.canExportOriginalFile() } + ?.let { item -> { saveOriginalItem(item) } }, + onShareClick = selectedItems.singleOrNull() + ?.takeIf { it.canExportOriginalFile() } + ?.let { item -> { shareOriginalItem(item) } }, onDeleteClick = { showDeleteConfirmDialog = true }, onSelectAllClick = { viewModel.selectAllLibraryFiles() }, onShelfClick = viewModel::onShelfClick, @@ -397,28 +434,17 @@ fun LibraryScreen( ) } - itemForInfoDialog?.let { item -> - if (showInfoDialog) { - FileInfoDialog( - item = item, - usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, - onDismiss = { - showInfoDialog = false - itemForInfoDialog = null - }, - onSaveMetadata = { metadata -> - viewModel.updateBookMetadata(item.bookId, metadata) - }, - onSaveDisplayName = { name -> - viewModel.updateCustomName(item.bookId, name) - }, - onRestoreMetadata = { - viewModel.restoreOriginalBookMetadata(item.bookId) - }, - onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) } - ) - } - } + HydratedFileInfoDialog( + item = itemForInfoDialog, + isVisible = showInfoDialog, + uiState = uiState, + viewModel = viewModel, + onDismiss = { + showInfoDialog = false + itemForInfoDialog = null + }, + onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) } + ) CustomTopBanner(bannerMessage = uiState.bannerMessage) } } @@ -436,10 +462,42 @@ fun ShelfScreen( val sortOrder = uiState.sortOrder val showRenameDialogFor = uiState.showRenameShelfDialogFor val showDeleteDialogFor = uiState.showDeleteShelfDialogFor + val context = LocalContext.current + val scope = rememberCoroutineScope() var showRemoveFromShelfDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) } var itemForInfoDialog by remember { mutableStateOf(null) } + var pendingSaveOriginalItem by remember { mutableStateOf(null) } + + val saveOriginalLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri -> + val item = pendingSaveOriginalItem + pendingSaveOriginalItem = null + if (uri != null && item?.uriString != null) { + viewModel.saveOriginalFile(item.uriString.toUri(), uri) + } + } + + fun saveOriginalItem(item: RecentFileItem) { + if (!item.canExportOriginalFile()) return + pendingSaveOriginalItem = item + saveOriginalLauncher.launch(item.suggestedOriginalFileName()) + } + + fun shareOriginalItem(item: RecentFileItem) { + val uriString = item.uriString ?: return + if (!item.canExportOriginalFile()) return + scope.launch { + viewModel.shareOriginalFile( + activityContext = context, + sourceUri = uriString.toUri(), + fileType = item.type, + filename = item.suggestedOriginalFileName() + ) + } + } BackHandler(enabled = true) { when { @@ -491,6 +549,12 @@ fun ShelfScreen( showInfoDialog = true } }, + onSaveClick = selectedItems.singleOrNull() + ?.takeIf { it.canExportOriginalFile() } + ?.let { item -> { saveOriginalItem(item) } }, + onShareClick = selectedItems.singleOrNull() + ?.takeIf { it.canExportOriginalFile() } + ?.let { item -> { shareOriginalItem(item) } }, onDeleteClick = { showRemoveFromShelfDialog = true }, onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) }, onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) }, @@ -531,19 +595,14 @@ fun ShelfScreen( ) } - itemForInfoDialog?.let { item -> - if (showInfoDialog) { - FileInfoDialog( - item = item, - usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, - onDismiss = { showInfoDialog = false; itemForInfoDialog = null }, - onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) }, - onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) }, - onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) }, - onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) } - ) - } - } + HydratedFileInfoDialog( + item = itemForInfoDialog, + isVisible = showInfoDialog, + uiState = uiState, + viewModel = viewModel, + onDismiss = { showInfoDialog = false; itemForInfoDialog = null }, + onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) } + ) CustomTopBanner(bannerMessage = uiState.bannerMessage) } } @@ -578,6 +637,8 @@ fun LibraryScreenContent( onItemClick: (RecentFileItem) -> Unit, onItemLongClick: (RecentFileItem) -> Unit, onInfoClick: () -> Unit, + onSaveClick: (() -> Unit)?, + onShareClick: (() -> Unit)?, onDeleteClick: () -> Unit, onSelectAllClick: () -> Unit, onShelfClick: (Shelf) -> Unit, @@ -640,6 +701,8 @@ fun LibraryScreenContent( onTagClick = onTagClick, onPinClick = onPinClick, onInfoClick = onInfoClick, + onSaveClick = onSaveClick, + onShareClick = onShareClick, onDeleteClick = onDeleteClick, onSelectAllClick = onSelectAllClick ) @@ -1046,6 +1109,8 @@ private fun ShelfDetailScreen( onClearSelection: () -> Unit, onTagClick: () -> Unit, onInfoClick: () -> Unit, + onSaveClick: (() -> Unit)?, + onShareClick: (() -> Unit)?, onDeleteClick: () -> Unit, onRenameShelf: () -> Unit, onDeleteShelf: () -> Unit, @@ -1128,6 +1193,8 @@ private fun ShelfDetailScreen( onNavIconClick = onClearSelection, onTagClick = onTagClick, onInfoClick = onInfoClick, + onSaveClick = onSaveClick, + onShareClick = onShareClick, onDeleteClick = onDeleteClick ) } else if (isSearchActive) { diff --git a/app/src/main/java/com/aryan/reader/MainActivity.kt b/app/src/main/java/com/aryan/reader/MainActivity.kt index e5835ed..382c1ad 100644 --- a/app/src/main/java/com/aryan/reader/MainActivity.kt +++ b/app/src/main/java/com/aryan/reader/MainActivity.kt @@ -58,10 +58,12 @@ import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET @UnstableApi -class MainActivity : AppCompatActivity() { +open class MainActivity : AppCompatActivity() { private val viewModel: MainViewModel by viewModels() private lateinit var platformFeaturesRepository: PlatformFeaturesRepository + private val isTemporaryExternalOpen: Boolean + get() = intent?.getBooleanExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, false) == true private val updateLauncher = registerForActivityResult( ActivityResultContracts.StartIntentSenderForResult() @@ -86,6 +88,14 @@ class MainActivity : AppCompatActivity() { } } + lifecycleScope.launch { + viewModel.temporaryExternalOpenFinished.collect { + if (isTemporaryExternalOpen) { + finishAndRemoveTask() + } + } + } + if (savedInstanceState == null) { handleIntent(intent) } @@ -160,7 +170,12 @@ class MainActivity : AppCompatActivity() { if (intent?.action == Intent.ACTION_VIEW && intent.data != null) { Timber.d("Received VIEW intent with URI: ${intent.data}") val uri = intent.data!! - viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true) + viewModel.onFileSelected( + uri, + isFromRecent = false, + isExternalIntent = true, + isTemporaryExternalIntent = isTemporaryExternalOpen + ) } } } diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 4a7b4bd..c5d96d9 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -222,10 +222,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private val _navigationEvent = Channel(Channel.BUFFERED) @Suppress("unused") val navigationEvent = _navigationEvent.receiveAsFlow() + private val _temporaryExternalOpenFinished = Channel(Channel.BUFFERED) + val temporaryExternalOpenFinished = _temporaryExternalOpenFinished.receiveAsFlow() private var bannerDismissJob: Job? = null private var bannerDismissGeneration = 0L private var pendingSwitchDeferred: CompletableDeferred? = null private var externalOpenedBookId: String? = null + private var temporaryExternalSessionBookId: String? = null private var cloudContentRetryJob: Job? = null private val cloudMetadataUploadJobs = ConcurrentHashMap() @@ -804,6 +807,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy(showTagSelectionDialogFor = emptySet()) } } + suspend fun getFileInfoItem(bookId: String): RecentFileItem? { + return recentFilesRepository.getFileByBookId(bookId) + } + fun createAndAssignTag(name: String, bookIds: Set) { val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds) 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) { 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) { if (!uiState.value.isSyncEnabled) return cloudMetadataUploadJobs.remove(bookId)?.cancel() @@ -2761,6 +2872,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val closingBookId = _internalState.value.selectedBookId val uriString = _internalState.value.selectedPdfUri?.toString() ?: _internalState.value.selectedEpubUri?.toString() + val isTemporaryExternalSession = closingBookId != null && closingBookId == temporaryExternalSessionBookId logCloudSyncTrace { "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, selectedFileType = null, isLoading = false, + isTemporaryExternalOpen = false, errorMessage = null, initialLocator = null, initialPageInBook = null, @@ -2794,20 +2907,40 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio isOpeningFromTtsNotification = false ) } - clearPersistedReaderSession() + if (!isTemporaryExternalSession) { + clearPersistedReaderSession() + } var removesExternalFileOnClose = false - if (closingBookId != null && closingBookId == externalOpenedBookId) { - val behavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK" + if (closingBookId != null && (closingBookId == externalOpenedBookId || isTemporaryExternalSession)) { + 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") { _internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) } } else if (behavior == "DELETE") { removesExternalFileOnClose = true 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 { clearPendingExternalFileRemovals(setOf(closingBookId)) } externalOpenedBookId = null + temporaryExternalSessionBookId = null } 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) { Timber.i("Opening recent file: $uri") viewModelScope.launch { @@ -4815,7 +4953,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } else { 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 { - it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet()) + it.copy( + isLoading = true, + errorMessage = null, + contextualActionItems = emptySet() + ) } viewModelScope.launch { @@ -4877,10 +5028,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (importResult != null) { val (internalUri, bookId, type) = importResult if (isExternalIntent) { - externalOpenedBookId = bookId - if (prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") == "DELETE") { - markPendingExternalFileRemoval(bookId, internalUri.toString()) - } + trackExternalOpenForClose( + bookId = bookId, + importedCopyUriString = internalUri.toString(), + isTemporaryExternalIntent = isTemporaryExternalIntent + ) } val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File" openBook( @@ -4895,6 +5047,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val existingItem = recentFilesRepository.getFileByBookId(hash) if (existingItem != null) { Timber.i("Re-selected an existing book. Opening it.") + if (isTemporaryExternalIntent) { + trackExternalOpenForClose( + bookId = existingItem.bookId, + importedCopyUriString = null, + isTemporaryExternalIntent = true + ) + } onRecentFileClicked(existingItem) _internalState.update { it.copy(isLoading = false) } 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) { viewModelScope.launch { 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) clearImportedFileCache(bookId) bookCacheDao.deleteEntireBookCache(bookId) @@ -5199,7 +5397,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio isInitialPageExplicit: Boolean = false, initialLocatorOverride: Locator? = null, initialCfiOverride: String? = null, - preserveTtsOnOpen: Boolean = false + preserveTtsOnOpen: Boolean = false, + persistToLibrary: Boolean = true ) { val openBookStartTime = System.currentTimeMillis() ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type") @@ -5293,16 +5492,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms") - persistReaderSession(bookId, type) - addFileToRecent( - uri, - type, - bookId, - customDisplayName = originalDisplayName, - isRecent = true, - sourceFolderUri = null, - bundleResult = bundleResult - ) + if (persistToLibrary) { + persistReaderSession(bookId, type) + addFileToRecent( + uri, + type, + bookId, + customDisplayName = originalDisplayName, + isRecent = true, + sourceFolderUri = null, + bundleResult = bundleResult + ) + } if (!suppressNavigation) { 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") - persistReaderSession(bookId, type) + if (persistToLibrary) { + persistReaderSession(bookId, type) + } if (!suppressNavigation) { Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event") @@ -5352,22 +5555,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio when (type) { FileType.EPUB -> { - loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult) + loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary) } FileType.MOBI -> { - loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult) + loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary) } FileType.FB2 -> { - loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult) + loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary) } 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 -> { 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() Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 START") viewModelScope.launch { @@ -5412,9 +5621,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.i("FB2 parsing successful. Title: ${fb2Book.title}") Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 completed | chapters=${fb2Book.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") - addFileToRecent( - uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult - ) + if (persistToLibrary) { + addFileToRecent( + uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult + ) + } _internalState.update { it.copy(selectedEpubBook = fb2Book, isLoading = false) } } 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() Timber.tag("FileOpenPerf").d("[$bookId] loadOdt START | isFlat=$isFlat") viewModelScope.launch { @@ -5449,9 +5667,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.i("ODT parsing successful. Title: ${odtBook.title}") Timber.tag("FileOpenPerf").d("[$bookId] loadOdt completed | chapters=${odtBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") - addFileToRecent( - uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult - ) + if (persistToLibrary) { + addFileToRecent( + 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) } } catch (e: Exception) { @@ -5468,7 +5688,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio bookId: String, type: FileType, customDisplayName: String? = null, - bundleResult: CalibreBundleResult? = null + bundleResult: CalibreBundleResult? = null, + persistToLibrary: Boolean = true ) { val loadStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type") @@ -5506,16 +5727,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.tag("FileOpenPerf") .d("[$bookId] loadSingleFile: importSingleFile completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") Timber.i("Import successful ($type). Title: ${epubBook.title}") - addFileToRecent( - uri, - type, - bookId, - epubBook, - customDisplayName, - isRecent = true, - sourceFolderUri = null, - bundleResult = bundleResult - ) + if (persistToLibrary) { + addFileToRecent( + uri, + type, + bookId, + epubBook, + customDisplayName, + isRecent = true, + sourceFolderUri = null, + bundleResult = bundleResult + ) + } _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } Timber.tag("FileOpenPerf") @@ -5554,7 +5777,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio 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 { if (!_internalState.value.isLoading) { _internalState.update { it.copy(isLoading = true, errorMessage = null) } @@ -5576,16 +5805,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (mobiAsEpubBook != null) { Timber.i("MOBI parsing successful. Title: ${mobiAsEpubBook.title}") - addFileToRecent( - uri, - FileType.MOBI, - bookId, - mobiAsEpubBook, - customDisplayName, - isRecent = true, - sourceFolderUri = null, - bundleResult = bundleResult - ) + if (persistToLibrary) { + addFileToRecent( + uri, + FileType.MOBI, + bookId, + mobiAsEpubBook, + customDisplayName, + isRecent = true, + sourceFolderUri = null, + bundleResult = bundleResult + ) + } _internalState.update { 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() Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START") viewModelScope.launch { @@ -5633,16 +5870,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.tag("FileOpenPerf") .d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") - addFileToRecent( - uri, - FileType.EPUB, - bookId, - epubBook, - customDisplayName, - isRecent = true, - sourceFolderUri = null, - bundleResult = bundleResult - ) + if (persistToLibrary) { + addFileToRecent( + uri, + FileType.EPUB, + bookId, + epubBook, + customDisplayName, + isRecent = true, + sourceFolderUri = null, + bundleResult = bundleResult + ) + } _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } 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_FILE_TYPE = "last_open_file_type" 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_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" diff --git a/app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt b/app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt index 5de4838..0865865 100644 --- a/app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt +++ b/app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt @@ -2,7 +2,10 @@ package com.aryan.reader import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import com.aryan.reader.data.RecentFileItem @Composable @@ -64,28 +67,17 @@ internal fun ReaderFileInfoDialogs( } } - item?.let { fileInfoItem -> - if (isFileInfoVisible) { - FileInfoDialog( - item = fileInfoItem, - usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, - onDismiss = { onFileInfoVisibleChange(false) }, - onSaveMetadata = { metadata -> - viewModel.updateBookMetadata(fileInfoItem.bookId, metadata) - }, - onSaveDisplayName = { name -> - viewModel.updateCustomName(fileInfoItem.bookId, name) - }, - onRestoreMetadata = { - viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId) - }, - onOpenTags = { - onFileInfoVisibleChange(false) - viewModel.openTagSelection(setOf(fileInfoItem.bookId)) - } - ) + HydratedFileInfoDialog( + item = item, + isVisible = isFileInfoVisible, + uiState = uiState, + viewModel = viewModel, + onDismiss = { onFileInfoVisibleChange(false) }, + onOpenTags = { bookId -> + onFileInfoVisibleChange(false) + viewModel.openTagSelection(setOf(bookId)) } - } + ) if (uiState.showTagSelectionDialogFor.isNotEmpty()) { TagSelectionBottomSheet( @@ -102,3 +94,49 @@ internal fun ReaderFileInfoDialogs( ) } } + +@Composable +internal fun HydratedFileInfoDialog( + item: RecentFileItem?, + isVisible: Boolean, + uiState: ReaderScreenState, + viewModel: MainViewModel, + onDismiss: () -> Unit, + onOpenTags: (String) -> Unit +) { + var fileInfoItem by remember(item?.bookId) { mutableStateOf(item) } + var hasResolvedFullItem by remember(item?.bookId) { mutableStateOf(false) } + + LaunchedEffect(item) { + fileInfoItem = item + hasResolvedFullItem = false + } + + LaunchedEffect(isVisible, item?.bookId) { + if (isVisible && item != null) { + fileInfoItem = viewModel.getFileInfoItem(item.bookId)?.copy(tags = item.tags) ?: item + hasResolvedFullItem = true + } + } + + val resolvedItem = fileInfoItem + if (isVisible && resolvedItem != null && hasResolvedFullItem) { + FileInfoDialog( + item = resolvedItem, + usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, + onDismiss = onDismiss, + onSaveMetadata = { metadata -> + viewModel.updateBookMetadata(resolvedItem.bookId, metadata) + }, + onSaveDisplayName = { name -> + viewModel.updateCustomName(resolvedItem.bookId, name) + }, + onRestoreMetadata = { + viewModel.restoreOriginalBookMetadata(resolvedItem.bookId) + }, + onOpenTags = { + onOpenTags(resolvedItem.bookId) + } + ) + } +} diff --git a/app/src/main/java/com/aryan/reader/ReaderFontDiagnostics.kt b/app/src/main/java/com/aryan/reader/ReaderFontDiagnostics.kt new file mode 100644 index 0000000..ec6c07c --- /dev/null +++ b/app/src/main/java/com/aryan/reader/ReaderFontDiagnostics.kt @@ -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()}" +} diff --git a/app/src/main/java/com/aryan/reader/ReaderPopupSizing.kt b/app/src/main/java/com/aryan/reader/ReaderPopupSizing.kt new file mode 100644 index 0000000..ec2c327 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/ReaderPopupSizing.kt @@ -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 + } +} diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt index 98a75d3..5be6d68 100644 --- a/app/src/main/java/com/aryan/reader/SharedComposables.kt +++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt @@ -87,6 +87,7 @@ import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Restore import androidx.compose.material.icons.filled.Save 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.Gavel 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.core.net.toUri import androidx.core.text.HtmlCompat +import com.aryan.reader.shared.SharedFileCapabilities import com.aryan.reader.shared.SharedLegalLinks import com.aryan.reader.shared.SharedLegalProfile import com.aryan.reader.data.BookMetadataEdit @@ -274,6 +276,8 @@ fun ContextualTopAppBar( selectedItemCount: Int, onNavIconClick: () -> Unit, onInfoClick: (() -> Unit)? = null, + onSaveClick: (() -> Unit)? = null, + onShareClick: (() -> Unit)? = null, onTagClick: (() -> Unit)? = null, onSelectAllClick: (() -> Unit)? = null, onPinClick: (() -> Unit)? = null, @@ -302,6 +306,16 @@ fun ContextualTopAppBar( 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) { IconButton(onClick = onSelectAllClick) { Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all)) @@ -1584,6 +1598,31 @@ fun RecentFileItem.isOpdsStream(): Boolean { 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 private fun statusBadgeColors(overlay: Boolean): Pair { val container = if (overlay) { diff --git a/app/src/main/java/com/aryan/reader/UiLabelResources.kt b/app/src/main/java/com/aryan/reader/UiLabelResources.kt index 5467cd1..c649619 100644 --- a/app/src/main/java/com/aryan/reader/UiLabelResources.kt +++ b/app/src/main/java/com/aryan/reader/UiLabelResources.kt @@ -67,7 +67,8 @@ val supportedAppLanguageOptions = listOf( "中文", "简体中文", ) - ) + ), + AppLanguageOption("et", R.string.language_estonian, listOf("estonian", "eesti")) ) val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions diff --git a/app/src/main/java/com/aryan/reader/data/FontsRepository.kt b/app/src/main/java/com/aryan/reader/data/FontsRepository.kt index 8f96108..9b1f0a9 100644 --- a/app/src/main/java/com/aryan/reader/data/FontsRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/FontsRepository.kt @@ -25,12 +25,15 @@ import android.provider.OpenableColumns import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext +import com.aryan.reader.ReaderFontDiagnosticsTag +import com.aryan.reader.readerFontDiagnosticSummary import timber.log.Timber import java.io.File import java.io.FileOutputStream import java.util.UUID private const val FONTS_DIR = "custom_fonts" +private const val MAX_IMPORTED_FONT_BASENAME_LENGTH = 120 class FontsRepository(private val context: Context) { private val fontDao = AppDatabase.getDatabase(context).customFontDao() @@ -73,14 +76,23 @@ class FontsRepository(private val context: Context) { val contentResolver = context.contentResolver val originalName = getFileName(uri) ?: "unknown.ttf" 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")) { + Timber.tag(ReaderFontDiagnosticsTag).w( + "import.unsupported originalName='$originalName' extension='$extension'" + ) return@withContext Result.failure(Exception("Unsupported font format. Please use TTF, OTF, or WOFF2.")) } val fontId = UUID.randomUUID().toString() - val internalFileName = "font_${fontId}.$extension" - val destinationFile = File(fontsDir, internalFileName) + val destinationFile = uniqueImportedFontFile(displayName, extension, fontId) + val internalFileName = destinationFile.name contentResolver.openInputStream(uri)?.use { input -> FileOutputStream(destinationFile).use { output -> @@ -88,8 +100,6 @@ class FontsRepository(private val context: Context) { } } - val displayName = originalName.substringBeforeLast('.') - val entity = CustomFontEntity( id = fontId, displayName = displayName, @@ -100,10 +110,16 @@ class FontsRepository(private val context: Context) { ) 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}") Result.success(entity) } catch (e: Exception) { + Timber.tag(ReaderFontDiagnosticsTag).e(e, "import.failed uri='$uri'") Timber.e(e, "Failed to import font") Result.failure(e) } @@ -130,6 +146,18 @@ class FontsRepository(private val context: Context) { 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? { var result: String? = null if (uri.scheme == "content") { @@ -152,4 +180,18 @@ class FontsRepository(private val context: Context) { } return result } -} \ No newline at end of file +} + +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" +} diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt index be5dbd1..b42d72a 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt @@ -33,7 +33,7 @@ interface RecentFileDao { @Upsert suspend fun insertOrUpdateFiles(files: List) - @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> @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") 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 @Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)") diff --git a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt index 716fba2..0ba2106 100644 --- a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt @@ -457,19 +457,17 @@ class EpubParser(private val context: Context) { var pageTargets: List = emptyList() val ncxMetadataMap = mutableMapOf() 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) { Timber.d("shouldUseToc is true. Attempting to parse NCX.") - val tocFileItem = manifestItems.values.firstOrNull { - 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 (tocFileItem != null && ncxParentDir != null) { if (tocDocumentNode != null) { Timber.d("Successfully parsed NCX file: ${tocFileItem.absPath}") 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.") val tableOfContents = if (shouldUseToc) { - val tocFileItem = manifestItems.values.firstOrNull { - 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) } - val navMapElement = tocDocumentNode?.selectFirstTag("navMap") as Element? + if (tocDocumentNode != null && ncxParentDir != null) { + val navMapElement = tocDocumentNode.selectFirstTag("navMap") as Element? if (navMapElement != null) { parseTableOfContents(navMapElement, ncxParentDir) @@ -592,6 +583,21 @@ class EpubParser(private val context: Context) { return result } + private fun resolveTocFileItem( + spine: Node, + manifestItems: Map + ): 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) private fun createEpubDocument(files: Map): EpubDocument { diff --git a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt index 9956b5c..13a3c07 100644 --- a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt @@ -41,7 +41,7 @@ class OdtParser(private val context: Context) { val mathJaxFileName = "tex-mml-chtml.js" val mathJaxFile = File(extractionDir, mathJaxFileName) - if (!mathJaxFile.exists()) { + if (parseContent && !mathJaxFile.exists()) { try { context.assets.open("mathjax/$mathJaxFileName").use { input -> FileOutputStream(mathJaxFile).use { output -> @@ -170,32 +170,33 @@ class OdtParser(private val context: Context) { try { if (!isFlat) { - val zis = ZipInputStream(inputStream) - var entry = zis.nextEntry var contentXmlBytes: ByteArray? = null var stylesXmlBytes: ByteArray? = null val ignoredFiles = setOf("meta.xml", "settings.xml", "META-INF/manifest.xml") - while (entry != null) { - if (!entry.isDirectory) { - when (entry.name) { - "content.xml" -> contentXmlBytes = zis.readBytes() - "styles.xml" -> stylesXmlBytes = zis.readBytes() - "Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes() - else -> { - if (entry.name !in ignoredFiles) { - val extractedFile = safeFileInRoot(extractionDir, entry.name) - if (extractedFile != null) { - extractedFile.parentFile?.mkdirs() - FileOutputStream(extractedFile).use { out -> zis.copyTo(out) } - } else { - Timber.w("Skipping unsafe ODT entry outside extraction root: ${entry.name}") + ZipInputStream(inputStream).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + if (!entry.isDirectory) { + when (entry.name) { + "content.xml" -> contentXmlBytes = zis.readBytes() + "styles.xml" -> stylesXmlBytes = zis.readBytes() + "Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes() + else -> { + if (entry.name !in ignoredFiles) { + val extractedFile = safeFileInRoot(extractionDir, entry.name) + if (extractedFile != null) { + extractedFile.parentFile?.mkdirs() + FileOutputStream(extractedFile).use { out -> zis.copyTo(out) } + } else { + Timber.w("Skipping unsafe ODT entry outside extraction root: ${entry.name}") + } } } } } + entry = zis.nextEntry } - entry = zis.nextEntry } // Pre-parse styles if available diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index 84a7e93..19fa399 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -22,8 +22,6 @@ package com.aryan.reader.epubreader import android.annotation.SuppressLint import android.content.ActivityNotFoundException -import android.content.ClipData -import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.graphics.Color @@ -38,8 +36,6 @@ import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background 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.Box 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.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider 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.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize @@ -86,7 +85,13 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.core.net.toUri import com.aryan.reader.R +import com.aryan.reader.ReaderFontDiagnosticsTag +import com.aryan.reader.copyPlainTextToClipboard 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.SharedSelectionMenuSize import com.aryan.reader.shared.ui.SharedSelectionMenuViewport @@ -96,6 +101,7 @@ import kotlinx.coroutines.launch import org.json.JSONObject import timber.log.Timber import java.io.BufferedReader +import java.io.File import java.io.InputStreamReader private const val TAG_LINK_NAV = "LINK_NAV" @@ -202,6 +208,49 @@ private fun getFontCssInjection(): String { """.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 { return try { context.assets.open("epub_reader.js").use { inputStream -> @@ -514,6 +563,7 @@ fun ChapterWebView( onFootnoteRequested: (String) -> Unit, currentFontFamily: ReaderFont, customFontPath: String? = null, + epubFontFaceCss: String = "", currentTextAlign: ReaderTextAlign, onHighlightClicked: () -> Unit, onAutoScrollChapterEnd: () -> Unit = {}, @@ -578,10 +628,14 @@ fun ChapterWebView( showExternalLinkDialog = null }) { Text(stringResource(R.string.action_open)) } TextButton(onClick = { - val clipboard = - context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow) - clipboard.setPrimaryClip(clip) + val copied = copyPlainTextToClipboard( + context = context, + label = context.getString(R.string.clip_label_copied_link), + text = urlToShow + ) + if (!copied) { + Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show() + } showExternalLinkDialog = null }) { Text(stringResource(R.string.action_copy)) } } @@ -927,13 +981,14 @@ fun ChapterWebView( ) val fontCss = getFontCssInjection().replace("\n", " ") - val customFontCss = if (customFontPath != null) { - "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }" - } else "" - val combinedCss = "$fontCss $customFontCss" + val customFontCss = buildCustomFontCssForWebView(customFontPath, "initial") + val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss) + .filter { it.isNotBlank() } + .joinToString(separator = " ") + val escapedCombinedCss = escapeJsString(combinedCss) 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") { Timber.d("CSS Injection result: $it") } @@ -1125,10 +1180,10 @@ fun ChapterWebView( runtimeApplierState.logPending(chapterTitle) } else { val fontCss = getFontCssInjection().replace("\n", " ") - val customFontCss = if (customFontPath != null) { - "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }" - } else "" - val combinedCss = "$fontCss $customFontCss" + val customFontCss = buildCustomFontCssForWebView(customFontPath, "runtime") + val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss) + .filter { it.isNotBlank() } + .joinToString(separator = " ") val fontNameForJs = if (customFontPath != null) { "CustomFont" } else if (currentFontFamily == ReaderFont.ORIGINAL) { @@ -1166,8 +1221,9 @@ fun ChapterWebView( if (fontCssChanged) { runtimeApplierState.fontCss = combinedCss + val escapedCombinedCss = escapeJsString(combinedCss) 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) } @@ -1304,10 +1360,14 @@ fun ChapterWebView( ) { PaginatedTextSelectionMenu( onCopy = { - val clipboard = - context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), state.selectedText) - clipboard.setPrimaryClip(clip) + val copied = copyPlainTextToClipboard( + context = context, + label = context.getString(R.string.clip_label_copied_text), + text = state.selectedText + ) + if (!copied) { + Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show() + } state.finishActionModeCallback() localWebViewRef?.clearFocus() localWebViewRef?.evaluateJavascript( diff --git a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt index 0aab63d..2475f83 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt @@ -4,7 +4,10 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets 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.size import androidx.compose.foundation.rememberScrollState @@ -20,12 +23,14 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -35,14 +40,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.core.graphics.drawable.toBitmap import com.aryan.reader.R import com.aryan.reader.areReaderAiFeaturesEnabled +import com.aryan.reader.readerModalMaxHeightDp @Suppress("KotlinConstantConditions") @OptIn(ExperimentalMaterial3Api::class) @@ -65,25 +71,28 @@ fun DictionarySettingsDialog( val context = LocalContext.current var dictionaryApps by remember { mutableStateOf>(emptyList()) } var searchApps by remember { mutableStateOf>(emptyList()) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val configuration = LocalConfiguration.current + val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp LaunchedEffect(Unit) { dictionaryApps = ExternalDictionaryHelper.getAvailableDictionaries(context) searchApps = ExternalDictionaryHelper.getAvailableSearchApps(context) } - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 6.dp, - modifier = Modifier.fillMaxWidth() + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentWindowInsets = { WindowInsets.navigationBars } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxSheetHeight) + .verticalScroll(rememberScrollState()) + .padding(24.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(24.dp) - ) { Text( text = stringResource(R.string.dict_lookup_settings), style = MaterialTheme.typography.headlineSmall, @@ -212,7 +221,6 @@ fun DictionarySettingsDialog( onSelect = onSelectSearchPackage, placeholder = stringResource(R.string.dict_select_app) ) - } } } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index 96e7f55..9834106 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -27,8 +27,6 @@ package com.aryan.reader.epubreader import android.Manifest import android.annotation.SuppressLint import android.app.Activity -import android.content.ClipData -import android.content.ClipboardManager import android.content.Context import android.content.pm.PackageManager import android.media.AudioManager @@ -140,6 +138,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign 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.core.content.ContextCompat @@ -154,6 +153,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.media3.common.util.UnstableApi import com.aryan.reader.AiDefinitionResult import com.aryan.reader.BuildConfig +import com.aryan.reader.copyPlainTextToClipboard import com.aryan.reader.BookWordReplacementsSheet import com.aryan.reader.BuiltInThemes import com.aryan.reader.MainViewModel @@ -191,6 +191,7 @@ import com.aryan.reader.loadTtsReplacementPreferences import com.aryan.reader.readerSliderBookmarkPosition import com.aryan.reader.readerSliderChromeColors import com.aryan.reader.readerSliderToggleState +import com.aryan.reader.paginatedreader.CssParser import com.aryan.reader.paginatedreader.BookPaginator import com.aryan.reader.paginatedreader.HeaderBlock 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.TextContentBlock import com.aryan.reader.paginatedreader.TtsChunk +import com.aryan.reader.paginatedreader.buildEpubFontFaceCss 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.semanticBlockModule import com.aryan.reader.rememberSearchState @@ -243,6 +247,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.protobuf.ProtoBuf +import org.jsoup.Jsoup import org.json.JSONArray import org.json.JSONObject import timber.log.Timber @@ -1127,6 +1132,20 @@ fun EpubReaderHost( var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf>(emptyList()) } var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf>(emptyList()) } 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 cfiToLoad by remember { mutableStateOf(initialCfi) } @@ -1175,7 +1194,7 @@ fun EpubReaderHost( fun currentNativeVerticalLocator(): Locator? { val bookPaginator = paginator as? BookPaginator val pageChapterIndex = bookPaginator?.findChapterIndexForPage(nativeVerticalCurrentPage) - return nativeVerticalLocation?.locator + return nativeVerticalLocation?.locatorForPersistence() ?: lastKnownLocator?.takeIf { pageChapterIndex == null || it.chapterIndex == pageChapterIndex } ?: bookPaginator?.getLocatorForPage(nativeVerticalCurrentPage) } @@ -1187,6 +1206,8 @@ fun EpubReaderHost( keepVisible: Boolean = false ) { if (locator != null) { + nativeVerticalScrollRequest = null + nativeVerticalProgressScrollRequest = null nativeVerticalLocatorScrollRequest = locator nativeVerticalLocatorScrollRequestId += 1L 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 { return when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> if (isNativeVerticalMode) { @@ -4280,6 +4339,74 @@ fun EpubReaderHost( val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel() val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel() 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( 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) { RenderMode.VERTICAL_SCROLL -> { @@ -4460,6 +4598,7 @@ fun EpubReaderHost( }, onLocationChanged = { location -> nativeVerticalLocation = location + location.locatorForPersistence()?.let { lastKnownLocator = it } }, onTap = { focusManager.clearFocus() @@ -4613,6 +4752,27 @@ fun EpubReaderHost( """.trimIndent() val chapterToRender = chapters[targetChapterIndex] + val chapterFontFaceCss = remember( + chapterHead, + chapterToRender.absPath, + epubBook.extractionBasePath + ) { + val fontFaces = Jsoup.parse("$chapterHead") + .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 = targetChapterIndex == currentChapterIndex @@ -4975,6 +5135,9 @@ fun EpubReaderHost( currentVerticalMargin = currentVerticalMargin, currentFontFamily = currentFontFamily, customFontPath = currentCustomFontPath, + epubFontFaceCss = listOf(epubFontFaceCss, chapterFontFaceCss) + .filter { it.isNotBlank() } + .joinToString(separator = " "), currentTextAlign = currentTextAlign, activeTextureId = activeTextureId, activeTextureAlpha = activeTextureAlpha, @@ -6057,8 +6220,8 @@ fun EpubReaderHost( ?: "Chapter" val displayPageInfo = when { - isNativeVerticalMode && nativeVerticalTotalPages > 0 -> - " (${nativeVerticalCurrentPage + 1}/$nativeVerticalTotalPages)" + isNativeVerticalMode && nativeVerticalDisplayPageInfo != null -> + " (${nativeVerticalDisplayPageInfo.currentPage}/${nativeVerticalDisplayPageInfo.totalPages})" currentScrollHeightValue <= 0 || isChapterParsing -> "" else -> " ($currentPageInChapter/$totalPagesInCurrentChapter)" } @@ -7059,9 +7222,14 @@ fun EpubReaderHost( highlightToNoteCfi = null }, onCopy = { - val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), targetHighlight.text) - clipboardManager.setPrimaryClip(clip) + val copied = copyPlainTextToClipboard( + context = context, + 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 }, onDictionary = { diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt index 16d3818..6139344 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt @@ -107,8 +107,19 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.edit import com.aryan.reader.R +import com.aryan.reader.ReaderFontDiagnosticsTag 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.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 kotlin.math.roundToInt @@ -412,8 +423,64 @@ fun getComposeFontFamily( ): FontFamily { if (customFontPath != null) { return try { - FontFamily(Font(File(customFontPath))) - } catch (_: Exception) { + val baseFile = File(customFontPath) + 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() + 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 } } @@ -436,6 +503,18 @@ fun getComposeFontFamily( 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( context: Context, fontSize: Float, @@ -853,16 +932,56 @@ fun FontSelectionSheetContent( ) } } else { + val customFontFamilies = remember(customFonts) { + val grouped = customFonts + .filterNot { it.isDeleted } + .map { it.toSharedCustomFontItem() } + .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(customFonts) { fontEntity -> - val isSelected = currentCustomFontPath == fontEntity.path - val fontFamily = remember(fontEntity.path) { - try { FontFamily(Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default } + 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( 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 = { 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() ) 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" fun saveRemoveEdgePadding(context: Context, enabled: Boolean) { @@ -915,6 +1051,8 @@ fun VisualOptionsSheet( onDismiss: () -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val configuration = LocalConfiguration.current + val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, @@ -924,6 +1062,8 @@ fun VisualOptionsSheet( Column( modifier = Modifier .fillMaxWidth() + .heightIn(max = maxSheetHeight) + .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 8.dp) ) { Row( diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt index 42ce113..d0d5e0c 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt @@ -34,6 +34,8 @@ import androidx.compose.ui.input.key.type import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import com.aryan.reader.paginatedreader.AndroidEpubKeyCommand +import com.aryan.reader.paginatedreader.androidEpubKeyCommandOrNull import com.aryan.reader.RenderMode @Composable @@ -150,3 +152,45 @@ fun Modifier.volumeScrollHandler( } 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 +} diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt index 7781952..2703448 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt @@ -328,16 +328,15 @@ private fun handleVerticalAutoAdvance( val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex) if (!nativeChunks.isNullOrEmpty()) { - val resumeIdx = findTtsChunkResumeIndex( + val startChunkIndex = resolveTtsContinuationStartIndex( chunks = nativeChunks, + loadedChunkCount = loadedChunkCount, sourceCfi = lastReadCfi, startOffsetInSource = currentState.startOffsetInSource, - currentText = currentState.currentText, - currentChunkIndexFallback = currentState.currentChunkIndex + currentText = currentState.currentText ) - if (resumeIdx != null && resumeIdx + 1 < nativeChunks.size) { - val startChunkIndex = resumeIdx + 1 + if (startChunkIndex != null) { val token = getAuthToken() ttsController.start( chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId), diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt index e02e82a..0fd123e 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt @@ -78,6 +78,29 @@ internal fun findTtsChunkResumeIndex( return currentChunkIndexFallback.takeIf { it in chunks.indices } } +internal fun resolveTtsContinuationStartIndex( + chunks: List, + 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 { if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false val parentParts = parentPath.split('/').filter { it.isNotEmpty() } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/AndroidEpubKeyCommands.kt b/app/src/main/java/com/aryan/reader/paginatedreader/AndroidEpubKeyCommands.kt new file mode 100644 index 0000000..bbfa901 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/paginatedreader/AndroidEpubKeyCommands.kt @@ -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 + } +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt index f9642f6..d06b6ac 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt @@ -202,6 +202,7 @@ class BookPaginator( private val chapterTextRangeIndex = ConcurrentHashMap>() private val chapterPageNavigationIndex = ConcurrentHashMap>() private val chapterAnchorPageIndex = ConcurrentHashMap>() + private val expandedAllFontFaces = expandFontFacesWithSiblings(allFontFaces, extractionBasePath) private var pageCountsAreAccurate by mutableStateOf(false) private val finalizedChapterCounts = ConcurrentHashMap.newKeySet() @@ -385,7 +386,7 @@ class BookPaginator( append("-pageCache:$LATEST_PAGE_CACHE_VERSION") append("-ua:${userAgentStylesheet.hashCode()}") append("-css:${bookCss.hashCode()}") - append("-fonts:${allFontFaces.hashCode()}") + append("-fonts:${expandedAllFontFaces.hashCode()}") } val hash = configString.hashCode() return hash @@ -872,7 +873,7 @@ class BookPaginator( density = density.density, constraintsMaxWidth = constraints.maxWidth, constraintsMaxHeight = constraints.maxHeight, - fontFaces = this.allFontFaces, + fontFaces = expandedAllFontFaces, styleConfigHash = currentConfigHash, bookReplacementPreferencesJson = ReaderBookReplacementPreferencesJson.encode( bookReplacementPreferences.scopedToFile(bookReplacementFileId), diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/EpubFontFaceSiblings.kt b/app/src/main/java/com/aryan/reader/paginatedreader/EpubFontFaceSiblings.kt new file mode 100644 index 0000000..d3f7264 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/paginatedreader/EpubFontFaceSiblings.kt @@ -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, + extractionPath: String +): List { + 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, + 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") diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt index ae1f204..b94b224 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt @@ -24,6 +24,9 @@ import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily 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.supportsVariableWeightAxis import java.io.File import java.security.MessageDigest @@ -41,7 +44,11 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map if (fontFaces.isEmpty()) { 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. // This assumes the parent of the extraction path is a stable base directory for epubs. @@ -55,21 +62,39 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map // e.g., "d0e205bf-65cc-4ab4-93cc-cd2d613a7bb3.epub" from a longer temp path. val bookId = File(extractionPath).name.substringBeforeLast("_") - val fontsByFamily = fontFaces.groupBy { + val fontsByFamily = expandedFontFaces.groupBy { 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}") return fontsByFamily.mapValues { (familyName, fontInfos) -> - val fontList = fontInfos.mapNotNull { fontInfo -> + val seenVariants = mutableSetOf() + val fontList = fontInfos.flatMap { fontInfo -> try { 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()) { + Timber.tag(ReaderFontDiagnosticsTag).w( + "native.load.missing family='$familyName' src='${fontInfo.src}' resolved='${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 if (fontFile.extension.equals("woff2", ignoreCase = true)) { @@ -80,9 +105,15 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map if (cachedTtfFile.exists()) { // Use the globally cached TTF file if it exists fontFile = cachedTtfFile + Timber.tag(ReaderFontDiagnosticsTag).i( + "native.load.woff2CacheHit src='${fontInfo.src}' cached='${cachedTtfFile.absolutePath}'" + ) Timber.d("Using globally cached TTF for '${fontInfo.src}'") } else { // 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}") val woff2Data = fontFile.readBytes() val ttfData = Woff2Converter.convertWoff2ToTtf(woff2Data) @@ -90,31 +121,69 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map if (ttfData != null) { cachedTtfFile.writeBytes(ttfData) 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}'") } else { + Timber.tag(ReaderFontDiagnosticsTag).e( + "native.load.woff2ConvertFailed src='${fontInfo.src}' source='${fontFile.absolutePath}'" + ) Timber.e("Failed to convert woff2 font: ${fontFile.name}") - return@mapNotNull null + return@flatMap emptyList() } } } - Font( - fontFile, - fontInfo.fontWeight ?: FontWeight.Normal, - fontInfo.fontStyle ?: FontStyle.Normal + val weights = if (sourceName.supportsVariableWeightAxis()) { + variableEpubFontWeights + } else { + 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" + ) + 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}") - null + emptyList() } } 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.") FontFamily(fontList) } else { + Timber.tag(ReaderFontDiagnosticsTag).w("native.load.empty family='$familyName'") Timber.w("Could not load any font styles for family '$familyName'.") null } }.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 +) diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index 3c46fff..5686027 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -5,14 +5,13 @@ package com.aryan.reader.paginatedreader import android.annotation.SuppressLint import android.content.ActivityNotFoundException -import android.content.ClipData -import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.os.Build import android.util.Log import android.widget.Toast import com.aryan.reader.BuildConfig +import com.aryan.reader.copyPlainTextToClipboard import androidx.compose.ui.unit.isSpecified import androidx.annotation.RequiresApi 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.shared.ReaderBookReplacementPreferences import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator +import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay @@ -192,6 +192,8 @@ import timber.log.Timber import java.io.File import java.net.URI import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.Base64 import kotlin.math.abs import kotlin.math.roundToInt import kotlin.math.sqrt @@ -243,7 +245,8 @@ data class NativeVerticalLocation( val firstVisibleItemSize: Int, val isAtStart: Boolean, val isAtEnd: Boolean, - val visibleTextRanges: List = emptyList() + val visibleTextRanges: List = emptyList(), + val chapterPageInfo: NativeVerticalChapterPageInfo? = null ) data class NativeVerticalVisibleTextRange( @@ -253,6 +256,34 @@ data class NativeVerticalVisibleTextRange( 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( val pageIndex: Int, val blockIndex: Int, @@ -318,7 +349,7 @@ internal fun nativeVerticalInitialChapterPrefetchOrder( chapterCount: Int, initialChapter: Int, forwardCount: Int = 2, - backwardCount: Int = 1 + backwardCount: Int = 0 ): List { if (chapterCount <= 0) return emptyList() val start = initialChapter.coerceIn(0, chapterCount - 1) @@ -1026,6 +1057,68 @@ internal fun nativeVerticalProgressForCompatPage(pageIndex: Int, totalPageCount: .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, + itemWeights: List, + 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( itemWeights: List, progressPercent: Float @@ -1119,31 +1212,45 @@ private fun findNativeVerticalFlowItemIndexForProgress( ) } -private fun estimateNativeVerticalScrollProgressPercent( - items: List, +internal fun estimateNativeVerticalWeightedScrollProgressPercent( + itemWeights: List, firstVisibleItemIndex: Int, firstVisibleItemScrollOffset: Int, firstVisibleItemSize: Int ): Float? { - if (items.isEmpty()) return null - val totalWeight = items.sumOf { it.locationWeight }.takeIf { it > 0 } ?: return null - val safeIndex = firstVisibleItemIndex.coerceIn(0, items.lastIndex) - val completedWeight = items + if (itemWeights.isEmpty()) return null + val totalWeight = itemWeights.sumOf { it }.takeIf { it > 0 } ?: return null + val safeIndex = firstVisibleItemIndex.coerceIn(0, itemWeights.lastIndex) + val completedWeight = itemWeights .take(safeIndex) - .sumOf { it.locationWeight } - val currentItem = items[safeIndex] + .sum() + val currentItemWeight = itemWeights[safeIndex] val currentFraction = if (firstVisibleItemSize > 0) { (firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat()) .coerceIn(0f, 1f) } else { 0f } - val weightedPosition = completedWeight + (currentItem.locationWeight * currentFraction) + val weightedPosition = completedWeight + (currentItemWeight * currentFraction) return ((weightedPosition.toDouble() / totalWeight.toDouble()) * 100.0) .toFloat() .coerceIn(0f, 100f) } +private fun estimateNativeVerticalScrollProgressPercent( + items: List, + firstVisibleItemIndex: Int, + firstVisibleItemScrollOffset: Int, + firstVisibleItemSize: Int +): Float? { + return estimateNativeVerticalWeightedScrollProgressPercent( + itemWeights = items.map { it.locationWeight }, + firstVisibleItemIndex = firstVisibleItemIndex, + firstVisibleItemScrollOffset = firstVisibleItemScrollOffset, + firstVisibleItemSize = firstVisibleItemSize + ) +} + private fun findNativeVerticalFlowItemIndexForLocator( items: List, chapters: List, @@ -1333,7 +1440,7 @@ private fun resolveNativeVerticalVisibleTextRanges( val start = blockStart + (firstVisibleOffset ?: 0) val end = blockStart + (lastVisibleOffset ?: block.content.text.length) - NativeVerticalVisibleTextRange( + bounds.top to NativeVerticalVisibleTextRange( chapterIndex = chapterIndex, blockIndex = block.blockIndex, startCharOffset = start, @@ -1341,6 +1448,8 @@ private fun resolveNativeVerticalVisibleTextRanges( ) } } + .sortedBy { it.first } + .map { it.second } .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(" 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( block: ImageBlock, density: Density, @@ -2023,7 +2163,9 @@ private fun WrappingContentLayout( Layout(content = { 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, contentScale = imageContentScale(block.floatedImage.style) ) @@ -2971,7 +3113,7 @@ fun PaginatedReaderScreen( } @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) -@OptIn(ExperimentalSerializationApi::class, FlowPreview::class) +@OptIn(ExperimentalSerializationApi::class) @Composable fun NativeVerticalReaderScreen( modifier: Modifier = Modifier, @@ -3342,19 +3484,19 @@ fun NativeVerticalReaderScreen( ) if (exactDelta != null) { val scrollDelta = if (keepVisible) { - val viewportHeight = rootWindowBounds.height - val comfortableTop = viewportHeight * 0.24f - val comfortableBottom = viewportHeight * 0.76f - if (exactDelta in comfortableTop..comfortableBottom) { - 0f - } else { - exactDelta - (viewportHeight * 0.38f) - } + nativeVerticalCenteredScrollDelta( + targetOffsetInViewport = exactDelta, + viewportHeight = rootWindowBounds.height + ) } else { exactDelta } if (abs(scrollDelta) > 1f) { - listState.scrollBy(scrollDelta) + if (animate) { + listState.animateScrollBy(scrollDelta) + } else { + listState.scrollBy(scrollDelta) + } } if (keepVisible || abs(exactDelta) > 1f) return true } @@ -3364,7 +3506,11 @@ fun NativeVerticalReaderScreen( chapters = chapters, locator = locator ) ?: return false - listState.scrollToItem(targetIndex) + if (animate) { + listState.animateScrollToItem(targetIndex) + } else { + listState.scrollToItem(targetIndex) + } repeat(4) { withFrameNanos { } val refinedDelta = resolveNativeVerticalScrollDeltaForLocator( @@ -3379,13 +3525,19 @@ fun NativeVerticalReaderScreen( ) if (refinedDelta != null) { val scrollDelta = if (keepVisible) { - val viewportHeight = rootWindowBounds.height - refinedDelta - (viewportHeight * 0.38f) + nativeVerticalCenteredScrollDelta( + targetOffsetInViewport = refinedDelta, + viewportHeight = rootWindowBounds.height + ) } else { refinedDelta } if (abs(scrollDelta) > 1f) { - listState.scrollBy(scrollDelta) + if (animate) { + listState.animateScrollBy(scrollDelta) + } else { + listState.scrollBy(scrollDelta) + } } return true } @@ -3441,8 +3593,11 @@ fun NativeVerticalReaderScreen( prefetchOrder.forEach { chapterIndex -> if (!isActive) return@LaunchedEffect + while (isActive && listState.isScrollInProgress) { + delay(80L) + } loadFlowChapter(chapterIndex) - delay(16L) + delay(80L) } } @@ -3453,9 +3608,18 @@ fun NativeVerticalReaderScreen( didInitialScroll = true return@LaunchedEffect } - val didScroll = scrollToFlowLocator(targetLocator, animate = false) || - scrollToCompatPage(initialNativePageIndex, animate = false) - if (didScroll) { + val didLocatorScroll = scrollToFlowLocator(targetLocator, animate = false) + val didScroll = didLocatorScroll || + if (shouldFallbackNativeVerticalInitialScrollToCompatPage( + hasInitialLocator = initialNativeLocator != null, + didLocatorScroll = didLocatorScroll + ) + ) { + scrollToCompatPage(initialNativePageIndex, animate = false) + } else { + false + } + if (didScroll || initialNativeLocator != null) { didInitialScroll = true } } @@ -3471,7 +3635,12 @@ fun NativeVerticalReaderScreen( LaunchedEffect(scrollRequestLocatorId, scrollRequestLocator, scrollRequestLocatorKeepVisible, flowChapters, rootWindowBounds) { val requestedLocator = scrollRequestLocator ?: 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( nativeVerticalCompatPageForProgress( estimateNativeVerticalProgressPercent(book, requestedLocator) ?: 0f, @@ -3506,6 +3675,7 @@ fun NativeVerticalReaderScreen( var lastReportedTotalPageCount by remember { mutableIntStateOf(0) } var lastReportedProgressPercent by remember { mutableFloatStateOf(-1f) } var lastReportedLocator by remember { mutableStateOf(null) } + var lastReportedChapterPageInfo by remember { mutableStateOf(null) } var lastReportedVisibleTextRanges by remember { mutableStateOf>(emptyList()) } LaunchedEffect(paginator, totalPageCount, rootWindowBounds, blockLayoutMap, flowChapters, flowItems) { @@ -3531,7 +3701,6 @@ fun NativeVerticalReaderScreen( initialScrollComplete = didInitialScroll ) } - .debounce(80) .collectLatest { sample -> if (!sample.initialScrollComplete) return@collectLatest val total = sample.totalPageCount @@ -3561,18 +3730,32 @@ fun NativeVerticalReaderScreen( } val compatPage = nativeVerticalCompatPageForProgress(progressPercent, total) 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 ( compatPage != lastReportedVisiblePage || total != lastReportedTotalPageCount || abs(progressPercent - lastReportedProgressPercent) >= 0.05f || locator != lastReportedLocator || + chapterPageInfo != lastReportedChapterPageInfo || visibleTextRanges != lastReportedVisibleTextRanges ) { lastReportedVisiblePage = compatPage lastReportedTotalPageCount = total lastReportedProgressPercent = progressPercent lastReportedLocator = locator + lastReportedChapterPageInfo = chapterPageInfo lastReportedVisibleTextRanges = visibleTextRanges onLocationChanged( NativeVerticalLocation( @@ -3586,7 +3769,8 @@ fun NativeVerticalReaderScreen( firstVisibleItemSize = sample.firstVisibleItemSize, isAtStart = sample.isAtStart, isAtEnd = sample.isAtEnd, - visibleTextRanges = visibleTextRanges + visibleTextRanges = visibleTextRanges, + chapterPageInfo = chapterPageInfo ) ) onProgressChanged(compatPage, total, progressPercent) @@ -3644,11 +3828,14 @@ fun NativeVerticalReaderScreen( }, dismissButton = { TextButton(onClick = { - val clipboardManager = - context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboardManager.setPrimaryClip( - ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), urlToShow) + val copied = copyPlainTextToClipboard( + context = context, + label = context.getString(R.string.clip_label_copied_text), + text = urlToShow ) + if (!copied) { + Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show() + } showExternalLinkDialog = null }) { Text(stringResource(R.string.action_copy)) } } @@ -3673,7 +3860,8 @@ fun NativeVerticalReaderScreen( LazyColumn( state = listState, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(listState), contentPadding = PaddingValues(top = verticalPadding, bottom = verticalPadding) ) { itemsIndexed( @@ -3820,11 +4008,14 @@ fun NativeVerticalReaderScreen( ) { PaginatedTextSelectionMenu( onCopy = { - val clipboardManager = - context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboardManager.setPrimaryClip( - ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), sel.text) + val copied = copyPlainTextToClipboard( + context = context, + label = context.getString(R.string.clip_label_copied_text), + text = sel.text ) + if (!copied) { + Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show() + } activeSelection = null }, onSelectAll = null, @@ -5806,10 +5997,14 @@ internal fun PaginatedReaderContent( Row(horizontalArrangement = Arrangement.End) { TextButton( onClick = { - val clipboard = - context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow) - clipboard.setPrimaryClip(clip) + val copied = copyPlainTextToClipboard( + context = context, + label = context.getString(R.string.clip_label_copied_link), + text = urlToShow + ) + if (!copied) { + Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show() + } showExternalLinkDialog = null }) { Text(stringResource(R.string.action_copy)) } TextButton( @@ -7535,10 +7730,14 @@ internal fun PaginatedReaderContent( ) { PaginatedTextSelectionMenu( onCopy = { - val clipboardManager = - context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), sel.text) - clipboardManager.setPrimaryClip(clip) + val copied = copyPlainTextToClipboard( + context = context, + label = context.getString(R.string.clip_label_copied_text), + text = sel.text + ) + if (!copied) { + Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show() + } activeSelection = null }, onSelectAll = null, @@ -8107,15 +8306,15 @@ private fun RenderFlexChildBlock( searchHighlighted } - // Apply block specific styles (like header font weight) - val finalStyle = if (block is HeaderBlock) { - createHeaderTextStyle( + val finalStyle = when (block) { + is HeaderBlock -> createHeaderTextStyle( baseStyle = textStyle, level = block.level, textAlign = block.textAlign ) - } else { - textStyle + is ParagraphBlock -> textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign) + is QuoteBlock -> textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign) + is ListItemBlock -> textStyle } TextWithEmphasis( @@ -8157,7 +8356,7 @@ private fun RenderFlexChildBlock( if (itemMarkerImage != null) { val imageRequest = - Builder(LocalContext.current).data(File(itemMarkerImage)) + Builder(LocalContext.current).data(nativeVerticalImageModelData(itemMarkerImage)) .crossfade(true).build() 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) { Modifier.width(style.width) } else { - Modifier + Modifier.fillMaxWidth() } ) .then( @@ -8250,7 +8449,7 @@ private fun RenderFlexChildBlock( ) AsyncImage( - model = Builder(LocalContext.current).data(File(childBlock.path)).crossfade(true) + model = Builder(LocalContext.current).data(nativeVerticalImageModelData(childBlock.path)).crossfade(true) .build(), contentDescription = childBlock.altText, modifier = imageModifier, @@ -8338,9 +8537,7 @@ private fun RenderFlexChildBlock( } else if (blockInCell is ImageBlock) { AsyncImage( model = Builder(LocalContext.current).data( - File( - blockInCell.path - ) + nativeVerticalImageModelData(blockInCell.path) ).build(), contentDescription = blockInCell.altText, contentScale = imageContentScale(blockInCell.style), diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt b/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt index e1a789d..386f4ea 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber +import java.io.ByteArrayOutputStream import java.io.FileInputStream import java.io.FileOutputStream 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_HEIGHT_PX = 2048 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 { val core: PdfiumCoreKt by lazy { @@ -42,7 +45,8 @@ internal data class DocumentCacheItem( val pfd: ParcelFileDescriptor?, val totalPages: Int, val pageAspectRatios: List, - val flatTableOfContents: List + val flatTableOfContents: List, + val isPasswordProtectedPdf: Boolean = false ) 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 { return Random.nextInt(1000, 9999).toString() } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index 6efc966..1a44fe5 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -166,7 +166,7 @@ private fun Throwable.readablePdfErrorDetail(): String { private const val PDF_TILE_SIZE_DP = 256 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_RENDER_IDLE_COOLDOWN_MS = 220L 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 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 canvasHeightPx = remember { mutableFloatStateOf(0f) } @@ -1241,7 +1247,7 @@ internal fun PdfPageComposable( } } - if (latestShouldPauseHighResTileRendering && renderScale > 1f) { + if (latestShouldPauseHighResTileRendering) { if (shouldLogTileSample) { PdfVerticalPerfLog.d( "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) if (!isActive) return@collectLatest - if (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) { + if (latestShouldPauseHighResTileRendering) { if (shouldLogHighResTile) { PdfVerticalPerfLog.d( "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 (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) { + if (latestShouldPauseHighResTileRendering) { if (shouldLogHighResTile) { PdfVerticalPerfLog.d( "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 } - if (renderedTiles.isNotEmpty() && latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) { + if (renderedTiles.isNotEmpty() && latestShouldPauseHighResTileRendering) { if (shouldLogHighResTile) { PdfVerticalPerfLog.d( "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 stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) } val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) } - val shouldDrawHighResTiles = !shouldPauseHighResTileRendering + val shouldDrawHighResTiles = !shouldPauseHighResTileRendering && needsTilingNow LaunchedEffect(shouldDrawHighResTiles, stableTiles.item.size, effectiveScale) { - if (stableTiles.item.isNotEmpty() && effectiveScale > 1f) { + if (stableTiles.item.isNotEmpty() && shouldDrawHighResTiles) { PdfVerticalPerfLog.d( "tile-display mode=${if (isVerticalScroll) "vertical" else "pagination"} page=$pageIndex " + "visible=$shouldDrawHighResTiles tiles=${stableTiles.item.size} pause=$shouldPauseHighResTileRendering " + @@ -4513,8 +4519,7 @@ private fun PdfBitmapLayer( } } - val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000 - if (needsTiling && shouldDrawHighResTiles) { + if (shouldDrawHighResTiles) { tiles.forEach { tile -> if ( tile.bitmap.isCanvasSafeBitmap( @@ -5353,7 +5358,7 @@ private fun PdfPageRenderer( ) { MagnifierComposable( 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, magnifierCenterOnBitmap = magnifierCenterTarget, contentWidthPx = staticData.targetWidth, diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt index e34ac6e..6cc32b7 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.background 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.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close 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.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource 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.SystemUiMode import com.aryan.reader.epubreader.titleRes +import com.aryan.reader.readerModalMaxHeightDp import com.aryan.reader.shared.reader.ReaderPageSpreadMode @@ -568,6 +573,8 @@ fun PdfVisualOptionsSheet( onDismiss: () -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val configuration = LocalConfiguration.current + val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, @@ -577,6 +584,8 @@ fun PdfVisualOptionsSheet( Column( modifier = Modifier .fillMaxWidth() + .heightIn(max = maxSheetHeight) + .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 8.dp) .padding(bottom = 32.dp) ) { diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt index 8c9abac..7f34d34 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt @@ -72,7 +72,8 @@ internal fun pdfOverflowMenuSections( hasHiddenToolbarTools: Boolean, isPro: Boolean, effectiveFileType: FileType, - hasFileInfo: Boolean = true + hasFileInfo: Boolean = true, + canPrintDocument: Boolean = true ): List = buildList { add(PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR) if (hasHiddenToolbarTools) add(PdfOverflowMenuSection.HIDDEN_TOOLS) @@ -96,7 +97,7 @@ internal fun pdfOverflowMenuSections( if ( !hiddenTools.contains(PdfReaderTool.SHARE.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) } @@ -136,6 +137,7 @@ internal fun PdfTopBar( isReflowingThisBook: Boolean, hasReflowFile: Boolean, isPdfDocumentLoaded: Boolean, + canPrintDocument: Boolean = true, isTabsEnabled: Boolean, openTabs: List, activeTabBookId: String?, @@ -395,12 +397,13 @@ internal fun PdfTopBar( val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name) val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.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( hiddenTools = hiddenTools, hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(), isPro = BuildConfig.IS_PRO, - effectiveFileType = effectiveFileType + effectiveFileType = effectiveFileType, + canPrintDocument = canPrintDocument ).forEachIndexed { index, section -> if (index > 0) HorizontalDivider() when (section) { diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index 9b72a08..c501ce2 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -378,7 +378,6 @@ fun PdfViewerScreen( var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) } var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) } var showScreenOrientationSheet by remember { mutableStateOf(false) } - var documentPassword by rememberSaveable { mutableStateOf(null) } var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) } var isScrollLocked by remember { mutableStateOf(false) } var lockedState by remember { mutableStateOf?>(null) } @@ -454,6 +453,8 @@ fun PdfViewerScreen( val uiState by viewModel.uiState.collectAsState() val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri val effectiveFileType = uiState.selectedFileType ?: FileType.PDF + var documentPassword by rememberSaveable(effectivePdfUri.toString()) { mutableStateOf(null) } + var isPrintBlockedForPasswordProtectedPdf by rememberSaveable(effectivePdfUri.toString()) { mutableStateOf(false) } val isComicFile = effectiveFileType in COMIC_ARCHIVE_FILE_TYPES var showNewTabSheet by remember { mutableStateOf(false) } @@ -598,7 +599,11 @@ fun PdfViewerScreen( 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 jobName = "${context.getString(R.string.app_name)} - $originalFileName" @@ -2423,8 +2428,9 @@ fun PdfViewerScreen( } } - LaunchedEffect(currentPageScale) { - if (currentPageScale != 1f) { + val zoomIndicatorPercentage = pdfZoomIndicatorPercent(currentPageScale) + LaunchedEffect(zoomIndicatorPercentage) { + if (shouldShowPdfZoomIndicator(zoomIndicatorPercentage)) { showZoomIndicator = true delay(1500) showZoomIndicator = false @@ -3518,6 +3524,7 @@ fun PdfViewerScreen( isDocumentReady = false errorMessage = null documentMetadataTitle = null + isPrintBlockedForPasswordProtectedPdf = false currentBookId = null areAnnotationsLoaded = false loadedSidecarBookId = null @@ -3591,6 +3598,7 @@ fun PdfViewerScreen( totalPages = cachedItem.totalPages pageAspectRatios = cachedItem.pageAspectRatios flatTableOfContents = cachedItem.flatTableOfContents + isPrintBlockedForPasswordProtectedPdf = cachedItem.isPasswordProtectedPdf val mapPage = tabStateMap[currentBookId!!] val uiPage = uiState.initialPageInBook @@ -3634,6 +3642,8 @@ fun PdfViewerScreen( val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore) + val loadedPasswordProtectedPdf = selectedDocumentType == FileType.PDF && + (documentPassword != null || isPdfLikelyEncryptedForPrint(context, effectivePdfUri)) if (!isActive) { doc.close() @@ -3641,6 +3651,7 @@ fun PdfViewerScreen( } pdfDocument = doc + isPrintBlockedForPasswordProtectedPdf = loadedPasswordProtectedPdf documentMetadataTitle = (doc as? PdfDocumentWrapper)?.let { wrapper -> PdfiumEngineProvider.withPdfium { wrapper.pdfDocument.getDocumentMeta().title?.takeIf { it.isNotBlank() } @@ -3737,7 +3748,8 @@ fun PdfViewerScreen( pfd = null, totalPages = pagesCount, pageAspectRatios = ratios, - flatTableOfContents = flatTableOfContents + flatTableOfContents = flatTableOfContents, + isPasswordProtectedPdf = loadedPasswordProtectedPdf ) ) @@ -4558,6 +4570,8 @@ fun PdfViewerScreen( val latestSpreadScale = rememberUpdatedState(currentActiveScale) val latestSpreadOffset = rememberUpdatedState(currentActiveOffset) val spreadPageGap = if (showVerticalPageGap) 8.dp else 0.dp + val spreadPageGapPx = with(density) { spreadPageGap.toPx() } + val spreadPageCount = spreadPageIndices.size var spreadPanFlingJob by remember { mutableStateOf(null) } Row( modifier = Modifier @@ -4930,6 +4944,20 @@ fun PdfViewerScreen( ) { spreadPageIndices.forEach { 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) { derivedStateOf { bookmarks.any { it.pageIndex == pageIndex } @@ -5130,7 +5158,7 @@ fun PdfViewerScreen( ocrHoverHighlights = stableOcrRects, modifier = if (spreadPageIndices.size > 1) { Modifier - .weight(1f) + .width(spreadPageWidth) .fillMaxHeight() } else { Modifier.fillMaxSize() @@ -6278,6 +6306,7 @@ fun PdfViewerScreen( isReflowingThisBook = isReflowingThisBook, hasReflowFile = hasReflowFile, isPdfDocumentLoaded = pdfDocument != null, + canPrintDocument = !isPrintBlockedForPasswordProtectedPdf, isTabsEnabled = isPdfTabStripVisible, openTabs = openTabs, activeTabBookId = activeTabBookId, @@ -7100,9 +7129,8 @@ fun PdfViewerScreen( enter = fadeIn(), exit = fadeOut() ) { - val percentage = (currentPageScale * 100).roundToInt() ZoomPercentageIndicator( - percentage = percentage, + percentage = zoomIndicatorPercentage, onResetZoomClick = { resetZoomTrigger = System.currentTimeMillis() } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt index 7fa37b4..5f66339 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt @@ -3,6 +3,7 @@ package com.aryan.reader.pdf import androidx.compose.ui.geometry.Offset import com.aryan.reader.shared.pdf.PdfSpreadLayout import com.aryan.reader.shared.reader.ReaderSettings +import kotlin.math.roundToInt internal fun resolveEraserStrokeWidth( 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( isScrollLocked: Boolean, lockedState: Triple? @@ -139,3 +156,32 @@ internal fun shouldResetPdfZoomAfterBubbleZoomCleanup( isZoomEnabled && !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 diff --git a/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt b/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt index b88ee12..93e897a 100644 --- a/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt +++ b/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt @@ -32,11 +32,14 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults 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.toArgb import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.selected @@ -84,6 +88,7 @@ import com.aryan.reader.HexInput import com.aryan.reader.R import com.aryan.reader.RgbInputColumn import com.aryan.reader.SpectrumBox +import com.aryan.reader.readerModalMaxHeightDp import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @@ -151,18 +156,28 @@ fun ToolSettingsPopup( } val circleSize = 28.dp + val configuration = LocalConfiguration.current + val maxPopupHeight = readerModalMaxHeightDp( + screenHeightDp = configuration.screenHeightDp, + fraction = 0.8f, + verticalMarginDp = 64, + preferredMinHeightDp = 240 + ).dp Surface( modifier = modifier .width(360.dp) - .padding(12.dp), + .padding(12.dp) + .heightIn(max = maxPopupHeight), shape = RoundedCornerShape(28.dp), color = Color(0xFF1E1E1E), shadowElevation = 12.dp, tonalElevation = 0.dp ) { Column( - modifier = Modifier.padding(20.dp), + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { if (isEraser) { @@ -450,15 +465,21 @@ private fun ColorPickerDialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false) ) { + val configuration = LocalConfiguration.current + val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp + Surface( shape = RoundedCornerShape(24.dp), color = Color(0xFF2C2C2C), modifier = Modifier .fillMaxWidth(0.85f) .padding(8.dp) + .heightIn(max = maxDialogHeight) ) { Column( - modifier = Modifier.padding(20.dp), + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { Box( @@ -788,4 +809,4 @@ private fun StyledPropertySlider( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt index 8cadc36..b9e7da2 100644 --- a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt +++ b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt @@ -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() { if (tts == null) return @@ -302,7 +313,7 @@ class BaseTtsSynthesizer(private val context: Context) { requests.remove(utteranceId) if (attempt < MAX_RETRY_ATTEMPTS) { - shutdownEngineLocked() + stopEngineForRetryLocked() } } } diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt index 0c42007..0629516 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -55,6 +55,15 @@ import com.aryan.reader.paginatedreader.TtsChunk import kotlinx.coroutines.delay import kotlin.math.roundToInt +internal fun stableSortedIntSnapshot(values: Collection): List { + 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 get() = ttsSessionCommand("com.aryan.reader.tts.START") 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_PUNCTUATION_PAUSE_MS = 120L 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 fun ttsSessionCommand(action: String): SessionCommand { @@ -143,9 +153,14 @@ internal fun resolveReusableTtsPlaylistIndex( internal fun shouldAdvanceToTtsPlaylistChunk( currentChunkIndex: Int, - playlistChunkIndex: Int? + playlistChunkIndex: Int?, + skippedChunkIndices: Set = emptySet() ): Boolean { - return playlistChunkIndex == currentChunkIndex + 1 + return playlistChunkIndex == resolveNextPlayableTtsChunkIndex( + currentChunkIndex = currentChunkIndex, + totalChunks = maxOf(playlistChunkIndex?.plus(1) ?: 0, currentChunkIndex + 2), + skippedChunkIndices = skippedChunkIndices + ) } internal fun shouldStartTtsTransitionPrefetch( @@ -162,6 +177,22 @@ internal fun shouldStopTtsPrefetchAfterMissingChunk( return !isLoaded && playlistIndex == null } +internal fun resolveNextPlayableTtsChunkIndex( + currentChunkIndex: Int, + totalChunks: Int, + skippedChunkIndices: Set +): 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? { if (totalBytes <= TTS_STREAM_WAV_HEADER_BYTES) return null 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 val loadedChunks: MutableSet = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) private val chunkStreamIds = java.util.concurrent.ConcurrentHashMap() + private val skippedChunks: MutableSet = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) + private val chunkGenerationFailures = java.util.concurrent.ConcurrentHashMap() enum class TtsMode { CLOUD, BASE @@ -346,7 +379,7 @@ class TtsPlaybackManager( } private fun cancelPrefetchWork() { - logChunkNav("prefetch-cancel", "activePrefetching=${prefetchingJobs.keys.sorted()} lastPrefetch=$lastPrefetchIndex") + logChunkNav("prefetch-cancel", "activePrefetching=${stableSortedIntSnapshot(prefetchingJobs.keys)} lastPrefetch=$lastPrefetchIndex") prefetchLoopJob?.cancel() prefetchingJobs.values.forEach { it.cancel() } prefetchingJobs.clear() @@ -386,7 +419,7 @@ class TtsPlaybackManager( } 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( @@ -826,6 +859,8 @@ class TtsPlaybackManager( this.pageIndex = pageIndex loadedChunks.clear() + skippedChunks.clear() + chunkGenerationFailures.clear() lastPrefetchIndex = -1 _ttsState.value = TtsState( @@ -914,7 +949,11 @@ class TtsPlaybackManager( } 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 { logChunkNavMain( "advance-next-no-target", @@ -922,16 +961,16 @@ class TtsPlaybackManager( ) return false } - val nextPlaylistIndex = findPlaylistIndexForChunk(nextChunkIndex) + val nextPlaylistIndex = findPlaylistIndexForChunk(nextPlayableChunkIndex) ?: run { logChunkNavMain( "advance-next-missing-playlist-item", - "currentChunk=$currentChunkIndex expectedNextChunk=$nextChunkIndex" + "currentChunk=$currentChunkIndex expectedNextChunk=$nextPlayableChunkIndex skipped=${stableSortedIntSnapshot(skippedChunks)}" ) return false } val nextPlaylistChunkIndex = player.getMediaItemAt(nextPlaylistIndex).mediaId.toIntOrNull() - if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex)) { + if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex, skippedChunks)) { logChunkNavWarnMain( "advance-next-refused-non-contiguous", "Refusing non-contiguous TTS advance. current=$currentChunkIndex, nextPlaylistChunk=$nextPlaylistChunkIndex" @@ -940,7 +979,7 @@ class TtsPlaybackManager( } logChunkNavMain( "advance-next-seek", - "currentChunk=$currentChunkIndex nextChunk=$nextChunkIndex nextPlaylistIndex=$nextPlaylistIndex" + "currentChunk=$currentChunkIndex nextChunk=$nextPlayableChunkIndex nextPlaylistIndex=$nextPlaylistIndex" ) player.seekTo(nextPlaylistIndex, 0L) return true @@ -1028,6 +1067,8 @@ class TtsPlaybackManager( audioFiles.clear() chunkStreamIds.clear() loadedChunks.clear() + skippedChunks.clear() + chunkGenerationFailures.clear() lastPrefetchIndex = -1 Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( @@ -1097,6 +1138,8 @@ class TtsPlaybackManager( val serverText = ttsAudioData.serverText if ((audioFile != null || streamUri != null) && serverText != null) { + chunkGenerationFailures.remove(startAtIndex) + skippedChunks.remove(startAtIndex) if (audioFile != null) { audioFiles[startAtIndex] = audioFile } @@ -1167,10 +1210,30 @@ class TtsPlaybackManager( prefetchNextChunkAudio(startAtIndex) } } else { + val failureCount = recordChunkGenerationFailure(startAtIndex) logChunkNav( "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( isLoading = false, errorMessage = ttsAudioData.error ?: appContext.getString(R.string.tts_error_load_audio) @@ -1243,6 +1306,8 @@ class TtsPlaybackManager( pageIndex = null cancelPrefetchWork() loadedChunks.clear() + skippedChunks.clear() + chunkGenerationFailures.clear() scope.launch { clearAudioFiles() @@ -1434,6 +1499,10 @@ class TtsPlaybackManager( } val targetIndex = currentIndex + i if (targetIndex < textChunks.size) { + if (skippedChunks.contains(targetIndex)) { + logChunkNav("prefetch-target-skip-marked", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation") + continue + } if (prefetchingJobs.containsKey(targetIndex)) { logChunkNav("prefetch-target-skip-inflight", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation") continue @@ -1496,6 +1565,8 @@ class TtsPlaybackManager( val serverText = ttsAudioData.serverText if ((audioFile != null || streamUri != null) && serverText != null) { + chunkGenerationFailures.remove(targetIndex) + skippedChunks.remove(targetIndex) val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings) val pathToUse = streamUri ?: audioFile!!.absolutePath val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk) @@ -1560,7 +1631,11 @@ class TtsPlaybackManager( } 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) { logChunkNavMain( @@ -1579,11 +1654,19 @@ class TtsPlaybackManager( } } } else { + val failureCount = recordChunkGenerationFailure(targetIndex) Timber.e("Prefetch: Failed to download chunk $targetIndex") logChunkNav( "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 @@ -1599,6 +1682,13 @@ class TtsPlaybackManager( ) return@launch } + if (skippedChunks.contains(targetIndex)) { + logChunkNav( + "prefetch-after-join-skipped", + "targetChunk=$targetIndex generation=$generation" + ) + continue + } val shouldStopAfterMissingChunk = withContext(Dispatchers.Main) { val playlistIndex = findPlaylistIndexForChunk(targetIndex) shouldStopTtsPrefetchAfterMissingChunk( @@ -1625,6 +1715,12 @@ class TtsPlaybackManager( } } + private fun recordChunkGenerationFailure(chunkIndex: Int): Int { + return chunkGenerationFailures + .getOrPut(chunkIndex) { AtomicInteger(0) } + .incrementAndGet() + } + private suspend fun trackWordByWord() { var loopCount = 0 while (true) { @@ -1821,6 +1917,8 @@ class TtsPlaybackManager( chunkStreamIds.values.forEach { StreamRegistry.remove(it) } // ADDED chunkStreamIds.clear() // ADDED loadedChunks.clear() + skippedChunks.clear() + chunkGenerationFailures.clear() } } diff --git a/app/src/main/res/values-et/plurals.xml b/app/src/main/res/values-et/plurals.xml index abf02c6..e02f040 100644 --- a/app/src/main/res/values-et/plurals.xml +++ b/app/src/main/res/values-et/plurals.xml @@ -22,99 +22,99 @@ %1$d riiul - %1$d riiulid + %1$d riiulit - Kas soovite %1$d jäädavalt kustutada valitud faili oma seadmest? Seda toimingut ei saa tagasi võtta. - Kas soovite %1$d jäädavalt kustutada teie seadmest valitud failid? Seda toimingut ei saa tagasi võtta. + Kas kustutada %1$d valitud fail seadmest jäädavalt? Seda toimingut ei saa tagasi võtta. + Kas kustutada %1$d valitud faili seadmest jäädavalt? Seda toimingut ei saa tagasi võtta. - Kas soovite eemaldada %1$d valitud faili viimaste failide loendist? See kuvatakse uuesti, kui avate selle uuesti raamatukogust. - Kas soovite eemaldada %1$d valitud failid viimaste failide loendist? See kuvatakse uuesti, kui avate selle uuesti raamatukogust. + Kas eemaldada %1$d valitud fail viimaste failide loendist? See ilmub uuesti, kui avad selle raamatukogust. + Kas eemaldada %1$d valitud faili viimaste failide loendist? Need ilmuvad uuesti, kui avad need raamatukogust. - Kas soovite kindlasti eemaldada %1$d raamat \'%2$s\' riiul? Raamat jääb teie kogusse ja kuvatakse jaotises Riiulita. - Kas soovite kindlasti eemaldada %1$d raamatud \'%2$s\' riiul? Raamatud jäävad teie kogusse ja kuvatakse jaotises Riiulita. + Kas eemaldada %1$d raamat riiulilt "%2$s"? Raamat jääb raamatukokku ja kuvatakse riiulita raamatute all. + Kas eemaldada %1$d raamatut riiulilt "%2$s"? Raamatud jäävad raamatukokku ja kuvatakse riiulita raamatute all. %1$d raamat raamatukogust eemaldatud. - %1$d raamatud raamatukogust eemaldatud. + %1$d raamatut eemaldati raamatukogust. - Importimine %1$d raamat… See ilmub peagi teie teegis. - Importimine %1$d raamatud… Need ilmuvad peagi teie kogusse. + Impordin %1$d raamatut… See ilmub peagi raamatukogusse. + Impordin %1$d raamatut… Need ilmuvad peagi raamatukogusse. - Imporditud %1$d raamat. Selle leiate vahekaardilt Raamatukogu. - Imporditud %1$d raamatuid. Leiate need vahekaardilt Raamatukogu. + Imporditi %1$d raamat. Leiad selle vahekaardilt Raamatukogu. + Imporditi %1$d raamatut. Leiad need vahekaardilt Raamatukogu. %1$d raamat lisatud riiulile. - %1$d raamatud lisatud riiulile. + %1$d raamatut lisati riiulile. %1$d raamat sildiga "%2$s". - %1$d raamatud sildiga "%2$s". + %1$d raamatut märgiti sildiga "%2$s". - Eemaldatud kaust "%1$s" ja %2$d raamat rakendusest. - Eemaldatud kaust "%1$s" ja %2$d raamatud rakendusest. + Eemaldati kaust "%1$s" ja %2$d raamat rakendusest. + Eemaldati kaust "%1$s" ja %2$d raamatut rakendusest. - %1$d kausta - %1$d kaustad + %1$d kaust + %1$d kausta - %1$d faili - %1$d failid + %1$d fail + %1$d faili - Langetage import %1$d faili - Langetage import %1$d failid + Lohista importimiseks %1$d fail + Lohista importimiseks %1$d faili %1$d toetamata fail jäetakse vahele. - %1$d toetamata failid jäetakse vahele. + %1$d toetamata faili jäetakse vahele. - Importimine %1$d fail… - Importimine %1$d failid… + Impordin %1$d faili… + Impordin %1$d faili… - Imporditud %1$d faili. - Imporditud %1$d failid. + Imporditi %1$d fail. + Imporditi %1$d faili. - Imporditud %1$d faili. Lugeja tugi tuleb hiljem. - Imporditud %1$d failid. Lugeja tugi tuleb hiljem. + Imporditi %1$d fail. Lugeja tugi tuleb hiljem. + Imporditi %1$d faili. Lugeja tugi tuleb hiljem. - Ei saanud importida %1$d faili. - Ei saanud importida %1$d failid. + %1$d faili importimine nurjus. + %1$d faili importimine nurjus. - Vahele jäetud %1$d faili. - Vahele jäetud %1$d failid. + %1$d fail jäeti vahele. + %1$d faili jäeti vahele. - Eemalda "%1$s" ja selle %2$d raamatut rakendusest? Ketta faile ei kustutata. - Eemalda "%1$s" ja selle %2$d raamatud rakendusest? Ketta faile ei kustutata. + Kas eemaldada "%1$s" ja selle %2$d raamat rakendusest? Kettal olevaid faile ei kustutata. + Kas eemaldada "%1$s" ja selle %2$d raamatut rakendusest? Kettal olevaid faile ei kustutata. - Kausta sünkroonimine ebaõnnestus %1$d kausta. - Kausta sünkroonimine ebaõnnestus %1$d kaustad. + %1$d kausta sünkroonimine nurjus. + %1$d kausta sünkroonimine nurjus. - Kausta sünkroonimine on lõpetatud %1$d kaust jäi vahele. - Kausta sünkroonimine on lõpetatud %1$d kaustad vahele jäetud. + Kausta sünkroonimine lõppes, %1$d kaust jäeti vahele. + Kausta sünkroonimine lõppes, %1$d kausta jäeti vahele. - Eemaldatud %1$d voogesitatud OPDS raamat sellest kataloogist. - Eemaldatud %1$d voogesitatud OPDS raamatud sellest kataloogist. + Sellest kataloogist eemaldati %1$d voogedastatud OPDS-raamat. + Sellest kataloogist eemaldati %1$d voogedastatud OPDS-raamatut. - %1$d tag - %1$d sildid + %1$d silt + %1$d silti Kõik raamatud %1$d @@ -133,7 +133,7 @@ Kaustad %1$d - (%1$d tükk) - (%1$d tükid) + (%1$d osa) + (%1$d osa) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index c9f585f..0522653 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -4,25 +4,25 @@ Salvesta Kustuta Eemalda - Sobib + OK Sulge Lisa Muuda nime Tagasi Otsi - Selge + Tühjenda Rakenda Luba Viga: %1$s Mine tagasi - Tasuta + Vaba Aktiivsed vahelehed Kuva vahekaardid ülemisel rakenduseribal Sule vahekaart - Sulgege kõik vahelehed + Sulge kõik vahelehed Kas sulgeda kõik vahelehed? - Kas olete kindel, et soovite sulgeda kõik aktiivsed vahelehed? - %1$s nõustute meie %2$s ja kinnitage, et olete lugenud meie %3$s. + Kas sulgeda kõik aktiivsed vahelehed? + %1$s nõustud meie %2$s ja kinnitad, et oled lugenud meie %3$s. Kasutustingimused Privaatsuspoliitika Litsentsid @@ -30,58 +30,58 @@ Tühjenda valik Kinnita/vabasta Info - Valige Kõik + Vali kõik Eemalda hiljutiste hulgast - 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. + 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. Faili teave Raamatu nimi Kopeeri nimi Algne nimi: %1$s - Taastage originaal + Taasta algne nimi Faili nimi: %1$s Autor Vorming Suurus Lisatud Asukoht - Allikas: OPDS Voog + Allikas: OPDS-voog Rakendusesisene salvestusruum Sisemälu - Umbes Episteme - Versioon: %1$s (Järgmine: %2$d) - Valige fail + Teave Episteme kohta + Versioon: %1$s (järk: %2$d) + Vali fail Kas kustutada kõik sünkroonitud andmed? - 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. + 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. KUSTUTA KÕIK ANDMED Kodu Raamatukogu Viimased failid - Teie raamatukogu on tühi - Valige lugemiseks fail või sünkroonige kohalik kaust, et raamatuid automaatselt importida. + Raamatukogu on tühi + Vali lugemiseks fail või sünkrooni kohalik kaust, et raamatud automaatselt importida. Viimaseid faile pole - Avage fail oma teegist, et seda siin näha. + Ava fail raamatukogust, et seda siin näha. Kausta sünkroonimise seadistamine Sünkrooni kaust Kohalik kaust Kinnitatud %1$d%% täielik Pole kohapeal saadaval - Logige sisse rakendusega Google + Logi Google’iga sisse Sisse logides Episteme Pro Uuenda versioonile Episteme Pro Sünkrooni raamatukogu Pilvesünkroonimine kohalike kaustade jaoks - Laadige raamatud oma sünkroonitud kaustadest üles kausta Google Drive. + Laadi sünkroonitud kaustade raamatud Google Drive’i üles. Kohandatud fondid - Toetage projekti + Toeta projekti Abi ja tagasiside Logi välja Viimaste failide limiit Piiramata %1$d failid Tühjenda raamatu vahemälu - Tühjendage reflow vahemälu + Tühjenda reflow-vahemälu Raamatukogu Otsi pealkirja või autorit… Tüübid: %1$s @@ -92,28 +92,28 @@ Kaustad Kataloogid Päringule \"%1$s\" ei leitud tulemusi - Valige PDF, EPUB, MOBI või AZW3 alustamiseks oma seadmest faili. + Alustamiseks vali seadmest PDF-, EPUB-, MOBI- või AZW3-fail. Lisa fail Uus riiul - Looge uus riiul + Loo uus riiul Riiuli nimi Loo - Nimetage riiul ümber + Nimeta riiul ümber Kustuta riiul - Lisage raamatuid + Lisa raamatuid See riiul on tühi Lisa %1$s LISA (%1$d) Pole ühtegi riiulita raamatut, mida lisada Kõik raamatud on juba sellel riiulil - Nimetage riiul ümber + Nimeta riiul ümber Kas kustutada riiul? - Kas soovite kindlasti kustutada faili \'%1$s\' riiul? Kõik raamatud teisaldatakse riiulitele. + Kas kustutada riiul "%1$s"? Kõik raamatud teisaldatakse riiulita raamatute alla. Kas eemaldada riiulist? Kustuta %1$s? - Kas soovite kindlasti kustutada %1$d valitud %2$s? Kõik sees olevad raamatud teisaldatakse jaotisesse Riiulita. - Sünkroonige kohalikud kaustad - Reaalajas teegi loomiseks ühendage kohalikud kaustad. Episteme jälgib faile ja sünkroonimise edenemist. + Kas kustutada %1$d valitud %2$s? Kõik sees olevad raamatud teisaldatakse riiulita raamatute alla. + Sünkrooni kohalikud kaustad + Ühenda kohalikud kaustad, et luua reaalajas raamatukogu. Episteme jälgib faile ja sünkroonib lugemisjärge. Lisa kaust Skanni kõik Skannimine… @@ -126,24 +126,24 @@ Luba kohalik sünkroonimine Kohalik sünkroonimine on keelatud Kas keelata kohaliku kausta sünkroonimine? - Episteme lõpetab selle kausta skannimise ja kirjutamise JSON failide sünkroonimine. Eemaldage %1$s kaust ka sellest kaustast? - Sünkrooni andmed - Eemaldage sünkroonimisandmed + Episteme lõpetab selle kausta skannimise ja sünkroonimise JSON-failidesse kirjutamise. Kas eemaldada kaustast ka %1$s kaust? + Hoia sünkroonimisandmed + Eemalda sünkroonimisandmed Filtreeri failitüübid - Valige failitüübid, mida soovite sellest kaustast sünkroonida: - Filtri raamatukogu + Vali failitüübid, mida sellest kaustast sünkroonida: + Filtreeri raamatukogu Faili tüüp Allikakaust - Loe olekut - Kustuta kõik + Lugemise olek + Tühjenda kõik Rakendusesisene salvestusruum Kas salvestada fail? - 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). - Ära\'ära küsi uuesti - Hoidke raamatukogus + 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.) + Ära küsi uuesti + Hoia raamatukogus Eemalda Küsi iga kord - Hoidke alati + Hoia alati Eemalda alati Välise faili käitumine Lisa kataloog @@ -155,29 +155,29 @@ Pole saadaval Laadi alla Laadi alla vorming - Voogesitage kohe - Lugege + Voogedasta kohe + Loe Toetatud vorminguid pole saadaval. VÄLJAANDJA AVALDATUD KEEL Sisukokkuvõte Redigeeri kataloogi - Lisa OPDS Kataloog + Lisa OPDS-kataloog Kataloogi nimi URL Autentimine (valikuline) Kasutajanimi Parool Kustuta kataloog - Kas soovite kindlasti kustutada \'%1$s\'? - Selle kataloogi kustutamisel eemaldatakse jäädavalt ka %1$d sellega seotud raamatute voogesitamine teie kogust. + Kas oled kindel, et soovid kustutada \'%1$s\'? + Selle kataloogi kustutamisel eemaldatakse raamatukogust jäädavalt ka %1$d sellega seotud voogedastatud raamatut. Eelseadistatud Tasuta plaan Igavesti tasuta Mitu vormingut - Toed PDF, EPUB, MOBI, AZW3 - Android Tekst kõneks + Toetab vorminguid PDF, EPUB, MOBI ja AZW3 + Androidi tekst kõneks Kuulake oma raamatuid sisseehitatud TTS Põhisõnastik Otsige kiiresti üles üksikud sõnad @@ -188,25 +188,25 @@ Varajase juurdepääsu müük Omadused: Pilvesünkroonimine seadmete vahel - Hoidke kogu oma kogu, sealhulgas raamatufailid ja lugemised, sünkroonituna kuni neljas seadmes. + Hoia kogu oma kogu, sealhulgas raamatufailid ja lugemised, sünkroonituna kuni neljas seadmes. Kokkuvõte Saate päevas 10 tasuta kokkuvõtet peatükkide või lehtede kohta Nutikas sõnastik Otsige fraase ja isegi lõike, mitte ainult üksikuid sõnu Prioriteetsete funktsioonide taotlused - Teie ettepanekud seatakse prioriteediks + Sinu ettepanekud seatakse prioriteediks Pro funktsioonid on lukustamata! Sisselogimine Nõutav Ostu kinnitamine… Olemasolev ost leitud - Hankige eluaegne juurdepääs + Hangi eluaegne juurdepääs Uuendamine pole praegu saadaval. Kontrollige oma Internetti ja proovige uuesti. - Logige sisse oma Google konto ostmiseks Episteme Pro. - Logige sisse oma Google konto krediidi ostmiseks. - See võib võtta mõne hetke. Teie Pro staatust värskendatakse automaatselt. - Sellel seadmel on juba Pro-ost, kuid see\' on lingitud teise kontoga. Pro funktsioonide taastamiseks logige sisse kontole, mida kasutati algsel ostul. + Logi sisse oma Google konto ostmiseks Episteme Pro. + Logi sisse oma Google konto krediidi ostmiseks. + See võib võtta mõne hetke. Pro-olekut värskendatakse automaatselt. + Selles seadmes on juba Pro-ost, kuid see on seotud teise kontoga. Pro-funktsioonide taastamiseks logi sisse kontoga, millega algne ost tehti. Te\'toodate Episteme Pro meie varase juurdepääsu perioodil erisoodushinnaga! See on piiratud aja pakkumine. - Logige sisse oma Google konto ostmiseks Episteme Pro ja avage kõik esmaklassilised funktsioonid. + Logi Google’i kontoga sisse, et osta Episteme Pro ja avada kõik premium-funktsioonid. Mitte praegu Selge! Kohandatud fondid @@ -218,40 +218,40 @@ No fonts found matching \'%1$s\' Juba alla laaditud Kohandatud fonte pole - Importige TTF- või OTF-faile, et neid oma raamatutes kasutada. + Impordi TTF- või OTF-faile, et neid oma raamatutes kasutada. Eelvaade pole saadaval (kehtetu fondifail) Kas kustutada font? - Kas soovite kindlasti kustutada \'%1$s\'? Kui sünkroonimine on sisse lülitatud, eemaldatakse see kõigist teie seadmetest. + Kas kustutada "%1$s"? Kui sünkroonimine on sisse lülitatud, eemaldatakse see kõigist seadmetest. Kas kustutada fondid? - Kas soovite kindlasti kustutada %1$d valitud fonte? Kui sünkroonimine on sisse lülitatud, eemaldatakse need kõigist teie seadmetest. + Kas kustutada %1$d valitud fonti? Kui sünkroonimine on sisse lülitatud, eemaldatakse need kõigist seadmetest. Võtke ühendust - Kas leidsite vea, teil on funktsioonitaotlus või soovite lihtsalt tere öelda? Andke meile teada GitHubis või saatke meile e-kiri. + Leidsid vea, sul on funktsioonisoov või tahad lihtsalt tere öelda? Anna GitHubis teada või saada meile e-kiri. GitHubi probleemid Teatage vigadest, taotlege funktsioone ja jälgige arenduse edenemist. Meili tugi Muude päringute korral võtke meiega otse e-posti teel ühendust. - Toetage projekti + Toeta projekti Aidake hoida Episteme liigub - Teie tugi aitab mul hoida ja täiustada Episteme kõigile!!! + Sinu tugi aitab Epistemet kõigi jaoks hoida ja täiustada. Sponsor GitHubis - Toetage arendust otse GitHubi sponsorite kaudu. Tänutäheks saate projekti repos README hüüdlause. + Toeta arendust otse GitHubi sponsorite kaudu. Tänutäheks saate projekti repos README hüüdlause. Liituge Patreoniga 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. - Avage Episteme Pro - Seadmetevaheline sünkroonimine on Pro funktsioon. Avage kõik professionaalsed funktsioonid ühe ühekordse ostuga. + Ava Episteme Pro + Seadmetevaheline sünkroonimine on Pro-funktsioon. Ava kõik Pro-funktsioonid ühe ühekordse ostuga. Uuendage Kinnitage väljalogimine - Kas olete kindel, et soovite välja logida? + Kas logida välja? Seadme limiit on saavutatud - Kasutamiseks Episteme Pro selles seadmes eemaldage üks oma olemasolevatest registreeritud seadmetest. + Episteme Pro kasutamiseks selles seadmes eemalda üks olemasolevatest registreeritud seadmetest. Viimati nähtud: %1$s Kinnitage hävitav tegevus - 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? + 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? Tühjenda raamatu vahemälu 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. Kinnita ja kustuta - Tühjendage reflow vahemälu - 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. + Tühjenda reflow-vahemälu + 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. Tagasi Sõnastik Rohkem valikuid @@ -267,7 +267,7 @@ Luba tume režiim Keela tume režiim Lukusta panoraam - Avage panoraam + Ava panoraamimine Täisekraan Kuva esiletõstmised Peida esiletõstmised @@ -280,37 +280,37 @@ Eelmine tulemus Järgmine tulemus Väljuge lugejast ja naaske avakuvale - Valige sõnade otsimiseks eelistatud rakendus + Vali sõnade otsimiseks eelistatud rakendus Juurdepääs lugemisrežiimile, järjehoidjatele ja täpsematele seadetele Lohistage, et hüpata kiiresti dokumendi mis tahes lehele Sirvige peatükke ja navigeerige mis tahes jaotisesse Reguleerige fonti, suurust, rea kõrgust, joondamist ja kohandatud fonte Otsige sellest raamatust üles mis tahes sõna või fraas Tehke praegusest peatükist või leheküljest kokkuvõte, kasutades AI - Lugege raamatut ette oma seadme\'s häälemootori abil + Loe raamatut ette oma seadme\'s häälemootori abil Peatage praegune ettelugemise seanss Peatage praegune etteloetud taasesitus - Jätkake peatatud ettelugemisega taasesitust + Jätka peatatud ettelugemist Inverteerida PDF värvid tumeda režiimi jaoks Keela tume režiim ja taasta originaal PDF värvid - Lukustage lehel horisontaalne panoraam - Avage panoraam, et uuesti lubada suumimiseks ja lohistamiseks kokkusurutud liigutused + Lukusta lehel horisontaalne panoraamimine + Ava panoraamimine, et lubada uuesti suumimis- ja lohistusliigutused Peitke kõik kasutajaliidese juhtnupud, et näha kaasahaaravat ja häireteta lugemisvaadet Märkige visuaalselt valitud tekstipiirkonnad praegusel lehel - Eemaldage lehelt valitav tekstiülekate + Eemalda lehelt valitav tekstiülekate Lisage tinti või tekstimärkusi Lõpetage redigeerimine ja naaske tavalisse lugemisvaatesse Väljuge otsingust ja minge tagasi lugeja juurde Kustutage praegune otsingupäring ja alustage otsast peale - Laiendage paneeli, et näha kõiki otsingu vasteid - Ahendage otsingutulemuste paneel + Laienda paneeli, et näha kõiki otsinguvastuseid + Ahenda otsingutulemuste paneel Hüppa dokumendis eelmisele otsingu vastele Hüppa dokumendis järgmise otsingu vaste juurde Logi sisse - Valige kaust - Valige + Vali kaust + Vali Privaatsuspoliitika • Kasutustingimused • Litsentsid - Teie seade\' ei toeta kaustade valikut. Saate endiselt faile ükshaaval importida. + Sinu seade ei toeta kaustade valimist. Faile saab endiselt ükshaaval importida. Failihaldurit ei leitud. Installige failihalduri rakendus. Allalaaditud %1$s %1$s: %2$s @@ -323,7 +323,7 @@ Ostmisel ilmnes viga. Uuendamine õnnestus! Tere tulemast Pro-sse. Ostu kinnitamine ebaõnnestus. Kui teilt võeti tasu, võtke ühendust klienditoega. - See seade eemaldati teie kontolt. + See seade eemaldati sinu kontolt. Seda seadet ei saanud kinnitada. Palun kontrollige oma ühendust. Seadmete värskendamine ebaõnnestus. Palun proovi uuesti. Säästmine PDF… @@ -332,9 +332,15 @@ Viga salvestamisel PDF: %1$s Originaali salvestamine PDF… Originaal PDF edukalt salvestatud. + Algse faili salvestamine… + Algne fail salvestati. + Faili salvestamisel tekkis viga: %1$s Jagamine: %1$s Jaga PDF + Jaga faili Jagamine ebaõnnestus: %1$s + Lõikelauale kopeerimine nurjus + Parooliga kaitstud PDF-faile ei saa printida Limiit saavutatud: maksimaalne %1$d kaustad lubatud. See kaust on juba sünkroonitud. Lisatud kaust: %1$s @@ -360,7 +366,7 @@ Sisselogimine ebaõnnestus. Palun proovi uuesti. Ei leitud Google konto. See võib juhtuda värske installi korral, proovige mõne aja pärast uuesti. Sisselogimisel ilmnes viga. Kontrollige oma Interneti-ühendust. - Seadmehalduse testimiseks logige sisse. + Seadmehalduse testimiseks logi sisse. Sünkroonimine on Episteme Pro funktsiooni. Pole sisse logitud, ei saa sünkroonida. Pilvesünkroonimine: värskenduste otsimine… @@ -386,7 +392,7 @@ Tulemusi ei leitud. Kokkuvõtte genereerimine… Peatus - Lugege ette + Loe ette Kopeeri Kopeeri lõim Kokkuvõtet ei saanud luua. @@ -404,7 +410,7 @@ Seadme hääleseaded Sulgege seaded Süsteemi vaikeseade - Vastab teie Android süsteemi seaded + Vastab sinu Android süsteemi seaded Valitud Häälte laadimine… Selles seadmes pole hääli saadaval. @@ -450,7 +456,7 @@ Kasutab valitud rakendust sõnastikust otsimiseks. Varurakendus Sõnastiku rakendus - Valige rakendus + Vali rakendus Tõlgi Rakendus, mida kasutatakse valitud teksti tõlkimiseks. Otsi rakendust @@ -466,16 +472,16 @@ Kokkuvõtte genereerimine… Peatüki kokkuvõte Loo kokkuvõte (beeta) - Avage peatüki kokkuvõte + Ava peatüki kokkuvõte Saate mis tahes peatüki lühikokkuvõtteid kasutades Episteme Pro. Selle funktsiooni kasutamise alustamiseks uuendage. Lisateave - Avage nutikas sõnaraamat + Ava nutikas sõnastik 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. Järjehoidja Valitud pesa Kohandage palett Puudutage muutmiseks pesa: - Valige pesa värv: + Vali pesa värv: See peatükk on tühi. Peatükki ei leitud Viga peatüki laadimisel @@ -496,7 +502,7 @@ Helitugevuse nupu kerimine Helitugevuse nupp Lehekülje pööramine Realistlikud leheküljepöörded - Hoidke ekraan sees + Hoia ekraan sees Visuaalsed valikud Ekraani suund Muutke lugemisrežiimi @@ -526,7 +532,7 @@ Mängi Kohalik kiirus Globaalne kiirus - Valige Režiim + Vali režiim Kehtib kõikidele failidele Salvestatud ainult selle faili jaoks Keela muusiku režiim @@ -550,25 +556,25 @@ Otsige üles You haven\'t added any bookmarks yet. Pilte ei leitud. - Laadige pilt alla + Laadi pilt alla Rohkem valikuid järjehoidja jaoks Nimeta järjehoidja ümber Uus nimi Uus pealkiri Kas kustutada järjehoidja? - Kas olete kindel, et soovite selle järjehoidja jäädavalt kustutada? + Kas oled kindel, et soovid selle järjehoidja jäädavalt kustutada? Esiletõsteid veel pole. Tundmatu peatükk Valikud Kas kustutada esiletõst? - Kas olete kindel, et soovite selle esiletõstmise jäädavalt kustutada? + Kas kustutada see esiletõst jäädavalt? Salvestatud %1$s Pilti ei saanud salvestada. Originaal PDF ei leitud. Viga: raamatu sisu ei leitud. Tee: %1$s - Valige esmalt sõnastikurakendus. - Valige esmalt tõlkerakendus. - Valige esmalt otsingurakendus. + Vali esmalt sõnastikurakendus. + Vali esmalt tõlkerakendus. + Vali esmalt otsingurakendus. Selle raamatu jaoks pole peatükke saadaval. Navigeerimine asukohta… Nõutav luba @@ -578,7 +584,7 @@ Põhjendatud joonduse kasutamine leheküljelises režiimis võib küljenduse piirangute tõttu muuta teksti valiku ja esiletõstmised ebatäpseks. ma saan aru Peatükki navigeerimine… - Valige esmalt võrguühenduseta sõnastik. + Vali esmalt võrguühenduseta sõnastik. Raamat pole veel lehekülgedega varustatud. Oodake, kuni raamat on täielikult laaditud. Eelmise peatüki väljalase @@ -594,7 +600,7 @@ Lähtesta Suurus Vahekaugus - Valige Font + Vali Font Eelseaded Imporditud Import failidest @@ -606,20 +612,20 @@ Kaks lehte Esimene leht üksi Alustab esikülje laialivalgumist pärast kaanelehte. - Eemaldage lehtede vahe + Eemalda lehtede vahe Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel. Peida lehenumbri ülekate Eemaldab igalt lehelt väikese lehekülgede arvu sildi. Süsteemi kasutajaliides (oleku- ja navigeerimisribad) Kontrollige seadme\'-süsteemi ribade nähtavust. Ekraani suund - Valige, kas lugeja järgib süsteemi orientatsiooni või eelistab vertikaalset või horisontaalset, kui Android lubab seda. + Vali, kas lugeja järgib süsteemi orientatsiooni või eelistab vertikaalset või horisontaalset, kui Android lubab seda. Edenemisriba Lugemise edenemise ja peatüki indikaator lugemisekraanil. positsioon Peatükkide sujuv üleminek - Laadige lõpust mööda kerides kohe järgmine/eelmine peatükk, ilma tõmmake värskendamiseks animatsioonita. - Eemaldage serva polsterdus + Laadi lõpust mööda kerides kohe järgmine/eelmine peatükk, ilma tõmmake värskendamiseks animatsioonita. + Eemalda servapolsterdus Eemaldab horisontaalse vahe vasakust ja paremast servast. Heledus Kasutage süsteemi heledust @@ -636,10 +642,10 @@ Versioon %1$s Ehitamine %1$s Sirvige lähtekoodi, tärniga, kahvliga ja teatage probleemidest. - Kuidas me teie andmeid käsitleme. + Kuidas me sinu andmeid käsitleme. Kasutustingimused. Kasutatud avatud lähtekoodiga teegid. - Importimine %1$d raamatud… Need ilmuvad peagi teie kogusse. + Importimine %1$d raamatud… Need ilmuvad peagi sinu kogusse. Loodi riiul "%1$s". Loodi nutikas riiul "%1$s". Riiul nimetati ümber "%1$s". @@ -678,7 +684,7 @@ Hääle reguleerimine Kiirus (%1$sx) Kõrgus (%1$sx) - Nii kõlavad teie praegused hääleseaded. + Nii kõlavad sinu praegused hääleseaded. Peata raamat Jätkamise raamat Süsteemi hääle/mootori sätted @@ -688,7 +694,7 @@ Salvestatud ainult selle faili jaoks Kerige üles Kohandage tööriistariba - 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. + Vali tööriistad, mida nähtavana hoida. Tööriista märke eemaldamine peidab selle kasutajaliidesest, et lugemisruum oleks häirimatu. Märkused Kõik Märkmetega @@ -706,8 +712,8 @@ Võta tagasi Tee uuesti Näita dokki - Valige Fontide perekond - Valige Fondi suurus + Vali fondipere + Vali Fondi suurus Fondi taust Paks Kursiiv @@ -731,7 +737,7 @@ Sisesta tühi leht Kustuta leht Tekib… %1$d%% - Avage tekstivaade + Ava tekstivaade Loo tekstivaade Jaga Salvesta koopia seadmesse @@ -743,11 +749,11 @@ %1$d+ Lehekülgi Lehe kokkuvõte (lehekülg %1$d) Allalaadimine %1$s keelepakett… - Valige OCR Keel - Paremate tekstituvastustulemuste saamiseks valige selle dokumendi esmane keel/skript. + Vali OCR Keel + Paremate tekstituvastustulemuste saamiseks vali selle dokumendi peamine keel/kiri. Saate seda hiljem muuta jaotises Rohkem valikuid > OCR Keel. Kas dokument uuesti indekseerida? - 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. + 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. Indekseeri uuesti Parooliga kaitstud See dokument on krüpteeritud. Selle vaatamiseks sisestage parool. @@ -758,13 +764,13 @@ You are about to navigate to:\n%1$s Külastage Salvesta seadmesse - Valige salvestamiseks vorming: + Vali salvestamiseks vorming: Koos märkustega Originaal - Valige jagamiseks vorming: + Vali jagamiseks vorming: Ettevalmistus PDF… Lisa PDF vahekaardile - Teisi PDF-e teie teegist ei leitud. + Teisi PDF-e sinu teegist ei leitud. PDF on tühi või seda ei saa kuvada. Leht lisatud aadressil %1$d Leht kustutatud @@ -792,23 +798,24 @@ Luba range failifilter 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? Süsteemi vaikeseade - inglise keel - inglise keel (vaikimisi) + English (inglise) + English (vaikimisi) العربية (araabia) - saksa (saksa) + Deutsch (saksa) türkçe (türgi) Français (prantsuse) Русский (vene) Беларуская (valgevene keel) español (hispaania) - portugali keel (Brasiilia) - itaalia keel (itaalia) + Português (Brasiilia) + Italiano (itaalia) polski (poola) Tiếng Việt (vietnami) 日本語 (jaapani keel) 한국어 (korea) हिन्दी (hindi) 简体中文 (hiina, lihtsustatud) + Eesti Rakenduse teema Välimus Kontrast @@ -828,7 +835,7 @@ Rakenduse teema Rakenduse ikoon Seade - Avage sahtel + Ava sahtel Profiilipilt Profiil Pro funktsioon @@ -874,7 +881,7 @@ Pilve hääled Seadme hääled Pilve vahemälu - Valige Kvaliteetne pilvehääl + Vali Kvaliteetne pilvehääl Puhasta proovid Süsteemi vaikehääl Kasutab seadme sätteid @@ -913,11 +920,11 @@ Ostes, Krediidid otsas 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. - Hankige Pro / lisage krediiti - Avage lehe kokkuvõte - Hankige täpseid kokkuvõtteid mis tahes lehekülje kohta, millel on Episteme Pro. Selle funktsiooni kasutamise alustamiseks uuendage. - Laadige alla Bubble Zoom mudel - Funktsiooni Bubble Zoom kasutamiseks kasutage AI mudel tuleb alla laadida (~134 MB). Kas soovite selle kohe alla laadida? + Hangi Pro / lisa krediiti + Ava lehe kokkuvõte + Hangi Episteme Proga täpseid kokkuvõtteid mis tahes lehekülje kohta. Uuenda, et seda funktsiooni kasutada. + Laadi alla Bubble Zoom mudel + Bubble Zoomi kasutamiseks tuleb alla laadida AI-mudel (~134 MB). Kas laadida see kohe alla? Tõlgi Tagasi lk %1$d Lehekülg %1$d @@ -939,7 +946,7 @@ Lähtestage suum Loo demomärkusi Demo annotatsioonid - Avage pliiatsi mänguväljak + Ava pliiatsi mänguväljak Uus vaheleht Tõstke esile kogu tekst Lülitage redigeerimisrežiim sisse @@ -949,7 +956,7 @@ hindi, marati, sanskriti + inglise keel hiina + inglise keel jaapani + inglise keel - Korea + inglise keel + korea + inglise keel Leht pole saadaval Dokument Loodud @@ -968,7 +975,7 @@ Esita/Paus Lähtestage kiirus Lähtesta helikõrgus - Valige Värv + Vali Värv Sellel raamatul pole kuvatavat sisu. Kopeeritud link Kopeeritud tekst @@ -1000,8 +1007,8 @@ Dokumenti ei laaditud. AI funktsioonid pole võrguühenduseta saadaval OSS ehitada. Turvakaalutlustel blokeeritud. - Valige mudel %1$s aastal AI võtme ja mudeli sätted. - Lisage a %1$s API sisestage AI võtme ja mudeli sätted. + Vali mudel %1$s AI võtmete ja mudelite sätetes. + Lisa %1$s API-võti AI võtmete ja mudelite sätetes. AI pakkuja andis tühja vastuse. AI pakkuja viga: %1$d. %2$s See kokkuvõte vajab Gemini mudelit, kuna valitud Groqi mudelid ei toeta PDF/pildisisendit. @@ -1045,7 +1052,7 @@ Kasutatakse lugude kokkuvõtete genereerimiseks. Kasutab salvestatud Gemini võti. Ainult %1$s on praegu toetatud. Salvesta %1$s võti? - 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. + Pärast salvestamist on nähtavad ainult esimesed 3 ja viimased 3 märki. Hiljem muutmiseks asenda või kustuta võti. Kustuta %1$s võti? Seda teenusepakkujat kasutavad funktsioonid lakkavad töötamast kuni uue võtme salvestamiseni. Võti pole salvestatud @@ -1058,7 +1065,7 @@ Tekstuuriga Kohandatud tahke Kohandatud tekstuuriga - Valige Kohandatud tekstuur + Vali Kohandatud tekstuur Teksti heledus (hele) Teksti heledus (tume) Vaikimisi @@ -1094,7 +1101,7 @@ Peidetud tööriistad Rohkem menüüd Varjatud tööriistad - Pange tööriistad siia + Pane tööriistad siia Lohistage ümberjärjestamiseks Välised rakendused Navigeerimisliugur @@ -1145,7 +1152,7 @@ tühi tekst Holland (hollandi) Українська (ukraina) - indoneesia (indoneesia) + Bahasa Indonesia (indoneesia) Umbes Lauaarvuti lugeja Juurdepääs töölauale @@ -1159,10 +1166,10 @@ Vahemälu: %1$s Vahemällu salvestatud Vahemällu salvestatud kokkuvõte - Valige Gemini hääl, mida kasutatakse pilve ettelugemiseks. + Vali Gemini hääl, mida kasutatakse pilve ettelugemiseks. Kustutage loodud töölauaraamat ja EPUB lehekülgede vahemälu failid? Järgmisel raamatute avamisel luuakse need uuesti. - Tühjendage hääle vahemälu - Sulgege tööriistad + Tühjenda hääle vahemälu + Sulge tööriistad Pilvesünkroonimine Pilv TTS vajab Gemini Pilv TTS vajab sisselogitud krediiti @@ -1179,12 +1186,12 @@ Imporditud fondid lugeja jaoks Kustuta font Kustuta %1$s? Seda kasutavad raamatud naasevad vaikefondile. - Kas kustutada \"%1$s\"? Raamatud jäävad teie raamatukogusse. + Kas kustutada \"%1$s\"? Raamatud jäävad sinu raamatukogusse. Kustuta kokkuvõte Keelatud - Pukseerige failid importimiseks - Eemaldage importimiseks toetatud failid - Kui soovite midagi muud, võtke meiega otse e-posti teel ühendust. + Pukseeri failid importimiseks + Eemalda importimiseks toetatud failid + Kui vajad midagi muud, võta meiega otse e-posti teel ühendust. Võrdub Lisad Tagasiside @@ -1195,36 +1202,36 @@ Tasuta, %1$d vasakule Loo kokkuvõte Loo kokkuvõte - Teatage vigadest, taotlege funktsioone või võtke otse ühendust toega. + Teata vigadest, taotle funktsioone või võta toega otse ühendust. GitHubi sponsorid - Toetage arengut GitHubi sponsorite kaudu. + Toeta arendust GitHub Sponsorsi kaudu. Google sisselogimine pole selle töölauajärgu jaoks konfigureeritud. Suurem kui Abi Veaaruanded, funktsioonitaotlused ja tugi Peida - Importige faile + Impordi faile Probleemid - Avage probleemide jälgija vigade ja funktsioonitaotluste jaoks. + Ava probleemide jälgija vigade ja funktsioonisoovide jaoks. Vähem kui Raamatukogu ja lugeja Ükskõik milline Raamatukogu tegevused Rohkem Selle raamatu kohta pole veel vahemällu salvestatud kokkuvõtteid. - Importige TTF-, OTF- või WOFF2-faile, et neid raamatutes kasutada. + Impordi TTF-, OTF- või WOFF2-faile, et neid raamatutes kasutada. Ei leitud fonte, mis vastavad \"%1$s\" Nr Google konto on ühendatud. Selle jaotise kohta pole vahemällu salvestatud kokkuvõtet. Võrguühenduseta lauaarvuti lugeja Avatud lugejad Avamine %1$s - Teie raamatukogu avamine + Raamatukogu avamine Operaator Lehekülg Parooliga kaitstud PDF Patreon - Toetage projekti Patreonis. + Toeta projekti Patreonis. Peatatud %1$s nõuab enne avamist parooli. Parool on nõutav või vale. @@ -1269,13 +1276,13 @@ Vaade Hääle vahemälu Manustatud veebivaate ettevalmistamine… - Preparing bundled embedded webview %1$d%% + Pakitud manustatud veebivaate ettevalmistamine %1$d%% Manustatud veebivaade installitud. Taaskäivitage Episteme seadistamise lõpetamiseks. - Embedded webview could not start: %1$s + Manustatud veebivaadet ei saanud käivitada: %1$s Töötab… Tööruum Lisa riiulile - Create a shelf first, then add selected books to it. + Loo esmalt riiul ja lisa siis valitud raamatud sinna. Loo teema Olemasolev: %1$s Klõpsasite välisel lingil. @@ -1291,20 +1298,20 @@ Märkuste valikud Märkuste tegemise tööriistad Abi - Valige, milline PDF päästa. + Vali, milline PDF päästa. Tühjenda hüppeajalugu Pilv TTS ebaõnnestunud. - Lisage a Gemini klahvi ja valige Gemini pilv TTS aastal AI võtmed ja mudelid. + Lisa Gemini võti ja vali Gemini pilve-TTS jaotises AI võtmed ja mudelid. Pilv TTS pole selle töölaua järgu jaoks konfigureeritud. - Logige sisse rakendusega Google to use cloud TTS. - Pilv TTS needs a signed-in account with credits. Pro ja krediite saab osta ainult veebisaidilt Android rakendus. + Pilve-TTS-i kasutamiseks logi Google’iga sisse. + Pilve-TTS vajab sisselogitud krediitidega kontot. Pro ja krediite saab osta ainult Androidi rakendusest. Värv Kommentaaride valikud Kohandatud See eemaldab märkuse sellelt PDF. Kas kustutada märkus? Dokumendi tekst - Manustatud PDF kommenteerida + Manustatud PDF-i kommentaar Lehe renderdamine ebaõnnestus. Funktsioon pole saadaval Valmis @@ -1330,14 +1337,14 @@ There is no text to summarize. Ava kommentaar Krediidid otsas. Pro ja krediite saab osta ainult Android rakendus. - Pilve kasutamine TTS needs credits on desktop. Pro ja krediite saab osta ainult veebisaidilt Android rakendus. - Using this feature needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus. - Using recaps needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus. - Using summaries needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus. - Pan - PDF tegevus ebaõnnestus - PDF action could not be completed. - PDF kommenteerida + Pilve-TTS vajab töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest. + See funktsioon vajab töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest. + Kokkuvõtted vajavad töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest. + Kokkuvõtted vajavad töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest. + Panoraami + PDF-i toiming ebaõnnestus + PDF-i toimingut ei saanud lõpule viia. + PDF-i kommentaar lk. %1$d PDF leht %1$d Lehekülg %1$d - %2$s @@ -1359,15 +1366,15 @@ Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel. Ümmargune highlighter Salvestatud asukohta %1$s - Kerige + Keri Otsi: PDF - Valige tekst + Vali tekst Valitud %1$s Kuva otsingutulemused - Logige sisse rakendusega Google selle funktsiooni kasutamiseks töölaual. - Logige sisse rakendusega Google mitmesõnalise nutika sõnastiku kasutamiseks töölaual. - Logige sisse rakendusega Google töölaual kokkuvõtete kasutamiseks. - Logige sisse rakendusega Google töölaual kokkuvõtete kasutamiseks. + Logi sisse rakendusega Google selle funktsiooni kasutamiseks töölaual. + Logi sisse rakendusega Google mitmesõnalise nutika sõnastiku kasutamiseks töölaual. + Logi sisse rakendusega Google töölaual kokkuvõtete kasutamiseks. + Logi sisse rakendusega Google töölaual kokkuvõtete kasutamiseks. Peatatud Tekstimärkus tekstimärkus @@ -1394,13 +1401,13 @@ Kaust Sirvige Kategooriad - Ch. %1$d - Peatükk Pöörded - Valige font - Valige lugeja tekstuur + Ptk %1$d + Peatüki pöörded + Vali font + Vali lugeja tekstuur Kustuta failitüübid Lehekülje märkuste kustutamine - Selged allikad + Tühjenda allikad Tühjenda olek Tühjenda sildid Sule lugeja @@ -1427,16 +1434,16 @@ %1$s - %2$s Peida filtrid Peida lugeja tööriistad - Puudutage pesa ja seejärel valige värv. - Jätkake lugemist ja hiljutisi raamatuid - Importige raamatuid + Puuduta pesa ja vali värv. + Jätka lugemist ja vaata hiljutisi raamatuid + Impordi raamatuid Impordi kaust Imporditud fondid %1$s %2$s Suurendada %1$s Hüppe ajalugu Paigutus ja vahekaugus - Importige failid rakenduste salvestusruumi või lisage failide lugemiseks kaust. + Impordi failid rakenduse salvestusruumi või lisa failide lugemiseks kaust. Sirvige oma kollektsiooni AI võtmed Nutikas %1$d diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 722414e..4e0fcf7 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1512,4 +1512,6 @@ Chỉ thay thế nội dung được đọc Văn bản trình đọc, tô sáng và vị trí vẫn không đổi. %1$s -> %2$s + Không thể sao chép vào khay nhớ tạm + Không thể in tệp PDF được bảo vệ bằng mật khẩu diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1d0bd06..f8c6274 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -201,8 +201,13 @@ Keep in Library Remove Ask Every Time + After closing an externally opened file, ask whether to keep it in the library or remove it. Always Keep + Externally opened files are copied into the library and kept after closing. Always Remove + Externally opened files are copied for reading, then removed after closing. + Open Temporarily + Open directly from the source app in a temporary reader. Back returns to that app without adding the file to the library. External File Behavior @@ -480,10 +485,14 @@ Saving original PDF… Original PDF saved successfully. + Saving original file... + Original file saved successfully. + Error saving file: %1$s Sharing: %1$s Share PDF + Share file Share failed: %1$s @@ -1054,6 +1063,8 @@ Could not open print settings + Could not copy to clipboard + Password protected PDF files cannot be printed Loading PDF… @@ -1194,6 +1205,7 @@ 한국어 (Korean) हिन्दी (Hindi) 简体中文 (Chinese, Simplified) + Eesti (Estonian) App Theme diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 2be1632..f07cfbb 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -19,4 +19,5 @@ + diff --git a/app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt b/app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt index 189b743..a2e3520 100644 --- a/app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt +++ b/app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt @@ -12,16 +12,28 @@ class AndroidStringFormatResourcesTest { @Test 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 baseNames = readResourceNames( stringsFile = File(resDirectory, "values/strings.xml"), includeNonTranslatable = false ) - val vietnameseNames = readResourceNames(File(resDirectory, "values-vi/strings.xml")) - val missingNames = baseNames.filterNot { it in vietnameseNames } + val localizedNames = readResourceNames(File(resDirectory, "$localeDirectory/strings.xml")) + val missingNames = baseNames.filterNot { it in localizedNames } assertTrue( - "Missing Vietnamese strings:\n${missingNames.joinToString(separator = "\n")}", + "Missing $localeName strings:\n${missingNames.joinToString(separator = "\n")}", missingNames.isEmpty() ) } diff --git a/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt b/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt index bc4cd30..bc5815a 100644 --- a/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt +++ b/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt @@ -15,11 +15,11 @@ class AppLanguageOptionsTest { assertEquals( listOf( "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 } ) - assertEquals(R.string.language_chinese_simplified, supportedAppLanguageOptions.last().labelRes) + assertEquals(R.string.language_estonian, supportedAppLanguageOptions.last().labelRes) } @Test @@ -51,6 +51,7 @@ class AppLanguageOptionsTest { val vietnamese = supportedAppLanguageOptions.first { it.tag == "vi" } val japanese = supportedAppLanguageOptions.first { it.tag == "ja" } 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(dutch.matchesLanguageSearch(label = "Nederlands", query = "dutch")) @@ -66,6 +67,7 @@ class AppLanguageOptionsTest { assertTrue(vietnamese.matchesLanguageSearch(label = "Tiếng Việt", query = "tieng viet")) assertTrue(japanese.matchesLanguageSearch(label = "日本語", query = "nihongo")) assertTrue(korean.matchesLanguageSearch(label = "한국어", query = "hangul")) + assertTrue(estonian.matchesLanguageSearch(label = "Eesti", query = "eesti")) } @Test diff --git a/app/src/test/java/com/aryan/reader/ClipboardUtilsTest.kt b/app/src/test/java/com/aryan/reader/ClipboardUtilsTest.kt new file mode 100644 index 0000000..9e83f6d --- /dev/null +++ b/app/src/test/java/com/aryan/reader/ClipboardUtilsTest.kt @@ -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") }) + } +} diff --git a/app/src/test/java/com/aryan/reader/ExternalFileOpenRouteDeciderTest.kt b/app/src/test/java/com/aryan/reader/ExternalFileOpenRouteDeciderTest.kt new file mode 100644 index 0000000..0a9e992 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/ExternalFileOpenRouteDeciderTest.kt @@ -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) + ) + } + } +} diff --git a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt index 182a5b7..0b533e2 100644 --- a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt +++ b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt @@ -1,6 +1,7 @@ package com.aryan.reader import android.app.Application +import android.content.ContentResolver import android.content.SharedPreferences import android.content.res.Resources import android.net.Uri @@ -22,6 +23,7 @@ import com.aryan.reader.tts.TtsPlaybackManager import io.mockk.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -45,6 +47,7 @@ class MainViewModelTest { private lateinit var mockApplication: Application private lateinit var mockPrefs: SharedPreferences private lateinit var mockEditor: SharedPreferences.Editor + private val prefsStringSets = mutableMapOf>() private val billingStateFlow = MutableStateFlow(ProUpgradeState()) private val customFontsFlow = MutableStateFlow>(emptyList()) @@ -64,6 +67,12 @@ class MainViewModelTest { } private class TestMainViewModel(application: Application) : MainViewModel(application) { + val locallyCleanedBookIds = mutableListOf() + + override suspend fun cleanupBookDataLocally(bookId: String) { + locallyCleanedBookIds += bookId + } + fun clearForTest() { ViewModel::class.java .getDeclaredMethod("clear\$lifecycle_viewmodel_release") @@ -83,6 +92,7 @@ class MainViewModelTest { billingStateFlow.value = ProUpgradeState() customFontsFlow.value = emptyList() ttsStateFlow.value = TtsPlaybackManager.TtsState() + prefsStringSets.clear() mockkStatic(Log::class) every { Log.isLoggable(any(), any()) } returns false @@ -109,9 +119,14 @@ class MainViewModelTest { every { mockApplication.filesDir } returns filesDir every { mockApplication.cacheDir } returns cacheDir every { mockApplication.getExternalFilesDir(any()) } returns externalFilesDir + every { mockApplication.getString(any()) } answers { "res-${firstArg()}" } + every { mockApplication.getString(any(), *anyVararg()) } answers { "res-${firstArg()}" } every { mockPrefs.edit() } returns mockEditor every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? } + every { mockPrefs.getStringSet(any(), any()) } answers { + prefsStringSets[firstArg()]?.toMutableSet() ?: secondArg?>()?.toMutableSet() + } every { mockPrefs.getBoolean(any(), any()) } answers { secondArg() as Boolean } every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int } every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float } @@ -174,6 +189,7 @@ class MainViewModelTest { coEvery { anyConstructed().addBooksToShelf(any(), any()) } just Runs coEvery { anyConstructed().deleteShelf(any()) } just Runs coEvery { anyConstructed().deleteFilePermanently(any()) } just Runs + coEvery { anyConstructed().addRecentFile(any()) } just Runs coEvery { anyConstructed().deleteBookByUriString(any()) } returns true every { anyConstructed().getAllFonts() } returns customFontsFlow @@ -417,38 +433,39 @@ class MainViewModelTest { viewModel.setStrictFileFilter(true) viewModel.setUsePdfFileNameAsDisplayName(true) viewModel.setExternalFileBehavior("KEEP") + viewModel.setExternalFileBehavior("TEMPORARY") val state = viewModel.uiState.first { - it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "KEEP" + it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "TEMPORARY" } assertTrue(state.useStrictFileFilter) assertTrue(state.usePdfFileNameAsDisplayName) - assertEquals("KEEP", state.externalFileBehavior) + assertEquals("TEMPORARY", state.externalFileBehavior) verify { mockEditor.putBoolean("use_strict_file_filter", 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", "TEMPORARY") } } @Test 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 pendingEntry = """{"bookId":"external-book","uriString":"$pendingUri"}""" - every { - mockPrefs.getStringSet("pending_external_file_removals", any()) - } returns mutableSetOf(pendingEntry) + prefsStringSets["pending_external_file_removals"] = setOf(pendingEntry) every { mockPrefs.getString("last_open_book_id", null) } returns "external-book" every { mockPrefs.getString("last_open_file_type", null) } returns FileType.EPUB.name val restored = TestMainViewModel(mockApplication) try { advanceUntilIdle() - - coVerify { + coVerify(timeout = 1_000) { anyConstructed().deleteFilePermanently(listOf("external-book")) } + coVerify { anyConstructed().deleteBookByUriString(pendingUri) } + assertEquals(listOf("external-book"), restored.locallyCleanedBookIds) verify(atLeast = 1) { mockEditor.remove("last_open_book_id") } verify(atLeast = 1) { mockEditor.remove("last_open_file_type") } 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() + every { mockApplication.contentResolver } returns resolver + every { resolver.getType(externalUri) } returns "application/pdf" + every { resolver.query(externalUri, null, null, null, null) } returns null + coEvery { anyConstructed().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().importBook(any()) } + coVerify(exactly = 0) { anyConstructed().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().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().deleteFilePermanently(listOf(item.bookId)) } + coVerify(exactly = 0) { anyConstructed().deleteBookByUriString(item.uriString!!) } + assertTrue(finishEvent.isCompleted) + } + @Test fun `screen capture protection persists and updates state`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { diff --git a/app/src/test/java/com/aryan/reader/ReaderPopupSizingTest.kt b/app/src/test/java/com/aryan/reader/ReaderPopupSizingTest.kt new file mode 100644 index 0000000..01b5a95 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/ReaderPopupSizingTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/aryan/reader/data/ImportedFontFileNameTest.kt b/app/src/test/java/com/aryan/reader/data/ImportedFontFileNameTest.kt new file mode 100644 index 0000000..8c7dbcf --- /dev/null +++ b/app/src/test/java/com/aryan/reader/data/ImportedFontFileNameTest.kt @@ -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.")) + } +} diff --git a/app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt b/app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt index d279997..61ac1b7 100644 --- a/app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt +++ b/app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt @@ -105,6 +105,26 @@ class RecentFileDaoReadingPositionTest { 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 { return RecentFileEntity( bookId = "book-1", diff --git a/app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt b/app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt index 1b96604..d1fa26c 100644 --- a/app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt +++ b/app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt @@ -104,6 +104,30 @@ class EpubParserUnitTest { 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 fun `metadata only extraction streams images to disk without retaining image bytes`() { val cacheDir = temp.newFolder("cache-metadata-stream") @@ -449,6 +473,50 @@ class EpubParserUnitTest { "OEBPS/images/unlisted.png" to "not-real-image" ) + private fun mergedVolumeTocEpubBytes(): ByteArray = zipBytes( + "META-INF/container.xml" to """ + + """.trimIndent(), + "OEBPS/content.opf" to """ + + Merged Volumes + + + + + + + + + + + + + + + + """.trimIndent(), + "OEBPS/1/toc.ncx" to """ + + Volume 1 + + """.trimIndent(), + "OEBPS/toc.ncx" to """ + + Volume 1 + Chapter 1 + + Volume 2 + Chapter 2 + + + """.trimIndent(), + "OEBPS/1/title.xhtml" to "

HTML Volume 1

Volume one.

", + "OEBPS/1/chapter1.xhtml" to "

HTML Chapter 1

Chapter one.

", + "OEBPS/2/title.xhtml" to "

HTML Volume 2

Volume two.

", + "OEBPS/2/chapter1.xhtml" to "

HTML Chapter 2

Chapter two.

" + ) + private fun minimalEpubBytesWithoutOptionalMetadata(): ByteArray = zipBytes( "META-INF/container.xml" to """ diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderTtsHighlightAssetTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderTtsHighlightAssetTest.kt new file mode 100644 index 0000000..afbc6a1 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderTtsHighlightAssetTest.kt @@ -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}") + } +} diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubTtsChunkMatchingTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubTtsChunkMatchingTest.kt index 6883660..2d46fde 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubTtsChunkMatchingTest.kt +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubTtsChunkMatchingTest.kt @@ -62,4 +62,45 @@ class EpubTtsChunkMatchingTest { 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" + ) + ) + } } diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/AndroidEpubKeyCommandsTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/AndroidEpubKeyCommandsTest.kt new file mode 100644 index 0000000..0357e3e --- /dev/null +++ b/app/src/test/java/com/aryan/reader/paginatedreader/AndroidEpubKeyCommandsTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/EpubFontFaceSiblingsTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/EpubFontFaceSiblingsTest.kt new file mode 100644 index 0000000..df92e2c --- /dev/null +++ b/app/src/test/java/com/aryan/reader/paginatedreader/EpubFontFaceSiblingsTest.kt @@ -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() + } + } +} diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/NativeVerticalLocationTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/NativeVerticalLocationTest.kt index 47f088e..67de172 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/NativeVerticalLocationTest.kt +++ b/app/src/test/java/com/aryan/reader/paginatedreader/NativeVerticalLocationTest.kt @@ -1,6 +1,7 @@ package com.aryan.reader.paginatedreader import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class NativeVerticalLocationTest { @@ -27,4 +28,157 @@ class NativeVerticalLocationTest { assertEquals(2, nativeVerticalProgressToItemIndex(weights, 25f)) 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("""""", (model as SvgData).content) + } + + @Test + fun `native vertical svg data uri decoding preserves plus signs`() { + assertEquals( + """""", + 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 + ) + } } diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/ReaderNavigationTargetsTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/ReaderNavigationTargetsTest.kt index aa45dc9..9ea5f5f 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/ReaderNavigationTargetsTest.kt +++ b/app/src/test/java/com/aryan/reader/paginatedreader/ReaderNavigationTargetsTest.kt @@ -76,7 +76,7 @@ class ReaderNavigationTargetsTest { @Test fun `native vertical initial prefetch is bounded around requested chapter`() { assertEquals( - listOf(4, 5, 2), + listOf(4, 5), nativeVerticalInitialChapterPrefetchOrder(chapterCount = 6, initialChapter = 3) ) } diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt index 3bbdfda..531b6e7 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt @@ -111,6 +111,20 @@ class PdfReaderCoreLogicTest { 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 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 { @@ -383,6 +397,32 @@ class PdfReaderCoreLogicTest { 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 fun `canUsePdfSidecarsForBook only accepts loaded sidecars for active book`() { assertTrue(canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = true)) diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt index 2009edd..1078858 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt @@ -281,6 +281,23 @@ class PdfReaderSettingsAndSharedModelsTest { 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 fun `pdf overflow sections expose file info only when available and visible`() { val visibleSections = pdfOverflowMenuSections( diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfZoomLockStateTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfZoomLockStateTest.kt index db34456..357a5be 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfZoomLockStateTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfZoomLockStateTest.kt @@ -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 fun `page change preserves locked zoom scale only in paginated lock mode`() { val lockedState = Triple(2.25f, -12f, 32f) diff --git a/app/src/test/java/com/aryan/reader/tts/TtsChunkNavigationTest.kt b/app/src/test/java/com/aryan/reader/tts/TtsChunkNavigationTest.kt index dcf513a..1e579cd 100644 --- a/app/src/test/java/com/aryan/reader/tts/TtsChunkNavigationTest.kt +++ b/app/src/test/java/com/aryan/reader/tts/TtsChunkNavigationTest.kt @@ -7,6 +7,7 @@ import org.junit.Test import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder +import java.util.concurrent.ConcurrentHashMap class TtsChunkNavigationTest { @Test @@ -53,6 +54,25 @@ class TtsChunkNavigationTest { 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 fun `transition prefetch is deferred only for the rebuilding generation`() { assertEquals(false, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = 6)) @@ -150,6 +170,16 @@ class TtsChunkNavigationTest { assertNull(estimateTtsNotificationDurationMs(text = " ")) } + @Test + fun `stable sorted snapshot copies concurrent cache keys`() { + val cache = ConcurrentHashMap() + cache[3] = "three" + cache[1] = "one" + cache[2] = "two" + + assertEquals(listOf(1, 2, 3), stableSortedIntSnapshot(cache.keys)) + } + @Test fun `wav file duration is read from pcm byte rate`() { val file = createTempWavFile(pcmBytes = 48_000) diff --git a/build.gradle.kts b/build.gradle.kts index 32c90e6..5568021 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,3 +8,24 @@ plugins { alias(libs.plugins.compose.multiplatform) 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().configureEach { + maxHeapSize = "4g" + } +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index a411702..dbb906a 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,19 +1,31 @@ import org.gradle.api.GradleException import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.Exec 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.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.Sync 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.process.ExecOperations import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.gradle.work.DisableCachingByDefault 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.Files import java.nio.file.StandardCopyOption @@ -21,6 +33,8 @@ import java.util.Properties import java.util.zip.ZipEntry import java.util.zip.ZipFile import java.util.zip.ZipOutputStream +import javax.imageio.ImageIO +import javax.inject.Inject plugins { 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 + + @get:Input + abstract val publisher: Property + + @get:Input + abstract val publisherDisplayName: Property + + @get:Input + abstract val packageName: Property + + @get:Input + abstract val packageDescription: Property + + @get:Input + abstract val packageVersion: Property + + @get:Input + abstract val architecture: Property + + @get:Input + abstract val executablePath: Property + + @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( + """ + + + + + ${xmlEscaped(packageName.get())} + ${xmlEscaped(publisherDisplayName.get())} + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + """.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 + + @get:Input + abstract val hostOsId: Property + + @get:Input + abstract val hostArchId: Property + + @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=." + ) + } + + 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 + + @get:Input + abstract val certificatePassword: Property + + @get:Input + abstract val timestampUrl: Property + + @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=." + ) + } + + 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.") abstract class GenerateDesktopCloudConfigTask : DefaultTask() { @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 + + @get:Input + abstract val providedPackageName: Property + + @get:Input + abstract val packageVersion: Property + + @get:Input + abstract val packageRelease: Property + + @get:Input + abstract val packageDescription: Property + + @get:Input + abstract val appDisplayName: Property + + @get:Input + abstract val installDirectoryName: Property + + @get:Input + abstract val launcherName: Property + + @get:Input + abstract val executableName: Property + + @get:Input + abstract val sourceUrl: Property + + @get:Input + abstract val projectUrl: Property + + @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 { + 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 { + 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.") abstract class StripInvalidJarSignaturesTask : DefaultTask() { @get:Input @@ -546,6 +1067,79 @@ fun normalizeDesktopPackageFormats( 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? { + return candidates.filter { it.isFile }.maxByOrNull { it.absolutePath } +} + +fun windowsSdkToolCandidates(toolName: String): List { + 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 desktopFlavor = providers.gradleProperty("desktopFlavor") .orElse("standard") @@ -565,19 +1159,49 @@ val desktopPackageVersion = providers.gradleProperty("desktopPackageVersion") .orElse(desktopResolvedVersionName) .map(::normalizeDesktopPackageVersion) val desktopPackageName = if (isOssOfflineDesktop) "Episteme oss" else "Episteme" +val desktopLinuxPackageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme" val desktopPackageDescription = if (isOssOfflineDesktop) { "Episteme oss offline desktop reader" } else { "Episteme desktop reader" } 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 desktopOsArch = System.getProperty("os.arch") 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") .orElse(desktopDefaultPackageFormats(desktopOsName)) .map { normalizeDesktopPackageFormats(it, desktopOsName) } .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") && desktopArchId(desktopOsArch) == "x64" val desktopReleaseProguardEnabled = providers.gradleProperty("desktopReleaseProguard") @@ -688,6 +1312,127 @@ val verifyDesktopNativePackaging by tasks.registering(VerifyDesktopNativePackagi 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("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("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 { jvm("desktop") jvmToolchain(21) @@ -771,7 +1516,7 @@ compose.desktop { } linux { iconFile.set(desktopLinuxIconFile) - packageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme" + packageName = desktopLinuxPackageName debMaintainer = "epistemereader@gmail.com" menuGroup = "Office" appCategory = "Office" @@ -818,6 +1563,7 @@ tasks.matching { "packageReleaseDistributionForCurrentOS", "packageReleaseExe", "packageReleaseMsi", + "packageReleaseMsix", "packageReleaseDeb", "packageReleaseRpm", "runReleaseDistributable" @@ -853,10 +1599,16 @@ tasks.matching { "packageReleaseExe", "packageMsi", "packageReleaseMsi", + "prepareReleaseMsixPackage", + "packageReleaseMsix", + "signReleaseMsix", "packageDeb", "packageReleaseDeb", "packageRpm", "packageReleaseRpm", + "packageLinuxTar", + "prepareAurPackage", + "packageAur", "runDistributable", "runReleaseDistributable" ) diff --git a/desktopApp/packaging/README.md b/desktopApp/packaging/README.md new file mode 100644 index 0000000..65cd664 --- /dev/null +++ b/desktopApp/packaging/README.md @@ -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= ` + -PdesktopMsixPublisher= ` + -PdesktopMsixPublisherDisplayName= ` + :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\\x64\makeappx.exe" ` + :desktopApp:packageReleaseMsix -x test +``` + +Local signing is optional and separate: + +```powershell +.\gradlew.bat ` + -PdesktopMsixCertificatePath=C:\path\to\certificate.pfx ` + -PdesktopMsixCertificatePassword= ` + :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--.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-.tar.gz +aur-episteme-oss-bin-.tar.gz +``` diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDialogs.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDialogs.kt index da09c30..92ba0b6 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDialogs.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDialogs.kt @@ -77,6 +77,16 @@ internal fun chooseSaveImageFile(defaultFileName: String): 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? { val chooser = JFileChooser().apply { dialogTitle = desktopDialogString("desktop_import_folder", "Import folder") diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLanguageSettings.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLanguageSettings.kt index d8d9bc2..5382def 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLanguageSettings.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLanguageSettings.kt @@ -58,7 +58,8 @@ internal val DesktopLanguageOptions = listOf( DesktopLanguageOption("zh-CN", "language_chinese_simplified", "Chinese, Simplified"), DesktopLanguageOption("nl", "language_dutch", "Dutch"), 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 { diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt index c7da3af..007ffc4 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt @@ -35,6 +35,7 @@ import com.aryan.reader.shared.AppAction import com.aryan.reader.shared.BannerMessage import com.aryan.reader.shared.BookItem import com.aryan.reader.shared.ReaderPlatform +import com.aryan.reader.shared.SharedFileCapabilities import com.aryan.reader.shared.SharedFolderPathResolver import com.aryan.reader.shared.SharedReaderScreenState 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" } } +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( enriched: BookItem, original: BookItem? @@ -203,7 +222,8 @@ internal fun LibraryScreen( onImportFolder: () -> Unit, onSyncFolderMetadata: () -> Unit, onScanFolders: () -> Unit, - onTogglePinned: (BookItem) -> Unit + onTogglePinned: (BookItem) -> Unit, + onSaveOriginalFile: (BookItem) -> Unit = {} ) { SharedLibraryScreen( state = state, @@ -231,6 +251,7 @@ internal fun LibraryScreen( onSyncFolderMetadata = onSyncFolderMetadata, onScanFolders = onScanFolders, onTogglePinned = onTogglePinned, + onSaveOriginalFile = onSaveOriginalFile, platform = ReaderPlatform.DESKTOP, useImportEmptyStateWhenLibraryEmpty = true ) diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt index 8c09fa3..defaf8f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt @@ -7,11 +7,44 @@ import com.aryan.reader.shared.AppFontPreferenceKind import com.aryan.reader.shared.CustomFontItem import com.aryan.reader.shared.reader.ReaderPage 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 internal fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily { 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() + 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() } @@ -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.samePageLayoutAs(other: List): Boolean { if (size != other.size) return false return indices.all { index -> diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt index 4a4bca3..6861db2 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt @@ -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() { scope.launch { withContext(Dispatchers.IO) { @@ -4200,7 +4217,8 @@ internal fun EpistemeDesktopApp( onManageShelfBooks = { shelfToManageBooks = it }, onSyncFolderMetadata = { syncFolderMetadata() }, 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( @@ -4249,7 +4267,8 @@ internal fun EpistemeDesktopApp( onManageShelfBooks = { shelfToManageBooks = it }, onSyncFolderMetadata = { syncFolderMetadata() }, onScanFolders = { scanSyncedFolders() }, - onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) } + onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }, + onSaveOriginalFile = ::saveDesktopOriginalFile ) SharedAppTab.CATALOGS -> { diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAurPackagingMetadataTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAurPackagingMetadataTest.kt new file mode 100644 index 0000000..c26b565 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAurPackagingMetadataTest.kt @@ -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() + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStringResourcesTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStringResourcesTest.kt index 8ba9ae7..b3c1536 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStringResourcesTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStringResourcesTest.kt @@ -127,6 +127,14 @@ class DesktopStringResourcesTest { assertEquals("language_portuguese_brazilian", option.labelKey) } + @Test + fun resolvesSelectedDesktopLanguageOptionForEstonian() { + val option = selectedDesktopLanguageOption("et") + + assertEquals("et", option.normalizedTag) + assertEquals("language_estonian", option.labelKey) + } + @Test fun desktopLanguageSettingsStorePersistsLanguageAcrossInstances() { val tempDirectory = Files.createTempDirectory("episteme-desktop-language-test") diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/scripts/desktop/download-pdfium.ps1 b/scripts/desktop/download-pdfium.ps1 new file mode 100644 index 0000000..4a040e4 --- /dev/null +++ b/scripts/desktop/download-pdfium.ps1 @@ -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 + } +} diff --git a/scripts/desktop/publish-pdfium-release.ps1 b/scripts/desktop/publish-pdfium-release.ps1 new file mode 100644 index 0000000..7fa30ce --- /dev/null +++ b/scripts/desktop/publish-pdfium-release.ps1 @@ -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 + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a27ab25..093a81c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,6 +23,20 @@ dependencyResolutionManagement { } rootProject.name = "Reader" -include(":app") + +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(":shared") include(":desktopApp") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index c044eb4..b231b97 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,20 +1,40 @@ +import com.android.build.api.dsl.LibraryExtension + plugins { alias(libs.plugins.kotlin.multiplatform) - id("com.android.library") + alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlin.serialization) 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 { - androidTarget() + if (!desktopOnlyBuild) { + androidTarget() + } jvm("desktop") jvmToolchain(21) sourceSets { val commonMain by getting - val androidMain by getting val desktopMain by getting val readerJvmMain by creating { dependsOn(commonMain) @@ -22,7 +42,10 @@ kotlin { implementation("org.jsoup:jsoup:1.17.2") } } - androidMain.dependsOn(readerJvmMain) + if (!desktopOnlyBuild) { + val androidMain by getting + androidMain.dependsOn(readerJvmMain) + } desktopMain.dependsOn(readerJvmMain) commonMain.dependencies { @@ -40,15 +63,17 @@ kotlin { } } -android { - namespace = "com.aryan.reader.shared" - compileSdk = 36 +if (!desktopOnlyBuild) { + extensions.configure("android") { + namespace = "com.aryan.reader.shared" + compileSdk = 36 - defaultConfig { - minSdk = 26 - } + defaultConfig { + minSdk = 26 + } - buildFeatures { - buildConfig = true + buildFeatures { + buildConfig = true + } } } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/CustomFontModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/CustomFontModels.kt index 95d1551..f868fb2 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/CustomFontModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/CustomFontModels.kt @@ -1,5 +1,8 @@ package com.aryan.reader.shared +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight + data class CustomFontItem( val id: String, val displayName: String, @@ -10,3 +13,76 @@ data class CustomFontItem( val isDeleted: Boolean = false ) +data class CustomFontVariantItem( + val font: CustomFontItem, + val variant: FontVariant? +) + +data class CustomFontFamilyItem( + val familyName: String, + val variants: List +) + +fun List.groupByFamily(): List { + 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 { + 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() + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/FontVariantInference.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/FontVariantInference.kt new file mode 100644 index 0000000..cbcc7d8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/FontVariantInference.kt @@ -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.variantTokenIndexes(): Set { + val indexes = mutableSetOf() + 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.containsCompoundToken(compoundTokens: Set): Boolean { + return windowed(size = 2, step = 1, partialWindows = false) + .any { (first, second) -> "$first$second" in compoundTokens } +} + +private fun List.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.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 { + 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 diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModels.kt index f25e446..ae6d21c 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModels.kt @@ -239,7 +239,9 @@ internal enum class NonReaderLibraryPrimaryAction { } internal enum class NonReaderBookOverflowAction { - ADD_TO_SHELF + ADD_TO_SHELF, + SAVE_ORIGINAL, + SHARE_ORIGINAL } internal fun visibleNonReaderLibraryTabs( @@ -266,8 +268,14 @@ internal fun bookOverflowActionsForPlatform( platform: ReaderPlatform = ReaderPlatform.ANDROID ): Set { return when (platform) { - ReaderPlatform.DESKTOP -> setOf(NonReaderBookOverflowAction.ADD_TO_SHELF) - ReaderPlatform.ANDROID -> emptySet() + ReaderPlatform.DESKTOP -> setOf( + NonReaderBookOverflowAction.ADD_TO_SHELF, + NonReaderBookOverflowAction.SAVE_ORIGINAL + ) + ReaderPlatform.ANDROID -> setOf( + NonReaderBookOverflowAction.SAVE_ORIGINAL, + NonReaderBookOverflowAction.SHARE_ORIGINAL + ) } } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt index 0a31527..5993a45 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt @@ -56,6 +56,8 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Search 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.Tag import androidx.compose.material3.AssistChip @@ -165,6 +167,8 @@ fun SharedHomeScreen( onRemoveSelected: () -> Unit, onShowBookInfo: (BookItem) -> Unit = {}, onEditBook: (BookItem) -> Unit = {}, + onSaveOriginalFile: (BookItem) -> Unit = {}, + onShareOriginalFile: (BookItem) -> Unit = {}, onTagSelectedBooks: () -> Unit = {}, onAddSelectedBooksToShelf: () -> Unit = {}, onOpenTab: (BookItem) -> Unit = onOpenBook, @@ -173,6 +177,7 @@ fun SharedHomeScreen( onRecentLimitChange: (Int) -> Unit = {}, onTogglePinned: (BookItem) -> Unit = {}, onOpenSettings: () -> Unit = {}, + platform: ReaderPlatform = ReaderPlatform.ANDROID, showActiveTabs: Boolean = true, modifier: Modifier = Modifier ) { @@ -188,6 +193,16 @@ fun SharedHomeScreen( ) { 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( title = readerString("nav_home", "Home"), subtitle = readerString("desktop_home_subtitle", "Continue reading and recent books"), @@ -268,6 +283,8 @@ fun SharedHomeScreen( onOpenBook = { onOpenBook(book) }, onShowBookInfo = { onShowBookInfo(book) }, onEditBook = { onEditBook(book) }, + onSaveOriginalFile = saveOriginalFileAction?.let { save -> { save(book) } }, + onShareOriginalFile = shareOriginalFileAction?.let { share -> { share(book) } }, onTogglePinned = { onTogglePinned(book) } ) } @@ -294,6 +311,8 @@ fun SharedHomeScreen( onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onSaveOriginalFile = saveOriginalFileAction, + onShareOriginalFile = shareOriginalFileAction, onTogglePinned = onTogglePinned ) } @@ -309,6 +328,8 @@ fun SharedHomeScreen( onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onSaveOriginalFile = saveOriginalFileAction, + onShareOriginalFile = shareOriginalFileAction, onTogglePinned = onTogglePinned ) } @@ -331,6 +352,8 @@ fun SharedLibraryScreen( onRemoveSelected: () -> Unit, onShowBookInfo: (BookItem) -> Unit = {}, onEditBook: (BookItem) -> Unit = {}, + onSaveOriginalFile: (BookItem) -> Unit = {}, + onShareOriginalFile: (BookItem) -> Unit = {}, onCreateShelf: () -> Unit = {}, onCreateShelfWithBooks: (String, Set) -> Unit = { _, _ -> }, onCreateSmartShelf: () -> Unit = {}, @@ -446,6 +469,8 @@ fun SharedLibraryScreen( onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onSaveOriginalFile = onSaveOriginalFile, + onShareOriginalFile = onShareOriginalFile, onTogglePinned = onTogglePinned, onAddBooksToShelf = onAddBooksToShelf, onManageShelfBooks = onManageShelfBooks, @@ -495,6 +520,8 @@ fun SharedLibraryScreen( onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onSaveOriginalFile = onSaveOriginalFile, + onShareOriginalFile = onShareOriginalFile, onTogglePinned = onTogglePinned, onAddBooksToShelf = onAddBooksToShelf, onManageShelfBooks = onManageShelfBooks, @@ -601,8 +628,11 @@ private fun ContinueReadingCard( onOpenBook: () -> Unit, onShowBookInfo: () -> Unit, onEditBook: () -> Unit, + onSaveOriginalFile: (() -> Unit)?, + onShareOriginalFile: (() -> Unit)?, onTogglePinned: () -> Unit ) { + val canUseOriginalFileActions = !book.isOpdsStream() && book.path != null Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(SharedUiTokens.surfaceRadius), @@ -643,6 +673,16 @@ private fun ContinueReadingCard( IconButton(onClick = onEditBook) { 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, onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, + onSaveOriginalFile: ((BookItem) -> Unit)?, + onShareOriginalFile: ((BookItem) -> Unit)?, onTogglePinned: (BookItem) -> Unit ) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -674,6 +716,8 @@ private fun HomeBookShelf( onToggleSelection = { onToggleSelection(book.id) }, onShowInfo = { onShowBookInfo(book) }, onEdit = { onEditBook(book) }, + onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } }, + onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } }, onTogglePinned = { onTogglePinned(book) }, modifier = Modifier.width(168.dp) ) @@ -1226,6 +1270,8 @@ private fun LibraryContent( onToggleSelection: (String) -> Unit, onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, + onSaveOriginalFile: (BookItem) -> Unit, + onShareOriginalFile: (BookItem) -> Unit, onTogglePinned: (BookItem) -> Unit, onAddBooksToShelf: (Set) -> Unit, onManageShelfBooks: ((Shelf) -> Unit)?, @@ -1262,6 +1308,16 @@ private fun LibraryContent( } else { 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 showNewShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in primaryLibraryActionsForTab(selectedTab, platform) @@ -1314,6 +1370,8 @@ private fun LibraryContent( onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onSaveOriginalFile = saveOriginalFileAction, + onShareOriginalFile = shareOriginalFileAction, onTogglePinned = onTogglePinned, onAddToShelf = addToShelfFromBookAction?.let { addToShelf -> { book -> addToShelf(setOf(book.id)) } }, modifier = Modifier.weight(1f) @@ -1347,6 +1405,8 @@ private fun LibraryContent( onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onSaveOriginalFile = saveOriginalFileAction, + onShareOriginalFile = shareOriginalFileAction, onTogglePinned = onTogglePinned, onAddBooksToShelf = addToShelfFromBookAction, onManageShelfBooks = manageShelfBooksAction, @@ -1370,6 +1430,8 @@ private fun LibraryContent( onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onSaveOriginalFile = saveOriginalFileAction, + onShareOriginalFile = shareOriginalFileAction, onTogglePinned = onTogglePinned, onAddBooksToShelf = addToShelfFromBookAction, onRenameShelf = onRenameShelf, @@ -1387,6 +1449,8 @@ private fun LibraryContent( onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onSaveOriginalFile = saveOriginalFileAction, + onShareOriginalFile = shareOriginalFileAction, onTogglePinned = onTogglePinned, onAddBooksToShelf = addToShelfFromBookAction, emptyTitle = readerString("desktop_no_tags_yet", "No tags yet"), @@ -1409,6 +1473,8 @@ private fun LibraryContent( onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onSaveOriginalFile = saveOriginalFileAction, + onShareOriginalFile = shareOriginalFileAction, onTogglePinned = onTogglePinned, onAddBooksToShelf = addToShelfFromBookAction, onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) }, @@ -1431,6 +1497,8 @@ private fun LibraryContent( onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onSaveOriginalFile = saveOriginalFileAction, + onShareOriginalFile = shareOriginalFileAction, onTogglePinned = onTogglePinned, onAddBooksToShelf = addToShelfFromBookAction, onRemoveFolder = onRemoveFolder, @@ -1690,6 +1758,8 @@ private fun BookGrid( onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, onTogglePinned: (BookItem) -> Unit, + onSaveOriginalFile: ((BookItem) -> Unit)? = null, + onShareOriginalFile: ((BookItem) -> Unit)? = null, onAddToShelf: ((BookItem) -> Unit)? = null, modifier: Modifier = Modifier ) { @@ -1710,6 +1780,8 @@ private fun BookGrid( onShowInfo = { onShowBookInfo(book) }, onEdit = { onEditBook(book) }, onTogglePinned = { onTogglePinned(book) }, + onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } }, + onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } }, onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } } ) } @@ -1733,6 +1805,8 @@ private fun BookGrid( onShowInfo = { onShowBookInfo(book) }, onEdit = { onEditBook(book) }, onTogglePinned = { onTogglePinned(book) }, + onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } }, + onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } }, onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } } ) } @@ -1752,6 +1826,8 @@ private fun BookTile( onShowInfo: () -> Unit, onEdit: () -> Unit, onTogglePinned: () -> Unit, + onSaveOriginalFile: (() -> Unit)? = null, + onShareOriginalFile: (() -> Unit)? = null, onAddToShelf: (() -> Unit)? = null, modifier: Modifier = Modifier ) { @@ -1803,6 +1879,8 @@ private fun BookTile( onShowInfo = onShowInfo, onEdit = onEdit, onToggleSelection = onToggleSelection, + onSaveOriginalFile = onSaveOriginalFile.takeIf { !book.isOpdsStream() && book.path != null }, + onShareOriginalFile = onShareOriginalFile.takeIf { !book.isOpdsStream() && book.path != null }, onAddToShelf = onAddToShelf ) } @@ -1839,6 +1917,8 @@ private fun BookListItem( onShowInfo: () -> Unit, onEdit: () -> Unit, onTogglePinned: () -> Unit, + onSaveOriginalFile: (() -> Unit)? = null, + onShareOriginalFile: (() -> Unit)? = null, onAddToShelf: (() -> Unit)? = null ) { var menuExpanded by remember { mutableStateOf(false) } @@ -1881,6 +1961,8 @@ private fun BookListItem( onShowInfo = onShowInfo, onEdit = onEdit, onToggleSelection = onToggleSelection, + onSaveOriginalFile = onSaveOriginalFile.takeIf { !book.isOpdsStream() && book.path != null }, + onShareOriginalFile = onShareOriginalFile.takeIf { !book.isOpdsStream() && book.path != null }, onAddToShelf = onAddToShelf ) } @@ -1898,6 +1980,8 @@ private fun BookActionMenu( onShowInfo: () -> Unit, onEdit: () -> Unit, onToggleSelection: () -> Unit, + onSaveOriginalFile: (() -> Unit)? = null, + onShareOriginalFile: (() -> Unit)? = null, onAddToShelf: (() -> Unit)? = null ) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { @@ -1925,6 +2009,26 @@ private fun BookActionMenu( 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) { DropdownMenuItem( leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) }, @@ -2112,6 +2216,8 @@ private fun ShelfCollection( onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, onTogglePinned: (BookItem) -> Unit, + onSaveOriginalFile: ((BookItem) -> Unit)? = null, + onShareOriginalFile: ((BookItem) -> Unit)? = null, onAddBooksToShelf: ((Set) -> Unit)? = null, onManageShelfBooks: ((Shelf) -> Unit)? = null, onRenameShelf: (Shelf) -> Unit = {}, @@ -2151,6 +2257,8 @@ private fun ShelfCollection( onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, onTogglePinned = onTogglePinned, + onSaveOriginalFile = onSaveOriginalFile, + onShareOriginalFile = onShareOriginalFile, onAddBooksToShelf = onAddBooksToShelf, onManageShelfBooks = onManageShelfBooks, onRenameShelf = onRenameShelf, @@ -2172,6 +2280,8 @@ private fun ShelfSection( onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, onTogglePinned: (BookItem) -> Unit, + onSaveOriginalFile: ((BookItem) -> Unit)?, + onShareOriginalFile: ((BookItem) -> Unit)?, onAddBooksToShelf: ((Set) -> Unit)?, onManageShelfBooks: ((Shelf) -> Unit)?, onRenameShelf: (Shelf) -> Unit, @@ -2258,6 +2368,8 @@ private fun ShelfSection( onShowInfo = { onShowBookInfo(book) }, onEdit = { onEditBook(book) }, onTogglePinned = { onTogglePinned(book) }, + onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } }, + onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } }, onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } }, modifier = Modifier.width(148.dp) ) @@ -2279,6 +2391,8 @@ private fun FolderShelfDetail( onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, onTogglePinned: (BookItem) -> Unit, + onSaveOriginalFile: ((BookItem) -> Unit)? = null, + onShareOriginalFile: ((BookItem) -> Unit)? = null, onAddBooksToShelf: ((Set) -> Unit)? = null, onOpenShelf: (Shelf) -> Unit, onBack: () -> Unit, @@ -2345,6 +2459,8 @@ private fun FolderShelfDetail( onShowInfo = { onShowBookInfo(book) }, onEdit = { onEditBook(book) }, onTogglePinned = { onTogglePinned(book) }, + onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } }, + onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } }, onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } } ) } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt index 2946c9b..56fab51 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt @@ -1663,13 +1663,9 @@ private fun List.findSharedNativeVerticalPageIndexForBlock(block: Se return firstOrNull()?.pageIndex } -private fun List.sharedNativeVerticalItemIndexForLocator( +internal fun List.sharedNativeVerticalItemIndexForLocator( locator: ReaderLocator ): Int? { - locator.pageIndex?.let { pageIndex -> - val samePage = indexOfFirst { item -> item.page.pageIndex == pageIndex } - if (samePage >= 0) return samePage - } val chapterIndex = locator.chapterIndex if (chapterIndex != null) { locator.blockIndex?.let { blockIndex -> @@ -1687,6 +1683,12 @@ private fun List.sharedNativeVerticalItemIndexForL } 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 } if (sameChapter >= 0) return sameChapter } @@ -3170,7 +3172,7 @@ private fun SemanticTextBlock.renderedTextStyle( ).takeIf { it.isSpecified } ?: foreground, fontSize = fontSize, lineHeight = lineHeight, - fontFamily = fallbackFontFamily, + fontFamily = style.spanStyle.fontFamily ?: fallbackFontFamily, fontWeight = fontWeight ?: style.spanStyle.fontWeight ?: if (this is SemanticHeader) FontWeight.Bold else MaterialTheme.typography.bodyLarge.fontWeight, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt index 238832f..45f00b6 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt @@ -117,6 +117,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.aryan.reader.shared.BuiltInReaderThemes 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.PageInfoMode import com.aryan.reader.shared.PageInfoPosition @@ -1740,28 +1743,46 @@ fun SharedReaderFormatControls( } } - val activeCustomFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() } - if (activeCustomFonts.isNotEmpty()) { + val activeCustomFontFamilies = customFonts.filterNot { it.isDeleted }.groupByFamily() + if (activeCustomFontFamilies.isNotEmpty()) { Text( readerString("desktop_imported_fonts", "Imported fonts"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) SharedReaderChoiceRow { - activeCustomFonts.forEach { font -> + activeCustomFontFamilies.forEach { family -> + val isSelected = family.variants.any { it.font.path == settings.customFontPath } FilterChip( - selected = settings.customFontPath == font.path, + selected = isSelected, 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( ReaderAction.SettingsChanged( settings.copy( - fontFamily = font.displayName, - customFontPath = font.path + fontFamily = family.familyName, + 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 + ) + } + } + } ) } } diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/FontVariantInferenceTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/FontVariantInferenceTest.kt new file mode 100644 index 0000000..c2d4d76 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/FontVariantInferenceTest.kt @@ -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() + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModelsTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModelsTest.kt index 198d266..52b73e3 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModelsTest.kt @@ -74,12 +74,21 @@ class NonReaderLayoutModelsTest { } @Test - fun `desktop book overflow exposes add to shelf action without changing android`() { + fun `book overflow exposes platform save and share actions`() { assertEquals( - setOf(NonReaderBookOverflowAction.ADD_TO_SHELF), + setOf( + NonReaderBookOverflowAction.ADD_TO_SHELF, + NonReaderBookOverflowAction.SAVE_ORIGINAL + ), bookOverflowActionsForPlatform(ReaderPlatform.DESKTOP) ) - assertEquals(emptySet(), bookOverflowActionsForPlatform(ReaderPlatform.ANDROID)) + assertEquals( + setOf( + NonReaderBookOverflowAction.SAVE_ORIGINAL, + NonReaderBookOverflowAction.SHARE_ORIGINAL + ), + bookOverflowActionsForPlatform(ReaderPlatform.ANDROID) + ) } @Test diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt index 4ee7ec6..14f550b 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt @@ -12,6 +12,7 @@ import com.aryan.reader.paginatedreader.SemanticImage import com.aryan.reader.paginatedreader.SemanticMath import com.aryan.reader.paginatedreader.SemanticParagraph 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.SharedEpubBook import com.aryan.reader.shared.reader.SharedEpubChapter @@ -192,6 +193,57 @@ class SharedNativeVerticalReaderFlowTest { 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 fun `svg math blocks stay in native vertical flow`() { val math = SemanticMath( diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/paginatedreader/HtmlParserLinkTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/paginatedreader/HtmlParserLinkTest.kt index b95c684..83c31e9 100644 --- a/shared/src/desktopTest/kotlin/com/aryan/reader/paginatedreader/HtmlParserLinkTest.kt +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/paginatedreader/HtmlParserLinkTest.kt @@ -1,6 +1,8 @@ package com.aryan.reader.paginatedreader 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.Density import androidx.compose.ui.unit.sp @@ -96,15 +98,53 @@ class HtmlParserLinkTest { }) } - private fun parse(html: String): List { + @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 = """ + + +

plain italic

+ + + """.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 = emptyMap() + ): List { return htmlToSemanticBlocks( html = html, - cssRules = OptimizedCssRules(), + cssRules = cssRules, textStyle = TextStyle(fontSize = 16.sp), chapterAbsPath = "OEBPS/chapter1.xhtml", extractionBasePath = "", density = Density(1f), - fontFamilyMap = emptyMap(), + fontFamilyMap = fontFamilyMap, constraints = Constraints(maxWidth = 400, maxHeight = 800) ) } diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedEpubPaginationCacheTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedEpubPaginationCacheTest.kt index 9a7035f..e41a918 100644 --- a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedEpubPaginationCacheTest.kt +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedEpubPaginationCacheTest.kt @@ -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 { return SharedEpubBook( id = "book-id", diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoaderTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoaderTest.kt index ecd0282..9b0d460 100644 --- a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoaderTest.kt +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoaderTest.kt @@ -223,6 +223,22 @@ class SharedJvmBookLoaderTest { 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) { val dir = Files.createTempDirectory("reader-shared-loader").toFile() try { @@ -305,6 +321,70 @@ class SharedJvmBookLoaderTest { } } + private fun writeMergedVolumeTocEpub(file: File) { + writeZip(file) { + text( + "META-INF/container.xml", + """ + + + + + + """.trimIndent() + ) + text( + "content.opf", + """ + + + Merged Volumes + + + + + + + + + + + + + + + + + """.trimIndent() + ) + text( + "1/toc.ncx", + """ + + Volume 1 + + """.trimIndent() + ) + text( + "toc.ncx", + """ + + Volume 1 + Chapter 1 + + Volume 2 + Chapter 2 + + + """.trimIndent() + ) + text("1/title.xhtml", "

Volume 1

Volume one.

") + text("1/chapter1.xhtml", "

Chapter 1

Chapter one.

") + text("2/title.xhtml", "

Volume 2

Volume two.

") + text("2/chapter1.xhtml", "

Chapter 2

Chapter two.

") + } + } + private fun writeTwoChapterEpub(file: File) { writeZip(file) { text( diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt b/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt index 8a12c5e..02fe2e0 100644 --- a/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt +++ b/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt @@ -213,7 +213,7 @@ private class SemanticHtmlParser( } val body = document.body() - return parseContainer(body, getElementStyle(body)) + return parseContainer(body, getElementStyle(body).withResolvedFontFamily()) } private inline fun Element.anyChildElement(predicate: (Element) -> Boolean): Boolean { @@ -295,7 +295,7 @@ private class SemanticHtmlParser( textEmphasis = elementOwnStyle.textEmphasis ?: inheritedStyle.textEmphasis, whiteSpace = elementOwnStyle.whiteSpace ?: inheritedStyle.whiteSpace, customProperties = inheritedStyle.customProperties + elementOwnStyle.customProperties - ) + ).withResolvedFontFamily() if (finalStyle.display == "none") return emptyList() @@ -364,7 +364,19 @@ private class SemanticHtmlParser( val pseudoStyle = rulesForElement(element, pseudoElement).fold(CssStyle()) { acc, rule -> 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? { @@ -805,7 +817,7 @@ private class SemanticHtmlParser( appendText("\n"); return } val currentElementStyle = getElementStyle(node, inheritedStyle.customProperties) - val newStyle = inheritedStyle.merge(currentElementStyle) + val newStyle = inheritedStyle.merge(currentElementStyle).withResolvedFontFamily() if (newStyle.display == "none") return val tag = node.tagName().lowercase() val href = node.linkHrefOrNull() @@ -969,7 +981,10 @@ private class SemanticHtmlParser( val isOrdered = listElement.tagName().lowercase() == "ol" val items = listElement.children().mapNotNull { child -> 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 imageSrc = itemStyle.blockStyle.listStyleImage?.let { resolveImagePath(it) } 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() 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.blockStyle.backgroundColor.isSpecified) { diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedEpubPaginationCache.kt b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedEpubPaginationCache.kt index 2c446c8..934071c 100644 --- a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedEpubPaginationCache.kt +++ b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedEpubPaginationCache.kt @@ -383,20 +383,29 @@ class SharedEpubPaginationCache( private fun cleanupOldConfigurations(bookHash: String) { val bookDir = File(cacheRoot, bookHash) - val files = bookDir.listFiles { file -> file.isFile && file.name.endsWith(".pages.pb") } - ?.sortedByDescending { it.lastModified() } - .orEmpty() + val files = pageCacheFiles(bookDir) files.drop(3).forEach { file -> file.delete() File(bookDir, file.name.removeSuffix(".pages.pb") + ".chapters").deleteRecursively() } val activeConfigNames = files.take(3).map { it.name.removeSuffix(".pages.pb") }.toSet() - bookDir.listFiles { file -> file.isDirectory && file.name.endsWith(".chapters") } - .orEmpty() + chapterCacheDirs(bookDir) .filterNot { dir -> dir.name.removeSuffix(".chapters") in activeConfigNames } .forEach { it.deleteRecursively() } } + private fun pageCacheFiles(bookDir: File): List { + 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 { + val files = bookDir.listFiles() ?: return emptyList() + return files.filter { file -> file.isDirectory && file.name.endsWith(".chapters") } + } + private fun CachedReaderPages.matches(key: SharedEpubPaginationCacheKey): Boolean { return schemaVersion == SharedEpubPaginationCacheSchemaVersion && processingVersion == SharedEpubPaginationProcessingVersion && diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoader.kt b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoader.kt index 2282444..da53279 100644 --- a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoader.kt +++ b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoader.kt @@ -224,7 +224,7 @@ object SharedJvmBookLoader { "skipped=${!parseSemanticBlocks}" } val tocStartedAt = System.nanoTime() - val tableOfContents = parseEpubTableOfContents(zip, manifest, basePath) + val tableOfContents = parseEpubTableOfContents(zip, opf, manifest, basePath) logJvmBookOpenTrace { "event=epub_toc_loaded file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + "durationMs=${tocStartedAt.jvmBookOpenTraceElapsedMs()} entries=${tableOfContents.size}" @@ -1302,10 +1302,11 @@ object SharedJvmBookLoader { private fun parseEpubTableOfContents( zip: ZipFile, + opf: String, manifest: Map, basePath: String ): List { - val manifestNcxHref = manifest.values.firstOrNull { it.endsWith(".ncx", ignoreCase = true) } + val manifestNcxHref = resolveEpubNcxHref(opf, manifest) val ncxPath = manifestNcxHref ?.let { normalizeZipPath(basePath + it) } ?: zip.entries().asSequence() @@ -1356,6 +1357,18 @@ object SharedJvmBookLoader { return entries } + private fun resolveEpubNcxHref(opf: String, manifest: Map): String? { + Regex("<(?:[^:>]+:)?spine\\b[^>]*>", RegexOption.IGNORE_CASE) + .find(opf) + ?.value + ?.attr("toc") + ?.takeIf { it.isNotBlank() } + ?.let { tocId -> manifest[tocId] } + ?.let { return it } + + return manifest.values.firstOrNull { it.endsWith(".ncx", ignoreCase = true) } + } + private fun loadEpubCss(zip: ZipFile, manifest: Map, basePath: String): Map { return manifest.values .filter { it.endsWith(".css", ignoreCase = true) } diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt index 1d62cc6..ffc651d 100644 --- a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt +++ b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt @@ -1158,6 +1158,7 @@ private fun SemanticTextBlock.textStyle(baseStyle: TextStyle, settings: ReaderSe return baseStyle.copy( fontSize = fontSize, lineHeight = lineHeight, + fontFamily = style.spanStyle.fontFamily ?: baseStyle.fontFamily, fontWeight = if (this is SemanticHeader) FontWeight.Bold else baseStyle.fontWeight, textAlign = resolveSharedReaderTextAlign( cssTextAlign = style.paragraphStyle.textAlign,