Linux support (#381)

* Add desktop release CI and support for Arch Linux packaging

* Make Gradle wrapper executable in desktop-release workflow

* Make Gradle wrapper executable in desktop-release workflow

* Make Gradle wrapper executable in desktop-release workflow

* Configure Gradle and update Java environment in desktop-release workflow

* Update Java setup and AUR packaging in desktop release workflow

* Update Java setup and AUR packaging in desktop release workflow

* Add MSIX packaging support for Windows desktop distribution

* Update AUR packaging metadata and validation

* Use spine toc attribute for NCX resolution

* crash fixes

* Implement automatic discovery and injection of EPUB font face siblings

* Enhance custom font support with family grouping and variable font handling

* Optimize metadata loading and improve TTS highlighting

* Add keyboard navigation support for EPUB reader

* Refine PDF spread page sizing to respect aspect ratios

* Implement responsive maximum height for reader popups and sheets

* Handle TTS generation failures by skipping problematic chunks

* Refactor PDF tile rendering logic and zoom indicator behavior

* Prefer block and offset locators over page index in native vertical flow

* Implement save and share actions for original book files

* Add Estonian language support

* Implement temporary viewing mode for external files

* Implement direct opening for temporary external files without library persistence

* fix failing tests

* Import SharedFileCapabilities in DesktopLibraryUi

* Improve native vertical reader progress, persistence, and image support

* Center target in viewport for native vertical reader and support animated scrolling
This commit is contained in:
Aryan 2026-06-14 13:43:49 +05:30 committed by GitHub
parent a13d6599d1
commit 625a4d5d2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 6012 additions and 687 deletions

409
.github/workflows/desktop-release.yml vendored Normal file
View file

@ -0,0 +1,409 @@
name: Desktop release
on:
workflow_dispatch:
inputs:
version:
description: Desktop version, for example 1.0.1
required: true
default: "1.0.1"
release_tag:
description: GitHub release tag for desktop artifacts
required: true
default: "v1.0.1"
pdfium_tag:
description: GitHub release tag containing Pdfium zip assets
required: true
default: "pdfium-desktop-v1"
draft:
description: Create or update the desktop release as a draft
required: true
default: "true"
type: choice
options:
- "true"
- "false"
permissions:
contents: write
env:
DESKTOP_FIREBASE_PROJECT_ID: ${{ secrets.DESKTOP_FIREBASE_PROJECT_ID }}
DESKTOP_FIREBASE_WEB_API_KEY: ${{ secrets.DESKTOP_FIREBASE_WEB_API_KEY }}
DESKTOP_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.DESKTOP_GOOGLE_OAUTH_CLIENT_ID }}
DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET }}
jobs:
windows-msi:
name: Windows MSI (${{ matrix.flavor.label }})
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
flavor:
- label: standard
gradle: standard
- label: oss
gradle: oss
steps:
- uses: actions/checkout@v4
- name: Configure Gradle
shell: pwsh
run: |
@'
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
kotlin.mpp.applyDefaultHierarchyTemplate=false
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
org.gradle.workers.max=2
'@ | Set-Content -Path gradle.properties -NoNewline -Encoding UTF8
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Download Pdfium
shell: pwsh
run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
env:
GH_TOKEN: ${{ github.token }}
- name: Install WiX
shell: pwsh
run: choco install wixtoolset --no-progress -y
- name: Build MSI
shell: pwsh
env:
DESKTOP_FLAVOR: ${{ matrix.flavor.gradle }}
DESKTOP_VERSION: ${{ inputs.version }}
run: |
$gradleArgs = @(
"-PdesktopOnly=true",
"-PdesktopFlavor=$env:DESKTOP_FLAVOR",
"-PdesktopVersion=$env:DESKTOP_VERSION",
"-PdesktopPackageVersion=$env:DESKTOP_VERSION",
":desktopApp:packageReleaseMsi"
)
./gradlew.bat @gradleArgs
- name: Stage MSI
shell: pwsh
run: |
New-Item -ItemType Directory -Force release-assets | Out-Null
$source = Get-ChildItem desktopApp/build/compose/binaries/main-release/msi -Filter *.msi | Select-Object -First 1
if ($null -eq $source) { throw "MSI output not found" }
Copy-Item $source.FullName "release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-windows-x64.msi"
- uses: actions/upload-artifact@v4
with:
name: windows-msi-${{ matrix.flavor.label }}
path: release-assets/*
ubuntu-deb-tar:
name: Ubuntu DEB and tar (${{ matrix.flavor.label }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
flavor:
- label: standard
gradle: standard
- label: oss
gradle: oss
steps:
- uses: actions/checkout@v4
- name: Configure Gradle
run: |
cat > gradle.properties <<'EOF'
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
kotlin.mpp.applyDefaultHierarchyTemplate=false
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
org.gradle.workers.max=2
EOF
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Make Gradle wrapper executable
run: chmod +x gradlew
- name: Install packaging tools
run: sudo apt-get update && sudo apt-get install -y fakeroot dpkg-dev binutils
- name: Download Pdfium
shell: pwsh
run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
env:
GH_TOKEN: ${{ github.token }}
- name: Build DEB and Linux tarball
run: >
./gradlew
-PdesktopOnly=true
-PdesktopFlavor=${{ matrix.flavor.gradle }}
-PdesktopVersion=${{ inputs.version }}
-PdesktopPackageVersion=${{ inputs.version }}
:desktopApp:packageDeb
:desktopApp:packageLinuxTar
- name: Stage DEB and tarball
run: |
set -euo pipefail
mkdir -p release-assets
cp "$(find desktopApp/build/compose/binaries/main/deb -name '*.deb' -print -quit)" \
"release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-amd64.deb"
cp "$(find desktopApp/build/compose/binaries/main/linux-tar -name '*.tar.gz' -print -quit)" \
"release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x64.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: ubuntu-deb-tar-${{ matrix.flavor.label }}
path: release-assets/*
fedora-rpm:
name: Fedora RPM (${{ matrix.flavor.label }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
flavor:
- label: standard
gradle: standard
- label: oss
gradle: oss
steps:
- uses: actions/checkout@v4
- name: Configure Gradle
run: |
cat > gradle.properties <<'EOF'
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
kotlin.mpp.applyDefaultHierarchyTemplate=false
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
org.gradle.workers.max=2
EOF
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Make Gradle wrapper executable
run: chmod +x gradlew
- name: Download Pdfium
shell: pwsh
run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
env:
GH_TOKEN: ${{ github.token }}
- name: Build RPM in Fedora
run: |
set -euo pipefail
docker run --rm \
-v "$PWD:/workspace" \
-v "$JAVA_HOME:/host-jdk-21:ro" \
-w /workspace \
-e DESKTOP_FIREBASE_PROJECT_ID \
-e DESKTOP_FIREBASE_WEB_API_KEY \
-e DESKTOP_GOOGLE_OAUTH_CLIENT_ID \
-e DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET \
fedora:44 bash -lc '
set -euo pipefail
dnf install -y ca-certificates findutils git rpm-build unzip which
update-ca-trust
export JAVA_HOME=/host-jdk-21
export PATH="$JAVA_HOME/bin:$PATH"
if [ -f /etc/pki/java/cacerts ]; then
export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/etc/pki/java/cacerts ${JAVA_TOOL_OPTIONS:-}"
elif [ -f /etc/pki/ca-trust/extracted/java/cacerts ]; then
export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/etc/pki/ca-trust/extracted/java/cacerts ${JAVA_TOOL_OPTIONS:-}"
fi
JPACKAGE_HOME="$JAVA_HOME"
./gradlew \
-PdesktopOnly=true \
-PdesktopFlavor=${{ matrix.flavor.gradle }} \
-PdesktopVersion=${{ inputs.version }} \
-PdesktopPackageVersion=${{ inputs.version }} \
-PdesktopPackagingJavaHome="$JPACKAGE_HOME" \
:desktopApp:packageRpm
'
sudo chown -R "$USER:$USER" desktopApp/build
- name: Stage RPM
run: |
set -euo pipefail
mkdir -p release-assets
cp "$(find desktopApp/build/compose/binaries/main/rpm -name '*.rpm' -print -quit)" \
"release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x86_64.rpm"
- uses: actions/upload-artifact@v4
with:
name: fedora-rpm-${{ matrix.flavor.label }}
path: release-assets/*
arch-package:
name: Arch package (${{ matrix.flavor.label }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
flavor:
- label: standard
gradle: standard
aur: episteme-bin
- label: oss
gradle: oss
aur: episteme-oss-bin
steps:
- uses: actions/checkout@v4
- name: Configure Gradle
run: |
cat > gradle.properties <<'EOF'
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
kotlin.mpp.applyDefaultHierarchyTemplate=false
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
org.gradle.workers.max=2
EOF
- name: Make Gradle wrapper executable
run: chmod +x gradlew
- name: Download Pdfium
shell: pwsh
run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
env:
GH_TOKEN: ${{ github.token }}
- name: Build Arch package
run: |
set -euo pipefail
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace \
-e DESKTOP_FIREBASE_PROJECT_ID \
-e DESKTOP_FIREBASE_WEB_API_KEY \
-e DESKTOP_GOOGLE_OAUTH_CLIENT_ID \
-e DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET \
archlinux:latest bash -lc '
set -euo pipefail
pacman -Syu --noconfirm
pacman -S --needed --noconfirm base-devel desktop-file-utils git jdk21-openjdk namcap unzip
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
export PATH="$JAVA_HOME/bin:$PATH"
useradd -m builder
chown -R builder:builder /workspace
runuser -u builder -- env \
JAVA_HOME="$JAVA_HOME" \
PATH="$PATH" \
DESKTOP_FIREBASE_PROJECT_ID="$DESKTOP_FIREBASE_PROJECT_ID" \
DESKTOP_FIREBASE_WEB_API_KEY="$DESKTOP_FIREBASE_WEB_API_KEY" \
DESKTOP_GOOGLE_OAUTH_CLIENT_ID="$DESKTOP_GOOGLE_OAUTH_CLIENT_ID" \
DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET="$DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET" \
bash -lc "cd /workspace && ./gradlew \
-PdesktopOnly=true \
-PdesktopFlavor=${{ matrix.flavor.gradle }} \
-PdesktopVersion=${{ inputs.version }} \
-PdesktopPackageVersion=${{ inputs.version }} \
-PdesktopAurPackageRelease=1 \
:desktopApp:prepareAurPackage && \
cd desktopApp/build/aur/${{ matrix.flavor.aur }} && \
makepkg -sf --cleanbuild --nodeps && \
makepkg --printsrcinfo > .SRCINFO.generated && \
cmp -s .SRCINFO .SRCINFO.generated && \
pkgfile=\$(find . -maxdepth 1 -name '*.pkg.tar.zst' -print -quit) && \
desktop_file=${{ matrix.flavor.aur }} && \
desktop_file=\${desktop_file%-bin}.desktop && \
tmpdir=\$(mktemp -d) && \
bsdtar -xOf \"\$pkgfile\" \"usr/share/applications/\$desktop_file\" > \"\$tmpdir/\$desktop_file\" && \
desktop-file-validate \"\$tmpdir/\$desktop_file\" && \
namcap PKGBUILD && \
namcap \"\$pkgfile\" && \
cd /workspace && \
mkdir -p release-assets && \
cp \$(find desktopApp/build/aur/${{ matrix.flavor.aur }} -maxdepth 1 -name '*.pkg.tar.zst' -print -quit) \
release-assets/${{ matrix.flavor.aur }}-${{ inputs.version }}-1-x86_64.pkg.tar.zst && \
./gradlew \
-PdesktopOnly=true \
-PdesktopFlavor=${{ matrix.flavor.gradle }} \
-PdesktopVersion=${{ inputs.version }} \
-PdesktopPackageVersion=${{ inputs.version }} \
-PdesktopAurPackageRelease=1 \
-PdesktopAurSourceUrl=https://github.com/${{ github.repository }}/releases/download/${{ inputs.release_tag }}/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x64.tar.gz \
:desktopApp:prepareAurPackage && \
cd desktopApp/build/aur/${{ matrix.flavor.aur }} && \
makepkg --printsrcinfo > .SRCINFO.generated && \
cmp -s .SRCINFO .SRCINFO.generated"
'
sudo chown -R "$USER:$USER" desktopApp/build release-assets
- name: Stage Arch package and AUR metadata
run: |
set -euo pipefail
mkdir -p release-assets
tar -C "desktopApp/build/aur/${{ matrix.flavor.aur }}" \
-czf "release-assets/aur-${{ matrix.flavor.aur }}-${{ inputs.version }}.tar.gz" \
PKGBUILD .SRCINFO
- uses: actions/upload-artifact@v4
with:
name: arch-package-${{ matrix.flavor.label }}
path: release-assets/*
publish-release:
name: Publish draft release
runs-on: ubuntu-latest
needs:
- windows-msi
- ubuntu-deb-tar
- fedora-rpm
- arch-package
steps:
- uses: actions/download-artifact@v4
with:
path: downloaded-artifacts
- name: Collect release assets
run: |
set -euo pipefail
mkdir -p release-assets
find downloaded-artifacts -type f -exec cp {} release-assets/ \;
(cd release-assets && sha256sum * > SHA256SUMS.txt)
- name: Create or update GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if ! gh release view "${{ inputs.release_tag }}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
draft_flag=""
if [ "${{ inputs.draft }}" = "true" ]; then
draft_flag="--draft"
fi
gh release create "${{ inputs.release_tag }}" \
--repo "${{ github.repository }}" \
--title "Episteme ${{ inputs.version }}" \
--notes "Desktop release ${{ inputs.version }}" \
$draft_flag
fi
gh release upload "${{ inputs.release_tag }}" release-assets/* \
--repo "${{ github.repository }}" \
--clobber

2
.gitignore vendored
View file

@ -25,3 +25,5 @@ cache/
worker/ worker/
output/ output/
policies/ policies/
episteme-bin/
episteme-oss-bin/

12
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2/brotli" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2/brotli/research/esaxx" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/woff2/brotli/research/libdivsufsort" vcs="Git" />
<mapping directory="$PROJECT_DIR$/episteme-bin" vcs="Git" />
<mapping directory="$PROJECT_DIR$/episteme-oss-bin" vcs="Git" />
</component>
</project>

View file

@ -171,6 +171,7 @@ android {
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
unitTests.all { unitTests.all {
it.maxHeapSize = "4g"
it.jvmArgs("-Xss2m") it.jvmArgs("-Xss2m")
} }
} }

View file

@ -320,6 +320,8 @@ class LibraryScreenContentTest {
onItemClick = {}, onItemClick = {},
onItemLongClick = { item -> selectedItems.value = setOf(item) }, onItemLongClick = { item -> selectedItems.value = setOf(item) },
onInfoClick = onInfoClick, onInfoClick = onInfoClick,
onSaveClick = null,
onShareClick = null,
onDeleteClick = onDeleteClick, onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick, onSelectAllClick = onSelectAllClick,
onShelfClick = onShelfClick, onShelfClick = onShelfClick,

View file

@ -55,7 +55,22 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity>
<activity
android:name=".TemporaryExternalFileActivity"
android:exported="false"
android:theme="@style/Theme.Reader"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:excludeFromRecents="true"
android:launchMode="standard" />
<activity
android:name=".ExternalFileOpenRouterActivity"
android:exported="true"
android:theme="@style/Theme.App.Starting"
android:noHistory="true"
android:excludeFromRecents="true">
<!-- PDF --> <!-- PDF -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />

View file

@ -128,8 +128,15 @@
background-color: rgba(255, 236, 179, 0.8); background-color: rgba(255, 236, 179, 0.8);
/* Semi-transparent Gold */ /* Semi-transparent Gold */
color: black !important; color: black !important;
display: inline !important;
text-align: initial !important;
text-align-last: auto !important;
letter-spacing: normal !important;
word-spacing: normal !important;
padding: 0.1em 0; padding: 0.1em 0;
border-radius: 3px; border-radius: 3px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
} }
html.dark-theme span.tts-highlight { html.dark-theme span.tts-highlight {
@ -1496,6 +1503,16 @@
}; };
const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS"; const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS";
const TTS_HIGHLIGHT_BLOCK_SELECTOR = "p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th";
function getTtsHighlightBlock(node) {
if (!node) {
return document.body;
}
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return (element && element.closest(TTS_HIGHLIGHT_BLOCK_SELECTOR)) || document.body;
}
window.highlightFromCfi = function (cfi, textToHighlight, startOffset) { window.highlightFromCfi = function (cfi, textToHighlight, startOffset) {
console.log(`$ { console.log(`$ {
@ -1556,9 +1573,10 @@
, Text content: '${(location.node.textContent || "").substring(0, 50)}...' `); , Text content: '${(location.node.textContent || "").substring(0, 50)}...' `);
const baseNode = location.node; const baseNode = location.node;
const highlightRoot = getTtsHighlightBlock(baseNode);
let remainingOffset = startOffset; let remainingOffset = startOffset;
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); const treeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
treeWalker.currentNode = baseNode; treeWalker.currentNode = baseNode;
let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode(); let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode();
@ -1626,7 +1644,7 @@
} else { } else {
remainingTextLength -= availableLength; remainingTextLength -= availableLength;
// Important: We need a fresh walker starting from the endNode to find the *next* text node reliably // Important: We need a fresh walker starting from the endNode to find the *next* text node reliably
const nextNodeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); const nextNodeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
nextNodeWalker.currentNode = endNode; nextNodeWalker.currentNode = endNode;
endNode = nextNodeWalker.nextNode(); endNode = nextNodeWalker.nextNode();
endOffset = 0; // Start from the beginning of the next node endOffset = 0; // Start from the beginning of the next node
@ -1677,11 +1695,14 @@
TTS_HIGHLIGHT_LOG_TAG TTS_HIGHLIGHT_LOG_TAG
} }
: surroundContents failed, using fallback. Error: $ { : surroundContents failed, using same-block fallback. Error: $ {
e.message e.message
} }
`); `);
if (!highlightRoot.contains(range.commonAncestorContainer)) {
return "JS: Highlight range escaped current block.";
}
const contents = range.extractContents(); const contents = range.extractContents();
highlightSpan.appendChild(contents); highlightSpan.appendChild(contents);
range.insertNode(highlightSpan); range.insertNode(highlightSpan);

View file

@ -220,12 +220,18 @@ fun AppNavigation(
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) { NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
composable(AppDestinations.MAIN_ROUTE) { composable(AppDestinations.MAIN_ROUTE) {
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).") Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
if (uiState.isTemporaryExternalOpen) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
MainScreen( MainScreen(
viewModel = viewModel, viewModel = viewModel,
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,
navController = navController navController = navController
) )
} }
}
// PDF Viewer Screen Composable // PDF Viewer Screen Composable
composable(route = AppDestinations.PDF_VIEWER_ROUTE) { composable(route = AppDestinations.PDF_VIEWER_ROUTE) {

View file

@ -43,6 +43,7 @@ data class ReaderScreenState(
val selectedEpubUri: Uri? = null, val selectedEpubUri: Uri? = null,
val selectedFileType: FileType? = null, val selectedFileType: FileType? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isTemporaryExternalOpen: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val contextualActionItems: Set<RecentFileItem> = emptySet(), val contextualActionItems: Set<RecentFileItem> = emptySet(),
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL, val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,

View file

@ -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
}
}

View file

@ -138,6 +138,7 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@ -862,6 +863,13 @@ fun AiDefinitionPopup(
val ttsController = rememberTtsController() val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState() val ttsState by ttsController.ttsState.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val configuration = LocalConfiguration.current
val maxPopupHeight = readerModalMaxHeightDp(
screenHeightDp = configuration.screenHeightDp,
fraction = 0.65f,
verticalMarginDp = 48,
preferredMinHeightDp = 180
).dp
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -882,7 +890,7 @@ fun AiDefinitionPopup(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 5.dp) .padding(horizontal = 16.dp, vertical = 5.dp)
.heightIn(min = 150.dp, max = 400.dp), .heightIn(max = maxPopupHeight),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) { ) {
@ -3310,12 +3318,16 @@ fun ThemeColorPickerDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false) properties = DialogProperties(usePlatformDefaultWidth = false)
) { ) {
val configuration = LocalConfiguration.current
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
Surface( Surface(
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C), color = Color(0xFF2C2C2C),
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.9f) .fillMaxWidth(0.9f)
.padding(16.dp) .padding(16.dp)
.heightIn(max = maxDialogHeight)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -3495,12 +3507,16 @@ fun HighlightColorPickerDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false) properties = DialogProperties(usePlatformDefaultWidth = false)
) { ) {
val configuration = LocalConfiguration.current
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
Surface( Surface(
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C), color = Color(0xFF2C2C2C),
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.9f) .fillMaxWidth(0.9f)
.padding(16.dp) .padding(16.dp)
.heightIn(max = maxDialogHeight)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier

View file

@ -0,0 +1,57 @@
package com.aryan.reader
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
const val EXTRA_TEMPORARY_EXTERNAL_OPEN = "com.aryan.reader.extra.TEMPORARY_EXTERNAL_OPEN"
object ExternalFileOpenRouteDecider {
const val BEHAVIOR_TEMPORARY = "TEMPORARY"
fun shouldOpenTemporary(externalFileBehavior: String?): Boolean {
return externalFileBehavior == BEHAVIOR_TEMPORARY
}
fun targetActivityClass(externalFileBehavior: String?): Class<out Activity> {
return if (shouldOpenTemporary(externalFileBehavior)) {
TemporaryExternalFileActivity::class.java
} else {
MainActivity::class.java
}
}
}
class ExternalFileOpenRouterActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
routeExternalOpen(intent)
finish()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
routeExternalOpen(intent)
finish()
}
private fun routeExternalOpen(sourceIntent: Intent?) {
if (sourceIntent?.action != Intent.ACTION_VIEW || sourceIntent.data == null) return
val prefs = getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val behavior = prefs.getString("external_file_behavior", "ASK")
val temporary = ExternalFileOpenRouteDecider.shouldOpenTemporary(behavior)
val targetIntent = Intent(sourceIntent).apply {
setClass(this@ExternalFileOpenRouterActivity, ExternalFileOpenRouteDecider.targetActivityClass(behavior))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
if (temporary) {
putExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, true)
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
}
}
startActivity(targetIntent)
}
}
class TemporaryExternalFileActivity : MainActivity()

View file

@ -64,10 +64,17 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aryan.reader.shared.CustomFontItem import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.CustomFontFamilyItem
import com.aryan.reader.shared.CustomFontVariantItem
import com.aryan.reader.shared.fontFaceLabel
import com.aryan.reader.shared.fontFaceSummary
import com.aryan.reader.shared.groupByFamily
import com.aryan.reader.shared.hasVariableWeightFace
import com.aryan.reader.shared.ui.SharedAppFontSelector import com.aryan.reader.shared.ui.SharedAppFontSelector
import com.aryan.reader.shared.ui.SharedFontSettingsSection import com.aryan.reader.shared.ui.SharedFontSettingsSection
import com.aryan.reader.shared.ui.SharedFontSettingsTabs import com.aryan.reader.shared.ui.SharedFontSettingsTabs
@ -97,6 +104,7 @@ fun FontsScreen(
val selectedFonts = remember(fonts, selectedFontIds) { val selectedFonts = remember(fonts, selectedFontIds) {
fonts.filter { it.id in selectedFontIds } fonts.filter { it.id in selectedFontIds }
} }
val fontEntitiesById = remember(fonts) { fonts.associateBy { it.id } }
val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty() val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty()
LaunchedEffect(fonts) { LaunchedEffect(fonts) {
@ -172,6 +180,7 @@ fun FontsScreen(
) { padding -> ) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) { Box(modifier = Modifier.fillMaxSize().padding(padding)) {
val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() } val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
val fontFamilies = remember(sharedFonts) { sharedFonts.groupByFamily() }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
SharedFontSettingsTabs( SharedFontSettingsTabs(
selectedSection = selectedSection, selectedSection = selectedSection,
@ -202,16 +211,20 @@ fun FontsScreen(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items(fonts, key = { it.id }) { font -> items(fontFamilies, key = { family -> family.variants.joinToString("|") { it.font.id } }) { family ->
FontListItem( FontFamilyListItem(
font = font, family = family,
isSelected = font.id in selectedFontIds, selectedFontIds = selectedFontIds,
isSelectionMode = isFontSelectionMode, isSelectionMode = isFontSelectionMode,
onSelectionToggle = { fontEntityForId = { id -> fontEntitiesById[id] },
selectedFontIds = selectedFontIds.toggle(font.id) onVariantSelectionToggle = { id ->
selectedFontIds = selectedFontIds.toggle(id)
}, },
onDelete = { onFamilySelectionToggle = {
fontsPendingDelete = listOf(font) selectedFontIds = selectedFontIds.toggleAll(family.variants.map { it.font.id })
},
onDeleteVariant = { id ->
fontEntitiesById[id]?.let { fontsPendingDelete = listOf(it) }
} }
) )
} }
@ -531,6 +544,188 @@ fun FontListItem(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FontFamilyListItem(
family: CustomFontFamilyItem,
selectedFontIds: Set<String>,
isSelectionMode: Boolean,
fontEntityForId: (String) -> CustomFontEntity?,
onVariantSelectionToggle: (String) -> Unit,
onFamilySelectionToggle: () -> Unit,
onDeleteVariant: (String) -> Unit
) {
val baseFont = remember(family) {
family.variants.firstOrNull { it.fontFaceLabel() == "Regular" }?.font ?: family.variants.first().font
}
val customTypeface = remember(baseFont.path) {
try {
FontFamily(Font(File(baseFont.path)))
} catch (_: Exception) {
null
}
}
val familyFontIds = remember(family) { family.variants.map { it.font.id }.toSet() }
val isSelected = familyFontIds.any { it in selectedFontIds }
val allSelected = familyFontIds.all { it in selectedFontIds }
val faceSummary = remember(family) {
buildString {
append(family.fontFaceSummary())
if (family.hasVariableWeightFace()) append(" - Variable weight")
append(" - ${family.variants.size} file")
if (family.variants.size != 1) append("s")
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
if (isSelectionMode) {
onFamilySelectionToggle()
}
},
onLongClick = onFamilySelectionToggle
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
} else {
MaterialTheme.colorScheme.surface
}
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (isSelectionMode) {
Checkbox(
checked = allSelected,
onCheckedChange = { onFamilySelectionToggle() },
modifier = Modifier.padding(end = 8.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = family.familyName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = faceSummary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), MaterialTheme.shapes.small)
.padding(12.dp)
) {
if (customTypeface != null) {
Text(
text = stringResource(R.string.font_preview_text),
fontFamily = customTypeface,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface
)
} else {
Text(
text = stringResource(R.string.font_preview_error),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
Spacer(modifier = Modifier.height(8.dp))
family.variants.forEachIndexed { index, variant ->
FontVariantRow(
variant = variant,
entity = fontEntityForId(variant.font.id),
isSelected = variant.font.id in selectedFontIds,
isSelectionMode = isSelectionMode,
onSelectionToggle = { onVariantSelectionToggle(variant.font.id) },
onDelete = { onDeleteVariant(variant.font.id) }
)
if (index != family.variants.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(start = if (isSelectionMode) 48.dp else 0.dp))
}
}
}
}
}
@Composable
private fun FontVariantRow(
variant: CustomFontVariantItem,
entity: CustomFontEntity?,
isSelected: Boolean,
isSelectionMode: Boolean,
onSelectionToggle: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.clickable(enabled = isSelectionMode) { onSelectionToggle() }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (isSelectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { onSelectionToggle() },
modifier = Modifier.padding(end = 8.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = variant.fontFaceLabel(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = entity?.fileName ?: variant.font.fileName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = variant.font.fileExtension.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(horizontal = 8.dp)
)
if (!isSelectionMode) {
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> { private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
return filterNot { it.isDeleted } return filterNot { it.isDeleted }
.sortedBy { it.displayName.lowercase() } .sortedBy { it.displayName.lowercase() }
@ -591,3 +786,8 @@ fun DeleteFontsConfirmationDialog(
private fun Set<String>.toggle(id: String): Set<String> { private fun Set<String>.toggle(id: String): Set<String> {
return if (id in this) this - id else this + id return if (id in this) this - id else this + id
} }
private fun Set<String>.toggleAll(ids: List<String>): Set<String> {
val idSet = ids.toSet()
return if (containsAll(idSet)) this - idSet else this + idSet
}

View file

@ -139,6 +139,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@ -193,6 +194,7 @@ fun HomeScreen(
var showAboutDialog by remember { mutableStateOf(false) } var showAboutDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) } var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
var showBehaviorDialog by remember { mutableStateOf(false) } var showBehaviorDialog by remember { mutableStateOf(false) }
var showStrictFilterDialog by remember { mutableStateOf(false) } var showStrictFilterDialog by remember { mutableStateOf(false) }
var showClearBookCacheDialog by remember { mutableStateOf(false) } var showClearBookCacheDialog by remember { mutableStateOf(false) }
@ -221,6 +223,35 @@ fun HomeScreen(
} }
} }
val saveOriginalLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
) { uri ->
val item = pendingSaveOriginalItem
pendingSaveOriginalItem = null
if (uri != null && item?.uriString != null) {
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
}
}
fun saveOriginalItem(item: RecentFileItem) {
if (!item.canExportOriginalFile()) return
pendingSaveOriginalItem = item
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
}
fun shareOriginalItem(item: RecentFileItem) {
val uriString = item.uriString ?: return
if (!item.canExportOriginalFile()) return
scope.launch {
viewModel.shareOriginalFile(
activityContext = context,
sourceUri = uriString.toUri(),
fileType = item.type,
filename = item.suggestedOriginalFileName()
)
}
}
LaunchedEffect(uiState.isRequestingDrivePermission) { LaunchedEffect(uiState.isRequestingDrivePermission) {
if (uiState.isRequestingDrivePermission) { if (uiState.isRequestingDrivePermission) {
val intent = viewModel.getDriveSignInIntent(context) val intent = viewModel.getDriveSignInIntent(context)
@ -390,6 +421,12 @@ fun HomeScreen(
showInfoDialog = true showInfoDialog = true
} }
}, },
onSaveClick = selectedContextItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { saveOriginalItem(item) } },
onShareClick = selectedContextItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { shareOriginalItem(item) } },
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) }, onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
onDeleteClick = { showDeleteConfirmDialog = true }, onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllRecentFiles() }) onSelectAllClick = { viewModel.selectAllRecentFiles() })
@ -502,28 +539,17 @@ fun HomeScreen(
) )
} }
itemForInfoDialog?.let { item -> HydratedFileInfoDialog(
if (showInfoDialog) { item = itemForInfoDialog,
FileInfoDialog( isVisible = showInfoDialog,
item = item, uiState = uiState,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, viewModel = viewModel,
onDismiss = { onDismiss = {
showInfoDialog = false showInfoDialog = false
itemForInfoDialog = null itemForInfoDialog = null
}, },
onSaveMetadata = { metadata -> onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
viewModel.updateBookMetadata(item.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(item.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(item.bookId)
},
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
) )
}
}
if (showClearReflowCacheDialog) { if (showClearReflowCacheDialog) {
DangerousFolderActionDialog( DangerousFolderActionDialog(
@ -1786,9 +1812,14 @@ fun ExternalFileBehaviorDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.options_external_file_behavior)) }, title = { Text(stringResource(R.string.options_external_file_behavior)) },
text = { text = {
Column { Column(modifier = Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) {
val options = listOf("ASK" to R.string.external_file_behavior_ask, "KEEP" to R.string.external_file_behavior_keep, "DELETE" to R.string.external_file_behavior_delete) val options = listOf(
options.forEach { (value, labelRes) -> Triple("ASK", R.string.external_file_behavior_ask, R.string.external_file_behavior_ask_desc),
Triple("KEEP", R.string.external_file_behavior_keep, R.string.external_file_behavior_keep_desc),
Triple("DELETE", R.string.external_file_behavior_delete, R.string.external_file_behavior_delete_desc),
Triple("TEMPORARY", R.string.external_file_behavior_temporary, R.string.external_file_behavior_temporary_desc)
)
options.forEach { (value, labelRes, descriptionRes) ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@ -1798,7 +1829,14 @@ fun ExternalFileBehaviorDialog(
) { ) {
RadioButton(selected = currentBehavior == value, onClick = null) RadioButton(selected = currentBehavior == value, onClick = null)
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(labelRes)) Text(stringResource(labelRes))
Text(
text = stringResource(descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }
} }

View file

@ -133,6 +133,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
@ -271,6 +272,36 @@ fun LibraryScreen(
var showDeleteShelvesDialog by remember { mutableStateOf(false) } var showDeleteShelvesDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) } var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
val saveOriginalLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
) { uri ->
val item = pendingSaveOriginalItem
pendingSaveOriginalItem = null
if (uri != null && item?.uriString != null) {
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
}
}
fun saveOriginalItem(item: RecentFileItem) {
if (!item.canExportOriginalFile()) return
pendingSaveOriginalItem = item
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
}
fun shareOriginalItem(item: RecentFileItem) {
val uriString = item.uriString ?: return
if (!item.canExportOriginalFile()) return
scope.launch {
viewModel.shareOriginalFile(
activityContext = context,
sourceUri = uriString.toUri(),
fileType = item.type,
filename = item.suggestedOriginalFileName()
)
}
}
BackHandler(enabled = isContextualModeActive) { BackHandler(enabled = isContextualModeActive) {
viewModel.clearContextualAction() viewModel.clearContextualAction()
@ -317,6 +348,12 @@ fun LibraryScreen(
showInfoDialog = true showInfoDialog = true
} }
}, },
onSaveClick = selectedItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { saveOriginalItem(item) } },
onShareClick = selectedItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { shareOriginalItem(item) } },
onDeleteClick = { showDeleteConfirmDialog = true }, onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllLibraryFiles() }, onSelectAllClick = { viewModel.selectAllLibraryFiles() },
onShelfClick = viewModel::onShelfClick, onShelfClick = viewModel::onShelfClick,
@ -397,28 +434,17 @@ fun LibraryScreen(
) )
} }
itemForInfoDialog?.let { item -> HydratedFileInfoDialog(
if (showInfoDialog) { item = itemForInfoDialog,
FileInfoDialog( isVisible = showInfoDialog,
item = item, uiState = uiState,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, viewModel = viewModel,
onDismiss = { onDismiss = {
showInfoDialog = false showInfoDialog = false
itemForInfoDialog = null itemForInfoDialog = null
}, },
onSaveMetadata = { metadata -> onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
viewModel.updateBookMetadata(item.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(item.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(item.bookId)
},
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
) )
}
}
CustomTopBanner(bannerMessage = uiState.bannerMessage) CustomTopBanner(bannerMessage = uiState.bannerMessage)
} }
} }
@ -436,10 +462,42 @@ fun ShelfScreen(
val sortOrder = uiState.sortOrder val sortOrder = uiState.sortOrder
val showRenameDialogFor = uiState.showRenameShelfDialogFor val showRenameDialogFor = uiState.showRenameShelfDialogFor
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
val context = LocalContext.current
val scope = rememberCoroutineScope()
var showRemoveFromShelfDialog by remember { mutableStateOf(false) } var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) } var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
val saveOriginalLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
) { uri ->
val item = pendingSaveOriginalItem
pendingSaveOriginalItem = null
if (uri != null && item?.uriString != null) {
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
}
}
fun saveOriginalItem(item: RecentFileItem) {
if (!item.canExportOriginalFile()) return
pendingSaveOriginalItem = item
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
}
fun shareOriginalItem(item: RecentFileItem) {
val uriString = item.uriString ?: return
if (!item.canExportOriginalFile()) return
scope.launch {
viewModel.shareOriginalFile(
activityContext = context,
sourceUri = uriString.toUri(),
fileType = item.type,
filename = item.suggestedOriginalFileName()
)
}
}
BackHandler(enabled = true) { BackHandler(enabled = true) {
when { when {
@ -491,6 +549,12 @@ fun ShelfScreen(
showInfoDialog = true showInfoDialog = true
} }
}, },
onSaveClick = selectedItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { saveOriginalItem(item) } },
onShareClick = selectedItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { shareOriginalItem(item) } },
onDeleteClick = { showRemoveFromShelfDialog = true }, onDeleteClick = { showRemoveFromShelfDialog = true },
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) }, onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) }, onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
@ -531,19 +595,14 @@ fun ShelfScreen(
) )
} }
itemForInfoDialog?.let { item -> HydratedFileInfoDialog(
if (showInfoDialog) { item = itemForInfoDialog,
FileInfoDialog( isVisible = showInfoDialog,
item = item, uiState = uiState,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, viewModel = viewModel,
onDismiss = { showInfoDialog = false; itemForInfoDialog = null }, onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) }, onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) },
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
) )
}
}
CustomTopBanner(bannerMessage = uiState.bannerMessage) CustomTopBanner(bannerMessage = uiState.bannerMessage)
} }
} }
@ -578,6 +637,8 @@ fun LibraryScreenContent(
onItemClick: (RecentFileItem) -> Unit, onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit, onItemLongClick: (RecentFileItem) -> Unit,
onInfoClick: () -> Unit, onInfoClick: () -> Unit,
onSaveClick: (() -> Unit)?,
onShareClick: (() -> Unit)?,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
onSelectAllClick: () -> Unit, onSelectAllClick: () -> Unit,
onShelfClick: (Shelf) -> Unit, onShelfClick: (Shelf) -> Unit,
@ -640,6 +701,8 @@ fun LibraryScreenContent(
onTagClick = onTagClick, onTagClick = onTagClick,
onPinClick = onPinClick, onPinClick = onPinClick,
onInfoClick = onInfoClick, onInfoClick = onInfoClick,
onSaveClick = onSaveClick,
onShareClick = onShareClick,
onDeleteClick = onDeleteClick, onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick onSelectAllClick = onSelectAllClick
) )
@ -1046,6 +1109,8 @@ private fun ShelfDetailScreen(
onClearSelection: () -> Unit, onClearSelection: () -> Unit,
onTagClick: () -> Unit, onTagClick: () -> Unit,
onInfoClick: () -> Unit, onInfoClick: () -> Unit,
onSaveClick: (() -> Unit)?,
onShareClick: (() -> Unit)?,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
onRenameShelf: () -> Unit, onRenameShelf: () -> Unit,
onDeleteShelf: () -> Unit, onDeleteShelf: () -> Unit,
@ -1128,6 +1193,8 @@ private fun ShelfDetailScreen(
onNavIconClick = onClearSelection, onNavIconClick = onClearSelection,
onTagClick = onTagClick, onTagClick = onTagClick,
onInfoClick = onInfoClick, onInfoClick = onInfoClick,
onSaveClick = onSaveClick,
onShareClick = onShareClick,
onDeleteClick = onDeleteClick onDeleteClick = onDeleteClick
) )
} else if (isSearchActive) { } else if (isSearchActive) {

View file

@ -58,10 +58,12 @@ import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI
import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET
@UnstableApi @UnstableApi
class MainActivity : AppCompatActivity() { open class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels() private val viewModel: MainViewModel by viewModels()
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
private val isTemporaryExternalOpen: Boolean
get() = intent?.getBooleanExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, false) == true
private val updateLauncher = registerForActivityResult( private val updateLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult() ActivityResultContracts.StartIntentSenderForResult()
@ -86,6 +88,14 @@ class MainActivity : AppCompatActivity() {
} }
} }
lifecycleScope.launch {
viewModel.temporaryExternalOpenFinished.collect {
if (isTemporaryExternalOpen) {
finishAndRemoveTask()
}
}
}
if (savedInstanceState == null) { if (savedInstanceState == null) {
handleIntent(intent) handleIntent(intent)
} }
@ -160,7 +170,12 @@ class MainActivity : AppCompatActivity() {
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) { if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
Timber.d("Received VIEW intent with URI: ${intent.data}") Timber.d("Received VIEW intent with URI: ${intent.data}")
val uri = intent.data!! val uri = intent.data!!
viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true) viewModel.onFileSelected(
uri,
isFromRecent = false,
isExternalIntent = true,
isTemporaryExternalIntent = isTemporaryExternalOpen
)
} }
} }
} }

View file

@ -222,10 +222,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val _navigationEvent = Channel<NavigationEvent>(Channel.BUFFERED) private val _navigationEvent = Channel<NavigationEvent>(Channel.BUFFERED)
@Suppress("unused") @Suppress("unused")
val navigationEvent = _navigationEvent.receiveAsFlow() val navigationEvent = _navigationEvent.receiveAsFlow()
private val _temporaryExternalOpenFinished = Channel<Unit>(Channel.BUFFERED)
val temporaryExternalOpenFinished = _temporaryExternalOpenFinished.receiveAsFlow()
private var bannerDismissJob: Job? = null private var bannerDismissJob: Job? = null
private var bannerDismissGeneration = 0L private var bannerDismissGeneration = 0L
private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null
private var externalOpenedBookId: String? = null private var externalOpenedBookId: String? = null
private var temporaryExternalSessionBookId: String? = null
private var cloudContentRetryJob: Job? = null private var cloudContentRetryJob: Job? = null
private val cloudMetadataUploadJobs = ConcurrentHashMap<String, Job>() private val cloudMetadataUploadJobs = ConcurrentHashMap<String, Job>()
@ -804,6 +807,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(showTagSelectionDialogFor = emptySet()) } _internalState.update { it.copy(showTagSelectionDialogFor = emptySet()) }
} }
suspend fun getFileInfoItem(bookId: String): RecentFileItem? {
return recentFilesRepository.getFileByBookId(bookId)
}
fun createAndAssignTag(name: String, bookIds: Set<String>) { fun createAndAssignTag(name: String, bookIds: Set<String>) {
val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds) val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
if (sanitizedBookIds.isEmpty()) return if (sanitizedBookIds.isEmpty()) return
@ -2089,6 +2096,48 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
internal fun trackExternalOpenForClose(
bookId: String,
importedCopyUriString: String?,
isTemporaryExternalIntent: Boolean
) {
if (isTemporaryExternalIntent) {
temporaryExternalSessionBookId = bookId
if (importedCopyUriString != null) {
externalOpenedBookId = bookId
markPendingExternalFileRemoval(bookId, importedCopyUriString)
}
return
}
externalOpenedBookId = bookId
if (
importedCopyUriString != null &&
prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, EXTERNAL_FILE_BEHAVIOR_ASK) == "DELETE"
) {
markPendingExternalFileRemoval(bookId, importedCopyUriString)
}
}
fun saveOriginalFile(sourceUri: Uri, destUri: Uri) {
viewModelScope.launch {
_internalState.update {
it.copy(isLoading = true, bannerMessage = BannerMessage(appContext.getString(R.string.banner_saving_original_file)))
}
try {
withContext(Dispatchers.IO) {
copyUriBytes(sourceUri, destUri)
}
showBanner(appContext.getString(R.string.banner_original_file_saved))
} catch (e: Exception) {
Timber.e(e, "Failed to save original file")
showBanner(appContext.getString(R.string.error_saving_file, e.localizedMessage ?: e.message.orEmpty()), isError = true)
} finally {
_internalState.update { it.copy(isLoading = false) }
}
}
}
fun togglePinForContextualItems(isHome: Boolean) { fun togglePinForContextualItems(isHome: Boolean) {
if (_internalState.value.contextualActionItems.isEmpty()) return if (_internalState.value.contextualActionItems.isEmpty()) return
@ -2206,6 +2255,68 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
suspend fun shareOriginalFile(
activityContext: Context,
sourceUri: Uri,
fileType: FileType,
filename: String
) {
withContext(Dispatchers.IO) {
try {
val shareDir = File(appContext.cacheDir, "shared_files")
if (shareDir.exists()) {
shareDir.listFiles()?.forEach { file ->
try {
file.delete()
} catch (_: Exception) {
Timber.w("Failed to delete temp share file: ${file.name}")
}
}
} else {
shareDir.mkdirs()
}
val destFile = File(shareDir, filename)
FileOutputStream(destFile).use { output ->
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
input.copyTo(output)
} ?: error("Could not open source file.")
}
val authority = "${appContext.packageName}.provider"
val contentUri = androidx.core.content.FileProvider.getUriForFile(
appContext, authority, destFile
)
val mimeType = SharedFileCapabilities.mimeTypeFor(fileType) ?: "application/octet-stream"
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_STREAM, contentUri)
putExtra(Intent.EXTRA_TITLE, filename)
putExtra(Intent.EXTRA_SUBJECT, appContext.getString(R.string.share_subject, filename))
clipData = ClipData.newRawUri(filename, contentUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(shareIntent, appContext.getString(R.string.share_file_chooser_title))
if (activityContext !is android.app.Activity) {
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
withContext(Dispatchers.Main) { activityContext.startActivity(chooser) }
} catch (e: Exception) {
Timber.e(e, "Share original file failed")
showBanner(appContext.getString(R.string.error_share_failed, e.localizedMessage ?: e.message.orEmpty()), isError = true)
}
}
}
private fun copyUriBytes(sourceUri: Uri, destUri: Uri) {
val contentResolver = appContext.contentResolver
contentResolver.openInputStream(sourceUri)?.use { input ->
contentResolver.openOutputStream(destUri)?.use { output ->
input.copyTo(output)
} ?: error("Could not open destination file.")
} ?: error("Could not open source file.")
}
private fun queueCloudMetadataUpload(bookId: String, reason: String, debounce: Boolean = true) { private fun queueCloudMetadataUpload(bookId: String, reason: String, debounce: Boolean = true) {
if (!uiState.value.isSyncEnabled) return if (!uiState.value.isSyncEnabled) return
cloudMetadataUploadJobs.remove(bookId)?.cancel() cloudMetadataUploadJobs.remove(bookId)?.cancel()
@ -2761,6 +2872,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val closingBookId = _internalState.value.selectedBookId val closingBookId = _internalState.value.selectedBookId
val uriString = _internalState.value.selectedPdfUri?.toString() val uriString = _internalState.value.selectedPdfUri?.toString()
?: _internalState.value.selectedEpubUri?.toString() ?: _internalState.value.selectedEpubUri?.toString()
val isTemporaryExternalSession = closingBookId != null && closingBookId == temporaryExternalSessionBookId
logCloudSyncTrace { logCloudSyncTrace {
"android.reader.close_request book=$closingBookId uri=${uriString.cloudSyncPreview()} sync=${uiState.value.isSyncEnabled}" "android.reader.close_request book=$closingBookId uri=${uriString.cloudSyncPreview()} sync=${uiState.value.isSyncEnabled}"
} }
@ -2787,6 +2899,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
selectedEpubBook = null, selectedEpubBook = null,
selectedFileType = null, selectedFileType = null,
isLoading = false, isLoading = false,
isTemporaryExternalOpen = false,
errorMessage = null, errorMessage = null,
initialLocator = null, initialLocator = null,
initialPageInBook = null, initialPageInBook = null,
@ -2794,20 +2907,40 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isOpeningFromTtsNotification = false isOpeningFromTtsNotification = false
) )
} }
if (!isTemporaryExternalSession) {
clearPersistedReaderSession() clearPersistedReaderSession()
}
var removesExternalFileOnClose = false var removesExternalFileOnClose = false
if (closingBookId != null && closingBookId == externalOpenedBookId) { if (closingBookId != null && (closingBookId == externalOpenedBookId || isTemporaryExternalSession)) {
val behavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK" val behavior = if (isTemporaryExternalSession) {
EXTERNAL_FILE_BEHAVIOR_TEMPORARY
} else {
prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, EXTERNAL_FILE_BEHAVIOR_ASK) ?: EXTERNAL_FILE_BEHAVIOR_ASK
}
if (behavior == "ASK") { if (behavior == "ASK") {
_internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) } _internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) }
} else if (behavior == "DELETE") { } else if (behavior == "DELETE") {
removesExternalFileOnClose = true removesExternalFileOnClose = true
deletePendingExternalFileRemoval(closingBookId, uriString) deletePendingExternalFileRemoval(closingBookId, uriString)
} else if (behavior == EXTERNAL_FILE_BEHAVIOR_TEMPORARY) {
removesExternalFileOnClose = true
val shouldDeleteImportedCopy = closingBookId == externalOpenedBookId
if (shouldDeleteImportedCopy) {
viewModelScope.launch {
deletePendingExternalFileRemoval(PendingExternalFileRemoval(closingBookId, uriString))
_temporaryExternalOpenFinished.send(Unit)
}
} else {
viewModelScope.launch {
_temporaryExternalOpenFinished.send(Unit)
}
}
} else { } else {
clearPendingExternalFileRemovals(setOf(closingBookId)) clearPendingExternalFileRemovals(setOf(closingBookId))
} }
externalOpenedBookId = null externalOpenedBookId = null
temporaryExternalSessionBookId = null
} }
if (uriString != null && !removesExternalFileOnClose) { if (uriString != null && !removesExternalFileOnClose) {
@ -4802,7 +4935,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
fun onFileSelected(uri: Uri, isFromRecent: Boolean = false, isExternalIntent: Boolean = false) { fun onFileSelected(
uri: Uri,
isFromRecent: Boolean = false,
isExternalIntent: Boolean = false,
isTemporaryExternalIntent: Boolean = false
) {
if (isFromRecent) { if (isFromRecent) {
Timber.i("Opening recent file: $uri") Timber.i("Opening recent file: $uri")
viewModelScope.launch { viewModelScope.launch {
@ -4815,7 +4953,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} else { } else {
Timber.i("Importing new file: $uri") Timber.i("Importing new file: $uri")
importExternalFile(uri, isExternalIntent) importExternalFile(uri, isExternalIntent, isTemporaryExternalIntent)
} }
} }
@ -4865,9 +5003,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
private fun importExternalFile(externalUri: Uri, isExternalIntent: Boolean = false) { private fun importExternalFile(
externalUri: Uri,
isExternalIntent: Boolean = false,
isTemporaryExternalIntent: Boolean = false
) {
if (isTemporaryExternalIntent) {
openTemporaryExternalFile(externalUri)
return
}
_internalState.update { _internalState.update {
it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet()) it.copy(
isLoading = true,
errorMessage = null,
contextualActionItems = emptySet()
)
} }
viewModelScope.launch { viewModelScope.launch {
@ -4877,10 +5028,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (importResult != null) { if (importResult != null) {
val (internalUri, bookId, type) = importResult val (internalUri, bookId, type) = importResult
if (isExternalIntent) { if (isExternalIntent) {
externalOpenedBookId = bookId trackExternalOpenForClose(
if (prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") == "DELETE") { bookId = bookId,
markPendingExternalFileRemoval(bookId, internalUri.toString()) importedCopyUriString = internalUri.toString(),
} isTemporaryExternalIntent = isTemporaryExternalIntent
)
} }
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File" val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File"
openBook( openBook(
@ -4895,6 +5047,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val existingItem = recentFilesRepository.getFileByBookId(hash) val existingItem = recentFilesRepository.getFileByBookId(hash)
if (existingItem != null) { if (existingItem != null) {
Timber.i("Re-selected an existing book. Opening it.") Timber.i("Re-selected an existing book. Opening it.")
if (isTemporaryExternalIntent) {
trackExternalOpenForClose(
bookId = existingItem.bookId,
importedCopyUriString = null,
isTemporaryExternalIntent = true
)
}
onRecentFileClicked(existingItem) onRecentFileClicked(existingItem)
_internalState.update { it.copy(isLoading = false) } _internalState.update { it.copy(isLoading = false) }
return@launch return@launch
@ -4928,6 +5087,45 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
private fun openTemporaryExternalFile(externalUri: Uri) {
_internalState.update {
it.copy(
isLoading = true,
isTemporaryExternalOpen = true,
errorMessage = null,
contextualActionItems = emptySet()
)
}
viewModelScope.launch {
val type = getFileTypeFromUri(externalUri, appContext)
if (type == null) {
_internalState.update {
it.copy(
isLoading = false,
isTemporaryExternalOpen = false,
errorMessage = appContext.getString(R.string.error_unsupported_file_type)
)
}
return@launch
}
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Temporary File"
val bookId = "temporary-${UUID.randomUUID()}"
trackExternalOpenForClose(
bookId = bookId,
importedCopyUriString = null,
isTemporaryExternalIntent = true
)
openBook(
uri = externalUri,
bookId = bookId,
type = type,
originalDisplayName = displayName,
persistToLibrary = false
)
}
}
fun saveHighlights(bookId: String, highlightsJson: String) { fun saveHighlights(bookId: String, highlightsJson: String) {
viewModelScope.launch { viewModelScope.launch {
val currentBookUri = _internalState.value.selectedPdfUri ?: _internalState.value.selectedEpubUri val currentBookUri = _internalState.value.selectedPdfUri ?: _internalState.value.selectedEpubUri
@ -5154,7 +5352,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
private suspend fun cleanupBookDataLocally(bookId: String) { protected open suspend fun cleanupBookDataLocally(bookId: String) {
pdfTextRepository.clearBookText(bookId) pdfTextRepository.clearBookText(bookId)
clearImportedFileCache(bookId) clearImportedFileCache(bookId)
bookCacheDao.deleteEntireBookCache(bookId) bookCacheDao.deleteEntireBookCache(bookId)
@ -5199,7 +5397,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isInitialPageExplicit: Boolean = false, isInitialPageExplicit: Boolean = false,
initialLocatorOverride: Locator? = null, initialLocatorOverride: Locator? = null,
initialCfiOverride: String? = null, initialCfiOverride: String? = null,
preserveTtsOnOpen: Boolean = false preserveTtsOnOpen: Boolean = false,
persistToLibrary: Boolean = true
) { ) {
val openBookStartTime = System.currentTimeMillis() val openBookStartTime = System.currentTimeMillis()
ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type") ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type")
@ -5293,6 +5492,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
} }
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms") ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
if (persistToLibrary) {
persistReaderSession(bookId, type) persistReaderSession(bookId, type)
addFileToRecent( addFileToRecent(
uri, uri,
@ -5303,6 +5503,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
sourceFolderUri = null, sourceFolderUri = null,
bundleResult = bundleResult bundleResult = bundleResult
) )
}
if (!suppressNavigation) { if (!suppressNavigation) {
Timber.tag("FileSwitch").d("PDF state updated, emitting navigation event") Timber.tag("FileSwitch").d("PDF state updated, emitting navigation event")
@ -5343,7 +5544,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
} }
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms") ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
if (persistToLibrary) {
persistReaderSession(bookId, type) persistReaderSession(bookId, type)
}
if (!suppressNavigation) { if (!suppressNavigation) {
Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event") Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event")
@ -5352,22 +5555,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when (type) { when (type) {
FileType.EPUB -> { FileType.EPUB -> {
loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult) loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
} }
FileType.MOBI -> { FileType.MOBI -> {
loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult) loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
} }
FileType.FB2 -> { FileType.FB2 -> {
loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult) loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
} }
FileType.ODT, FileType.FODT -> { FileType.ODT, FileType.FODT -> {
loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult) loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
} }
else -> { else -> {
loadSingleFile( loadSingleFile(
uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary
) )
} }
} }
@ -5390,7 +5593,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
private fun loadFb2(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) { private fun loadFb2(
uri: Uri,
bookId: String,
customDisplayName: String? = null,
bundleResult: CalibreBundleResult? = null,
persistToLibrary: Boolean = true
) {
val loadStart = System.currentTimeMillis() val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 START") Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 START")
viewModelScope.launch { viewModelScope.launch {
@ -5412,9 +5621,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.i("FB2 parsing successful. Title: ${fb2Book.title}") Timber.i("FB2 parsing successful. Title: ${fb2Book.title}")
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 completed | chapters=${fb2Book.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 completed | chapters=${fb2Book.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
if (persistToLibrary) {
addFileToRecent( addFileToRecent(
uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
) )
}
_internalState.update { it.copy(selectedEpubBook = fb2Book, isLoading = false) } _internalState.update { it.copy(selectedEpubBook = fb2Book, isLoading = false) }
} catch (e: Exception) { } catch (e: Exception) {
@ -5426,7 +5637,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
private fun loadOdt(uri: Uri, bookId: String, isFlat: Boolean, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) { private fun loadOdt(
uri: Uri,
bookId: String,
isFlat: Boolean,
customDisplayName: String? = null,
bundleResult: CalibreBundleResult? = null,
persistToLibrary: Boolean = true
) {
val loadStart = System.currentTimeMillis() val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt START | isFlat=$isFlat") Timber.tag("FileOpenPerf").d("[$bookId] loadOdt START | isFlat=$isFlat")
viewModelScope.launch { viewModelScope.launch {
@ -5449,9 +5667,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.i("ODT parsing successful. Title: ${odtBook.title}") Timber.i("ODT parsing successful. Title: ${odtBook.title}")
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt completed | chapters=${odtBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") Timber.tag("FileOpenPerf").d("[$bookId] loadOdt completed | chapters=${odtBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
if (persistToLibrary) {
addFileToRecent( addFileToRecent(
uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
) )
}
_internalState.update { it.copy(selectedEpubBook = odtBook, isLoading = false) } _internalState.update { it.copy(selectedEpubBook = odtBook, isLoading = false) }
} catch (e: Exception) { } catch (e: Exception) {
@ -5468,7 +5688,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
bookId: String, bookId: String,
type: FileType, type: FileType,
customDisplayName: String? = null, customDisplayName: String? = null,
bundleResult: CalibreBundleResult? = null bundleResult: CalibreBundleResult? = null,
persistToLibrary: Boolean = true
) { ) {
val loadStart = System.currentTimeMillis() val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type") Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type")
@ -5506,6 +5727,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileOpenPerf") Timber.tag("FileOpenPerf")
.d("[$bookId] loadSingleFile: importSingleFile completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") .d("[$bookId] loadSingleFile: importSingleFile completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
Timber.i("Import successful ($type). Title: ${epubBook.title}") Timber.i("Import successful ($type). Title: ${epubBook.title}")
if (persistToLibrary) {
addFileToRecent( addFileToRecent(
uri, uri,
type, type,
@ -5516,6 +5738,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
sourceFolderUri = null, sourceFolderUri = null,
bundleResult = bundleResult bundleResult = bundleResult
) )
}
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf") Timber.tag("FileOpenPerf")
@ -5554,7 +5777,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return resolveFileTypeFromMetadata(fileName, mimeType) return resolveFileTypeFromMetadata(fileName, mimeType)
} }
private fun loadMobi(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) { private fun loadMobi(
uri: Uri,
bookId: String,
customDisplayName: String? = null,
bundleResult: CalibreBundleResult? = null,
persistToLibrary: Boolean = true
) {
viewModelScope.launch { viewModelScope.launch {
if (!_internalState.value.isLoading) { if (!_internalState.value.isLoading) {
_internalState.update { it.copy(isLoading = true, errorMessage = null) } _internalState.update { it.copy(isLoading = true, errorMessage = null) }
@ -5576,6 +5805,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (mobiAsEpubBook != null) { if (mobiAsEpubBook != null) {
Timber.i("MOBI parsing successful. Title: ${mobiAsEpubBook.title}") Timber.i("MOBI parsing successful. Title: ${mobiAsEpubBook.title}")
if (persistToLibrary) {
addFileToRecent( addFileToRecent(
uri, uri,
FileType.MOBI, FileType.MOBI,
@ -5586,6 +5816,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
sourceFolderUri = null, sourceFolderUri = null,
bundleResult = bundleResult bundleResult = bundleResult
) )
}
_internalState.update { _internalState.update {
it.copy(selectedEpubBook = mobiAsEpubBook, isLoading = false) it.copy(selectedEpubBook = mobiAsEpubBook, isLoading = false)
} }
@ -5607,7 +5838,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
private fun loadEpub(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) { private fun loadEpub(
uri: Uri,
bookId: String,
customDisplayName: String? = null,
bundleResult: CalibreBundleResult? = null,
persistToLibrary: Boolean = true
) {
val loadStart = System.currentTimeMillis() val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START") Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START")
viewModelScope.launch { viewModelScope.launch {
@ -5633,6 +5870,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileOpenPerf") Timber.tag("FileOpenPerf")
.d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") .d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
if (persistToLibrary) {
addFileToRecent( addFileToRecent(
uri, uri,
FileType.EPUB, FileType.EPUB,
@ -5643,6 +5881,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
sourceFolderUri = null, sourceFolderUri = null,
bundleResult = bundleResult bundleResult = bundleResult
) )
}
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf") Timber.tag("FileOpenPerf")
@ -7081,6 +7320,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_LAST_OPEN_BOOK_ID = "last_open_book_id" private const val KEY_LAST_OPEN_BOOK_ID = "last_open_book_id"
private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type" private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type"
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior" private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior"
private const val EXTERNAL_FILE_BEHAVIOR_ASK = "ASK"
private const val EXTERNAL_FILE_BEHAVIOR_TEMPORARY = "TEMPORARY"
private const val KEY_PENDING_EXTERNAL_FILE_REMOVALS = "pending_external_file_removals" private const val KEY_PENDING_EXTERNAL_FILE_REMOVALS = "pending_external_file_removals"
private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter" private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter"
private const val KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME = "use_pdf_file_name_as_display_name" private const val KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME = "use_pdf_file_name_as_display_name"

View file

@ -2,7 +2,10 @@ package com.aryan.reader
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
@Composable @Composable
@ -64,28 +67,17 @@ internal fun ReaderFileInfoDialogs(
} }
} }
item?.let { fileInfoItem -> HydratedFileInfoDialog(
if (isFileInfoVisible) { item = item,
FileInfoDialog( isVisible = isFileInfoVisible,
item = fileInfoItem, uiState = uiState,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, viewModel = viewModel,
onDismiss = { onFileInfoVisibleChange(false) }, onDismiss = { onFileInfoVisibleChange(false) },
onSaveMetadata = { metadata -> onOpenTags = { bookId ->
viewModel.updateBookMetadata(fileInfoItem.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(fileInfoItem.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId)
},
onOpenTags = {
onFileInfoVisibleChange(false) onFileInfoVisibleChange(false)
viewModel.openTagSelection(setOf(fileInfoItem.bookId)) viewModel.openTagSelection(setOf(bookId))
} }
) )
}
}
if (uiState.showTagSelectionDialogFor.isNotEmpty()) { if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
TagSelectionBottomSheet( TagSelectionBottomSheet(
@ -102,3 +94,49 @@ internal fun ReaderFileInfoDialogs(
) )
} }
} }
@Composable
internal fun HydratedFileInfoDialog(
item: RecentFileItem?,
isVisible: Boolean,
uiState: ReaderScreenState,
viewModel: MainViewModel,
onDismiss: () -> Unit,
onOpenTags: (String) -> Unit
) {
var fileInfoItem by remember(item?.bookId) { mutableStateOf(item) }
var hasResolvedFullItem by remember(item?.bookId) { mutableStateOf(false) }
LaunchedEffect(item) {
fileInfoItem = item
hasResolvedFullItem = false
}
LaunchedEffect(isVisible, item?.bookId) {
if (isVisible && item != null) {
fileInfoItem = viewModel.getFileInfoItem(item.bookId)?.copy(tags = item.tags) ?: item
hasResolvedFullItem = true
}
}
val resolvedItem = fileInfoItem
if (isVisible && resolvedItem != null && hasResolvedFullItem) {
FileInfoDialog(
item = resolvedItem,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = onDismiss,
onSaveMetadata = { metadata ->
viewModel.updateBookMetadata(resolvedItem.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(resolvedItem.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(resolvedItem.bookId)
},
onOpenTags = {
onOpenTags(resolvedItem.bookId)
}
)
}
}

View file

@ -0,0 +1,15 @@
package com.aryan.reader
import com.aryan.reader.shared.detectFontVariant
import com.aryan.reader.shared.familyFilenameSignature
import com.aryan.reader.shared.supportsVariableWeightAxis
const val ReaderFontDiagnosticsTag = "ReaderFontDiag"
fun readerFontDiagnosticSummary(nameWithoutExtension: String): String {
val variant = nameWithoutExtension.detectFontVariant()
return "name='$nameWithoutExtension' " +
"signature='${nameWithoutExtension.familyFilenameSignature()}' " +
"variant=$variant " +
"variableWght=${nameWithoutExtension.supportsVariableWeightAxis()}"
}

View file

@ -0,0 +1,19 @@
package com.aryan.reader
import kotlin.math.roundToInt
fun readerModalMaxHeightDp(
screenHeightDp: Int,
fraction: Float = 0.85f,
verticalMarginDp: Int = 32,
preferredMinHeightDp: Int = 220
): Int {
val usableHeight = (screenHeightDp - verticalMarginDp).coerceAtLeast(1)
val proportionalHeight = (screenHeightDp * fraction).roundToInt().coerceAtLeast(1)
val cappedHeight = minOf(usableHeight, proportionalHeight)
return if (usableHeight >= preferredMinHeightDp) {
cappedHeight.coerceAtLeast(preferredMinHeightDp)
} else {
usableHeight
}
}

View file

@ -87,6 +87,7 @@ import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Restore import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.FileOpen import androidx.compose.material.icons.outlined.FileOpen
import androidx.compose.material.icons.outlined.Gavel import androidx.compose.material.icons.outlined.Gavel
import androidx.compose.material.icons.outlined.Policy import androidx.compose.material.icons.outlined.Policy
@ -142,6 +143,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.SharedLegalLinks import com.aryan.reader.shared.SharedLegalLinks
import com.aryan.reader.shared.SharedLegalProfile import com.aryan.reader.shared.SharedLegalProfile
import com.aryan.reader.data.BookMetadataEdit import com.aryan.reader.data.BookMetadataEdit
@ -274,6 +276,8 @@ fun ContextualTopAppBar(
selectedItemCount: Int, selectedItemCount: Int,
onNavIconClick: () -> Unit, onNavIconClick: () -> Unit,
onInfoClick: (() -> Unit)? = null, onInfoClick: (() -> Unit)? = null,
onSaveClick: (() -> Unit)? = null,
onShareClick: (() -> Unit)? = null,
onTagClick: (() -> Unit)? = null, onTagClick: (() -> Unit)? = null,
onSelectAllClick: (() -> Unit)? = null, onSelectAllClick: (() -> Unit)? = null,
onPinClick: (() -> Unit)? = null, onPinClick: (() -> Unit)? = null,
@ -302,6 +306,16 @@ fun ContextualTopAppBar(
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.info)) Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.info))
} }
} }
if (selectedItemCount == 1 && onSaveClick != null) {
IconButton(onClick = onSaveClick) {
Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.action_save_copy_to_device))
}
}
if (selectedItemCount == 1 && onShareClick != null) {
IconButton(onClick = onShareClick) {
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.action_share))
}
}
if (onSelectAllClick != null) { if (onSelectAllClick != null) {
IconButton(onClick = onSelectAllClick) { IconButton(onClick = onSelectAllClick) {
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all)) Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all))
@ -1584,6 +1598,31 @@ fun RecentFileItem.isOpdsStream(): Boolean {
return uriString?.startsWith("opds-pse://") == true return uriString?.startsWith("opds-pse://") == true
} }
fun RecentFileItem.canExportOriginalFile(): Boolean {
return uriString != null && !isOpdsStream()
}
fun RecentFileItem.suggestedOriginalFileName(): String {
val fallbackExtension = SharedFileCapabilities.primaryExtensionFor(type)
val baseName = displayName
.takeIf { it.isNotBlank() }
?: title?.takeIf { it.isNotBlank() }
?: "book"
val sanitized = baseName
.replace(Regex("""[\\/:*?"<>|]+"""), "_")
.trim()
.take(120)
.ifBlank { "book" }
return if (
fallbackExtension != null &&
!sanitized.endsWith(".$fallbackExtension", ignoreCase = true)
) {
"$sanitized.$fallbackExtension"
} else {
sanitized
}
}
@Composable @Composable
private fun statusBadgeColors(overlay: Boolean): Pair<Color, Color> { private fun statusBadgeColors(overlay: Boolean): Pair<Color, Color> {
val container = if (overlay) { val container = if (overlay) {

View file

@ -67,7 +67,8 @@ val supportedAppLanguageOptions = listOf(
"中文", "中文",
"简体中文", "简体中文",
) )
) ),
AppLanguageOption("et", R.string.language_estonian, listOf("estonian", "eesti"))
) )
val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions

View file

@ -25,12 +25,15 @@ import android.provider.OpenableColumns
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.aryan.reader.ReaderFontDiagnosticsTag
import com.aryan.reader.readerFontDiagnosticSummary
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.UUID import java.util.UUID
private const val FONTS_DIR = "custom_fonts" private const val FONTS_DIR = "custom_fonts"
private const val MAX_IMPORTED_FONT_BASENAME_LENGTH = 120
class FontsRepository(private val context: Context) { class FontsRepository(private val context: Context) {
private val fontDao = AppDatabase.getDatabase(context).customFontDao() private val fontDao = AppDatabase.getDatabase(context).customFontDao()
@ -73,14 +76,23 @@ class FontsRepository(private val context: Context) {
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
val originalName = getFileName(uri) ?: "unknown.ttf" val originalName = getFileName(uri) ?: "unknown.ttf"
val extension = originalName.substringAfterLast('.', "").lowercase() val extension = originalName.substringAfterLast('.', "").lowercase()
val displayName = originalName.substringBeforeLast('.')
Timber.tag(ReaderFontDiagnosticsTag).i(
"import.start originalName='$originalName' extension='$extension' " +
readerFontDiagnosticSummary(displayName)
)
if (extension !in listOf("ttf", "otf", "woff2")) { if (extension !in listOf("ttf", "otf", "woff2")) {
Timber.tag(ReaderFontDiagnosticsTag).w(
"import.unsupported originalName='$originalName' extension='$extension'"
)
return@withContext Result.failure(Exception("Unsupported font format. Please use TTF, OTF, or WOFF2.")) return@withContext Result.failure(Exception("Unsupported font format. Please use TTF, OTF, or WOFF2."))
} }
val fontId = UUID.randomUUID().toString() val fontId = UUID.randomUUID().toString()
val internalFileName = "font_${fontId}.$extension" val destinationFile = uniqueImportedFontFile(displayName, extension, fontId)
val destinationFile = File(fontsDir, internalFileName) val internalFileName = destinationFile.name
contentResolver.openInputStream(uri)?.use { input -> contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(destinationFile).use { output -> FileOutputStream(destinationFile).use { output ->
@ -88,8 +100,6 @@ class FontsRepository(private val context: Context) {
} }
} }
val displayName = originalName.substringBeforeLast('.')
val entity = CustomFontEntity( val entity = CustomFontEntity(
id = fontId, id = fontId,
displayName = displayName, displayName = displayName,
@ -100,10 +110,16 @@ class FontsRepository(private val context: Context) {
) )
fontDao.insertFont(entity) fontDao.insertFont(entity)
Timber.tag(ReaderFontDiagnosticsTag).i(
"import.saved displayName='$displayName' internalFileName='$internalFileName' " +
"exists=${destinationFile.exists()} bytes=${destinationFile.length()} " +
"path='${destinationFile.absolutePath}'"
)
Timber.d("Imported font: $displayName to ${destinationFile.absolutePath}") Timber.d("Imported font: $displayName to ${destinationFile.absolutePath}")
Result.success(entity) Result.success(entity)
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(ReaderFontDiagnosticsTag).e(e, "import.failed uri='$uri'")
Timber.e(e, "Failed to import font") Timber.e(e, "Failed to import font")
Result.failure(e) Result.failure(e)
} }
@ -130,6 +146,18 @@ class FontsRepository(private val context: Context) {
fontDao.deletePermanently(fontId) fontDao.deletePermanently(fontId)
} }
private fun uniqueImportedFontFile(displayName: String, extension: String, fontId: String): File {
val preferredFileName = importedFontFileName(displayName, extension)
val preferredFile = File(fontsDir, preferredFileName)
if (!preferredFile.exists()) return preferredFile
val fallbackFileName = importedFontFileName(
displayName = "${displayName}_${fontId.take(8)}",
extension = extension
)
return File(fontsDir, fallbackFileName)
}
private fun getFileName(uri: Uri): String? { private fun getFileName(uri: Uri): String? {
var result: String? = null var result: String? = null
if (uri.scheme == "content") { if (uri.scheme == "content") {
@ -153,3 +181,17 @@ class FontsRepository(private val context: Context) {
return result return result
} }
} }
internal fun importedFontFileName(displayName: String, extension: String): String {
val safeBaseName = displayName
.replace(Regex("""[\\/:*?"<>|\p{Cntrl}]"""), "_")
.replace(Regex("""\s+"""), " ")
.trim(' ', '.')
.take(MAX_IMPORTED_FONT_BASENAME_LENGTH)
.ifBlank { "font" }
val safeExtension = extension
.lowercase()
.replace(Regex("""[^a-z0-9]"""), "")
.ifBlank { "ttf" }
return "$safeBaseName.$safeExtension"
}

View file

@ -33,7 +33,7 @@ interface RecentFileDao {
@Upsert @Upsert
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>) suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC") @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, substr(description, 1, 4096) AS description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, substr(originalDescription, 1, 4096) AS originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
fun getRecentFiles(): Flow<List<RecentFileSummary>> fun getRecentFiles(): Flow<List<RecentFileSummary>>
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0") @Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
@ -45,7 +45,7 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId") @Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean) suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit") @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, substr(description, 1, 4096) AS description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, substr(originalDescription, 1, 4096) AS originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
fun getRecentFilesList(limit: Int): List<RecentFileSummary> fun getRecentFilesList(limit: Int): List<RecentFileSummary>
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)") @Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")

View file

@ -457,19 +457,17 @@ class EpubParser(private val context: Context) {
var pageTargets: List<EpubPageTarget> = emptyList() var pageTargets: List<EpubPageTarget> = emptyList()
val ncxMetadataMap = mutableMapOf<String, NcxMetadata>() val ncxMetadataMap = mutableMapOf<String, NcxMetadata>()
val extractionRoot = File(extractionBasePath) val extractionRoot = File(extractionBasePath)
val tocFileItem = if (shouldUseToc) resolveTocFileItem(document.spine, manifestItems) else null
val tocDocumentNode = tocFileItem?.let { item ->
val ncxData = filesContentMap[item.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, item.absPath).takeIf { it.exists() }?.readBytes()
ncxData?.let { parseXMLFile(it) }
}
val ncxParentDir = tocFileItem?.let { File(it.absPath).parentFile ?: File("") }
if (shouldUseToc) { if (shouldUseToc) {
Timber.d("shouldUseToc is true. Attempting to parse NCX.") Timber.d("shouldUseToc is true. Attempting to parse NCX.")
val tocFileItem = manifestItems.values.firstOrNull { if (tocFileItem != null && ncxParentDir != null) {
it.absPath.endsWith(".ncx", ignoreCase = true)
}
if (tocFileItem != null) {
val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
if (tocDocumentNode != null) { if (tocDocumentNode != null) {
Timber.d("Successfully parsed NCX file: ${tocFileItem.absPath}") Timber.d("Successfully parsed NCX file: ${tocFileItem.absPath}")
val pageListElement = tocDocumentNode.selectFirstTag("pageList") as Element? val pageListElement = tocDocumentNode.selectFirstTag("pageList") as Element?
@ -495,15 +493,8 @@ class EpubParser(private val context: Context) {
Timber.d("Parsing chapters based on OPF spine for rendering order. NCX titles/depth will be used if available.") Timber.d("Parsing chapters based on OPF spine for rendering order. NCX titles/depth will be used if available.")
val tableOfContents = if (shouldUseToc) { val tableOfContents = if (shouldUseToc) {
val tocFileItem = manifestItems.values.firstOrNull { if (tocDocumentNode != null && ncxParentDir != null) {
it.absPath.endsWith(".ncx", ignoreCase = true) val navMapElement = tocDocumentNode.selectFirstTag("navMap") as Element?
}
if (tocFileItem != null) {
val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
val navMapElement = tocDocumentNode?.selectFirstTag("navMap") as Element?
if (navMapElement != null) { if (navMapElement != null) {
parseTableOfContents(navMapElement, ncxParentDir) parseTableOfContents(navMapElement, ncxParentDir)
@ -592,6 +583,21 @@ class EpubParser(private val context: Context) {
return result return result
} }
private fun resolveTocFileItem(
spine: Node,
manifestItems: Map<String, EpubManifestItem>
): EpubManifestItem? {
spine.getAttributeValue("toc")
?.takeIf { it.isNotBlank() }
?.let { tocId -> manifestItems[tocId] }
?.let { return it }
return manifestItems.values.firstOrNull {
it.mediaType.equals("application/x-dtbncx+xml", ignoreCase = true)
} ?: manifestItems.values.firstOrNull {
it.absPath.endsWith(".ncx", ignoreCase = true)
}
}
@Throws(EpubParserException::class) @Throws(EpubParserException::class)
private fun createEpubDocument(files: Map<String, EpubFile>): EpubDocument { private fun createEpubDocument(files: Map<String, EpubFile>): EpubDocument {

View file

@ -41,7 +41,7 @@ class OdtParser(private val context: Context) {
val mathJaxFileName = "tex-mml-chtml.js" val mathJaxFileName = "tex-mml-chtml.js"
val mathJaxFile = File(extractionDir, mathJaxFileName) val mathJaxFile = File(extractionDir, mathJaxFileName)
if (!mathJaxFile.exists()) { if (parseContent && !mathJaxFile.exists()) {
try { try {
context.assets.open("mathjax/$mathJaxFileName").use { input -> context.assets.open("mathjax/$mathJaxFileName").use { input ->
FileOutputStream(mathJaxFile).use { output -> FileOutputStream(mathJaxFile).use { output ->
@ -170,12 +170,12 @@ class OdtParser(private val context: Context) {
try { try {
if (!isFlat) { if (!isFlat) {
val zis = ZipInputStream(inputStream)
var entry = zis.nextEntry
var contentXmlBytes: ByteArray? = null var contentXmlBytes: ByteArray? = null
var stylesXmlBytes: ByteArray? = null var stylesXmlBytes: ByteArray? = null
val ignoredFiles = setOf("meta.xml", "settings.xml", "META-INF/manifest.xml") val ignoredFiles = setOf("meta.xml", "settings.xml", "META-INF/manifest.xml")
ZipInputStream(inputStream).use { zis ->
var entry = zis.nextEntry
while (entry != null) { while (entry != null) {
if (!entry.isDirectory) { if (!entry.isDirectory) {
when (entry.name) { when (entry.name) {
@ -197,6 +197,7 @@ class OdtParser(private val context: Context) {
} }
entry = zis.nextEntry entry = zis.nextEntry
} }
}
// Pre-parse styles if available // Pre-parse styles if available
stylesXmlBytes?.let { extractStyles(it.inputStream()) } stylesXmlBytes?.let { extractStyles(it.inputStream()) }

View file

@ -22,8 +22,6 @@ package com.aryan.reader.epubreader
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
@ -38,8 +36,6 @@ import android.widget.Toast
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -52,8 +48,10 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -76,6 +74,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
@ -86,7 +85,13 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.ReaderFontDiagnosticsTag
import com.aryan.reader.copyPlainTextToClipboard
import com.aryan.reader.getReaderTextureDataUri import com.aryan.reader.getReaderTextureDataUri
import com.aryan.reader.readerFontDiagnosticSummary
import com.aryan.reader.shared.detectFontVariant
import com.aryan.reader.shared.familyFilenameSignature
import com.aryan.reader.shared.fontWeightCssDescriptor
import com.aryan.reader.shared.ui.SharedSelectionMenuRect import com.aryan.reader.shared.ui.SharedSelectionMenuRect
import com.aryan.reader.shared.ui.SharedSelectionMenuSize import com.aryan.reader.shared.ui.SharedSelectionMenuSize
import com.aryan.reader.shared.ui.SharedSelectionMenuViewport import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
@ -96,6 +101,7 @@ import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
import timber.log.Timber import timber.log.Timber
import java.io.BufferedReader import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV" private const val TAG_LINK_NAV = "LINK_NAV"
@ -202,6 +208,49 @@ private fun getFontCssInjection(): String {
""".trimIndent() """.trimIndent()
} }
private fun buildCustomFontCssForWebView(customFontPath: String?, phase: String): String {
if (customFontPath == null) return ""
val fontFile = File(customFontPath)
val signature = fontFile.nameWithoutExtension.familyFilenameSignature()
val siblings = fontFile.parentFile?.listFiles()?.filter {
it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
it.nameWithoutExtension.familyFilenameSignature() == signature
} ?: listOf(fontFile)
Timber.tag(ReaderFontDiagnosticsTag).i(
"webview.$phase.customCss.start basePath='${fontFile.absolutePath}' " +
"exists=${fontFile.exists()} bytes=${fontFile.length()} " +
readerFontDiagnosticSummary(fontFile.nameWithoutExtension) +
" siblings=${siblings.joinToString { it.name }}"
)
val css = siblings.mapNotNull { sibling ->
val variant = sibling.nameWithoutExtension.detectFontVariant()
if (variant == null) {
Timber.tag(ReaderFontDiagnosticsTag).w(
"webview.$phase.customCss.skipNoVariant file='${sibling.name}' " +
readerFontDiagnosticSummary(sibling.nameWithoutExtension)
)
return@mapNotNull null
}
val weight = sibling.nameWithoutExtension.fontWeightCssDescriptor(variant.weight)
val style = if (variant.style == FontStyle.Italic) "italic" else "normal"
Timber.tag(ReaderFontDiagnosticsTag).i(
"webview.$phase.customCss.face file='${sibling.name}' fontWeight='$weight' fontStyle='$style' " +
readerFontDiagnosticSummary(sibling.nameWithoutExtension)
)
"@font-face { font-family: 'CustomFont'; src: url('file://${sibling.absolutePath}'); font-weight: $weight; font-style: $style; }"
}.joinToString(" ")
val faceCount = Regex("@font-face").findAll(css).count()
Timber.tag(ReaderFontDiagnosticsTag).i(
"webview.$phase.customCss.done faceCount=$faceCount cssLength=${css.length}"
)
return css
}
private fun getJsToInject(context: Context): String { private fun getJsToInject(context: Context): String {
return try { return try {
context.assets.open("epub_reader.js").use { inputStream -> context.assets.open("epub_reader.js").use { inputStream ->
@ -514,6 +563,7 @@ fun ChapterWebView(
onFootnoteRequested: (String) -> Unit, onFootnoteRequested: (String) -> Unit,
currentFontFamily: ReaderFont, currentFontFamily: ReaderFont,
customFontPath: String? = null, customFontPath: String? = null,
epubFontFaceCss: String = "",
currentTextAlign: ReaderTextAlign, currentTextAlign: ReaderTextAlign,
onHighlightClicked: () -> Unit, onHighlightClicked: () -> Unit,
onAutoScrollChapterEnd: () -> Unit = {}, onAutoScrollChapterEnd: () -> Unit = {},
@ -578,10 +628,14 @@ fun ChapterWebView(
showExternalLinkDialog = null showExternalLinkDialog = null
}) { Text(stringResource(R.string.action_open)) } }) { Text(stringResource(R.string.action_open)) }
TextButton(onClick = { TextButton(onClick = {
val clipboard = val copied = copyPlainTextToClipboard(
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager context = context,
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow) label = context.getString(R.string.clip_label_copied_link),
clipboard.setPrimaryClip(clip) text = urlToShow
)
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
showExternalLinkDialog = null showExternalLinkDialog = null
}) { Text(stringResource(R.string.action_copy)) } }) { Text(stringResource(R.string.action_copy)) }
} }
@ -927,13 +981,14 @@ fun ChapterWebView(
) )
val fontCss = getFontCssInjection().replace("\n", " ") val fontCss = getFontCssInjection().replace("\n", " ")
val customFontCss = if (customFontPath != null) { val customFontCss = buildCustomFontCssForWebView(customFontPath, "initial")
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }" val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss)
} else "" .filter { it.isNotBlank() }
val combinedCss = "$fontCss $customFontCss" .joinToString(separator = " ")
val escapedCombinedCss = escapeJsString(combinedCss)
val injectFontJs = val injectFontJs =
"var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$combinedCss\"; document.head.appendChild(style);" "var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$escapedCombinedCss\"; document.head.appendChild(style);"
view?.evaluateJavascript("javascript:$injectFontJs") { view?.evaluateJavascript("javascript:$injectFontJs") {
Timber.d("CSS Injection result: $it") Timber.d("CSS Injection result: $it")
} }
@ -1125,10 +1180,10 @@ fun ChapterWebView(
runtimeApplierState.logPending(chapterTitle) runtimeApplierState.logPending(chapterTitle)
} else { } else {
val fontCss = getFontCssInjection().replace("\n", " ") val fontCss = getFontCssInjection().replace("\n", " ")
val customFontCss = if (customFontPath != null) { val customFontCss = buildCustomFontCssForWebView(customFontPath, "runtime")
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }" val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss)
} else "" .filter { it.isNotBlank() }
val combinedCss = "$fontCss $customFontCss" .joinToString(separator = " ")
val fontNameForJs = if (customFontPath != null) { val fontNameForJs = if (customFontPath != null) {
"CustomFont" "CustomFont"
} else if (currentFontFamily == ReaderFont.ORIGINAL) { } else if (currentFontFamily == ReaderFont.ORIGINAL) {
@ -1166,8 +1221,9 @@ fun ChapterWebView(
if (fontCssChanged) { if (fontCssChanged) {
runtimeApplierState.fontCss = combinedCss runtimeApplierState.fontCss = combinedCss
val escapedCombinedCss = escapeJsString(combinedCss)
val injectFontJs = val injectFontJs =
"var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";" "var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$escapedCombinedCss\";"
webView.evaluateJavascript("javascript:$injectFontJs", null) webView.evaluateJavascript("javascript:$injectFontJs", null)
} }
@ -1304,10 +1360,14 @@ fun ChapterWebView(
) { ) {
PaginatedTextSelectionMenu( PaginatedTextSelectionMenu(
onCopy = { onCopy = {
val clipboard = val copied = copyPlainTextToClipboard(
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager context = context,
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), state.selectedText) label = context.getString(R.string.clip_label_copied_text),
clipboard.setPrimaryClip(clip) text = state.selectedText
)
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
state.finishActionModeCallback() state.finishActionModeCallback()
localWebViewRef?.clearFocus() localWebViewRef?.clearFocus()
localWebViewRef?.evaluateJavascript( localWebViewRef?.evaluateJavascript(

View file

@ -4,7 +4,10 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -20,12 +23,14 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -35,14 +40,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.areReaderAiFeaturesEnabled import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.readerModalMaxHeightDp
@Suppress("KotlinConstantConditions") @Suppress("KotlinConstantConditions")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -65,22 +71,25 @@ fun DictionarySettingsDialog(
val context = LocalContext.current val context = LocalContext.current
var dictionaryApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) } var dictionaryApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
var searchApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) } var searchApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val configuration = LocalConfiguration.current
val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
dictionaryApps = ExternalDictionaryHelper.getAvailableDictionaries(context) dictionaryApps = ExternalDictionaryHelper.getAvailableDictionaries(context)
searchApps = ExternalDictionaryHelper.getAvailableSearchApps(context) searchApps = ExternalDictionaryHelper.getAvailableSearchApps(context)
} }
Dialog(onDismissRequest = onDismiss) { ModalBottomSheet(
Surface( onDismissRequest = onDismiss,
shape = RoundedCornerShape(24.dp), sheetState = sheetState,
color = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp, contentWindowInsets = { WindowInsets.navigationBars }
modifier = Modifier.fillMaxWidth()
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = maxSheetHeight)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(24.dp) .padding(24.dp)
) { ) {
@ -214,7 +223,6 @@ fun DictionarySettingsDialog(
) )
} }
} }
}
} }
@Composable @Composable

View file

@ -27,8 +27,6 @@ package com.aryan.reader.epubreader
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.AudioManager import android.media.AudioManager
@ -140,6 +138,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -154,6 +153,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.aryan.reader.AiDefinitionResult import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import com.aryan.reader.copyPlainTextToClipboard
import com.aryan.reader.BookWordReplacementsSheet import com.aryan.reader.BookWordReplacementsSheet
import com.aryan.reader.BuiltInThemes import com.aryan.reader.BuiltInThemes
import com.aryan.reader.MainViewModel import com.aryan.reader.MainViewModel
@ -191,6 +191,7 @@ import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.readerSliderBookmarkPosition import com.aryan.reader.readerSliderBookmarkPosition
import com.aryan.reader.readerSliderChromeColors import com.aryan.reader.readerSliderChromeColors
import com.aryan.reader.readerSliderToggleState import com.aryan.reader.readerSliderToggleState
import com.aryan.reader.paginatedreader.CssParser
import com.aryan.reader.paginatedreader.BookPaginator import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.HeaderBlock import com.aryan.reader.paginatedreader.HeaderBlock
import com.aryan.reader.paginatedreader.IPaginator import com.aryan.reader.paginatedreader.IPaginator
@ -204,7 +205,10 @@ import com.aryan.reader.paginatedreader.ParagraphBlock
import com.aryan.reader.paginatedreader.QuoteBlock import com.aryan.reader.paginatedreader.QuoteBlock
import com.aryan.reader.paginatedreader.TextContentBlock import com.aryan.reader.paginatedreader.TextContentBlock
import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.paginatedreader.buildEpubFontFaceCss
import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.locatorForPersistence
import com.aryan.reader.paginatedreader.nativeVerticalChapterPageInfo
import com.aryan.reader.paginatedreader.nativeVerticalProgressForCompatPage import com.aryan.reader.paginatedreader.nativeVerticalProgressForCompatPage
import com.aryan.reader.paginatedreader.semanticBlockModule import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState import com.aryan.reader.rememberSearchState
@ -243,6 +247,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoBuf
import org.jsoup.Jsoup
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import timber.log.Timber import timber.log.Timber
@ -1127,6 +1132,20 @@ fun EpubReaderHost(
var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) } var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) } var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
var chapterHead by remember(currentChapterIndex) { mutableStateOf("") } var chapterHead by remember(currentChapterIndex) { mutableStateOf("") }
val epubFontFaceCss = remember(epubBook.css, epubBook.extractionBasePath) {
val fontFaces = epubBook.css.flatMap { (path, content) ->
CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = 16f,
density = 1f,
constraints = Constraints(maxWidth = 1, maxHeight = 1),
isDarkTheme = false,
adaptThemeColors = false
).fontFaces
}
buildEpubFontFaceCss(fontFaces, epubBook.extractionBasePath)
}
var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) } var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) }
var cfiToLoad by remember { mutableStateOf(initialCfi) } var cfiToLoad by remember { mutableStateOf(initialCfi) }
@ -1175,7 +1194,7 @@ fun EpubReaderHost(
fun currentNativeVerticalLocator(): Locator? { fun currentNativeVerticalLocator(): Locator? {
val bookPaginator = paginator as? BookPaginator val bookPaginator = paginator as? BookPaginator
val pageChapterIndex = bookPaginator?.findChapterIndexForPage(nativeVerticalCurrentPage) val pageChapterIndex = bookPaginator?.findChapterIndexForPage(nativeVerticalCurrentPage)
return nativeVerticalLocation?.locator return nativeVerticalLocation?.locatorForPersistence()
?: lastKnownLocator?.takeIf { pageChapterIndex == null || it.chapterIndex == pageChapterIndex } ?: lastKnownLocator?.takeIf { pageChapterIndex == null || it.chapterIndex == pageChapterIndex }
?: bookPaginator?.getLocatorForPage(nativeVerticalCurrentPage) ?: bookPaginator?.getLocatorForPage(nativeVerticalCurrentPage)
} }
@ -1187,6 +1206,8 @@ fun EpubReaderHost(
keepVisible: Boolean = false keepVisible: Boolean = false
) { ) {
if (locator != null) { if (locator != null) {
nativeVerticalScrollRequest = null
nativeVerticalProgressScrollRequest = null
nativeVerticalLocatorScrollRequest = locator nativeVerticalLocatorScrollRequest = locator
nativeVerticalLocatorScrollRequestId += 1L nativeVerticalLocatorScrollRequestId += 1L
nativeVerticalLocatorScrollKeepVisible = keepVisible nativeVerticalLocatorScrollKeepVisible = keepVisible
@ -2110,6 +2131,44 @@ fun EpubReaderHost(
} }
} }
val nativeVerticalDisplayPageInfo = remember(
isNativeVerticalMode,
nativeVerticalLocation,
nativeVerticalCurrentPage,
nativeVerticalTotalPages,
currentChapterIndex,
lastKnownLocator,
paginator
) {
if (!isNativeVerticalMode) {
null
} else {
nativeVerticalLocation?.chapterPageInfo ?: run {
val bookPaginator = paginator as? BookPaginator
val locationLocator = nativeVerticalLocation?.locator
val chapterIndex = nativeVerticalLocation?.chapterIndex
?: locationLocator?.chapterIndex
?: lastKnownLocator?.chapterIndex
?: currentChapterIndex
val locatorForChapter = locationLocator
?.takeIf { it.chapterIndex == chapterIndex }
?: lastKnownLocator?.takeIf { it.chapterIndex == chapterIndex }
val chapterLengthChars = chapters
.getOrNull(chapterIndex)
?.plainTextCharacterCount()
?: 0
nativeVerticalChapterPageInfo(
chapterCharOffset = locatorForChapter?.charOffset,
chapterLengthChars = chapterLengthChars,
chapterPageCount = bookPaginator?.chapterPageCounts?.get(chapterIndex),
compatPageIndex = nativeVerticalCurrentPage,
chapterStartPageIndex = bookPaginator?.chapterStartPageIndices?.get(chapterIndex)
)
}
}
}
fun currentEpubSliderPage(): Int { fun currentEpubSliderPage(): Int {
return when (currentRenderMode) { return when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> if (isNativeVerticalMode) { RenderMode.VERTICAL_SCROLL -> if (isNativeVerticalMode) {
@ -4280,6 +4339,74 @@ fun EpubReaderHost(
val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel() val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel()
val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel() val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel()
val isEpubJumpHistoryVisible = showBars && !searchState.isSearchActive && (epubJumpBackLabel != null || epubJumpForwardLabel != null) val isEpubJumpHistoryVisible = showBars && !searchState.isSearchActive && (epubJumpBackLabel != null || epubJumpForwardLabel != null)
val keyboardLineScrollPx = with(density) {
(configuration.screenHeightDp.dp.toPx() * 0.16f).roundToInt().coerceAtLeast(96)
}
val keyboardPageScrollPx = with(density) {
(configuration.screenHeightDp.dp.toPx() * 0.82f).roundToInt().coerceAtLeast(keyboardLineScrollPx)
}
fun scrollVerticalReaderBy(deltaPx: Int) {
if (isNativeVerticalMode) {
nativeVerticalScrollDeltaRequestId += 1L
nativeVerticalScrollDeltaAnimated = false
nativeVerticalScrollDeltaRequest = deltaPx.toFloat()
} else {
webViewRefForTts?.evaluateJavascript(
"window.scrollBy({ top: $deltaPx, behavior: 'smooth' });",
null
)
}
}
fun navigateReaderPage(targetPage: Int) {
when {
isNativeVerticalMode -> {
val lastPage = (nativeVerticalTotalPages - 1).coerceAtLeast(0)
nativeVerticalScrollRequest = targetPage.coerceIn(0, lastPage)
}
currentRenderMode == RenderMode.VERTICAL_SCROLL -> {
scrollVerticalReaderBy((targetPage - nativeVerticalCurrentPage).coerceIn(-1, 1) * keyboardPageScrollPx)
}
else -> {
scope.launch {
val pageCount = paginatedPagerState.pageCount
if (pageCount <= 0) return@launch
val page = targetPage.coerceIn(0, pageCount - 1)
if (page != paginatedPagerState.currentPage) {
if (isPageTurnAnimationEnabled) {
paginatedPagerState.animateScrollToPage(page, animationSpec = tween(700))
} else {
paginatedPagerState.scrollToPage(page)
}
}
}
}
}
}
fun navigateReaderPageBy(delta: Int) {
when {
isNativeVerticalMode -> navigateReaderPage(nativeVerticalCurrentPage + delta)
currentRenderMode == RenderMode.VERTICAL_SCROLL -> scrollVerticalReaderBy(delta * keyboardPageScrollPx)
else -> navigateReaderPage(paginatedPagerState.currentPage + delta)
}
}
fun navigateReaderBoundary(first: Boolean) {
when {
isNativeVerticalMode -> navigateReaderPage(if (first) 0 else nativeVerticalTotalPages - 1)
currentRenderMode == RenderMode.VERTICAL_SCROLL -> {
val script = if (first) {
"window.scrollTo({ top: 0, behavior: 'smooth' });"
} else {
"window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });"
}
webViewRefForTts?.evaluateJavascript(script, null)
}
else -> navigateReaderPage(if (first) 0 else paginatedPagerState.pageCount - 1)
}
}
Box( Box(
modifier = Modifier modifier = Modifier
@ -4363,6 +4490,17 @@ fun EpubReaderHost(
} }
} }
) )
.epubReaderKeyboardNavigationHandler(
enabled = !searchState.isSearchActive,
renderMode = currentRenderMode,
isRightToLeftPagination = rightToLeftPagination,
verticalLineScrollPx = keyboardLineScrollPx,
onVerticalScrollBy = ::scrollVerticalReaderBy,
onNextPage = { navigateReaderPageBy(1) },
onPreviousPage = { navigateReaderPageBy(-1) },
onFirstPage = { navigateReaderBoundary(first = true) },
onLastPage = { navigateReaderBoundary(first = false) }
)
) { ) {
when (currentRenderMode) { when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> { RenderMode.VERTICAL_SCROLL -> {
@ -4460,6 +4598,7 @@ fun EpubReaderHost(
}, },
onLocationChanged = { location -> onLocationChanged = { location ->
nativeVerticalLocation = location nativeVerticalLocation = location
location.locatorForPersistence()?.let { lastKnownLocator = it }
}, },
onTap = { onTap = {
focusManager.clearFocus() focusManager.clearFocus()
@ -4613,6 +4752,27 @@ fun EpubReaderHost(
""".trimIndent() """.trimIndent()
val chapterToRender = chapters[targetChapterIndex] val chapterToRender = chapters[targetChapterIndex]
val chapterFontFaceCss = remember(
chapterHead,
chapterToRender.absPath,
epubBook.extractionBasePath
) {
val fontFaces = Jsoup.parse("<head>$chapterHead</head>")
.head()
.getElementsByTag("style")
.flatMap { styleElement ->
CssParser.parse(
cssContent = styleElement.data(),
cssPath = chapterToRender.absPath,
baseFontSizeSp = 16f,
density = 1f,
constraints = Constraints(maxWidth = 1, maxHeight = 1),
isDarkTheme = false,
adaptThemeColors = false
).fontFaces
}
buildEpubFontFaceCss(fontFaces, epubBook.extractionBasePath)
}
fun isCurrentRenderedChapter(): Boolean = fun isCurrentRenderedChapter(): Boolean =
targetChapterIndex == currentChapterIndex targetChapterIndex == currentChapterIndex
@ -4975,6 +5135,9 @@ fun EpubReaderHost(
currentVerticalMargin = currentVerticalMargin, currentVerticalMargin = currentVerticalMargin,
currentFontFamily = currentFontFamily, currentFontFamily = currentFontFamily,
customFontPath = currentCustomFontPath, customFontPath = currentCustomFontPath,
epubFontFaceCss = listOf(epubFontFaceCss, chapterFontFaceCss)
.filter { it.isNotBlank() }
.joinToString(separator = " "),
currentTextAlign = currentTextAlign, currentTextAlign = currentTextAlign,
activeTextureId = activeTextureId, activeTextureId = activeTextureId,
activeTextureAlpha = activeTextureAlpha, activeTextureAlpha = activeTextureAlpha,
@ -6057,8 +6220,8 @@ fun EpubReaderHost(
?: "Chapter" ?: "Chapter"
val displayPageInfo = when { val displayPageInfo = when {
isNativeVerticalMode && nativeVerticalTotalPages > 0 -> isNativeVerticalMode && nativeVerticalDisplayPageInfo != null ->
" (${nativeVerticalCurrentPage + 1}/$nativeVerticalTotalPages)" " (${nativeVerticalDisplayPageInfo.currentPage}/${nativeVerticalDisplayPageInfo.totalPages})"
currentScrollHeightValue <= 0 || isChapterParsing -> "" currentScrollHeightValue <= 0 || isChapterParsing -> ""
else -> " ($currentPageInChapter/$totalPagesInCurrentChapter)" else -> " ($currentPageInChapter/$totalPagesInCurrentChapter)"
} }
@ -7059,9 +7222,14 @@ fun EpubReaderHost(
highlightToNoteCfi = null highlightToNoteCfi = null
}, },
onCopy = { onCopy = {
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val copied = copyPlainTextToClipboard(
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), targetHighlight.text) context = context,
clipboardManager.setPrimaryClip(clip) label = context.getString(R.string.clip_label_copied_text),
text = targetHighlight.text
)
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
highlightToNoteCfi = null highlightToNoteCfi = null
}, },
onDictionary = { onDictionary = {

View file

@ -107,8 +107,19 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.ReaderFontDiagnosticsTag
import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.readerModalMaxHeightDp
import com.aryan.reader.readerFontDiagnosticSummary
import com.aryan.reader.supportedFontMimeTypes import com.aryan.reader.supportedFontMimeTypes
import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.detectFontVariant
import com.aryan.reader.shared.fontFaceSummary
import com.aryan.reader.shared.familyFilenameSignature
import com.aryan.reader.shared.groupByFamily
import com.aryan.reader.shared.hasVariableWeightFace
import com.aryan.reader.shared.supportsVariableWeightAxis
import timber.log.Timber
import java.io.File import java.io.File
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -412,8 +423,64 @@ fun getComposeFontFamily(
): FontFamily { ): FontFamily {
if (customFontPath != null) { if (customFontPath != null) {
return try { return try {
FontFamily(Font(File(customFontPath))) val baseFile = File(customFontPath)
} catch (_: Exception) { val signature = baseFile.nameWithoutExtension.familyFilenameSignature()
val siblings = baseFile.parentFile?.listFiles()?.filter {
it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
it.nameWithoutExtension.familyFilenameSignature() == signature
} ?: listOf(baseFile)
Timber.tag(ReaderFontDiagnosticsTag).i(
"compose.custom.start basePath='${baseFile.absolutePath}' " +
"exists=${baseFile.exists()} bytes=${baseFile.length()} " +
readerFontDiagnosticSummary(baseFile.nameWithoutExtension) +
" siblings=${siblings.joinToString { it.name }}"
)
val seenVariants = mutableSetOf<String>()
val fontList = siblings.flatMap { sibling ->
try {
val variant = sibling.nameWithoutExtension.detectFontVariant()
val weights = if (sibling.nameWithoutExtension.supportsVariableWeightAxis()) {
variableReaderFontWeights
} else {
listOf(variant?.weight ?: FontWeight.Normal)
}
Timber.tag(ReaderFontDiagnosticsTag).i(
"compose.custom.candidate file='${sibling.name}' " +
readerFontDiagnosticSummary(sibling.nameWithoutExtension) +
" style=${variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal} " +
"weights=${weights.joinToString { it.weight.toString() }}"
)
weights.mapNotNull { weight ->
val style = variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal
if (seenVariants.add("${weight.weight}|$style")) {
Font(sibling, weight, style)
} else {
Timber.tag(ReaderFontDiagnosticsTag).i(
"compose.custom.skipDuplicate file='${sibling.name}' weight=${weight.weight} style=$style"
)
null
}
}
} catch (e: Exception) {
Timber.tag(ReaderFontDiagnosticsTag).e(e, "compose.custom.candidateFailed file='${sibling.name}'")
emptyList()
}
}
if (fontList.isNotEmpty()) {
Timber.tag(ReaderFontDiagnosticsTag).i(
"compose.custom.loaded base='${baseFile.name}' registeredVariants=${seenVariants.joinToString()}"
)
FontFamily(fontList)
} else {
Timber.tag(ReaderFontDiagnosticsTag).w(
"compose.custom.fallbackSingle base='${baseFile.name}' no inferred variants loaded"
)
FontFamily(Font(baseFile))
}
} catch (e: Exception) {
Timber.tag(ReaderFontDiagnosticsTag).e(e, "compose.custom.failed path='$customFontPath'")
FontFamily.Default FontFamily.Default
} }
} }
@ -436,6 +503,18 @@ fun getComposeFontFamily(
return FontFamily.Default return FontFamily.Default
} }
private val variableReaderFontWeights = listOf(
FontWeight.Thin,
FontWeight.ExtraLight,
FontWeight.Light,
FontWeight.Normal,
FontWeight.Medium,
FontWeight.SemiBold,
FontWeight.Bold,
FontWeight.ExtraBold,
FontWeight.Black
)
fun saveReaderSettings( fun saveReaderSettings(
context: Context, context: Context,
fontSize: Float, fontSize: Float,
@ -853,16 +932,56 @@ fun FontSelectionSheetContent(
) )
} }
} else { } else {
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { val customFontFamilies = remember(customFonts) {
items(customFonts) { fontEntity -> val grouped = customFonts
val isSelected = currentCustomFontPath == fontEntity.path .filterNot { it.isDeleted }
val fontFamily = remember(fontEntity.path) { .map { it.toSharedCustomFontItem() }
try { FontFamily(Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default } .groupByFamily()
Timber.tag(ReaderFontDiagnosticsTag).i(
"picker.grouped count=${grouped.size} families=${
grouped.joinToString { family ->
"${family.familyName} -> [" +
family.variants.joinToString { variantItem ->
val font = variantItem.font
"${font.displayName}:${variantItem.variant}"
} + "]"
} }
}"
)
grouped
}
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
items(customFontFamilies) { family ->
val baseFont = family.variants.firstOrNull {
val variant = it.variant
variant != null &&
variant.weight == FontWeight.Normal &&
variant.style == androidx.compose.ui.text.font.FontStyle.Normal
}?.font ?: family.variants.first().font
val isSelected = family.variants.any { it.font.path == currentCustomFontPath }
val fontFamily = remember(baseFont.path) {
getComposeFontFamily(ReaderFont.ORIGINAL, baseFont.path)
}
Timber.tag(ReaderFontDiagnosticsTag).i(
"picker.row family='${family.familyName}' base='${baseFont.displayName}' " +
"selected=$isSelected variants=${
family.variants.joinToString { "${it.font.displayName}:${it.variant}" }
}"
)
ListItem( ListItem(
headlineContent = { headlineContent = {
Text(fontEntity.displayName, fontFamily = fontFamily) Text(family.familyName, fontFamily = fontFamily)
},
supportingContent = {
Text(
buildString {
append(family.fontFaceSummary())
if (family.hasVariableWeightFace()) append(" - Variable weight")
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}, },
trailingContent = { trailingContent = {
if (isSelected) { if (isSelected) {
@ -873,7 +992,12 @@ fun FontSelectionSheetContent(
) )
} }
}, },
modifier = Modifier.clickable { onFontSelected(ReaderFont.ORIGINAL, fontEntity.path) }, modifier = Modifier.clickable {
Timber.tag(ReaderFontDiagnosticsTag).i(
"picker.selected family='${family.familyName}' base='${baseFont.displayName}' path='${baseFont.path}'"
)
onFontSelected(ReaderFont.ORIGINAL, baseFont.path)
},
colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors() colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors()
) )
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
@ -887,6 +1011,18 @@ fun FontSelectionSheetContent(
} }
} }
private fun CustomFontEntity.toSharedCustomFontItem(): CustomFontItem {
return CustomFontItem(
id = id,
displayName = displayName,
fileName = fileName,
fileExtension = fileExtension,
path = path,
timestamp = timestamp,
isDeleted = isDeleted
)
}
private const val REMOVE_EDGE_PADDING_KEY = "reader_remove_edge_padding" private const val REMOVE_EDGE_PADDING_KEY = "reader_remove_edge_padding"
fun saveRemoveEdgePadding(context: Context, enabled: Boolean) { fun saveRemoveEdgePadding(context: Context, enabled: Boolean) {
@ -915,6 +1051,8 @@ fun VisualOptionsSheet(
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val configuration = LocalConfiguration.current
val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState, sheetState = sheetState,
@ -924,6 +1062,8 @@ fun VisualOptionsSheet(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = maxSheetHeight)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 8.dp) .padding(horizontal = 24.dp, vertical = 8.dp)
) { ) {
Row( Row(

View file

@ -34,6 +34,8 @@ import androidx.compose.ui.input.key.type
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import com.aryan.reader.paginatedreader.AndroidEpubKeyCommand
import com.aryan.reader.paginatedreader.androidEpubKeyCommandOrNull
import com.aryan.reader.RenderMode import com.aryan.reader.RenderMode
@Composable @Composable
@ -150,3 +152,45 @@ fun Modifier.volumeScrollHandler(
} }
true true
} }
fun Modifier.epubReaderKeyboardNavigationHandler(
enabled: Boolean,
renderMode: RenderMode,
isRightToLeftPagination: Boolean,
verticalLineScrollPx: Int,
onVerticalScrollBy: (Int) -> Unit,
onNextPage: () -> Unit,
onPreviousPage: () -> Unit,
onFirstPage: () -> Unit,
onLastPage: () -> Unit
): Modifier = this.onPreviewKeyEvent { keyEvent ->
if (!enabled) return@onPreviewKeyEvent false
val command = androidEpubKeyCommandOrNull(
keyCode = keyEvent.nativeKeyEvent.keyCode,
rightToLeftPagination = isRightToLeftPagination,
isCtrlPressed = keyEvent.nativeKeyEvent.isCtrlPressed
) ?: return@onPreviewKeyEvent false
if (keyEvent.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent when (command) {
AndroidEpubKeyCommand.SCROLL_UP,
AndroidEpubKeyCommand.SCROLL_DOWN -> renderMode == RenderMode.VERTICAL_SCROLL
else -> true
}
}
when (command) {
AndroidEpubKeyCommand.SCROLL_UP -> {
if (renderMode != RenderMode.VERTICAL_SCROLL) return@onPreviewKeyEvent false
onVerticalScrollBy(-verticalLineScrollPx)
}
AndroidEpubKeyCommand.SCROLL_DOWN -> {
if (renderMode != RenderMode.VERTICAL_SCROLL) return@onPreviewKeyEvent false
onVerticalScrollBy(verticalLineScrollPx)
}
AndroidEpubKeyCommand.PREVIOUS_PAGE -> onPreviousPage()
AndroidEpubKeyCommand.NEXT_PAGE -> onNextPage()
AndroidEpubKeyCommand.FIRST_PAGE -> onFirstPage()
AndroidEpubKeyCommand.LAST_PAGE -> onLastPage()
}
true
}

View file

@ -328,16 +328,15 @@ private fun handleVerticalAutoAdvance(
val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex) val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex)
if (!nativeChunks.isNullOrEmpty()) { if (!nativeChunks.isNullOrEmpty()) {
val resumeIdx = findTtsChunkResumeIndex( val startChunkIndex = resolveTtsContinuationStartIndex(
chunks = nativeChunks, chunks = nativeChunks,
loadedChunkCount = loadedChunkCount,
sourceCfi = lastReadCfi, sourceCfi = lastReadCfi,
startOffsetInSource = currentState.startOffsetInSource, startOffsetInSource = currentState.startOffsetInSource,
currentText = currentState.currentText, currentText = currentState.currentText
currentChunkIndexFallback = currentState.currentChunkIndex
) )
if (resumeIdx != null && resumeIdx + 1 < nativeChunks.size) { if (startChunkIndex != null) {
val startChunkIndex = resumeIdx + 1
val token = getAuthToken() val token = getAuthToken()
ttsController.start( ttsController.start(
chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId), chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),

View file

@ -78,6 +78,29 @@ internal fun findTtsChunkResumeIndex(
return currentChunkIndexFallback.takeIf { it in chunks.indices } return currentChunkIndexFallback.takeIf { it in chunks.indices }
} }
internal fun resolveTtsContinuationStartIndex(
chunks: List<TtsChunk>,
loadedChunkCount: Int,
sourceCfi: String?,
startOffsetInSource: Int,
currentText: String?
): Int? {
val matchedResumeIndex = findTtsChunkResumeIndex(
chunks = chunks,
sourceCfi = sourceCfi,
startOffsetInSource = startOffsetInSource,
currentText = currentText,
currentChunkIndexFallback = -1
)
val matchedNextIndex = matchedResumeIndex?.plus(1)
if (matchedNextIndex != null && matchedNextIndex in chunks.indices) {
return matchedNextIndex
}
return loadedChunkCount.takeIf { it in chunks.indices }
}
private fun cfiPathContains(parentPath: String, childPath: String): Boolean { private fun cfiPathContains(parentPath: String, childPath: String): Boolean {
if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false
val parentParts = parentPath.split('/').filter { it.isNotEmpty() } val parentParts = parentPath.split('/').filter { it.isNotEmpty() }

View file

@ -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
}
}

View file

@ -202,6 +202,7 @@ class BookPaginator(
private val chapterTextRangeIndex = ConcurrentHashMap<Int, List<TextRangeIndex>>() private val chapterTextRangeIndex = ConcurrentHashMap<Int, List<TextRangeIndex>>()
private val chapterPageNavigationIndex = ConcurrentHashMap<Int, List<PageNavigationEntry>>() private val chapterPageNavigationIndex = ConcurrentHashMap<Int, List<PageNavigationEntry>>()
private val chapterAnchorPageIndex = ConcurrentHashMap<Int, Map<String, Int>>() private val chapterAnchorPageIndex = ConcurrentHashMap<Int, Map<String, Int>>()
private val expandedAllFontFaces = expandFontFacesWithSiblings(allFontFaces, extractionBasePath)
private var pageCountsAreAccurate by mutableStateOf(false) private var pageCountsAreAccurate by mutableStateOf(false)
private val finalizedChapterCounts = ConcurrentHashMap.newKeySet<Int>() private val finalizedChapterCounts = ConcurrentHashMap.newKeySet<Int>()
@ -385,7 +386,7 @@ class BookPaginator(
append("-pageCache:$LATEST_PAGE_CACHE_VERSION") append("-pageCache:$LATEST_PAGE_CACHE_VERSION")
append("-ua:${userAgentStylesheet.hashCode()}") append("-ua:${userAgentStylesheet.hashCode()}")
append("-css:${bookCss.hashCode()}") append("-css:${bookCss.hashCode()}")
append("-fonts:${allFontFaces.hashCode()}") append("-fonts:${expandedAllFontFaces.hashCode()}")
} }
val hash = configString.hashCode() val hash = configString.hashCode()
return hash return hash
@ -872,7 +873,7 @@ class BookPaginator(
density = density.density, density = density.density,
constraintsMaxWidth = constraints.maxWidth, constraintsMaxWidth = constraints.maxWidth,
constraintsMaxHeight = constraints.maxHeight, constraintsMaxHeight = constraints.maxHeight,
fontFaces = this.allFontFaces, fontFaces = expandedAllFontFaces,
styleConfigHash = currentConfigHash, styleConfigHash = currentConfigHash,
bookReplacementPreferencesJson = ReaderBookReplacementPreferencesJson.encode( bookReplacementPreferencesJson = ReaderBookReplacementPreferencesJson.encode(
bookReplacementPreferences.scopedToFile(bookReplacementFileId), bookReplacementPreferences.scopedToFile(bookReplacementFileId),

View file

@ -0,0 +1,132 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import com.aryan.reader.ReaderFontDiagnosticsTag
import com.aryan.reader.readerFontDiagnosticSummary
import com.aryan.reader.shared.detectFontVariant
import com.aryan.reader.shared.familyFilenameSignature
import com.aryan.reader.shared.fontWeightCssDescriptor
import timber.log.Timber
import java.io.File
private val supportedEpubFontExtensions = setOf("ttf", "otf", "woff", "woff2")
fun expandFontFacesWithSiblings(
fontFaces: List<FontFaceInfo>,
extractionPath: String
): List<FontFaceInfo> {
if (fontFaces.isEmpty()) return emptyList()
Timber.tag(ReaderFontDiagnosticsTag).i(
"epub.siblings.start inputCount=${fontFaces.size} extractionPath='$extractionPath'"
)
val result = fontFaces.toMutableList()
val existingKeys = result.mapTo(mutableSetOf()) { it.variantKey() }
val extractionRoot = File(extractionPath)
fontFaces.forEach { fontFace ->
val sourceFile = fontFace.resolvedFile(extractionRoot).takeIf { it.isFile } ?: return@forEach
val sourceSignature = sourceFile.familyFilenameSignature()
if (sourceSignature.isBlank()) return@forEach
val parent = sourceFile.parentFile ?: return@forEach
Timber.tag(ReaderFontDiagnosticsTag).i(
"epub.siblings.source family='${fontFace.fontFamily}' src='${fontFace.src}' " +
"file='${sourceFile.name}' " +
readerFontDiagnosticSummary(sourceFile.nameWithoutExtension)
)
parent.listFiles()
?.asSequence()
?.filter { candidate ->
candidate.isFile &&
candidate.extension.lowercase() in supportedEpubFontExtensions &&
candidate.nameWithoutExtension.familyFilenameSignature() == sourceSignature
}
?.forEach { candidate ->
val variant = candidate.nameWithoutExtension.detectFontVariant()
if (variant == null) {
Timber.tag(ReaderFontDiagnosticsTag).w(
"epub.siblings.skipNoVariant file='${candidate.name}' " +
readerFontDiagnosticSummary(candidate.nameWithoutExtension)
)
return@forEach
}
val src = candidate.toFontFaceSrc(extractionRoot)
val inferred = fontFace.copy(
src = src,
fontWeight = variant.weight,
fontStyle = variant.style
)
if (existingKeys.add(inferred.variantKey())) {
Timber.tag(ReaderFontDiagnosticsTag).i(
"epub.siblings.add family='${fontFace.fontFamily}' src='$src' variant=$variant"
)
result += inferred
} else {
Timber.tag(ReaderFontDiagnosticsTag).i(
"epub.siblings.skipDuplicate family='${fontFace.fontFamily}' src='$src' variant=$variant"
)
}
}
}
Timber.tag(ReaderFontDiagnosticsTag).i("epub.siblings.done outputCount=${result.size}")
return result
}
fun buildEpubFontFaceCss(
fontFaces: List<FontFaceInfo>,
extractionPath: String
): String {
val extractionRoot = File(extractionPath)
return expandFontFacesWithSiblings(fontFaces, extractionPath)
.distinctBy { it.variantKey() }
.mapNotNull { fontFace ->
val file = fontFace.resolvedFile(extractionRoot).takeIf { it.isFile } ?: return@mapNotNull null
val family = fontFace.fontFamily.cssString()
val url = file.toURI().toString().cssUrlString()
val weight = file.nameWithoutExtension.fontWeightCssDescriptor(fontFace.fontWeight ?: FontWeight.Normal)
val style = if (fontFace.fontStyle == FontStyle.Italic) "italic" else "normal"
Timber.tag(ReaderFontDiagnosticsTag).i(
"epub.css.face family='$family' file='${file.name}' fontWeight='$weight' fontStyle='$style' " +
readerFontDiagnosticSummary(file.nameWithoutExtension)
)
"@font-face { font-family: '$family'; src: url('$url'); font-weight: $weight; font-style: $style; }"
}
.joinToString(separator = " ")
}
private fun FontFaceInfo.resolvedFile(extractionRoot: File): File {
val source = File(src)
return if (source.isAbsolute) source else File(extractionRoot, src)
}
private fun FontFaceInfo.variantKey(): String {
return listOf(
fontFamily.trim().lowercase(),
src.replace('\\', '/').lowercase(),
fontWeight?.weight ?: FontWeight.Normal.weight,
fontStyle ?: FontStyle.Normal
).joinToString(separator = "|")
}
private fun File.toFontFaceSrc(extractionRoot: File): String {
val relative = runCatching {
extractionRoot.toPath().relativize(toPath()).toString()
}.getOrNull()
return relative
?.takeIf { !it.startsWith("..") && it.isNotBlank() }
?.replace(File.separatorChar, '/')
?: absolutePath
}
private fun File.familyFilenameSignature(): String {
return nameWithoutExtension.familyFilenameSignature()
}
private fun String.cssString(): String = replace("\\", "\\\\").replace("'", "\\'")
private fun String.cssUrlString(): String = replace("\\", "\\\\").replace("'", "%27")

View file

@ -24,6 +24,9 @@ import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import com.aryan.reader.ReaderFontDiagnosticsTag
import com.aryan.reader.readerFontDiagnosticSummary
import com.aryan.reader.shared.supportsVariableWeightAxis
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
@ -41,7 +44,11 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
if (fontFaces.isEmpty()) { if (fontFaces.isEmpty()) {
return emptyMap() return emptyMap()
} }
Timber.d("Loading ${fontFaces.size} font faces from extraction path: $extractionPath") val expandedFontFaces = expandFontFacesWithSiblings(fontFaces, extractionPath)
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.start inputCount=${fontFaces.size} expandedCount=${expandedFontFaces.size} extractionPath='$extractionPath'"
)
Timber.d("Loading ${expandedFontFaces.size} font faces from extraction path: $extractionPath")
// 1. Define a stable, global font cache directory. // 1. Define a stable, global font cache directory.
// This assumes the parent of the extraction path is a stable base directory for epubs. // This assumes the parent of the extraction path is a stable base directory for epubs.
@ -55,21 +62,39 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
// e.g., "d0e205bf-65cc-4ab4-93cc-cd2d613a7bb3.epub" from a longer temp path. // e.g., "d0e205bf-65cc-4ab4-93cc-cd2d613a7bb3.epub" from a longer temp path.
val bookId = File(extractionPath).name.substringBeforeLast("_") val bookId = File(extractionPath).name.substringBeforeLast("_")
val fontsByFamily = fontFaces.groupBy { val fontsByFamily = expandedFontFaces.groupBy {
it.fontFamily.trim().removeSurrounding("'").removeSurrounding("\"").lowercase() it.fontFamily.trim().removeSurrounding("'").removeSurrounding("\"").lowercase()
} }
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.grouped families=${
fontsByFamily.mapValues { (_, infos) ->
infos.joinToString { "${it.src}:${it.fontWeight}:${it.fontStyle}" }
}
}"
)
Timber.d("Grouped font faces by normalized family: ${fontsByFamily.keys}") Timber.d("Grouped font faces by normalized family: ${fontsByFamily.keys}")
return fontsByFamily.mapValues { (familyName, fontInfos) -> return fontsByFamily.mapValues { (familyName, fontInfos) ->
val fontList = fontInfos.mapNotNull { fontInfo -> val seenVariants = mutableSetOf<String>()
val fontList = fontInfos.flatMap { fontInfo ->
try { try {
Timber.d("Attempting to load font '$familyName' from resolved src path: '${fontInfo.src}'") Timber.d("Attempting to load font '$familyName' from resolved src path: '${fontInfo.src}'")
var fontFile = File(extractionPath, fontInfo.src) var fontFile = File(fontInfo.src).let { source ->
if (source.isAbsolute) source else File(extractionPath, fontInfo.src)
}
if (!fontFile.exists()) { if (!fontFile.exists()) {
Timber.tag(ReaderFontDiagnosticsTag).w(
"native.load.missing family='$familyName' src='${fontInfo.src}' resolved='${fontFile.absolutePath}'"
)
Timber.w("Font file not found at: ${fontFile.absolutePath}") Timber.w("Font file not found at: ${fontFile.absolutePath}")
return@mapNotNull null return@flatMap emptyList()
} }
val sourceName = fontFile.nameWithoutExtension
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.candidate family='$familyName' src='${fontInfo.src}' file='${fontFile.name}' " +
readerFontDiagnosticSummary(sourceName)
)
// Handle WOFF2 conversion and global caching // Handle WOFF2 conversion and global caching
if (fontFile.extension.equals("woff2", ignoreCase = true)) { if (fontFile.extension.equals("woff2", ignoreCase = true)) {
@ -80,9 +105,15 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
if (cachedTtfFile.exists()) { if (cachedTtfFile.exists()) {
// Use the globally cached TTF file if it exists // Use the globally cached TTF file if it exists
fontFile = cachedTtfFile fontFile = cachedTtfFile
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.woff2CacheHit src='${fontInfo.src}' cached='${cachedTtfFile.absolutePath}'"
)
Timber.d("Using globally cached TTF for '${fontInfo.src}'") Timber.d("Using globally cached TTF for '${fontInfo.src}'")
} else { } else {
// Convert and save the TTF to the global cache if it doesn't exist // Convert and save the TTF to the global cache if it doesn't exist
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.woff2Convert src='${fontInfo.src}' source='${fontFile.absolutePath}'"
)
Timber.d("Converting woff2 font: ${fontFile.name}") Timber.d("Converting woff2 font: ${fontFile.name}")
val woff2Data = fontFile.readBytes() val woff2Data = fontFile.readBytes()
val ttfData = Woff2Converter.convertWoff2ToTtf(woff2Data) val ttfData = Woff2Converter.convertWoff2ToTtf(woff2Data)
@ -90,31 +121,69 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
if (ttfData != null) { if (ttfData != null) {
cachedTtfFile.writeBytes(ttfData) cachedTtfFile.writeBytes(ttfData)
fontFile = cachedTtfFile // Use the newly created TTF file fontFile = cachedTtfFile // Use the newly created TTF file
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.woff2Converted src='${fontInfo.src}' cached='${cachedTtfFile.absolutePath}' bytes=${cachedTtfFile.length()}"
)
Timber.d("Successfully converted and globally cached woff2 as '${cachedTtfFile.name}'") Timber.d("Successfully converted and globally cached woff2 as '${cachedTtfFile.name}'")
} else { } else {
Timber.tag(ReaderFontDiagnosticsTag).e(
"native.load.woff2ConvertFailed src='${fontInfo.src}' source='${fontFile.absolutePath}'"
)
Timber.e("Failed to convert woff2 font: ${fontFile.name}") Timber.e("Failed to convert woff2 font: ${fontFile.name}")
return@mapNotNull null return@flatMap emptyList()
} }
} }
} }
Font( val weights = if (sourceName.supportsVariableWeightAxis()) {
fontFile, variableEpubFontWeights
fontInfo.fontWeight ?: FontWeight.Normal, } else {
fontInfo.fontStyle ?: FontStyle.Normal listOf(fontInfo.fontWeight ?: FontWeight.Normal)
}
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.registerPlan family='$familyName' file='${fontFile.name}' " +
"style=${fontInfo.fontStyle ?: FontStyle.Normal} weights=${weights.joinToString { it.weight.toString() }}"
)
weights.mapNotNull { weight ->
val style = fontInfo.fontStyle ?: FontStyle.Normal
if (seenVariants.add("${weight.weight}|$style")) {
Font(fontFile, weight, style)
} else {
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.skipDuplicate family='$familyName' file='${fontFile.name}' weight=${weight.weight} style=$style"
) )
} catch (e: Exception) {
Timber.e(e, "Error loading font: ${fontInfo.src}")
null null
} }
} }
} catch (e: Exception) {
Timber.tag(ReaderFontDiagnosticsTag).e(e, "native.load.failed family='$familyName' src='${fontInfo.src}'")
Timber.e(e, "Error loading font: ${fontInfo.src}")
emptyList()
}
}
if (fontList.isNotEmpty()) { if (fontList.isNotEmpty()) {
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.loaded family='$familyName' registeredVariants=${seenVariants.joinToString()}"
)
Timber.d("Loaded family '$familyName' with ${fontList.size} font styles.") Timber.d("Loaded family '$familyName' with ${fontList.size} font styles.")
FontFamily(fontList) FontFamily(fontList)
} else { } else {
Timber.tag(ReaderFontDiagnosticsTag).w("native.load.empty family='$familyName'")
Timber.w("Could not load any font styles for family '$familyName'.") Timber.w("Could not load any font styles for family '$familyName'.")
null null
} }
}.filterValues { it != null }.mapValues { it.value!! } }.filterValues { it != null }.mapValues { it.value!! }
} }
private val variableEpubFontWeights = listOf(
FontWeight.Thin,
FontWeight.ExtraLight,
FontWeight.Light,
FontWeight.Normal,
FontWeight.Medium,
FontWeight.SemiBold,
FontWeight.Bold,
FontWeight.ExtraBold,
FontWeight.Black
)

View file

@ -5,14 +5,13 @@ package com.aryan.reader.paginatedreader
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import com.aryan.reader.copyPlainTextToClipboard
import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isSpecified
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@ -175,6 +174,7 @@ import com.aryan.reader.epubreader.UserHighlight
import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.shared.ReaderBookReplacementPreferences import com.aryan.reader.shared.ReaderBookReplacementPreferences
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -192,6 +192,8 @@ import timber.log.Timber
import java.io.File import java.io.File
import java.net.URI import java.net.URI
import java.net.URLDecoder import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.Base64
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sqrt import kotlin.math.sqrt
@ -243,7 +245,8 @@ data class NativeVerticalLocation(
val firstVisibleItemSize: Int, val firstVisibleItemSize: Int,
val isAtStart: Boolean, val isAtStart: Boolean,
val isAtEnd: Boolean, val isAtEnd: Boolean,
val visibleTextRanges: List<NativeVerticalVisibleTextRange> = emptyList() val visibleTextRanges: List<NativeVerticalVisibleTextRange> = emptyList(),
val chapterPageInfo: NativeVerticalChapterPageInfo? = null
) )
data class NativeVerticalVisibleTextRange( data class NativeVerticalVisibleTextRange(
@ -253,6 +256,34 @@ data class NativeVerticalVisibleTextRange(
val endCharOffset: Int val endCharOffset: Int
) )
fun NativeVerticalLocation.locatorForPersistence(): Locator? {
val visibleRange = visibleTextRanges.firstOrNull()
return if (visibleRange != null) {
Locator(
chapterIndex = visibleRange.chapterIndex,
blockIndex = visibleRange.blockIndex,
charOffset = visibleRange.startCharOffset
)
} else {
locator
}
}
internal fun shouldFallbackNativeVerticalInitialScrollToCompatPage(
hasInitialLocator: Boolean,
didLocatorScroll: Boolean
): Boolean = !hasInitialLocator && !didLocatorScroll
internal fun nativeVerticalCenteredScrollDelta(
targetOffsetInViewport: Float,
viewportHeight: Float
): Float = targetOffsetInViewport - (viewportHeight * 0.5f)
data class NativeVerticalChapterPageInfo(
val currentPage: Int,
val totalPages: Int
)
private data class SelectionBlockKey( private data class SelectionBlockKey(
val pageIndex: Int, val pageIndex: Int,
val blockIndex: Int, val blockIndex: Int,
@ -318,7 +349,7 @@ internal fun nativeVerticalInitialChapterPrefetchOrder(
chapterCount: Int, chapterCount: Int,
initialChapter: Int, initialChapter: Int,
forwardCount: Int = 2, forwardCount: Int = 2,
backwardCount: Int = 1 backwardCount: Int = 0
): List<Int> { ): List<Int> {
if (chapterCount <= 0) return emptyList() if (chapterCount <= 0) return emptyList()
val start = initialChapter.coerceIn(0, chapterCount - 1) val start = initialChapter.coerceIn(0, chapterCount - 1)
@ -1026,6 +1057,68 @@ internal fun nativeVerticalProgressForCompatPage(pageIndex: Int, totalPageCount:
.coerceIn(0f, 100f) .coerceIn(0f, 100f)
} }
internal fun nativeVerticalChapterPageInfo(
chapterCharOffset: Int?,
chapterLengthChars: Int,
chapterPageCount: Int?,
compatPageIndex: Int,
chapterStartPageIndex: Int?
): NativeVerticalChapterPageInfo? {
val total = chapterPageCount?.takeIf { it > 0 } ?: return null
val pageIndexInChapter = if (chapterCharOffset != null && chapterLengthChars > 0) {
((chapterCharOffset.coerceIn(0, chapterLengthChars).toFloat() / chapterLengthChars.toFloat()) * (total - 1))
.roundToInt()
} else if (chapterStartPageIndex != null) {
compatPageIndex - chapterStartPageIndex
} else {
0
}.coerceIn(0, total - 1)
return NativeVerticalChapterPageInfo(
currentPage = pageIndexInChapter + 1,
totalPages = total
)
}
internal fun nativeVerticalChapterPageInfoForScroll(
itemChapterIndices: List<Int>,
itemWeights: List<Int>,
firstVisibleItemIndex: Int,
firstVisibleItemScrollOffset: Int,
firstVisibleItemSize: Int,
chapterPageCount: Int?
): NativeVerticalChapterPageInfo? {
val total = chapterPageCount?.takeIf { it > 0 } ?: return null
if (itemChapterIndices.isEmpty() || itemWeights.isEmpty()) {
return NativeVerticalChapterPageInfo(currentPage = 1, totalPages = total)
}
val safeIndex = firstVisibleItemIndex.coerceIn(0, minOf(itemChapterIndices.lastIndex, itemWeights.lastIndex))
val chapterIndex = itemChapterIndices[safeIndex]
val chapterItems = itemChapterIndices.indices.filter { index ->
index < itemWeights.size && itemChapterIndices[index] == chapterIndex
}
val totalChapterWeight = chapterItems.sumOf { itemWeights[it].coerceAtLeast(0) }
if (totalChapterWeight <= 0) {
return NativeVerticalChapterPageInfo(currentPage = 1, totalPages = total)
}
val completedWeight = chapterItems
.filter { it < safeIndex }
.sumOf { itemWeights[it].coerceAtLeast(0) }
val currentWeight = itemWeights[safeIndex].coerceAtLeast(0)
val currentFraction = if (firstVisibleItemSize > 0) {
(firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat())
.coerceIn(0f, 1f)
} else {
0f
}
val chapterProgress = ((completedWeight + currentWeight * currentFraction) / totalChapterWeight.toFloat())
.coerceIn(0f, 1f)
val pageIndexInChapter = (chapterProgress * (total - 1)).roundToInt().coerceIn(0, total - 1)
return NativeVerticalChapterPageInfo(
currentPage = pageIndexInChapter + 1,
totalPages = total
)
}
internal fun nativeVerticalProgressToItemIndex( internal fun nativeVerticalProgressToItemIndex(
itemWeights: List<Int>, itemWeights: List<Int>,
progressPercent: Float progressPercent: Float
@ -1119,31 +1212,45 @@ private fun findNativeVerticalFlowItemIndexForProgress(
) )
} }
private fun estimateNativeVerticalScrollProgressPercent( internal fun estimateNativeVerticalWeightedScrollProgressPercent(
items: List<NativeVerticalFlowItem>, itemWeights: List<Int>,
firstVisibleItemIndex: Int, firstVisibleItemIndex: Int,
firstVisibleItemScrollOffset: Int, firstVisibleItemScrollOffset: Int,
firstVisibleItemSize: Int firstVisibleItemSize: Int
): Float? { ): Float? {
if (items.isEmpty()) return null if (itemWeights.isEmpty()) return null
val totalWeight = items.sumOf { it.locationWeight }.takeIf { it > 0 } ?: return null val totalWeight = itemWeights.sumOf { it }.takeIf { it > 0 } ?: return null
val safeIndex = firstVisibleItemIndex.coerceIn(0, items.lastIndex) val safeIndex = firstVisibleItemIndex.coerceIn(0, itemWeights.lastIndex)
val completedWeight = items val completedWeight = itemWeights
.take(safeIndex) .take(safeIndex)
.sumOf { it.locationWeight } .sum()
val currentItem = items[safeIndex] val currentItemWeight = itemWeights[safeIndex]
val currentFraction = if (firstVisibleItemSize > 0) { val currentFraction = if (firstVisibleItemSize > 0) {
(firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat()) (firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat())
.coerceIn(0f, 1f) .coerceIn(0f, 1f)
} else { } else {
0f 0f
} }
val weightedPosition = completedWeight + (currentItem.locationWeight * currentFraction) val weightedPosition = completedWeight + (currentItemWeight * currentFraction)
return ((weightedPosition.toDouble() / totalWeight.toDouble()) * 100.0) return ((weightedPosition.toDouble() / totalWeight.toDouble()) * 100.0)
.toFloat() .toFloat()
.coerceIn(0f, 100f) .coerceIn(0f, 100f)
} }
private fun estimateNativeVerticalScrollProgressPercent(
items: List<NativeVerticalFlowItem>,
firstVisibleItemIndex: Int,
firstVisibleItemScrollOffset: Int,
firstVisibleItemSize: Int
): Float? {
return estimateNativeVerticalWeightedScrollProgressPercent(
itemWeights = items.map { it.locationWeight },
firstVisibleItemIndex = firstVisibleItemIndex,
firstVisibleItemScrollOffset = firstVisibleItemScrollOffset,
firstVisibleItemSize = firstVisibleItemSize
)
}
private fun findNativeVerticalFlowItemIndexForLocator( private fun findNativeVerticalFlowItemIndexForLocator(
items: List<NativeVerticalFlowItem>, items: List<NativeVerticalFlowItem>,
chapters: List<NativeVerticalFlowChapter>, chapters: List<NativeVerticalFlowChapter>,
@ -1333,7 +1440,7 @@ private fun resolveNativeVerticalVisibleTextRanges(
val start = blockStart + (firstVisibleOffset ?: 0) val start = blockStart + (firstVisibleOffset ?: 0)
val end = blockStart + (lastVisibleOffset ?: block.content.text.length) val end = blockStart + (lastVisibleOffset ?: block.content.text.length)
NativeVerticalVisibleTextRange( bounds.top to NativeVerticalVisibleTextRange(
chapterIndex = chapterIndex, chapterIndex = chapterIndex,
blockIndex = block.blockIndex, blockIndex = block.blockIndex,
startCharOffset = start, startCharOffset = start,
@ -1341,6 +1448,8 @@ private fun resolveNativeVerticalVisibleTextRanges(
) )
} }
} }
.sortedBy { it.first }
.map { it.second }
.toList() .toList()
} }
@ -1903,6 +2012,37 @@ private fun imageContentScale(style: BlockStyle): ContentScale {
} }
} }
internal fun nativeVerticalSvgContentFromDataUri(source: String): String? {
if (!source.startsWith("data:image/svg+xml", ignoreCase = true)) return null
val commaIndex = source.indexOf(',')
if (commaIndex < 0) return null
val metadata = source.substring(0, commaIndex)
val payload = source.substring(commaIndex + 1)
return runCatching {
if (metadata.contains(";base64", ignoreCase = true)) {
String(Base64.getDecoder().decode(payload), StandardCharsets.UTF_8)
} else {
URLDecoder.decode(payload.replace("+", "%2B"), "UTF-8")
}
}.getOrNull()
}
internal fun nativeVerticalImageModelData(source: String): Any {
val trimmed = source.trim()
return when {
trimmed.startsWith("<svg", ignoreCase = true) -> SvgData(trimmed)
trimmed.startsWith("data:image/svg+xml", ignoreCase = true) ->
nativeVerticalSvgContentFromDataUri(trimmed)?.let { SvgData(it) } ?: trimmed
trimmed.startsWith("file:", ignoreCase = true) ||
trimmed.startsWith("content:", ignoreCase = true) ||
trimmed.startsWith("android.resource:", ignoreCase = true) ||
trimmed.startsWith("http://", ignoreCase = true) ||
trimmed.startsWith("https://", ignoreCase = true) -> trimmed.toUri()
trimmed.startsWith("data:", ignoreCase = true) -> trimmed
else -> File(trimmed)
}
}
private fun tableCellImageModifier( private fun tableCellImageModifier(
block: ImageBlock, block: ImageBlock,
density: Density, density: Density,
@ -2023,7 +2163,9 @@ private fun WrappingContentLayout(
Layout(content = { Layout(content = {
AsyncImage( AsyncImage(
model = Builder(LocalContext.current).data(File(block.floatedImage.path)).build(), model = Builder(LocalContext.current)
.data(nativeVerticalImageModelData(block.floatedImage.path))
.build(),
contentDescription = block.floatedImage.altText, contentDescription = block.floatedImage.altText,
contentScale = imageContentScale(block.floatedImage.style) contentScale = imageContentScale(block.floatedImage.style)
) )
@ -2971,7 +3113,7 @@ fun PaginatedReaderScreen(
} }
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(ExperimentalSerializationApi::class, FlowPreview::class) @OptIn(ExperimentalSerializationApi::class)
@Composable @Composable
fun NativeVerticalReaderScreen( fun NativeVerticalReaderScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -3342,20 +3484,20 @@ fun NativeVerticalReaderScreen(
) )
if (exactDelta != null) { if (exactDelta != null) {
val scrollDelta = if (keepVisible) { val scrollDelta = if (keepVisible) {
val viewportHeight = rootWindowBounds.height nativeVerticalCenteredScrollDelta(
val comfortableTop = viewportHeight * 0.24f targetOffsetInViewport = exactDelta,
val comfortableBottom = viewportHeight * 0.76f viewportHeight = rootWindowBounds.height
if (exactDelta in comfortableTop..comfortableBottom) { )
0f
} else {
exactDelta - (viewportHeight * 0.38f)
}
} else { } else {
exactDelta exactDelta
} }
if (abs(scrollDelta) > 1f) { if (abs(scrollDelta) > 1f) {
if (animate) {
listState.animateScrollBy(scrollDelta)
} else {
listState.scrollBy(scrollDelta) listState.scrollBy(scrollDelta)
} }
}
if (keepVisible || abs(exactDelta) > 1f) return true if (keepVisible || abs(exactDelta) > 1f) return true
} }
@ -3364,7 +3506,11 @@ fun NativeVerticalReaderScreen(
chapters = chapters, chapters = chapters,
locator = locator locator = locator
) ?: return false ) ?: return false
if (animate) {
listState.animateScrollToItem(targetIndex)
} else {
listState.scrollToItem(targetIndex) listState.scrollToItem(targetIndex)
}
repeat(4) { repeat(4) {
withFrameNanos { } withFrameNanos { }
val refinedDelta = resolveNativeVerticalScrollDeltaForLocator( val refinedDelta = resolveNativeVerticalScrollDeltaForLocator(
@ -3379,14 +3525,20 @@ fun NativeVerticalReaderScreen(
) )
if (refinedDelta != null) { if (refinedDelta != null) {
val scrollDelta = if (keepVisible) { val scrollDelta = if (keepVisible) {
val viewportHeight = rootWindowBounds.height nativeVerticalCenteredScrollDelta(
refinedDelta - (viewportHeight * 0.38f) targetOffsetInViewport = refinedDelta,
viewportHeight = rootWindowBounds.height
)
} else { } else {
refinedDelta refinedDelta
} }
if (abs(scrollDelta) > 1f) { if (abs(scrollDelta) > 1f) {
if (animate) {
listState.animateScrollBy(scrollDelta)
} else {
listState.scrollBy(scrollDelta) listState.scrollBy(scrollDelta)
} }
}
return true return true
} }
} }
@ -3441,8 +3593,11 @@ fun NativeVerticalReaderScreen(
prefetchOrder.forEach { chapterIndex -> prefetchOrder.forEach { chapterIndex ->
if (!isActive) return@LaunchedEffect if (!isActive) return@LaunchedEffect
while (isActive && listState.isScrollInProgress) {
delay(80L)
}
loadFlowChapter(chapterIndex) loadFlowChapter(chapterIndex)
delay(16L) delay(80L)
} }
} }
@ -3453,9 +3608,18 @@ fun NativeVerticalReaderScreen(
didInitialScroll = true didInitialScroll = true
return@LaunchedEffect return@LaunchedEffect
} }
val didScroll = scrollToFlowLocator(targetLocator, animate = false) || val didLocatorScroll = scrollToFlowLocator(targetLocator, animate = false)
val didScroll = didLocatorScroll ||
if (shouldFallbackNativeVerticalInitialScrollToCompatPage(
hasInitialLocator = initialNativeLocator != null,
didLocatorScroll = didLocatorScroll
)
) {
scrollToCompatPage(initialNativePageIndex, animate = false) scrollToCompatPage(initialNativePageIndex, animate = false)
if (didScroll) { } else {
false
}
if (didScroll || initialNativeLocator != null) {
didInitialScroll = true didInitialScroll = true
} }
} }
@ -3471,7 +3635,12 @@ fun NativeVerticalReaderScreen(
LaunchedEffect(scrollRequestLocatorId, scrollRequestLocator, scrollRequestLocatorKeepVisible, flowChapters, rootWindowBounds) { LaunchedEffect(scrollRequestLocatorId, scrollRequestLocator, scrollRequestLocatorKeepVisible, flowChapters, rootWindowBounds) {
val requestedLocator = scrollRequestLocator ?: return@LaunchedEffect val requestedLocator = scrollRequestLocator ?: return@LaunchedEffect
if (flowChapters == null || rootWindowBounds == Rect.Zero) return@LaunchedEffect if (flowChapters == null || rootWindowBounds == Rect.Zero) return@LaunchedEffect
if (scrollToFlowLocator(requestedLocator, animate = false, keepVisible = scrollRequestLocatorKeepVisible)) { if (scrollToFlowLocator(
locator = requestedLocator,
animate = scrollRequestLocatorKeepVisible,
keepVisible = scrollRequestLocatorKeepVisible
)
) {
paginator.onUserScrolledTo( paginator.onUserScrolledTo(
nativeVerticalCompatPageForProgress( nativeVerticalCompatPageForProgress(
estimateNativeVerticalProgressPercent(book, requestedLocator) ?: 0f, estimateNativeVerticalProgressPercent(book, requestedLocator) ?: 0f,
@ -3506,6 +3675,7 @@ fun NativeVerticalReaderScreen(
var lastReportedTotalPageCount by remember { mutableIntStateOf(0) } var lastReportedTotalPageCount by remember { mutableIntStateOf(0) }
var lastReportedProgressPercent by remember { mutableFloatStateOf(-1f) } var lastReportedProgressPercent by remember { mutableFloatStateOf(-1f) }
var lastReportedLocator by remember { mutableStateOf<Locator?>(null) } var lastReportedLocator by remember { mutableStateOf<Locator?>(null) }
var lastReportedChapterPageInfo by remember { mutableStateOf<NativeVerticalChapterPageInfo?>(null) }
var lastReportedVisibleTextRanges by remember { mutableStateOf<List<NativeVerticalVisibleTextRange>>(emptyList()) } var lastReportedVisibleTextRanges by remember { mutableStateOf<List<NativeVerticalVisibleTextRange>>(emptyList()) }
LaunchedEffect(paginator, totalPageCount, rootWindowBounds, blockLayoutMap, flowChapters, flowItems) { LaunchedEffect(paginator, totalPageCount, rootWindowBounds, blockLayoutMap, flowChapters, flowItems) {
@ -3531,7 +3701,6 @@ fun NativeVerticalReaderScreen(
initialScrollComplete = didInitialScroll initialScrollComplete = didInitialScroll
) )
} }
.debounce(80)
.collectLatest { sample -> .collectLatest { sample ->
if (!sample.initialScrollComplete) return@collectLatest if (!sample.initialScrollComplete) return@collectLatest
val total = sample.totalPageCount val total = sample.totalPageCount
@ -3561,18 +3730,32 @@ fun NativeVerticalReaderScreen(
} }
val compatPage = nativeVerticalCompatPageForProgress(progressPercent, total) val compatPage = nativeVerticalCompatPageForProgress(progressPercent, total)
paginator.onUserScrolledTo(compatPage) paginator.onUserScrolledTo(compatPage)
val visibleChapterIndex = locator?.chapterIndex
?: flowItems.getOrNull(sample.firstVisiblePageIndex)?.chapterIndex
val chapterPageInfo = visibleChapterIndex?.let { chapterIndex ->
nativeVerticalChapterPageInfoForScroll(
itemChapterIndices = flowItems.map { it.chapterIndex },
itemWeights = flowItems.map { it.locationWeight },
firstVisibleItemIndex = sample.firstVisiblePageIndex,
firstVisibleItemScrollOffset = sample.firstVisiblePageScrollOffset,
firstVisibleItemSize = sample.firstVisibleItemSize,
chapterPageCount = paginator.chapterPageCounts[chapterIndex]
)
}
if ( if (
compatPage != lastReportedVisiblePage || compatPage != lastReportedVisiblePage ||
total != lastReportedTotalPageCount || total != lastReportedTotalPageCount ||
abs(progressPercent - lastReportedProgressPercent) >= 0.05f || abs(progressPercent - lastReportedProgressPercent) >= 0.05f ||
locator != lastReportedLocator || locator != lastReportedLocator ||
chapterPageInfo != lastReportedChapterPageInfo ||
visibleTextRanges != lastReportedVisibleTextRanges visibleTextRanges != lastReportedVisibleTextRanges
) { ) {
lastReportedVisiblePage = compatPage lastReportedVisiblePage = compatPage
lastReportedTotalPageCount = total lastReportedTotalPageCount = total
lastReportedProgressPercent = progressPercent lastReportedProgressPercent = progressPercent
lastReportedLocator = locator lastReportedLocator = locator
lastReportedChapterPageInfo = chapterPageInfo
lastReportedVisibleTextRanges = visibleTextRanges lastReportedVisibleTextRanges = visibleTextRanges
onLocationChanged( onLocationChanged(
NativeVerticalLocation( NativeVerticalLocation(
@ -3586,7 +3769,8 @@ fun NativeVerticalReaderScreen(
firstVisibleItemSize = sample.firstVisibleItemSize, firstVisibleItemSize = sample.firstVisibleItemSize,
isAtStart = sample.isAtStart, isAtStart = sample.isAtStart,
isAtEnd = sample.isAtEnd, isAtEnd = sample.isAtEnd,
visibleTextRanges = visibleTextRanges visibleTextRanges = visibleTextRanges,
chapterPageInfo = chapterPageInfo
) )
) )
onProgressChanged(compatPage, total, progressPercent) onProgressChanged(compatPage, total, progressPercent)
@ -3644,11 +3828,14 @@ fun NativeVerticalReaderScreen(
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { TextButton(onClick = {
val clipboardManager = val copied = copyPlainTextToClipboard(
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager context = context,
clipboardManager.setPrimaryClip( label = context.getString(R.string.clip_label_copied_text),
ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), urlToShow) text = urlToShow
) )
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
showExternalLinkDialog = null showExternalLinkDialog = null
}) { Text(stringResource(R.string.action_copy)) } }) { Text(stringResource(R.string.action_copy)) }
} }
@ -3673,7 +3860,8 @@ fun NativeVerticalReaderScreen(
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize()
.sharedAcceleratedLazyWheelScroll(listState),
contentPadding = PaddingValues(top = verticalPadding, bottom = verticalPadding) contentPadding = PaddingValues(top = verticalPadding, bottom = verticalPadding)
) { ) {
itemsIndexed( itemsIndexed(
@ -3820,11 +4008,14 @@ fun NativeVerticalReaderScreen(
) { ) {
PaginatedTextSelectionMenu( PaginatedTextSelectionMenu(
onCopy = { onCopy = {
val clipboardManager = val copied = copyPlainTextToClipboard(
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager context = context,
clipboardManager.setPrimaryClip( label = context.getString(R.string.clip_label_copied_text),
ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), sel.text) text = sel.text
) )
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
activeSelection = null activeSelection = null
}, },
onSelectAll = null, onSelectAll = null,
@ -5806,10 +5997,14 @@ internal fun PaginatedReaderContent(
Row(horizontalArrangement = Arrangement.End) { Row(horizontalArrangement = Arrangement.End) {
TextButton( TextButton(
onClick = { onClick = {
val clipboard = val copied = copyPlainTextToClipboard(
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager context = context,
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow) label = context.getString(R.string.clip_label_copied_link),
clipboard.setPrimaryClip(clip) text = urlToShow
)
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
showExternalLinkDialog = null showExternalLinkDialog = null
}) { Text(stringResource(R.string.action_copy)) } }) { Text(stringResource(R.string.action_copy)) }
TextButton( TextButton(
@ -7535,10 +7730,14 @@ internal fun PaginatedReaderContent(
) { ) {
PaginatedTextSelectionMenu( PaginatedTextSelectionMenu(
onCopy = { onCopy = {
val clipboardManager = val copied = copyPlainTextToClipboard(
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager context = context,
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), sel.text) label = context.getString(R.string.clip_label_copied_text),
clipboardManager.setPrimaryClip(clip) text = sel.text
)
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
activeSelection = null activeSelection = null
}, },
onSelectAll = null, onSelectAll = null,
@ -8107,15 +8306,15 @@ private fun RenderFlexChildBlock(
searchHighlighted searchHighlighted
} }
// Apply block specific styles (like header font weight) val finalStyle = when (block) {
val finalStyle = if (block is HeaderBlock) { is HeaderBlock -> createHeaderTextStyle(
createHeaderTextStyle(
baseStyle = textStyle, baseStyle = textStyle,
level = block.level, level = block.level,
textAlign = block.textAlign textAlign = block.textAlign
) )
} else { is ParagraphBlock -> textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign)
textStyle is QuoteBlock -> textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign)
is ListItemBlock -> textStyle
} }
TextWithEmphasis( TextWithEmphasis(
@ -8157,7 +8356,7 @@ private fun RenderFlexChildBlock(
if (itemMarkerImage != null) { if (itemMarkerImage != null) {
val imageRequest = val imageRequest =
Builder(LocalContext.current).data(File(itemMarkerImage)) Builder(LocalContext.current).data(nativeVerticalImageModelData(itemMarkerImage))
.crossfade(true).build() .crossfade(true).build()
val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() } val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
@ -8227,7 +8426,7 @@ private fun RenderFlexChildBlock(
} else if (style.width != Dp.Unspecified && style.width > 0.dp) { } else if (style.width != Dp.Unspecified && style.width > 0.dp) {
Modifier.width(style.width) Modifier.width(style.width)
} else { } else {
Modifier Modifier.fillMaxWidth()
} }
) )
.then( .then(
@ -8250,7 +8449,7 @@ private fun RenderFlexChildBlock(
) )
AsyncImage( AsyncImage(
model = Builder(LocalContext.current).data(File(childBlock.path)).crossfade(true) model = Builder(LocalContext.current).data(nativeVerticalImageModelData(childBlock.path)).crossfade(true)
.build(), .build(),
contentDescription = childBlock.altText, contentDescription = childBlock.altText,
modifier = imageModifier, modifier = imageModifier,
@ -8338,9 +8537,7 @@ private fun RenderFlexChildBlock(
} else if (blockInCell is ImageBlock) { } else if (blockInCell is ImageBlock) {
AsyncImage( AsyncImage(
model = Builder(LocalContext.current).data( model = Builder(LocalContext.current).data(
File( nativeVerticalImageModelData(blockInCell.path)
blockInCell.path
)
).build(), ).build(),
contentDescription = blockInCell.altText, contentDescription = blockInCell.altText,
contentScale = imageContentScale(blockInCell.style), contentScale = imageContentScale(blockInCell.style),

View file

@ -21,6 +21,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.io.ByteArrayOutputStream
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -30,6 +31,8 @@ import kotlin.random.Random
private const val PDF_PREVIEW_MAX_WIDTH_PX = 1080 private const val PDF_PREVIEW_MAX_WIDTH_PX = 1080
private const val PDF_PREVIEW_MAX_HEIGHT_PX = 2048 private const val PDF_PREVIEW_MAX_HEIGHT_PX = 2048
private const val PDF_PREVIEW_MAX_BYTES = 16L * 1024L * 1024L private const val PDF_PREVIEW_MAX_BYTES = 16L * 1024L * 1024L
private const val PDF_ENCRYPT_MARKER_TAIL_BYTES = 512 * 1024
private val PDF_ENCRYPT_MARKER = "/Encrypt".toByteArray(Charsets.US_ASCII)
object PdfiumCoreProvider { object PdfiumCoreProvider {
val core: PdfiumCoreKt by lazy { val core: PdfiumCoreKt by lazy {
@ -42,7 +45,8 @@ internal data class DocumentCacheItem(
val pfd: ParcelFileDescriptor?, val pfd: ParcelFileDescriptor?,
val totalPages: Int, val totalPages: Int,
val pageAspectRatios: List<Float>, val pageAspectRatios: List<Float>,
val flatTableOfContents: List<TocEntry> val flatTableOfContents: List<TocEntry>,
val isPasswordProtectedPdf: Boolean = false
) )
internal class DocumentCache(val maxSize: Int = 3) { internal class DocumentCache(val maxSize: Int = 3) {
@ -123,6 +127,68 @@ class PdfPrintDocumentAdapter(
} }
} }
internal fun pdfBytesContainEncryptMarker(bytes: ByteArray): Boolean {
for (index in 0..bytes.size - PDF_ENCRYPT_MARKER.size) {
var matches = true
for (offset in PDF_ENCRYPT_MARKER.indices) {
if (bytes[index + offset] != PDF_ENCRYPT_MARKER[offset]) {
matches = false
break
}
}
if (matches && bytes.getOrNull(index + PDF_ENCRYPT_MARKER.size)?.isPdfNameDelimiter() != false) {
return true
}
}
return false
}
internal fun isPdfLikelyEncryptedForPrint(context: Context, uri: Uri): Boolean {
return try {
context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
FileInputStream(pfd.fileDescriptor).use { input ->
val knownSize = pfd.statSize.takeIf { it >= 0L }
?: runCatching { input.channel.size() }.getOrNull()?.takeIf { it >= 0L }
val tailBytes = if (knownSize != null && knownSize > PDF_ENCRYPT_MARKER_TAIL_BYTES) {
input.channel.position(knownSize - PDF_ENCRYPT_MARKER_TAIL_BYTES)
input.readBytes()
} else if (knownSize != null) {
input.readBytes()
} else {
input.readLastBytes(PDF_ENCRYPT_MARKER_TAIL_BYTES)
}
pdfBytesContainEncryptMarker(tailBytes)
}
} ?: false
} catch (e: Exception) {
Timber.tag("PdfPrint").w(e, "Could not inspect PDF encryption marker before print")
false
}
}
private fun Byte.isPdfNameDelimiter(): Boolean {
return when (toInt().toChar()) {
'\u0000', '\t', '\n', '\u000C', '\r', ' ', '(', ')', '<', '>', '[', ']', '{', '}', '/', '%' -> true
else -> false
}
}
private fun FileInputStream.readLastBytes(maxBytes: Int): ByteArray {
val output = ByteArrayOutputStream(maxBytes)
val buffer = ByteArray(8192)
var bytesRead: Int
while (read(buffer).also { bytesRead = it } > 0) {
if (output.size() + bytesRead <= maxBytes) {
output.write(buffer, 0, bytesRead)
} else {
val combined = output.toByteArray() + buffer.copyOf(bytesRead)
output.reset()
output.write(combined, combined.size - maxBytes, maxBytes)
}
}
return output.toByteArray()
}
internal fun generateShortId(): String { internal fun generateShortId(): String {
return Random.nextInt(1000, 9999).toString() return Random.nextInt(1000, 9999).toString()
} }

View file

@ -166,7 +166,7 @@ private fun Throwable.readablePdfErrorDetail(): String {
private const val PDF_TILE_SIZE_DP = 256 private const val PDF_TILE_SIZE_DP = 256
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072 private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
private const val PDF_TILE_SCALE_TOLERANCE = 0.06f private const val PDF_TILE_SCALE_TOLERANCE = 0.03f
private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 60L private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 60L
private const val PDF_TILE_RENDER_IDLE_COOLDOWN_MS = 220L private const val PDF_TILE_RENDER_IDLE_COOLDOWN_MS = 220L
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
@ -462,7 +462,13 @@ internal fun PdfPageComposable(
var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) } var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) }
var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) } var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) }
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage) val needsTilingNow = shouldRenderPdfHighResTiles(
effectiveScale = effectiveScale,
targetWidthPx = actualBitmapWidthPx,
targetHeightPx = actualBitmapHeightPx,
isVerticalScroll = isVerticalScroll,
isActivePage = isActivePage
)
val canvasWidthPx = remember { mutableFloatStateOf(0f) } val canvasWidthPx = remember { mutableFloatStateOf(0f) }
val canvasHeightPx = remember { mutableFloatStateOf(0f) } val canvasHeightPx = remember { mutableFloatStateOf(0f) }
@ -1241,7 +1247,7 @@ internal fun PdfPageComposable(
} }
} }
if (latestShouldPauseHighResTileRendering && renderScale > 1f) { if (latestShouldPauseHighResTileRendering) {
if (shouldLogTileSample) { if (shouldLogTileSample) {
PdfVerticalPerfLog.d( PdfVerticalPerfLog.d(
"tile-render-paused mode=$tileLogMode page=$pageIndex reason=motion scale=${PdfVerticalPerfLog.f(renderScale)} " + "tile-render-paused mode=$tileLogMode page=$pageIndex reason=motion scale=${PdfVerticalPerfLog.f(renderScale)} " +
@ -1260,7 +1266,7 @@ internal fun PdfPageComposable(
} }
delay(PDF_TILE_IDLE_RENDER_DELAY_MS) delay(PDF_TILE_IDLE_RENDER_DELAY_MS)
if (!isActive) return@collectLatest if (!isActive) return@collectLatest
if (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) { if (latestShouldPauseHighResTileRendering) {
if (shouldLogHighResTile) { if (shouldLogHighResTile) {
PdfVerticalPerfLog.d( PdfVerticalPerfLog.d(
"tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-resumed missing=${tilesToRenderIds.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" "tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-resumed missing=${tilesToRenderIds.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
@ -1323,7 +1329,7 @@ internal fun PdfPageComposable(
) )
} }
if (!isActive) return@withLock if (!isActive) return@withLock
if (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) { if (latestShouldPauseHighResTileRendering) {
if (shouldLogHighResTile) { if (shouldLogHighResTile) {
PdfVerticalPerfLog.d( PdfVerticalPerfLog.d(
"tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-started-before-native tile=$tileId scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" "tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-started-before-native tile=$tileId scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
@ -1380,7 +1386,7 @@ internal fun PdfPageComposable(
} }
return@collectLatest return@collectLatest
} }
if (renderedTiles.isNotEmpty() && latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) { if (renderedTiles.isNotEmpty() && latestShouldPauseHighResTileRendering) {
if (shouldLogHighResTile) { if (shouldLogHighResTile) {
PdfVerticalPerfLog.d( PdfVerticalPerfLog.d(
"tile-render-discarded mode=$tileLogMode page=$pageIndex reason=motion-before-commit rendered=${renderedTiles.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" "tile-render-discarded mode=$tileLogMode page=$pageIndex reason=motion-before-commit rendered=${renderedTiles.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
@ -4040,9 +4046,9 @@ internal fun PdfPageComposable(
val stableTiles = remember(tiles) { StableHolder(tiles) } val stableTiles = remember(tiles) { StableHolder(tiles) }
val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) } val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) }
val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) } val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) }
val shouldDrawHighResTiles = !shouldPauseHighResTileRendering val shouldDrawHighResTiles = !shouldPauseHighResTileRendering && needsTilingNow
LaunchedEffect(shouldDrawHighResTiles, stableTiles.item.size, effectiveScale) { LaunchedEffect(shouldDrawHighResTiles, stableTiles.item.size, effectiveScale) {
if (stableTiles.item.isNotEmpty() && effectiveScale > 1f) { if (stableTiles.item.isNotEmpty() && shouldDrawHighResTiles) {
PdfVerticalPerfLog.d( PdfVerticalPerfLog.d(
"tile-display mode=${if (isVerticalScroll) "vertical" else "pagination"} page=$pageIndex " + "tile-display mode=${if (isVerticalScroll) "vertical" else "pagination"} page=$pageIndex " +
"visible=$shouldDrawHighResTiles tiles=${stableTiles.item.size} pause=$shouldPauseHighResTileRendering " + "visible=$shouldDrawHighResTiles tiles=${stableTiles.item.size} pause=$shouldPauseHighResTileRendering " +
@ -4513,8 +4519,7 @@ private fun PdfBitmapLayer(
} }
} }
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000 if (shouldDrawHighResTiles) {
if (needsTiling && shouldDrawHighResTiles) {
tiles.forEach { tile -> tiles.forEach { tile ->
if ( if (
tile.bitmap.isCanvasSafeBitmap( tile.bitmap.isCanvasSafeBitmap(
@ -5353,7 +5358,7 @@ private fun PdfPageRenderer(
) { ) {
MagnifierComposable( MagnifierComposable(
sourceBitmap = staticData.bitmap.item.asImageBitmap(), sourceBitmap = staticData.bitmap.item.asImageBitmap(),
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(), tiles = if (staticData.shouldDrawHighResTiles) staticData.tiles.item else emptyList(),
currentScale = effectiveScale, currentScale = effectiveScale,
magnifierCenterOnBitmap = magnifierCenterTarget, magnifierCenterOnBitmap = magnifierCenterTarget,
contentWidthPx = staticData.targetWidth, contentWidthPx = staticData.targetWidth,

View file

@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
@ -28,8 +29,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
@ -64,6 +67,7 @@ import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -74,6 +78,7 @@ import com.aryan.reader.R
import com.aryan.reader.epubreader.OptionSegmentedControl import com.aryan.reader.epubreader.OptionSegmentedControl
import com.aryan.reader.epubreader.SystemUiMode import com.aryan.reader.epubreader.SystemUiMode
import com.aryan.reader.epubreader.titleRes import com.aryan.reader.epubreader.titleRes
import com.aryan.reader.readerModalMaxHeightDp
import com.aryan.reader.shared.reader.ReaderPageSpreadMode import com.aryan.reader.shared.reader.ReaderPageSpreadMode
@ -568,6 +573,8 @@ fun PdfVisualOptionsSheet(
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val configuration = LocalConfiguration.current
val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState, sheetState = sheetState,
@ -577,6 +584,8 @@ fun PdfVisualOptionsSheet(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = maxSheetHeight)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 8.dp) .padding(horizontal = 24.dp, vertical = 8.dp)
.padding(bottom = 32.dp) .padding(bottom = 32.dp)
) { ) {

View file

@ -72,7 +72,8 @@ internal fun pdfOverflowMenuSections(
hasHiddenToolbarTools: Boolean, hasHiddenToolbarTools: Boolean,
isPro: Boolean, isPro: Boolean,
effectiveFileType: FileType, effectiveFileType: FileType,
hasFileInfo: Boolean = true hasFileInfo: Boolean = true,
canPrintDocument: Boolean = true
): List<PdfOverflowMenuSection> = buildList { ): List<PdfOverflowMenuSection> = buildList {
add(PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR) add(PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR)
if (hasHiddenToolbarTools) add(PdfOverflowMenuSection.HIDDEN_TOOLS) if (hasHiddenToolbarTools) add(PdfOverflowMenuSection.HIDDEN_TOOLS)
@ -96,7 +97,7 @@ internal fun pdfOverflowMenuSections(
if ( if (
!hiddenTools.contains(PdfReaderTool.SHARE.name) || !hiddenTools.contains(PdfReaderTool.SHARE.name) ||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) || (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) ||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) (effectiveFileType == FileType.PDF && canPrintDocument && !hiddenTools.contains(PdfReaderTool.PRINT.name))
) { ) {
add(PdfOverflowMenuSection.FILE_ACTIONS) add(PdfOverflowMenuSection.FILE_ACTIONS)
} }
@ -136,6 +137,7 @@ internal fun PdfTopBar(
isReflowingThisBook: Boolean, isReflowingThisBook: Boolean,
hasReflowFile: Boolean, hasReflowFile: Boolean,
isPdfDocumentLoaded: Boolean, isPdfDocumentLoaded: Boolean,
canPrintDocument: Boolean = true,
isTabsEnabled: Boolean, isTabsEnabled: Boolean,
openTabs: List<RecentFileItem>, openTabs: List<RecentFileItem>,
activeTabBookId: String?, activeTabBookId: String?,
@ -395,12 +397,13 @@ internal fun PdfTopBar(
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name) val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name) val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name) val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)
val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name) val showPrintAction = effectiveFileType == FileType.PDF && canPrintDocument && !hiddenTools.contains(PdfReaderTool.PRINT.name)
pdfOverflowMenuSections( pdfOverflowMenuSections(
hiddenTools = hiddenTools, hiddenTools = hiddenTools,
hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(), hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(),
isPro = BuildConfig.IS_PRO, isPro = BuildConfig.IS_PRO,
effectiveFileType = effectiveFileType effectiveFileType = effectiveFileType,
canPrintDocument = canPrintDocument
).forEachIndexed { index, section -> ).forEachIndexed { index, section ->
if (index > 0) HorizontalDivider() if (index > 0) HorizontalDivider()
when (section) { when (section) {

View file

@ -378,7 +378,6 @@ fun PdfViewerScreen(
var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) } var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) }
var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) } var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) }
var showScreenOrientationSheet by remember { mutableStateOf(false) } var showScreenOrientationSheet by remember { mutableStateOf(false) }
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) } var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
var isScrollLocked by remember { mutableStateOf(false) } var isScrollLocked by remember { mutableStateOf(false) }
var lockedState by remember { mutableStateOf<Triple<Float, Float, Float>?>(null) } var lockedState by remember { mutableStateOf<Triple<Float, Float, Float>?>(null) }
@ -454,6 +453,8 @@ fun PdfViewerScreen(
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
var documentPassword by rememberSaveable(effectivePdfUri.toString()) { mutableStateOf<String?>(null) }
var isPrintBlockedForPasswordProtectedPdf by rememberSaveable(effectivePdfUri.toString()) { mutableStateOf(false) }
val isComicFile = effectiveFileType in COMIC_ARCHIVE_FILE_TYPES val isComicFile = effectiveFileType in COMIC_ARCHIVE_FILE_TYPES
var showNewTabSheet by remember { mutableStateOf(false) } var showNewTabSheet by remember { mutableStateOf(false) }
@ -598,7 +599,11 @@ fun PdfViewerScreen(
isAutoScrollLocal = loadPdfAutoScrollLocalMode(context, bookId) isAutoScrollLocal = loadPdfAutoScrollLocalMode(context, bookId)
} }
val onPrintDocument: () -> Unit = { val onPrintDocument: () -> Unit = onPrintDocument@{
if (isPrintBlockedForPasswordProtectedPdf) {
showBanner(context.getString(R.string.error_print_password_protected), isError = true)
return@onPrintDocument
}
val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager
val jobName = "${context.getString(R.string.app_name)} - $originalFileName" val jobName = "${context.getString(R.string.app_name)} - $originalFileName"
@ -2423,8 +2428,9 @@ fun PdfViewerScreen(
} }
} }
LaunchedEffect(currentPageScale) { val zoomIndicatorPercentage = pdfZoomIndicatorPercent(currentPageScale)
if (currentPageScale != 1f) { LaunchedEffect(zoomIndicatorPercentage) {
if (shouldShowPdfZoomIndicator(zoomIndicatorPercentage)) {
showZoomIndicator = true showZoomIndicator = true
delay(1500) delay(1500)
showZoomIndicator = false showZoomIndicator = false
@ -3518,6 +3524,7 @@ fun PdfViewerScreen(
isDocumentReady = false isDocumentReady = false
errorMessage = null errorMessage = null
documentMetadataTitle = null documentMetadataTitle = null
isPrintBlockedForPasswordProtectedPdf = false
currentBookId = null currentBookId = null
areAnnotationsLoaded = false areAnnotationsLoaded = false
loadedSidecarBookId = null loadedSidecarBookId = null
@ -3591,6 +3598,7 @@ fun PdfViewerScreen(
totalPages = cachedItem.totalPages totalPages = cachedItem.totalPages
pageAspectRatios = cachedItem.pageAspectRatios pageAspectRatios = cachedItem.pageAspectRatios
flatTableOfContents = cachedItem.flatTableOfContents flatTableOfContents = cachedItem.flatTableOfContents
isPrintBlockedForPasswordProtectedPdf = cachedItem.isPasswordProtectedPdf
val mapPage = tabStateMap[currentBookId!!] val mapPage = tabStateMap[currentBookId!!]
val uiPage = uiState.initialPageInBook val uiPage = uiState.initialPageInBook
@ -3634,6 +3642,8 @@ fun PdfViewerScreen(
val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF
val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore) val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore)
val loadedPasswordProtectedPdf = selectedDocumentType == FileType.PDF &&
(documentPassword != null || isPdfLikelyEncryptedForPrint(context, effectivePdfUri))
if (!isActive) { if (!isActive) {
doc.close() doc.close()
@ -3641,6 +3651,7 @@ fun PdfViewerScreen(
} }
pdfDocument = doc pdfDocument = doc
isPrintBlockedForPasswordProtectedPdf = loadedPasswordProtectedPdf
documentMetadataTitle = (doc as? PdfDocumentWrapper)?.let { wrapper -> documentMetadataTitle = (doc as? PdfDocumentWrapper)?.let { wrapper ->
PdfiumEngineProvider.withPdfium { PdfiumEngineProvider.withPdfium {
wrapper.pdfDocument.getDocumentMeta().title?.takeIf { it.isNotBlank() } wrapper.pdfDocument.getDocumentMeta().title?.takeIf { it.isNotBlank() }
@ -3737,7 +3748,8 @@ fun PdfViewerScreen(
pfd = null, pfd = null,
totalPages = pagesCount, totalPages = pagesCount,
pageAspectRatios = ratios, pageAspectRatios = ratios,
flatTableOfContents = flatTableOfContents flatTableOfContents = flatTableOfContents,
isPasswordProtectedPdf = loadedPasswordProtectedPdf
) )
) )
@ -4558,6 +4570,8 @@ fun PdfViewerScreen(
val latestSpreadScale = rememberUpdatedState(currentActiveScale) val latestSpreadScale = rememberUpdatedState(currentActiveScale)
val latestSpreadOffset = rememberUpdatedState(currentActiveOffset) val latestSpreadOffset = rememberUpdatedState(currentActiveOffset)
val spreadPageGap = if (showVerticalPageGap) 8.dp else 0.dp val spreadPageGap = if (showVerticalPageGap) 8.dp else 0.dp
val spreadPageGapPx = with(density) { spreadPageGap.toPx() }
val spreadPageCount = spreadPageIndices.size
var spreadPanFlingJob by remember { mutableStateOf<Job?>(null) } var spreadPanFlingJob by remember { mutableStateOf<Job?>(null) }
Row( Row(
modifier = Modifier modifier = Modifier
@ -4930,6 +4944,20 @@ fun PdfViewerScreen(
) { ) {
spreadPageIndices.forEach { pageIndex -> spreadPageIndices.forEach { pageIndex ->
key(pageIndex) { key(pageIndex) {
val spreadPageWidth = if (spreadPageCount > 1) {
val pageAspectRatio = displayPageRatios.getOrElse(pageIndex) { 1f }
with(density) {
pdfSpreadPageSlotWidth(
containerWidth = boxMaxWidthFloat,
containerHeight = boxMaxHeightFloat,
pageGap = spreadPageGapPx,
spreadPageCount = spreadPageCount,
pageAspectRatio = pageAspectRatio
).toDp()
}
} else {
with(density) { boxMaxWidthFloat.toDp() }
}
val isPageBookmarked by remember(bookmarks, pageIndex) { val isPageBookmarked by remember(bookmarks, pageIndex) {
derivedStateOf { derivedStateOf {
bookmarks.any { it.pageIndex == pageIndex } bookmarks.any { it.pageIndex == pageIndex }
@ -5130,7 +5158,7 @@ fun PdfViewerScreen(
ocrHoverHighlights = stableOcrRects, ocrHoverHighlights = stableOcrRects,
modifier = if (spreadPageIndices.size > 1) { modifier = if (spreadPageIndices.size > 1) {
Modifier Modifier
.weight(1f) .width(spreadPageWidth)
.fillMaxHeight() .fillMaxHeight()
} else { } else {
Modifier.fillMaxSize() Modifier.fillMaxSize()
@ -6278,6 +6306,7 @@ fun PdfViewerScreen(
isReflowingThisBook = isReflowingThisBook, isReflowingThisBook = isReflowingThisBook,
hasReflowFile = hasReflowFile, hasReflowFile = hasReflowFile,
isPdfDocumentLoaded = pdfDocument != null, isPdfDocumentLoaded = pdfDocument != null,
canPrintDocument = !isPrintBlockedForPasswordProtectedPdf,
isTabsEnabled = isPdfTabStripVisible, isTabsEnabled = isPdfTabStripVisible,
openTabs = openTabs, openTabs = openTabs,
activeTabBookId = activeTabBookId, activeTabBookId = activeTabBookId,
@ -7100,9 +7129,8 @@ fun PdfViewerScreen(
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
val percentage = (currentPageScale * 100).roundToInt()
ZoomPercentageIndicator( ZoomPercentageIndicator(
percentage = percentage, percentage = zoomIndicatorPercentage,
onResetZoomClick = { onResetZoomClick = {
resetZoomTrigger = System.currentTimeMillis() resetZoomTrigger = System.currentTimeMillis()
} }

View file

@ -3,6 +3,7 @@ package com.aryan.reader.pdf
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import com.aryan.reader.shared.pdf.PdfSpreadLayout import com.aryan.reader.shared.pdf.PdfSpreadLayout
import com.aryan.reader.shared.reader.ReaderSettings import com.aryan.reader.shared.reader.ReaderSettings
import kotlin.math.roundToInt
internal fun resolveEraserStrokeWidth( internal fun resolveEraserStrokeWidth(
isEraserOverride: Boolean, isEraserOverride: Boolean,
@ -88,6 +89,22 @@ internal fun clampPdfSpreadCameraOffset(
) )
} }
internal fun pdfSpreadPageSlotWidth(
containerWidth: Float,
containerHeight: Float,
pageGap: Float,
spreadPageCount: Int,
pageAspectRatio: Float
): Float {
if (containerWidth <= 0f || containerHeight <= 0f || spreadPageCount <= 0) return 0f
val safeGap = pageGap.coerceAtLeast(0f)
val safeAspectRatio = pageAspectRatio.takeIf { it.isFinite() && it > 0f } ?: 1f
val availableWidth = (containerWidth - (safeGap * (spreadPageCount - 1))).coerceAtLeast(0f)
val maxPageWidth = availableWidth / spreadPageCount
val heightFittedPageWidth = containerHeight * safeAspectRatio
return heightFittedPageWidth.coerceAtMost(maxPageWidth).coerceAtLeast(0f)
}
internal fun activePdfCameraAfterLockPreferenceLoad( internal fun activePdfCameraAfterLockPreferenceLoad(
isScrollLocked: Boolean, isScrollLocked: Boolean,
lockedState: Triple<Float, Float, Float>? lockedState: Triple<Float, Float, Float>?
@ -139,3 +156,32 @@ internal fun shouldResetPdfZoomAfterBubbleZoomCleanup(
isZoomEnabled && isZoomEnabled &&
!isScrollLocked !isScrollLocked
} }
internal fun shouldRenderPdfHighResTiles(
effectiveScale: Float,
targetWidthPx: Int,
targetHeightPx: Int,
isVerticalScroll: Boolean,
isActivePage: Boolean,
largePageThresholdPx: Int = 3000,
verticalScaleTolerance: Float = 0.01f
): Boolean {
val hasLargePage = targetWidthPx > largePageThresholdPx || targetHeightPx > largePageThresholdPx
val isPageEligible = isVerticalScroll || isActivePage
if (!isPageEligible) return false
if (hasLargePage) return true
val safeScale = effectiveScale.takeIf { it.isFinite() && it > 0f } ?: 1f
return if (isVerticalScroll) {
kotlin.math.abs(safeScale - 1f) > verticalScaleTolerance
} else {
safeScale > 1f
}
}
internal fun pdfZoomIndicatorPercent(scale: Float): Int {
val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f
return (safeScale * 100f).roundToInt()
}
internal fun shouldShowPdfZoomIndicator(percentage: Int): Boolean = percentage != 100

View file

@ -32,11 +32,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -69,6 +72,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.selected
@ -84,6 +88,7 @@ import com.aryan.reader.HexInput
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.RgbInputColumn import com.aryan.reader.RgbInputColumn
import com.aryan.reader.SpectrumBox import com.aryan.reader.SpectrumBox
import com.aryan.reader.readerModalMaxHeightDp
import kotlin.math.roundToInt import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -151,18 +156,28 @@ fun ToolSettingsPopup(
} }
val circleSize = 28.dp val circleSize = 28.dp
val configuration = LocalConfiguration.current
val maxPopupHeight = readerModalMaxHeightDp(
screenHeightDp = configuration.screenHeightDp,
fraction = 0.8f,
verticalMarginDp = 64,
preferredMinHeightDp = 240
).dp
Surface( Surface(
modifier = modifier modifier = modifier
.width(360.dp) .width(360.dp)
.padding(12.dp), .padding(12.dp)
.heightIn(max = maxPopupHeight),
shape = RoundedCornerShape(28.dp), shape = RoundedCornerShape(28.dp),
color = Color(0xFF1E1E1E), color = Color(0xFF1E1E1E),
shadowElevation = 12.dp, shadowElevation = 12.dp,
tonalElevation = 0.dp tonalElevation = 0.dp
) { ) {
Column( Column(
modifier = Modifier.padding(20.dp), modifier = Modifier
.padding(20.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (isEraser) { if (isEraser) {
@ -450,15 +465,21 @@ private fun ColorPickerDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false) properties = DialogProperties(usePlatformDefaultWidth = false)
) { ) {
val configuration = LocalConfiguration.current
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
Surface( Surface(
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C), color = Color(0xFF2C2C2C),
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.85f) .fillMaxWidth(0.85f)
.padding(8.dp) .padding(8.dp)
.heightIn(max = maxDialogHeight)
) { ) {
Column( Column(
modifier = Modifier.padding(20.dp), modifier = Modifier
.padding(20.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Box( Box(

View file

@ -176,6 +176,17 @@ class BaseTtsSynthesizer(private val context: Context) {
} }
} }
private suspend fun stopEngineForRetryLocked() {
Timber.w("BaseTts: Stopping current TTS utterance before retry.")
try {
tts?.stop()
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to stop TTS during retry recovery")
} finally {
delay(350)
}
}
private fun applyPreferredVoice() { private fun applyPreferredVoice() {
if (tts == null) return if (tts == null) return
@ -302,7 +313,7 @@ class BaseTtsSynthesizer(private val context: Context) {
requests.remove(utteranceId) requests.remove(utteranceId)
if (attempt < MAX_RETRY_ATTEMPTS) { if (attempt < MAX_RETRY_ATTEMPTS) {
shutdownEngineLocked() stopEngineForRetryLocked()
} }
} }
} }

View file

@ -55,6 +55,15 @@ import com.aryan.reader.paginatedreader.TtsChunk
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.math.roundToInt import kotlin.math.roundToInt
internal fun stableSortedIntSnapshot(values: Collection<Int>): List<Int> {
return try {
values.toTypedArray().sorted()
} catch (e: RuntimeException) {
Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).w(e, "Failed to snapshot TTS cache keys")
emptyList()
}
}
val START_TTS_COMMAND: SessionCommand val START_TTS_COMMAND: SessionCommand
get() = ttsSessionCommand("com.aryan.reader.tts.START") get() = ttsSessionCommand("com.aryan.reader.tts.START")
val STOP_TTS_COMMAND: SessionCommand val STOP_TTS_COMMAND: SessionCommand
@ -107,6 +116,7 @@ private const val TTS_NOTIFICATION_TRAILING_BUFFER_MS = 2_000L
private const val TTS_NOTIFICATION_AVERAGE_WORD_MS = 550L private const val TTS_NOTIFICATION_AVERAGE_WORD_MS = 550L
private const val TTS_NOTIFICATION_PUNCTUATION_PAUSE_MS = 120L private const val TTS_NOTIFICATION_PUNCTUATION_PAUSE_MS = 120L
private const val NO_DEFERRED_TRANSITION_PREFETCH_GENERATION = -1 private const val NO_DEFERRED_TRANSITION_PREFETCH_GENERATION = -1
internal const val MAX_CHUNK_GENERATION_FAILURES = 2
private val TTS_NOTIFICATION_WORD_PATTERN = Regex("""\S+""") private val TTS_NOTIFICATION_WORD_PATTERN = Regex("""\S+""")
private fun ttsSessionCommand(action: String): SessionCommand { private fun ttsSessionCommand(action: String): SessionCommand {
@ -143,9 +153,14 @@ internal fun resolveReusableTtsPlaylistIndex(
internal fun shouldAdvanceToTtsPlaylistChunk( internal fun shouldAdvanceToTtsPlaylistChunk(
currentChunkIndex: Int, currentChunkIndex: Int,
playlistChunkIndex: Int? playlistChunkIndex: Int?,
skippedChunkIndices: Set<Int> = emptySet()
): Boolean { ): Boolean {
return playlistChunkIndex == currentChunkIndex + 1 return playlistChunkIndex == resolveNextPlayableTtsChunkIndex(
currentChunkIndex = currentChunkIndex,
totalChunks = maxOf(playlistChunkIndex?.plus(1) ?: 0, currentChunkIndex + 2),
skippedChunkIndices = skippedChunkIndices
)
} }
internal fun shouldStartTtsTransitionPrefetch( internal fun shouldStartTtsTransitionPrefetch(
@ -162,6 +177,22 @@ internal fun shouldStopTtsPrefetchAfterMissingChunk(
return !isLoaded && playlistIndex == null return !isLoaded && playlistIndex == null
} }
internal fun resolveNextPlayableTtsChunkIndex(
currentChunkIndex: Int,
totalChunks: Int,
skippedChunkIndices: Set<Int>
): Int? {
if (totalChunks <= 0 || currentChunkIndex !in -1 until totalChunks) return null
return ((currentChunkIndex + 1) until totalChunks).firstOrNull { it !in skippedChunkIndices }
}
internal fun shouldGiveUpTtsChunkGeneration(
failureCount: Int,
maxFailures: Int = MAX_CHUNK_GENERATION_FAILURES
): Boolean {
return failureCount >= maxFailures
}
internal fun resolveTtsStreamPcmDurationMs(totalBytes: Long): Long? { internal fun resolveTtsStreamPcmDurationMs(totalBytes: Long): Long? {
if (totalBytes <= TTS_STREAM_WAV_HEADER_BYTES) return null if (totalBytes <= TTS_STREAM_WAV_HEADER_BYTES) return null
return ((totalBytes - TTS_STREAM_WAV_HEADER_BYTES) / TTS_STREAM_PCM_BYTES_PER_MS) return ((totalBytes - TTS_STREAM_WAV_HEADER_BYTES) / TTS_STREAM_PCM_BYTES_PER_MS)
@ -241,6 +272,8 @@ class TtsPlaybackManager(
private var currentAuthToken: String? = null private var currentAuthToken: String? = null
private val loadedChunks: MutableSet<Int> = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) private val loadedChunks: MutableSet<Int> = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
private val chunkStreamIds = java.util.concurrent.ConcurrentHashMap<Int, String>() private val chunkStreamIds = java.util.concurrent.ConcurrentHashMap<Int, String>()
private val skippedChunks: MutableSet<Int> = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
private val chunkGenerationFailures = java.util.concurrent.ConcurrentHashMap<Int, AtomicInteger>()
enum class TtsMode { enum class TtsMode {
CLOUD, BASE CLOUD, BASE
@ -346,7 +379,7 @@ class TtsPlaybackManager(
} }
private fun cancelPrefetchWork() { private fun cancelPrefetchWork() {
logChunkNav("prefetch-cancel", "activePrefetching=${prefetchingJobs.keys.sorted()} lastPrefetch=$lastPrefetchIndex") logChunkNav("prefetch-cancel", "activePrefetching=${stableSortedIntSnapshot(prefetchingJobs.keys)} lastPrefetch=$lastPrefetchIndex")
prefetchLoopJob?.cancel() prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() } prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear() prefetchingJobs.clear()
@ -386,7 +419,7 @@ class TtsPlaybackManager(
} }
private fun cacheSnapshot(): String { private fun cacheSnapshot(): String {
return "generation=${currentPlaybackGeneration()} deferredTransitionPrefetch=${deferredTransitionPrefetchGeneration.get()} lastPrefetch=$lastPrefetchIndex loaded=${loadedChunks.sorted()} audio=${audioFiles.keys.sorted()} streams=${chunkStreamIds.keys.sorted()} prefetching=${prefetchingJobs.keys.sorted()}" return "generation=${currentPlaybackGeneration()} deferredTransitionPrefetch=${deferredTransitionPrefetchGeneration.get()} lastPrefetch=$lastPrefetchIndex loaded=${stableSortedIntSnapshot(loadedChunks)} skipped=${stableSortedIntSnapshot(skippedChunks)} audio=${stableSortedIntSnapshot(audioFiles.keys)} streams=${stableSortedIntSnapshot(chunkStreamIds.keys)} prefetching=${stableSortedIntSnapshot(prefetchingJobs.keys)}"
} }
override fun onConnect( override fun onConnect(
@ -826,6 +859,8 @@ class TtsPlaybackManager(
this.pageIndex = pageIndex this.pageIndex = pageIndex
loadedChunks.clear() loadedChunks.clear()
skippedChunks.clear()
chunkGenerationFailures.clear()
lastPrefetchIndex = -1 lastPrefetchIndex = -1
_ttsState.value = TtsState( _ttsState.value = TtsState(
@ -914,7 +949,11 @@ class TtsPlaybackManager(
} }
private fun advanceToNextChunkMediaItem(currentChunkIndex: Int): Boolean { private fun advanceToNextChunkMediaItem(currentChunkIndex: Int): Boolean {
val nextChunkIndex = resolveTtsChunkSkipTarget(currentChunkIndex, textChunks.size, direction = 1) val nextPlayableChunkIndex = resolveNextPlayableTtsChunkIndex(
currentChunkIndex = currentChunkIndex,
totalChunks = textChunks.size,
skippedChunkIndices = skippedChunks
)
?: run { ?: run {
logChunkNavMain( logChunkNavMain(
"advance-next-no-target", "advance-next-no-target",
@ -922,16 +961,16 @@ class TtsPlaybackManager(
) )
return false return false
} }
val nextPlaylistIndex = findPlaylistIndexForChunk(nextChunkIndex) val nextPlaylistIndex = findPlaylistIndexForChunk(nextPlayableChunkIndex)
?: run { ?: run {
logChunkNavMain( logChunkNavMain(
"advance-next-missing-playlist-item", "advance-next-missing-playlist-item",
"currentChunk=$currentChunkIndex expectedNextChunk=$nextChunkIndex" "currentChunk=$currentChunkIndex expectedNextChunk=$nextPlayableChunkIndex skipped=${stableSortedIntSnapshot(skippedChunks)}"
) )
return false return false
} }
val nextPlaylistChunkIndex = player.getMediaItemAt(nextPlaylistIndex).mediaId.toIntOrNull() val nextPlaylistChunkIndex = player.getMediaItemAt(nextPlaylistIndex).mediaId.toIntOrNull()
if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex)) { if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex, skippedChunks)) {
logChunkNavWarnMain( logChunkNavWarnMain(
"advance-next-refused-non-contiguous", "advance-next-refused-non-contiguous",
"Refusing non-contiguous TTS advance. current=$currentChunkIndex, nextPlaylistChunk=$nextPlaylistChunkIndex" "Refusing non-contiguous TTS advance. current=$currentChunkIndex, nextPlaylistChunk=$nextPlaylistChunkIndex"
@ -940,7 +979,7 @@ class TtsPlaybackManager(
} }
logChunkNavMain( logChunkNavMain(
"advance-next-seek", "advance-next-seek",
"currentChunk=$currentChunkIndex nextChunk=$nextChunkIndex nextPlaylistIndex=$nextPlaylistIndex" "currentChunk=$currentChunkIndex nextChunk=$nextPlayableChunkIndex nextPlaylistIndex=$nextPlaylistIndex"
) )
player.seekTo(nextPlaylistIndex, 0L) player.seekTo(nextPlaylistIndex, 0L)
return true return true
@ -1028,6 +1067,8 @@ class TtsPlaybackManager(
audioFiles.clear() audioFiles.clear()
chunkStreamIds.clear() chunkStreamIds.clear()
loadedChunks.clear() loadedChunks.clear()
skippedChunks.clear()
chunkGenerationFailures.clear()
lastPrefetchIndex = -1 lastPrefetchIndex = -1
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
@ -1097,6 +1138,8 @@ class TtsPlaybackManager(
val serverText = ttsAudioData.serverText val serverText = ttsAudioData.serverText
if ((audioFile != null || streamUri != null) && serverText != null) { if ((audioFile != null || streamUri != null) && serverText != null) {
chunkGenerationFailures.remove(startAtIndex)
skippedChunks.remove(startAtIndex)
if (audioFile != null) { if (audioFile != null) {
audioFiles[startAtIndex] = audioFile audioFiles[startAtIndex] = audioFile
} }
@ -1167,10 +1210,30 @@ class TtsPlaybackManager(
prefetchNextChunkAudio(startAtIndex) prefetchNextChunkAudio(startAtIndex)
} }
} else { } else {
val failureCount = recordChunkGenerationFailure(startAtIndex)
logChunkNav( logChunkNav(
"prepare-first-failed", "prepare-first-failed",
"chunk=$startAtIndex error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}" "chunk=$startAtIndex failureCount=$failureCount error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
) )
val nextPlayableChunk = resolveNextPlayableTtsChunkIndex(
currentChunkIndex = startAtIndex,
totalChunks = textChunks.size,
skippedChunkIndices = skippedChunks + startAtIndex
)
if (shouldGiveUpTtsChunkGeneration(failureCount) && nextPlayableChunk != null) {
skippedChunks.add(startAtIndex)
logChunkNav(
"prepare-first-skip-failed-chunk",
"chunk=$startAtIndex nextChunk=$nextPlayableChunk failureCount=$failureCount"
)
prepareAndPlayFirstChunk(
startAtIndex = nextPlayableChunk,
playWhenReady = playWhenReady,
startAtPosition = 0L,
prefetchAfterPrepare = prefetchAfterPrepare
)
return
}
_ttsState.value = _ttsState.value.copy( _ttsState.value = _ttsState.value.copy(
isLoading = false, isLoading = false,
errorMessage = ttsAudioData.error ?: appContext.getString(R.string.tts_error_load_audio) errorMessage = ttsAudioData.error ?: appContext.getString(R.string.tts_error_load_audio)
@ -1243,6 +1306,8 @@ class TtsPlaybackManager(
pageIndex = null pageIndex = null
cancelPrefetchWork() cancelPrefetchWork()
loadedChunks.clear() loadedChunks.clear()
skippedChunks.clear()
chunkGenerationFailures.clear()
scope.launch { scope.launch {
clearAudioFiles() clearAudioFiles()
@ -1434,6 +1499,10 @@ class TtsPlaybackManager(
} }
val targetIndex = currentIndex + i val targetIndex = currentIndex + i
if (targetIndex < textChunks.size) { if (targetIndex < textChunks.size) {
if (skippedChunks.contains(targetIndex)) {
logChunkNav("prefetch-target-skip-marked", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation")
continue
}
if (prefetchingJobs.containsKey(targetIndex)) { if (prefetchingJobs.containsKey(targetIndex)) {
logChunkNav("prefetch-target-skip-inflight", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation") logChunkNav("prefetch-target-skip-inflight", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation")
continue continue
@ -1496,6 +1565,8 @@ class TtsPlaybackManager(
val serverText = ttsAudioData.serverText val serverText = ttsAudioData.serverText
if ((audioFile != null || streamUri != null) && serverText != null) { if ((audioFile != null || streamUri != null) && serverText != null) {
chunkGenerationFailures.remove(targetIndex)
skippedChunks.remove(targetIndex)
val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings) val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings)
val pathToUse = streamUri ?: audioFile!!.absolutePath val pathToUse = streamUri ?: audioFile!!.absolutePath
val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk) val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk)
@ -1560,7 +1631,11 @@ class TtsPlaybackManager(
} }
val currentChunkIndex = currentChunkIndexFromPlayer() val currentChunkIndex = currentChunkIndexFromPlayer()
val isImmediateNextChunk = targetIndex == currentChunkIndex + 1 val isImmediateNextChunk = targetIndex == resolveNextPlayableTtsChunkIndex(
currentChunkIndex = currentChunkIndex,
totalChunks = textChunks.size,
skippedChunkIndices = skippedChunks
)
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) { if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) {
logChunkNavMain( logChunkNavMain(
@ -1579,11 +1654,19 @@ class TtsPlaybackManager(
} }
} }
} else { } else {
val failureCount = recordChunkGenerationFailure(targetIndex)
Timber.e("Prefetch: Failed to download chunk $targetIndex") Timber.e("Prefetch: Failed to download chunk $targetIndex")
logChunkNav( logChunkNav(
"prefetch-generate-failed", "prefetch-generate-failed",
"targetChunk=$targetIndex generation=$generation error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}" "targetChunk=$targetIndex generation=$generation failureCount=$failureCount error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
) )
if (shouldGiveUpTtsChunkGeneration(failureCount)) {
skippedChunks.add(targetIndex)
logChunkNav(
"prefetch-skip-failed-chunk",
"targetChunk=$targetIndex generation=$generation failureCount=$failureCount"
)
}
} }
} }
prefetchingJobs[targetIndex] = job prefetchingJobs[targetIndex] = job
@ -1599,6 +1682,13 @@ class TtsPlaybackManager(
) )
return@launch return@launch
} }
if (skippedChunks.contains(targetIndex)) {
logChunkNav(
"prefetch-after-join-skipped",
"targetChunk=$targetIndex generation=$generation"
)
continue
}
val shouldStopAfterMissingChunk = withContext(Dispatchers.Main) { val shouldStopAfterMissingChunk = withContext(Dispatchers.Main) {
val playlistIndex = findPlaylistIndexForChunk(targetIndex) val playlistIndex = findPlaylistIndexForChunk(targetIndex)
shouldStopTtsPrefetchAfterMissingChunk( shouldStopTtsPrefetchAfterMissingChunk(
@ -1625,6 +1715,12 @@ class TtsPlaybackManager(
} }
} }
private fun recordChunkGenerationFailure(chunkIndex: Int): Int {
return chunkGenerationFailures
.getOrPut(chunkIndex) { AtomicInteger(0) }
.incrementAndGet()
}
private suspend fun trackWordByWord() { private suspend fun trackWordByWord() {
var loopCount = 0 var loopCount = 0
while (true) { while (true) {
@ -1821,6 +1917,8 @@ class TtsPlaybackManager(
chunkStreamIds.values.forEach { StreamRegistry.remove(it) } // ADDED chunkStreamIds.values.forEach { StreamRegistry.remove(it) } // ADDED
chunkStreamIds.clear() // ADDED chunkStreamIds.clear() // ADDED
loadedChunks.clear() loadedChunks.clear()
skippedChunks.clear()
chunkGenerationFailures.clear()
} }
} }

View file

@ -22,99 +22,99 @@
</plurals> </plurals>
<plurals name="shelf_count"> <plurals name="shelf_count">
<item quantity="one">%1$d riiul</item> <item quantity="one">%1$d riiul</item>
<item quantity="other">%1$d riiulid</item> <item quantity="other">%1$d riiulit</item>
</plurals> </plurals>
<plurals name="dialog_permanently_delete_desc"> <plurals name="dialog_permanently_delete_desc">
<item quantity="one">Kas soovite %1$d jäädavalt kustutada valitud faili oma seadmest? Seda toimingut ei saa tagasi võtta.</item> <item quantity="one">Kas kustutada %1$d valitud fail seadmest jäädavalt? Seda toimingut ei saa tagasi võtta.</item>
<item quantity="other">Kas soovite %1$d jäädavalt kustutada teie seadmest valitud failid? Seda toimingut ei saa tagasi võtta.</item> <item quantity="other">Kas kustutada %1$d valitud faili seadmest jäädavalt? Seda toimingut ei saa tagasi võtta.</item>
</plurals> </plurals>
<plurals name="dialog_remove_recents_desc"> <plurals name="dialog_remove_recents_desc">
<item quantity="one">Kas soovite eemaldada %1$d valitud faili viimaste failide loendist? See kuvatakse uuesti, kui avate selle uuesti raamatukogust.</item> <item quantity="one">Kas eemaldada %1$d valitud fail viimaste failide loendist? See ilmub uuesti, kui avad selle raamatukogust.</item>
<item quantity="other">Kas soovite eemaldada %1$d valitud failid viimaste failide loendist? See kuvatakse uuesti, kui avate selle uuesti raamatukogust.</item> <item quantity="other">Kas eemaldada %1$d valitud faili viimaste failide loendist? Need ilmuvad uuesti, kui avad need raamatukogust.</item>
</plurals> </plurals>
<plurals name="dialog_remove_from_shelf_desc"> <plurals name="dialog_remove_from_shelf_desc">
<item quantity="one">Kas soovite kindlasti eemaldada %1$d raamat \'%2$s\' riiul? Raamat jääb teie kogusse ja kuvatakse jaotises Riiulita.</item> <item quantity="one">Kas eemaldada %1$d raamat riiulilt &quot;%2$s&quot;? Raamat jääb raamatukokku ja kuvatakse riiulita raamatute all.</item>
<item quantity="other">Kas soovite kindlasti eemaldada %1$d raamatud \'%2$s\' riiul? Raamatud jäävad teie kogusse ja kuvatakse jaotises Riiulita.</item> <item quantity="other">Kas eemaldada %1$d raamatut riiulilt &quot;%2$s&quot;? Raamatud jäävad raamatukokku ja kuvatakse riiulita raamatute all.</item>
</plurals> </plurals>
<plurals name="banner_books_removed_library"> <plurals name="banner_books_removed_library">
<item quantity="one">%1$d raamat raamatukogust eemaldatud.</item> <item quantity="one">%1$d raamat raamatukogust eemaldatud.</item>
<item quantity="other">%1$d raamatud raamatukogust eemaldatud.</item> <item quantity="other">%1$d raamatut eemaldati raamatukogust.</item>
</plurals> </plurals>
<plurals name="banner_importing_books_count"> <plurals name="banner_importing_books_count">
<item quantity="one">Importimine %1$d raamat… See ilmub peagi teie teegis.</item> <item quantity="one">Impordin %1$d raamatut… See ilmub peagi raamatukogusse.</item>
<item quantity="other">Importimine %1$d raamatud… Need ilmuvad peagi teie kogusse.</item> <item quantity="other">Impordin %1$d raamatut… Need ilmuvad peagi raamatukogusse.</item>
</plurals> </plurals>
<plurals name="banner_books_imported_library_tab"> <plurals name="banner_books_imported_library_tab">
<item quantity="one">Imporditud %1$d raamat. Selle leiate vahekaardilt Raamatukogu.</item> <item quantity="one">Imporditi %1$d raamat. Leiad selle vahekaardilt Raamatukogu.</item>
<item quantity="other">Imporditud %1$d raamatuid. Leiate need vahekaardilt Raamatukogu.</item> <item quantity="other">Imporditi %1$d raamatut. Leiad need vahekaardilt Raamatukogu.</item>
</plurals> </plurals>
<plurals name="banner_books_added_to_shelf"> <plurals name="banner_books_added_to_shelf">
<item quantity="one">%1$d raamat lisatud riiulile.</item> <item quantity="one">%1$d raamat lisatud riiulile.</item>
<item quantity="other">%1$d raamatud lisatud riiulile.</item> <item quantity="other">%1$d raamatut lisati riiulile.</item>
</plurals> </plurals>
<plurals name="banner_books_tagged_with_tag"> <plurals name="banner_books_tagged_with_tag">
<item quantity="one">%1$d raamat sildiga &quot;%2$s&quot;.</item> <item quantity="one">%1$d raamat sildiga &quot;%2$s&quot;.</item>
<item quantity="other">%1$d raamatud sildiga &quot;%2$s&quot;.</item> <item quantity="other">%1$d raamatut märgiti sildiga &quot;%2$s&quot;.</item>
</plurals> </plurals>
<plurals name="banner_folder_removed_with_book_count"> <plurals name="banner_folder_removed_with_book_count">
<item quantity="one">Eemaldatud kaust &quot;%1$s&quot; ja %2$d raamat rakendusest.</item> <item quantity="one">Eemaldati kaust &quot;%1$s&quot; ja %2$d raamat rakendusest.</item>
<item quantity="other">Eemaldatud kaust &quot;%1$s&quot; ja %2$d raamatud rakendusest.</item> <item quantity="other">Eemaldati kaust &quot;%1$s&quot; ja %2$d raamatut rakendusest.</item>
</plurals> </plurals>
<plurals name="folder_count"> <plurals name="folder_count">
<item quantity="one">%1$d kausta</item> <item quantity="one">%1$d kaust</item>
<item quantity="other">%1$d kaustad</item> <item quantity="other">%1$d kausta</item>
</plurals> </plurals>
<plurals name="file_count"> <plurals name="file_count">
<item quantity="one">%1$d faili</item> <item quantity="one">%1$d fail</item>
<item quantity="other">%1$d failid</item> <item quantity="other">%1$d faili</item>
</plurals> </plurals>
<plurals name="desktop_drop_import_file_count"> <plurals name="desktop_drop_import_file_count">
<item quantity="one">Langetage import %1$d faili</item> <item quantity="one">Lohista importimiseks %1$d fail</item>
<item quantity="other">Langetage import %1$d failid</item> <item quantity="other">Lohista importimiseks %1$d faili</item>
</plurals> </plurals>
<plurals name="desktop_unsupported_import_file_count"> <plurals name="desktop_unsupported_import_file_count">
<item quantity="one">%1$d toetamata fail jäetakse vahele.</item> <item quantity="one">%1$d toetamata fail jäetakse vahele.</item>
<item quantity="other">%1$d toetamata failid jäetakse vahele.</item> <item quantity="other">%1$d toetamata faili jäetakse vahele.</item>
</plurals> </plurals>
<plurals name="desktop_importing_file_count"> <plurals name="desktop_importing_file_count">
<item quantity="one">Importimine %1$d fail</item> <item quantity="one">Impordin %1$d faili</item>
<item quantity="other">Importimine %1$d failid</item> <item quantity="other">Impordin %1$d faili</item>
</plurals> </plurals>
<plurals name="desktop_imported_file_count"> <plurals name="desktop_imported_file_count">
<item quantity="one">Imporditud %1$d faili.</item> <item quantity="one">Imporditi %1$d fail.</item>
<item quantity="other">Imporditud %1$d failid.</item> <item quantity="other">Imporditi %1$d faili.</item>
</plurals> </plurals>
<plurals name="desktop_imported_file_count_reader_support_later"> <plurals name="desktop_imported_file_count_reader_support_later">
<item quantity="one">Imporditud %1$d faili. Lugeja tugi tuleb hiljem.</item> <item quantity="one">Imporditi %1$d fail. Lugeja tugi tuleb hiljem.</item>
<item quantity="other">Imporditud %1$d failid. Lugeja tugi tuleb hiljem.</item> <item quantity="other">Imporditi %1$d faili. Lugeja tugi tuleb hiljem.</item>
</plurals> </plurals>
<plurals name="desktop_import_failed_file_count"> <plurals name="desktop_import_failed_file_count">
<item quantity="one">Ei saanud importida %1$d faili.</item> <item quantity="one">%1$d faili importimine nurjus.</item>
<item quantity="other">Ei saanud importida %1$d failid.</item> <item quantity="other">%1$d faili importimine nurjus.</item>
</plurals> </plurals>
<plurals name="desktop_skipped_file_count"> <plurals name="desktop_skipped_file_count">
<item quantity="one">Vahele jäetud %1$d faili.</item> <item quantity="one">%1$d fail jäeti vahele.</item>
<item quantity="other">Vahele jäetud %1$d failid.</item> <item quantity="other">%1$d faili jäeti vahele.</item>
</plurals> </plurals>
<plurals name="desktop_remove_folder_desc_with_book_count"> <plurals name="desktop_remove_folder_desc_with_book_count">
<item quantity="one">Eemalda &quot;%1$s&quot; ja selle %2$d raamatut rakendusest? Ketta faile ei kustutata.</item> <item quantity="one">Kas eemaldada &quot;%1$s&quot; ja selle %2$d raamat rakendusest? Kettal olevaid faile ei kustutata.</item>
<item quantity="other">Eemalda &quot;%1$s&quot; ja selle %2$d raamatud rakendusest? Ketta faile ei kustutata.</item> <item quantity="other">Kas eemaldada &quot;%1$s&quot; ja selle %2$d raamatut rakendusest? Kettal olevaid faile ei kustutata.</item>
</plurals> </plurals>
<plurals name="desktop_folder_sync_failed_folder_count"> <plurals name="desktop_folder_sync_failed_folder_count">
<item quantity="one">Kausta sünkroonimine ebaõnnestus %1$d kausta.</item> <item quantity="one">%1$d kausta sünkroonimine nurjus.</item>
<item quantity="other">Kausta sünkroonimine ebaõnnestus %1$d kaustad.</item> <item quantity="other">%1$d kausta sünkroonimine nurjus.</item>
</plurals> </plurals>
<plurals name="desktop_folder_sync_skipped_folder_count"> <plurals name="desktop_folder_sync_skipped_folder_count">
<item quantity="one">Kausta sünkroonimine on lõpetatud %1$d kaust jäi vahele.</item> <item quantity="one">Kausta sünkroonimine lõppes, %1$d kaust jäeti vahele.</item>
<item quantity="other">Kausta sünkroonimine on lõpetatud %1$d kaustad vahele jäetud.</item> <item quantity="other">Kausta sünkroonimine lõppes, %1$d kausta jäeti vahele.</item>
</plurals> </plurals>
<plurals name="desktop_opds_removed_stream_book_count"> <plurals name="desktop_opds_removed_stream_book_count">
<item quantity="one">Eemaldatud %1$d voogesitatud OPDS raamat sellest kataloogist.</item> <item quantity="one">Sellest kataloogist eemaldati %1$d voogedastatud OPDS-raamat.</item>
<item quantity="other">Eemaldatud %1$d voogesitatud OPDS raamatud sellest kataloogist.</item> <item quantity="other">Sellest kataloogist eemaldati %1$d voogedastatud OPDS-raamatut.</item>
</plurals> </plurals>
<plurals name="tag_count"> <plurals name="tag_count">
<item quantity="one">%1$d tag</item> <item quantity="one">%1$d silt</item>
<item quantity="other">%1$d sildid</item> <item quantity="other">%1$d silti</item>
</plurals> </plurals>
<plurals name="desktop_library_tab_books_count"> <plurals name="desktop_library_tab_books_count">
<item quantity="one">Kõik raamatud %1$d</item> <item quantity="one">Kõik raamatud %1$d</item>
@ -133,7 +133,7 @@
<item quantity="other">Kaustad %1$d</item> <item quantity="other">Kaustad %1$d</item>
</plurals> </plurals>
<plurals name="tts_cache_chunk_count_parenthetical"> <plurals name="tts_cache_chunk_count_parenthetical">
<item quantity="one">(%1$d tükk)</item> <item quantity="one">(%1$d osa)</item>
<item quantity="other">(%1$d tükid)</item> <item quantity="other">(%1$d osa)</item>
</plurals> </plurals>
</resources> </resources>

View file

@ -4,25 +4,25 @@
<string name="action_save">Salvesta</string> <string name="action_save">Salvesta</string>
<string name="action_delete">Kustuta</string> <string name="action_delete">Kustuta</string>
<string name="action_remove">Eemalda</string> <string name="action_remove">Eemalda</string>
<string name="action_ok">Sobib</string> <string name="action_ok">OK</string>
<string name="action_close">Sulge</string> <string name="action_close">Sulge</string>
<string name="action_add">Lisa</string> <string name="action_add">Lisa</string>
<string name="action_rename">Muuda nime</string> <string name="action_rename">Muuda nime</string>
<string name="action_back">Tagasi</string> <string name="action_back">Tagasi</string>
<string name="action_search">Otsi</string> <string name="action_search">Otsi</string>
<string name="action_clear">Selge</string> <string name="action_clear">Tühjenda</string>
<string name="action_apply">Rakenda</string> <string name="action_apply">Rakenda</string>
<string name="action_enable">Luba</string> <string name="action_enable">Luba</string>
<string name="error_message_format">Viga: %1$s</string> <string name="error_message_format">Viga: %1$s</string>
<string name="action_go_back">Mine tagasi</string> <string name="action_go_back">Mine tagasi</string>
<string name="tab_free">Tasuta</string> <string name="tab_free">Vaba</string>
<string name="active_tabs">Aktiivsed vahelehed</string> <string name="active_tabs">Aktiivsed vahelehed</string>
<string name="pdf_tabs_show_top_app_bar_tabs">Kuva vahekaardid ülemisel rakenduseribal</string> <string name="pdf_tabs_show_top_app_bar_tabs">Kuva vahekaardid ülemisel rakenduseribal</string>
<string name="close_tab">Sule vahekaart</string> <string name="close_tab">Sule vahekaart</string>
<string name="close_all_tabs">Sulgege kõik vahelehed</string> <string name="close_all_tabs">Sulge kõik vahelehed</string>
<string name="dialog_close_all_tabs">Kas sulgeda kõik vahelehed?</string> <string name="dialog_close_all_tabs">Kas sulgeda kõik vahelehed?</string>
<string name="dialog_close_all_tabs_desc">Kas olete kindel, et soovite sulgeda kõik aktiivsed vahelehed?</string> <string name="dialog_close_all_tabs_desc">Kas sulgeda kõik aktiivsed vahelehed?</string>
<string name="legal_agreement_full">%1$s nõustute meie %2$s ja kinnitage, et olete lugenud meie %3$s.</string> <string name="legal_agreement_full">%1$s nõustud meie %2$s ja kinnitad, et oled lugenud meie %3$s.</string>
<string name="legal_terms_of_service">Kasutustingimused</string> <string name="legal_terms_of_service">Kasutustingimused</string>
<string name="legal_privacy_policy">Privaatsuspoliitika</string> <string name="legal_privacy_policy">Privaatsuspoliitika</string>
<string name="legal_licenses">Litsentsid</string> <string name="legal_licenses">Litsentsid</string>
@ -30,58 +30,58 @@
<string name="clear_selection">Tühjenda valik</string> <string name="clear_selection">Tühjenda valik</string>
<string name="pin_unpin">Kinnita/vabasta</string> <string name="pin_unpin">Kinnita/vabasta</string>
<string name="info">Info</string> <string name="info">Info</string>
<string name="select_all">Valige Kõik</string> <string name="select_all">Vali kõik</string>
<string name="dialog_remove_from_recents">Eemalda hiljutiste hulgast</string> <string name="dialog_remove_from_recents">Eemalda hiljutiste hulgast</string>
<string name="dialog_warning_folder_sync_delete">Warning: Some selected items are synced from a local folder. Proceeding will delete the actual files from your device storage.\n\nThis action cannot be undone.</string> <string name="dialog_warning_folder_sync_delete">Hoiatus: osa valitud üksusi on sünkroonitud kohalikust kaustast. Jätkamisel kustutatakse tegelikud failid seadme mälust.\n\nSeda toimingut ei saa tagasi võtta.</string>
<string name="file_information">Faili teave</string> <string name="file_information">Faili teave</string>
<string name="book_name">Raamatu nimi</string> <string name="book_name">Raamatu nimi</string>
<string name="copy_name">Kopeeri nimi</string> <string name="copy_name">Kopeeri nimi</string>
<string name="original_name">Algne nimi: %1$s</string> <string name="original_name">Algne nimi: %1$s</string>
<string name="revert_to_original">Taastage originaal</string> <string name="revert_to_original">Taasta algne nimi</string>
<string name="file_name">Faili nimi: %1$s</string> <string name="file_name">Faili nimi: %1$s</string>
<string name="author">Autor</string> <string name="author">Autor</string>
<string name="format">Vorming</string> <string name="format">Vorming</string>
<string name="size">Suurus</string> <string name="size">Suurus</string>
<string name="added">Lisatud</string> <string name="added">Lisatud</string>
<string name="location">Asukoht</string> <string name="location">Asukoht</string>
<string name="source_opds">Allikas: OPDS Voog</string> <string name="source_opds">Allikas: OPDS-voog</string>
<string name="source_in_app">Rakendusesisene salvestusruum</string> <string name="source_in_app">Rakendusesisene salvestusruum</string>
<string name="internal_storage">Sisemälu</string> <string name="internal_storage">Sisemälu</string>
<string name="about_title">Umbes Episteme</string> <string name="about_title">Teave Episteme kohta</string>
<string name="about_version">Versioon: %1$s (Järgmine: %2$d)</string> <string name="about_version">Versioon: %1$s (järk: %2$d)</string>
<string name="empty_select_file">Valige fail</string> <string name="empty_select_file">Vali fail</string>
<string name="clear_cloud_data_title">Kas kustutada kõik sünkroonitud andmed?</string> <string name="clear_cloud_data_title">Kas kustutada kõik sünkroonitud andmed?</string>
<string name="clear_cloud_data_desc">Kas olete kindel, et soovite kõik oma raamatuandmed pilvest jäädavalt kustutada? See kustutab uuesti sünkroonimise vältimiseks ka teie kohaliku raamatukogu. Seda toimingut ei saa tagasi võtta.</string> <string name="clear_cloud_data_desc">Kas kustutada kõik raamatuandmed pilvest jäädavalt? See tühjendab uuesti sünkroonimise vältimiseks ka kohaliku raamatukogu. Seda toimingut ei saa tagasi võtta.</string>
<string name="delete_all_data">KUSTUTA KÕIK ANDMED</string> <string name="delete_all_data">KUSTUTA KÕIK ANDMED</string>
<string name="nav_home">Kodu</string> <string name="nav_home">Kodu</string>
<string name="nav_library">Raamatukogu</string> <string name="nav_library">Raamatukogu</string>
<string name="recent_files">Viimased failid</string> <string name="recent_files">Viimased failid</string>
<string name="your_library_empty">Teie raamatukogu on tühi</string> <string name="your_library_empty">Raamatukogu on tühi</string>
<string name="your_library_empty_desc">Valige lugemiseks fail või sünkroonige kohalik kaust, et raamatuid automaatselt importida.</string> <string name="your_library_empty_desc">Vali lugemiseks fail või sünkrooni kohalik kaust, et raamatud automaatselt importida.</string>
<string name="no_recent_files">Viimaseid faile pole</string> <string name="no_recent_files">Viimaseid faile pole</string>
<string name="no_recent_files_desc">Avage fail oma teegist, et seda siin näha.</string> <string name="no_recent_files_desc">Ava fail raamatukogust, et seda siin näha.</string>
<string name="setup_folder_sync">Kausta sünkroonimise seadistamine</string> <string name="setup_folder_sync">Kausta sünkroonimise seadistamine</string>
<string name="sync_folder">Sünkrooni kaust</string> <string name="sync_folder">Sünkrooni kaust</string>
<string name="local_folder">Kohalik kaust</string> <string name="local_folder">Kohalik kaust</string>
<string name="pinned">Kinnitatud</string> <string name="pinned">Kinnitatud</string>
<string name="progress_complete">%1$d%% täielik</string> <string name="progress_complete">%1$d%% täielik</string>
<string name="not_available_locally">Pole kohapeal saadaval</string> <string name="not_available_locally">Pole kohapeal saadaval</string>
<string name="drawer_sign_in">Logige sisse rakendusega Google</string> <string name="drawer_sign_in">Logi Googleiga sisse</string>
<string name="drawer_by_signing_in">Sisse logides</string> <string name="drawer_by_signing_in">Sisse logides</string>
<string name="drawer_pro_unlocked">Episteme Pro</string> <string name="drawer_pro_unlocked">Episteme Pro</string>
<string name="drawer_upgrade_pro">Uuenda versioonile Episteme Pro</string> <string name="drawer_upgrade_pro">Uuenda versioonile Episteme Pro</string>
<string name="drawer_sync_library">Sünkrooni raamatukogu</string> <string name="drawer_sync_library">Sünkrooni raamatukogu</string>
<string name="drawer_backup_local_folders">Pilvesünkroonimine kohalike kaustade jaoks</string> <string name="drawer_backup_local_folders">Pilvesünkroonimine kohalike kaustade jaoks</string>
<string name="drawer_backup_desc">Laadige raamatud oma sünkroonitud kaustadest üles kausta Google Drive.</string> <string name="drawer_backup_desc">Laadi sünkroonitud kaustade raamatud Google Drivei üles.</string>
<string name="drawer_custom_fonts">Kohandatud fondid</string> <string name="drawer_custom_fonts">Kohandatud fondid</string>
<string name="drawer_support_project">Toetage projekti</string> <string name="drawer_support_project">Toeta projekti</string>
<string name="drawer_help_feedback">Abi ja tagasiside</string> <string name="drawer_help_feedback">Abi ja tagasiside</string>
<string name="drawer_sign_out">Logi välja</string> <string name="drawer_sign_out">Logi välja</string>
<string name="options_recent_limit">Viimaste failide limiit</string> <string name="options_recent_limit">Viimaste failide limiit</string>
<string name="options_no_limit">Piiramata</string> <string name="options_no_limit">Piiramata</string>
<string name="options_files_limit">%1$d failid</string> <string name="options_files_limit">%1$d failid</string>
<string name="options_clear_book_cache">Tühjenda raamatu vahemälu</string> <string name="options_clear_book_cache">Tühjenda raamatu vahemälu</string>
<string name="options_clear_reflow_cache">Tühjendage reflow vahemälu</string> <string name="options_clear_reflow_cache">Tühjenda reflow-vahemälu</string>
<string name="library_title">Raamatukogu</string> <string name="library_title">Raamatukogu</string>
<string name="search_placeholder">Otsi pealkirja või autorit…</string> <string name="search_placeholder">Otsi pealkirja või autorit…</string>
<string name="filter_types">Tüübid: %1$s</string> <string name="filter_types">Tüübid: %1$s</string>
@ -92,28 +92,28 @@
<string name="tab_folders">Kaustad</string> <string name="tab_folders">Kaustad</string>
<string name="tab_catalogs">Kataloogid</string> <string name="tab_catalogs">Kataloogid</string>
<string name="no_results_found">Päringule \&quot;%1$s\&quot; ei leitud tulemusi</string> <string name="no_results_found">Päringule \&quot;%1$s\&quot; ei leitud tulemusi</string>
<string name="library_empty_desc">Valige PDF, EPUB, MOBI või AZW3 alustamiseks oma seadmest faili.</string> <string name="library_empty_desc">Alustamiseks vali seadmest PDF-, EPUB-, MOBI- või AZW3-fail.</string>
<string name="fab_add_file">Lisa fail</string> <string name="fab_add_file">Lisa fail</string>
<string name="fab_new_shelf">Uus riiul</string> <string name="fab_new_shelf">Uus riiul</string>
<string name="create_new_shelf">Looge uus riiul</string> <string name="create_new_shelf">Loo uus riiul</string>
<string name="shelf_name_hint">Riiuli nimi</string> <string name="shelf_name_hint">Riiuli nimi</string>
<string name="action_create">Loo</string> <string name="action_create">Loo</string>
<string name="menu_rename_shelf">Nimetage riiul ümber</string> <string name="menu_rename_shelf">Nimeta riiul ümber</string>
<string name="menu_delete_shelf">Kustuta riiul</string> <string name="menu_delete_shelf">Kustuta riiul</string>
<string name="fab_add_books">Lisage raamatuid</string> <string name="fab_add_books">Lisa raamatuid</string>
<string name="shelf_empty">See riiul on tühi</string> <string name="shelf_empty">See riiul on tühi</string>
<string name="add_to_shelf">Lisa %1$s</string> <string name="add_to_shelf">Lisa %1$s</string>
<string name="fab_add_count">LISA (%1$d)</string> <string name="fab_add_count">LISA (%1$d)</string>
<string name="no_unshelved_books">Pole ühtegi riiulita raamatut, mida lisada</string> <string name="no_unshelved_books">Pole ühtegi riiulita raamatut, mida lisada</string>
<string name="all_books_in_shelf">Kõik raamatud on juba sellel riiulil</string> <string name="all_books_in_shelf">Kõik raamatud on juba sellel riiulil</string>
<string name="dialog_rename_shelf">Nimetage riiul ümber</string> <string name="dialog_rename_shelf">Nimeta riiul ümber</string>
<string name="dialog_delete_shelf">Kas kustutada riiul?</string> <string name="dialog_delete_shelf">Kas kustutada riiul?</string>
<string name="dialog_delete_shelf_desc">Kas soovite kindlasti kustutada faili \'%1$s\' riiul? Kõik raamatud teisaldatakse riiulitele.</string> <string name="dialog_delete_shelf_desc">Kas kustutada riiul &quot;%1$s&quot;? Kõik raamatud teisaldatakse riiulita raamatute alla.</string>
<string name="dialog_remove_from_shelf">Kas eemaldada riiulist?</string> <string name="dialog_remove_from_shelf">Kas eemaldada riiulist?</string>
<string name="dialog_delete_shelves">Kustuta %1$s?</string> <string name="dialog_delete_shelves">Kustuta %1$s?</string>
<string name="dialog_delete_shelves_desc">Kas soovite kindlasti kustutada %1$d valitud %2$s? Kõik sees olevad raamatud teisaldatakse jaotisesse Riiulita.</string> <string name="dialog_delete_shelves_desc">Kas kustutada %1$d valitud %2$s? Kõik sees olevad raamatud teisaldatakse riiulita raamatute alla.</string>
<string name="sync_local_folders">Sünkroonige kohalikud kaustad</string> <string name="sync_local_folders">Sünkrooni kohalikud kaustad</string>
<string name="sync_folders_desc">Reaalajas teegi loomiseks ühendage kohalikud kaustad. Episteme jälgib faile ja sünkroonimise edenemist.</string> <string name="sync_folders_desc">Ühenda kohalikud kaustad, et luua reaalajas raamatukogu. Episteme jälgib faile ja sünkroonib lugemisjärge.</string>
<string name="fab_add_folder">Lisa kaust</string> <string name="fab_add_folder">Lisa kaust</string>
<string name="scan_all">Skanni kõik</string> <string name="scan_all">Skanni kõik</string>
<string name="scanning">Skannimine…</string> <string name="scanning">Skannimine…</string>
@ -126,24 +126,24 @@
<string name="menu_enable_folder_local_sync">Luba kohalik sünkroonimine</string> <string name="menu_enable_folder_local_sync">Luba kohalik sünkroonimine</string>
<string name="folder_local_sync_disabled">Kohalik sünkroonimine on keelatud</string> <string name="folder_local_sync_disabled">Kohalik sünkroonimine on keelatud</string>
<string name="dialog_disable_folder_local_sync_title">Kas keelata kohaliku kausta sünkroonimine?</string> <string name="dialog_disable_folder_local_sync_title">Kas keelata kohaliku kausta sünkroonimine?</string>
<string name="dialog_disable_folder_local_sync_desc">Episteme lõpetab selle kausta skannimise ja kirjutamise JSON failide sünkroonimine. Eemaldage %1$s kaust ka sellest kaustast?</string> <string name="dialog_disable_folder_local_sync_desc">Episteme lõpetab selle kausta skannimise ja sünkroonimise JSON-failidesse kirjutamise. Kas eemaldada kaustast ka %1$s kaust?</string>
<string name="action_disable_keep_sync_data">Sünkrooni andmed</string> <string name="action_disable_keep_sync_data">Hoia sünkroonimisandmed</string>
<string name="action_disable_remove_sync_data">Eemaldage sünkroonimisandmed</string> <string name="action_disable_remove_sync_data">Eemalda sünkroonimisandmed</string>
<string name="filter_file_types">Filtreeri failitüübid</string> <string name="filter_file_types">Filtreeri failitüübid</string>
<string name="filter_file_types_desc">Valige failitüübid, mida soovite sellest kaustast sünkroonida:</string> <string name="filter_file_types_desc">Vali failitüübid, mida sellest kaustast sünkroonida:</string>
<string name="filter_library">Filtri raamatukogu</string> <string name="filter_library">Filtreeri raamatukogu</string>
<string name="filter_file_type">Faili tüüp</string> <string name="filter_file_type">Faili tüüp</string>
<string name="filter_source_folder">Allikakaust</string> <string name="filter_source_folder">Allikakaust</string>
<string name="filter_read_status">Loe olekut</string> <string name="filter_read_status">Lugemise olek</string>
<string name="clear_all">Kustuta kõik</string> <string name="clear_all">Tühjenda kõik</string>
<string name="filter_in_app_storage">Rakendusesisene salvestusruum</string> <string name="filter_in_app_storage">Rakendusesisene salvestusruum</string>
<string name="external_file_prompt_title">Kas salvestada fail?</string> <string name="external_file_prompt_title">Kas salvestada fail?</string>
<string name="external_file_prompt_desc">Do you want to save this external file in the app\'s library? If not, it will be removed.\n\n(You can change this default behavior anytime from the Home Screen &gt; More Options &gt; External File Behavior).</string> <string name="external_file_prompt_desc">Kas salvestada see väline fail rakenduse raamatukokku? Kui mitte, eemaldatakse see.\n\n(Seda vaikekäitumist saad igal ajal muuta: avakuva &gt; Rohkem valikuid &gt; Välise faili käitumine.)</string>
<string name="external_file_dont_ask">Ära\'ära küsi uuesti</string> <string name="external_file_dont_ask">Ära küsi uuesti</string>
<string name="external_file_keep">Hoidke raamatukogus</string> <string name="external_file_keep">Hoia raamatukogus</string>
<string name="external_file_delete">Eemalda</string> <string name="external_file_delete">Eemalda</string>
<string name="external_file_behavior_ask">Küsi iga kord</string> <string name="external_file_behavior_ask">Küsi iga kord</string>
<string name="external_file_behavior_keep">Hoidke alati</string> <string name="external_file_behavior_keep">Hoia alati</string>
<string name="external_file_behavior_delete">Eemalda alati</string> <string name="external_file_behavior_delete">Eemalda alati</string>
<string name="options_external_file_behavior">Välise faili käitumine</string> <string name="options_external_file_behavior">Välise faili käitumine</string>
<string name="fab_add_catalog">Lisa kataloog</string> <string name="fab_add_catalog">Lisa kataloog</string>
@ -155,29 +155,29 @@
<string name="action_unavailable">Pole saadaval</string> <string name="action_unavailable">Pole saadaval</string>
<string name="action_download">Laadi alla</string> <string name="action_download">Laadi alla</string>
<string name="download_format">Laadi alla vorming</string> <string name="download_format">Laadi alla vorming</string>
<string name="action_stream_now">Voogesitage kohe</string> <string name="action_stream_now">Voogedasta kohe</string>
<string name="action_read">Lugege</string> <string name="action_read">Loe</string>
<string name="no_supported_formats">Toetatud vorminguid pole saadaval.</string> <string name="no_supported_formats">Toetatud vorminguid pole saadaval.</string>
<string name="publisher">VÄLJAANDJA</string> <string name="publisher">VÄLJAANDJA</string>
<string name="published">AVALDATUD</string> <string name="published">AVALDATUD</string>
<string name="language">KEEL</string> <string name="language">KEEL</string>
<string name="synopsis">Sisukokkuvõte</string> <string name="synopsis">Sisukokkuvõte</string>
<string name="edit_catalog">Redigeeri kataloogi</string> <string name="edit_catalog">Redigeeri kataloogi</string>
<string name="add_opds_catalog">Lisa OPDS Kataloog</string> <string name="add_opds_catalog">Lisa OPDS-kataloog</string>
<string name="catalog_name">Kataloogi nimi</string> <string name="catalog_name">Kataloogi nimi</string>
<string name="url">URL</string> <string name="url">URL</string>
<string name="auth_optional">Autentimine (valikuline)</string> <string name="auth_optional">Autentimine (valikuline)</string>
<string name="username">Kasutajanimi</string> <string name="username">Kasutajanimi</string>
<string name="password">Parool</string> <string name="password">Parool</string>
<string name="delete_catalog">Kustuta kataloog</string> <string name="delete_catalog">Kustuta kataloog</string>
<string name="delete_catalog_desc">Kas soovite kindlasti kustutada \'%1$s\'?</string> <string name="delete_catalog_desc">Kas oled kindel, et soovid kustutada \'%1$s\'?</string>
<string name="delete_catalog_warning">Selle kataloogi kustutamisel eemaldatakse jäädavalt ka %1$d sellega seotud raamatute voogesitamine teie kogust.</string> <string name="delete_catalog_warning">Selle kataloogi kustutamisel eemaldatakse raamatukogust jäädavalt ka %1$d sellega seotud voogedastatud raamatut.</string>
<string name="preset_label">Eelseadistatud</string> <string name="preset_label">Eelseadistatud</string>
<string name="free_plan">Tasuta plaan</string> <string name="free_plan">Tasuta plaan</string>
<string name="forever_free">Igavesti tasuta</string> <string name="forever_free">Igavesti tasuta</string>
<string name="feature_multiple_formats">Mitu vormingut</string> <string name="feature_multiple_formats">Mitu vormingut</string>
<string name="feature_multiple_formats_desc">Toed PDF, EPUB, MOBI, AZW3</string> <string name="feature_multiple_formats_desc">Toetab vorminguid PDF, EPUB, MOBI ja AZW3</string>
<string name="feature_tts">Android Tekst kõneks</string> <string name="feature_tts">Androidi tekst kõneks</string>
<string name="feature_tts_desc">Kuulake oma raamatuid sisseehitatud TTS</string> <string name="feature_tts_desc">Kuulake oma raamatuid sisseehitatud TTS</string>
<string name="feature_dict">Põhisõnastik</string> <string name="feature_dict">Põhisõnastik</string>
<string name="feature_dict_desc">Otsige kiiresti üles üksikud sõnad</string> <string name="feature_dict_desc">Otsige kiiresti üles üksikud sõnad</string>
@ -188,25 +188,25 @@
<string name="early_access_sale">Varajase juurdepääsu müük</string> <string name="early_access_sale">Varajase juurdepääsu müük</string>
<string name="pro_includes">Omadused:</string> <string name="pro_includes">Omadused:</string>
<string name="feature_cloud_sync">Pilvesünkroonimine seadmete vahel</string> <string name="feature_cloud_sync">Pilvesünkroonimine seadmete vahel</string>
<string name="feature_cloud_sync_desc">Hoidke kogu oma kogu, sealhulgas raamatufailid ja lugemised, sünkroonituna kuni neljas seadmes.</string> <string name="feature_cloud_sync_desc">Hoia kogu oma kogu, sealhulgas raamatufailid ja lugemised, sünkroonituna kuni neljas seadmes.</string>
<string name="feature_summarize">Kokkuvõte</string> <string name="feature_summarize">Kokkuvõte</string>
<string name="feature_summarize_desc">Saate päevas 10 tasuta kokkuvõtet peatükkide või lehtede kohta</string> <string name="feature_summarize_desc">Saate päevas 10 tasuta kokkuvõtet peatükkide või lehtede kohta</string>
<string name="feature_smart_dict">Nutikas sõnastik</string> <string name="feature_smart_dict">Nutikas sõnastik</string>
<string name="feature_smart_dict_desc">Otsige fraase ja isegi lõike, mitte ainult üksikuid sõnu</string> <string name="feature_smart_dict_desc">Otsige fraase ja isegi lõike, mitte ainult üksikuid sõnu</string>
<string name="feature_priority">Prioriteetsete funktsioonide taotlused</string> <string name="feature_priority">Prioriteetsete funktsioonide taotlused</string>
<string name="feature_priority_desc">Teie ettepanekud seatakse prioriteediks</string> <string name="feature_priority_desc">Sinu ettepanekud seatakse prioriteediks</string>
<string name="pro_unlocked">Pro funktsioonid on lukustamata!</string> <string name="pro_unlocked">Pro funktsioonid on lukustamata!</string>
<string name="sign_in_required">Sisselogimine Nõutav</string> <string name="sign_in_required">Sisselogimine Nõutav</string>
<string name="verifying_purchase">Ostu kinnitamine…</string> <string name="verifying_purchase">Ostu kinnitamine…</string>
<string name="existing_purchase_found">Olemasolev ost leitud</string> <string name="existing_purchase_found">Olemasolev ost leitud</string>
<string name="get_lifetime_access">Hankige eluaegne juurdepääs</string> <string name="get_lifetime_access">Hangi eluaegne juurdepääs</string>
<string name="upgrade_unavailable">Uuendamine pole praegu saadaval. Kontrollige oma Internetti ja proovige uuesti.</string> <string name="upgrade_unavailable">Uuendamine pole praegu saadaval. Kontrollige oma Internetti ja proovige uuesti.</string>
<string name="sign_in_to_purchase">Logige sisse oma Google konto ostmiseks Episteme Pro.</string> <string name="sign_in_to_purchase">Logi sisse oma Google konto ostmiseks Episteme Pro.</string>
<string name="sign_in_to_purchase_credits">Logige sisse oma Google konto krediidi ostmiseks.</string> <string name="sign_in_to_purchase_credits">Logi sisse oma Google konto krediidi ostmiseks.</string>
<string name="verifying_purchase_desc">See võib võtta mõne hetke. Teie Pro staatust värskendatakse automaatselt.</string> <string name="verifying_purchase_desc">See võib võtta mõne hetke. Pro-olekut värskendatakse automaatselt.</string>
<string name="dialog_existing_purchase_desc">Sellel seadmel on juba Pro-ost, kuid see\' on lingitud teise kontoga. Pro funktsioonide taastamiseks logige sisse kontole, mida kasutati algsel ostul.</string> <string name="dialog_existing_purchase_desc">Selles seadmes on juba Pro-ost, kuid see on seotud teise kontoga. Pro-funktsioonide taastamiseks logi sisse kontoga, millega algne ost tehti.</string>
<string name="dialog_early_access_desc">Te\'toodate Episteme Pro meie varase juurdepääsu perioodil erisoodushinnaga! See on piiratud aja pakkumine.</string> <string name="dialog_early_access_desc">Te\'toodate Episteme Pro meie varase juurdepääsu perioodil erisoodushinnaga! See on piiratud aja pakkumine.</string>
<string name="dialog_sign_in_required_desc">Logige sisse oma Google konto ostmiseks Episteme Pro ja avage kõik esmaklassilised funktsioonid.</string> <string name="dialog_sign_in_required_desc">Logi Googlei kontoga sisse, et osta Episteme Pro ja avada kõik premium-funktsioonid.</string>
<string name="action_not_now">Mitte praegu</string> <string name="action_not_now">Mitte praegu</string>
<string name="action_got_it">Selge!</string> <string name="action_got_it">Selge!</string>
<string name="custom_fonts">Kohandatud fondid</string> <string name="custom_fonts">Kohandatud fondid</string>
@ -218,40 +218,40 @@
<string name="google_fonts_no_matches">No fonts found matching \'%1$s\'</string> <string name="google_fonts_no_matches">No fonts found matching \'%1$s\'</string>
<string name="content_desc_already_downloaded">Juba alla laaditud</string> <string name="content_desc_already_downloaded">Juba alla laaditud</string>
<string name="no_custom_fonts">Kohandatud fonte pole</string> <string name="no_custom_fonts">Kohandatud fonte pole</string>
<string name="import_fonts_desc">Importige TTF- või OTF-faile, et neid oma raamatutes kasutada.</string> <string name="import_fonts_desc">Impordi TTF- või OTF-faile, et neid oma raamatutes kasutada.</string>
<string name="font_preview_error">Eelvaade pole saadaval (kehtetu fondifail)</string> <string name="font_preview_error">Eelvaade pole saadaval (kehtetu fondifail)</string>
<string name="dialog_delete_font">Kas kustutada font?</string> <string name="dialog_delete_font">Kas kustutada font?</string>
<string name="dialog_delete_font_desc">Kas soovite kindlasti kustutada \'%1$s\'? Kui sünkroonimine on sisse lülitatud, eemaldatakse see kõigist teie seadmetest.</string> <string name="dialog_delete_font_desc">Kas kustutada &quot;%1$s&quot;? Kui sünkroonimine on sisse lülitatud, eemaldatakse see kõigist seadmetest.</string>
<string name="dialog_delete_fonts">Kas kustutada fondid?</string> <string name="dialog_delete_fonts">Kas kustutada fondid?</string>
<string name="dialog_delete_fonts_desc">Kas soovite kindlasti kustutada %1$d valitud fonte? Kui sünkroonimine on sisse lülitatud, eemaldatakse need kõigist teie seadmetest.</string> <string name="dialog_delete_fonts_desc">Kas kustutada %1$d valitud fonti? Kui sünkroonimine on sisse lülitatud, eemaldatakse need kõigist seadmetest.</string>
<string name="get_in_touch">Võtke ühendust</string> <string name="get_in_touch">Võtke ühendust</string>
<string name="feedback_desc">Kas leidsite vea, teil on funktsioonitaotlus või soovite lihtsalt tere öelda? Andke meile teada GitHubis või saatke meile e-kiri.</string> <string name="feedback_desc">Leidsid vea, sul on funktsioonisoov või tahad lihtsalt tere öelda? Anna GitHubis teada või saada meile e-kiri.</string>
<string name="github_issues">GitHubi probleemid</string> <string name="github_issues">GitHubi probleemid</string>
<string name="github_issues_desc">Teatage vigadest, taotlege funktsioone ja jälgige arenduse edenemist.</string> <string name="github_issues_desc">Teatage vigadest, taotlege funktsioone ja jälgige arenduse edenemist.</string>
<string name="email_support">Meili tugi</string> <string name="email_support">Meili tugi</string>
<string name="email_support_desc">Muude päringute korral võtke meiega otse e-posti teel ühendust.</string> <string name="email_support_desc">Muude päringute korral võtke meiega otse e-posti teel ühendust.</string>
<string name="support_project_title">Toetage projekti</string> <string name="support_project_title">Toeta projekti</string>
<string name="support_project_heading">Aidake hoida Episteme liigub</string> <string name="support_project_heading">Aidake hoida Episteme liigub</string>
<string name="support_project_desc">Teie tugi aitab mul hoida ja täiustada Episteme kõigile!!!</string> <string name="support_project_desc">Sinu tugi aitab Epistemet kõigi jaoks hoida ja täiustada.</string>
<string name="support_github_sponsor">Sponsor GitHubis</string> <string name="support_github_sponsor">Sponsor GitHubis</string>
<string name="support_github_sponsor_desc">Toetage arendust otse GitHubi sponsorite kaudu. Tänutäheks saate projekti repos README hüüdlause.</string> <string name="support_github_sponsor_desc">Toeta arendust otse GitHubi sponsorite kaudu. Tänutäheks saate projekti repos README hüüdlause.</string>
<string name="support_patreon">Liituge Patreoniga</string> <string name="support_patreon">Liituge Patreoniga</string>
<string name="support_patreon_desc">Tänutäheks rakenduse toetamise eest saavad Patreoni toetajad lisasisu ja -hüvesid: pilkupüüre sellest, millega ma töötan, varasemaid ekraanipilte ja värskendusi, hääli, mis aitavad kujundada, kuidas uued funktsioonid peaksid välja nägema ja töötama, ning README-hüüde projekti repos.</string> <string name="support_patreon_desc">Tänutäheks rakenduse toetamise eest saavad Patreoni toetajad lisasisu ja -hüvesid: pilkupüüre sellest, millega ma töötan, varasemaid ekraanipilte ja värskendusi, hääli, mis aitavad kujundada, kuidas uued funktsioonid peaksid välja nägema ja töötama, ning README-hüüde projekti repos.</string>
<string name="dialog_unlock_pro">Avage Episteme Pro</string> <string name="dialog_unlock_pro">Ava Episteme Pro</string>
<string name="dialog_unlock_pro_desc">Seadmetevaheline sünkroonimine on Pro funktsioon. Avage kõik professionaalsed funktsioonid ühe ühekordse ostuga.</string> <string name="dialog_unlock_pro_desc">Seadmetevaheline sünkroonimine on Pro-funktsioon. Ava kõik Pro-funktsioonid ühe ühekordse ostuga.</string>
<string name="action_upgrade">Uuendage</string> <string name="action_upgrade">Uuendage</string>
<string name="dialog_confirm_sign_out">Kinnitage väljalogimine</string> <string name="dialog_confirm_sign_out">Kinnitage väljalogimine</string>
<string name="dialog_confirm_sign_out_desc">Kas olete kindel, et soovite välja logida?</string> <string name="dialog_confirm_sign_out_desc">Kas logida välja?</string>
<string name="device_limit_reached">Seadme limiit on saavutatud</string> <string name="device_limit_reached">Seadme limiit on saavutatud</string>
<string name="device_limit_reached_desc">Kasutamiseks Episteme Pro selles seadmes eemaldage üks oma olemasolevatest registreeritud seadmetest.</string> <string name="device_limit_reached_desc">Episteme Pro kasutamiseks selles seadmes eemalda üks olemasolevatest registreeritud seadmetest.</string>
<string name="last_seen">Viimati nähtud: %1$s</string> <string name="last_seen">Viimati nähtud: %1$s</string>
<string name="dialog_destructive_action">Kinnitage hävitav tegevus</string> <string name="dialog_destructive_action">Kinnitage hävitav tegevus</string>
<string name="dialog_destructive_action_desc">See kustutab jäädavalt kõik teie raamatud ja lugemise edenemine sellest seadmest JA teie seadmest Google Drive konto. Seda toimingut ei saa tagasi võtta. Oled sa kindel?</string> <string name="dialog_destructive_action_desc">See kustutab jäädavalt kõik raamatud ja lugemisjärje sellest seadmest ning Google Drivei kontolt. Seda toimingut ei saa tagasi võtta. Kas jätkata?</string>
<string name="dialog_clear_book_cache">Tühjenda raamatu vahemälu</string> <string name="dialog_clear_book_cache">Tühjenda raamatu vahemälu</string>
<string name="dialog_clear_book_cache_desc">See kustutab kõik töödeldud lehed lehekülgede muutmise režiimis. See aitab lahendada paigutusprobleeme, kuid järgmisel korral tuleb raamatute avamisel uuesti töödelda.</string> <string name="dialog_clear_book_cache_desc">See kustutab kõik töödeldud lehed lehekülgede muutmise režiimis. See aitab lahendada paigutusprobleeme, kuid järgmisel korral tuleb raamatute avamisel uuesti töödelda.</string>
<string name="action_confirm_clear">Kinnita ja kustuta</string> <string name="action_confirm_clear">Kinnita ja kustuta</string>
<string name="dialog_clear_reflow_cache">Tühjendage reflow vahemälu</string> <string name="dialog_clear_reflow_cache">Tühjenda reflow-vahemälu</string>
<string name="dialog_clear_reflow_cache_desc">See kustutab kõik loodud \'Tekstivaade\' PDF-ide versioonid ja tühjendage nendega seotud pildid/HTML-i vahemälu. Teie algsed PDF-id jäävad puutumata.</string> <string name="dialog_clear_reflow_cache_desc">See kustutab kõik loodud PDF-ide tekstivaate versioonid ja tühjendab nendega seotud piltide/HTML-i vahemälu. Algsed PDF-id jäävad puutumata.</string>
<string name="tooltip_back">Tagasi</string> <string name="tooltip_back">Tagasi</string>
<string name="tooltip_dictionary">Sõnastik</string> <string name="tooltip_dictionary">Sõnastik</string>
<string name="tooltip_more_options">Rohkem valikuid</string> <string name="tooltip_more_options">Rohkem valikuid</string>
@ -267,7 +267,7 @@
<string name="tooltip_dark_mode_on">Luba tume režiim</string> <string name="tooltip_dark_mode_on">Luba tume režiim</string>
<string name="tooltip_dark_mode_off">Keela tume režiim</string> <string name="tooltip_dark_mode_off">Keela tume režiim</string>
<string name="tooltip_lock_pan">Lukusta panoraam</string> <string name="tooltip_lock_pan">Lukusta panoraam</string>
<string name="tooltip_unlock_pan">Avage panoraam</string> <string name="tooltip_unlock_pan">Ava panoraamimine</string>
<string name="tooltip_fullscreen">Täisekraan</string> <string name="tooltip_fullscreen">Täisekraan</string>
<string name="tooltip_highlights">Kuva esiletõstmised</string> <string name="tooltip_highlights">Kuva esiletõstmised</string>
<string name="tooltip_highlights_off">Peida esiletõstmised</string> <string name="tooltip_highlights_off">Peida esiletõstmised</string>
@ -280,37 +280,37 @@
<string name="tooltip_prev_result">Eelmine tulemus</string> <string name="tooltip_prev_result">Eelmine tulemus</string>
<string name="tooltip_next_result">Järgmine tulemus</string> <string name="tooltip_next_result">Järgmine tulemus</string>
<string name="tooltip_back_desc">Väljuge lugejast ja naaske avakuvale</string> <string name="tooltip_back_desc">Väljuge lugejast ja naaske avakuvale</string>
<string name="tooltip_dictionary_desc">Valige sõnade otsimiseks eelistatud rakendus</string> <string name="tooltip_dictionary_desc">Vali sõnade otsimiseks eelistatud rakendus</string>
<string name="tooltip_more_options_desc">Juurdepääs lugemisrežiimile, järjehoidjatele ja täpsematele seadetele</string> <string name="tooltip_more_options_desc">Juurdepääs lugemisrežiimile, järjehoidjatele ja täpsematele seadetele</string>
<string name="tooltip_slider_desc">Lohistage, et hüpata kiiresti dokumendi mis tahes lehele</string> <string name="tooltip_slider_desc">Lohistage, et hüpata kiiresti dokumendi mis tahes lehele</string>
<string name="tooltip_toc_desc">Sirvige peatükke ja navigeerige mis tahes jaotisesse</string> <string name="tooltip_toc_desc">Sirvige peatükke ja navigeerige mis tahes jaotisesse</string>
<string name="tooltip_format_desc">Reguleerige fonti, suurust, rea kõrgust, joondamist ja kohandatud fonte</string> <string name="tooltip_format_desc">Reguleerige fonti, suurust, rea kõrgust, joondamist ja kohandatud fonte</string>
<string name="tooltip_search_desc">Otsige sellest raamatust üles mis tahes sõna või fraas</string> <string name="tooltip_search_desc">Otsige sellest raamatust üles mis tahes sõna või fraas</string>
<string name="tooltip_ai_desc">Tehke praegusest peatükist või leheküljest kokkuvõte, kasutades AI</string> <string name="tooltip_ai_desc">Tehke praegusest peatükist või leheküljest kokkuvõte, kasutades AI</string>
<string name="tooltip_tts_start_desc">Lugege raamatut ette oma seadme\'s häälemootori abil</string> <string name="tooltip_tts_start_desc">Loe raamatut ette oma seadme\'s häälemootori abil</string>
<string name="tooltip_tts_stop_desc">Peatage praegune ettelugemise seanss</string> <string name="tooltip_tts_stop_desc">Peatage praegune ettelugemise seanss</string>
<string name="tooltip_tts_pause_desc">Peatage praegune etteloetud taasesitus</string> <string name="tooltip_tts_pause_desc">Peatage praegune etteloetud taasesitus</string>
<string name="tooltip_tts_resume_desc">Jätkake peatatud ettelugemisega taasesitust</string> <string name="tooltip_tts_resume_desc">Jätka peatatud ettelugemist</string>
<string name="tooltip_dark_mode_on_desc">Inverteerida PDF värvid tumeda režiimi jaoks</string> <string name="tooltip_dark_mode_on_desc">Inverteerida PDF värvid tumeda režiimi jaoks</string>
<string name="tooltip_dark_mode_off_desc">Keela tume režiim ja taasta originaal PDF värvid</string> <string name="tooltip_dark_mode_off_desc">Keela tume režiim ja taasta originaal PDF värvid</string>
<string name="tooltip_lock_pan_desc">Lukustage lehel horisontaalne panoraam</string> <string name="tooltip_lock_pan_desc">Lukusta lehel horisontaalne panoraamimine</string>
<string name="tooltip_unlock_pan_desc">Avage panoraam, et uuesti lubada suumimiseks ja lohistamiseks kokkusurutud liigutused</string> <string name="tooltip_unlock_pan_desc">Ava panoraamimine, et lubada uuesti suumimis- ja lohistusliigutused</string>
<string name="tooltip_fullscreen_desc">Peitke kõik kasutajaliidese juhtnupud, et näha kaasahaaravat ja häireteta lugemisvaadet</string> <string name="tooltip_fullscreen_desc">Peitke kõik kasutajaliidese juhtnupud, et näha kaasahaaravat ja häireteta lugemisvaadet</string>
<string name="tooltip_highlights_desc">Märkige visuaalselt valitud tekstipiirkonnad praegusel lehel</string> <string name="tooltip_highlights_desc">Märkige visuaalselt valitud tekstipiirkonnad praegusel lehel</string>
<string name="tooltip_highlights_off_desc">Eemaldage lehelt valitav tekstiülekate</string> <string name="tooltip_highlights_off_desc">Eemalda lehelt valitav tekstiülekate</string>
<string name="tooltip_edit_mode_desc">Lisage tinti või tekstimärkusi</string> <string name="tooltip_edit_mode_desc">Lisage tinti või tekstimärkusi</string>
<string name="tooltip_edit_mode_exit_desc">Lõpetage redigeerimine ja naaske tavalisse lugemisvaatesse</string> <string name="tooltip_edit_mode_exit_desc">Lõpetage redigeerimine ja naaske tavalisse lugemisvaatesse</string>
<string name="tooltip_close_search_desc">Väljuge otsingust ja minge tagasi lugeja juurde</string> <string name="tooltip_close_search_desc">Väljuge otsingust ja minge tagasi lugeja juurde</string>
<string name="tooltip_clear_search_desc">Kustutage praegune otsingupäring ja alustage otsast peale</string> <string name="tooltip_clear_search_desc">Kustutage praegune otsingupäring ja alustage otsast peale</string>
<string name="tooltip_show_results_desc">Laiendage paneeli, et näha kõiki otsingu vasteid</string> <string name="tooltip_show_results_desc">Laienda paneeli, et näha kõiki otsinguvastuseid</string>
<string name="tooltip_hide_results_desc">Ahendage otsingutulemuste paneel</string> <string name="tooltip_hide_results_desc">Ahenda otsingutulemuste paneel</string>
<string name="tooltip_prev_result_desc">Hüppa dokumendis eelmisele otsingu vastele</string> <string name="tooltip_prev_result_desc">Hüppa dokumendis eelmisele otsingu vastele</string>
<string name="tooltip_next_result_desc">Hüppa dokumendis järgmise otsingu vaste juurde</string> <string name="tooltip_next_result_desc">Hüppa dokumendis järgmise otsingu vaste juurde</string>
<string name="action_sign_in">Logi sisse</string> <string name="action_sign_in">Logi sisse</string>
<string name="action_select_folder">Valige kaust</string> <string name="action_select_folder">Vali kaust</string>
<string name="action_select">Valige</string> <string name="action_select">Vali</string>
<string name="legal_footer_combined">Privaatsuspoliitika • Kasutustingimused • Litsentsid</string> <string name="legal_footer_combined">Privaatsuspoliitika • Kasutustingimused • Litsentsid</string>
<string name="error_folder_selection_unsupported">Teie seade\' ei toeta kaustade valikut. Saate endiselt faile ükshaaval importida.</string> <string name="error_folder_selection_unsupported">Sinu seade ei toeta kaustade valimist. Faile saab endiselt ükshaaval importida.</string>
<string name="error_no_file_manager">Failihaldurit ei leitud. Installige failihalduri rakendus.</string> <string name="error_no_file_manager">Failihaldurit ei leitud. Installige failihalduri rakendus.</string>
<string name="banner_downloaded">Allalaaditud %1$s</string> <string name="banner_downloaded">Allalaaditud %1$s</string>
<string name="filter_facet">%1$s: %2$s</string> <string name="filter_facet">%1$s: %2$s</string>
@ -323,7 +323,7 @@
<string name="error_purchase_general">Ostmisel ilmnes viga.</string> <string name="error_purchase_general">Ostmisel ilmnes viga.</string>
<string name="banner_upgrade_success">Uuendamine õnnestus! Tere tulemast Pro-sse.</string> <string name="banner_upgrade_success">Uuendamine õnnestus! Tere tulemast Pro-sse.</string>
<string name="error_purchase_verification">Ostu kinnitamine ebaõnnestus. Kui teilt võeti tasu, võtke ühendust klienditoega.</string> <string name="error_purchase_verification">Ostu kinnitamine ebaõnnestus. Kui teilt võeti tasu, võtke ühendust klienditoega.</string>
<string name="banner_device_removed">See seade eemaldati teie kontolt.</string> <string name="banner_device_removed">See seade eemaldati sinu kontolt.</string>
<string name="error_verify_device">Seda seadet ei saanud kinnitada. Palun kontrollige oma ühendust.</string> <string name="error_verify_device">Seda seadet ei saanud kinnitada. Palun kontrollige oma ühendust.</string>
<string name="error_update_devices">Seadmete värskendamine ebaõnnestus. Palun proovi uuesti.</string> <string name="error_update_devices">Seadmete värskendamine ebaõnnestus. Palun proovi uuesti.</string>
<string name="banner_saving_pdf">Säästmine PDF…</string> <string name="banner_saving_pdf">Säästmine PDF…</string>
@ -332,9 +332,15 @@
<string name="error_saving_pdf">Viga salvestamisel PDF: %1$s</string> <string name="error_saving_pdf">Viga salvestamisel PDF: %1$s</string>
<string name="banner_saving_original_pdf">Originaali salvestamine PDF…</string> <string name="banner_saving_original_pdf">Originaali salvestamine PDF…</string>
<string name="banner_original_pdf_saved">Originaal PDF edukalt salvestatud.</string> <string name="banner_original_pdf_saved">Originaal PDF edukalt salvestatud.</string>
<string name="banner_saving_original_file">Algse faili salvestamine…</string>
<string name="banner_original_file_saved">Algne fail salvestati.</string>
<string name="error_saving_file">Faili salvestamisel tekkis viga: %1$s</string>
<string name="share_subject">Jagamine: %1$s</string> <string name="share_subject">Jagamine: %1$s</string>
<string name="share_chooser_title">Jaga PDF</string> <string name="share_chooser_title">Jaga PDF</string>
<string name="share_file_chooser_title">Jaga faili</string>
<string name="error_share_failed">Jagamine ebaõnnestus: %1$s</string> <string name="error_share_failed">Jagamine ebaõnnestus: %1$s</string>
<string name="error_copy_to_clipboard">Lõikelauale kopeerimine nurjus</string>
<string name="error_print_password_protected">Parooliga kaitstud PDF-faile ei saa printida</string>
<string name="error_folder_limit_reached">Limiit saavutatud: maksimaalne %1$d kaustad lubatud.</string> <string name="error_folder_limit_reached">Limiit saavutatud: maksimaalne %1$d kaustad lubatud.</string>
<string name="error_folder_already_synced">See kaust on juba sünkroonitud.</string> <string name="error_folder_already_synced">See kaust on juba sünkroonitud.</string>
<string name="banner_folder_added">Lisatud kaust: %1$s</string> <string name="banner_folder_added">Lisatud kaust: %1$s</string>
@ -360,7 +366,7 @@
<string name="error_sign_in_failed">Sisselogimine ebaõnnestus. Palun proovi uuesti.</string> <string name="error_sign_in_failed">Sisselogimine ebaõnnestus. Palun proovi uuesti.</string>
<string name="error_no_google_account">Ei leitud Google konto. See võib juhtuda värske installi korral, proovige mõne aja pärast uuesti.</string> <string name="error_no_google_account">Ei leitud Google konto. See võib juhtuda värske installi korral, proovige mõne aja pärast uuesti.</string>
<string name="error_sign_in_internet">Sisselogimisel ilmnes viga. Kontrollige oma Interneti-ühendust.</string> <string name="error_sign_in_internet">Sisselogimisel ilmnes viga. Kontrollige oma Interneti-ühendust.</string>
<string name="error_sign_in_device_management">Seadmehalduse testimiseks logige sisse.</string> <string name="error_sign_in_device_management">Seadmehalduse testimiseks logi sisse.</string>
<string name="error_sync_pro_feature">Sünkroonimine on Episteme Pro funktsiooni.</string> <string name="error_sync_pro_feature">Sünkroonimine on Episteme Pro funktsiooni.</string>
<string name="error_not_signed_in_sync">Pole sisse logitud, ei saa sünkroonida.</string> <string name="error_not_signed_in_sync">Pole sisse logitud, ei saa sünkroonida.</string>
<string name="banner_cloud_sync_checking">Pilvesünkroonimine: värskenduste otsimine…</string> <string name="banner_cloud_sync_checking">Pilvesünkroonimine: värskenduste otsimine…</string>
@ -386,7 +392,7 @@
<string name="search_no_results_simple">Tulemusi ei leitud.</string> <string name="search_no_results_simple">Tulemusi ei leitud.</string>
<string name="generating_summary">Kokkuvõtte genereerimine…</string> <string name="generating_summary">Kokkuvõtte genereerimine…</string>
<string name="action_stop">Peatus</string> <string name="action_stop">Peatus</string>
<string name="action_read_aloud">Lugege ette</string> <string name="action_read_aloud">Loe ette</string>
<string name="action_copy">Kopeeri</string> <string name="action_copy">Kopeeri</string>
<string name="action_copy_thread">Kopeeri lõim</string> <string name="action_copy_thread">Kopeeri lõim</string>
<string name="no_summary_generated">Kokkuvõtet ei saanud luua.</string> <string name="no_summary_generated">Kokkuvõtet ei saanud luua.</string>
@ -404,7 +410,7 @@
<string name="tts_device_voice_settings">Seadme hääleseaded</string> <string name="tts_device_voice_settings">Seadme hääleseaded</string>
<string name="content_desc_close_settings">Sulgege seaded</string> <string name="content_desc_close_settings">Sulgege seaded</string>
<string name="tts_system_default">Süsteemi vaikeseade</string> <string name="tts_system_default">Süsteemi vaikeseade</string>
<string name="tts_system_default_desc">Vastab teie Android süsteemi seaded</string> <string name="tts_system_default_desc">Vastab sinu Android süsteemi seaded</string>
<string name="content_desc_selected">Valitud</string> <string name="content_desc_selected">Valitud</string>
<string name="tts_loading_voices">Häälte laadimine…</string> <string name="tts_loading_voices">Häälte laadimine…</string>
<string name="tts_no_voices">Selles seadmes pole hääli saadaval.</string> <string name="tts_no_voices">Selles seadmes pole hääli saadaval.</string>
@ -450,7 +456,7 @@
<string name="dict_external_description">Kasutab valitud rakendust sõnastikust otsimiseks.</string> <string name="dict_external_description">Kasutab valitud rakendust sõnastikust otsimiseks.</string>
<string name="dict_fallback_app">Varurakendus</string> <string name="dict_fallback_app">Varurakendus</string>
<string name="dict_dictionary_app">Sõnastiku rakendus</string> <string name="dict_dictionary_app">Sõnastiku rakendus</string>
<string name="dict_select_app">Valige rakendus</string> <string name="dict_select_app">Vali rakendus</string>
<string name="dict_translate">Tõlgi</string> <string name="dict_translate">Tõlgi</string>
<string name="dict_translate_description">Rakendus, mida kasutatakse valitud teksti tõlkimiseks.</string> <string name="dict_translate_description">Rakendus, mida kasutatakse valitud teksti tõlkimiseks.</string>
<string name="dict_search_app">Otsi rakendust</string> <string name="dict_search_app">Otsi rakendust</string>
@ -466,16 +472,16 @@
<string name="ai_generating_recap">Kokkuvõtte genereerimine…</string> <string name="ai_generating_recap">Kokkuvõtte genereerimine…</string>
<string name="ai_chapter_summary">Peatüki kokkuvõte</string> <string name="ai_chapter_summary">Peatüki kokkuvõte</string>
<string name="ai_story_recap_beta">Loo kokkuvõte (beeta)</string> <string name="ai_story_recap_beta">Loo kokkuvõte (beeta)</string>
<string name="ai_unlock_summarization">Avage peatüki kokkuvõte</string> <string name="ai_unlock_summarization">Ava peatüki kokkuvõte</string>
<string name="ai_unlock_summarization_desc">Saate mis tahes peatüki lühikokkuvõtteid kasutades Episteme Pro. Selle funktsiooni kasutamise alustamiseks uuendage.</string> <string name="ai_unlock_summarization_desc">Saate mis tahes peatüki lühikokkuvõtteid kasutades Episteme Pro. Selle funktsiooni kasutamise alustamiseks uuendage.</string>
<string name="action_learn_more">Lisateave</string> <string name="action_learn_more">Lisateave</string>
<string name="ai_unlock_smart_dict">Avage nutikas sõnaraamat</string> <string name="ai_unlock_smart_dict">Ava nutikas sõnastik</string>
<string name="ai_unlock_smart_dict_desc">Tervete fraaside ja lõikude määratlemine kuni 2000 tähemärgini on Pro funktsioon. Täiendage, et saada mis tahes valitud teksti jaoks kohesed määratlused.</string> <string name="ai_unlock_smart_dict_desc">Tervete fraaside ja lõikude määratlemine kuni 2000 tähemärgini on Pro funktsioon. Täiendage, et saada mis tahes valitud teksti jaoks kohesed määratlused.</string>
<string name="content_desc_bookmark_icon">Järjehoidja</string> <string name="content_desc_bookmark_icon">Järjehoidja</string>
<string name="content_desc_selected_slot">Valitud pesa</string> <string name="content_desc_selected_slot">Valitud pesa</string>
<string name="dialog_customize_palette">Kohandage palett</string> <string name="dialog_customize_palette">Kohandage palett</string>
<string name="palette_tap_slot_to_edit">Puudutage muutmiseks pesa:</string> <string name="palette_tap_slot_to_edit">Puudutage muutmiseks pesa:</string>
<string name="palette_select_color_for_slot">Valige pesa värv:</string> <string name="palette_select_color_for_slot">Vali pesa värv:</string>
<string name="chapter_empty">See peatükk on tühi.</string> <string name="chapter_empty">See peatükk on tühi.</string>
<string name="chapter_not_found">Peatükki ei leitud</string> <string name="chapter_not_found">Peatükki ei leitud</string>
<string name="error_loading_chapter">Viga peatüki laadimisel</string> <string name="error_loading_chapter">Viga peatüki laadimisel</string>
@ -496,7 +502,7 @@
<string name="menu_volume_button_scrolling">Helitugevuse nupu kerimine</string> <string name="menu_volume_button_scrolling">Helitugevuse nupu kerimine</string>
<string name="menu_volume_button_page_turn">Helitugevuse nupp Lehekülje pööramine</string> <string name="menu_volume_button_page_turn">Helitugevuse nupp Lehekülje pööramine</string>
<string name="menu_realistic_page_turns">Realistlikud leheküljepöörded</string> <string name="menu_realistic_page_turns">Realistlikud leheküljepöörded</string>
<string name="menu_keep_screen_on">Hoidke ekraan sees</string> <string name="menu_keep_screen_on">Hoia ekraan sees</string>
<string name="menu_visual_options">Visuaalsed valikud</string> <string name="menu_visual_options">Visuaalsed valikud</string>
<string name="menu_screen_orientation">Ekraani suund</string> <string name="menu_screen_orientation">Ekraani suund</string>
<string name="menu_change_reading_mode">Muutke lugemisrežiimi</string> <string name="menu_change_reading_mode">Muutke lugemisrežiimi</string>
@ -526,7 +532,7 @@
<string name="content_desc_start_playback">Mängi</string> <string name="content_desc_start_playback">Mängi</string>
<string name="auto_scroll_local_speed">Kohalik kiirus</string> <string name="auto_scroll_local_speed">Kohalik kiirus</string>
<string name="auto_scroll_global_speed">Globaalne kiirus</string> <string name="auto_scroll_global_speed">Globaalne kiirus</string>
<string name="content_desc_select_mode">Valige Režiim</string> <string name="content_desc_select_mode">Vali režiim</string>
<string name="auto_scroll_applies_all_files">Kehtib kõikidele failidele</string> <string name="auto_scroll_applies_all_files">Kehtib kõikidele failidele</string>
<string name="auto_scroll_saved_for_file">Salvestatud ainult selle faili jaoks</string> <string name="auto_scroll_saved_for_file">Salvestatud ainult selle faili jaoks</string>
<string name="content_desc_disable_musician_mode">Keela muusiku režiim</string> <string name="content_desc_disable_musician_mode">Keela muusiku režiim</string>
@ -550,25 +556,25 @@
<string name="action_locate">Otsige üles</string> <string name="action_locate">Otsige üles</string>
<string name="no_bookmarks_yet">You haven\'t added any bookmarks yet.</string> <string name="no_bookmarks_yet">You haven\'t added any bookmarks yet.</string>
<string name="no_images_found">Pilte ei leitud.</string> <string name="no_images_found">Pilte ei leitud.</string>
<string name="content_desc_download_image">Laadige pilt alla</string> <string name="content_desc_download_image">Laadi pilt alla</string>
<string name="content_desc_more_options_bookmark">Rohkem valikuid järjehoidja jaoks</string> <string name="content_desc_more_options_bookmark">Rohkem valikuid järjehoidja jaoks</string>
<string name="dialog_rename_bookmark">Nimeta järjehoidja ümber</string> <string name="dialog_rename_bookmark">Nimeta järjehoidja ümber</string>
<string name="label_new_name">Uus nimi</string> <string name="label_new_name">Uus nimi</string>
<string name="label_new_title">Uus pealkiri</string> <string name="label_new_title">Uus pealkiri</string>
<string name="dialog_delete_bookmark">Kas kustutada järjehoidja?</string> <string name="dialog_delete_bookmark">Kas kustutada järjehoidja?</string>
<string name="dialog_delete_bookmark_desc">Kas olete kindel, et soovite selle järjehoidja jäädavalt kustutada?</string> <string name="dialog_delete_bookmark_desc">Kas oled kindel, et soovid selle järjehoidja jäädavalt kustutada?</string>
<string name="no_highlights_yet">Esiletõsteid veel pole.</string> <string name="no_highlights_yet">Esiletõsteid veel pole.</string>
<string name="unknown_chapter">Tundmatu peatükk</string> <string name="unknown_chapter">Tundmatu peatükk</string>
<string name="content_desc_options">Valikud</string> <string name="content_desc_options">Valikud</string>
<string name="dialog_delete_highlight">Kas kustutada esiletõst?</string> <string name="dialog_delete_highlight">Kas kustutada esiletõst?</string>
<string name="dialog_delete_highlight_desc">Kas olete kindel, et soovite selle esiletõstmise jäädavalt kustutada?</string> <string name="dialog_delete_highlight_desc">Kas kustutada see esiletõst jäädavalt?</string>
<string name="saved_image_message">Salvestatud %1$s</string> <string name="saved_image_message">Salvestatud %1$s</string>
<string name="error_save_image">Pilti ei saanud salvestada.</string> <string name="error_save_image">Pilti ei saanud salvestada.</string>
<string name="banner_original_pdf_not_found">Originaal PDF ei leitud.</string> <string name="banner_original_pdf_not_found">Originaal PDF ei leitud.</string>
<string name="error_book_content_not_found">Viga: raamatu sisu ei leitud. Tee: %1$s</string> <string name="error_book_content_not_found">Viga: raamatu sisu ei leitud. Tee: %1$s</string>
<string name="toast_select_dictionary_first">Valige esmalt sõnastikurakendus.</string> <string name="toast_select_dictionary_first">Vali esmalt sõnastikurakendus.</string>
<string name="toast_select_translate_first">Valige esmalt tõlkerakendus.</string> <string name="toast_select_translate_first">Vali esmalt tõlkerakendus.</string>
<string name="toast_select_search_first">Valige esmalt otsingurakendus.</string> <string name="toast_select_search_first">Vali esmalt otsingurakendus.</string>
<string name="no_chapters_available">Selle raamatu jaoks pole peatükke saadaval.</string> <string name="no_chapters_available">Selle raamatu jaoks pole peatükke saadaval.</string>
<string name="navigating_to_position">Navigeerimine asukohta…</string> <string name="navigating_to_position">Navigeerimine asukohta…</string>
<string name="dialog_permission_required">Nõutav luba</string> <string name="dialog_permission_required">Nõutav luba</string>
@ -578,7 +584,7 @@
<string name="dialog_justified_text_limitation_desc">Põhjendatud joonduse kasutamine leheküljelises režiimis võib küljenduse piirangute tõttu muuta teksti valiku ja esiletõstmised ebatäpseks.</string> <string name="dialog_justified_text_limitation_desc">Põhjendatud joonduse kasutamine leheküljelises režiimis võib küljenduse piirangute tõttu muuta teksti valiku ja esiletõstmised ebatäpseks.</string>
<string name="action_i_understand">ma saan aru</string> <string name="action_i_understand">ma saan aru</string>
<string name="navigating_to_chapter">Peatükki navigeerimine…</string> <string name="navigating_to_chapter">Peatükki navigeerimine…</string>
<string name="toast_select_offline_dict_first">Valige esmalt võrguühenduseta sõnastik.</string> <string name="toast_select_offline_dict_first">Vali esmalt võrguühenduseta sõnastik.</string>
<string name="banner_book_not_paginated">Raamat pole veel lehekülgedega varustatud.</string> <string name="banner_book_not_paginated">Raamat pole veel lehekülgedega varustatud.</string>
<string name="banner_wait_for_load">Oodake, kuni raamat on täielikult laaditud.</string> <string name="banner_wait_for_load">Oodake, kuni raamat on täielikult laaditud.</string>
<string name="release_for_previous_chapter">Eelmise peatüki väljalase</string> <string name="release_for_previous_chapter">Eelmise peatüki väljalase</string>
@ -594,7 +600,7 @@
<string name="action_reset">Lähtesta</string> <string name="action_reset">Lähtesta</string>
<string name="label_size">Suurus</string> <string name="label_size">Suurus</string>
<string name="label_spacing">Vahekaugus</string> <string name="label_spacing">Vahekaugus</string>
<string name="select_font">Valige Font</string> <string name="select_font">Vali Font</string>
<string name="tab_presets">Eelseaded</string> <string name="tab_presets">Eelseaded</string>
<string name="tab_imported">Imporditud</string> <string name="tab_imported">Imporditud</string>
<string name="button_import_from_files">Import failidest</string> <string name="button_import_from_files">Import failidest</string>
@ -606,20 +612,20 @@
<string name="visual_options_pdf_spread_two">Kaks lehte</string> <string name="visual_options_pdf_spread_two">Kaks lehte</string>
<string name="visual_options_pdf_first_page_alone">Esimene leht üksi</string> <string name="visual_options_pdf_first_page_alone">Esimene leht üksi</string>
<string name="visual_options_pdf_first_page_alone_desc">Alustab esikülje laialivalgumist pärast kaanelehte.</string> <string name="visual_options_pdf_first_page_alone_desc">Alustab esikülje laialivalgumist pärast kaanelehte.</string>
<string name="visual_options_remove_page_gap">Eemaldage lehtede vahe</string> <string name="visual_options_remove_page_gap">Eemalda lehtede vahe</string>
<string name="visual_options_remove_page_gap_desc">Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel.</string> <string name="visual_options_remove_page_gap_desc">Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel.</string>
<string name="visual_options_hide_page_number_overlay">Peida lehenumbri ülekate</string> <string name="visual_options_hide_page_number_overlay">Peida lehenumbri ülekate</string>
<string name="visual_options_hide_page_number_overlay_desc">Eemaldab igalt lehelt väikese lehekülgede arvu sildi.</string> <string name="visual_options_hide_page_number_overlay_desc">Eemaldab igalt lehelt väikese lehekülgede arvu sildi.</string>
<string name="visual_options_system_ui">Süsteemi kasutajaliides (oleku- ja navigeerimisribad)</string> <string name="visual_options_system_ui">Süsteemi kasutajaliides (oleku- ja navigeerimisribad)</string>
<string name="visual_options_system_ui_desc">Kontrollige seadme\'-süsteemi ribade nähtavust.</string> <string name="visual_options_system_ui_desc">Kontrollige seadme\'-süsteemi ribade nähtavust.</string>
<string name="visual_options_screen_orientation">Ekraani suund</string> <string name="visual_options_screen_orientation">Ekraani suund</string>
<string name="visual_options_screen_orientation_desc">Valige, kas lugeja järgib süsteemi orientatsiooni või eelistab vertikaalset või horisontaalset, kui Android lubab seda.</string> <string name="visual_options_screen_orientation_desc">Vali, kas lugeja järgib süsteemi orientatsiooni või eelistab vertikaalset või horisontaalset, kui Android lubab seda.</string>
<string name="visual_options_progress_bar">Edenemisriba</string> <string name="visual_options_progress_bar">Edenemisriba</string>
<string name="visual_options_progress_bar_desc">Lugemise edenemise ja peatüki indikaator lugemisekraanil.</string> <string name="visual_options_progress_bar_desc">Lugemise edenemise ja peatüki indikaator lugemisekraanil.</string>
<string name="visual_options_progress_bar_position">positsioon</string> <string name="visual_options_progress_bar_position">positsioon</string>
<string name="visual_options_seamless_chapter">Peatükkide sujuv üleminek</string> <string name="visual_options_seamless_chapter">Peatükkide sujuv üleminek</string>
<string name="visual_options_seamless_chapter_desc">Laadige lõpust mööda kerides kohe järgmine/eelmine peatükk, ilma tõmmake värskendamiseks animatsioonita.</string> <string name="visual_options_seamless_chapter_desc">Laadi lõpust mööda kerides kohe järgmine/eelmine peatükk, ilma tõmmake värskendamiseks animatsioonita.</string>
<string name="visual_options_edge_padding">Eemaldage serva polsterdus</string> <string name="visual_options_edge_padding">Eemalda servapolsterdus</string>
<string name="visual_options_edge_padding_desc">Eemaldab horisontaalse vahe vasakust ja paremast servast.</string> <string name="visual_options_edge_padding_desc">Eemaldab horisontaalse vahe vasakust ja paremast servast.</string>
<string name="reader_brightness_title">Heledus</string> <string name="reader_brightness_title">Heledus</string>
<string name="reader_brightness_system">Kasutage süsteemi heledust</string> <string name="reader_brightness_system">Kasutage süsteemi heledust</string>
@ -636,10 +642,10 @@
<string name="about_version_name">Versioon %1$s</string> <string name="about_version_name">Versioon %1$s</string>
<string name="about_build_code">Ehitamine %1$s</string> <string name="about_build_code">Ehitamine %1$s</string>
<string name="about_github_desc">Sirvige lähtekoodi, tärniga, kahvliga ja teatage probleemidest.</string> <string name="about_github_desc">Sirvige lähtekoodi, tärniga, kahvliga ja teatage probleemidest.</string>
<string name="about_privacy_desc">Kuidas me teie andmeid käsitleme.</string> <string name="about_privacy_desc">Kuidas me sinu andmeid käsitleme.</string>
<string name="about_terms_desc">Kasutustingimused.</string> <string name="about_terms_desc">Kasutustingimused.</string>
<string name="about_licenses_desc">Kasutatud avatud lähtekoodiga teegid.</string> <string name="about_licenses_desc">Kasutatud avatud lähtekoodiga teegid.</string>
<string name="banner_importing_multiple">Importimine %1$d raamatud… Need ilmuvad peagi teie kogusse.</string> <string name="banner_importing_multiple">Importimine %1$d raamatud… Need ilmuvad peagi sinu kogusse.</string>
<string name="banner_shelf_created">Loodi riiul &quot;%1$s&quot;.</string> <string name="banner_shelf_created">Loodi riiul &quot;%1$s&quot;.</string>
<string name="banner_smart_shelf_created">Loodi nutikas riiul &quot;%1$s&quot;.</string> <string name="banner_smart_shelf_created">Loodi nutikas riiul &quot;%1$s&quot;.</string>
<string name="banner_shelf_renamed">Riiul nimetati ümber &quot;%1$s&quot;.</string> <string name="banner_shelf_renamed">Riiul nimetati ümber &quot;%1$s&quot;.</string>
@ -678,7 +684,7 @@
<string name="tts_voice_adjustments">Hääle reguleerimine</string> <string name="tts_voice_adjustments">Hääle reguleerimine</string>
<string name="tts_speed_label">Kiirus (%1$sx)</string> <string name="tts_speed_label">Kiirus (%1$sx)</string>
<string name="tts_pitch_label">Kõrgus (%1$sx)</string> <string name="tts_pitch_label">Kõrgus (%1$sx)</string>
<string name="tts_sample_text">Nii kõlavad teie praegused hääleseaded.</string> <string name="tts_sample_text">Nii kõlavad sinu praegused hääleseaded.</string>
<string name="tts_pause_book">Peata raamat</string> <string name="tts_pause_book">Peata raamat</string>
<string name="tts_resume_book">Jätkamise raamat</string> <string name="tts_resume_book">Jätkamise raamat</string>
<string name="tts_system_settings">Süsteemi hääle/mootori sätted</string> <string name="tts_system_settings">Süsteemi hääle/mootori sätted</string>
@ -688,7 +694,7 @@
<string name="auto_scroll_desc_local">Salvestatud ainult selle faili jaoks</string> <string name="auto_scroll_desc_local">Salvestatud ainult selle faili jaoks</string>
<string name="action_scroll_to_top">Kerige üles</string> <string name="action_scroll_to_top">Kerige üles</string>
<string name="title_customize_toolbar">Kohandage tööriistariba</string> <string name="title_customize_toolbar">Kohandage tööriistariba</string>
<string name="desc_customize_toolbar">Valige tööriistad, mida soovite nähtavana hoida. Tööriista märke tühistamine peidab selle kasutajaliidese eest, et anda teile tähelepanu kõrvalejuhtimiseta lugemisruumi.</string> <string name="desc_customize_toolbar">Vali tööriistad, mida nähtavana hoida. Tööriista märke eemaldamine peidab selle kasutajaliidesest, et lugemisruum oleks häirimatu.</string>
<string name="tab_annotations">Märkused</string> <string name="tab_annotations">Märkused</string>
<string name="filter_all">Kõik</string> <string name="filter_all">Kõik</string>
<string name="filter_with_notes">Märkmetega</string> <string name="filter_with_notes">Märkmetega</string>
@ -706,8 +712,8 @@
<string name="content_desc_undo">Võta tagasi</string> <string name="content_desc_undo">Võta tagasi</string>
<string name="content_desc_redo">Tee uuesti</string> <string name="content_desc_redo">Tee uuesti</string>
<string name="content_desc_show_dock">Näita dokki</string> <string name="content_desc_show_dock">Näita dokki</string>
<string name="content_desc_select_font_family">Valige Fontide perekond</string> <string name="content_desc_select_font_family">Vali fondipere</string>
<string name="content_desc_select_font_size">Valige Fondi suurus</string> <string name="content_desc_select_font_size">Vali Fondi suurus</string>
<string name="content_desc_font_background">Fondi taust</string> <string name="content_desc_font_background">Fondi taust</string>
<string name="content_desc_bold">Paks</string> <string name="content_desc_bold">Paks</string>
<string name="content_desc_italic">Kursiiv</string> <string name="content_desc_italic">Kursiiv</string>
@ -731,7 +737,7 @@
<string name="menu_insert_blank_page">Sisesta tühi leht</string> <string name="menu_insert_blank_page">Sisesta tühi leht</string>
<string name="menu_delete_page">Kustuta leht</string> <string name="menu_delete_page">Kustuta leht</string>
<string name="generating_reflow_progress">Tekib… %1$d%%</string> <string name="generating_reflow_progress">Tekib… %1$d%%</string>
<string name="action_open_text_view">Avage tekstivaade</string> <string name="action_open_text_view">Ava tekstivaade</string>
<string name="action_generate_text_view">Loo tekstivaade</string> <string name="action_generate_text_view">Loo tekstivaade</string>
<string name="action_share">Jaga</string> <string name="action_share">Jaga</string>
<string name="action_save_copy_to_device">Salvesta koopia seadmesse</string> <string name="action_save_copy_to_device">Salvesta koopia seadmesse</string>
@ -743,11 +749,11 @@
<string name="msg_search_pages_count">%1$d+ Lehekülgi</string> <string name="msg_search_pages_count">%1$d+ Lehekülgi</string>
<string name="action_summarize_page">Lehe kokkuvõte (lehekülg %1$d)</string> <string name="action_summarize_page">Lehe kokkuvõte (lehekülg %1$d)</string>
<string name="msg_downloading_language_pack">Allalaadimine %1$s keelepakett…</string> <string name="msg_downloading_language_pack">Allalaadimine %1$s keelepakett…</string>
<string name="title_select_ocr_language">Valige OCR Keel</string> <string name="title_select_ocr_language">Vali OCR Keel</string>
<string name="desc_select_ocr_language">Paremate tekstituvastustulemuste saamiseks valige selle dokumendi esmane keel/skript.</string> <string name="desc_select_ocr_language">Paremate tekstituvastustulemuste saamiseks vali selle dokumendi peamine keel/kiri.</string>
<string name="desc_ocr_language_change_later">Saate seda hiljem muuta jaotises Rohkem valikuid &gt; OCR Keel.</string> <string name="desc_ocr_language_change_later">Saate seda hiljem muuta jaotises Rohkem valikuid &gt; OCR Keel.</string>
<string name="title_reindex_document">Kas dokument uuesti indekseerida?</string> <string name="title_reindex_document">Kas dokument uuesti indekseerida?</string>
<string name="desc_reindex_document_warning">You are changing the OCR script to %1$s.\n\nTo ensure search accuracy, we need to clear the existing index and re-scan pages that require OCR using this new language.\n\nThis will happen in the background.</string> <string name="desc_reindex_document_warning">Muudad OCR-i kirja väärtuseks %1$s.\n\nOtsingu täpsuse tagamiseks peame olemasoleva indeksi tühjendama ja OCR-i vajavad lehed selle uue keelega uuesti skannima.\n\nSee toimub taustal.</string>
<string name="action_reindex">Indekseeri uuesti</string> <string name="action_reindex">Indekseeri uuesti</string>
<string name="title_password_protected">Parooliga kaitstud</string> <string name="title_password_protected">Parooliga kaitstud</string>
<string name="desc_password_protected">See dokument on krüpteeritud. Selle vaatamiseks sisestage parool.</string> <string name="desc_password_protected">See dokument on krüpteeritud. Selle vaatamiseks sisestage parool.</string>
@ -758,13 +764,13 @@
<string name="desc_external_link_warning">You are about to navigate to:\n%1$s</string> <string name="desc_external_link_warning">You are about to navigate to:\n%1$s</string>
<string name="action_visit">Külastage</string> <string name="action_visit">Külastage</string>
<string name="title_save_to_device">Salvesta seadmesse</string> <string name="title_save_to_device">Salvesta seadmesse</string>
<string name="desc_choose_format_save">Valige salvestamiseks vorming:</string> <string name="desc_choose_format_save">Vali salvestamiseks vorming:</string>
<string name="action_with_annotations">Koos märkustega</string> <string name="action_with_annotations">Koos märkustega</string>
<string name="action_original">Originaal</string> <string name="action_original">Originaal</string>
<string name="desc_choose_format_share">Valige jagamiseks vorming:</string> <string name="desc_choose_format_share">Vali jagamiseks vorming:</string>
<string name="msg_preparing_pdf">Ettevalmistus PDF…</string> <string name="msg_preparing_pdf">Ettevalmistus PDF…</string>
<string name="title_add_pdf_to_tab">Lisa PDF vahekaardile</string> <string name="title_add_pdf_to_tab">Lisa PDF vahekaardile</string>
<string name="msg_no_other_pdfs_found">Teisi PDF-e teie teegist ei leitud.</string> <string name="msg_no_other_pdfs_found">Teisi PDF-e sinu teegist ei leitud.</string>
<string name="msg_pdf_empty_or_error">PDF on tühi või seda ei saa kuvada.</string> <string name="msg_pdf_empty_or_error">PDF on tühi või seda ei saa kuvada.</string>
<string name="msg_page_added_at">Leht lisatud aadressil %1$d</string> <string name="msg_page_added_at">Leht lisatud aadressil %1$d</string>
<string name="msg_page_deleted">Leht kustutatud</string> <string name="msg_page_deleted">Leht kustutatud</string>
@ -792,23 +798,24 @@
<string name="dialog_strict_file_filter_title">Luba range failifilter</string> <string name="dialog_strict_file_filter_title">Luba range failifilter</string>
<string name="dialog_strict_file_filter_desc">If you enable this, some supported file types like AZW3, CB7, and FB2 might not show up depending on your file manager.\n\nAre you sure you want to enable this filter?</string> <string name="dialog_strict_file_filter_desc">If you enable this, some supported file types like AZW3, CB7, and FB2 might not show up depending on your file manager.\n\nAre you sure you want to enable this filter?</string>
<string name="language_system_default">Süsteemi vaikeseade</string> <string name="language_system_default">Süsteemi vaikeseade</string>
<string name="language_english">inglise keel</string> <string name="language_english">English (inglise)</string>
<string name="language_english_default">inglise keel (vaikimisi)</string> <string name="language_english_default">English (vaikimisi)</string>
<string name="language_arabic">العربية (araabia)</string> <string name="language_arabic">العربية (araabia)</string>
<string name="language_german">saksa (saksa)</string> <string name="language_german">Deutsch (saksa)</string>
<string name="language_turkish">türkçe (türgi)</string> <string name="language_turkish">türkçe (türgi)</string>
<string name="language_french">Français (prantsuse)</string> <string name="language_french">Français (prantsuse)</string>
<string name="language_russian">Русский (vene)</string> <string name="language_russian">Русский (vene)</string>
<string name="language_belarusian">Беларуская (valgevene keel)</string> <string name="language_belarusian">Беларуская (valgevene keel)</string>
<string name="language_spanish">español (hispaania)</string> <string name="language_spanish">español (hispaania)</string>
<string name="language_portuguese_brazilian">portugali keel (Brasiilia)</string> <string name="language_portuguese_brazilian">Português (Brasiilia)</string>
<string name="language_italian">itaalia keel (itaalia)</string> <string name="language_italian">Italiano (itaalia)</string>
<string name="language_polish">polski (poola)</string> <string name="language_polish">polski (poola)</string>
<string name="language_vietnamese">Tiếng Việt (vietnami)</string> <string name="language_vietnamese">Tiếng Việt (vietnami)</string>
<string name="language_japanese">日本語 (jaapani keel)</string> <string name="language_japanese">日本語 (jaapani keel)</string>
<string name="language_korean">한국어 (korea)</string> <string name="language_korean">한국어 (korea)</string>
<string name="language_hindi">हिन्दी (hindi)</string> <string name="language_hindi">हिन्दी (hindi)</string>
<string name="language_chinese_simplified">简体中文 (hiina, lihtsustatud)</string> <string name="language_chinese_simplified">简体中文 (hiina, lihtsustatud)</string>
<string name="language_estonian">Eesti</string>
<string name="app_theme_title">Rakenduse teema</string> <string name="app_theme_title">Rakenduse teema</string>
<string name="app_theme_appearance">Välimus</string> <string name="app_theme_appearance">Välimus</string>
<string name="app_theme_contrast">Kontrast</string> <string name="app_theme_contrast">Kontrast</string>
@ -828,7 +835,7 @@
<string name="content_desc_app_theme">Rakenduse teema</string> <string name="content_desc_app_theme">Rakenduse teema</string>
<string name="content_desc_app_icon">Rakenduse ikoon</string> <string name="content_desc_app_icon">Rakenduse ikoon</string>
<string name="content_desc_device">Seade</string> <string name="content_desc_device">Seade</string>
<string name="content_desc_open_drawer">Avage sahtel</string> <string name="content_desc_open_drawer">Ava sahtel</string>
<string name="content_desc_profile_picture">Profiilipilt</string> <string name="content_desc_profile_picture">Profiilipilt</string>
<string name="content_desc_profile">Profiil</string> <string name="content_desc_profile">Profiil</string>
<string name="content_desc_pro_feature">Pro funktsioon</string> <string name="content_desc_pro_feature">Pro funktsioon</string>
@ -874,7 +881,7 @@
<string name="tts_tab_cloud_voices">Pilve hääled</string> <string name="tts_tab_cloud_voices">Pilve hääled</string>
<string name="tts_tab_device_voices">Seadme hääled</string> <string name="tts_tab_device_voices">Seadme hääled</string>
<string name="tts_tab_cloud_cache">Pilve vahemälu</string> <string name="tts_tab_cloud_cache">Pilve vahemälu</string>
<string name="tts_select_cloud_voice">Valige Kvaliteetne pilvehääl</string> <string name="tts_select_cloud_voice">Vali Kvaliteetne pilvehääl</string>
<string name="tts_clear_samples">Puhasta proovid</string> <string name="tts_clear_samples">Puhasta proovid</string>
<string name="tts_system_default_voice">Süsteemi vaikehääl</string> <string name="tts_system_default_voice">Süsteemi vaikehääl</string>
<string name="tts_uses_device_settings">Kasutab seadme sätteid</string> <string name="tts_uses_device_settings">Kasutab seadme sätteid</string>
@ -913,11 +920,11 @@
<string name="legal_by_purchasing">Ostes,</string> <string name="legal_by_purchasing">Ostes,</string>
<string name="dialog_out_of_credits_title">Krediidid otsas</string> <string name="dialog_out_of_credits_title">Krediidid otsas</string>
<string name="dialog_out_of_credits_desc">Teil ei ole piisavalt krediiti\' Hangi Episteme Pro 10 tasuta kokkuvõtet päevas või lisage krediiti, et kasutada kokkuvõtteid, Cloud TTS ja loo kokkuvõte.</string> <string name="dialog_out_of_credits_desc">Teil ei ole piisavalt krediiti\' Hangi Episteme Pro 10 tasuta kokkuvõtet päevas või lisage krediiti, et kasutada kokkuvõtteid, Cloud TTS ja loo kokkuvõte.</string>
<string name="action_get_pro_or_add_credits">Hankige Pro / lisage krediiti</string> <string name="action_get_pro_or_add_credits">Hangi Pro / lisa krediiti</string>
<string name="dialog_unlock_page_summarization">Avage lehe kokkuvõte</string> <string name="dialog_unlock_page_summarization">Ava lehe kokkuvõte</string>
<string name="dialog_unlock_page_summarization_desc">Hankige täpseid kokkuvõtteid mis tahes lehekülje kohta, millel on Episteme Pro. Selle funktsiooni kasutamise alustamiseks uuendage.</string> <string name="dialog_unlock_page_summarization_desc">Hangi Episteme Proga täpseid kokkuvõtteid mis tahes lehekülje kohta. Uuenda, et seda funktsiooni kasutada.</string>
<string name="dialog_download_bubble_zoom_model">Laadige alla Bubble Zoom mudel</string> <string name="dialog_download_bubble_zoom_model">Laadi alla Bubble Zoom mudel</string>
<string name="dialog_download_bubble_zoom_model_desc">Funktsiooni Bubble Zoom kasutamiseks kasutage AI mudel tuleb alla laadida (~134 MB). Kas soovite selle kohe alla laadida?</string> <string name="dialog_download_bubble_zoom_model_desc">Bubble Zoomi kasutamiseks tuleb alla laadida AI-mudel (~134 MB). Kas laadida see kohe alla?</string>
<string name="action_translate">Tõlgi</string> <string name="action_translate">Tõlgi</string>
<string name="pdf_back_to_page_short">Tagasi lk %1$d</string> <string name="pdf_back_to_page_short">Tagasi lk %1$d</string>
<string name="pdf_page_short">Lehekülg %1$d</string> <string name="pdf_page_short">Lehekülg %1$d</string>
@ -939,7 +946,7 @@
<string name="content_desc_reset_zoom">Lähtestage suum</string> <string name="content_desc_reset_zoom">Lähtestage suum</string>
<string name="content_desc_generate_demo_annotations">Loo demomärkusi</string> <string name="content_desc_generate_demo_annotations">Loo demomärkusi</string>
<string name="tooltip_demo_annotations">Demo annotatsioonid</string> <string name="tooltip_demo_annotations">Demo annotatsioonid</string>
<string name="content_desc_open_pen_playground">Avage pliiatsi mänguväljak</string> <string name="content_desc_open_pen_playground">Ava pliiatsi mänguväljak</string>
<string name="content_desc_new_tab">Uus vaheleht</string> <string name="content_desc_new_tab">Uus vaheleht</string>
<string name="content_desc_highlight_all_text">Tõstke esile kogu tekst</string> <string name="content_desc_highlight_all_text">Tõstke esile kogu tekst</string>
<string name="content_desc_toggle_editing_mode">Lülitage redigeerimisrežiim sisse</string> <string name="content_desc_toggle_editing_mode">Lülitage redigeerimisrežiim sisse</string>
@ -949,7 +956,7 @@
<string name="ocr_language_devanagari">hindi, marati, sanskriti + inglise keel</string> <string name="ocr_language_devanagari">hindi, marati, sanskriti + inglise keel</string>
<string name="ocr_language_chinese">hiina + inglise keel</string> <string name="ocr_language_chinese">hiina + inglise keel</string>
<string name="ocr_language_japanese">jaapani + inglise keel</string> <string name="ocr_language_japanese">jaapani + inglise keel</string>
<string name="ocr_language_korean">Korea + inglise keel</string> <string name="ocr_language_korean">korea + inglise keel</string>
<string name="msg_page_unavailable">Leht pole saadaval</string> <string name="msg_page_unavailable">Leht pole saadaval</string>
<string name="default_document_title">Dokument</string> <string name="default_document_title">Dokument</string>
<string name="generated_author">Loodud</string> <string name="generated_author">Loodud</string>
@ -968,7 +975,7 @@
<string name="content_desc_play_pause">Esita/Paus</string> <string name="content_desc_play_pause">Esita/Paus</string>
<string name="content_desc_reset_speed">Lähtestage kiirus</string> <string name="content_desc_reset_speed">Lähtestage kiirus</string>
<string name="content_desc_reset_pitch">Lähtesta helikõrgus</string> <string name="content_desc_reset_pitch">Lähtesta helikõrgus</string>
<string name="dialog_select_color">Valige Värv</string> <string name="dialog_select_color">Vali Värv</string>
<string name="msg_book_no_content">Sellel raamatul pole kuvatavat sisu.</string> <string name="msg_book_no_content">Sellel raamatul pole kuvatavat sisu.</string>
<string name="clip_label_copied_link">Kopeeritud link</string> <string name="clip_label_copied_link">Kopeeritud link</string>
<string name="clip_label_copied_text">Kopeeritud tekst</string> <string name="clip_label_copied_text">Kopeeritud tekst</string>
@ -1000,8 +1007,8 @@
<string name="pdf_error_document_not_loaded">Dokumenti ei laaditud.</string> <string name="pdf_error_document_not_loaded">Dokumenti ei laaditud.</string>
<string name="ai_error_offline_oss">AI funktsioonid pole võrguühenduseta saadaval OSS ehitada.</string> <string name="ai_error_offline_oss">AI funktsioonid pole võrguühenduseta saadaval OSS ehitada.</string>
<string name="ai_error_blocked_safety">Turvakaalutlustel blokeeritud.</string> <string name="ai_error_blocked_safety">Turvakaalutlustel blokeeritud.</string>
<string name="ai_error_choose_model">Valige mudel %1$s aastal AI võtme ja mudeli sätted.</string> <string name="ai_error_choose_model">Vali mudel %1$s AI võtmete ja mudelite sätetes.</string>
<string name="ai_error_add_provider_key">Lisage a %1$s API sisestage AI võtme ja mudeli sätted.</string> <string name="ai_error_add_provider_key">Lisa %1$s API-võti AI võtmete ja mudelite sätetes.</string>
<string name="ai_error_provider_empty_response">AI pakkuja andis tühja vastuse.</string> <string name="ai_error_provider_empty_response">AI pakkuja andis tühja vastuse.</string>
<string name="ai_error_provider_error">AI pakkuja viga: %1$d. %2$s</string> <string name="ai_error_provider_error">AI pakkuja viga: %1$d. %2$s</string>
<string name="ai_error_gemini_required_for_image_summary">See kokkuvõte vajab Gemini mudelit, kuna valitud Groqi mudelid ei toeta PDF/pildisisendit.</string> <string name="ai_error_gemini_required_for_image_summary">See kokkuvõte vajab Gemini mudelit, kuna valitud Groqi mudelid ei toeta PDF/pildisisendit.</string>
@ -1045,7 +1052,7 @@
<string name="ai_settings_recaps_desc">Kasutatakse lugude kokkuvõtete genereerimiseks.</string> <string name="ai_settings_recaps_desc">Kasutatakse lugude kokkuvõtete genereerimiseks.</string>
<string name="ai_settings_cloud_tts_desc">Kasutab salvestatud Gemini võti. Ainult %1$s on praegu toetatud.</string> <string name="ai_settings_cloud_tts_desc">Kasutab salvestatud Gemini võti. Ainult %1$s on praegu toetatud.</string>
<string name="dialog_save_provider_key">Salvesta %1$s võti?</string> <string name="dialog_save_provider_key">Salvesta %1$s võti?</string>
<string name="dialog_save_key_desc">Pärast salvestamist on nähtavad ainult esimesed 3 ja 3 viimast tähemärki. Kui soovite seda hiljem muuta, asendage see või kustutage see.</string> <string name="dialog_save_key_desc">Pärast salvestamist on nähtavad ainult esimesed 3 ja viimased 3 märki. Hiljem muutmiseks asenda või kustuta võti.</string>
<string name="dialog_delete_provider_key">Kustuta %1$s võti?</string> <string name="dialog_delete_provider_key">Kustuta %1$s võti?</string>
<string name="dialog_delete_key_desc">Seda teenusepakkujat kasutavad funktsioonid lakkavad töötamast kuni uue võtme salvestamiseni.</string> <string name="dialog_delete_key_desc">Seda teenusepakkujat kasutavad funktsioonid lakkavad töötamast kuni uue võtme salvestamiseni.</string>
<string name="ai_settings_no_key_saved">Võti pole salvestatud</string> <string name="ai_settings_no_key_saved">Võti pole salvestatud</string>
@ -1058,7 +1065,7 @@
<string name="theme_textured">Tekstuuriga</string> <string name="theme_textured">Tekstuuriga</string>
<string name="theme_custom_solid_default">Kohandatud tahke</string> <string name="theme_custom_solid_default">Kohandatud tahke</string>
<string name="theme_custom_textured_default">Kohandatud tekstuuriga</string> <string name="theme_custom_textured_default">Kohandatud tekstuuriga</string>
<string name="theme_select_custom_texture">Valige Kohandatud tekstuur</string> <string name="theme_select_custom_texture">Vali Kohandatud tekstuur</string>
<string name="app_theme_text_brightness_light">Teksti heledus (hele)</string> <string name="app_theme_text_brightness_light">Teksti heledus (hele)</string>
<string name="app_theme_text_brightness_dark">Teksti heledus (tume)</string> <string name="app_theme_text_brightness_dark">Teksti heledus (tume)</string>
<string name="label_default">Vaikimisi</string> <string name="label_default">Vaikimisi</string>
@ -1094,7 +1101,7 @@
<string name="toolbar_hidden_tools">Peidetud tööriistad</string> <string name="toolbar_hidden_tools">Peidetud tööriistad</string>
<string name="toolbar_more_menu">Rohkem menüüd</string> <string name="toolbar_more_menu">Rohkem menüüd</string>
<string name="toolbar_hidden_tools_menu">Varjatud tööriistad</string> <string name="toolbar_hidden_tools_menu">Varjatud tööriistad</string>
<string name="toolbar_drop_tools_here">Pange tööriistad siia</string> <string name="toolbar_drop_tools_here">Pane tööriistad siia</string>
<string name="content_desc_drag_to_reorder">Lohistage ümberjärjestamiseks</string> <string name="content_desc_drag_to_reorder">Lohistage ümberjärjestamiseks</string>
<string name="tool_external_apps">Välised rakendused</string> <string name="tool_external_apps">Välised rakendused</string>
<string name="tool_navigation_slider">Navigeerimisliugur</string> <string name="tool_navigation_slider">Navigeerimisliugur</string>
@ -1145,7 +1152,7 @@
<string name="book_replacements_empty_replacement">tühi tekst</string> <string name="book_replacements_empty_replacement">tühi tekst</string>
<string name="language_dutch">Holland (hollandi)</string> <string name="language_dutch">Holland (hollandi)</string>
<string name="language_ukrainian">Українська (ukraina)</string> <string name="language_ukrainian">Українська (ukraina)</string>
<string name="language_indonesian">indoneesia (indoneesia)</string> <string name="language_indonesian">Bahasa Indonesia (indoneesia)</string>
<string name="desktop_about">Umbes</string> <string name="desktop_about">Umbes</string>
<string name="desktop_about_subtitle">Lauaarvuti lugeja</string> <string name="desktop_about_subtitle">Lauaarvuti lugeja</string>
<string name="desktop_access">Juurdepääs töölauale</string> <string name="desktop_access">Juurdepääs töölauale</string>
@ -1159,10 +1166,10 @@
<string name="desktop_cache_format">Vahemälu: %1$s</string> <string name="desktop_cache_format">Vahemälu: %1$s</string>
<string name="desktop_cached">Vahemällu salvestatud</string> <string name="desktop_cached">Vahemällu salvestatud</string>
<string name="desktop_cached_summary">Vahemällu salvestatud kokkuvõte</string> <string name="desktop_cached_summary">Vahemällu salvestatud kokkuvõte</string>
<string name="desktop_choose_cloud_tts_voice">Valige Gemini hääl, mida kasutatakse pilve ettelugemiseks.</string> <string name="desktop_choose_cloud_tts_voice">Vali Gemini hääl, mida kasutatakse pilve ettelugemiseks.</string>
<string name="desktop_clear_book_cache_desc">Kustutage loodud töölauaraamat ja EPUB lehekülgede vahemälu failid? Järgmisel raamatute avamisel luuakse need uuesti.</string> <string name="desktop_clear_book_cache_desc">Kustutage loodud töölauaraamat ja EPUB lehekülgede vahemälu failid? Järgmisel raamatute avamisel luuakse need uuesti.</string>
<string name="desktop_clear_voice_cache">Tühjendage hääle vahemälu</string> <string name="desktop_clear_voice_cache">Tühjenda hääle vahemälu</string>
<string name="desktop_close_tools">Sulgege tööriistad</string> <string name="desktop_close_tools">Sulge tööriistad</string>
<string name="desktop_cloud_sync">Pilvesünkroonimine</string> <string name="desktop_cloud_sync">Pilvesünkroonimine</string>
<string name="desktop_cloud_tts_needs_gemini">Pilv TTS vajab Gemini</string> <string name="desktop_cloud_tts_needs_gemini">Pilv TTS vajab Gemini</string>
<string name="desktop_cloud_tts_needs_signed_in_credits">Pilv TTS vajab sisselogitud krediiti</string> <string name="desktop_cloud_tts_needs_signed_in_credits">Pilv TTS vajab sisselogitud krediiti</string>
@ -1179,12 +1186,12 @@
<string name="desktop_custom_fonts_desc">Imporditud fondid lugeja jaoks</string> <string name="desktop_custom_fonts_desc">Imporditud fondid lugeja jaoks</string>
<string name="desktop_delete_font">Kustuta font</string> <string name="desktop_delete_font">Kustuta font</string>
<string name="desktop_delete_font_desc">Kustuta %1$s? Seda kasutavad raamatud naasevad vaikefondile.</string> <string name="desktop_delete_font_desc">Kustuta %1$s? Seda kasutavad raamatud naasevad vaikefondile.</string>
<string name="desktop_delete_shelf_desc">Kas kustutada \&quot;%1$s\&quot;? Raamatud jäävad teie raamatukogusse.</string> <string name="desktop_delete_shelf_desc">Kas kustutada \&quot;%1$s\&quot;? Raamatud jäävad sinu raamatukogusse.</string>
<string name="desktop_delete_summary">Kustuta kokkuvõte</string> <string name="desktop_delete_summary">Kustuta kokkuvõte</string>
<string name="desktop_disabled">Keelatud</string> <string name="desktop_disabled">Keelatud</string>
<string name="desktop_drop_files_to_import">Pukseerige failid importimiseks</string> <string name="desktop_drop_files_to_import">Pukseeri failid importimiseks</string>
<string name="desktop_drop_supported_files_to_import">Eemaldage importimiseks toetatud failid</string> <string name="desktop_drop_supported_files_to_import">Eemalda importimiseks toetatud failid</string>
<string name="desktop_email_support_desc">Kui soovite midagi muud, võtke meiega otse e-posti teel ühendust.</string> <string name="desktop_email_support_desc">Kui vajad midagi muud, võta meiega otse e-posti teel ühendust.</string>
<string name="desktop_equals">Võrdub</string> <string name="desktop_equals">Võrdub</string>
<string name="desktop_extras">Lisad</string> <string name="desktop_extras">Lisad</string>
<string name="desktop_feedback">Tagasiside</string> <string name="desktop_feedback">Tagasiside</string>
@ -1195,36 +1202,36 @@
<string name="desktop_free_remaining_format">Tasuta, %1$d vasakule</string> <string name="desktop_free_remaining_format">Tasuta, %1$d vasakule</string>
<string name="desktop_generate_recap">Loo kokkuvõte</string> <string name="desktop_generate_recap">Loo kokkuvõte</string>
<string name="desktop_generate_summary">Loo kokkuvõte</string> <string name="desktop_generate_summary">Loo kokkuvõte</string>
<string name="desktop_get_in_touch_desc">Teatage vigadest, taotlege funktsioone või võtke otse ühendust toega.</string> <string name="desktop_get_in_touch_desc">Teata vigadest, taotle funktsioone või võta toega otse ühendust.</string>
<string name="desktop_github_sponsors">GitHubi sponsorid</string> <string name="desktop_github_sponsors">GitHubi sponsorid</string>
<string name="desktop_github_sponsors_desc">Toetage arengut GitHubi sponsorite kaudu.</string> <string name="desktop_github_sponsors_desc">Toeta arendust GitHub Sponsorsi kaudu.</string>
<string name="desktop_google_sign_in_not_configured">Google sisselogimine pole selle töölauajärgu jaoks konfigureeritud.</string> <string name="desktop_google_sign_in_not_configured">Google sisselogimine pole selle töölauajärgu jaoks konfigureeritud.</string>
<string name="desktop_greater_than">Suurem kui</string> <string name="desktop_greater_than">Suurem kui</string>
<string name="desktop_help">Abi</string> <string name="desktop_help">Abi</string>
<string name="desktop_help_feedback_desc">Veaaruanded, funktsioonitaotlused ja tugi</string> <string name="desktop_help_feedback_desc">Veaaruanded, funktsioonitaotlused ja tugi</string>
<string name="desktop_hide">Peida</string> <string name="desktop_hide">Peida</string>
<string name="desktop_import_files">Importige faile</string> <string name="desktop_import_files">Impordi faile</string>
<string name="desktop_issues">Probleemid</string> <string name="desktop_issues">Probleemid</string>
<string name="desktop_issues_desc">Avage probleemide jälgija vigade ja funktsioonitaotluste jaoks.</string> <string name="desktop_issues_desc">Ava probleemide jälgija vigade ja funktsioonisoovide jaoks.</string>
<string name="desktop_less_than">Vähem kui</string> <string name="desktop_less_than">Vähem kui</string>
<string name="desktop_library_and_reader">Raamatukogu ja lugeja</string> <string name="desktop_library_and_reader">Raamatukogu ja lugeja</string>
<string name="desktop_match_any">Ükskõik milline</string> <string name="desktop_match_any">Ükskõik milline</string>
<string name="desktop_more_library_actions">Raamatukogu tegevused</string> <string name="desktop_more_library_actions">Raamatukogu tegevused</string>
<string name="desktop_more_menu">Rohkem</string> <string name="desktop_more_menu">Rohkem</string>
<string name="desktop_no_cached_summaries_book">Selle raamatu kohta pole veel vahemällu salvestatud kokkuvõtteid.</string> <string name="desktop_no_cached_summaries_book">Selle raamatu kohta pole veel vahemällu salvestatud kokkuvõtteid.</string>
<string name="desktop_no_custom_fonts_desc">Importige TTF-, OTF- või WOFF2-faile, et neid raamatutes kasutada.</string> <string name="desktop_no_custom_fonts_desc">Impordi TTF-, OTF- või WOFF2-faile, et neid raamatutes kasutada.</string>
<string name="desktop_no_fonts_matching">Ei leitud fonte, mis vastavad \&quot;%1$s\&quot;</string> <string name="desktop_no_fonts_matching">Ei leitud fonte, mis vastavad \&quot;%1$s\&quot;</string>
<string name="desktop_no_google_account_connected">Nr Google konto on ühendatud.</string> <string name="desktop_no_google_account_connected">Nr Google konto on ühendatud.</string>
<string name="desktop_no_summary_cached_section">Selle jaotise kohta pole vahemällu salvestatud kokkuvõtet.</string> <string name="desktop_no_summary_cached_section">Selle jaotise kohta pole vahemällu salvestatud kokkuvõtet.</string>
<string name="desktop_offline_oss_reader">Võrguühenduseta lauaarvuti lugeja</string> <string name="desktop_offline_oss_reader">Võrguühenduseta lauaarvuti lugeja</string>
<string name="desktop_open_readers">Avatud lugejad</string> <string name="desktop_open_readers">Avatud lugejad</string>
<string name="desktop_opening_title">Avamine %1$s</string> <string name="desktop_opening_title">Avamine %1$s</string>
<string name="desktop_opening_your_library">Teie raamatukogu avamine</string> <string name="desktop_opening_your_library">Raamatukogu avamine</string>
<string name="desktop_operator">Operaator</string> <string name="desktop_operator">Operaator</string>
<string name="desktop_page">Lehekülg</string> <string name="desktop_page">Lehekülg</string>
<string name="desktop_password_protected_pdf">Parooliga kaitstud PDF</string> <string name="desktop_password_protected_pdf">Parooliga kaitstud PDF</string>
<string name="desktop_patreon">Patreon</string> <string name="desktop_patreon">Patreon</string>
<string name="desktop_patreon_desc">Toetage projekti Patreonis.</string> <string name="desktop_patreon_desc">Toeta projekti Patreonis.</string>
<string name="desktop_paused">Peatatud</string> <string name="desktop_paused">Peatatud</string>
<string name="desktop_pdf_password_required_desc">%1$s nõuab enne avamist parooli.</string> <string name="desktop_pdf_password_required_desc">%1$s nõuab enne avamist parooli.</string>
<string name="desktop_pdf_password_required_or_incorrect">Parool on nõutav või vale.</string> <string name="desktop_pdf_password_required_or_incorrect">Parool on nõutav või vale.</string>
@ -1269,13 +1276,13 @@
<string name="desktop_view">Vaade</string> <string name="desktop_view">Vaade</string>
<string name="desktop_voice_cache">Hääle vahemälu</string> <string name="desktop_voice_cache">Hääle vahemälu</string>
<string name="desktop_webview_preparing">Manustatud veebivaate ettevalmistamine…</string> <string name="desktop_webview_preparing">Manustatud veebivaate ettevalmistamine…</string>
<string name="desktop_webview_preparing_progress">Preparing bundled embedded webview %1$d%%</string> <string name="desktop_webview_preparing_progress">Pakitud manustatud veebivaate ettevalmistamine %1$d%%</string>
<string name="desktop_webview_restart_required">Manustatud veebivaade installitud. Taaskäivitage Episteme seadistamise lõpetamiseks.</string> <string name="desktop_webview_restart_required">Manustatud veebivaade installitud. Taaskäivitage Episteme seadistamise lõpetamiseks.</string>
<string name="desktop_webview_start_error">Embedded webview could not start: %1$s</string> <string name="desktop_webview_start_error">Manustatud veebivaadet ei saanud käivitada: %1$s</string>
<string name="desktop_working">Töötab…</string> <string name="desktop_working">Töötab…</string>
<string name="desktop_workspace">Tööruum</string> <string name="desktop_workspace">Tööruum</string>
<string name="desktop_add_to_shelf">Lisa riiulile</string> <string name="desktop_add_to_shelf">Lisa riiulile</string>
<string name="desktop_create_shelf_first">Create a shelf first, then add selected books to it.</string> <string name="desktop_create_shelf_first">Loo esmalt riiul ja lisa siis valitud raamatud sinna.</string>
<string name="desktop_create_theme">Loo teema</string> <string name="desktop_create_theme">Loo teema</string>
<string name="desktop_existing_tags_format">Olemasolev: %1$s</string> <string name="desktop_existing_tags_format">Olemasolev: %1$s</string>
<string name="desktop_external_link_desc">Klõpsasite välisel lingil.</string> <string name="desktop_external_link_desc">Klõpsasite välisel lingil.</string>
@ -1291,20 +1298,20 @@
<string name="desktop_annotation_options">Märkuste valikud</string> <string name="desktop_annotation_options">Märkuste valikud</string>
<string name="desktop_annotation_tools">Märkuste tegemise tööriistad</string> <string name="desktop_annotation_tools">Märkuste tegemise tööriistad</string>
<string name="desktop_assist">Abi</string> <string name="desktop_assist">Abi</string>
<string name="desktop_choose_pdf_to_save">Valige, milline PDF päästa.</string> <string name="desktop_choose_pdf_to_save">Vali, milline PDF päästa.</string>
<string name="desktop_clear_jump_history">Tühjenda hüppeajalugu</string> <string name="desktop_clear_jump_history">Tühjenda hüppeajalugu</string>
<string name="desktop_cloud_tts_failed">Pilv TTS ebaõnnestunud.</string> <string name="desktop_cloud_tts_failed">Pilv TTS ebaõnnestunud.</string>
<string name="desktop_cloud_tts_needs_gemini_key_desc">Lisage a Gemini klahvi ja valige Gemini pilv TTS aastal AI võtmed ja mudelid.</string> <string name="desktop_cloud_tts_needs_gemini_key_desc">Lisa Gemini võti ja vali Gemini pilve-TTS jaotises AI võtmed ja mudelid.</string>
<string name="desktop_cloud_tts_not_configured_desc">Pilv TTS pole selle töölaua järgu jaoks konfigureeritud.</string> <string name="desktop_cloud_tts_not_configured_desc">Pilv TTS pole selle töölaua järgu jaoks konfigureeritud.</string>
<string name="desktop_cloud_tts_sign_in_required_desc">Logige sisse rakendusega Google to use cloud TTS.</string> <string name="desktop_cloud_tts_sign_in_required_desc">Pilve-TTS-i kasutamiseks logi Googleiga sisse.</string>
<string name="desktop_cloud_tts_signed_in_credits_required_desc">Pilv TTS needs a signed-in account with credits. Pro ja krediite saab osta ainult veebisaidilt Android rakendus.</string> <string name="desktop_cloud_tts_signed_in_credits_required_desc">Pilve-TTS vajab sisselogitud krediitidega kontot. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
<string name="desktop_color">Värv</string> <string name="desktop_color">Värv</string>
<string name="desktop_comment_options">Kommentaaride valikud</string> <string name="desktop_comment_options">Kommentaaride valikud</string>
<string name="desktop_custom_theme_default">Kohandatud</string> <string name="desktop_custom_theme_default">Kohandatud</string>
<string name="desktop_delete_annotation_desc">See eemaldab märkuse sellelt PDF.</string> <string name="desktop_delete_annotation_desc">See eemaldab märkuse sellelt PDF.</string>
<string name="desktop_delete_annotation_title">Kas kustutada märkus?</string> <string name="desktop_delete_annotation_title">Kas kustutada märkus?</string>
<string name="desktop_document_text">Dokumendi tekst</string> <string name="desktop_document_text">Dokumendi tekst</string>
<string name="desktop_embedded_pdf_comment">Manustatud PDF kommenteerida</string> <string name="desktop_embedded_pdf_comment">Manustatud PDF-i kommentaar</string>
<string name="desktop_failed_render_page">Lehe renderdamine ebaõnnestus.</string> <string name="desktop_failed_render_page">Lehe renderdamine ebaõnnestus.</string>
<string name="desktop_feature_unavailable">Funktsioon pole saadaval</string> <string name="desktop_feature_unavailable">Funktsioon pole saadaval</string>
<string name="desktop_finished">Valmis</string> <string name="desktop_finished">Valmis</string>
@ -1330,14 +1337,14 @@
<string name="desktop_no_text_to_summarize">There is no text to summarize.</string> <string name="desktop_no_text_to_summarize">There is no text to summarize.</string>
<string name="desktop_open_comment">Ava kommentaar</string> <string name="desktop_open_comment">Ava kommentaar</string>
<string name="desktop_out_of_credits_android_purchase_desc">Krediidid otsas. Pro ja krediite saab osta ainult Android rakendus.</string> <string name="desktop_out_of_credits_android_purchase_desc">Krediidid otsas. Pro ja krediite saab osta ainult Android rakendus.</string>
<string name="desktop_out_of_credits_cloud_tts_desc">Pilve kasutamine TTS needs credits on desktop. Pro ja krediite saab osta ainult veebisaidilt Android rakendus.</string> <string name="desktop_out_of_credits_cloud_tts_desc">Pilve-TTS vajab töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
<string name="desktop_out_of_credits_generic_feature_desc">Using this feature needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus.</string> <string name="desktop_out_of_credits_generic_feature_desc">See funktsioon vajab töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
<string name="desktop_out_of_credits_recaps_desc">Using recaps needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus.</string> <string name="desktop_out_of_credits_recaps_desc">Kokkuvõtted vajavad töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
<string name="desktop_out_of_credits_summaries_desc">Using summaries needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus.</string> <string name="desktop_out_of_credits_summaries_desc">Kokkuvõtted vajavad töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
<string name="desktop_pan">Pan</string> <string name="desktop_pan">Panoraami</string>
<string name="desktop_pdf_action_failed">PDF tegevus ebaõnnestus</string> <string name="desktop_pdf_action_failed">PDF-i toiming ebaõnnestus</string>
<string name="desktop_pdf_action_failed_desc">PDF action could not be completed.</string> <string name="desktop_pdf_action_failed_desc">PDF-i toimingut ei saanud lõpule viia.</string>
<string name="desktop_pdf_comment">PDF kommenteerida</string> <string name="desktop_pdf_comment">PDF-i kommentaar</string>
<string name="desktop_pdf_compact_page_number">lk. %1$d</string> <string name="desktop_pdf_compact_page_number">lk. %1$d</string>
<string name="desktop_pdf_page_content_desc">PDF leht %1$d</string> <string name="desktop_pdf_page_content_desc">PDF leht %1$d</string>
<string name="desktop_pdf_page_author_format">Lehekülg %1$d - %2$s</string> <string name="desktop_pdf_page_author_format">Lehekülg %1$d - %2$s</string>
@ -1359,15 +1366,15 @@
<string name="desktop_remove_gap_between_pages_desc">Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel.</string> <string name="desktop_remove_gap_between_pages_desc">Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel.</string>
<string name="desktop_round_highlighter">Ümmargune highlighter</string> <string name="desktop_round_highlighter">Ümmargune highlighter</string>
<string name="desktop_saved_to_path_format">Salvestatud asukohta %1$s</string> <string name="desktop_saved_to_path_format">Salvestatud asukohta %1$s</string>
<string name="desktop_scroll">Kerige</string> <string name="desktop_scroll">Keri</string>
<string name="desktop_search_in_pdf">Otsi: PDF</string> <string name="desktop_search_in_pdf">Otsi: PDF</string>
<string name="desktop_select_text">Valige tekst</string> <string name="desktop_select_text">Vali tekst</string>
<string name="desktop_selected_annotation_format">Valitud %1$s</string> <string name="desktop_selected_annotation_format">Valitud %1$s</string>
<string name="desktop_show_search_results">Kuva otsingutulemused</string> <string name="desktop_show_search_results">Kuva otsingutulemused</string>
<string name="desktop_sign_in_required_generic_feature_desc">Logige sisse rakendusega Google selle funktsiooni kasutamiseks töölaual.</string> <string name="desktop_sign_in_required_generic_feature_desc">Logi sisse rakendusega Google selle funktsiooni kasutamiseks töölaual.</string>
<string name="desktop_sign_in_required_multi_word_dictionary_desc">Logige sisse rakendusega Google mitmesõnalise nutika sõnastiku kasutamiseks töölaual.</string> <string name="desktop_sign_in_required_multi_word_dictionary_desc">Logi sisse rakendusega Google mitmesõnalise nutika sõnastiku kasutamiseks töölaual.</string>
<string name="desktop_sign_in_required_recaps_desc">Logige sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string> <string name="desktop_sign_in_required_recaps_desc">Logi sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
<string name="desktop_sign_in_required_summaries_desc">Logige sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string> <string name="desktop_sign_in_required_summaries_desc">Logi sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
<string name="desktop_stopped">Peatatud</string> <string name="desktop_stopped">Peatatud</string>
<string name="desktop_text_note">Tekstimärkus</string> <string name="desktop_text_note">Tekstimärkus</string>
<string name="desktop_text_note_lowercase">tekstimärkus</string> <string name="desktop_text_note_lowercase">tekstimärkus</string>
@ -1394,13 +1401,13 @@
<string name="desktop_book_badge_folder">Kaust</string> <string name="desktop_book_badge_folder">Kaust</string>
<string name="desktop_browse">Sirvige</string> <string name="desktop_browse">Sirvige</string>
<string name="desktop_categories">Kategooriad</string> <string name="desktop_categories">Kategooriad</string>
<string name="desktop_chapter_short_format">Ch. %1$d</string> <string name="desktop_chapter_short_format">Ptk %1$d</string>
<string name="desktop_chapter_turns">Peatükk Pöörded</string> <string name="desktop_chapter_turns">Peatüki pöörded</string>
<string name="desktop_choose_font">Valige font</string> <string name="desktop_choose_font">Vali font</string>
<string name="desktop_choose_reader_texture">Valige lugeja tekstuur</string> <string name="desktop_choose_reader_texture">Vali lugeja tekstuur</string>
<string name="desktop_clear_file_types">Kustuta failitüübid</string> <string name="desktop_clear_file_types">Kustuta failitüübid</string>
<string name="desktop_clear_page_annotations">Lehekülje märkuste kustutamine</string> <string name="desktop_clear_page_annotations">Lehekülje märkuste kustutamine</string>
<string name="desktop_clear_sources">Selged allikad</string> <string name="desktop_clear_sources">Tühjenda allikad</string>
<string name="desktop_clear_status">Tühjenda olek</string> <string name="desktop_clear_status">Tühjenda olek</string>
<string name="desktop_clear_tags">Tühjenda sildid</string> <string name="desktop_clear_tags">Tühjenda sildid</string>
<string name="desktop_close_reader">Sule lugeja</string> <string name="desktop_close_reader">Sule lugeja</string>
@ -1427,16 +1434,16 @@
<string name="desktop_label_pair_format">%1$s - %2$s</string> <string name="desktop_label_pair_format">%1$s - %2$s</string>
<string name="desktop_hide_filters">Peida filtrid</string> <string name="desktop_hide_filters">Peida filtrid</string>
<string name="desktop_hide_reader_tools">Peida lugeja tööriistad</string> <string name="desktop_hide_reader_tools">Peida lugeja tööriistad</string>
<string name="desktop_highlight_palette_hint">Puudutage pesa ja seejärel valige värv.</string> <string name="desktop_highlight_palette_hint">Puuduta pesa ja vali värv.</string>
<string name="desktop_home_subtitle">Jätkake lugemist ja hiljutisi raamatuid</string> <string name="desktop_home_subtitle">Jätka lugemist ja vaata hiljutisi raamatuid</string>
<string name="desktop_import_books">Importige raamatuid</string> <string name="desktop_import_books">Impordi raamatuid</string>
<string name="desktop_import_folder">Impordi kaust</string> <string name="desktop_import_folder">Impordi kaust</string>
<string name="desktop_imported_fonts">Imporditud fondid</string> <string name="desktop_imported_fonts">Imporditud fondid</string>
<string name="desktop_import_result_pair">%1$s %2$s</string> <string name="desktop_import_result_pair">%1$s %2$s</string>
<string name="desktop_increase_format">Suurendada %1$s</string> <string name="desktop_increase_format">Suurendada %1$s</string>
<string name="desktop_jump_history">Hüppe ajalugu</string> <string name="desktop_jump_history">Hüppe ajalugu</string>
<string name="desktop_layout_spacing">Paigutus ja vahekaugus</string> <string name="desktop_layout_spacing">Paigutus ja vahekaugus</string>
<string name="desktop_library_empty_desc">Importige failid rakenduste salvestusruumi või lisage failide lugemiseks kaust.</string> <string name="desktop_library_empty_desc">Impordi failid rakenduse salvestusruumi või lisa failide lugemiseks kaust.</string>
<string name="desktop_library_subtitle">Sirvige oma kollektsiooni</string> <string name="desktop_library_subtitle">Sirvige oma kollektsiooni</string>
<string name="desktop_ai_keys">AI võtmed</string> <string name="desktop_ai_keys">AI võtmed</string>
<string name="desktop_library_tab_smart_shelves_count">Nutikas %1$d</string> <string name="desktop_library_tab_smart_shelves_count">Nutikas %1$d</string>

View file

@ -1512,4 +1512,6 @@
<string name="tts_replacements_replace_only_spoken">Chỉ thay thế nội dung được đọc</string> <string name="tts_replacements_replace_only_spoken">Chỉ thay thế nội dung được đọc</string>
<string name="tts_replacements_replace_only_spoken_desc">Văn bản trình đọc, tô sáng và vị trí vẫn không đổi.</string> <string name="tts_replacements_replace_only_spoken_desc">Văn bản trình đọc, tô sáng và vị trí vẫn không đổi.</string>
<string name="tts_replacements_summary_format">%1$s -&gt; %2$s</string> <string name="tts_replacements_summary_format">%1$s -&gt; %2$s</string>
<string name="error_copy_to_clipboard">Không thể sao chép vào khay nhớ tạm</string>
<string name="error_print_password_protected">Không thể in tệp PDF được bảo vệ bằng mật khẩu</string>
</resources> </resources>

View file

@ -201,8 +201,13 @@
<string name="external_file_keep">Keep in Library</string> <string name="external_file_keep">Keep in Library</string>
<string name="external_file_delete">Remove</string> <string name="external_file_delete">Remove</string>
<string name="external_file_behavior_ask">Ask Every Time</string> <string name="external_file_behavior_ask">Ask Every Time</string>
<string name="external_file_behavior_ask_desc">After closing an externally opened file, ask whether to keep it in the library or remove it.</string>
<string name="external_file_behavior_keep">Always Keep</string> <string name="external_file_behavior_keep">Always Keep</string>
<string name="external_file_behavior_keep_desc">Externally opened files are copied into the library and kept after closing.</string>
<string name="external_file_behavior_delete">Always Remove</string> <string name="external_file_behavior_delete">Always Remove</string>
<string name="external_file_behavior_delete_desc">Externally opened files are copied for reading, then removed after closing.</string>
<string name="external_file_behavior_temporary">Open Temporarily</string>
<string name="external_file_behavior_temporary_desc">Open directly from the source app in a temporary reader. Back returns to that app without adding the file to the library.</string>
<string name="options_external_file_behavior">External File Behavior</string> <string name="options_external_file_behavior">External File Behavior</string>
<!-- OPDS — "OPDS" is a technical protocol name, do not translate it. --> <!-- OPDS — "OPDS" is a technical protocol name, do not translate it. -->
@ -480,10 +485,14 @@
<string name="banner_saving_original_pdf">Saving original PDF…</string> <string name="banner_saving_original_pdf">Saving original PDF…</string>
<!-- PDF = file format name — do not translate. --> <!-- PDF = file format name — do not translate. -->
<string name="banner_original_pdf_saved">Original PDF saved successfully.</string> <string name="banner_original_pdf_saved">Original PDF saved successfully.</string>
<string name="banner_saving_original_file">Saving original file...</string>
<string name="banner_original_file_saved">Original file saved successfully.</string>
<string name="error_saving_file">Error saving file: %1$s</string>
<!-- Email/share subject line. %1$s = the name of the file being shared. Example: "Sharing: Dracula.pdf". --> <!-- Email/share subject line. %1$s = the name of the file being shared. Example: "Sharing: Dracula.pdf". -->
<string name="share_subject">Sharing: %1$s</string> <string name="share_subject">Sharing: %1$s</string>
<!-- PDF = file format name — do not translate. --> <!-- PDF = file format name — do not translate. -->
<string name="share_chooser_title">Share PDF</string> <string name="share_chooser_title">Share PDF</string>
<string name="share_file_chooser_title">Share file</string>
<!-- Share failure banner. %1$s = error reason string from the system. Example: "Share failed: No app found". --> <!-- Share failure banner. %1$s = error reason string from the system. Example: "Share failed: No app found". -->
<string name="error_share_failed">Share failed: %1$s</string> <string name="error_share_failed">Share failed: %1$s</string>
<!-- Folder sync limit error. %1$d = the maximum number of folders allowed. Example: "Limit reached: Maximum 3 folders allowed." --> <!-- Folder sync limit error. %1$d = the maximum number of folders allowed. Example: "Limit reached: Maximum 3 folders allowed." -->
@ -1054,6 +1063,8 @@
<!-- PdfViewerScreen & General Reader --> <!-- PdfViewerScreen & General Reader -->
<string name="error_open_print_settings">Could not open print settings</string> <string name="error_open_print_settings">Could not open print settings</string>
<string name="error_copy_to_clipboard">Could not copy to clipboard</string>
<string name="error_print_password_protected">Password protected PDF files cannot be printed</string>
<!-- PDF = file format name — do not translate. --> <!-- PDF = file format name — do not translate. -->
<string name="loading_pdf">Loading PDF…</string> <string name="loading_pdf">Loading PDF…</string>
<!-- PDF = file format name — do not translate. --> <!-- PDF = file format name — do not translate. -->
@ -1194,6 +1205,7 @@
<string name="language_korean">한국어 (Korean)</string> <string name="language_korean">한국어 (Korean)</string>
<string name="language_hindi">हिन्दी (Hindi)</string> <string name="language_hindi">हिन्दी (Hindi)</string>
<string name="language_chinese_simplified">简体中文 (Chinese, Simplified)</string> <string name="language_chinese_simplified">简体中文 (Chinese, Simplified)</string>
<string name="language_estonian">Eesti (Estonian)</string>
<!-- App-wide theme controls in HomeScreen.kt. --> <!-- App-wide theme controls in HomeScreen.kt. -->
<string name="app_theme_title">App Theme</string> <string name="app_theme_title">App Theme</string>

View file

@ -19,4 +19,5 @@
<locale android:name="ko"/> <locale android:name="ko"/>
<locale android:name="hi"/> <locale android:name="hi"/>
<locale android:name="zh-CN"/> <locale android:name="zh-CN"/>
<locale android:name="et"/>
</locale-config> </locale-config>

View file

@ -12,16 +12,28 @@ class AndroidStringFormatResourcesTest {
@Test @Test
fun `vietnamese strings cover translatable base resources`() { fun `vietnamese strings cover translatable base resources`() {
assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-vi", localeName = "Vietnamese")
}
@Test
fun `estonian strings cover translatable base resources`() {
assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-et", localeName = "Estonian")
}
private fun assertLocaleCoversTranslatableBaseResources(
localeDirectory: String,
localeName: String
) {
val resDirectory = findResDirectory() val resDirectory = findResDirectory()
val baseNames = readResourceNames( val baseNames = readResourceNames(
stringsFile = File(resDirectory, "values/strings.xml"), stringsFile = File(resDirectory, "values/strings.xml"),
includeNonTranslatable = false includeNonTranslatable = false
) )
val vietnameseNames = readResourceNames(File(resDirectory, "values-vi/strings.xml")) val localizedNames = readResourceNames(File(resDirectory, "$localeDirectory/strings.xml"))
val missingNames = baseNames.filterNot { it in vietnameseNames } val missingNames = baseNames.filterNot { it in localizedNames }
assertTrue( assertTrue(
"Missing Vietnamese strings:\n${missingNames.joinToString(separator = "\n")}", "Missing $localeName strings:\n${missingNames.joinToString(separator = "\n")}",
missingNames.isEmpty() missingNames.isEmpty()
) )
} }

View file

@ -15,11 +15,11 @@ class AppLanguageOptionsTest {
assertEquals( assertEquals(
listOf( listOf(
"en", "ar", "de", "nl", "tr", "fr", "ru", "uk", "be", "es", "pt-BR", "it", "pl", "en", "ar", "de", "nl", "tr", "fr", "ru", "uk", "be", "es", "pt-BR", "it", "pl",
"id", "vi", "ja", "ko", "hi", "zh-CN" "id", "vi", "ja", "ko", "hi", "zh-CN", "et"
), ),
supportedAppLanguageOptions.mapNotNull { it.tag } supportedAppLanguageOptions.mapNotNull { it.tag }
) )
assertEquals(R.string.language_chinese_simplified, supportedAppLanguageOptions.last().labelRes) assertEquals(R.string.language_estonian, supportedAppLanguageOptions.last().labelRes)
} }
@Test @Test
@ -51,6 +51,7 @@ class AppLanguageOptionsTest {
val vietnamese = supportedAppLanguageOptions.first { it.tag == "vi" } val vietnamese = supportedAppLanguageOptions.first { it.tag == "vi" }
val japanese = supportedAppLanguageOptions.first { it.tag == "ja" } val japanese = supportedAppLanguageOptions.first { it.tag == "ja" }
val korean = supportedAppLanguageOptions.first { it.tag == "ko" } val korean = supportedAppLanguageOptions.first { it.tag == "ko" }
val estonian = supportedAppLanguageOptions.first { it.tag == "et" }
assertTrue(turkish.matchesLanguageSearch(label = "Türkçe (Turkish)", query = "turkce")) assertTrue(turkish.matchesLanguageSearch(label = "Türkçe (Turkish)", query = "turkce"))
assertTrue(dutch.matchesLanguageSearch(label = "Nederlands", query = "dutch")) assertTrue(dutch.matchesLanguageSearch(label = "Nederlands", query = "dutch"))
@ -66,6 +67,7 @@ class AppLanguageOptionsTest {
assertTrue(vietnamese.matchesLanguageSearch(label = "Tiếng Việt", query = "tieng viet")) assertTrue(vietnamese.matchesLanguageSearch(label = "Tiếng Việt", query = "tieng viet"))
assertTrue(japanese.matchesLanguageSearch(label = "日本語", query = "nihongo")) assertTrue(japanese.matchesLanguageSearch(label = "日本語", query = "nihongo"))
assertTrue(korean.matchesLanguageSearch(label = "한국어", query = "hangul")) assertTrue(korean.matchesLanguageSearch(label = "한국어", query = "hangul"))
assertTrue(estonian.matchesLanguageSearch(label = "Eesti", query = "eesti"))
} }
@Test @Test

View file

@ -0,0 +1,17 @@
package com.aryan.reader
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ClipboardUtilsTest {
@Test
fun `set primary clip reports success`() {
assertTrue(setPrimaryClipSafely {})
}
@Test
fun `set primary clip handles security rejection`() {
assertFalse(setPrimaryClipSafely { throw SecurityException("denied") })
}
}

View file

@ -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)
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.aryan.reader package com.aryan.reader
import android.app.Application import android.app.Application
import android.content.ContentResolver
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.Resources import android.content.res.Resources
import android.net.Uri import android.net.Uri
@ -22,6 +23,7 @@ import com.aryan.reader.tts.TtsPlaybackManager
import io.mockk.* import io.mockk.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@ -45,6 +47,7 @@ class MainViewModelTest {
private lateinit var mockApplication: Application private lateinit var mockApplication: Application
private lateinit var mockPrefs: SharedPreferences private lateinit var mockPrefs: SharedPreferences
private lateinit var mockEditor: SharedPreferences.Editor private lateinit var mockEditor: SharedPreferences.Editor
private val prefsStringSets = mutableMapOf<String, Set<String>>()
private val billingStateFlow = MutableStateFlow(ProUpgradeState()) private val billingStateFlow = MutableStateFlow(ProUpgradeState())
private val customFontsFlow = MutableStateFlow<List<CustomFontEntity>>(emptyList()) private val customFontsFlow = MutableStateFlow<List<CustomFontEntity>>(emptyList())
@ -64,6 +67,12 @@ class MainViewModelTest {
} }
private class TestMainViewModel(application: Application) : MainViewModel(application) { private class TestMainViewModel(application: Application) : MainViewModel(application) {
val locallyCleanedBookIds = mutableListOf<String>()
override suspend fun cleanupBookDataLocally(bookId: String) {
locallyCleanedBookIds += bookId
}
fun clearForTest() { fun clearForTest() {
ViewModel::class.java ViewModel::class.java
.getDeclaredMethod("clear\$lifecycle_viewmodel_release") .getDeclaredMethod("clear\$lifecycle_viewmodel_release")
@ -83,6 +92,7 @@ class MainViewModelTest {
billingStateFlow.value = ProUpgradeState() billingStateFlow.value = ProUpgradeState()
customFontsFlow.value = emptyList() customFontsFlow.value = emptyList()
ttsStateFlow.value = TtsPlaybackManager.TtsState() ttsStateFlow.value = TtsPlaybackManager.TtsState()
prefsStringSets.clear()
mockkStatic(Log::class) mockkStatic(Log::class)
every { Log.isLoggable(any(), any()) } returns false every { Log.isLoggable(any(), any()) } returns false
@ -109,9 +119,14 @@ class MainViewModelTest {
every { mockApplication.filesDir } returns filesDir every { mockApplication.filesDir } returns filesDir
every { mockApplication.cacheDir } returns cacheDir every { mockApplication.cacheDir } returns cacheDir
every { mockApplication.getExternalFilesDir(any()) } returns externalFilesDir every { mockApplication.getExternalFilesDir(any()) } returns externalFilesDir
every { mockApplication.getString(any()) } answers { "res-${firstArg<Int>()}" }
every { mockApplication.getString(any(), *anyVararg()) } answers { "res-${firstArg<Int>()}" }
every { mockPrefs.edit() } returns mockEditor every { mockPrefs.edit() } returns mockEditor
every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? } every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? }
every { mockPrefs.getStringSet(any(), any()) } answers {
prefsStringSets[firstArg<String>()]?.toMutableSet() ?: secondArg<Set<String>?>()?.toMutableSet()
}
every { mockPrefs.getBoolean(any(), any()) } answers { secondArg() as Boolean } every { mockPrefs.getBoolean(any(), any()) } answers { secondArg() as Boolean }
every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int } every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int }
every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float } every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float }
@ -174,6 +189,7 @@ class MainViewModelTest {
coEvery { anyConstructed<RecentFilesRepository>().addBooksToShelf(any(), any()) } just Runs coEvery { anyConstructed<RecentFilesRepository>().addBooksToShelf(any(), any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().deleteShelf(any()) } just Runs coEvery { anyConstructed<RecentFilesRepository>().deleteShelf(any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().deleteFilePermanently(any()) } just Runs coEvery { anyConstructed<RecentFilesRepository>().deleteFilePermanently(any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().addRecentFile(any()) } just Runs
coEvery { anyConstructed<BookImporter>().deleteBookByUriString(any()) } returns true coEvery { anyConstructed<BookImporter>().deleteBookByUriString(any()) } returns true
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
@ -417,38 +433,39 @@ class MainViewModelTest {
viewModel.setStrictFileFilter(true) viewModel.setStrictFileFilter(true)
viewModel.setUsePdfFileNameAsDisplayName(true) viewModel.setUsePdfFileNameAsDisplayName(true)
viewModel.setExternalFileBehavior("KEEP") viewModel.setExternalFileBehavior("KEEP")
viewModel.setExternalFileBehavior("TEMPORARY")
val state = viewModel.uiState.first { val state = viewModel.uiState.first {
it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "KEEP" it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "TEMPORARY"
} }
assertTrue(state.useStrictFileFilter) assertTrue(state.useStrictFileFilter)
assertTrue(state.usePdfFileNameAsDisplayName) assertTrue(state.usePdfFileNameAsDisplayName)
assertEquals("KEEP", state.externalFileBehavior) assertEquals("TEMPORARY", state.externalFileBehavior)
verify { mockEditor.putBoolean("use_strict_file_filter", true) } verify { mockEditor.putBoolean("use_strict_file_filter", true) }
verify { mockEditor.putBoolean("use_pdf_file_name_as_display_name", true) } verify { mockEditor.putBoolean("use_pdf_file_name_as_display_name", true) }
verify { mockEditor.putString("external_file_behavior", "KEEP") } verify { mockEditor.putString("external_file_behavior", "KEEP") }
verify { mockEditor.putString("external_file_behavior", "TEMPORARY") }
} }
@Test @Test
fun `startup removes pending external always-remove file before restoring session`() = runTest(testDispatcher) { fun `startup removes pending external always-remove file before restoring session`() = runTest(testDispatcher) {
val pendingUri = "file:///data/user/0/com.aryan.reader/files/books/external.epub" val pendingUri = "file:///data/user/0/com.aryan.reader/files/books/external.epub"
val pendingEntry = """{"bookId":"external-book","uriString":"$pendingUri"}""" val pendingEntry = """{"bookId":"external-book","uriString":"$pendingUri"}"""
every { prefsStringSets["pending_external_file_removals"] = setOf(pendingEntry)
mockPrefs.getStringSet("pending_external_file_removals", any())
} returns mutableSetOf(pendingEntry)
every { mockPrefs.getString("last_open_book_id", null) } returns "external-book" every { mockPrefs.getString("last_open_book_id", null) } returns "external-book"
every { mockPrefs.getString("last_open_file_type", null) } returns FileType.EPUB.name every { mockPrefs.getString("last_open_file_type", null) } returns FileType.EPUB.name
val restored = TestMainViewModel(mockApplication) val restored = TestMainViewModel(mockApplication)
try { try {
advanceUntilIdle() advanceUntilIdle()
coVerify(timeout = 1_000) {
coVerify {
anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf("external-book")) anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf("external-book"))
} }
coVerify { coVerify {
anyConstructed<BookImporter>().deleteBookByUriString(pendingUri) anyConstructed<BookImporter>().deleteBookByUriString(pendingUri)
} }
assertEquals(listOf("external-book"), restored.locallyCleanedBookIds)
verify(atLeast = 1) { mockEditor.remove("last_open_book_id") } verify(atLeast = 1) { mockEditor.remove("last_open_book_id") }
verify(atLeast = 1) { mockEditor.remove("last_open_file_type") } verify(atLeast = 1) { mockEditor.remove("last_open_file_type") }
verify { mockEditor.remove("pending_external_file_removals") } verify { mockEditor.remove("pending_external_file_removals") }
@ -457,6 +474,60 @@ class MainViewModelTest {
} }
} }
@Test
fun `temporary external pdf opens directly without importing or adding to library`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val externalUri = mockUri("content://external/temp.pdf", path = "/temp.pdf", lastPathSegment = "temp.pdf")
val resolver = mockk<ContentResolver>()
every { mockApplication.contentResolver } returns resolver
every { resolver.getType(externalUri) } returns "application/pdf"
every { resolver.query(externalUri, null, null, null, null) } returns null
coEvery { anyConstructed<RecentFilesRepository>().getFileByBookId(match { it.startsWith("temporary-") }) } returns null
viewModel.onFileSelected(
externalUri,
isFromRecent = false,
isExternalIntent = true,
isTemporaryExternalIntent = true
)
advanceUntilIdle()
val selected = viewModel.uiState.first { it.selectedBookId?.startsWith("temporary-") == true && it.selectedPdfUri != null }
assertEquals(externalUri, selected.selectedPdfUri)
assertEquals(null, selected.showExternalFileSavePromptFor)
coVerify(exactly = 0) { anyConstructed<BookImporter>().importBook(any()) }
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().addRecentFile(any()) }
verify(exactly = 0) { mockEditor.putStringSet("pending_external_file_removals", any()) }
}
@Test
fun `closing temporary external direct book signals activity finish without library cleanup`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val item = recentFile("external-book", type = FileType.PDF)
coEvery { anyConstructed<RecentFilesRepository>().getFileByBookId(item.bookId) } returns item
viewModel.trackExternalOpenForClose(
bookId = item.bookId,
importedCopyUriString = null,
isTemporaryExternalIntent = true
)
viewModel.onRecentFileClicked(item)
advanceUntilIdle()
viewModel.uiState.first { it.selectedBookId == item.bookId }
val finishEvent = backgroundScope.async { viewModel.temporaryExternalOpenFinished.first() }
viewModel.clearSelectedFile()
advanceUntilIdle()
assertEquals(null, viewModel.uiState.value.showExternalFileSavePromptFor)
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf(item.bookId)) }
coVerify(exactly = 0) { anyConstructed<BookImporter>().deleteBookByUriString(item.uriString!!) }
assertTrue(finishEvent.isCompleted)
}
@Test @Test
fun `screen capture protection persists and updates state`() = runTest(testDispatcher) { fun `screen capture protection persists and updates state`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {

View file

@ -0,0 +1,22 @@
package com.aryan.reader
import org.junit.Assert.assertEquals
import org.junit.Test
class ReaderPopupSizingTest {
@Test
fun `modal max height leaves edge margin on landscape-height screens`() {
assertEquals(306, readerModalMaxHeightDp(screenHeightDp = 360))
}
@Test
fun `modal max height uses preferred minimum when there is room`() {
assertEquals(220, readerModalMaxHeightDp(screenHeightDp = 252))
}
@Test
fun `modal max height stays within tiny screens`() {
assertEquals(168, readerModalMaxHeightDp(screenHeightDp = 200))
}
}

View file

@ -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."))
}
}

View file

@ -105,6 +105,26 @@ class RecentFileDaoReadingPositionTest {
assertTrue(item.isRecent) assertTrue(item.isRecent)
} }
@Test
fun `recent file summary caps oversized descriptions while full lookup keeps metadata`() = runTest {
val longDescription = "Summary ".repeat(2_000)
val longOriginalDescription = "Original ".repeat(2_000)
dao.insertOrUpdateFile(
recentFileEntity().copy(
description = longDescription,
originalDescription = longOriginalDescription
)
)
val summary = dao.getRecentFiles().first().single()
val full = dao.getFileByBookId("book-1")!!
assertEquals(4_096, summary.description?.length)
assertEquals(4_096, summary.originalDescription?.length)
assertEquals(longDescription, full.description)
assertEquals(longOriginalDescription, full.originalDescription)
}
private fun recentFileEntity(lastPositionCfi: String? = null): RecentFileEntity { private fun recentFileEntity(lastPositionCfi: String? = null): RecentFileEntity {
return RecentFileEntity( return RecentFileEntity(
bookId = "book-1", bookId = "book-1",

View file

@ -104,6 +104,30 @@ class EpubParserUnitTest {
assertTrue(extractionDir.list().isNullOrEmpty()) assertTrue(extractionDir.list().isNullOrEmpty())
} }
@Test
fun `createEpubBook uses spine toc id when manifest contains volume ncx files first`() = runTest {
val cacheDir = temp.newFolder("cache-merged-toc")
val extractionDir = temp.newFolder("extract-merged-toc")
val parser = EpubParser(contextWithCache(cacheDir))
val book = parser.createEpubBook(
inputStream = ByteArrayInputStream(mergedVolumeTocEpubBytes()),
bookId = "book-id",
shouldUseToc = true,
originalBookNameHint = "merged.epub",
parseContent = true,
extractionDirOverride = extractionDir
)
assertEquals(
listOf("Volume 1", "Chapter 1", "Volume 2", "Chapter 2"),
book.tableOfContents.map { it.label }
)
assertEquals(listOf(0, 1, 0, 1), book.tableOfContents.map { it.depth })
assertEquals("Volume 2", book.chapters[2].title)
assertEquals("Chapter 2", book.chapters[3].title)
}
@Test @Test
fun `metadata only extraction streams images to disk without retaining image bytes`() { fun `metadata only extraction streams images to disk without retaining image bytes`() {
val cacheDir = temp.newFolder("cache-metadata-stream") val cacheDir = temp.newFolder("cache-metadata-stream")
@ -449,6 +473,50 @@ class EpubParserUnitTest {
"OEBPS/images/unlisted.png" to "not-real-image" "OEBPS/images/unlisted.png" to "not-real-image"
) )
private fun mergedVolumeTocEpubBytes(): ByteArray = zipBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
""".trimIndent(),
"OEBPS/content.opf" to """
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata><dc:title>Merged Volumes</dc:title></metadata>
<manifest>
<item id="v1title" href="1/title.xhtml" media-type="application/xhtml+xml"/>
<item id="v1c1" href="1/chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="v2title" href="2/title.xhtml" media-type="application/xhtml+xml"/>
<item id="v2c1" href="2/chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="v1ncx" href="1/toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
</manifest>
<spine toc="ncx">
<itemref idref="v1title"/>
<itemref idref="v1c1"/>
<itemref idref="v2title"/>
<itemref idref="v2c1"/>
</spine>
</package>
""".trimIndent(),
"OEBPS/1/toc.ncx" to """
<ncx><navMap>
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="title.xhtml"/></navPoint>
</navMap></ncx>
""".trimIndent(),
"OEBPS/toc.ncx" to """
<ncx><navMap>
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="1/title.xhtml"/>
<navPoint><navLabel><text>Chapter 1</text></navLabel><content src="1/chapter1.xhtml"/></navPoint>
</navPoint>
<navPoint><navLabel><text>Volume 2</text></navLabel><content src="2/title.xhtml"/>
<navPoint><navLabel><text>Chapter 2</text></navLabel><content src="2/chapter1.xhtml"/></navPoint>
</navPoint>
</navMap></ncx>
""".trimIndent(),
"OEBPS/1/title.xhtml" to "<html><body><h1>HTML Volume 1</h1><p>Volume one.</p></body></html>",
"OEBPS/1/chapter1.xhtml" to "<html><body><h1>HTML Chapter 1</h1><p>Chapter one.</p></body></html>",
"OEBPS/2/title.xhtml" to "<html><body><h1>HTML Volume 2</h1><p>Volume two.</p></body></html>",
"OEBPS/2/chapter1.xhtml" to "<html><body><h1>HTML Chapter 2</h1><p>Chapter two.</p></body></html>"
)
private fun minimalEpubBytesWithoutOptionalMetadata(): ByteArray = zipBytes( private fun minimalEpubBytesWithoutOptionalMetadata(): ByteArray = zipBytes(
"META-INF/container.xml" to """ "META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container> <container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>

View file

@ -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}")
}
}

View file

@ -62,4 +62,45 @@ class EpubTtsChunkMatchingTest {
assertEquals(0, findTtsChunkStartIndex(chunks, nativeVerticalTarget)) assertEquals(0, findTtsChunkStartIndex(chunks, nativeVerticalTarget))
} }
@Test
fun `vertical continuation falls back to loaded chunk boundary when resume match is unavailable`() {
val chunks = listOf(
TtsChunk("Loaded one", "/4/2", 0),
TtsChunk("Loaded two", "/4/4", 0),
TtsChunk("Remaining three", "/4/6", 0),
TtsChunk("Remaining four", "/4/8", 0)
)
assertEquals(
2,
resolveTtsContinuationStartIndex(
chunks = chunks,
loadedChunkCount = 2,
sourceCfi = "/does/not/match",
startOffsetInSource = 0,
currentText = "not present"
)
)
}
@Test
fun `vertical continuation starts after matched spoken chunk`() {
val chunks = listOf(
TtsChunk("Loaded one", "/4/2", 0),
TtsChunk("Loaded two", "/4/4", 0),
TtsChunk("Remaining three", "/4/6", 0)
)
assertEquals(
2,
resolveTtsContinuationStartIndex(
chunks = chunks,
loadedChunkCount = 1,
sourceCfi = "/4/4",
startOffsetInSource = 0,
currentText = "Loaded two"
)
)
}
} }

View file

@ -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))
}
}

View file

@ -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()
}
}
}

View file

@ -1,6 +1,7 @@
package com.aryan.reader.paginatedreader package com.aryan.reader.paginatedreader
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class NativeVerticalLocationTest { class NativeVerticalLocationTest {
@ -27,4 +28,157 @@ class NativeVerticalLocationTest {
assertEquals(2, nativeVerticalProgressToItemIndex(weights, 25f)) assertEquals(2, nativeVerticalProgressToItemIndex(weights, 25f))
assertEquals(3, nativeVerticalProgressToItemIndex(weights, 100f)) assertEquals(3, nativeVerticalProgressToItemIndex(weights, 100f))
} }
@Test
fun `scroll progress updates within visible item offset`() {
val weights = listOf(100, 300, 600)
assertEquals(
25f,
estimateNativeVerticalWeightedScrollProgressPercent(
itemWeights = weights,
firstVisibleItemIndex = 1,
firstVisibleItemScrollOffset = 500,
firstVisibleItemSize = 1000
),
0.001f
)
assertEquals(
40f,
estimateNativeVerticalWeightedScrollProgressPercent(
itemWeights = weights,
firstVisibleItemIndex = 1,
firstVisibleItemScrollOffset = 1000,
firstVisibleItemSize = 1000
),
0.001f
)
}
@Test
fun `chapter page info uses chapter local locator offset`() {
val pageInfo = nativeVerticalChapterPageInfo(
chapterCharOffset = 500,
chapterLengthChars = 1000,
chapterPageCount = 11,
compatPageIndex = 900,
chapterStartPageIndex = 850
)
assertEquals(6, pageInfo?.currentPage)
assertEquals(11, pageInfo?.totalPages)
}
@Test
fun `chapter page info falls back to absolute page within chapter`() {
val pageInfo = nativeVerticalChapterPageInfo(
chapterCharOffset = null,
chapterLengthChars = 0,
chapterPageCount = 7,
compatPageIndex = 24,
chapterStartPageIndex = 20
)
assertEquals(5, pageInfo?.currentPage)
assertEquals(7, pageInfo?.totalPages)
}
@Test
fun `chapter page info follows scroll weight within current chapter`() {
val pageInfo = nativeVerticalChapterPageInfoForScroll(
itemChapterIndices = listOf(0, 0, 1, 1),
itemWeights = listOf(100, 300, 100, 300),
firstVisibleItemIndex = 1,
firstVisibleItemScrollOffset = 500,
firstVisibleItemSize = 1000,
chapterPageCount = 9
)
assertEquals(6, pageInfo?.currentPage)
assertEquals(9, pageInfo?.totalPages)
}
@Test
fun `native vertical image model decodes svg data uris for coil svg fetcher`() {
val model = nativeVerticalImageModelData(
"data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2010%2010%22%3E%3Ccircle%20r%3D%225%22%2F%3E%3C%2Fsvg%3E"
)
assertTrue(model is SvgData)
assertEquals("""<svg viewBox="0 0 10 10"><circle r="5"/></svg>""", (model as SvgData).content)
}
@Test
fun `native vertical svg data uri decoding preserves plus signs`() {
assertEquals(
"""<svg><path d="M1+2"/></svg>""",
nativeVerticalSvgContentFromDataUri(
"data:image/svg+xml,%3Csvg%3E%3Cpath%20d%3D%22M1+2%22%2F%3E%3C%2Fsvg%3E"
)
)
}
@Test
fun `native vertical persistence locator prefers visible text range`() {
val location = NativeVerticalLocation(
locator = Locator(chapterIndex = 2, blockIndex = 10, charOffset = 100),
chapterIndex = 2,
progressPercent = 42f,
compatPageIndex = 20,
compatTotalPages = 100,
firstVisibleItemIndex = 4,
firstVisibleItemScrollOffset = 250,
firstVisibleItemSize = 1000,
isAtStart = false,
isAtEnd = false,
visibleTextRanges = listOf(
NativeVerticalVisibleTextRange(
chapterIndex = 2,
blockIndex = 10,
startCharOffset = 380,
endCharOffset = 520
)
)
)
assertEquals(Locator(chapterIndex = 2, blockIndex = 10, charOffset = 380), location.locatorForPersistence())
}
@Test
fun `native vertical initial restore does not fallback to compat page when locator exists`() {
assertEquals(
false,
shouldFallbackNativeVerticalInitialScrollToCompatPage(
hasInitialLocator = true,
didLocatorScroll = false
)
)
assertEquals(
true,
shouldFallbackNativeVerticalInitialScrollToCompatPage(
hasInitialLocator = false,
didLocatorScroll = false
)
)
}
@Test
fun `native vertical tts follow centers target offset in viewport`() {
assertEquals(
100f,
nativeVerticalCenteredScrollDelta(
targetOffsetInViewport = 500f,
viewportHeight = 800f
),
0.001f
)
assertEquals(
-200f,
nativeVerticalCenteredScrollDelta(
targetOffsetInViewport = 200f,
viewportHeight = 800f
),
0.001f
)
}
} }

View file

@ -76,7 +76,7 @@ class ReaderNavigationTargetsTest {
@Test @Test
fun `native vertical initial prefetch is bounded around requested chapter`() { fun `native vertical initial prefetch is bounded around requested chapter`() {
assertEquals( assertEquals(
listOf(4, 5, 2), listOf(4, 5),
nativeVerticalInitialChapterPrefetchOrder(chapterCount = 6, initialChapter = 3) nativeVerticalInitialChapterPrefetchOrder(chapterCount = 6, initialChapter = 3)
) )
} }

View file

@ -111,6 +111,20 @@ class PdfReaderCoreLogicTest {
assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf"))) assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf")))
} }
@Test
fun `pdf encrypt marker detection matches trailer encrypt entry`() {
val bytes = "%PDF-1.7\ntrailer\n<< /Size 4 /Encrypt 2 0 R >>".toByteArray(Charsets.US_ASCII)
assertTrue(pdfBytesContainEncryptMarker(bytes))
}
@Test
fun `pdf encrypt marker detection ignores longer pdf names`() {
val bytes = "<< /EncryptMetadata false /Size 4 >>".toByteArray(Charsets.US_ASCII)
assertFalse(pdfBytesContainEncryptMarker(bytes))
}
@Test @Test
fun `getFastFileId uses stable file name and length for file uris`() { fun `getFastFileId uses stable file name and length for file uris`() {
val file = File("build/test-tmp/pdf-reader/fast-id-${System.nanoTime()}.pdf").apply { val file = File("build/test-tmp/pdf-reader/fast-id-${System.nanoTime()}.pdf").apply {
@ -383,6 +397,32 @@ class PdfReaderCoreLogicTest {
assertTrue(limitedScale >= 0.01f) assertTrue(limitedScale >= 0.01f)
} }
@Test
fun `spread page slot width fits page aspect instead of filling half landscape viewport`() {
val slotWidth = pdfSpreadPageSlotWidth(
containerWidth = 1920f,
containerHeight = 900f,
pageGap = 0f,
spreadPageCount = 2,
pageAspectRatio = 612f / 792f
)
assertEquals(695.4545f, slotWidth, 0.001f)
}
@Test
fun `spread page slot width caps pages to available spread width`() {
val slotWidth = pdfSpreadPageSlotWidth(
containerWidth = 1000f,
containerHeight = 900f,
pageGap = 20f,
spreadPageCount = 2,
pageAspectRatio = 1.4f
)
assertEquals(490f, slotWidth, 0.0001f)
}
@Test @Test
fun `canUsePdfSidecarsForBook only accepts loaded sidecars for active book`() { fun `canUsePdfSidecarsForBook only accepts loaded sidecars for active book`() {
assertTrue(canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = true)) assertTrue(canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = true))

View file

@ -281,6 +281,23 @@ class PdfReaderSettingsAndSharedModelsTest {
assertEquals(PdfOverflowMenuSection.FILE_ACTIONS, sections.last()) assertEquals(PdfOverflowMenuSection.FILE_ACTIONS, sections.last())
} }
@Test
fun `pdf overflow sections hide file actions when only unavailable print remains`() {
val sections = pdfOverflowMenuSections(
hiddenTools = setOf(
PdfReaderTool.SHARE.name,
PdfReaderTool.SAVE_COPY.name
),
hasHiddenToolbarTools = false,
isPro = false,
effectiveFileType = FileType.PDF,
hasFileInfo = false,
canPrintDocument = false
)
assertFalse(PdfOverflowMenuSection.FILE_ACTIONS in sections)
}
@Test @Test
fun `pdf overflow sections expose file info only when available and visible`() { fun `pdf overflow sections expose file info only when available and visible`() {
val visibleSections = pdfOverflowMenuSections( val visibleSections = pdfOverflowMenuSections(

View file

@ -96,6 +96,86 @@ class PdfZoomLockStateTest {
) )
} }
@Test
fun `vertical pdf high res tiles render for settled zoom below one hundred percent`() {
assertTrue(
shouldRenderPdfHighResTiles(
effectiveScale = 0.82f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = true,
isActivePage = true
)
)
}
@Test
fun `vertical pdf high res tiles skip exact one hundred percent unless page is large`() {
assertFalse(
shouldRenderPdfHighResTiles(
effectiveScale = 1f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = true,
isActivePage = true
)
)
assertTrue(
shouldRenderPdfHighResTiles(
effectiveScale = 1f,
targetWidthPx = 3200,
targetHeightPx = 1600,
isVerticalScroll = true,
isActivePage = true
)
)
}
@Test
fun `paginated pdf high res tiles keep existing zoom threshold`() {
assertFalse(
shouldRenderPdfHighResTiles(
effectiveScale = 0.82f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = false,
isActivePage = true
)
)
assertTrue(
shouldRenderPdfHighResTiles(
effectiveScale = 1.25f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = false,
isActivePage = true
)
)
assertFalse(
shouldRenderPdfHighResTiles(
effectiveScale = 1.25f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = false,
isActivePage = false
)
)
}
@Test
fun `zoom indicator percent rounds displayed scale`() {
assertEquals(82, pdfZoomIndicatorPercent(0.824f))
assertEquals(83, pdfZoomIndicatorPercent(0.826f))
assertEquals(100, pdfZoomIndicatorPercent(0.996f))
}
@Test
fun `zoom indicator hides only at displayed one hundred percent`() {
assertFalse(shouldShowPdfZoomIndicator(100))
assertTrue(shouldShowPdfZoomIndicator(99))
assertTrue(shouldShowPdfZoomIndicator(125))
}
@Test @Test
fun `page change preserves locked zoom scale only in paginated lock mode`() { fun `page change preserves locked zoom scale only in paginated lock mode`() {
val lockedState = Triple(2.25f, -12f, 32f) val lockedState = Triple(2.25f, -12f, 32f)

View file

@ -7,6 +7,7 @@ import org.junit.Test
import java.io.File import java.io.File
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.util.concurrent.ConcurrentHashMap
class TtsChunkNavigationTest { class TtsChunkNavigationTest {
@Test @Test
@ -53,6 +54,25 @@ class TtsChunkNavigationTest {
assertEquals(false, shouldAdvanceToTtsPlaylistChunk(currentChunkIndex = 8, playlistChunkIndex = null)) assertEquals(false, shouldAdvanceToTtsPlaylistChunk(currentChunkIndex = 8, playlistChunkIndex = null))
} }
@Test
fun `automatic playlist advance can step over chunks marked skipped after generation failures`() {
assertEquals(
true,
shouldAdvanceToTtsPlaylistChunk(
currentChunkIndex = 8,
playlistChunkIndex = 10,
skippedChunkIndices = setOf(9)
)
)
assertEquals(10, resolveNextPlayableTtsChunkIndex(8, 12, setOf(9)))
}
@Test
fun `chunk generation gives up after bounded failures`() {
assertEquals(false, shouldGiveUpTtsChunkGeneration(failureCount = 1, maxFailures = 2))
assertEquals(true, shouldGiveUpTtsChunkGeneration(failureCount = 2, maxFailures = 2))
}
@Test @Test
fun `transition prefetch is deferred only for the rebuilding generation`() { fun `transition prefetch is deferred only for the rebuilding generation`() {
assertEquals(false, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = 6)) assertEquals(false, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = 6))
@ -150,6 +170,16 @@ class TtsChunkNavigationTest {
assertNull(estimateTtsNotificationDurationMs(text = " ")) assertNull(estimateTtsNotificationDurationMs(text = " "))
} }
@Test
fun `stable sorted snapshot copies concurrent cache keys`() {
val cache = ConcurrentHashMap<Int, String>()
cache[3] = "three"
cache[1] = "one"
cache[2] = "two"
assertEquals(listOf(1, 2, 3), stableSortedIntSnapshot(cache.keys))
}
@Test @Test
fun `wav file duration is read from pcm byte rate`() { fun `wav file duration is read from pcm byte rate`() {
val file = createTempWavFile(pcmBytes = 48_000) val file = createTempWavFile(pcmBytes = 48_000)

View file

@ -8,3 +8,24 @@ plugins {
alias(libs.plugins.compose.multiplatform) apply false alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.kover) apply false alias(libs.plugins.kover) apply false
} }
val test by tasks.registering {
group = "verification"
description = "Runs available unit tests for the included projects."
}
subprojects {
val rootTest = rootProject.tasks.named("test")
tasks.matching {
it.name == "allTests" ||
it.name == "desktopTest" ||
it.name.endsWith("DebugUnitTest")
}.configureEach {
rootTest.configure {
dependsOn(this@configureEach)
}
}
tasks.withType<Test>().configureEach {
maxHeapSize = "4g"
}
}

View file

@ -1,19 +1,31 @@
import org.gradle.api.GradleException import org.gradle.api.GradleException
import org.gradle.api.DefaultTask import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.ListProperty import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.MapProperty import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property import org.gradle.api.provider.Property
import org.gradle.api.tasks.JavaExec import org.gradle.api.tasks.JavaExec
import org.gradle.api.tasks.Exec
import org.gradle.api.tasks.Input import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.Sync import org.gradle.api.tasks.Sync
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.bundling.Compression
import org.gradle.api.tasks.bundling.Tar
import org.gradle.jvm.tasks.Jar import org.gradle.jvm.tasks.Jar
import org.gradle.process.ExecOperations
import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.gradle.work.DisableCachingByDefault import org.gradle.work.DisableCachingByDefault
import java.io.File import java.io.File
import java.security.MessageDigest
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption
@ -21,6 +33,8 @@ import java.util.Properties
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
import javax.imageio.ImageIO
import javax.inject.Inject
plugins { plugins {
alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.multiplatform)
@ -81,6 +95,244 @@ abstract class RenameDesktopMsiOutputTask : DefaultTask() {
} }
} }
@DisableCachingByDefault(because = "Generates an MSIX manifest from package metadata.")
abstract class GenerateDesktopMsixManifestTask : DefaultTask() {
@get:Input
abstract val identityName: Property<String>
@get:Input
abstract val publisher: Property<String>
@get:Input
abstract val publisherDisplayName: Property<String>
@get:Input
abstract val packageName: Property<String>
@get:Input
abstract val packageDescription: Property<String>
@get:Input
abstract val packageVersion: Property<String>
@get:Input
abstract val architecture: Property<String>
@get:Input
abstract val executablePath: Property<String>
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun generate() {
fun xmlEscaped(value: String): String {
return value.replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
.replace("<", "&lt;")
.replace(">", "&gt;")
}
val file = outputFile.get().asFile
file.parentFile.mkdirs()
file.writeText(
"""
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity
Name="${xmlEscaped(identityName.get())}"
Publisher="${xmlEscaped(publisher.get())}"
Version="${xmlEscaped(packageVersion.get())}"
ProcessorArchitecture="${xmlEscaped(architecture.get())}" />
<Properties>
<DisplayName>${xmlEscaped(packageName.get())}</DisplayName>
<PublisherDisplayName>${xmlEscaped(publisherDisplayName.get())}</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Resources>
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.22621.0" />
</Dependencies>
<Applications>
<Application Id="Episteme" Executable="${xmlEscaped(executablePath.get())}" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="${xmlEscaped(packageName.get())}"
Description="${xmlEscaped(packageDescription.get())}"
BackgroundColor="transparent"
Square44x44Logo="Assets\Square44x44Logo.png"
Square150x150Logo="Assets\Square150x150Logo.png" />
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>
""".trimIndent() + "\n",
Charsets.UTF_8
)
}
}
@DisableCachingByDefault(because = "Generates fixed-size MSIX logo assets from the desktop icon.")
abstract class GenerateDesktopMsixAssetsTask : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val sourceIconFile: RegularFileProperty
@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty
@TaskAction
fun generate() {
val source = ImageIO.read(sourceIconFile.get().asFile)
?: throw GradleException("Could not read MSIX source icon ${sourceIconFile.get().asFile.absolutePath}.")
val output = outputDirectory.get().asFile
output.mkdirs()
writePng(source, output.resolve("Square44x44Logo.png"), 44)
writePng(source, output.resolve("Square150x150Logo.png"), 150)
writePng(source, output.resolve("StoreLogo.png"), 50)
}
private fun writePng(source: BufferedImage, target: File, size: Int) {
val image = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB)
val graphics = image.createGraphics()
try {
graphics.setRenderingHint(
RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BICUBIC
)
graphics.setRenderingHint(
RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY
)
graphics.drawImage(source, 0, 0, size, size, null)
} finally {
graphics.dispose()
}
ImageIO.write(image, "png", target)
}
}
@DisableCachingByDefault(because = "Packages the staged MSIX app image with Windows SDK makeappx.")
abstract class PackageDesktopMsixTask @Inject constructor(
private val execOperations: ExecOperations
) : DefaultTask() {
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val packageRootDirectory: DirectoryProperty
@get:OutputFile
abstract val outputFile: RegularFileProperty
@get:Input
abstract val makeAppxPath: Property<String>
@get:Input
abstract val hostOsId: Property<String>
@get:Input
abstract val hostArchId: Property<String>
@TaskAction
fun packageMsix() {
if (hostOsId.get() != "windows" || hostArchId.get() != "x64") {
throw GradleException(
"MSIX packaging requires a Windows x64 packaging host. " +
"Current host: ${hostOsId.get()} ${hostArchId.get()}."
)
}
val makeAppx = File(makeAppxPath.get())
if (!makeAppx.isFile) {
throw GradleException(
"Windows SDK makeappx.exe was not found at ${makeAppx.absolutePath}. " +
"Install the Windows SDK MSIX packaging tools or set " +
"-PdesktopMakeAppxPath=<path-to-makeappx.exe>."
)
}
val output = outputFile.get().asFile
output.parentFile.mkdirs()
if (output.exists() && !output.delete()) {
throw GradleException("Could not replace existing MSIX at ${output.absolutePath}.")
}
execOperations.exec {
executable = makeAppx.absolutePath
args(
"pack",
"/d",
packageRootDirectory.get().asFile.absolutePath,
"/p",
output.absolutePath,
"/o"
)
}
}
}
@DisableCachingByDefault(because = "Signs the MSIX package with Windows SDK signtool.")
abstract class SignDesktopMsixTask @Inject constructor(
private val execOperations: ExecOperations
) : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val unsignedMsixFile: RegularFileProperty
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val certificateFile: RegularFileProperty
@get:Input
abstract val signToolPath: Property<String>
@get:Input
abstract val certificatePassword: Property<String>
@get:Input
abstract val timestampUrl: Property<String>
@TaskAction
fun signMsix() {
val signTool = File(signToolPath.get())
if (!signTool.isFile) {
throw GradleException(
"Windows SDK signtool.exe was not found at ${signTool.absolutePath}. " +
"Install the Windows SDK or set -PdesktopSignToolPath=<path-to-signtool.exe>."
)
}
val signArgs = mutableListOf(
"sign",
"/fd",
"SHA256",
"/f",
certificateFile.get().asFile.absolutePath
)
val password = certificatePassword.get().trim()
if (password.isNotEmpty()) {
signArgs += listOf("/p", password)
}
val timestamp = timestampUrl.get().trim()
if (timestamp.isNotEmpty()) {
signArgs += listOf("/tr", timestamp, "/td", "SHA256")
}
signArgs += unsignedMsixFile.get().asFile.absolutePath
execOperations.exec {
executable = signTool.absolutePath
args(signArgs)
}
}
}
@DisableCachingByDefault(because = "Generates local desktop service config for native packages.") @DisableCachingByDefault(because = "Generates local desktop service config for native packages.")
abstract class GenerateDesktopCloudConfigTask : DefaultTask() { abstract class GenerateDesktopCloudConfigTask : DefaultTask() {
@get:Input @get:Input
@ -136,6 +388,275 @@ abstract class VerifyDesktopNativePackagingTask : DefaultTask() {
} }
} }
@DisableCachingByDefault(because = "Generates AUR package metadata from the local Linux distributable.")
abstract class PrepareDesktopAurPackageTask : DefaultTask() {
@get:Input
abstract val aurPackageName: Property<String>
@get:Input
abstract val providedPackageName: Property<String>
@get:Input
abstract val packageVersion: Property<String>
@get:Input
abstract val packageRelease: Property<String>
@get:Input
abstract val packageDescription: Property<String>
@get:Input
abstract val appDisplayName: Property<String>
@get:Input
abstract val installDirectoryName: Property<String>
@get:Input
abstract val launcherName: Property<String>
@get:Input
abstract val executableName: Property<String>
@get:Input
abstract val sourceUrl: Property<String>
@get:Input
abstract val projectUrl: Property<String>
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val linuxTarFile: RegularFileProperty
@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty
@TaskAction
fun prepare() {
val output = outputDirectory.get().asFile
val sourceTar = linuxTarFile.get().asFile
if (!sourceTar.isFile) {
throw GradleException("Missing Linux tarball for AUR packaging: ${sourceTar.absolutePath}")
}
output.deleteRecursively()
output.mkdirs()
val stagedTar = output.resolve(sourceTar.name)
sourceTar.copyTo(stagedTar, overwrite = true)
val sha256 = stagedTar.sha256()
val configuredSourceUrl = sourceUrl.get().trim()
val sourceEntry = if (configuredSourceUrl.isBlank()) {
stagedTar.name
} else {
"${stagedTar.name}::$configuredSourceUrl"
}
output.resolve("PKGBUILD").writeText(
aurPkgbuild(
pkgname = aurPackageName.get(),
providedPackage = providedPackageName.get(),
pkgver = packageVersion.get(),
pkgrel = packageRelease.get(),
pkgdesc = packageDescription.get(),
appName = appDisplayName.get(),
installDir = installDirectoryName.get(),
launcher = launcherName.get(),
executable = executableName.get(),
source = sourceEntry,
sha256 = sha256,
projectUrl = projectUrl.get()
)
)
output.resolve(".SRCINFO").writeText(
aurSrcInfo(
pkgname = aurPackageName.get(),
providedPackage = providedPackageName.get(),
pkgver = packageVersion.get(),
pkgrel = packageRelease.get(),
pkgdesc = packageDescription.get(),
source = sourceEntry,
sha256 = sha256,
projectUrl = projectUrl.get()
)
)
}
private fun File.sha256(): String {
val digest = MessageDigest.getInstance("SHA-256")
inputStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun shellSingleQuoted(value: String): String {
return "'" + value.replace("'", "'\"'\"'") + "'"
}
private fun archRuntimeDependencies(): List<String> {
return listOf(
"alsa-lib",
"atk",
"cairo",
"dbus",
"expat",
"fontconfig",
"freetype2",
"gcc-libs",
"gdk-pixbuf2",
"glib2",
"glibc",
"gtk3",
"libcups",
"libarchive",
"libsecret",
"libx11",
"libxcomposite",
"libxdamage",
"libxext",
"libxi",
"libxrandr",
"libxrender",
"libxtst",
"nss",
"pango",
"zlib"
)
}
private fun aurPkgbuild(
pkgname: String,
providedPackage: String,
pkgver: String,
pkgrel: String,
pkgdesc: String,
appName: String,
installDir: String,
launcher: String,
executable: String,
source: String,
sha256: String,
projectUrl: String
): String {
val desktopFile = "$providedPackage.desktop"
val iconName = providedPackage
val depends = archRuntimeDependencies()
val mimeTypes = archDesktopMimeTypes()
return """
pkgname=${shellSingleQuoted(pkgname)}
pkgver=${shellSingleQuoted(pkgver)}
pkgrel=${shellSingleQuoted(pkgrel)}
pkgdesc=${shellSingleQuoted(pkgdesc)}
arch=('x86_64')
url=${shellSingleQuoted(projectUrl)}
license=('AGPL-3.0-only')
depends=(${depends.joinToString(" ") { shellSingleQuoted(it) }})
provides=(${shellSingleQuoted(providedPackage)})
conflicts=(${shellSingleQuoted(providedPackage)})
source=(${shellSingleQuoted(source)})
sha256sums=(${shellSingleQuoted(sha256)})
options=('!debug')
package() {
install -dm755 "${'$'}pkgdir/opt/$installDir"
cp -a "$installDir/." "${'$'}pkgdir/opt/$installDir/"
chmod 755 "${'$'}pkgdir/opt/$installDir/bin/$executable"
install -dm755 "${'$'}pkgdir/usr/bin"
ln -sf "/opt/$installDir/bin/$executable" "${'$'}pkgdir/usr/bin/$launcher"
install -Dm644 "${'$'}pkgdir/opt/$installDir/share/licenses/LICENSE" "${'$'}pkgdir/usr/share/licenses/${'$'}pkgname/LICENSE"
local icon_path
icon_path="${'$'}(find "${'$'}pkgdir/opt/$installDir" -name 'episteme_icon.png' -print -quit)"
if [[ -n "${'$'}icon_path" ]]; then
install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/icons/hicolor/512x512/apps/$iconName.png"
install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/pixmaps/$iconName.png"
fi
install -Dm644 /dev/stdin "${'$'}pkgdir/usr/share/applications/$desktopFile" <<'EOF'
[Desktop Entry]
Type=Application
Name=$appName
Comment=$pkgdesc
Exec=$launcher %F
Icon=$iconName
Terminal=false
Categories=Office;Viewer;
MimeType=${mimeTypes.joinToString(";")};
EOF
}
""".trimIndent() + "\n"
}
private fun aurSrcInfo(
pkgname: String,
providedPackage: String,
pkgver: String,
pkgrel: String,
pkgdesc: String,
source: String,
sha256: String,
projectUrl: String
): String {
val depends = archRuntimeDependencies()
return """
pkgbase = $pkgname
pkgdesc = $pkgdesc
pkgver = $pkgver
pkgrel = $pkgrel
url = $projectUrl
arch = x86_64
license = AGPL-3.0-only
${depends.joinToString("\n") { "\tdepends = $it" }}
provides = $providedPackage
conflicts = $providedPackage
source = $source
sha256sums = $sha256
pkgname = $pkgname
""".trimIndent() + "\n"
}
private fun archDesktopMimeTypes(): List<String> {
return listOf(
"application/pdf",
"application/epub+zip",
"application/x-mobipocket-ebook",
"application/vnd.amazon.ebook",
"application/vnd.amazon.mobi8-ebook",
"text/markdown",
"text/x-markdown",
"text/plain",
"text/html",
"application/xhtml+xml",
"application/x-fictionbook+xml",
"application/x-zip-compressed-fb2",
"application/zip",
"application/vnd.comicbook+zip",
"application/x-cbz",
"application/vnd.comicbook-rar",
"application/x-cbr",
"application/x-rar-compressed",
"application/x-cb7",
"application/x-7z-compressed",
"application/vnd.comicbook+tar",
"application/x-cbt",
"application/x-tar",
"application/tar",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text",
"application/x-vnd.oasis.opendocument.text-flat-xml"
)
}
}
@DisableCachingByDefault(because = "Strips stale jar signatures in-place after ProGuard rewrites signed dependencies.") @DisableCachingByDefault(because = "Strips stale jar signatures in-place after ProGuard rewrites signed dependencies.")
abstract class StripInvalidJarSignaturesTask : DefaultTask() { abstract class StripInvalidJarSignaturesTask : DefaultTask() {
@get:Input @get:Input
@ -546,6 +1067,79 @@ fun normalizeDesktopPackageFormats(
return formats return formats
} }
fun normalizeDesktopMsixVersion(rawVersion: String): String {
val parts = rawVersion.trim().split('.')
if (parts.size !in 3..4 || parts.any { it.isBlank() || it.all(Char::isDigit).not() }) {
throw GradleException(
"desktopMsixVersion must be a numeric Windows package version with three or four parts, " +
"for example 1.0.1 or 1.0.1.0."
)
}
val normalized = if (parts.size == 3) parts + "0" else parts
normalized.forEach { part ->
val value = part.toIntOrNull()
if (value == null || value !in 0..65535) {
throw GradleException("desktopMsixVersion part '$part' is outside the MSIX range 0..65535.")
}
}
return normalized.joinToString(".")
}
fun normalizeDesktopMsixIdentityName(rawName: String): String {
val normalized = rawName.trim()
if (!Regex("[A-Za-z0-9][A-Za-z0-9.-]{2,49}").matches(normalized)) {
throw GradleException(
"desktopMsixIdentityName must be 3-50 characters using letters, numbers, dots, or hyphens."
)
}
return normalized
}
fun desktopMsixArchitecture(osArch: String = System.getProperty("os.arch")): String {
return when (desktopArchId(osArch)) {
"x64" -> "x64"
"arm64" -> "arm64"
"x86" -> "x86"
else -> "neutral"
}
}
fun latestExistingFile(candidates: List<File>): File? {
return candidates.filter { it.isFile }.maxByOrNull { it.absolutePath }
}
fun windowsSdkToolCandidates(toolName: String): List<File> {
val roots = listOfNotNull(
System.getenv("WindowsSdkDir")?.let(::File),
File("C:/Program Files (x86)/Windows Kits/10"),
File("C:/Program Files/Windows Kits/10"),
File("C:/Program Files (x86)/Windows Kits/10/App Certification Kit"),
File("C:/Program Files/Windows Kits/10/App Certification Kit")
).distinctBy { it.absolutePath.lowercase() }
val sdkBins = roots.flatMap { root ->
safeChildDirectories(root.resolve("bin")).flatMap { versionDir ->
listOf(
versionDir.resolve("x64/$toolName.exe"),
versionDir.resolve("x86/$toolName.exe"),
versionDir.resolve(toolName)
)
}
}
val directBins = roots.map { root -> root.resolve("$toolName.exe") }
val pathBins = (System.getenv("PATH") ?: "")
.split(File.pathSeparator)
.filter { it.isNotBlank() }
.map { File(it).resolve("$toolName.exe") }
return sdkBins + directBins + pathBins
}
fun findWindowsSdkTool(toolName: String, explicitPath: String?): File {
val explicit = explicitPath?.trim()?.takeIf { it.isNotEmpty() }?.let(::File)
if (explicit != null) return explicit
return latestExistingFile(windowsSdkToolCandidates(toolName))
?: File(rootProject.projectDir, "__missing_windows_sdk_tool__/$toolName.exe")
}
val desktopVersionName = "1.0.1" val desktopVersionName = "1.0.1"
val desktopFlavor = providers.gradleProperty("desktopFlavor") val desktopFlavor = providers.gradleProperty("desktopFlavor")
.orElse("standard") .orElse("standard")
@ -565,19 +1159,49 @@ val desktopPackageVersion = providers.gradleProperty("desktopPackageVersion")
.orElse(desktopResolvedVersionName) .orElse(desktopResolvedVersionName)
.map(::normalizeDesktopPackageVersion) .map(::normalizeDesktopPackageVersion)
val desktopPackageName = if (isOssOfflineDesktop) "Episteme oss" else "Episteme" val desktopPackageName = if (isOssOfflineDesktop) "Episteme oss" else "Episteme"
val desktopLinuxPackageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme"
val desktopPackageDescription = if (isOssOfflineDesktop) { val desktopPackageDescription = if (isOssOfflineDesktop) {
"Episteme oss offline desktop reader" "Episteme oss offline desktop reader"
} else { } else {
"Episteme desktop reader" "Episteme desktop reader"
} }
val desktopVendor = providers.gradleProperty("desktopVendor").orElse("Aryan") val desktopVendor = providers.gradleProperty("desktopVendor").orElse("Aryan")
val desktopVendorName = desktopVendor.get()
val desktopProjectUrl = providers.gradleProperty("desktopProjectUrl")
.orElse("https://github.com/Aryan-Raj3112/episteme")
val desktopOsName = System.getProperty("os.name") val desktopOsName = System.getProperty("os.name")
val desktopOsArch = System.getProperty("os.arch") val desktopOsArch = System.getProperty("os.arch")
val desktopPackageArchitecture = normalizeDesktopPackageArchitecture(desktopOsArch) val desktopPackageArchitecture = normalizeDesktopPackageArchitecture(desktopOsArch)
val desktopAurPackageName = providers.gradleProperty("desktopAurPackageName")
.orElse(if (isOssOfflineDesktop) "episteme-oss-bin" else "episteme-bin")
val desktopAurPackageRelease = providers.gradleProperty("desktopAurPackageRelease")
.orElse("1")
val desktopAurSourceUrl = providers.gradleProperty("desktopAurSourceUrl")
.orElse("")
val desktopPackageTargetFormats = providers.gradleProperty("desktopPackageFormats") val desktopPackageTargetFormats = providers.gradleProperty("desktopPackageFormats")
.orElse(desktopDefaultPackageFormats(desktopOsName)) .orElse(desktopDefaultPackageFormats(desktopOsName))
.map { normalizeDesktopPackageFormats(it, desktopOsName) } .map { normalizeDesktopPackageFormats(it, desktopOsName) }
.get() .get()
val desktopMsixIdentityName = providers.gradleProperty("desktopMsixIdentityName")
.orElse(if (isOssOfflineDesktop) "Aryan.EpistemeOss" else "Aryan.Episteme")
.map(::normalizeDesktopMsixIdentityName)
.get()
val desktopMsixPublisher = providers.gradleProperty("desktopMsixPublisher")
.orElse("CN=$desktopVendorName")
val desktopMsixPublisherDisplayName = providers.gradleProperty("desktopMsixPublisherDisplayName")
.orElse(desktopVendor)
val desktopMsixVersion = providers.gradleProperty("desktopMsixVersion")
.orElse(desktopPackageVersion)
.map(::normalizeDesktopMsixVersion)
.get()
val desktopMsixArchitecture = desktopMsixArchitecture(desktopOsArch)
val desktopMakeAppxPath = providers.gradleProperty("desktopMakeAppxPath").orNull
val desktopSignToolPath = providers.gradleProperty("desktopSignToolPath").orNull
val desktopMsixCertificatePath = providers.gradleProperty("desktopMsixCertificatePath").orNull
val desktopMsixCertificatePassword = providers.gradleProperty("desktopMsixCertificatePassword")
.orElse("")
val desktopMsixTimestampUrl = providers.gradleProperty("desktopMsixTimestampUrl")
.orElse("http://timestamp.digicert.com")
val desktopNativePackageSupportedHost = desktopOsId(desktopOsName) in setOf("windows", "linux") && val desktopNativePackageSupportedHost = desktopOsId(desktopOsName) in setOf("windows", "linux") &&
desktopArchId(desktopOsArch) == "x64" desktopArchId(desktopOsArch) == "x64"
val desktopReleaseProguardEnabled = providers.gradleProperty("desktopReleaseProguard") val desktopReleaseProguardEnabled = providers.gradleProperty("desktopReleaseProguard")
@ -688,6 +1312,127 @@ val verifyDesktopNativePackaging by tasks.registering(VerifyDesktopNativePackagi
missingStandardServiceConfig.set(desktopMissingStandardServiceConfig) missingStandardServiceConfig.set(desktopMissingStandardServiceConfig)
} }
val desktopDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main/app/$desktopPackageName")
val desktopReleaseDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main-release/app/$desktopPackageName")
val desktopLinuxTarFileName = "${desktopLinuxPackageName}-${desktopPackageVersion.get()}-linux-$desktopPackageArchitecture.tar.gz"
val desktopAurOutputDir = layout.buildDirectory.dir("aur/${desktopAurPackageName.get()}")
val desktopMsixPackageDir = layout.buildDirectory.dir("msix/package")
val desktopMsixAssetsDir = layout.buildDirectory.dir("msix/generated/assets")
val desktopMsixManifestFile = layout.buildDirectory.file("msix/generated/AppxManifest.xml")
val desktopMsixOutputFile = layout.buildDirectory.file(
"compose/binaries/main-release/msix/${desktopLinuxPackageName}-${desktopPackageVersion.get()}-windows-$desktopPackageArchitecture.msix"
)
val packageLinuxTar by tasks.registering(Tar::class) {
group = "distribution"
description = "Packages the Linux desktop distributable as a tar.gz for Arch/AUR packaging."
dependsOn("createDistributable")
archiveFileName.set(desktopLinuxTarFileName)
destinationDirectory.set(layout.buildDirectory.dir("compose/binaries/main/linux-tar"))
compression = Compression.GZIP
from(desktopDistributableAppDir) {
into(desktopLinuxPackageName)
}
from(desktopLinuxIconFile) {
into("$desktopLinuxPackageName/share")
}
from(rootProject.layout.projectDirectory.file("LICENSE")) {
into("$desktopLinuxPackageName/share/licenses")
}
}
val prepareAurPackage by tasks.registering(PrepareDesktopAurPackageTask::class) {
group = "distribution"
description = "Generates a local AUR package directory with PKGBUILD and .SRCINFO."
dependsOn(packageLinuxTar)
aurPackageName.set(desktopAurPackageName)
providedPackageName.set(desktopLinuxPackageName)
packageVersion.set(desktopPackageVersion)
packageRelease.set(desktopAurPackageRelease)
packageDescription.set(desktopPackageDescription)
appDisplayName.set(desktopPackageName)
installDirectoryName.set(desktopLinuxPackageName)
launcherName.set(desktopLinuxPackageName)
executableName.set(desktopPackageName)
sourceUrl.set(desktopAurSourceUrl)
projectUrl.set(desktopProjectUrl)
linuxTarFile.set(packageLinuxTar.flatMap { it.archiveFile })
outputDirectory.set(desktopAurOutputDir)
}
tasks.register<Exec>("packageAur") {
group = "distribution"
description = "Builds the generated AUR package with makepkg. Run this on Arch Linux."
dependsOn(prepareAurPackage)
commandLine("makepkg", "-sf", "--cleanbuild")
workingDir = desktopAurOutputDir.get().asFile
}
val generateDesktopMsixManifest by tasks.registering(GenerateDesktopMsixManifestTask::class) {
identityName.set(desktopMsixIdentityName)
publisher.set(desktopMsixPublisher)
publisherDisplayName.set(desktopMsixPublisherDisplayName)
packageName.set(desktopPackageName)
packageDescription.set(desktopPackageDescription)
packageVersion.set(desktopMsixVersion)
architecture.set(desktopMsixArchitecture)
executablePath.set("$desktopPackageName.exe")
outputFile.set(desktopMsixManifestFile)
}
val generateDesktopMsixAssets by tasks.registering(GenerateDesktopMsixAssetsTask::class) {
sourceIconFile.set(desktopLinuxIconFile)
outputDirectory.set(desktopMsixAssetsDir)
}
val prepareReleaseMsixPackage by tasks.registering(Sync::class) {
group = "distribution"
description = "Stages the release Windows app image and MSIX metadata for makeappx."
dependsOn("createReleaseDistributable", generateDesktopMsixManifest, generateDesktopMsixAssets)
from(desktopReleaseDistributableAppDir)
from(desktopMsixManifestFile)
from(desktopMsixAssetsDir) {
into("Assets")
}
into(desktopMsixPackageDir)
}
val packageReleaseMsix by tasks.registering(PackageDesktopMsixTask::class) {
group = "distribution"
description = "Packages the release Windows app image as an MSIX using Windows SDK makeappx."
dependsOn(prepareReleaseMsixPackage)
val makeAppx = findWindowsSdkTool("makeappx", desktopMakeAppxPath)
packageRootDirectory.set(desktopMsixPackageDir)
outputFile.set(desktopMsixOutputFile)
makeAppxPath.set(makeAppx.absolutePath)
hostOsId.set(desktopOsId(desktopOsName))
hostArchId.set(desktopArchId(desktopOsArch))
}
val signReleaseMsix = desktopMsixCertificatePath?.trim()?.takeIf { it.isNotEmpty() }?.let { certificatePath ->
tasks.register<SignDesktopMsixTask>("signReleaseMsix") {
group = "distribution"
description = "Signs the release MSIX with signtool when -PdesktopMsixCertificatePath is configured."
dependsOn(packageReleaseMsix)
val signTool = findWindowsSdkTool("signtool", desktopSignToolPath)
val resolvedCertificateFile = File(certificatePath).let { file ->
if (file.isAbsolute) file else project.file(certificatePath)
}
unsignedMsixFile.set(desktopMsixOutputFile)
certificateFile.set(resolvedCertificateFile)
signToolPath.set(signTool.absolutePath)
certificatePassword.set(desktopMsixCertificatePassword)
timestampUrl.set(desktopMsixTimestampUrl)
}
}
kotlin { kotlin {
jvm("desktop") jvm("desktop")
jvmToolchain(21) jvmToolchain(21)
@ -771,7 +1516,7 @@ compose.desktop {
} }
linux { linux {
iconFile.set(desktopLinuxIconFile) iconFile.set(desktopLinuxIconFile)
packageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme" packageName = desktopLinuxPackageName
debMaintainer = "epistemereader@gmail.com" debMaintainer = "epistemereader@gmail.com"
menuGroup = "Office" menuGroup = "Office"
appCategory = "Office" appCategory = "Office"
@ -818,6 +1563,7 @@ tasks.matching {
"packageReleaseDistributionForCurrentOS", "packageReleaseDistributionForCurrentOS",
"packageReleaseExe", "packageReleaseExe",
"packageReleaseMsi", "packageReleaseMsi",
"packageReleaseMsix",
"packageReleaseDeb", "packageReleaseDeb",
"packageReleaseRpm", "packageReleaseRpm",
"runReleaseDistributable" "runReleaseDistributable"
@ -853,10 +1599,16 @@ tasks.matching {
"packageReleaseExe", "packageReleaseExe",
"packageMsi", "packageMsi",
"packageReleaseMsi", "packageReleaseMsi",
"prepareReleaseMsixPackage",
"packageReleaseMsix",
"signReleaseMsix",
"packageDeb", "packageDeb",
"packageReleaseDeb", "packageReleaseDeb",
"packageRpm", "packageRpm",
"packageReleaseRpm", "packageReleaseRpm",
"packageLinuxTar",
"prepareAurPackage",
"packageAur",
"runDistributable", "runDistributable",
"runReleaseDistributable" "runReleaseDistributable"
) )

View file

@ -0,0 +1,229 @@
# Desktop package builds
Build Linux packages on the matching distro VM when testing manually:
```bash
cd ~/Reader
./gradlew :desktopApp:packageDeb -x test
./gradlew :desktopApp:packageRpm -x test
./gradlew :desktopApp:packageAur -x test
```
Build a Windows MSIX locally on Windows with the Windows SDK installed:
```powershell
cd C:\Users\aryan\Desktop\Reader
.\gradlew.bat -PdesktopOnly=true -PdesktopAllowUnconfiguredStandardServices=true :desktopApp:packageReleaseMsix -x test
```
Copy the newest generated MSIX to your desktop:
```powershell
$msix = Get-ChildItem .\desktopApp\build\compose\binaries\main-release\msix -Filter *.msix | Sort-Object LastWriteTime -Descending | Select-Object -First 1
Copy-Item -Force $msix.FullName "$env:USERPROFILE\Desktop\"
```
The MSIX task is separate from MSI packaging. It stages the release app image at
`desktopApp/build/msix/package`, packages it with Windows SDK `makeappx.exe`, and
writes the MSIX to:
```text
desktopApp/build/compose/binaries/main-release/msix
```
For Microsoft Store submission, set the package identity values from Partner
Center so `AppxManifest.xml` matches the reserved app identity:
```powershell
.\gradlew.bat `
-PdesktopOnly=true `
-PdesktopMsixIdentityName=<Partner Center package identity name> `
-PdesktopMsixPublisher=<Partner Center publisher CN> `
-PdesktopMsixPublisherDisplayName=<Publisher display name> `
:desktopApp:packageReleaseMsix -x test
```
If Windows SDK tools are not on `PATH`, pass them explicitly:
```powershell
.\gradlew.bat `
-PdesktopMakeAppxPath="C:\Program Files (x86)\Windows Kits\10\bin\<sdk-version>\x64\makeappx.exe" `
:desktopApp:packageReleaseMsix -x test
```
Local signing is optional and separate:
```powershell
.\gradlew.bat `
-PdesktopMsixCertificatePath=C:\path\to\certificate.pfx `
-PdesktopMsixCertificatePassword=<password> `
:desktopApp:signReleaseMsix -x test
```
Recommended VM split:
- Ubuntu: `./gradlew :desktopApp:packageDeb -x test`
- Fedora: `./gradlew :desktopApp:packageRpm -x test`
- Arch: `./gradlew :desktopApp:packageAur -x test`
Desktop-only Gradle invocations automatically skip the Android app module and the
Android target in `:shared`, so desktop packaging does not require `sdk.dir`,
Android SDK installation, or Android release signing values. You can force that
mode for unusual command shapes with:
```bash
./gradlew -PdesktopOnly=true :desktopApp:packageDeb -x test
```
Desktop release values are centralized in `gradle.properties`:
```properties
desktopVersion=1.0.1
desktopPackageVersion=1.0.1
desktopAurPackageRelease=1
```
The AUR path is native Arch packaging. It does not wrap the `.deb` or `.rpm`.
`packageAur` first creates a Linux app tarball, then generates an AUR worktree at:
```text
desktopApp/build/aur/episteme-bin
```
On Arch, install/test the generated package with:
```bash
sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-bin/*.pkg.tar.zst
episteme
```
For the OSS/offline flavor:
```bash
./gradlew :desktopApp:packageAur -PdesktopFlavor=oss -x test
sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-oss-bin/*.pkg.tar.zst
episteme-oss
```
To inspect the AUR recipe manually instead:
```bash
./gradlew :desktopApp:prepareAurPackage -x test
cd ~/Reader/desktopApp/build/aur/episteme-bin
makepkg -si
```
For publish-ready AUR metadata, pass the release tarball URL:
```bash
./gradlew :desktopApp:prepareAurPackage \
-PdesktopAurSourceUrl=https://example.com/releases/episteme-1.0.1-linux-x64.tar.gz \
-x test
```
Then publish the generated `PKGBUILD` and `.SRCINFO` from the AUR directory.
The generated AUR recipes use `license=('AGPL-3.0-only')` and install the root
`LICENSE` file into `/usr/share/licenses/$pkgname/`.
## AUR repository setup
Create an account at:
```text
https://aur.archlinux.org/register/
```
Add your public SSH key in the account settings, then confirm SSH works:
```bash
ssh aur@aur.archlinux.org
```
The command should authenticate and print AUR help text. It will not open a
normal shell.
Create the package repos by cloning their not-yet-existing names:
```bash
git clone ssh://aur@aur.archlinux.org/episteme-bin.git
git clone ssh://aur@aur.archlinux.org/episteme-oss-bin.git
```
If a name already exists, inspect it first. If it is abandoned, follow the AUR
orphan/adoption process instead of creating a duplicate package name.
For each release, extract the matching `aur-<package>-<version>.tar.gz` metadata
archive from the GitHub release, copy `PKGBUILD` and `.SRCINFO` into the matching
AUR clone, then commit and push:
```bash
tar -xzf aur-episteme-bin-1.0.1.tar.gz -C episteme-bin
cd episteme-bin
git add PKGBUILD .SRCINFO
git commit -m "Update to 1.0.1"
git push
```
Repeat the same flow for `episteme-oss-bin`.
## CI release workflow
`Desktop release` in GitHub Actions builds desktop artifacts for standard and
OSS flavors:
- Windows MSI
- Ubuntu/Debian DEB
- Fedora RPM
- Linux tarball used by AUR
- Direct Arch `.pkg.tar.zst`
- AUR metadata archives containing `PKGBUILD` and `.SRCINFO`
- `SHA256SUMS.txt`
Before running it, publish Pdfium once from a machine that has the ignored
`third_party/pdfium` folders:
```powershell
.\scripts\desktop\publish-pdfium-release.ps1 `
-Repository Aryan-Raj3112/episteme `
-Tag pdfium-desktop-v1
```
That release must contain:
```text
pdfium-linux-x64-v8.zip
pdfium-win-x64-v8.zip
```
The desktop release workflow downloads those assets with:
```powershell
.\scripts\desktop\download-pdfium.ps1 -Tag pdfium-desktop-v1
```
Required GitHub Secrets for standard desktop packages:
```text
DESKTOP_FIREBASE_PROJECT_ID
DESKTOP_FIREBASE_WEB_API_KEY
DESKTOP_GOOGLE_OAUTH_CLIENT_ID
DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET
```
`MYAPP_RELEASE_STORE_FILE` is not used by desktop packaging. Android is skipped
for `:desktopApp:*` tasks.
AUR publishing still needs the two AUR repos:
```text
episteme-bin
episteme-oss-bin
```
Upload the generated `PKGBUILD` and `.SRCINFO` from:
```text
aur-episteme-bin-<version>.tar.gz
aur-episteme-oss-bin-<version>.tar.gz
```

View file

@ -77,6 +77,16 @@ internal fun chooseSaveImageFile(defaultFileName: String): File? {
return File(directory, file) return File(directory, file)
} }
internal fun chooseSaveBookFile(defaultFileName: String): File? {
val dialog = FileDialog(null as Frame?, desktopDialogString("action_save_copy_to_device", "Save copy to device"), FileDialog.SAVE).apply {
file = defaultFileName
isVisible = true
}
val directory = dialog.directory ?: return null
val file = dialog.file ?: return null
return File(directory, file)
}
internal fun chooseFolder(): File? { internal fun chooseFolder(): File? {
val chooser = JFileChooser().apply { val chooser = JFileChooser().apply {
dialogTitle = desktopDialogString("desktop_import_folder", "Import folder") dialogTitle = desktopDialogString("desktop_import_folder", "Import folder")

View file

@ -58,7 +58,8 @@ internal val DesktopLanguageOptions = listOf(
DesktopLanguageOption("zh-CN", "language_chinese_simplified", "Chinese, Simplified"), DesktopLanguageOption("zh-CN", "language_chinese_simplified", "Chinese, Simplified"),
DesktopLanguageOption("nl", "language_dutch", "Dutch"), DesktopLanguageOption("nl", "language_dutch", "Dutch"),
DesktopLanguageOption("uk", "language_ukrainian", "Ukrainian"), DesktopLanguageOption("uk", "language_ukrainian", "Ukrainian"),
DesktopLanguageOption("id", "language_indonesian", "Indonesian") DesktopLanguageOption("id", "language_indonesian", "Indonesian"),
DesktopLanguageOption("et", "language_estonian", "Estonian")
) )
internal fun selectedDesktopLanguageOption(languageTag: String?): DesktopLanguageOption { internal fun selectedDesktopLanguageOption(languageTag: String?): DesktopLanguageOption {

View file

@ -35,6 +35,7 @@ import com.aryan.reader.shared.AppAction
import com.aryan.reader.shared.BannerMessage import com.aryan.reader.shared.BannerMessage
import com.aryan.reader.shared.BookItem import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.ReaderPlatform import com.aryan.reader.shared.ReaderPlatform
import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.SharedFolderPathResolver import com.aryan.reader.shared.SharedFolderPathResolver
import com.aryan.reader.shared.SharedReaderScreenState import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.Shelf import com.aryan.reader.shared.Shelf
@ -63,6 +64,24 @@ internal fun String.toDesktopSafeFileName(): String {
return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" } return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" }
} }
internal fun BookItem.desktopSuggestedOriginalFileName(): String {
val extension = path
?.let(::File)
?.extension
?.takeIf { it.isNotBlank() }
?: SharedFileCapabilities.primaryExtensionFor(type)
val safeName = displayName
.takeIf { it.isNotBlank() }
?: title?.takeIf { it.isNotBlank() }
?: "book"
val sanitized = safeName.toDesktopSafeFileName()
return if (extension != null && !sanitized.endsWith(".$extension", ignoreCase = true)) {
"$sanitized.$extension"
} else {
sanitized
}
}
internal fun BookItem.withDesktopImportMetadata( internal fun BookItem.withDesktopImportMetadata(
enriched: BookItem, enriched: BookItem,
original: BookItem? original: BookItem?
@ -203,7 +222,8 @@ internal fun LibraryScreen(
onImportFolder: () -> Unit, onImportFolder: () -> Unit,
onSyncFolderMetadata: () -> Unit, onSyncFolderMetadata: () -> Unit,
onScanFolders: () -> Unit, onScanFolders: () -> Unit,
onTogglePinned: (BookItem) -> Unit onTogglePinned: (BookItem) -> Unit,
onSaveOriginalFile: (BookItem) -> Unit = {}
) { ) {
SharedLibraryScreen( SharedLibraryScreen(
state = state, state = state,
@ -231,6 +251,7 @@ internal fun LibraryScreen(
onSyncFolderMetadata = onSyncFolderMetadata, onSyncFolderMetadata = onSyncFolderMetadata,
onScanFolders = onScanFolders, onScanFolders = onScanFolders,
onTogglePinned = onTogglePinned, onTogglePinned = onTogglePinned,
onSaveOriginalFile = onSaveOriginalFile,
platform = ReaderPlatform.DESKTOP, platform = ReaderPlatform.DESKTOP,
useImportEmptyStateWhenLibraryEmpty = true useImportEmptyStateWhenLibraryEmpty = true
) )

View file

@ -7,11 +7,44 @@ import com.aryan.reader.shared.AppFontPreferenceKind
import com.aryan.reader.shared.CustomFontItem import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.reader.ReaderPage import com.aryan.reader.shared.reader.ReaderPage
import com.aryan.reader.shared.reader.ReaderSettings import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.detectFontVariant
import com.aryan.reader.shared.familyFilenameSignature
import com.aryan.reader.shared.supportsVariableWeightAxis
import java.io.File import java.io.File
internal fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily { internal fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily {
customFontPath?.takeIf { it.isNotBlank() }?.let { path -> customFontPath?.takeIf { it.isNotBlank() }?.let { path ->
runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull()?.let { return it } val baseFile = File(path)
val signature = baseFile.nameWithoutExtension.familyFilenameSignature()
val siblings = baseFile.parentFile?.listFiles()?.filter {
it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
it.nameWithoutExtension.familyFilenameSignature() == signature
} ?: listOf(baseFile)
val seenVariants = mutableSetOf<String>()
val fontList = siblings.flatMap { sibling ->
try {
val variant = sibling.nameWithoutExtension.detectFontVariant()
val weights = if (sibling.nameWithoutExtension.supportsVariableWeightAxis()) {
variableDesktopReaderFontWeights
} else {
listOf(variant?.weight ?: androidx.compose.ui.text.font.FontWeight.Normal)
}
weights.mapNotNull { weight ->
val style = variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal
if (seenVariants.add("${weight.weight}|$style")) {
DesktopFont(sibling, weight, style)
} else {
null
}
}
} catch (e: Exception) {
emptyList()
}
}
if (fontList.isNotEmpty()) {
return FontFamily(fontList)
}
} }
return fontFamily.toComposeFontFamily() return fontFamily.toComposeFontFamily()
} }
@ -25,6 +58,18 @@ private fun String.toComposeFontFamily(): FontFamily {
} }
} }
private val variableDesktopReaderFontWeights = listOf(
androidx.compose.ui.text.font.FontWeight.Thin,
androidx.compose.ui.text.font.FontWeight.ExtraLight,
androidx.compose.ui.text.font.FontWeight.Light,
androidx.compose.ui.text.font.FontWeight.Normal,
androidx.compose.ui.text.font.FontWeight.Medium,
androidx.compose.ui.text.font.FontWeight.SemiBold,
androidx.compose.ui.text.font.FontWeight.Bold,
androidx.compose.ui.text.font.FontWeight.ExtraBold,
androidx.compose.ui.text.font.FontWeight.Black
)
internal fun List<ReaderPage>.samePageLayoutAs(other: List<ReaderPage>): Boolean { internal fun List<ReaderPage>.samePageLayoutAs(other: List<ReaderPage>): Boolean {
if (size != other.size) return false if (size != other.size) return false
return indices.all { index -> return indices.all { index ->

View file

@ -638,6 +638,23 @@ internal fun EpistemeDesktopApp(
} }
} }
fun saveDesktopOriginalFile(book: BookItem) {
val source = book.path?.let(::File)
if (source?.isFile != true) {
updateState(state.withBanner("Original file is not available.", isError = true))
return
}
val target = chooseSaveBookFile(book.desktopSuggestedOriginalFileName()) ?: return
runCatching {
target.parentFile?.mkdirs()
source.copyTo(target, overwrite = true)
}.onSuccess {
updateState(state.withBanner("Saved ${target.name}."))
}.onFailure { error ->
updateState(state.withBanner(error.message ?: "Could not save file.", isError = true))
}
}
fun clearDesktopBookCache() { fun clearDesktopBookCache() {
scope.launch { scope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -4200,7 +4217,8 @@ internal fun EpistemeDesktopApp(
onManageShelfBooks = { shelfToManageBooks = it }, onManageShelfBooks = { shelfToManageBooks = it },
onSyncFolderMetadata = { syncFolderMetadata() }, onSyncFolderMetadata = { syncFolderMetadata() },
onScanFolders = { scanSyncedFolders() }, onScanFolders = { scanSyncedFolders() },
onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) } onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) },
onSaveOriginalFile = ::saveDesktopOriginalFile
) )
SharedAppTab.SHELVES -> LibraryScreen( SharedAppTab.SHELVES -> LibraryScreen(
@ -4249,7 +4267,8 @@ internal fun EpistemeDesktopApp(
onManageShelfBooks = { shelfToManageBooks = it }, onManageShelfBooks = { shelfToManageBooks = it },
onSyncFolderMetadata = { syncFolderMetadata() }, onSyncFolderMetadata = { syncFolderMetadata() },
onScanFolders = { scanSyncedFolders() }, onScanFolders = { scanSyncedFolders() },
onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) } onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) },
onSaveOriginalFile = ::saveDesktopOriginalFile
) )
SharedAppTab.CATALOGS -> { SharedAppTab.CATALOGS -> {

View file

@ -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()
}
}

View file

@ -127,6 +127,14 @@ class DesktopStringResourcesTest {
assertEquals("language_portuguese_brazilian", option.labelKey) assertEquals("language_portuguese_brazilian", option.labelKey)
} }
@Test
fun resolvesSelectedDesktopLanguageOptionForEstonian() {
val option = selectedDesktopLanguageOption("et")
assertEquals("et", option.normalizedTag)
assertEquals("language_estonian", option.labelKey)
}
@Test @Test
fun desktopLanguageSettingsStorePersistsLanguageAcrossInstances() { fun desktopLanguageSettingsStorePersistsLanguageAcrossInstances() {
val tempDirectory = Files.createTempDirectory("episteme-desktop-language-test") val tempDirectory = Files.createTempDirectory("episteme-desktop-language-test")

0
gradlew vendored Normal file → Executable file
View file

View file

@ -0,0 +1,52 @@
param(
[string]$Repository = $env:GITHUB_REPOSITORY,
[string]$Tag = "pdfium-desktop-v1",
[string]$Destination = "third_party/pdfium"
)
if ([string]::IsNullOrWhiteSpace($Repository)) {
throw "Repository is required. Pass -Repository owner/repo or set GITHUB_REPOSITORY."
}
$ErrorActionPreference = "Stop"
$assets = @(
@{ Name = "pdfium-linux-x64-v8.zip"; Directory = "linux-x64-v8" },
@{ Name = "pdfium-win-x64-v8.zip"; Directory = "win-x64-v8" }
)
New-Item -ItemType Directory -Force -Path $Destination | Out-Null
$workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("reader-pdfium-" + [System.Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $workDir | Out-Null
try {
foreach ($asset in $assets) {
$zipPath = Join-Path $workDir $asset.Name
gh release download $Tag --repo $Repository --pattern $asset.Name --dir $workDir --clobber
if (-not (Test-Path $zipPath)) {
throw "Pdfium release asset was not downloaded: $($asset.Name)"
}
$targetDir = Join-Path $Destination $asset.Directory
if (Test-Path $targetDir) {
Remove-Item -Recurse -Force $targetDir
}
$extractDir = Join-Path $workDir $asset.Directory
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
Expand-Archive -Force -Path $zipPath -DestinationPath $extractDir
$rootedDir = Join-Path $extractDir $asset.Directory
if (Test-Path $rootedDir) {
Move-Item -Path $rootedDir -Destination $targetDir
} else {
New-Item -ItemType Directory -Force -Path $targetDir | Out-Null
Move-Item -Path (Join-Path $extractDir "*") -Destination $targetDir
}
}
} finally {
if (Test-Path $workDir) {
Remove-Item -Recurse -Force $workDir
}
}

View file

@ -0,0 +1,51 @@
param(
[string]$Repository = $env:GITHUB_REPOSITORY,
[string]$Tag = "pdfium-desktop-v1",
[string]$Title = "Desktop Pdfium binaries",
[string]$PdfiumRoot = "third_party/pdfium"
)
if ([string]::IsNullOrWhiteSpace($Repository)) {
throw "Repository is required. Pass -Repository owner/repo or set GITHUB_REPOSITORY."
}
$ErrorActionPreference = "Stop"
$folders = @(
@{ Directory = "linux-x64-v8"; Asset = "pdfium-linux-x64-v8.zip" },
@{ Directory = "win-x64-v8"; Asset = "pdfium-win-x64-v8.zip" }
)
$workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("reader-pdfium-release-" + [System.Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $workDir | Out-Null
try {
foreach ($folder in $folders) {
$source = Join-Path $PdfiumRoot $folder.Directory
if (-not (Test-Path $source)) {
throw "Missing Pdfium folder: $source"
}
$assetPath = Join-Path $workDir $folder.Asset
Compress-Archive -Force -Path $source -DestinationPath $assetPath
}
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
gh release view $Tag --repo $Repository *> $null
$releaseExists = $LASTEXITCODE -eq 0
$ErrorActionPreference = $previousErrorActionPreference
if (-not $releaseExists) {
gh release create $Tag --repo $Repository --title $Title --notes "Pdfium binaries used by desktop CI packaging."
}
foreach ($folder in $folders) {
$assetPath = Join-Path $workDir $folder.Asset
gh release upload $Tag $assetPath --repo $Repository --clobber
}
} finally {
if (Test-Path $workDir) {
Remove-Item -Recurse -Force $workDir
}
}

View file

@ -23,6 +23,20 @@ dependencyResolutionManagement {
} }
rootProject.name = "Reader" 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(":shared")
include(":desktopApp") include(":desktopApp")

View file

@ -1,20 +1,40 @@
import com.android.build.api.dsl.LibraryExtension
plugins { plugins {
alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.multiplatform)
id("com.android.library") alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kover) alias(libs.plugins.kover)
} }
fun isDesktopOnlyBuild(): Boolean {
providers.gradleProperty("desktopOnly").orNull
?.let { return it.equals("true", ignoreCase = true) }
val requestedTasks = gradle.startParameter.taskNames
return requestedTasks.isNotEmpty() && requestedTasks.all { taskName ->
val normalized = taskName.removePrefix(":")
normalized.startsWith("desktopApp:")
}
}
val desktopOnlyBuild = isDesktopOnlyBuild()
if (!desktopOnlyBuild) {
apply(plugin = "com.android.library")
}
kotlin { kotlin {
if (!desktopOnlyBuild) {
androidTarget() androidTarget()
}
jvm("desktop") jvm("desktop")
jvmToolchain(21) jvmToolchain(21)
sourceSets { sourceSets {
val commonMain by getting val commonMain by getting
val androidMain by getting
val desktopMain by getting val desktopMain by getting
val readerJvmMain by creating { val readerJvmMain by creating {
dependsOn(commonMain) dependsOn(commonMain)
@ -22,7 +42,10 @@ kotlin {
implementation("org.jsoup:jsoup:1.17.2") implementation("org.jsoup:jsoup:1.17.2")
} }
} }
if (!desktopOnlyBuild) {
val androidMain by getting
androidMain.dependsOn(readerJvmMain) androidMain.dependsOn(readerJvmMain)
}
desktopMain.dependsOn(readerJvmMain) desktopMain.dependsOn(readerJvmMain)
commonMain.dependencies { commonMain.dependencies {
@ -40,7 +63,8 @@ kotlin {
} }
} }
android { if (!desktopOnlyBuild) {
extensions.configure<LibraryExtension>("android") {
namespace = "com.aryan.reader.shared" namespace = "com.aryan.reader.shared"
compileSdk = 36 compileSdk = 36
@ -51,4 +75,5 @@ android {
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
} }
}
} }

View file

@ -1,5 +1,8 @@
package com.aryan.reader.shared package com.aryan.reader.shared
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
data class CustomFontItem( data class CustomFontItem(
val id: String, val id: String,
val displayName: String, val displayName: String,
@ -10,3 +13,76 @@ data class CustomFontItem(
val isDeleted: Boolean = false val isDeleted: Boolean = false
) )
data class CustomFontVariantItem(
val font: CustomFontItem,
val variant: FontVariant?
)
data class CustomFontFamilyItem(
val familyName: String,
val variants: List<CustomFontVariantItem>
)
fun List<CustomFontItem>.groupByFamily(): List<CustomFontFamilyItem> {
val families = this.groupBy {
it.displayName.familyFilenameSignature().takeIf { s -> s.isNotBlank() } ?: it.displayName
}
return families.map { (familyName, fonts) ->
val variants = fonts.map { font ->
CustomFontVariantItem(
font = font,
variant = font.displayName.detectFontVariant()
)
}
CustomFontFamilyItem(
familyName = familyName.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() },
variants = variants
)
}.sortedBy { it.familyName }
}
fun CustomFontVariantItem.fontFaceLabel(): String {
val variant = variant ?: return "Regular"
return when {
variant.weight.weight >= FontWeight.Bold.weight && variant.style == FontStyle.Italic -> "Bold Italic"
variant.weight.weight >= FontWeight.Bold.weight -> "Bold"
variant.style == FontStyle.Italic -> "Italic"
variant.weight == FontWeight.Normal -> "Regular"
variant.weight.weight < FontWeight.Normal.weight -> variant.weight.fontWeightLabel()
else -> variant.weight.fontWeightLabel()
}
}
fun CustomFontFamilyItem.fontFaceSummary(): String {
return variants
.sortedWith(compareBy<CustomFontVariantItem> {
it.variant?.style == FontStyle.Italic
}.thenBy {
it.variant?.weight?.weight ?: FontWeight.Normal.weight
})
.map { it.fontFaceLabel() }
.distinct()
.joinToString()
}
fun CustomFontFamilyItem.hasVariableWeightFace(): Boolean {
return variants.any { variant ->
variant.font.displayName.supportsVariableWeightAxis() || variant.font.fileName.supportsVariableWeightAxis()
}
}
private fun FontWeight.fontWeightLabel(): String {
return when (this) {
FontWeight.Thin -> "Thin"
FontWeight.ExtraLight -> "Extra Light"
FontWeight.Light -> "Light"
FontWeight.Normal -> "Regular"
FontWeight.Medium -> "Medium"
FontWeight.SemiBold -> "Semi Bold"
FontWeight.Bold -> "Bold"
FontWeight.ExtraBold -> "Extra Bold"
FontWeight.Black -> "Black"
else -> weight.toString()
}
}

View file

@ -0,0 +1,149 @@
package com.aryan.reader.shared
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
data class FontVariant(
val weight: FontWeight,
val style: FontStyle
)
private val filenameSeparatorsRegex = Regex("""[\s._,-]+""")
fun String.familyFilenameSignature(): String {
val tokens = filenameTokens()
val variantIndexes = tokens.variantTokenIndexes()
val signatureTokens = tokens.filterIndexed { index, token ->
token.isNotBlank() && index !in variantIndexes
}
return signatureTokens.joinToString(separator = " ")
}
fun String.supportsVariableWeightAxis(): Boolean {
return filenameTokens().any { it == "wght" }
}
fun String.fontWeightCssDescriptor(fallbackWeight: FontWeight = FontWeight.Normal): String {
return if (supportsVariableWeightAxis()) {
"100 900"
} else {
fallbackWeight.weight.toString()
}
}
private fun List<String>.variantTokenIndexes(): Set<Int> {
val indexes = mutableSetOf<Int>()
forEachIndexed { index, token ->
if (token in singleVariantTokens || token.toIntOrNull()?.isCssFontWeight() == true) {
indexes += index
}
val next = getOrNull(index + 1) ?: return@forEachIndexed
if ("$token$next" in compoundVariantTokens) {
indexes += index
indexes += index + 1
}
}
return indexes
}
private fun Int.isCssFontWeight(): Boolean = this in 100..900 && this % 100 == 0
private fun List<String>.containsCompoundToken(compoundTokens: Set<String>): Boolean {
return windowed(size = 2, step = 1, partialWindows = false)
.any { (first, second) -> "$first$second" in compoundTokens }
}
private fun List<String>.detectedWeight(): FontWeight {
val compactTokens = buildList {
addAll(this@detectedWeight)
this@detectedWeight.windowed(size = 2, step = 1, partialWindows = false)
.forEach { (first, second) -> add("$first$second") }
}
return compactTokens.asSequence()
.mapNotNull { token ->
tokenWeightMap[token]
?: compoundTokenWeightMap[token]
?: token.toIntOrNull()?.takeIf { it.isCssFontWeight() }?.let(::FontWeight)
}
.maxByOrNull { it.weight }
?: FontWeight.Normal
}
private fun List<String>.detectedStyle(): FontStyle {
return if (any { it in italicTokens } || containsCompoundToken(compoundItalicTokens)) {
FontStyle.Italic
} else {
FontStyle.Normal
}
}
fun String.detectFontVariant(): FontVariant? {
val tokens = filenameTokens()
.filter { it.isNotBlank() }
if (tokens.isEmpty()) return null
return FontVariant(weight = tokens.detectedWeight(), style = tokens.detectedStyle())
}
private fun String.filenameTokens(): List<String> {
return this
.replace(Regex("""(?i)variablefont"""), " variablefont ")
.replace(Regex("""(?<=[a-z])(?=[A-Z])"""), "-")
.lowercase()
.split(filenameSeparatorsRegex)
}
private val italicTokens = setOf("italic", "ital", "oblique", "obliq", "it", "itallic", "italics", "slanted", "slant")
private val tokenWeightMap = mapOf(
"thin" to FontWeight.Thin,
"hairline" to FontWeight.Thin,
"extralight" to FontWeight.ExtraLight,
"ultralight" to FontWeight.ExtraLight,
"light" to FontWeight.Light,
"regular" to FontWeight.Normal,
"normal" to FontWeight.Normal,
"roman" to FontWeight.Normal,
"book" to FontWeight.Normal,
"medium" to FontWeight.Medium,
"semibold" to FontWeight.SemiBold,
"demibold" to FontWeight.SemiBold,
"bold" to FontWeight.Bold,
"extrabold" to FontWeight.ExtraBold,
"ultrabold" to FontWeight.ExtraBold,
"black" to FontWeight.Black,
"heavy" to FontWeight.Black
)
private val variableFontTokens = setOf(
"variablefont",
"vf",
"variable",
"wght",
"wdth",
"opsz",
"slnt",
"grad",
"xtra",
"xopq",
"yopq",
"ytlc",
"ytuc",
"ytas",
"ytde"
)
private val compoundItalicTokens = setOf("bolditalic", "boldital", "boldoblique", "boldobliq")
private val compoundWeightTokens = mapOf(
"extralight" to FontWeight.ExtraLight,
"ultralight" to FontWeight.ExtraLight,
"semibold" to FontWeight.SemiBold,
"demibold" to FontWeight.SemiBold,
"extrabold" to FontWeight.ExtraBold,
"ultrabold" to FontWeight.ExtraBold
)
private val compoundVariableFontTokens = setOf("variablefont")
private val compoundTokenWeightMap = compoundItalicTokens.associateWith { FontWeight.Bold } + compoundWeightTokens
private val singleVariantTokens = italicTokens + tokenWeightMap.keys + variableFontTokens
private val compoundVariantTokens = compoundItalicTokens + compoundWeightTokens.keys + compoundVariableFontTokens

View file

@ -239,7 +239,9 @@ internal enum class NonReaderLibraryPrimaryAction {
} }
internal enum class NonReaderBookOverflowAction { internal enum class NonReaderBookOverflowAction {
ADD_TO_SHELF ADD_TO_SHELF,
SAVE_ORIGINAL,
SHARE_ORIGINAL
} }
internal fun visibleNonReaderLibraryTabs( internal fun visibleNonReaderLibraryTabs(
@ -266,8 +268,14 @@ internal fun bookOverflowActionsForPlatform(
platform: ReaderPlatform = ReaderPlatform.ANDROID platform: ReaderPlatform = ReaderPlatform.ANDROID
): Set<NonReaderBookOverflowAction> { ): Set<NonReaderBookOverflowAction> {
return when (platform) { return when (platform) {
ReaderPlatform.DESKTOP -> setOf(NonReaderBookOverflowAction.ADD_TO_SHELF) ReaderPlatform.DESKTOP -> setOf(
ReaderPlatform.ANDROID -> emptySet() NonReaderBookOverflowAction.ADD_TO_SHELF,
NonReaderBookOverflowAction.SAVE_ORIGINAL
)
ReaderPlatform.ANDROID -> setOf(
NonReaderBookOverflowAction.SAVE_ORIGINAL,
NonReaderBookOverflowAction.SHARE_ORIGINAL
)
} }
} }

View file

@ -56,6 +56,8 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
@ -165,6 +167,8 @@ fun SharedHomeScreen(
onRemoveSelected: () -> Unit, onRemoveSelected: () -> Unit,
onShowBookInfo: (BookItem) -> Unit = {}, onShowBookInfo: (BookItem) -> Unit = {},
onEditBook: (BookItem) -> Unit = {}, onEditBook: (BookItem) -> Unit = {},
onSaveOriginalFile: (BookItem) -> Unit = {},
onShareOriginalFile: (BookItem) -> Unit = {},
onTagSelectedBooks: () -> Unit = {}, onTagSelectedBooks: () -> Unit = {},
onAddSelectedBooksToShelf: () -> Unit = {}, onAddSelectedBooksToShelf: () -> Unit = {},
onOpenTab: (BookItem) -> Unit = onOpenBook, onOpenTab: (BookItem) -> Unit = onOpenBook,
@ -173,6 +177,7 @@ fun SharedHomeScreen(
onRecentLimitChange: (Int) -> Unit = {}, onRecentLimitChange: (Int) -> Unit = {},
onTogglePinned: (BookItem) -> Unit = {}, onTogglePinned: (BookItem) -> Unit = {},
onOpenSettings: () -> Unit = {}, onOpenSettings: () -> Unit = {},
platform: ReaderPlatform = ReaderPlatform.ANDROID,
showActiveTabs: Boolean = true, showActiveTabs: Boolean = true,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -188,6 +193,16 @@ fun SharedHomeScreen(
) { ) {
state.toNonReaderHomeLayoutModel() state.toNonReaderHomeLayoutModel()
} }
val saveOriginalFileAction = if (NonReaderBookOverflowAction.SAVE_ORIGINAL in bookOverflowActionsForPlatform(platform)) {
onSaveOriginalFile
} else {
null
}
val shareOriginalFileAction = if (NonReaderBookOverflowAction.SHARE_ORIGINAL in bookOverflowActionsForPlatform(platform)) {
onShareOriginalFile
} else {
null
}
NonReaderScreenScaffold( NonReaderScreenScaffold(
title = readerString("nav_home", "Home"), title = readerString("nav_home", "Home"),
subtitle = readerString("desktop_home_subtitle", "Continue reading and recent books"), subtitle = readerString("desktop_home_subtitle", "Continue reading and recent books"),
@ -268,6 +283,8 @@ fun SharedHomeScreen(
onOpenBook = { onOpenBook(book) }, onOpenBook = { onOpenBook(book) },
onShowBookInfo = { onShowBookInfo(book) }, onShowBookInfo = { onShowBookInfo(book) },
onEditBook = { onEditBook(book) }, onEditBook = { onEditBook(book) },
onSaveOriginalFile = saveOriginalFileAction?.let { save -> { save(book) } },
onShareOriginalFile = shareOriginalFileAction?.let { share -> { share(book) } },
onTogglePinned = { onTogglePinned(book) } onTogglePinned = { onTogglePinned(book) }
) )
} }
@ -294,6 +311,8 @@ fun SharedHomeScreen(
onToggleSelection = onToggleSelection, onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo, onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook, onEditBook = onEditBook,
onSaveOriginalFile = saveOriginalFileAction,
onShareOriginalFile = shareOriginalFileAction,
onTogglePinned = onTogglePinned onTogglePinned = onTogglePinned
) )
} }
@ -309,6 +328,8 @@ fun SharedHomeScreen(
onToggleSelection = onToggleSelection, onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo, onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook, onEditBook = onEditBook,
onSaveOriginalFile = saveOriginalFileAction,
onShareOriginalFile = shareOriginalFileAction,
onTogglePinned = onTogglePinned onTogglePinned = onTogglePinned
) )
} }
@ -331,6 +352,8 @@ fun SharedLibraryScreen(
onRemoveSelected: () -> Unit, onRemoveSelected: () -> Unit,
onShowBookInfo: (BookItem) -> Unit = {}, onShowBookInfo: (BookItem) -> Unit = {},
onEditBook: (BookItem) -> Unit = {}, onEditBook: (BookItem) -> Unit = {},
onSaveOriginalFile: (BookItem) -> Unit = {},
onShareOriginalFile: (BookItem) -> Unit = {},
onCreateShelf: () -> Unit = {}, onCreateShelf: () -> Unit = {},
onCreateShelfWithBooks: (String, Set<String>) -> Unit = { _, _ -> }, onCreateShelfWithBooks: (String, Set<String>) -> Unit = { _, _ -> },
onCreateSmartShelf: () -> Unit = {}, onCreateSmartShelf: () -> Unit = {},
@ -446,6 +469,8 @@ fun SharedLibraryScreen(
onToggleSelection = onToggleSelection, onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo, onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook, onEditBook = onEditBook,
onSaveOriginalFile = onSaveOriginalFile,
onShareOriginalFile = onShareOriginalFile,
onTogglePinned = onTogglePinned, onTogglePinned = onTogglePinned,
onAddBooksToShelf = onAddBooksToShelf, onAddBooksToShelf = onAddBooksToShelf,
onManageShelfBooks = onManageShelfBooks, onManageShelfBooks = onManageShelfBooks,
@ -495,6 +520,8 @@ fun SharedLibraryScreen(
onToggleSelection = onToggleSelection, onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo, onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook, onEditBook = onEditBook,
onSaveOriginalFile = onSaveOriginalFile,
onShareOriginalFile = onShareOriginalFile,
onTogglePinned = onTogglePinned, onTogglePinned = onTogglePinned,
onAddBooksToShelf = onAddBooksToShelf, onAddBooksToShelf = onAddBooksToShelf,
onManageShelfBooks = onManageShelfBooks, onManageShelfBooks = onManageShelfBooks,
@ -601,8 +628,11 @@ private fun ContinueReadingCard(
onOpenBook: () -> Unit, onOpenBook: () -> Unit,
onShowBookInfo: () -> Unit, onShowBookInfo: () -> Unit,
onEditBook: () -> Unit, onEditBook: () -> Unit,
onSaveOriginalFile: (() -> Unit)?,
onShareOriginalFile: (() -> Unit)?,
onTogglePinned: () -> Unit onTogglePinned: () -> Unit
) { ) {
val canUseOriginalFileActions = !book.isOpdsStream() && book.path != null
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(SharedUiTokens.surfaceRadius), shape = RoundedCornerShape(SharedUiTokens.surfaceRadius),
@ -643,6 +673,16 @@ private fun ContinueReadingCard(
IconButton(onClick = onEditBook) { IconButton(onClick = onEditBook) {
Icon(Icons.Default.Edit, contentDescription = readerString("action_edit", "Edit")) Icon(Icons.Default.Edit, contentDescription = readerString("action_edit", "Edit"))
} }
if (canUseOriginalFileActions && onSaveOriginalFile != null) {
IconButton(onClick = onSaveOriginalFile) {
Icon(Icons.Default.Save, contentDescription = readerString("action_save_copy_to_device", "Save copy to device"))
}
}
if (canUseOriginalFileActions && onShareOriginalFile != null) {
IconButton(onClick = onShareOriginalFile) {
Icon(Icons.Default.Share, contentDescription = readerString("action_share", "Share"))
}
}
} }
} }
} }
@ -659,6 +699,8 @@ private fun HomeBookShelf(
onToggleSelection: (String) -> Unit, onToggleSelection: (String) -> Unit,
onShowBookInfo: (BookItem) -> Unit, onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit,
onSaveOriginalFile: ((BookItem) -> Unit)?,
onShareOriginalFile: ((BookItem) -> Unit)?,
onTogglePinned: (BookItem) -> Unit onTogglePinned: (BookItem) -> Unit
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
@ -674,6 +716,8 @@ private fun HomeBookShelf(
onToggleSelection = { onToggleSelection(book.id) }, onToggleSelection = { onToggleSelection(book.id) },
onShowInfo = { onShowBookInfo(book) }, onShowInfo = { onShowBookInfo(book) },
onEdit = { onEditBook(book) }, onEdit = { onEditBook(book) },
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
onTogglePinned = { onTogglePinned(book) }, onTogglePinned = { onTogglePinned(book) },
modifier = Modifier.width(168.dp) modifier = Modifier.width(168.dp)
) )
@ -1226,6 +1270,8 @@ private fun LibraryContent(
onToggleSelection: (String) -> Unit, onToggleSelection: (String) -> Unit,
onShowBookInfo: (BookItem) -> Unit, onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit,
onSaveOriginalFile: (BookItem) -> Unit,
onShareOriginalFile: (BookItem) -> Unit,
onTogglePinned: (BookItem) -> Unit, onTogglePinned: (BookItem) -> Unit,
onAddBooksToShelf: (Set<String>) -> Unit, onAddBooksToShelf: (Set<String>) -> Unit,
onManageShelfBooks: ((Shelf) -> Unit)?, onManageShelfBooks: ((Shelf) -> Unit)?,
@ -1262,6 +1308,16 @@ private fun LibraryContent(
} else { } else {
null null
} }
val saveOriginalFileAction = if (NonReaderBookOverflowAction.SAVE_ORIGINAL in bookOverflowActionsForPlatform(platform)) {
onSaveOriginalFile
} else {
null
}
val shareOriginalFileAction = if (NonReaderBookOverflowAction.SHARE_ORIGINAL in bookOverflowActionsForPlatform(platform)) {
onShareOriginalFile
} else {
null
}
val manageShelfBooksAction = if (platform == ReaderPlatform.DESKTOP) onManageShelfBooks else null val manageShelfBooksAction = if (platform == ReaderPlatform.DESKTOP) onManageShelfBooks else null
val showNewShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in val showNewShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in
primaryLibraryActionsForTab(selectedTab, platform) primaryLibraryActionsForTab(selectedTab, platform)
@ -1314,6 +1370,8 @@ private fun LibraryContent(
onToggleSelection = onToggleSelection, onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo, onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook, onEditBook = onEditBook,
onSaveOriginalFile = saveOriginalFileAction,
onShareOriginalFile = shareOriginalFileAction,
onTogglePinned = onTogglePinned, onTogglePinned = onTogglePinned,
onAddToShelf = addToShelfFromBookAction?.let { addToShelf -> { book -> addToShelf(setOf(book.id)) } }, onAddToShelf = addToShelfFromBookAction?.let { addToShelf -> { book -> addToShelf(setOf(book.id)) } },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@ -1347,6 +1405,8 @@ private fun LibraryContent(
onToggleSelection = onToggleSelection, onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo, onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook, onEditBook = onEditBook,
onSaveOriginalFile = saveOriginalFileAction,
onShareOriginalFile = shareOriginalFileAction,
onTogglePinned = onTogglePinned, onTogglePinned = onTogglePinned,
onAddBooksToShelf = addToShelfFromBookAction, onAddBooksToShelf = addToShelfFromBookAction,
onManageShelfBooks = manageShelfBooksAction, onManageShelfBooks = manageShelfBooksAction,
@ -1370,6 +1430,8 @@ private fun LibraryContent(
onToggleSelection = onToggleSelection, onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo, onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook, onEditBook = onEditBook,
onSaveOriginalFile = saveOriginalFileAction,
onShareOriginalFile = shareOriginalFileAction,
onTogglePinned = onTogglePinned, onTogglePinned = onTogglePinned,
onAddBooksToShelf = addToShelfFromBookAction, onAddBooksToShelf = addToShelfFromBookAction,
onRenameShelf = onRenameShelf, onRenameShelf = onRenameShelf,
@ -1387,6 +1449,8 @@ private fun LibraryContent(
onToggleSelection = onToggleSelection, onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo, onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook, onEditBook = onEditBook,
onSaveOriginalFile = saveOriginalFileAction,
onShareOriginalFile = shareOriginalFileAction,
onTogglePinned = onTogglePinned, onTogglePinned = onTogglePinned,
onAddBooksToShelf = addToShelfFromBookAction, onAddBooksToShelf = addToShelfFromBookAction,
emptyTitle = readerString("desktop_no_tags_yet", "No tags yet"), emptyTitle = readerString("desktop_no_tags_yet", "No tags yet"),
@ -1409,6 +1473,8 @@ private fun LibraryContent(
onToggleSelection = onToggleSelection, onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo, onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook, onEditBook = onEditBook,
onSaveOriginalFile = saveOriginalFileAction,
onShareOriginalFile = shareOriginalFileAction,
onTogglePinned = onTogglePinned, onTogglePinned = onTogglePinned,
onAddBooksToShelf = addToShelfFromBookAction, onAddBooksToShelf = addToShelfFromBookAction,
onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) }, onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) },
@ -1431,6 +1497,8 @@ private fun LibraryContent(
onToggleSelection = onToggleSelection, onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo, onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook, onEditBook = onEditBook,
onSaveOriginalFile = saveOriginalFileAction,
onShareOriginalFile = shareOriginalFileAction,
onTogglePinned = onTogglePinned, onTogglePinned = onTogglePinned,
onAddBooksToShelf = addToShelfFromBookAction, onAddBooksToShelf = addToShelfFromBookAction,
onRemoveFolder = onRemoveFolder, onRemoveFolder = onRemoveFolder,
@ -1690,6 +1758,8 @@ private fun BookGrid(
onShowBookInfo: (BookItem) -> Unit, onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit,
onTogglePinned: (BookItem) -> Unit, onTogglePinned: (BookItem) -> Unit,
onSaveOriginalFile: ((BookItem) -> Unit)? = null,
onShareOriginalFile: ((BookItem) -> Unit)? = null,
onAddToShelf: ((BookItem) -> Unit)? = null, onAddToShelf: ((BookItem) -> Unit)? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -1710,6 +1780,8 @@ private fun BookGrid(
onShowInfo = { onShowBookInfo(book) }, onShowInfo = { onShowBookInfo(book) },
onEdit = { onEditBook(book) }, onEdit = { onEditBook(book) },
onTogglePinned = { onTogglePinned(book) }, onTogglePinned = { onTogglePinned(book) },
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } } onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
) )
} }
@ -1733,6 +1805,8 @@ private fun BookGrid(
onShowInfo = { onShowBookInfo(book) }, onShowInfo = { onShowBookInfo(book) },
onEdit = { onEditBook(book) }, onEdit = { onEditBook(book) },
onTogglePinned = { onTogglePinned(book) }, onTogglePinned = { onTogglePinned(book) },
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } } onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
) )
} }
@ -1752,6 +1826,8 @@ private fun BookTile(
onShowInfo: () -> Unit, onShowInfo: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onTogglePinned: () -> Unit, onTogglePinned: () -> Unit,
onSaveOriginalFile: (() -> Unit)? = null,
onShareOriginalFile: (() -> Unit)? = null,
onAddToShelf: (() -> Unit)? = null, onAddToShelf: (() -> Unit)? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -1803,6 +1879,8 @@ private fun BookTile(
onShowInfo = onShowInfo, onShowInfo = onShowInfo,
onEdit = onEdit, onEdit = onEdit,
onToggleSelection = onToggleSelection, onToggleSelection = onToggleSelection,
onSaveOriginalFile = onSaveOriginalFile.takeIf { !book.isOpdsStream() && book.path != null },
onShareOriginalFile = onShareOriginalFile.takeIf { !book.isOpdsStream() && book.path != null },
onAddToShelf = onAddToShelf onAddToShelf = onAddToShelf
) )
} }
@ -1839,6 +1917,8 @@ private fun BookListItem(
onShowInfo: () -> Unit, onShowInfo: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onTogglePinned: () -> Unit, onTogglePinned: () -> Unit,
onSaveOriginalFile: (() -> Unit)? = null,
onShareOriginalFile: (() -> Unit)? = null,
onAddToShelf: (() -> Unit)? = null onAddToShelf: (() -> Unit)? = null
) { ) {
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
@ -1881,6 +1961,8 @@ private fun BookListItem(
onShowInfo = onShowInfo, onShowInfo = onShowInfo,
onEdit = onEdit, onEdit = onEdit,
onToggleSelection = onToggleSelection, onToggleSelection = onToggleSelection,
onSaveOriginalFile = onSaveOriginalFile.takeIf { !book.isOpdsStream() && book.path != null },
onShareOriginalFile = onShareOriginalFile.takeIf { !book.isOpdsStream() && book.path != null },
onAddToShelf = onAddToShelf onAddToShelf = onAddToShelf
) )
} }
@ -1898,6 +1980,8 @@ private fun BookActionMenu(
onShowInfo: () -> Unit, onShowInfo: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onToggleSelection: () -> Unit, onToggleSelection: () -> Unit,
onSaveOriginalFile: (() -> Unit)? = null,
onShareOriginalFile: (() -> Unit)? = null,
onAddToShelf: (() -> Unit)? = null onAddToShelf: (() -> Unit)? = null
) { ) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
@ -1925,6 +2009,26 @@ private fun BookActionMenu(
onEdit() onEdit()
} }
) )
if (onSaveOriginalFile != null) {
DropdownMenuItem(
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) },
text = { Text(readerString("action_save_copy_to_device", "Save copy to device")) },
onClick = {
onDismiss()
onSaveOriginalFile()
}
)
}
if (onShareOriginalFile != null) {
DropdownMenuItem(
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
text = { Text(readerString("action_share", "Share")) },
onClick = {
onDismiss()
onShareOriginalFile()
}
)
}
if (onAddToShelf != null) { if (onAddToShelf != null) {
DropdownMenuItem( DropdownMenuItem(
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) },
@ -2112,6 +2216,8 @@ private fun ShelfCollection(
onShowBookInfo: (BookItem) -> Unit, onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit,
onTogglePinned: (BookItem) -> Unit, onTogglePinned: (BookItem) -> Unit,
onSaveOriginalFile: ((BookItem) -> Unit)? = null,
onShareOriginalFile: ((BookItem) -> Unit)? = null,
onAddBooksToShelf: ((Set<String>) -> Unit)? = null, onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
onManageShelfBooks: ((Shelf) -> Unit)? = null, onManageShelfBooks: ((Shelf) -> Unit)? = null,
onRenameShelf: (Shelf) -> Unit = {}, onRenameShelf: (Shelf) -> Unit = {},
@ -2151,6 +2257,8 @@ private fun ShelfCollection(
onShowBookInfo = onShowBookInfo, onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook, onEditBook = onEditBook,
onTogglePinned = onTogglePinned, onTogglePinned = onTogglePinned,
onSaveOriginalFile = onSaveOriginalFile,
onShareOriginalFile = onShareOriginalFile,
onAddBooksToShelf = onAddBooksToShelf, onAddBooksToShelf = onAddBooksToShelf,
onManageShelfBooks = onManageShelfBooks, onManageShelfBooks = onManageShelfBooks,
onRenameShelf = onRenameShelf, onRenameShelf = onRenameShelf,
@ -2172,6 +2280,8 @@ private fun ShelfSection(
onShowBookInfo: (BookItem) -> Unit, onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit,
onTogglePinned: (BookItem) -> Unit, onTogglePinned: (BookItem) -> Unit,
onSaveOriginalFile: ((BookItem) -> Unit)?,
onShareOriginalFile: ((BookItem) -> Unit)?,
onAddBooksToShelf: ((Set<String>) -> Unit)?, onAddBooksToShelf: ((Set<String>) -> Unit)?,
onManageShelfBooks: ((Shelf) -> Unit)?, onManageShelfBooks: ((Shelf) -> Unit)?,
onRenameShelf: (Shelf) -> Unit, onRenameShelf: (Shelf) -> Unit,
@ -2258,6 +2368,8 @@ private fun ShelfSection(
onShowInfo = { onShowBookInfo(book) }, onShowInfo = { onShowBookInfo(book) },
onEdit = { onEditBook(book) }, onEdit = { onEditBook(book) },
onTogglePinned = { onTogglePinned(book) }, onTogglePinned = { onTogglePinned(book) },
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } }, onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } },
modifier = Modifier.width(148.dp) modifier = Modifier.width(148.dp)
) )
@ -2279,6 +2391,8 @@ private fun FolderShelfDetail(
onShowBookInfo: (BookItem) -> Unit, onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit,
onTogglePinned: (BookItem) -> Unit, onTogglePinned: (BookItem) -> Unit,
onSaveOriginalFile: ((BookItem) -> Unit)? = null,
onShareOriginalFile: ((BookItem) -> Unit)? = null,
onAddBooksToShelf: ((Set<String>) -> Unit)? = null, onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
onOpenShelf: (Shelf) -> Unit, onOpenShelf: (Shelf) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
@ -2345,6 +2459,8 @@ private fun FolderShelfDetail(
onShowInfo = { onShowBookInfo(book) }, onShowInfo = { onShowBookInfo(book) },
onEdit = { onEditBook(book) }, onEdit = { onEditBook(book) },
onTogglePinned = { onTogglePinned(book) }, onTogglePinned = { onTogglePinned(book) },
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } } onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } }
) )
} }

View file

@ -1663,13 +1663,9 @@ private fun List<ReaderPage>.findSharedNativeVerticalPageIndexForBlock(block: Se
return firstOrNull()?.pageIndex return firstOrNull()?.pageIndex
} }
private fun List<SharedNativeVerticalFlowItem>.sharedNativeVerticalItemIndexForLocator( internal fun List<SharedNativeVerticalFlowItem>.sharedNativeVerticalItemIndexForLocator(
locator: ReaderLocator locator: ReaderLocator
): Int? { ): Int? {
locator.pageIndex?.let { pageIndex ->
val samePage = indexOfFirst { item -> item.page.pageIndex == pageIndex }
if (samePage >= 0) return samePage
}
val chapterIndex = locator.chapterIndex val chapterIndex = locator.chapterIndex
if (chapterIndex != null) { if (chapterIndex != null) {
locator.blockIndex?.let { blockIndex -> locator.blockIndex?.let { blockIndex ->
@ -1687,6 +1683,12 @@ private fun List<SharedNativeVerticalFlowItem>.sharedNativeVerticalItemIndexForL
} }
if (sameOffset >= 0) return sameOffset if (sameOffset >= 0) return sameOffset
} }
}
locator.pageIndex?.let { pageIndex ->
val samePage = indexOfFirst { item -> item.page.pageIndex == pageIndex }
if (samePage >= 0) return samePage
}
if (chapterIndex != null) {
val sameChapter = indexOfFirst { item -> item.page.chapterIndex == chapterIndex } val sameChapter = indexOfFirst { item -> item.page.chapterIndex == chapterIndex }
if (sameChapter >= 0) return sameChapter if (sameChapter >= 0) return sameChapter
} }
@ -3170,7 +3172,7 @@ private fun SemanticTextBlock.renderedTextStyle(
).takeIf { it.isSpecified } ?: foreground, ).takeIf { it.isSpecified } ?: foreground,
fontSize = fontSize, fontSize = fontSize,
lineHeight = lineHeight, lineHeight = lineHeight,
fontFamily = fallbackFontFamily, fontFamily = style.spanStyle.fontFamily ?: fallbackFontFamily,
fontWeight = fontWeight fontWeight = fontWeight
?: style.spanStyle.fontWeight ?: style.spanStyle.fontWeight
?: if (this is SemanticHeader) FontWeight.Bold else MaterialTheme.typography.bodyLarge.fontWeight, ?: if (this is SemanticHeader) FontWeight.Bold else MaterialTheme.typography.bodyLarge.fontWeight,

View file

@ -117,6 +117,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.aryan.reader.shared.BuiltInReaderThemes import com.aryan.reader.shared.BuiltInReaderThemes
import com.aryan.reader.shared.CustomFontItem import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.fontFaceSummary
import com.aryan.reader.shared.groupByFamily
import com.aryan.reader.shared.hasVariableWeightFace
import com.aryan.reader.shared.HighlightColor import com.aryan.reader.shared.HighlightColor
import com.aryan.reader.shared.PageInfoMode import com.aryan.reader.shared.PageInfoMode
import com.aryan.reader.shared.PageInfoPosition import com.aryan.reader.shared.PageInfoPosition
@ -1740,28 +1743,46 @@ fun SharedReaderFormatControls(
} }
} }
val activeCustomFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() } val activeCustomFontFamilies = customFonts.filterNot { it.isDeleted }.groupByFamily()
if (activeCustomFonts.isNotEmpty()) { if (activeCustomFontFamilies.isNotEmpty()) {
Text( Text(
readerString("desktop_imported_fonts", "Imported fonts"), readerString("desktop_imported_fonts", "Imported fonts"),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
SharedReaderChoiceRow { SharedReaderChoiceRow {
activeCustomFonts.forEach { font -> activeCustomFontFamilies.forEach { family ->
val isSelected = family.variants.any { it.font.path == settings.customFontPath }
FilterChip( FilterChip(
selected = settings.customFontPath == font.path, selected = isSelected,
onClick = { onClick = {
val baseFont = family.variants.firstOrNull { it.variant?.weight == FontWeight.Normal && it.variant?.style == androidx.compose.ui.text.font.FontStyle.Normal }?.font ?: family.variants.first().font
onReaderAction( onReaderAction(
ReaderAction.SettingsChanged( ReaderAction.SettingsChanged(
settings.copy( settings.copy(
fontFamily = font.displayName, fontFamily = family.familyName,
customFontPath = font.path customFontPath = baseFont.path
) )
) )
) )
}, },
label = { Text(font.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) } label = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(family.familyName, maxLines = 1, overflow = TextOverflow.Ellipsis)
val variantsStr = buildString {
append(family.fontFaceSummary())
if (family.hasVariableWeightFace()) append(" - Variable weight")
}
if (variantsStr.isNotBlank()) {
Text(
"($variantsStr)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1
)
}
}
}
) )
} }
} }

View file

@ -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()
)
}
}

View file

@ -74,12 +74,21 @@ class NonReaderLayoutModelsTest {
} }
@Test @Test
fun `desktop book overflow exposes add to shelf action without changing android`() { fun `book overflow exposes platform save and share actions`() {
assertEquals( assertEquals(
setOf(NonReaderBookOverflowAction.ADD_TO_SHELF), setOf(
NonReaderBookOverflowAction.ADD_TO_SHELF,
NonReaderBookOverflowAction.SAVE_ORIGINAL
),
bookOverflowActionsForPlatform(ReaderPlatform.DESKTOP) bookOverflowActionsForPlatform(ReaderPlatform.DESKTOP)
) )
assertEquals(emptySet<NonReaderBookOverflowAction>(), bookOverflowActionsForPlatform(ReaderPlatform.ANDROID)) assertEquals(
setOf(
NonReaderBookOverflowAction.SAVE_ORIGINAL,
NonReaderBookOverflowAction.SHARE_ORIGINAL
),
bookOverflowActionsForPlatform(ReaderPlatform.ANDROID)
)
} }
@Test @Test

View file

@ -12,6 +12,7 @@ import com.aryan.reader.paginatedreader.SemanticImage
import com.aryan.reader.paginatedreader.SemanticMath import com.aryan.reader.paginatedreader.SemanticMath
import com.aryan.reader.paginatedreader.SemanticParagraph import com.aryan.reader.paginatedreader.SemanticParagraph
import com.aryan.reader.paginatedreader.SemanticWrappingBlock import com.aryan.reader.paginatedreader.SemanticWrappingBlock
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.reader.ReaderPage import com.aryan.reader.shared.reader.ReaderPage
import com.aryan.reader.shared.reader.SharedEpubBook import com.aryan.reader.shared.reader.SharedEpubBook
import com.aryan.reader.shared.reader.SharedEpubChapter import com.aryan.reader.shared.reader.SharedEpubChapter
@ -192,6 +193,57 @@ class SharedNativeVerticalReaderFlowTest {
assertEquals(Color.Red, paragraphItem.style.blockStyle.borderTop?.color) assertEquals(Color.Red, paragraphItem.style.blockStyle.borderTop?.color)
} }
@Test
fun `shared native vertical restore prefers block locator before compat page`() {
val first = SemanticParagraph(
text = "First paragraph",
spans = emptyList(),
style = CssStyle(),
elementId = "p1",
cfi = "/4/2",
startCharOffsetInSource = 0,
blockIndex = 1
)
val second = SemanticParagraph(
text = "Second paragraph",
spans = emptyList(),
style = CssStyle(),
elementId = "p2",
cfi = "/4/4",
startCharOffsetInSource = 16,
blockIndex = 2
)
val book = SharedEpubBook(
id = "book",
fileName = "book.epub",
title = "Book",
chapters = listOf(
SharedEpubChapter(
id = "chapter_0",
title = "Chapter",
plainText = "First paragraph\nSecond paragraph",
semanticBlocks = listOf(first, second)
)
)
)
val items = buildSharedNativeVerticalFlowItems(book, pages = emptyList())
val restoredIndex = items.sharedNativeVerticalItemIndexForLocator(
ReaderLocator(
chapterIndex = 0,
pageIndex = 0,
startOffset = 16,
endOffset = 32,
blockIndex = 2,
charOffset = 16,
cfi = "/4/4:0"
)
)
assertEquals(1, restoredIndex)
assertEquals(2, items[restoredIndex!!].block?.blockIndex)
}
@Test @Test
fun `svg math blocks stay in native vertical flow`() { fun `svg math blocks stay in native vertical flow`() {
val math = SemanticMath( val math = SemanticMath(

View file

@ -1,6 +1,8 @@
package com.aryan.reader.paginatedreader package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -96,15 +98,53 @@ class HtmlParserLinkTest {
}) })
} }
private fun parse(html: String): List<SemanticBlock> { @Test
fun `css font family resolves onto block and inline span styles`() {
val cssRules = CssParser.parse(
cssContent = """
p { font-family: "BodyFace"; }
i { font-style: italic; }
""".trimIndent(),
cssPath = null,
baseFontSizeSp = 16f,
density = 1f,
constraints = Constraints(maxWidth = 400, maxHeight = 800),
isDarkTheme = false
).rules
val blocks = parse(
html = """
<html>
<body>
<p>plain <i>italic</i></p>
</body>
</html>
""".trimIndent(),
cssRules = cssRules,
fontFamilyMap = mapOf("bodyface" to FontFamily.Serif)
)
val paragraph = blocks.single() as SemanticParagraph
val italicSpan = paragraph.spans.single { it.tag == "i" }
assertEquals(FontFamily.Serif, paragraph.style.spanStyle.fontFamily)
assertEquals(FontFamily.Serif, italicSpan.style.spanStyle.fontFamily)
assertEquals(FontStyle.Italic, italicSpan.style.spanStyle.fontStyle)
}
private fun parse(
html: String,
cssRules: OptimizedCssRules = OptimizedCssRules(),
fontFamilyMap: Map<String, FontFamily> = emptyMap()
): List<SemanticBlock> {
return htmlToSemanticBlocks( return htmlToSemanticBlocks(
html = html, html = html,
cssRules = OptimizedCssRules(), cssRules = cssRules,
textStyle = TextStyle(fontSize = 16.sp), textStyle = TextStyle(fontSize = 16.sp),
chapterAbsPath = "OEBPS/chapter1.xhtml", chapterAbsPath = "OEBPS/chapter1.xhtml",
extractionBasePath = "", extractionBasePath = "",
density = Density(1f), density = Density(1f),
fontFamilyMap = emptyMap(), fontFamilyMap = fontFamilyMap,
constraints = Constraints(maxWidth = 400, maxHeight = 800) constraints = Constraints(maxWidth = 400, maxHeight = 800)
) )
} }

View file

@ -218,6 +218,48 @@ class SharedEpubPaginationCacheTest {
} }
} }
@Test
fun `saving more than three configurations removes oldest page cache`() = runBlocking {
val root = Files.createTempDirectory("reader-page-cache").toFile()
try {
val book = cacheBook()
val settings = ReaderSettings()
val pages = listOf(
ReaderPage(
pageIndex = 0,
chapterIndex = 0,
chapterTitle = "One",
text = "Cached page",
startOffset = 0,
endOffset = 11
)
)
val viewports = listOf(
ReaderViewportSpec(widthPx = 900, heightPx = 700),
ReaderViewportSpec(widthPx = 901, heightPx = 700),
ReaderViewportSpec(widthPx = 902, heightPx = 700),
ReaderViewportSpec(widthPx = 903, heightPx = 700)
)
val writer = SharedEpubPaginationCache(root)
viewports.take(3).forEachIndexed { index, viewport ->
writer.save(book, settings, viewport, pages)
val key = writer.keyFor(book, settings, viewport)
val file = root
.resolve(key.bookHash)
.resolve("${key.configHash.toUInt().toString(16)}.pages.pb")
file.setLastModified((index + 1) * 1_000L)
}
writer.save(book, settings, viewports.last(), pages)
val reader = SharedEpubPaginationCache(root)
assertNull(reader.load(book, settings, viewports.first()))
assertNotNull(reader.load(book, settings, viewports.last()))
} finally {
root.deleteRecursively()
}
}
private fun cacheBook(): SharedEpubBook { private fun cacheBook(): SharedEpubBook {
return SharedEpubBook( return SharedEpubBook(
id = "book-id", id = "book-id",

View file

@ -223,6 +223,22 @@ class SharedJvmBookLoaderTest {
assertTrue(!css.contains("data:font/woff2")) assertTrue(!css.contains("data:font/woff2"))
} }
@Test
fun `epub loader uses spine toc id when manifest contains volume ncx files first`() = withTempDir { dir ->
val file = File(dir, "merged-volumes.epub")
writeMergedVolumeTocEpub(file)
val book = SharedJvmBookLoader.loadEpub(file)
assertEquals(
listOf("Volume 1", "Chapter 1", "Volume 2", "Chapter 2"),
book.tableOfContents.map { it.label }
)
assertEquals(listOf(0, 1, 0, 1), book.tableOfContents.map { it.depth })
assertEquals("2/title.xhtml", book.tableOfContents[2].href)
assertEquals(4, book.chapters.size)
}
private fun withTempDir(block: (File) -> Unit) { private fun withTempDir(block: (File) -> Unit) {
val dir = Files.createTempDirectory("reader-shared-loader").toFile() val dir = Files.createTempDirectory("reader-shared-loader").toFile()
try { try {
@ -305,6 +321,70 @@ class SharedJvmBookLoaderTest {
} }
} }
private fun writeMergedVolumeTocEpub(file: File) {
writeZip(file) {
text(
"META-INF/container.xml",
"""
<container>
<rootfiles>
<rootfile full-path="content.opf"/>
</rootfiles>
</container>
""".trimIndent()
)
text(
"content.opf",
"""
<package>
<metadata>
<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Merged Volumes</dc:title>
</metadata>
<manifest>
<item id="v1title" href="1/title.xhtml" media-type="application/xhtml+xml"/>
<item id="v1c1" href="1/chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="v2title" href="2/title.xhtml" media-type="application/xhtml+xml"/>
<item id="v2c1" href="2/chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="v1ncx" href="1/toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
</manifest>
<spine toc="ncx">
<itemref idref="v1title"/>
<itemref idref="v1c1"/>
<itemref idref="v2title"/>
<itemref idref="v2c1"/>
</spine>
</package>
""".trimIndent()
)
text(
"1/toc.ncx",
"""
<ncx><navMap>
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="title.xhtml"/></navPoint>
</navMap></ncx>
""".trimIndent()
)
text(
"toc.ncx",
"""
<ncx><navMap>
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="1/title.xhtml"/>
<navPoint><navLabel><text>Chapter 1</text></navLabel><content src="1/chapter1.xhtml"/></navPoint>
</navPoint>
<navPoint><navLabel><text>Volume 2</text></navLabel><content src="2/title.xhtml"/>
<navPoint><navLabel><text>Chapter 2</text></navLabel><content src="2/chapter1.xhtml"/></navPoint>
</navPoint>
</navMap></ncx>
""".trimIndent()
)
text("1/title.xhtml", "<html><body><h1>Volume 1</h1><p>Volume one.</p></body></html>")
text("1/chapter1.xhtml", "<html><body><h1>Chapter 1</h1><p>Chapter one.</p></body></html>")
text("2/title.xhtml", "<html><body><h1>Volume 2</h1><p>Volume two.</p></body></html>")
text("2/chapter1.xhtml", "<html><body><h1>Chapter 2</h1><p>Chapter two.</p></body></html>")
}
}
private fun writeTwoChapterEpub(file: File) { private fun writeTwoChapterEpub(file: File) {
writeZip(file) { writeZip(file) {
text( text(

View file

@ -213,7 +213,7 @@ private class SemanticHtmlParser(
} }
val body = document.body() val body = document.body()
return parseContainer(body, getElementStyle(body)) return parseContainer(body, getElementStyle(body).withResolvedFontFamily())
} }
private inline fun Element.anyChildElement(predicate: (Element) -> Boolean): Boolean { private inline fun Element.anyChildElement(predicate: (Element) -> Boolean): Boolean {
@ -295,7 +295,7 @@ private class SemanticHtmlParser(
textEmphasis = elementOwnStyle.textEmphasis ?: inheritedStyle.textEmphasis, textEmphasis = elementOwnStyle.textEmphasis ?: inheritedStyle.textEmphasis,
whiteSpace = elementOwnStyle.whiteSpace ?: inheritedStyle.whiteSpace, whiteSpace = elementOwnStyle.whiteSpace ?: inheritedStyle.whiteSpace,
customProperties = inheritedStyle.customProperties + elementOwnStyle.customProperties customProperties = inheritedStyle.customProperties + elementOwnStyle.customProperties
) ).withResolvedFontFamily()
if (finalStyle.display == "none") return emptyList() if (finalStyle.display == "none") return emptyList()
@ -364,7 +364,19 @@ private class SemanticHtmlParser(
val pseudoStyle = rulesForElement(element, pseudoElement).fold(CssStyle()) { acc, rule -> val pseudoStyle = rulesForElement(element, pseudoElement).fold(CssStyle()) { acc, rule ->
acc.merge(rule.style) acc.merge(rule.style)
} }
return inheritedStyle.merge(pseudoStyle) return inheritedStyle.merge(pseudoStyle).withResolvedFontFamily()
}
private fun CssStyle.withResolvedFontFamily(): CssStyle {
if (spanStyle.fontFamily != null) return this
val resolvedFontFamily = fontFamilies.asSequence()
.mapNotNull { name ->
val normalized = name.trim().lowercase()
currentFontFamilyMap[normalized] ?: FontFamilyMapper.nameToFontFamily(normalized)
}
.firstOrNull()
?: return this
return copy(spanStyle = spanStyle.copy(fontFamily = resolvedFontFamily))
} }
private fun firstCssUrl(value: String): String? { private fun firstCssUrl(value: String): String? {
@ -805,7 +817,7 @@ private class SemanticHtmlParser(
appendText("\n"); return appendText("\n"); return
} }
val currentElementStyle = getElementStyle(node, inheritedStyle.customProperties) val currentElementStyle = getElementStyle(node, inheritedStyle.customProperties)
val newStyle = inheritedStyle.merge(currentElementStyle) val newStyle = inheritedStyle.merge(currentElementStyle).withResolvedFontFamily()
if (newStyle.display == "none") return if (newStyle.display == "none") return
val tag = node.tagName().lowercase() val tag = node.tagName().lowercase()
val href = node.linkHrefOrNull() val href = node.linkHrefOrNull()
@ -969,7 +981,10 @@ private class SemanticHtmlParser(
val isOrdered = listElement.tagName().lowercase() == "ol" val isOrdered = listElement.tagName().lowercase() == "ol"
val items = listElement.children().mapNotNull { child -> val items = listElement.children().mapNotNull { child ->
if (child.tagName().lowercase() != "li") return@mapNotNull null if (child.tagName().lowercase() != "li") return@mapNotNull null
val itemStyle = listStyle.merge(getElementStyle(child, listStyle.customProperties)).withResolvedBlockResources() val itemStyle = listStyle
.merge(getElementStyle(child, listStyle.customProperties))
.withResolvedFontFamily()
.withResolvedBlockResources()
val (text, spans) = buildSemanticTextAndSpans(child, itemStyle, inheritedLinkHref) val (text, spans) = buildSemanticTextAndSpans(child, itemStyle, inheritedLinkHref)
val imageSrc = itemStyle.blockStyle.listStyleImage?.let { resolveImagePath(it) } val imageSrc = itemStyle.blockStyle.listStyleImage?.let { resolveImagePath(it) }
SemanticListItem(text, spans, itemStyle, child.id().ifBlank { null }, child.getCfiPath(), 0, imageSrc, blockIndex = nextBlockIndex++) SemanticListItem(text, spans, itemStyle, child.id().ifBlank { null }, child.getCfiPath(), 0, imageSrc, blockIndex = nextBlockIndex++)
@ -990,7 +1005,9 @@ private class SemanticHtmlParser(
val tagName = cellElement.tagName().lowercase() val tagName = cellElement.tagName().lowercase()
if (tagName !in listOf("td", "th")) return@mapNotNull null if (tagName !in listOf("td", "th")) return@mapNotNull null
var cellCssStyle = getElementStyle(cellElement, rowStyle.customProperties).withResolvedBlockResources() var cellCssStyle = getElementStyle(cellElement, rowStyle.customProperties)
.withResolvedFontFamily()
.withResolvedBlockResources()
if (cellCssStyle.display == "none") return@mapNotNull null if (cellCssStyle.display == "none") return@mapNotNull null
if (!cellCssStyle.blockStyle.backgroundColor.isSpecified) { if (!cellCssStyle.blockStyle.backgroundColor.isSpecified) {

View file

@ -383,20 +383,29 @@ class SharedEpubPaginationCache(
private fun cleanupOldConfigurations(bookHash: String) { private fun cleanupOldConfigurations(bookHash: String) {
val bookDir = File(cacheRoot, bookHash) val bookDir = File(cacheRoot, bookHash)
val files = bookDir.listFiles { file -> file.isFile && file.name.endsWith(".pages.pb") } val files = pageCacheFiles(bookDir)
?.sortedByDescending { it.lastModified() }
.orEmpty()
files.drop(3).forEach { file -> files.drop(3).forEach { file ->
file.delete() file.delete()
File(bookDir, file.name.removeSuffix(".pages.pb") + ".chapters").deleteRecursively() File(bookDir, file.name.removeSuffix(".pages.pb") + ".chapters").deleteRecursively()
} }
val activeConfigNames = files.take(3).map { it.name.removeSuffix(".pages.pb") }.toSet() val activeConfigNames = files.take(3).map { it.name.removeSuffix(".pages.pb") }.toSet()
bookDir.listFiles { file -> file.isDirectory && file.name.endsWith(".chapters") } chapterCacheDirs(bookDir)
.orEmpty()
.filterNot { dir -> dir.name.removeSuffix(".chapters") in activeConfigNames } .filterNot { dir -> dir.name.removeSuffix(".chapters") in activeConfigNames }
.forEach { it.deleteRecursively() } .forEach { it.deleteRecursively() }
} }
private fun pageCacheFiles(bookDir: File): List<File> {
val files = bookDir.listFiles() ?: return emptyList()
return files
.filter { file -> file.isFile && file.name.endsWith(".pages.pb") }
.sortedByDescending { file -> file.lastModified() }
}
private fun chapterCacheDirs(bookDir: File): List<File> {
val files = bookDir.listFiles() ?: return emptyList()
return files.filter { file -> file.isDirectory && file.name.endsWith(".chapters") }
}
private fun CachedReaderPages.matches(key: SharedEpubPaginationCacheKey): Boolean { private fun CachedReaderPages.matches(key: SharedEpubPaginationCacheKey): Boolean {
return schemaVersion == SharedEpubPaginationCacheSchemaVersion && return schemaVersion == SharedEpubPaginationCacheSchemaVersion &&
processingVersion == SharedEpubPaginationProcessingVersion && processingVersion == SharedEpubPaginationProcessingVersion &&

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