diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
new file mode 100644
index 0000000..a5fb138
--- /dev/null
+++ b/.github/workflows/desktop-release.yml
@@ -0,0 +1,409 @@
+name: Desktop release
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Desktop version, for example 1.0.1
+ required: true
+ default: "1.0.1"
+ release_tag:
+ description: GitHub release tag for desktop artifacts
+ required: true
+ default: "v1.0.1"
+ pdfium_tag:
+ description: GitHub release tag containing Pdfium zip assets
+ required: true
+ default: "pdfium-desktop-v1"
+ draft:
+ description: Create or update the desktop release as a draft
+ required: true
+ default: "true"
+ type: choice
+ options:
+ - "true"
+ - "false"
+
+permissions:
+ contents: write
+
+env:
+ DESKTOP_FIREBASE_PROJECT_ID: ${{ secrets.DESKTOP_FIREBASE_PROJECT_ID }}
+ DESKTOP_FIREBASE_WEB_API_KEY: ${{ secrets.DESKTOP_FIREBASE_WEB_API_KEY }}
+ DESKTOP_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.DESKTOP_GOOGLE_OAUTH_CLIENT_ID }}
+ DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET }}
+
+jobs:
+ windows-msi:
+ name: Windows MSI (${{ matrix.flavor.label }})
+ runs-on: windows-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ flavor:
+ - label: standard
+ gradle: standard
+ - label: oss
+ gradle: oss
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Configure Gradle
+ shell: pwsh
+ run: |
+ @'
+ kotlin.code.style=official
+ kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
+ kotlin.mpp.applyDefaultHierarchyTemplate=false
+ org.gradle.caching=true
+ org.gradle.configuration-cache=true
+ org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
+ org.gradle.workers.max=2
+ '@ | Set-Content -Path gradle.properties -NoNewline -Encoding UTF8
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+
+ - name: Download Pdfium
+ shell: pwsh
+ run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ - name: Install WiX
+ shell: pwsh
+ run: choco install wixtoolset --no-progress -y
+
+ - name: Build MSI
+ shell: pwsh
+ env:
+ DESKTOP_FLAVOR: ${{ matrix.flavor.gradle }}
+ DESKTOP_VERSION: ${{ inputs.version }}
+ run: |
+ $gradleArgs = @(
+ "-PdesktopOnly=true",
+ "-PdesktopFlavor=$env:DESKTOP_FLAVOR",
+ "-PdesktopVersion=$env:DESKTOP_VERSION",
+ "-PdesktopPackageVersion=$env:DESKTOP_VERSION",
+ ":desktopApp:packageReleaseMsi"
+ )
+ ./gradlew.bat @gradleArgs
+
+ - name: Stage MSI
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Force release-assets | Out-Null
+ $source = Get-ChildItem desktopApp/build/compose/binaries/main-release/msi -Filter *.msi | Select-Object -First 1
+ if ($null -eq $source) { throw "MSI output not found" }
+ Copy-Item $source.FullName "release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-windows-x64.msi"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: windows-msi-${{ matrix.flavor.label }}
+ path: release-assets/*
+
+ ubuntu-deb-tar:
+ name: Ubuntu DEB and tar (${{ matrix.flavor.label }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ flavor:
+ - label: standard
+ gradle: standard
+ - label: oss
+ gradle: oss
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Configure Gradle
+ run: |
+ cat > gradle.properties <<'EOF'
+ kotlin.code.style=official
+ kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
+ kotlin.mpp.applyDefaultHierarchyTemplate=false
+ org.gradle.caching=true
+ org.gradle.configuration-cache=true
+ org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
+ org.gradle.workers.max=2
+ EOF
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+
+ - name: Make Gradle wrapper executable
+ run: chmod +x gradlew
+
+ - name: Install packaging tools
+ run: sudo apt-get update && sudo apt-get install -y fakeroot dpkg-dev binutils
+
+ - name: Download Pdfium
+ shell: pwsh
+ run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ - name: Build DEB and Linux tarball
+ run: >
+ ./gradlew
+ -PdesktopOnly=true
+ -PdesktopFlavor=${{ matrix.flavor.gradle }}
+ -PdesktopVersion=${{ inputs.version }}
+ -PdesktopPackageVersion=${{ inputs.version }}
+ :desktopApp:packageDeb
+ :desktopApp:packageLinuxTar
+
+ - name: Stage DEB and tarball
+ run: |
+ set -euo pipefail
+ mkdir -p release-assets
+ cp "$(find desktopApp/build/compose/binaries/main/deb -name '*.deb' -print -quit)" \
+ "release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-amd64.deb"
+ cp "$(find desktopApp/build/compose/binaries/main/linux-tar -name '*.tar.gz' -print -quit)" \
+ "release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x64.tar.gz"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ubuntu-deb-tar-${{ matrix.flavor.label }}
+ path: release-assets/*
+
+ fedora-rpm:
+ name: Fedora RPM (${{ matrix.flavor.label }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ flavor:
+ - label: standard
+ gradle: standard
+ - label: oss
+ gradle: oss
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Configure Gradle
+ run: |
+ cat > gradle.properties <<'EOF'
+ kotlin.code.style=official
+ kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
+ kotlin.mpp.applyDefaultHierarchyTemplate=false
+ org.gradle.caching=true
+ org.gradle.configuration-cache=true
+ org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
+ org.gradle.workers.max=2
+ EOF
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+
+ - name: Make Gradle wrapper executable
+ run: chmod +x gradlew
+
+ - name: Download Pdfium
+ shell: pwsh
+ run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ - name: Build RPM in Fedora
+ run: |
+ set -euo pipefail
+ docker run --rm \
+ -v "$PWD:/workspace" \
+ -v "$JAVA_HOME:/host-jdk-21:ro" \
+ -w /workspace \
+ -e DESKTOP_FIREBASE_PROJECT_ID \
+ -e DESKTOP_FIREBASE_WEB_API_KEY \
+ -e DESKTOP_GOOGLE_OAUTH_CLIENT_ID \
+ -e DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET \
+ fedora:44 bash -lc '
+ set -euo pipefail
+ dnf install -y ca-certificates findutils git rpm-build unzip which
+ update-ca-trust
+ export JAVA_HOME=/host-jdk-21
+ export PATH="$JAVA_HOME/bin:$PATH"
+ if [ -f /etc/pki/java/cacerts ]; then
+ export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/etc/pki/java/cacerts ${JAVA_TOOL_OPTIONS:-}"
+ elif [ -f /etc/pki/ca-trust/extracted/java/cacerts ]; then
+ export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/etc/pki/ca-trust/extracted/java/cacerts ${JAVA_TOOL_OPTIONS:-}"
+ fi
+ JPACKAGE_HOME="$JAVA_HOME"
+ ./gradlew \
+ -PdesktopOnly=true \
+ -PdesktopFlavor=${{ matrix.flavor.gradle }} \
+ -PdesktopVersion=${{ inputs.version }} \
+ -PdesktopPackageVersion=${{ inputs.version }} \
+ -PdesktopPackagingJavaHome="$JPACKAGE_HOME" \
+ :desktopApp:packageRpm
+ '
+ sudo chown -R "$USER:$USER" desktopApp/build
+
+ - name: Stage RPM
+ run: |
+ set -euo pipefail
+ mkdir -p release-assets
+ cp "$(find desktopApp/build/compose/binaries/main/rpm -name '*.rpm' -print -quit)" \
+ "release-assets/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x86_64.rpm"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: fedora-rpm-${{ matrix.flavor.label }}
+ path: release-assets/*
+
+ arch-package:
+ name: Arch package (${{ matrix.flavor.label }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ flavor:
+ - label: standard
+ gradle: standard
+ aur: episteme-bin
+ - label: oss
+ gradle: oss
+ aur: episteme-oss-bin
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Configure Gradle
+ run: |
+ cat > gradle.properties <<'EOF'
+ kotlin.code.style=official
+ kotlin.daemon.jvmargs=-Xmx4096M -XX:+UseG1GC -Dfile.encoding=UTF-8
+ kotlin.mpp.applyDefaultHierarchyTemplate=false
+ org.gradle.caching=true
+ org.gradle.configuration-cache=true
+ org.gradle.jvmargs=-Xmx2048M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
+ org.gradle.workers.max=2
+ EOF
+
+ - name: Make Gradle wrapper executable
+ run: chmod +x gradlew
+
+ - name: Download Pdfium
+ shell: pwsh
+ run: ./scripts/desktop/download-pdfium.ps1 -Repository "${{ github.repository }}" -Tag "${{ inputs.pdfium_tag }}"
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ - name: Build Arch package
+ run: |
+ set -euo pipefail
+ docker run --rm \
+ -v "$PWD:/workspace" \
+ -w /workspace \
+ -e DESKTOP_FIREBASE_PROJECT_ID \
+ -e DESKTOP_FIREBASE_WEB_API_KEY \
+ -e DESKTOP_GOOGLE_OAUTH_CLIENT_ID \
+ -e DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET \
+ archlinux:latest bash -lc '
+ set -euo pipefail
+ pacman -Syu --noconfirm
+ pacman -S --needed --noconfirm base-devel desktop-file-utils git jdk21-openjdk namcap unzip
+ export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
+ export PATH="$JAVA_HOME/bin:$PATH"
+ useradd -m builder
+ chown -R builder:builder /workspace
+ runuser -u builder -- env \
+ JAVA_HOME="$JAVA_HOME" \
+ PATH="$PATH" \
+ DESKTOP_FIREBASE_PROJECT_ID="$DESKTOP_FIREBASE_PROJECT_ID" \
+ DESKTOP_FIREBASE_WEB_API_KEY="$DESKTOP_FIREBASE_WEB_API_KEY" \
+ DESKTOP_GOOGLE_OAUTH_CLIENT_ID="$DESKTOP_GOOGLE_OAUTH_CLIENT_ID" \
+ DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET="$DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET" \
+ bash -lc "cd /workspace && ./gradlew \
+ -PdesktopOnly=true \
+ -PdesktopFlavor=${{ matrix.flavor.gradle }} \
+ -PdesktopVersion=${{ inputs.version }} \
+ -PdesktopPackageVersion=${{ inputs.version }} \
+ -PdesktopAurPackageRelease=1 \
+ :desktopApp:prepareAurPackage && \
+ cd desktopApp/build/aur/${{ matrix.flavor.aur }} && \
+ makepkg -sf --cleanbuild --nodeps && \
+ makepkg --printsrcinfo > .SRCINFO.generated && \
+ cmp -s .SRCINFO .SRCINFO.generated && \
+ pkgfile=\$(find . -maxdepth 1 -name '*.pkg.tar.zst' -print -quit) && \
+ desktop_file=${{ matrix.flavor.aur }} && \
+ desktop_file=\${desktop_file%-bin}.desktop && \
+ tmpdir=\$(mktemp -d) && \
+ bsdtar -xOf \"\$pkgfile\" \"usr/share/applications/\$desktop_file\" > \"\$tmpdir/\$desktop_file\" && \
+ desktop-file-validate \"\$tmpdir/\$desktop_file\" && \
+ namcap PKGBUILD && \
+ namcap \"\$pkgfile\" && \
+ cd /workspace && \
+ mkdir -p release-assets && \
+ cp \$(find desktopApp/build/aur/${{ matrix.flavor.aur }} -maxdepth 1 -name '*.pkg.tar.zst' -print -quit) \
+ release-assets/${{ matrix.flavor.aur }}-${{ inputs.version }}-1-x86_64.pkg.tar.zst && \
+ ./gradlew \
+ -PdesktopOnly=true \
+ -PdesktopFlavor=${{ matrix.flavor.gradle }} \
+ -PdesktopVersion=${{ inputs.version }} \
+ -PdesktopPackageVersion=${{ inputs.version }} \
+ -PdesktopAurPackageRelease=1 \
+ -PdesktopAurSourceUrl=https://github.com/${{ github.repository }}/releases/download/${{ inputs.release_tag }}/episteme-${{ matrix.flavor.label }}-${{ inputs.version }}-linux-x64.tar.gz \
+ :desktopApp:prepareAurPackage && \
+ cd desktopApp/build/aur/${{ matrix.flavor.aur }} && \
+ makepkg --printsrcinfo > .SRCINFO.generated && \
+ cmp -s .SRCINFO .SRCINFO.generated"
+ '
+ sudo chown -R "$USER:$USER" desktopApp/build release-assets
+
+ - name: Stage Arch package and AUR metadata
+ run: |
+ set -euo pipefail
+ mkdir -p release-assets
+ tar -C "desktopApp/build/aur/${{ matrix.flavor.aur }}" \
+ -czf "release-assets/aur-${{ matrix.flavor.aur }}-${{ inputs.version }}.tar.gz" \
+ PKGBUILD .SRCINFO
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: arch-package-${{ matrix.flavor.label }}
+ path: release-assets/*
+
+ publish-release:
+ name: Publish draft release
+ runs-on: ubuntu-latest
+ needs:
+ - windows-msi
+ - ubuntu-deb-tar
+ - fedora-rpm
+ - arch-package
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ path: downloaded-artifacts
+
+ - name: Collect release assets
+ run: |
+ set -euo pipefail
+ mkdir -p release-assets
+ find downloaded-artifacts -type f -exec cp {} release-assets/ \;
+ (cd release-assets && sha256sum * > SHA256SUMS.txt)
+
+ - name: Create or update GitHub release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ if ! gh release view "${{ inputs.release_tag }}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
+ draft_flag=""
+ if [ "${{ inputs.draft }}" = "true" ]; then
+ draft_flag="--draft"
+ fi
+ gh release create "${{ inputs.release_tag }}" \
+ --repo "${{ github.repository }}" \
+ --title "Episteme ${{ inputs.version }}" \
+ --notes "Desktop release ${{ inputs.version }}" \
+ $draft_flag
+ fi
+ gh release upload "${{ inputs.release_tag }}" release-assets/* \
+ --repo "${{ github.repository }}" \
+ --clobber
diff --git a/.gitignore b/.gitignore
index 81181f9..e183ade 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,4 +24,6 @@ third_party/pdfium/
cache/
worker/
output/
-policies/
\ No newline at end of file
+policies/
+episteme-bin/
+episteme-oss-bin/
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..d09f4f6
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index de561f2..2589bb8 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -171,6 +171,7 @@ android {
testOptions {
unitTests.isReturnDefaultValues = true
unitTests.all {
+ it.maxHeapSize = "4g"
it.jvmArgs("-Xss2m")
}
}
diff --git a/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt b/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt
index a674c3f..a660a71 100644
--- a/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt
+++ b/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt
@@ -320,6 +320,8 @@ class LibraryScreenContentTest {
onItemClick = {},
onItemLongClick = { item -> selectedItems.value = setOf(item) },
onInfoClick = onInfoClick,
+ onSaveClick = null,
+ onShareClick = null,
onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick,
onShelfClick = onShelfClick,
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bee3fda..c9a474e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -55,7 +55,22 @@
+
+
+
+
diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js
index bf37155..030e760 100644
--- a/app/src/main/assets/epub_reader.js
+++ b/app/src/main/assets/epub_reader.js
@@ -128,8 +128,15 @@
background-color: rgba(255, 236, 179, 0.8);
/* Semi-transparent Gold */
color: black !important;
+ display: inline !important;
+ text-align: initial !important;
+ text-align-last: auto !important;
+ letter-spacing: normal !important;
+ word-spacing: normal !important;
padding: 0.1em 0;
border-radius: 3px;
+ -webkit-box-decoration-break: clone;
+ box-decoration-break: clone;
}
html.dark-theme span.tts-highlight {
@@ -1496,6 +1503,16 @@
};
const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS";
+ const TTS_HIGHLIGHT_BLOCK_SELECTOR = "p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th";
+
+ function getTtsHighlightBlock(node) {
+ if (!node) {
+ return document.body;
+ }
+
+ const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
+ return (element && element.closest(TTS_HIGHLIGHT_BLOCK_SELECTOR)) || document.body;
+ }
window.highlightFromCfi = function (cfi, textToHighlight, startOffset) {
console.log(`$ {
@@ -1556,9 +1573,10 @@
, Text content: '${(location.node.textContent || "").substring(0, 50)}...' `);
const baseNode = location.node;
+ const highlightRoot = getTtsHighlightBlock(baseNode);
let remainingOffset = startOffset;
- const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
+ const treeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
treeWalker.currentNode = baseNode;
let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode();
@@ -1626,7 +1644,7 @@
} else {
remainingTextLength -= availableLength;
// Important: We need a fresh walker starting from the endNode to find the *next* text node reliably
- const nextNodeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
+ const nextNodeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
nextNodeWalker.currentNode = endNode;
endNode = nextNodeWalker.nextNode();
endOffset = 0; // Start from the beginning of the next node
@@ -1677,11 +1695,14 @@
TTS_HIGHLIGHT_LOG_TAG
}
- : surroundContents failed, using fallback. Error: $ {
+ : surroundContents failed, using same-block fallback. Error: $ {
e.message
}
`);
+ if (!highlightRoot.contains(range.commonAncestorContainer)) {
+ return "JS: Highlight range escaped current block.";
+ }
const contents = range.extractContents();
highlightSpan.appendChild(contents);
range.insertNode(highlightSpan);
diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt
index 18780d4..479330b 100644
--- a/app/src/main/java/com/aryan/reader/AppNavigation.kt
+++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt
@@ -220,11 +220,17 @@ fun AppNavigation(
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
composable(AppDestinations.MAIN_ROUTE) {
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
- MainScreen(
- viewModel = viewModel,
- windowSizeClass = windowSizeClass,
- navController = navController
- )
+ if (uiState.isTemporaryExternalOpen) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ } else {
+ MainScreen(
+ viewModel = viewModel,
+ windowSizeClass = windowSizeClass,
+ navController = navController
+ )
+ }
}
// PDF Viewer Screen Composable
diff --git a/app/src/main/java/com/aryan/reader/AppUiModels.kt b/app/src/main/java/com/aryan/reader/AppUiModels.kt
index f5236d6..d4e06bd 100644
--- a/app/src/main/java/com/aryan/reader/AppUiModels.kt
+++ b/app/src/main/java/com/aryan/reader/AppUiModels.kt
@@ -43,6 +43,7 @@ data class ReaderScreenState(
val selectedEpubUri: Uri? = null,
val selectedFileType: FileType? = null,
val isLoading: Boolean = false,
+ val isTemporaryExternalOpen: Boolean = false,
val errorMessage: String? = null,
val contextualActionItems: Set = emptySet(),
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
diff --git a/app/src/main/java/com/aryan/reader/ClipboardUtils.kt b/app/src/main/java/com/aryan/reader/ClipboardUtils.kt
new file mode 100644
index 0000000..d86d89a
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/ClipboardUtils.kt
@@ -0,0 +1,31 @@
+package com.aryan.reader
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import timber.log.Timber
+
+internal fun copyPlainTextToClipboard(
+ context: Context,
+ label: String,
+ text: String
+): Boolean {
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText(label, text)
+ return setPrimaryClipSafely {
+ clipboard.setPrimaryClip(clip)
+ }
+}
+
+internal inline fun setPrimaryClipSafely(setPrimaryClip: () -> Unit): Boolean {
+ return try {
+ setPrimaryClip()
+ true
+ } catch (e: SecurityException) {
+ Timber.w(e, "Clipboard write rejected by system policy")
+ false
+ } catch (e: RuntimeException) {
+ Timber.w(e, "Clipboard write failed")
+ false
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt
index 9fb3767..c42019a 100644
--- a/app/src/main/java/com/aryan/reader/Common.kt
+++ b/app/src/main/java/com/aryan/reader/Common.kt
@@ -138,6 +138,7 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -862,6 +863,13 @@ fun AiDefinitionPopup(
val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState()
val context = LocalContext.current
+ val configuration = LocalConfiguration.current
+ val maxPopupHeight = readerModalMaxHeightDp(
+ screenHeightDp = configuration.screenHeightDp,
+ fraction = 0.65f,
+ verticalMarginDp = 48,
+ preferredMinHeightDp = 180
+ ).dp
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
@@ -882,7 +890,7 @@ fun AiDefinitionPopup(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 5.dp)
- .heightIn(min = 150.dp, max = 400.dp),
+ .heightIn(max = maxPopupHeight),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
@@ -3310,12 +3318,16 @@ fun ThemeColorPickerDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
+ val configuration = LocalConfiguration.current
+ val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
+
Surface(
shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(16.dp)
+ .heightIn(max = maxDialogHeight)
) {
Column(
modifier = Modifier
@@ -3495,12 +3507,16 @@ fun HighlightColorPickerDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
+ val configuration = LocalConfiguration.current
+ val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
+
Surface(
shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(16.dp)
+ .heightIn(max = maxDialogHeight)
) {
Column(
modifier = Modifier
diff --git a/app/src/main/java/com/aryan/reader/ExternalFileOpenRouter.kt b/app/src/main/java/com/aryan/reader/ExternalFileOpenRouter.kt
new file mode 100644
index 0000000..dd3508d
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/ExternalFileOpenRouter.kt
@@ -0,0 +1,57 @@
+package com.aryan.reader
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+
+const val EXTRA_TEMPORARY_EXTERNAL_OPEN = "com.aryan.reader.extra.TEMPORARY_EXTERNAL_OPEN"
+
+object ExternalFileOpenRouteDecider {
+ const val BEHAVIOR_TEMPORARY = "TEMPORARY"
+
+ fun shouldOpenTemporary(externalFileBehavior: String?): Boolean {
+ return externalFileBehavior == BEHAVIOR_TEMPORARY
+ }
+
+ fun targetActivityClass(externalFileBehavior: String?): Class {
+ return if (shouldOpenTemporary(externalFileBehavior)) {
+ TemporaryExternalFileActivity::class.java
+ } else {
+ MainActivity::class.java
+ }
+ }
+}
+
+class ExternalFileOpenRouterActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ routeExternalOpen(intent)
+ finish()
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ routeExternalOpen(intent)
+ finish()
+ }
+
+ private fun routeExternalOpen(sourceIntent: Intent?) {
+ if (sourceIntent?.action != Intent.ACTION_VIEW || sourceIntent.data == null) return
+
+ val prefs = getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
+ val behavior = prefs.getString("external_file_behavior", "ASK")
+ val temporary = ExternalFileOpenRouteDecider.shouldOpenTemporary(behavior)
+ val targetIntent = Intent(sourceIntent).apply {
+ setClass(this@ExternalFileOpenRouterActivity, ExternalFileOpenRouteDecider.targetActivityClass(behavior))
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ if (temporary) {
+ putExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, true)
+ addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
+ }
+ }
+ startActivity(targetIntent)
+ }
+}
+
+class TemporaryExternalFileActivity : MainActivity()
diff --git a/app/src/main/java/com/aryan/reader/FontsScreen.kt b/app/src/main/java/com/aryan/reader/FontsScreen.kt
index 7c1caad..ebdd5d7 100644
--- a/app/src/main/java/com/aryan/reader/FontsScreen.kt
+++ b/app/src/main/java/com/aryan/reader/FontsScreen.kt
@@ -64,10 +64,17 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aryan.reader.shared.CustomFontItem
+import com.aryan.reader.shared.CustomFontFamilyItem
+import com.aryan.reader.shared.CustomFontVariantItem
+import com.aryan.reader.shared.fontFaceLabel
+import com.aryan.reader.shared.fontFaceSummary
+import com.aryan.reader.shared.groupByFamily
+import com.aryan.reader.shared.hasVariableWeightFace
import com.aryan.reader.shared.ui.SharedAppFontSelector
import com.aryan.reader.shared.ui.SharedFontSettingsSection
import com.aryan.reader.shared.ui.SharedFontSettingsTabs
@@ -97,6 +104,7 @@ fun FontsScreen(
val selectedFonts = remember(fonts, selectedFontIds) {
fonts.filter { it.id in selectedFontIds }
}
+ val fontEntitiesById = remember(fonts) { fonts.associateBy { it.id } }
val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty()
LaunchedEffect(fonts) {
@@ -172,6 +180,7 @@ fun FontsScreen(
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
+ val fontFamilies = remember(sharedFonts) { sharedFonts.groupByFamily() }
Column(modifier = Modifier.fillMaxSize()) {
SharedFontSettingsTabs(
selectedSection = selectedSection,
@@ -202,16 +211,20 @@ fun FontsScreen(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
- items(fonts, key = { it.id }) { font ->
- FontListItem(
- font = font,
- isSelected = font.id in selectedFontIds,
+ items(fontFamilies, key = { family -> family.variants.joinToString("|") { it.font.id } }) { family ->
+ FontFamilyListItem(
+ family = family,
+ selectedFontIds = selectedFontIds,
isSelectionMode = isFontSelectionMode,
- onSelectionToggle = {
- selectedFontIds = selectedFontIds.toggle(font.id)
+ fontEntityForId = { id -> fontEntitiesById[id] },
+ onVariantSelectionToggle = { id ->
+ selectedFontIds = selectedFontIds.toggle(id)
},
- onDelete = {
- fontsPendingDelete = listOf(font)
+ onFamilySelectionToggle = {
+ selectedFontIds = selectedFontIds.toggleAll(family.variants.map { it.font.id })
+ },
+ onDeleteVariant = { id ->
+ fontEntitiesById[id]?.let { fontsPendingDelete = listOf(it) }
}
)
}
@@ -531,6 +544,188 @@ fun FontListItem(
}
}
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun FontFamilyListItem(
+ family: CustomFontFamilyItem,
+ selectedFontIds: Set,
+ isSelectionMode: Boolean,
+ fontEntityForId: (String) -> CustomFontEntity?,
+ onVariantSelectionToggle: (String) -> Unit,
+ onFamilySelectionToggle: () -> Unit,
+ onDeleteVariant: (String) -> Unit
+) {
+ val baseFont = remember(family) {
+ family.variants.firstOrNull { it.fontFaceLabel() == "Regular" }?.font ?: family.variants.first().font
+ }
+ val customTypeface = remember(baseFont.path) {
+ try {
+ FontFamily(Font(File(baseFont.path)))
+ } catch (_: Exception) {
+ null
+ }
+ }
+ val familyFontIds = remember(family) { family.variants.map { it.font.id }.toSet() }
+ val isSelected = familyFontIds.any { it in selectedFontIds }
+ val allSelected = familyFontIds.all { it in selectedFontIds }
+ val faceSummary = remember(family) {
+ buildString {
+ append(family.fontFaceSummary())
+ if (family.hasVariableWeightFace()) append(" - Variable weight")
+ append(" - ${family.variants.size} file")
+ if (family.variants.size != 1) append("s")
+ }
+ }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = {
+ if (isSelectionMode) {
+ onFamilySelectionToggle()
+ }
+ },
+ onLongClick = onFamilySelectionToggle
+ ),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSelected) {
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
+ } else {
+ MaterialTheme.colorScheme.surface
+ }
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (isSelectionMode) {
+ Checkbox(
+ checked = allSelected,
+ onCheckedChange = { onFamilySelectionToggle() },
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = family.familyName,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = faceSummary,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), MaterialTheme.shapes.small)
+ .padding(12.dp)
+ ) {
+ if (customTypeface != null) {
+ Text(
+ text = stringResource(R.string.font_preview_text),
+ fontFamily = customTypeface,
+ fontSize = 18.sp,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.font_preview_error),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ family.variants.forEachIndexed { index, variant ->
+ FontVariantRow(
+ variant = variant,
+ entity = fontEntityForId(variant.font.id),
+ isSelected = variant.font.id in selectedFontIds,
+ isSelectionMode = isSelectionMode,
+ onSelectionToggle = { onVariantSelectionToggle(variant.font.id) },
+ onDelete = { onDeleteVariant(variant.font.id) }
+ )
+ if (index != family.variants.lastIndex) {
+ HorizontalDivider(modifier = Modifier.padding(start = if (isSelectionMode) 48.dp else 0.dp))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FontVariantRow(
+ variant: CustomFontVariantItem,
+ entity: CustomFontEntity?,
+ isSelected: Boolean,
+ isSelectionMode: Boolean,
+ onSelectionToggle: () -> Unit,
+ onDelete: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(6.dp))
+ .clickable(enabled = isSelectionMode) { onSelectionToggle() }
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (isSelectionMode) {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = { onSelectionToggle() },
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = variant.fontFaceLabel(),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = entity?.fileName ?: variant.font.fileName,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ Text(
+ text = variant.font.fileExtension.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ modifier = Modifier.padding(horizontal = 8.dp)
+ )
+ if (!isSelectionMode) {
+ IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = stringResource(R.string.action_delete),
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+}
+
private fun List.toSharedCustomFontItems(): List {
return filterNot { it.isDeleted }
.sortedBy { it.displayName.lowercase() }
@@ -591,3 +786,8 @@ fun DeleteFontsConfirmationDialog(
private fun Set.toggle(id: String): Set {
return if (id in this) this - id else this + id
}
+
+private fun Set.toggleAll(ids: List): Set {
+ val idSet = ids.toSet()
+ return if (containsAll(idSet)) this - idSet else this + idSet
+}
diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt
index 5dce10d..8b24157 100644
--- a/app/src/main/java/com/aryan/reader/HomeScreen.kt
+++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt
@@ -139,6 +139,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
+import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
@@ -193,6 +194,7 @@ fun HomeScreen(
var showAboutDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf(null) }
+ var pendingSaveOriginalItem by remember { mutableStateOf(null) }
var showBehaviorDialog by remember { mutableStateOf(false) }
var showStrictFilterDialog by remember { mutableStateOf(false) }
var showClearBookCacheDialog by remember { mutableStateOf(false) }
@@ -221,6 +223,35 @@ fun HomeScreen(
}
}
+ val saveOriginalLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument("application/octet-stream")
+ ) { uri ->
+ val item = pendingSaveOriginalItem
+ pendingSaveOriginalItem = null
+ if (uri != null && item?.uriString != null) {
+ viewModel.saveOriginalFile(item.uriString.toUri(), uri)
+ }
+ }
+
+ fun saveOriginalItem(item: RecentFileItem) {
+ if (!item.canExportOriginalFile()) return
+ pendingSaveOriginalItem = item
+ saveOriginalLauncher.launch(item.suggestedOriginalFileName())
+ }
+
+ fun shareOriginalItem(item: RecentFileItem) {
+ val uriString = item.uriString ?: return
+ if (!item.canExportOriginalFile()) return
+ scope.launch {
+ viewModel.shareOriginalFile(
+ activityContext = context,
+ sourceUri = uriString.toUri(),
+ fileType = item.type,
+ filename = item.suggestedOriginalFileName()
+ )
+ }
+ }
+
LaunchedEffect(uiState.isRequestingDrivePermission) {
if (uiState.isRequestingDrivePermission) {
val intent = viewModel.getDriveSignInIntent(context)
@@ -390,6 +421,12 @@ fun HomeScreen(
showInfoDialog = true
}
},
+ onSaveClick = selectedContextItems.singleOrNull()
+ ?.takeIf { it.canExportOriginalFile() }
+ ?.let { item -> { saveOriginalItem(item) } },
+ onShareClick = selectedContextItems.singleOrNull()
+ ?.takeIf { it.canExportOriginalFile() }
+ ?.let { item -> { shareOriginalItem(item) } },
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllRecentFiles() })
@@ -502,28 +539,17 @@ fun HomeScreen(
)
}
- itemForInfoDialog?.let { item ->
- if (showInfoDialog) {
- FileInfoDialog(
- item = item,
- usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
- onDismiss = {
- showInfoDialog = false
- itemForInfoDialog = null
- },
- onSaveMetadata = { metadata ->
- viewModel.updateBookMetadata(item.bookId, metadata)
- },
- onSaveDisplayName = { name ->
- viewModel.updateCustomName(item.bookId, name)
- },
- onRestoreMetadata = {
- viewModel.restoreOriginalBookMetadata(item.bookId)
- },
- onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
- )
- }
- }
+ HydratedFileInfoDialog(
+ item = itemForInfoDialog,
+ isVisible = showInfoDialog,
+ uiState = uiState,
+ viewModel = viewModel,
+ onDismiss = {
+ showInfoDialog = false
+ itemForInfoDialog = null
+ },
+ onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
+ )
if (showClearReflowCacheDialog) {
DangerousFolderActionDialog(
@@ -1786,9 +1812,14 @@ fun ExternalFileBehaviorDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.options_external_file_behavior)) },
text = {
- Column {
- val options = listOf("ASK" to R.string.external_file_behavior_ask, "KEEP" to R.string.external_file_behavior_keep, "DELETE" to R.string.external_file_behavior_delete)
- options.forEach { (value, labelRes) ->
+ Column(modifier = Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) {
+ val options = listOf(
+ Triple("ASK", R.string.external_file_behavior_ask, R.string.external_file_behavior_ask_desc),
+ Triple("KEEP", R.string.external_file_behavior_keep, R.string.external_file_behavior_keep_desc),
+ Triple("DELETE", R.string.external_file_behavior_delete, R.string.external_file_behavior_delete_desc),
+ Triple("TEMPORARY", R.string.external_file_behavior_temporary, R.string.external_file_behavior_temporary_desc)
+ )
+ options.forEach { (value, labelRes, descriptionRes) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -1798,7 +1829,14 @@ fun ExternalFileBehaviorDialog(
) {
RadioButton(selected = currentBehavior == value, onClick = null)
Spacer(modifier = Modifier.width(16.dp))
- Text(stringResource(labelRes))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(stringResource(labelRes))
+ Text(
+ text = stringResource(descriptionRes),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt
index ae267c1..342b624 100644
--- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt
+++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt
@@ -133,6 +133,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
@@ -271,6 +272,36 @@ fun LibraryScreen(
var showDeleteShelvesDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf(null) }
+ var pendingSaveOriginalItem by remember { mutableStateOf(null) }
+
+ val saveOriginalLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument("application/octet-stream")
+ ) { uri ->
+ val item = pendingSaveOriginalItem
+ pendingSaveOriginalItem = null
+ if (uri != null && item?.uriString != null) {
+ viewModel.saveOriginalFile(item.uriString.toUri(), uri)
+ }
+ }
+
+ fun saveOriginalItem(item: RecentFileItem) {
+ if (!item.canExportOriginalFile()) return
+ pendingSaveOriginalItem = item
+ saveOriginalLauncher.launch(item.suggestedOriginalFileName())
+ }
+
+ fun shareOriginalItem(item: RecentFileItem) {
+ val uriString = item.uriString ?: return
+ if (!item.canExportOriginalFile()) return
+ scope.launch {
+ viewModel.shareOriginalFile(
+ activityContext = context,
+ sourceUri = uriString.toUri(),
+ fileType = item.type,
+ filename = item.suggestedOriginalFileName()
+ )
+ }
+ }
BackHandler(enabled = isContextualModeActive) {
viewModel.clearContextualAction()
@@ -317,6 +348,12 @@ fun LibraryScreen(
showInfoDialog = true
}
},
+ onSaveClick = selectedItems.singleOrNull()
+ ?.takeIf { it.canExportOriginalFile() }
+ ?.let { item -> { saveOriginalItem(item) } },
+ onShareClick = selectedItems.singleOrNull()
+ ?.takeIf { it.canExportOriginalFile() }
+ ?.let { item -> { shareOriginalItem(item) } },
onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllLibraryFiles() },
onShelfClick = viewModel::onShelfClick,
@@ -397,28 +434,17 @@ fun LibraryScreen(
)
}
- itemForInfoDialog?.let { item ->
- if (showInfoDialog) {
- FileInfoDialog(
- item = item,
- usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
- onDismiss = {
- showInfoDialog = false
- itemForInfoDialog = null
- },
- onSaveMetadata = { metadata ->
- viewModel.updateBookMetadata(item.bookId, metadata)
- },
- onSaveDisplayName = { name ->
- viewModel.updateCustomName(item.bookId, name)
- },
- onRestoreMetadata = {
- viewModel.restoreOriginalBookMetadata(item.bookId)
- },
- onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
- )
- }
- }
+ HydratedFileInfoDialog(
+ item = itemForInfoDialog,
+ isVisible = showInfoDialog,
+ uiState = uiState,
+ viewModel = viewModel,
+ onDismiss = {
+ showInfoDialog = false
+ itemForInfoDialog = null
+ },
+ onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
+ )
CustomTopBanner(bannerMessage = uiState.bannerMessage)
}
}
@@ -436,10 +462,42 @@ fun ShelfScreen(
val sortOrder = uiState.sortOrder
val showRenameDialogFor = uiState.showRenameShelfDialogFor
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf(null) }
+ var pendingSaveOriginalItem by remember { mutableStateOf(null) }
+
+ val saveOriginalLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument("application/octet-stream")
+ ) { uri ->
+ val item = pendingSaveOriginalItem
+ pendingSaveOriginalItem = null
+ if (uri != null && item?.uriString != null) {
+ viewModel.saveOriginalFile(item.uriString.toUri(), uri)
+ }
+ }
+
+ fun saveOriginalItem(item: RecentFileItem) {
+ if (!item.canExportOriginalFile()) return
+ pendingSaveOriginalItem = item
+ saveOriginalLauncher.launch(item.suggestedOriginalFileName())
+ }
+
+ fun shareOriginalItem(item: RecentFileItem) {
+ val uriString = item.uriString ?: return
+ if (!item.canExportOriginalFile()) return
+ scope.launch {
+ viewModel.shareOriginalFile(
+ activityContext = context,
+ sourceUri = uriString.toUri(),
+ fileType = item.type,
+ filename = item.suggestedOriginalFileName()
+ )
+ }
+ }
BackHandler(enabled = true) {
when {
@@ -491,6 +549,12 @@ fun ShelfScreen(
showInfoDialog = true
}
},
+ onSaveClick = selectedItems.singleOrNull()
+ ?.takeIf { it.canExportOriginalFile() }
+ ?.let { item -> { saveOriginalItem(item) } },
+ onShareClick = selectedItems.singleOrNull()
+ ?.takeIf { it.canExportOriginalFile() }
+ ?.let { item -> { shareOriginalItem(item) } },
onDeleteClick = { showRemoveFromShelfDialog = true },
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
@@ -531,19 +595,14 @@ fun ShelfScreen(
)
}
- itemForInfoDialog?.let { item ->
- if (showInfoDialog) {
- FileInfoDialog(
- item = item,
- usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
- onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
- onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) },
- onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
- onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) },
- onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
- )
- }
- }
+ HydratedFileInfoDialog(
+ item = itemForInfoDialog,
+ isVisible = showInfoDialog,
+ uiState = uiState,
+ viewModel = viewModel,
+ onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
+ onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
+ )
CustomTopBanner(bannerMessage = uiState.bannerMessage)
}
}
@@ -578,6 +637,8 @@ fun LibraryScreenContent(
onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit,
onInfoClick: () -> Unit,
+ onSaveClick: (() -> Unit)?,
+ onShareClick: (() -> Unit)?,
onDeleteClick: () -> Unit,
onSelectAllClick: () -> Unit,
onShelfClick: (Shelf) -> Unit,
@@ -640,6 +701,8 @@ fun LibraryScreenContent(
onTagClick = onTagClick,
onPinClick = onPinClick,
onInfoClick = onInfoClick,
+ onSaveClick = onSaveClick,
+ onShareClick = onShareClick,
onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick
)
@@ -1046,6 +1109,8 @@ private fun ShelfDetailScreen(
onClearSelection: () -> Unit,
onTagClick: () -> Unit,
onInfoClick: () -> Unit,
+ onSaveClick: (() -> Unit)?,
+ onShareClick: (() -> Unit)?,
onDeleteClick: () -> Unit,
onRenameShelf: () -> Unit,
onDeleteShelf: () -> Unit,
@@ -1128,6 +1193,8 @@ private fun ShelfDetailScreen(
onNavIconClick = onClearSelection,
onTagClick = onTagClick,
onInfoClick = onInfoClick,
+ onSaveClick = onSaveClick,
+ onShareClick = onShareClick,
onDeleteClick = onDeleteClick
)
} else if (isSearchActive) {
diff --git a/app/src/main/java/com/aryan/reader/MainActivity.kt b/app/src/main/java/com/aryan/reader/MainActivity.kt
index e5835ed..382c1ad 100644
--- a/app/src/main/java/com/aryan/reader/MainActivity.kt
+++ b/app/src/main/java/com/aryan/reader/MainActivity.kt
@@ -58,10 +58,12 @@ import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI
import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET
@UnstableApi
-class MainActivity : AppCompatActivity() {
+open class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
+ private val isTemporaryExternalOpen: Boolean
+ get() = intent?.getBooleanExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, false) == true
private val updateLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
@@ -86,6 +88,14 @@ class MainActivity : AppCompatActivity() {
}
}
+ lifecycleScope.launch {
+ viewModel.temporaryExternalOpenFinished.collect {
+ if (isTemporaryExternalOpen) {
+ finishAndRemoveTask()
+ }
+ }
+ }
+
if (savedInstanceState == null) {
handleIntent(intent)
}
@@ -160,7 +170,12 @@ class MainActivity : AppCompatActivity() {
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
Timber.d("Received VIEW intent with URI: ${intent.data}")
val uri = intent.data!!
- viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true)
+ viewModel.onFileSelected(
+ uri,
+ isFromRecent = false,
+ isExternalIntent = true,
+ isTemporaryExternalIntent = isTemporaryExternalOpen
+ )
}
}
}
diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt
index 4a7b4bd..c5d96d9 100644
--- a/app/src/main/java/com/aryan/reader/MainViewModel.kt
+++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt
@@ -222,10 +222,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val _navigationEvent = Channel(Channel.BUFFERED)
@Suppress("unused")
val navigationEvent = _navigationEvent.receiveAsFlow()
+ private val _temporaryExternalOpenFinished = Channel(Channel.BUFFERED)
+ val temporaryExternalOpenFinished = _temporaryExternalOpenFinished.receiveAsFlow()
private var bannerDismissJob: Job? = null
private var bannerDismissGeneration = 0L
private var pendingSwitchDeferred: CompletableDeferred? = null
private var externalOpenedBookId: String? = null
+ private var temporaryExternalSessionBookId: String? = null
private var cloudContentRetryJob: Job? = null
private val cloudMetadataUploadJobs = ConcurrentHashMap()
@@ -804,6 +807,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(showTagSelectionDialogFor = emptySet()) }
}
+ suspend fun getFileInfoItem(bookId: String): RecentFileItem? {
+ return recentFilesRepository.getFileByBookId(bookId)
+ }
+
fun createAndAssignTag(name: String, bookIds: Set) {
val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
if (sanitizedBookIds.isEmpty()) return
@@ -2089,6 +2096,48 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
+ internal fun trackExternalOpenForClose(
+ bookId: String,
+ importedCopyUriString: String?,
+ isTemporaryExternalIntent: Boolean
+ ) {
+ if (isTemporaryExternalIntent) {
+ temporaryExternalSessionBookId = bookId
+ if (importedCopyUriString != null) {
+ externalOpenedBookId = bookId
+ markPendingExternalFileRemoval(bookId, importedCopyUriString)
+ }
+ return
+ }
+
+ externalOpenedBookId = bookId
+ if (
+ importedCopyUriString != null &&
+ prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, EXTERNAL_FILE_BEHAVIOR_ASK) == "DELETE"
+ ) {
+ markPendingExternalFileRemoval(bookId, importedCopyUriString)
+ }
+ }
+
+ fun saveOriginalFile(sourceUri: Uri, destUri: Uri) {
+ viewModelScope.launch {
+ _internalState.update {
+ it.copy(isLoading = true, bannerMessage = BannerMessage(appContext.getString(R.string.banner_saving_original_file)))
+ }
+ try {
+ withContext(Dispatchers.IO) {
+ copyUriBytes(sourceUri, destUri)
+ }
+ showBanner(appContext.getString(R.string.banner_original_file_saved))
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to save original file")
+ showBanner(appContext.getString(R.string.error_saving_file, e.localizedMessage ?: e.message.orEmpty()), isError = true)
+ } finally {
+ _internalState.update { it.copy(isLoading = false) }
+ }
+ }
+ }
+
fun togglePinForContextualItems(isHome: Boolean) {
if (_internalState.value.contextualActionItems.isEmpty()) return
@@ -2206,6 +2255,68 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
+ suspend fun shareOriginalFile(
+ activityContext: Context,
+ sourceUri: Uri,
+ fileType: FileType,
+ filename: String
+ ) {
+ withContext(Dispatchers.IO) {
+ try {
+ val shareDir = File(appContext.cacheDir, "shared_files")
+ if (shareDir.exists()) {
+ shareDir.listFiles()?.forEach { file ->
+ try {
+ file.delete()
+ } catch (_: Exception) {
+ Timber.w("Failed to delete temp share file: ${file.name}")
+ }
+ }
+ } else {
+ shareDir.mkdirs()
+ }
+
+ val destFile = File(shareDir, filename)
+ FileOutputStream(destFile).use { output ->
+ appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
+ input.copyTo(output)
+ } ?: error("Could not open source file.")
+ }
+
+ val authority = "${appContext.packageName}.provider"
+ val contentUri = androidx.core.content.FileProvider.getUriForFile(
+ appContext, authority, destFile
+ )
+ val mimeType = SharedFileCapabilities.mimeTypeFor(fileType) ?: "application/octet-stream"
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
+ type = mimeType
+ putExtra(Intent.EXTRA_STREAM, contentUri)
+ putExtra(Intent.EXTRA_TITLE, filename)
+ putExtra(Intent.EXTRA_SUBJECT, appContext.getString(R.string.share_subject, filename))
+ clipData = ClipData.newRawUri(filename, contentUri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ val chooser = Intent.createChooser(shareIntent, appContext.getString(R.string.share_file_chooser_title))
+ if (activityContext !is android.app.Activity) {
+ chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ withContext(Dispatchers.Main) { activityContext.startActivity(chooser) }
+ } catch (e: Exception) {
+ Timber.e(e, "Share original file failed")
+ showBanner(appContext.getString(R.string.error_share_failed, e.localizedMessage ?: e.message.orEmpty()), isError = true)
+ }
+ }
+ }
+
+ private fun copyUriBytes(sourceUri: Uri, destUri: Uri) {
+ val contentResolver = appContext.contentResolver
+ contentResolver.openInputStream(sourceUri)?.use { input ->
+ contentResolver.openOutputStream(destUri)?.use { output ->
+ input.copyTo(output)
+ } ?: error("Could not open destination file.")
+ } ?: error("Could not open source file.")
+ }
+
private fun queueCloudMetadataUpload(bookId: String, reason: String, debounce: Boolean = true) {
if (!uiState.value.isSyncEnabled) return
cloudMetadataUploadJobs.remove(bookId)?.cancel()
@@ -2761,6 +2872,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val closingBookId = _internalState.value.selectedBookId
val uriString = _internalState.value.selectedPdfUri?.toString()
?: _internalState.value.selectedEpubUri?.toString()
+ val isTemporaryExternalSession = closingBookId != null && closingBookId == temporaryExternalSessionBookId
logCloudSyncTrace {
"android.reader.close_request book=$closingBookId uri=${uriString.cloudSyncPreview()} sync=${uiState.value.isSyncEnabled}"
}
@@ -2787,6 +2899,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
selectedEpubBook = null,
selectedFileType = null,
isLoading = false,
+ isTemporaryExternalOpen = false,
errorMessage = null,
initialLocator = null,
initialPageInBook = null,
@@ -2794,20 +2907,40 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isOpeningFromTtsNotification = false
)
}
- clearPersistedReaderSession()
+ if (!isTemporaryExternalSession) {
+ clearPersistedReaderSession()
+ }
var removesExternalFileOnClose = false
- if (closingBookId != null && closingBookId == externalOpenedBookId) {
- val behavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK"
+ if (closingBookId != null && (closingBookId == externalOpenedBookId || isTemporaryExternalSession)) {
+ val behavior = if (isTemporaryExternalSession) {
+ EXTERNAL_FILE_BEHAVIOR_TEMPORARY
+ } else {
+ prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, EXTERNAL_FILE_BEHAVIOR_ASK) ?: EXTERNAL_FILE_BEHAVIOR_ASK
+ }
if (behavior == "ASK") {
_internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) }
} else if (behavior == "DELETE") {
removesExternalFileOnClose = true
deletePendingExternalFileRemoval(closingBookId, uriString)
+ } else if (behavior == EXTERNAL_FILE_BEHAVIOR_TEMPORARY) {
+ removesExternalFileOnClose = true
+ val shouldDeleteImportedCopy = closingBookId == externalOpenedBookId
+ if (shouldDeleteImportedCopy) {
+ viewModelScope.launch {
+ deletePendingExternalFileRemoval(PendingExternalFileRemoval(closingBookId, uriString))
+ _temporaryExternalOpenFinished.send(Unit)
+ }
+ } else {
+ viewModelScope.launch {
+ _temporaryExternalOpenFinished.send(Unit)
+ }
+ }
} else {
clearPendingExternalFileRemovals(setOf(closingBookId))
}
externalOpenedBookId = null
+ temporaryExternalSessionBookId = null
}
if (uriString != null && !removesExternalFileOnClose) {
@@ -4802,7 +4935,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- fun onFileSelected(uri: Uri, isFromRecent: Boolean = false, isExternalIntent: Boolean = false) {
+ fun onFileSelected(
+ uri: Uri,
+ isFromRecent: Boolean = false,
+ isExternalIntent: Boolean = false,
+ isTemporaryExternalIntent: Boolean = false
+ ) {
if (isFromRecent) {
Timber.i("Opening recent file: $uri")
viewModelScope.launch {
@@ -4815,7 +4953,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
} else {
Timber.i("Importing new file: $uri")
- importExternalFile(uri, isExternalIntent)
+ importExternalFile(uri, isExternalIntent, isTemporaryExternalIntent)
}
}
@@ -4865,9 +5003,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private fun importExternalFile(externalUri: Uri, isExternalIntent: Boolean = false) {
+ private fun importExternalFile(
+ externalUri: Uri,
+ isExternalIntent: Boolean = false,
+ isTemporaryExternalIntent: Boolean = false
+ ) {
+ if (isTemporaryExternalIntent) {
+ openTemporaryExternalFile(externalUri)
+ return
+ }
+
_internalState.update {
- it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet())
+ it.copy(
+ isLoading = true,
+ errorMessage = null,
+ contextualActionItems = emptySet()
+ )
}
viewModelScope.launch {
@@ -4877,10 +5028,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (importResult != null) {
val (internalUri, bookId, type) = importResult
if (isExternalIntent) {
- externalOpenedBookId = bookId
- if (prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") == "DELETE") {
- markPendingExternalFileRemoval(bookId, internalUri.toString())
- }
+ trackExternalOpenForClose(
+ bookId = bookId,
+ importedCopyUriString = internalUri.toString(),
+ isTemporaryExternalIntent = isTemporaryExternalIntent
+ )
}
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File"
openBook(
@@ -4895,6 +5047,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val existingItem = recentFilesRepository.getFileByBookId(hash)
if (existingItem != null) {
Timber.i("Re-selected an existing book. Opening it.")
+ if (isTemporaryExternalIntent) {
+ trackExternalOpenForClose(
+ bookId = existingItem.bookId,
+ importedCopyUriString = null,
+ isTemporaryExternalIntent = true
+ )
+ }
onRecentFileClicked(existingItem)
_internalState.update { it.copy(isLoading = false) }
return@launch
@@ -4928,6 +5087,45 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
+ private fun openTemporaryExternalFile(externalUri: Uri) {
+ _internalState.update {
+ it.copy(
+ isLoading = true,
+ isTemporaryExternalOpen = true,
+ errorMessage = null,
+ contextualActionItems = emptySet()
+ )
+ }
+ viewModelScope.launch {
+ val type = getFileTypeFromUri(externalUri, appContext)
+ if (type == null) {
+ _internalState.update {
+ it.copy(
+ isLoading = false,
+ isTemporaryExternalOpen = false,
+ errorMessage = appContext.getString(R.string.error_unsupported_file_type)
+ )
+ }
+ return@launch
+ }
+
+ val displayName = getFileNameFromUri(externalUri, appContext) ?: "Temporary File"
+ val bookId = "temporary-${UUID.randomUUID()}"
+ trackExternalOpenForClose(
+ bookId = bookId,
+ importedCopyUriString = null,
+ isTemporaryExternalIntent = true
+ )
+ openBook(
+ uri = externalUri,
+ bookId = bookId,
+ type = type,
+ originalDisplayName = displayName,
+ persistToLibrary = false
+ )
+ }
+ }
+
fun saveHighlights(bookId: String, highlightsJson: String) {
viewModelScope.launch {
val currentBookUri = _internalState.value.selectedPdfUri ?: _internalState.value.selectedEpubUri
@@ -5154,7 +5352,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private suspend fun cleanupBookDataLocally(bookId: String) {
+ protected open suspend fun cleanupBookDataLocally(bookId: String) {
pdfTextRepository.clearBookText(bookId)
clearImportedFileCache(bookId)
bookCacheDao.deleteEntireBookCache(bookId)
@@ -5199,7 +5397,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isInitialPageExplicit: Boolean = false,
initialLocatorOverride: Locator? = null,
initialCfiOverride: String? = null,
- preserveTtsOnOpen: Boolean = false
+ preserveTtsOnOpen: Boolean = false,
+ persistToLibrary: Boolean = true
) {
val openBookStartTime = System.currentTimeMillis()
ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type")
@@ -5293,16 +5492,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
- persistReaderSession(bookId, type)
- addFileToRecent(
- uri,
- type,
- bookId,
- customDisplayName = originalDisplayName,
- isRecent = true,
- sourceFolderUri = null,
- bundleResult = bundleResult
- )
+ if (persistToLibrary) {
+ persistReaderSession(bookId, type)
+ addFileToRecent(
+ uri,
+ type,
+ bookId,
+ customDisplayName = originalDisplayName,
+ isRecent = true,
+ sourceFolderUri = null,
+ bundleResult = bundleResult
+ )
+ }
if (!suppressNavigation) {
Timber.tag("FileSwitch").d("PDF state updated, emitting navigation event")
@@ -5343,7 +5544,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
- persistReaderSession(bookId, type)
+ if (persistToLibrary) {
+ persistReaderSession(bookId, type)
+ }
if (!suppressNavigation) {
Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event")
@@ -5352,22 +5555,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when (type) {
FileType.EPUB -> {
- loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
+ loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
}
FileType.MOBI -> {
- loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
+ loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
}
FileType.FB2 -> {
- loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
+ loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
}
FileType.ODT, FileType.FODT -> {
- loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult)
+ loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
}
else -> {
loadSingleFile(
- uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult
+ uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary
)
}
}
@@ -5390,7 +5593,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private fun loadFb2(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
+ private fun loadFb2(
+ uri: Uri,
+ bookId: String,
+ customDisplayName: String? = null,
+ bundleResult: CalibreBundleResult? = null,
+ persistToLibrary: Boolean = true
+ ) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 START")
viewModelScope.launch {
@@ -5412,9 +5621,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.i("FB2 parsing successful. Title: ${fb2Book.title}")
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 completed | chapters=${fb2Book.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
- addFileToRecent(
- uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
- )
+ if (persistToLibrary) {
+ addFileToRecent(
+ uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
+ )
+ }
_internalState.update { it.copy(selectedEpubBook = fb2Book, isLoading = false) }
} catch (e: Exception) {
@@ -5426,7 +5637,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private fun loadOdt(uri: Uri, bookId: String, isFlat: Boolean, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
+ private fun loadOdt(
+ uri: Uri,
+ bookId: String,
+ isFlat: Boolean,
+ customDisplayName: String? = null,
+ bundleResult: CalibreBundleResult? = null,
+ persistToLibrary: Boolean = true
+ ) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt START | isFlat=$isFlat")
viewModelScope.launch {
@@ -5449,9 +5667,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.i("ODT parsing successful. Title: ${odtBook.title}")
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt completed | chapters=${odtBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
- addFileToRecent(
- uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
- )
+ if (persistToLibrary) {
+ addFileToRecent(
+ uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
+ )
+ }
_internalState.update { it.copy(selectedEpubBook = odtBook, isLoading = false) }
} catch (e: Exception) {
@@ -5468,7 +5688,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
bookId: String,
type: FileType,
customDisplayName: String? = null,
- bundleResult: CalibreBundleResult? = null
+ bundleResult: CalibreBundleResult? = null,
+ persistToLibrary: Boolean = true
) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type")
@@ -5506,16 +5727,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileOpenPerf")
.d("[$bookId] loadSingleFile: importSingleFile completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
Timber.i("Import successful ($type). Title: ${epubBook.title}")
- addFileToRecent(
- uri,
- type,
- bookId,
- epubBook,
- customDisplayName,
- isRecent = true,
- sourceFolderUri = null,
- bundleResult = bundleResult
- )
+ if (persistToLibrary) {
+ addFileToRecent(
+ uri,
+ type,
+ bookId,
+ epubBook,
+ customDisplayName,
+ isRecent = true,
+ sourceFolderUri = null,
+ bundleResult = bundleResult
+ )
+ }
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf")
@@ -5554,7 +5777,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return resolveFileTypeFromMetadata(fileName, mimeType)
}
- private fun loadMobi(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
+ private fun loadMobi(
+ uri: Uri,
+ bookId: String,
+ customDisplayName: String? = null,
+ bundleResult: CalibreBundleResult? = null,
+ persistToLibrary: Boolean = true
+ ) {
viewModelScope.launch {
if (!_internalState.value.isLoading) {
_internalState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -5576,16 +5805,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (mobiAsEpubBook != null) {
Timber.i("MOBI parsing successful. Title: ${mobiAsEpubBook.title}")
- addFileToRecent(
- uri,
- FileType.MOBI,
- bookId,
- mobiAsEpubBook,
- customDisplayName,
- isRecent = true,
- sourceFolderUri = null,
- bundleResult = bundleResult
- )
+ if (persistToLibrary) {
+ addFileToRecent(
+ uri,
+ FileType.MOBI,
+ bookId,
+ mobiAsEpubBook,
+ customDisplayName,
+ isRecent = true,
+ sourceFolderUri = null,
+ bundleResult = bundleResult
+ )
+ }
_internalState.update {
it.copy(selectedEpubBook = mobiAsEpubBook, isLoading = false)
}
@@ -5607,7 +5838,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private fun loadEpub(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
+ private fun loadEpub(
+ uri: Uri,
+ bookId: String,
+ customDisplayName: String? = null,
+ bundleResult: CalibreBundleResult? = null,
+ persistToLibrary: Boolean = true
+ ) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START")
viewModelScope.launch {
@@ -5633,16 +5870,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileOpenPerf")
.d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
- addFileToRecent(
- uri,
- FileType.EPUB,
- bookId,
- epubBook,
- customDisplayName,
- isRecent = true,
- sourceFolderUri = null,
- bundleResult = bundleResult
- )
+ if (persistToLibrary) {
+ addFileToRecent(
+ uri,
+ FileType.EPUB,
+ bookId,
+ epubBook,
+ customDisplayName,
+ isRecent = true,
+ sourceFolderUri = null,
+ bundleResult = bundleResult
+ )
+ }
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf")
@@ -7081,6 +7320,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_LAST_OPEN_BOOK_ID = "last_open_book_id"
private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type"
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior"
+ private const val EXTERNAL_FILE_BEHAVIOR_ASK = "ASK"
+ private const val EXTERNAL_FILE_BEHAVIOR_TEMPORARY = "TEMPORARY"
private const val KEY_PENDING_EXTERNAL_FILE_REMOVALS = "pending_external_file_removals"
private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter"
private const val KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME = "use_pdf_file_name_as_display_name"
diff --git a/app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt b/app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt
index 5de4838..0865865 100644
--- a/app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt
+++ b/app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt
@@ -2,7 +2,10 @@ package com.aryan.reader
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import com.aryan.reader.data.RecentFileItem
@Composable
@@ -64,28 +67,17 @@ internal fun ReaderFileInfoDialogs(
}
}
- item?.let { fileInfoItem ->
- if (isFileInfoVisible) {
- FileInfoDialog(
- item = fileInfoItem,
- usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
- onDismiss = { onFileInfoVisibleChange(false) },
- onSaveMetadata = { metadata ->
- viewModel.updateBookMetadata(fileInfoItem.bookId, metadata)
- },
- onSaveDisplayName = { name ->
- viewModel.updateCustomName(fileInfoItem.bookId, name)
- },
- onRestoreMetadata = {
- viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId)
- },
- onOpenTags = {
- onFileInfoVisibleChange(false)
- viewModel.openTagSelection(setOf(fileInfoItem.bookId))
- }
- )
+ HydratedFileInfoDialog(
+ item = item,
+ isVisible = isFileInfoVisible,
+ uiState = uiState,
+ viewModel = viewModel,
+ onDismiss = { onFileInfoVisibleChange(false) },
+ onOpenTags = { bookId ->
+ onFileInfoVisibleChange(false)
+ viewModel.openTagSelection(setOf(bookId))
}
- }
+ )
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
TagSelectionBottomSheet(
@@ -102,3 +94,49 @@ internal fun ReaderFileInfoDialogs(
)
}
}
+
+@Composable
+internal fun HydratedFileInfoDialog(
+ item: RecentFileItem?,
+ isVisible: Boolean,
+ uiState: ReaderScreenState,
+ viewModel: MainViewModel,
+ onDismiss: () -> Unit,
+ onOpenTags: (String) -> Unit
+) {
+ var fileInfoItem by remember(item?.bookId) { mutableStateOf(item) }
+ var hasResolvedFullItem by remember(item?.bookId) { mutableStateOf(false) }
+
+ LaunchedEffect(item) {
+ fileInfoItem = item
+ hasResolvedFullItem = false
+ }
+
+ LaunchedEffect(isVisible, item?.bookId) {
+ if (isVisible && item != null) {
+ fileInfoItem = viewModel.getFileInfoItem(item.bookId)?.copy(tags = item.tags) ?: item
+ hasResolvedFullItem = true
+ }
+ }
+
+ val resolvedItem = fileInfoItem
+ if (isVisible && resolvedItem != null && hasResolvedFullItem) {
+ FileInfoDialog(
+ item = resolvedItem,
+ usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
+ onDismiss = onDismiss,
+ onSaveMetadata = { metadata ->
+ viewModel.updateBookMetadata(resolvedItem.bookId, metadata)
+ },
+ onSaveDisplayName = { name ->
+ viewModel.updateCustomName(resolvedItem.bookId, name)
+ },
+ onRestoreMetadata = {
+ viewModel.restoreOriginalBookMetadata(resolvedItem.bookId)
+ },
+ onOpenTags = {
+ onOpenTags(resolvedItem.bookId)
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/ReaderFontDiagnostics.kt b/app/src/main/java/com/aryan/reader/ReaderFontDiagnostics.kt
new file mode 100644
index 0000000..ec6c07c
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/ReaderFontDiagnostics.kt
@@ -0,0 +1,15 @@
+package com.aryan.reader
+
+import com.aryan.reader.shared.detectFontVariant
+import com.aryan.reader.shared.familyFilenameSignature
+import com.aryan.reader.shared.supportsVariableWeightAxis
+
+const val ReaderFontDiagnosticsTag = "ReaderFontDiag"
+
+fun readerFontDiagnosticSummary(nameWithoutExtension: String): String {
+ val variant = nameWithoutExtension.detectFontVariant()
+ return "name='$nameWithoutExtension' " +
+ "signature='${nameWithoutExtension.familyFilenameSignature()}' " +
+ "variant=$variant " +
+ "variableWght=${nameWithoutExtension.supportsVariableWeightAxis()}"
+}
diff --git a/app/src/main/java/com/aryan/reader/ReaderPopupSizing.kt b/app/src/main/java/com/aryan/reader/ReaderPopupSizing.kt
new file mode 100644
index 0000000..ec2c327
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/ReaderPopupSizing.kt
@@ -0,0 +1,19 @@
+package com.aryan.reader
+
+import kotlin.math.roundToInt
+
+fun readerModalMaxHeightDp(
+ screenHeightDp: Int,
+ fraction: Float = 0.85f,
+ verticalMarginDp: Int = 32,
+ preferredMinHeightDp: Int = 220
+): Int {
+ val usableHeight = (screenHeightDp - verticalMarginDp).coerceAtLeast(1)
+ val proportionalHeight = (screenHeightDp * fraction).roundToInt().coerceAtLeast(1)
+ val cappedHeight = minOf(usableHeight, proportionalHeight)
+ return if (usableHeight >= preferredMinHeightDp) {
+ cappedHeight.coerceAtLeast(preferredMinHeightDp)
+ } else {
+ usableHeight
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt
index 98a75d3..5be6d68 100644
--- a/app/src/main/java/com/aryan/reader/SharedComposables.kt
+++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt
@@ -87,6 +87,7 @@ import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.SelectAll
+import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.FileOpen
import androidx.compose.material.icons.outlined.Gavel
import androidx.compose.material.icons.outlined.Policy
@@ -142,6 +143,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.core.text.HtmlCompat
+import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.SharedLegalLinks
import com.aryan.reader.shared.SharedLegalProfile
import com.aryan.reader.data.BookMetadataEdit
@@ -274,6 +276,8 @@ fun ContextualTopAppBar(
selectedItemCount: Int,
onNavIconClick: () -> Unit,
onInfoClick: (() -> Unit)? = null,
+ onSaveClick: (() -> Unit)? = null,
+ onShareClick: (() -> Unit)? = null,
onTagClick: (() -> Unit)? = null,
onSelectAllClick: (() -> Unit)? = null,
onPinClick: (() -> Unit)? = null,
@@ -302,6 +306,16 @@ fun ContextualTopAppBar(
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.info))
}
}
+ if (selectedItemCount == 1 && onSaveClick != null) {
+ IconButton(onClick = onSaveClick) {
+ Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.action_save_copy_to_device))
+ }
+ }
+ if (selectedItemCount == 1 && onShareClick != null) {
+ IconButton(onClick = onShareClick) {
+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.action_share))
+ }
+ }
if (onSelectAllClick != null) {
IconButton(onClick = onSelectAllClick) {
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all))
@@ -1584,6 +1598,31 @@ fun RecentFileItem.isOpdsStream(): Boolean {
return uriString?.startsWith("opds-pse://") == true
}
+fun RecentFileItem.canExportOriginalFile(): Boolean {
+ return uriString != null && !isOpdsStream()
+}
+
+fun RecentFileItem.suggestedOriginalFileName(): String {
+ val fallbackExtension = SharedFileCapabilities.primaryExtensionFor(type)
+ val baseName = displayName
+ .takeIf { it.isNotBlank() }
+ ?: title?.takeIf { it.isNotBlank() }
+ ?: "book"
+ val sanitized = baseName
+ .replace(Regex("""[\\/:*?"<>|]+"""), "_")
+ .trim()
+ .take(120)
+ .ifBlank { "book" }
+ return if (
+ fallbackExtension != null &&
+ !sanitized.endsWith(".$fallbackExtension", ignoreCase = true)
+ ) {
+ "$sanitized.$fallbackExtension"
+ } else {
+ sanitized
+ }
+}
+
@Composable
private fun statusBadgeColors(overlay: Boolean): Pair {
val container = if (overlay) {
diff --git a/app/src/main/java/com/aryan/reader/UiLabelResources.kt b/app/src/main/java/com/aryan/reader/UiLabelResources.kt
index 5467cd1..c649619 100644
--- a/app/src/main/java/com/aryan/reader/UiLabelResources.kt
+++ b/app/src/main/java/com/aryan/reader/UiLabelResources.kt
@@ -67,7 +67,8 @@ val supportedAppLanguageOptions = listOf(
"中文",
"简体中文",
)
- )
+ ),
+ AppLanguageOption("et", R.string.language_estonian, listOf("estonian", "eesti"))
)
val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions
diff --git a/app/src/main/java/com/aryan/reader/data/FontsRepository.kt b/app/src/main/java/com/aryan/reader/data/FontsRepository.kt
index 8f96108..9b1f0a9 100644
--- a/app/src/main/java/com/aryan/reader/data/FontsRepository.kt
+++ b/app/src/main/java/com/aryan/reader/data/FontsRepository.kt
@@ -25,12 +25,15 @@ import android.provider.OpenableColumns
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
+import com.aryan.reader.ReaderFontDiagnosticsTag
+import com.aryan.reader.readerFontDiagnosticSummary
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
private const val FONTS_DIR = "custom_fonts"
+private const val MAX_IMPORTED_FONT_BASENAME_LENGTH = 120
class FontsRepository(private val context: Context) {
private val fontDao = AppDatabase.getDatabase(context).customFontDao()
@@ -73,14 +76,23 @@ class FontsRepository(private val context: Context) {
val contentResolver = context.contentResolver
val originalName = getFileName(uri) ?: "unknown.ttf"
val extension = originalName.substringAfterLast('.', "").lowercase()
+ val displayName = originalName.substringBeforeLast('.')
+
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "import.start originalName='$originalName' extension='$extension' " +
+ readerFontDiagnosticSummary(displayName)
+ )
if (extension !in listOf("ttf", "otf", "woff2")) {
+ Timber.tag(ReaderFontDiagnosticsTag).w(
+ "import.unsupported originalName='$originalName' extension='$extension'"
+ )
return@withContext Result.failure(Exception("Unsupported font format. Please use TTF, OTF, or WOFF2."))
}
val fontId = UUID.randomUUID().toString()
- val internalFileName = "font_${fontId}.$extension"
- val destinationFile = File(fontsDir, internalFileName)
+ val destinationFile = uniqueImportedFontFile(displayName, extension, fontId)
+ val internalFileName = destinationFile.name
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(destinationFile).use { output ->
@@ -88,8 +100,6 @@ class FontsRepository(private val context: Context) {
}
}
- val displayName = originalName.substringBeforeLast('.')
-
val entity = CustomFontEntity(
id = fontId,
displayName = displayName,
@@ -100,10 +110,16 @@ class FontsRepository(private val context: Context) {
)
fontDao.insertFont(entity)
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "import.saved displayName='$displayName' internalFileName='$internalFileName' " +
+ "exists=${destinationFile.exists()} bytes=${destinationFile.length()} " +
+ "path='${destinationFile.absolutePath}'"
+ )
Timber.d("Imported font: $displayName to ${destinationFile.absolutePath}")
Result.success(entity)
} catch (e: Exception) {
+ Timber.tag(ReaderFontDiagnosticsTag).e(e, "import.failed uri='$uri'")
Timber.e(e, "Failed to import font")
Result.failure(e)
}
@@ -130,6 +146,18 @@ class FontsRepository(private val context: Context) {
fontDao.deletePermanently(fontId)
}
+ private fun uniqueImportedFontFile(displayName: String, extension: String, fontId: String): File {
+ val preferredFileName = importedFontFileName(displayName, extension)
+ val preferredFile = File(fontsDir, preferredFileName)
+ if (!preferredFile.exists()) return preferredFile
+
+ val fallbackFileName = importedFontFileName(
+ displayName = "${displayName}_${fontId.take(8)}",
+ extension = extension
+ )
+ return File(fontsDir, fallbackFileName)
+ }
+
private fun getFileName(uri: Uri): String? {
var result: String? = null
if (uri.scheme == "content") {
@@ -152,4 +180,18 @@ class FontsRepository(private val context: Context) {
}
return result
}
-}
\ No newline at end of file
+}
+
+internal fun importedFontFileName(displayName: String, extension: String): String {
+ val safeBaseName = displayName
+ .replace(Regex("""[\\/:*?"<>|\p{Cntrl}]"""), "_")
+ .replace(Regex("""\s+"""), " ")
+ .trim(' ', '.')
+ .take(MAX_IMPORTED_FONT_BASENAME_LENGTH)
+ .ifBlank { "font" }
+ val safeExtension = extension
+ .lowercase()
+ .replace(Regex("""[^a-z0-9]"""), "")
+ .ifBlank { "ttf" }
+ return "$safeBaseName.$safeExtension"
+}
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
index be5dbd1..b42d72a 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
+++ b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
@@ -33,7 +33,7 @@ interface RecentFileDao {
@Upsert
suspend fun insertOrUpdateFiles(files: List)
- @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
+ @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, substr(description, 1, 4096) AS description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, substr(originalDescription, 1, 4096) AS originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
fun getRecentFiles(): Flow>
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
@@ -45,7 +45,7 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
- @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
+ @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, substr(description, 1, 4096) AS description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, substr(originalDescription, 1, 4096) AS originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
fun getRecentFilesList(limit: Int): List
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
diff --git a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt
index 716fba2..0ba2106 100644
--- a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt
+++ b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt
@@ -457,19 +457,17 @@ class EpubParser(private val context: Context) {
var pageTargets: List = emptyList()
val ncxMetadataMap = mutableMapOf()
val extractionRoot = File(extractionBasePath)
+ val tocFileItem = if (shouldUseToc) resolveTocFileItem(document.spine, manifestItems) else null
+ val tocDocumentNode = tocFileItem?.let { item ->
+ val ncxData = filesContentMap[item.absPath]?.data?.takeIf { it.isNotEmpty() }
+ ?: File(extractionRoot, item.absPath).takeIf { it.exists() }?.readBytes()
+ ncxData?.let { parseXMLFile(it) }
+ }
+ val ncxParentDir = tocFileItem?.let { File(it.absPath).parentFile ?: File("") }
if (shouldUseToc) {
Timber.d("shouldUseToc is true. Attempting to parse NCX.")
- val tocFileItem = manifestItems.values.firstOrNull {
- it.absPath.endsWith(".ncx", ignoreCase = true)
- }
- if (tocFileItem != null) {
- val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
- val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
- ?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
-
- val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
-
+ if (tocFileItem != null && ncxParentDir != null) {
if (tocDocumentNode != null) {
Timber.d("Successfully parsed NCX file: ${tocFileItem.absPath}")
val pageListElement = tocDocumentNode.selectFirstTag("pageList") as Element?
@@ -495,15 +493,8 @@ class EpubParser(private val context: Context) {
Timber.d("Parsing chapters based on OPF spine for rendering order. NCX titles/depth will be used if available.")
val tableOfContents = if (shouldUseToc) {
- val tocFileItem = manifestItems.values.firstOrNull {
- it.absPath.endsWith(".ncx", ignoreCase = true)
- }
- if (tocFileItem != null) {
- val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
- val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
- ?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
- val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
- val navMapElement = tocDocumentNode?.selectFirstTag("navMap") as Element?
+ if (tocDocumentNode != null && ncxParentDir != null) {
+ val navMapElement = tocDocumentNode.selectFirstTag("navMap") as Element?
if (navMapElement != null) {
parseTableOfContents(navMapElement, ncxParentDir)
@@ -592,6 +583,21 @@ class EpubParser(private val context: Context) {
return result
}
+ private fun resolveTocFileItem(
+ spine: Node,
+ manifestItems: Map
+ ): EpubManifestItem? {
+ spine.getAttributeValue("toc")
+ ?.takeIf { it.isNotBlank() }
+ ?.let { tocId -> manifestItems[tocId] }
+ ?.let { return it }
+
+ return manifestItems.values.firstOrNull {
+ it.mediaType.equals("application/x-dtbncx+xml", ignoreCase = true)
+ } ?: manifestItems.values.firstOrNull {
+ it.absPath.endsWith(".ncx", ignoreCase = true)
+ }
+ }
@Throws(EpubParserException::class)
private fun createEpubDocument(files: Map): EpubDocument {
diff --git a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt
index 9956b5c..13a3c07 100644
--- a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt
+++ b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt
@@ -41,7 +41,7 @@ class OdtParser(private val context: Context) {
val mathJaxFileName = "tex-mml-chtml.js"
val mathJaxFile = File(extractionDir, mathJaxFileName)
- if (!mathJaxFile.exists()) {
+ if (parseContent && !mathJaxFile.exists()) {
try {
context.assets.open("mathjax/$mathJaxFileName").use { input ->
FileOutputStream(mathJaxFile).use { output ->
@@ -170,32 +170,33 @@ class OdtParser(private val context: Context) {
try {
if (!isFlat) {
- val zis = ZipInputStream(inputStream)
- var entry = zis.nextEntry
var contentXmlBytes: ByteArray? = null
var stylesXmlBytes: ByteArray? = null
val ignoredFiles = setOf("meta.xml", "settings.xml", "META-INF/manifest.xml")
- while (entry != null) {
- if (!entry.isDirectory) {
- when (entry.name) {
- "content.xml" -> contentXmlBytes = zis.readBytes()
- "styles.xml" -> stylesXmlBytes = zis.readBytes()
- "Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes()
- else -> {
- if (entry.name !in ignoredFiles) {
- val extractedFile = safeFileInRoot(extractionDir, entry.name)
- if (extractedFile != null) {
- extractedFile.parentFile?.mkdirs()
- FileOutputStream(extractedFile).use { out -> zis.copyTo(out) }
- } else {
- Timber.w("Skipping unsafe ODT entry outside extraction root: ${entry.name}")
+ ZipInputStream(inputStream).use { zis ->
+ var entry = zis.nextEntry
+ while (entry != null) {
+ if (!entry.isDirectory) {
+ when (entry.name) {
+ "content.xml" -> contentXmlBytes = zis.readBytes()
+ "styles.xml" -> stylesXmlBytes = zis.readBytes()
+ "Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes()
+ else -> {
+ if (entry.name !in ignoredFiles) {
+ val extractedFile = safeFileInRoot(extractionDir, entry.name)
+ if (extractedFile != null) {
+ extractedFile.parentFile?.mkdirs()
+ FileOutputStream(extractedFile).use { out -> zis.copyTo(out) }
+ } else {
+ Timber.w("Skipping unsafe ODT entry outside extraction root: ${entry.name}")
+ }
}
}
}
}
+ entry = zis.nextEntry
}
- entry = zis.nextEntry
}
// Pre-parse styles if available
diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
index 84a7e93..19fa399 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
@@ -22,8 +22,6 @@ package com.aryan.reader.epubreader
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
-import android.content.ClipData
-import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Color
@@ -38,8 +36,6 @@ import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -52,8 +48,10 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -76,6 +74,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
@@ -86,7 +85,13 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri
import com.aryan.reader.R
+import com.aryan.reader.ReaderFontDiagnosticsTag
+import com.aryan.reader.copyPlainTextToClipboard
import com.aryan.reader.getReaderTextureDataUri
+import com.aryan.reader.readerFontDiagnosticSummary
+import com.aryan.reader.shared.detectFontVariant
+import com.aryan.reader.shared.familyFilenameSignature
+import com.aryan.reader.shared.fontWeightCssDescriptor
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
import com.aryan.reader.shared.ui.SharedSelectionMenuSize
import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
@@ -96,6 +101,7 @@ import kotlinx.coroutines.launch
import org.json.JSONObject
import timber.log.Timber
import java.io.BufferedReader
+import java.io.File
import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV"
@@ -202,6 +208,49 @@ private fun getFontCssInjection(): String {
""".trimIndent()
}
+private fun buildCustomFontCssForWebView(customFontPath: String?, phase: String): String {
+ if (customFontPath == null) return ""
+
+ val fontFile = File(customFontPath)
+ val signature = fontFile.nameWithoutExtension.familyFilenameSignature()
+ val siblings = fontFile.parentFile?.listFiles()?.filter {
+ it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
+ it.nameWithoutExtension.familyFilenameSignature() == signature
+ } ?: listOf(fontFile)
+
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "webview.$phase.customCss.start basePath='${fontFile.absolutePath}' " +
+ "exists=${fontFile.exists()} bytes=${fontFile.length()} " +
+ readerFontDiagnosticSummary(fontFile.nameWithoutExtension) +
+ " siblings=${siblings.joinToString { it.name }}"
+ )
+
+ val css = siblings.mapNotNull { sibling ->
+ val variant = sibling.nameWithoutExtension.detectFontVariant()
+ if (variant == null) {
+ Timber.tag(ReaderFontDiagnosticsTag).w(
+ "webview.$phase.customCss.skipNoVariant file='${sibling.name}' " +
+ readerFontDiagnosticSummary(sibling.nameWithoutExtension)
+ )
+ return@mapNotNull null
+ }
+
+ val weight = sibling.nameWithoutExtension.fontWeightCssDescriptor(variant.weight)
+ val style = if (variant.style == FontStyle.Italic) "italic" else "normal"
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "webview.$phase.customCss.face file='${sibling.name}' fontWeight='$weight' fontStyle='$style' " +
+ readerFontDiagnosticSummary(sibling.nameWithoutExtension)
+ )
+ "@font-face { font-family: 'CustomFont'; src: url('file://${sibling.absolutePath}'); font-weight: $weight; font-style: $style; }"
+ }.joinToString(" ")
+
+ val faceCount = Regex("@font-face").findAll(css).count()
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "webview.$phase.customCss.done faceCount=$faceCount cssLength=${css.length}"
+ )
+ return css
+}
+
private fun getJsToInject(context: Context): String {
return try {
context.assets.open("epub_reader.js").use { inputStream ->
@@ -514,6 +563,7 @@ fun ChapterWebView(
onFootnoteRequested: (String) -> Unit,
currentFontFamily: ReaderFont,
customFontPath: String? = null,
+ epubFontFaceCss: String = "",
currentTextAlign: ReaderTextAlign,
onHighlightClicked: () -> Unit,
onAutoScrollChapterEnd: () -> Unit = {},
@@ -578,10 +628,14 @@ fun ChapterWebView(
showExternalLinkDialog = null
}) { Text(stringResource(R.string.action_open)) }
TextButton(onClick = {
- val clipboard =
- context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
- val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow)
- clipboard.setPrimaryClip(clip)
+ val copied = copyPlainTextToClipboard(
+ context = context,
+ label = context.getString(R.string.clip_label_copied_link),
+ text = urlToShow
+ )
+ if (!copied) {
+ Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
+ }
showExternalLinkDialog = null
}) { Text(stringResource(R.string.action_copy)) }
}
@@ -927,13 +981,14 @@ fun ChapterWebView(
)
val fontCss = getFontCssInjection().replace("\n", " ")
- val customFontCss = if (customFontPath != null) {
- "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
- } else ""
- val combinedCss = "$fontCss $customFontCss"
+ val customFontCss = buildCustomFontCssForWebView(customFontPath, "initial")
+ val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss)
+ .filter { it.isNotBlank() }
+ .joinToString(separator = " ")
+ val escapedCombinedCss = escapeJsString(combinedCss)
val injectFontJs =
- "var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$combinedCss\"; document.head.appendChild(style);"
+ "var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$escapedCombinedCss\"; document.head.appendChild(style);"
view?.evaluateJavascript("javascript:$injectFontJs") {
Timber.d("CSS Injection result: $it")
}
@@ -1125,10 +1180,10 @@ fun ChapterWebView(
runtimeApplierState.logPending(chapterTitle)
} else {
val fontCss = getFontCssInjection().replace("\n", " ")
- val customFontCss = if (customFontPath != null) {
- "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
- } else ""
- val combinedCss = "$fontCss $customFontCss"
+ val customFontCss = buildCustomFontCssForWebView(customFontPath, "runtime")
+ val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss)
+ .filter { it.isNotBlank() }
+ .joinToString(separator = " ")
val fontNameForJs = if (customFontPath != null) {
"CustomFont"
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
@@ -1166,8 +1221,9 @@ fun ChapterWebView(
if (fontCssChanged) {
runtimeApplierState.fontCss = combinedCss
+ val escapedCombinedCss = escapeJsString(combinedCss)
val injectFontJs =
- "var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";"
+ "var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$escapedCombinedCss\";"
webView.evaluateJavascript("javascript:$injectFontJs", null)
}
@@ -1304,10 +1360,14 @@ fun ChapterWebView(
) {
PaginatedTextSelectionMenu(
onCopy = {
- val clipboard =
- context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
- val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), state.selectedText)
- clipboard.setPrimaryClip(clip)
+ val copied = copyPlainTextToClipboard(
+ context = context,
+ label = context.getString(R.string.clip_label_copied_text),
+ text = state.selectedText
+ )
+ if (!copied) {
+ Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
+ }
state.finishActionModeCallback()
localWebViewRef?.clearFocus()
localWebViewRef?.evaluateJavascript(
diff --git a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt
index 0aab63d..2475f83 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt
@@ -4,7 +4,10 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -20,12 +23,14 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -35,14 +40,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Dialog
import androidx.core.graphics.drawable.toBitmap
import com.aryan.reader.R
import com.aryan.reader.areReaderAiFeaturesEnabled
+import com.aryan.reader.readerModalMaxHeightDp
@Suppress("KotlinConstantConditions")
@OptIn(ExperimentalMaterial3Api::class)
@@ -65,25 +71,28 @@ fun DictionarySettingsDialog(
val context = LocalContext.current
var dictionaryApps by remember { mutableStateOf>(emptyList()) }
var searchApps by remember { mutableStateOf>(emptyList()) }
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val configuration = LocalConfiguration.current
+ val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
LaunchedEffect(Unit) {
dictionaryApps = ExternalDictionaryHelper.getAvailableDictionaries(context)
searchApps = ExternalDictionaryHelper.getAvailableSearchApps(context)
}
- Dialog(onDismissRequest = onDismiss) {
- Surface(
- shape = RoundedCornerShape(24.dp),
- color = MaterialTheme.colorScheme.surface,
- tonalElevation = 6.dp,
- modifier = Modifier.fillMaxWidth()
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentWindowInsets = { WindowInsets.navigationBars }
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = maxSheetHeight)
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp)
) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(rememberScrollState())
- .padding(24.dp)
- ) {
Text(
text = stringResource(R.string.dict_lookup_settings),
style = MaterialTheme.typography.headlineSmall,
@@ -212,7 +221,6 @@ fun DictionarySettingsDialog(
onSelect = onSelectSearchPackage,
placeholder = stringResource(R.string.dict_select_app)
)
- }
}
}
}
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
index 96e7f55..9834106 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
@@ -27,8 +27,6 @@ package com.aryan.reader.epubreader
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
-import android.content.ClipData
-import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioManager
@@ -140,6 +138,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -154,6 +153,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BuildConfig
+import com.aryan.reader.copyPlainTextToClipboard
import com.aryan.reader.BookWordReplacementsSheet
import com.aryan.reader.BuiltInThemes
import com.aryan.reader.MainViewModel
@@ -191,6 +191,7 @@ import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.readerSliderBookmarkPosition
import com.aryan.reader.readerSliderChromeColors
import com.aryan.reader.readerSliderToggleState
+import com.aryan.reader.paginatedreader.CssParser
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.HeaderBlock
import com.aryan.reader.paginatedreader.IPaginator
@@ -204,7 +205,10 @@ import com.aryan.reader.paginatedreader.ParagraphBlock
import com.aryan.reader.paginatedreader.QuoteBlock
import com.aryan.reader.paginatedreader.TextContentBlock
import com.aryan.reader.paginatedreader.TtsChunk
+import com.aryan.reader.paginatedreader.buildEpubFontFaceCss
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
+import com.aryan.reader.paginatedreader.locatorForPersistence
+import com.aryan.reader.paginatedreader.nativeVerticalChapterPageInfo
import com.aryan.reader.paginatedreader.nativeVerticalProgressForCompatPage
import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState
@@ -243,6 +247,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
+import org.jsoup.Jsoup
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
@@ -1127,6 +1132,20 @@ fun EpubReaderHost(
var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf>(emptyList()) }
var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf>(emptyList()) }
var chapterHead by remember(currentChapterIndex) { mutableStateOf("") }
+ val epubFontFaceCss = remember(epubBook.css, epubBook.extractionBasePath) {
+ val fontFaces = epubBook.css.flatMap { (path, content) ->
+ CssParser.parse(
+ cssContent = content,
+ cssPath = path,
+ baseFontSizeSp = 16f,
+ density = 1f,
+ constraints = Constraints(maxWidth = 1, maxHeight = 1),
+ isDarkTheme = false,
+ adaptThemeColors = false
+ ).fontFaces
+ }
+ buildEpubFontFaceCss(fontFaces, epubBook.extractionBasePath)
+ }
var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) }
var cfiToLoad by remember { mutableStateOf(initialCfi) }
@@ -1175,7 +1194,7 @@ fun EpubReaderHost(
fun currentNativeVerticalLocator(): Locator? {
val bookPaginator = paginator as? BookPaginator
val pageChapterIndex = bookPaginator?.findChapterIndexForPage(nativeVerticalCurrentPage)
- return nativeVerticalLocation?.locator
+ return nativeVerticalLocation?.locatorForPersistence()
?: lastKnownLocator?.takeIf { pageChapterIndex == null || it.chapterIndex == pageChapterIndex }
?: bookPaginator?.getLocatorForPage(nativeVerticalCurrentPage)
}
@@ -1187,6 +1206,8 @@ fun EpubReaderHost(
keepVisible: Boolean = false
) {
if (locator != null) {
+ nativeVerticalScrollRequest = null
+ nativeVerticalProgressScrollRequest = null
nativeVerticalLocatorScrollRequest = locator
nativeVerticalLocatorScrollRequestId += 1L
nativeVerticalLocatorScrollKeepVisible = keepVisible
@@ -2110,6 +2131,44 @@ fun EpubReaderHost(
}
}
+ val nativeVerticalDisplayPageInfo = remember(
+ isNativeVerticalMode,
+ nativeVerticalLocation,
+ nativeVerticalCurrentPage,
+ nativeVerticalTotalPages,
+ currentChapterIndex,
+ lastKnownLocator,
+ paginator
+ ) {
+ if (!isNativeVerticalMode) {
+ null
+ } else {
+ nativeVerticalLocation?.chapterPageInfo ?: run {
+ val bookPaginator = paginator as? BookPaginator
+ val locationLocator = nativeVerticalLocation?.locator
+ val chapterIndex = nativeVerticalLocation?.chapterIndex
+ ?: locationLocator?.chapterIndex
+ ?: lastKnownLocator?.chapterIndex
+ ?: currentChapterIndex
+ val locatorForChapter = locationLocator
+ ?.takeIf { it.chapterIndex == chapterIndex }
+ ?: lastKnownLocator?.takeIf { it.chapterIndex == chapterIndex }
+ val chapterLengthChars = chapters
+ .getOrNull(chapterIndex)
+ ?.plainTextCharacterCount()
+ ?: 0
+
+ nativeVerticalChapterPageInfo(
+ chapterCharOffset = locatorForChapter?.charOffset,
+ chapterLengthChars = chapterLengthChars,
+ chapterPageCount = bookPaginator?.chapterPageCounts?.get(chapterIndex),
+ compatPageIndex = nativeVerticalCurrentPage,
+ chapterStartPageIndex = bookPaginator?.chapterStartPageIndices?.get(chapterIndex)
+ )
+ }
+ }
+ }
+
fun currentEpubSliderPage(): Int {
return when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> if (isNativeVerticalMode) {
@@ -4280,6 +4339,74 @@ fun EpubReaderHost(
val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel()
val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel()
val isEpubJumpHistoryVisible = showBars && !searchState.isSearchActive && (epubJumpBackLabel != null || epubJumpForwardLabel != null)
+ val keyboardLineScrollPx = with(density) {
+ (configuration.screenHeightDp.dp.toPx() * 0.16f).roundToInt().coerceAtLeast(96)
+ }
+ val keyboardPageScrollPx = with(density) {
+ (configuration.screenHeightDp.dp.toPx() * 0.82f).roundToInt().coerceAtLeast(keyboardLineScrollPx)
+ }
+
+ fun scrollVerticalReaderBy(deltaPx: Int) {
+ if (isNativeVerticalMode) {
+ nativeVerticalScrollDeltaRequestId += 1L
+ nativeVerticalScrollDeltaAnimated = false
+ nativeVerticalScrollDeltaRequest = deltaPx.toFloat()
+ } else {
+ webViewRefForTts?.evaluateJavascript(
+ "window.scrollBy({ top: $deltaPx, behavior: 'smooth' });",
+ null
+ )
+ }
+ }
+
+ fun navigateReaderPage(targetPage: Int) {
+ when {
+ isNativeVerticalMode -> {
+ val lastPage = (nativeVerticalTotalPages - 1).coerceAtLeast(0)
+ nativeVerticalScrollRequest = targetPage.coerceIn(0, lastPage)
+ }
+ currentRenderMode == RenderMode.VERTICAL_SCROLL -> {
+ scrollVerticalReaderBy((targetPage - nativeVerticalCurrentPage).coerceIn(-1, 1) * keyboardPageScrollPx)
+ }
+ else -> {
+ scope.launch {
+ val pageCount = paginatedPagerState.pageCount
+ if (pageCount <= 0) return@launch
+ val page = targetPage.coerceIn(0, pageCount - 1)
+ if (page != paginatedPagerState.currentPage) {
+ if (isPageTurnAnimationEnabled) {
+ paginatedPagerState.animateScrollToPage(page, animationSpec = tween(700))
+ } else {
+ paginatedPagerState.scrollToPage(page)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun navigateReaderPageBy(delta: Int) {
+ when {
+ isNativeVerticalMode -> navigateReaderPage(nativeVerticalCurrentPage + delta)
+ currentRenderMode == RenderMode.VERTICAL_SCROLL -> scrollVerticalReaderBy(delta * keyboardPageScrollPx)
+ else -> navigateReaderPage(paginatedPagerState.currentPage + delta)
+ }
+ }
+
+ fun navigateReaderBoundary(first: Boolean) {
+ when {
+ isNativeVerticalMode -> navigateReaderPage(if (first) 0 else nativeVerticalTotalPages - 1)
+ currentRenderMode == RenderMode.VERTICAL_SCROLL -> {
+ val script = if (first) {
+ "window.scrollTo({ top: 0, behavior: 'smooth' });"
+ } else {
+ "window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });"
+ }
+ webViewRefForTts?.evaluateJavascript(script, null)
+ }
+ else -> navigateReaderPage(if (first) 0 else paginatedPagerState.pageCount - 1)
+ }
+ }
Box(
modifier = Modifier
@@ -4363,6 +4490,17 @@ fun EpubReaderHost(
}
}
)
+ .epubReaderKeyboardNavigationHandler(
+ enabled = !searchState.isSearchActive,
+ renderMode = currentRenderMode,
+ isRightToLeftPagination = rightToLeftPagination,
+ verticalLineScrollPx = keyboardLineScrollPx,
+ onVerticalScrollBy = ::scrollVerticalReaderBy,
+ onNextPage = { navigateReaderPageBy(1) },
+ onPreviousPage = { navigateReaderPageBy(-1) },
+ onFirstPage = { navigateReaderBoundary(first = true) },
+ onLastPage = { navigateReaderBoundary(first = false) }
+ )
) {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
@@ -4460,6 +4598,7 @@ fun EpubReaderHost(
},
onLocationChanged = { location ->
nativeVerticalLocation = location
+ location.locatorForPersistence()?.let { lastKnownLocator = it }
},
onTap = {
focusManager.clearFocus()
@@ -4613,6 +4752,27 @@ fun EpubReaderHost(
""".trimIndent()
val chapterToRender = chapters[targetChapterIndex]
+ val chapterFontFaceCss = remember(
+ chapterHead,
+ chapterToRender.absPath,
+ epubBook.extractionBasePath
+ ) {
+ val fontFaces = Jsoup.parse("$chapterHead")
+ .head()
+ .getElementsByTag("style")
+ .flatMap { styleElement ->
+ CssParser.parse(
+ cssContent = styleElement.data(),
+ cssPath = chapterToRender.absPath,
+ baseFontSizeSp = 16f,
+ density = 1f,
+ constraints = Constraints(maxWidth = 1, maxHeight = 1),
+ isDarkTheme = false,
+ adaptThemeColors = false
+ ).fontFaces
+ }
+ buildEpubFontFaceCss(fontFaces, epubBook.extractionBasePath)
+ }
fun isCurrentRenderedChapter(): Boolean =
targetChapterIndex == currentChapterIndex
@@ -4975,6 +5135,9 @@ fun EpubReaderHost(
currentVerticalMargin = currentVerticalMargin,
currentFontFamily = currentFontFamily,
customFontPath = currentCustomFontPath,
+ epubFontFaceCss = listOf(epubFontFaceCss, chapterFontFaceCss)
+ .filter { it.isNotBlank() }
+ .joinToString(separator = " "),
currentTextAlign = currentTextAlign,
activeTextureId = activeTextureId,
activeTextureAlpha = activeTextureAlpha,
@@ -6057,8 +6220,8 @@ fun EpubReaderHost(
?: "Chapter"
val displayPageInfo = when {
- isNativeVerticalMode && nativeVerticalTotalPages > 0 ->
- " (${nativeVerticalCurrentPage + 1}/$nativeVerticalTotalPages)"
+ isNativeVerticalMode && nativeVerticalDisplayPageInfo != null ->
+ " (${nativeVerticalDisplayPageInfo.currentPage}/${nativeVerticalDisplayPageInfo.totalPages})"
currentScrollHeightValue <= 0 || isChapterParsing -> ""
else -> " ($currentPageInChapter/$totalPagesInCurrentChapter)"
}
@@ -7059,9 +7222,14 @@ fun EpubReaderHost(
highlightToNoteCfi = null
},
onCopy = {
- val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
- val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), targetHighlight.text)
- clipboardManager.setPrimaryClip(clip)
+ val copied = copyPlainTextToClipboard(
+ context = context,
+ label = context.getString(R.string.clip_label_copied_text),
+ text = targetHighlight.text
+ )
+ if (!copied) {
+ Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
+ }
highlightToNoteCfi = null
},
onDictionary = {
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt
index 16d3818..6139344 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt
@@ -107,8 +107,19 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import com.aryan.reader.R
+import com.aryan.reader.ReaderFontDiagnosticsTag
import com.aryan.reader.data.CustomFontEntity
+import com.aryan.reader.readerModalMaxHeightDp
+import com.aryan.reader.readerFontDiagnosticSummary
import com.aryan.reader.supportedFontMimeTypes
+import com.aryan.reader.shared.CustomFontItem
+import com.aryan.reader.shared.detectFontVariant
+import com.aryan.reader.shared.fontFaceSummary
+import com.aryan.reader.shared.familyFilenameSignature
+import com.aryan.reader.shared.groupByFamily
+import com.aryan.reader.shared.hasVariableWeightFace
+import com.aryan.reader.shared.supportsVariableWeightAxis
+import timber.log.Timber
import java.io.File
import kotlin.math.roundToInt
@@ -412,8 +423,64 @@ fun getComposeFontFamily(
): FontFamily {
if (customFontPath != null) {
return try {
- FontFamily(Font(File(customFontPath)))
- } catch (_: Exception) {
+ val baseFile = File(customFontPath)
+ val signature = baseFile.nameWithoutExtension.familyFilenameSignature()
+ val siblings = baseFile.parentFile?.listFiles()?.filter {
+ it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
+ it.nameWithoutExtension.familyFilenameSignature() == signature
+ } ?: listOf(baseFile)
+
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "compose.custom.start basePath='${baseFile.absolutePath}' " +
+ "exists=${baseFile.exists()} bytes=${baseFile.length()} " +
+ readerFontDiagnosticSummary(baseFile.nameWithoutExtension) +
+ " siblings=${siblings.joinToString { it.name }}"
+ )
+
+ val seenVariants = mutableSetOf()
+ val fontList = siblings.flatMap { sibling ->
+ try {
+ val variant = sibling.nameWithoutExtension.detectFontVariant()
+ val weights = if (sibling.nameWithoutExtension.supportsVariableWeightAxis()) {
+ variableReaderFontWeights
+ } else {
+ listOf(variant?.weight ?: FontWeight.Normal)
+ }
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "compose.custom.candidate file='${sibling.name}' " +
+ readerFontDiagnosticSummary(sibling.nameWithoutExtension) +
+ " style=${variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal} " +
+ "weights=${weights.joinToString { it.weight.toString() }}"
+ )
+ weights.mapNotNull { weight ->
+ val style = variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal
+ if (seenVariants.add("${weight.weight}|$style")) {
+ Font(sibling, weight, style)
+ } else {
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "compose.custom.skipDuplicate file='${sibling.name}' weight=${weight.weight} style=$style"
+ )
+ null
+ }
+ }
+ } catch (e: Exception) {
+ Timber.tag(ReaderFontDiagnosticsTag).e(e, "compose.custom.candidateFailed file='${sibling.name}'")
+ emptyList()
+ }
+ }
+ if (fontList.isNotEmpty()) {
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "compose.custom.loaded base='${baseFile.name}' registeredVariants=${seenVariants.joinToString()}"
+ )
+ FontFamily(fontList)
+ } else {
+ Timber.tag(ReaderFontDiagnosticsTag).w(
+ "compose.custom.fallbackSingle base='${baseFile.name}' no inferred variants loaded"
+ )
+ FontFamily(Font(baseFile))
+ }
+ } catch (e: Exception) {
+ Timber.tag(ReaderFontDiagnosticsTag).e(e, "compose.custom.failed path='$customFontPath'")
FontFamily.Default
}
}
@@ -436,6 +503,18 @@ fun getComposeFontFamily(
return FontFamily.Default
}
+private val variableReaderFontWeights = listOf(
+ FontWeight.Thin,
+ FontWeight.ExtraLight,
+ FontWeight.Light,
+ FontWeight.Normal,
+ FontWeight.Medium,
+ FontWeight.SemiBold,
+ FontWeight.Bold,
+ FontWeight.ExtraBold,
+ FontWeight.Black
+)
+
fun saveReaderSettings(
context: Context,
fontSize: Float,
@@ -853,16 +932,56 @@ fun FontSelectionSheetContent(
)
}
} else {
+ val customFontFamilies = remember(customFonts) {
+ val grouped = customFonts
+ .filterNot { it.isDeleted }
+ .map { it.toSharedCustomFontItem() }
+ .groupByFamily()
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "picker.grouped count=${grouped.size} families=${
+ grouped.joinToString { family ->
+ "${family.familyName} -> [" +
+ family.variants.joinToString { variantItem ->
+ val font = variantItem.font
+ "${font.displayName}:${variantItem.variant}"
+ } + "]"
+ }
+ }"
+ )
+ grouped
+ }
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
- items(customFonts) { fontEntity ->
- val isSelected = currentCustomFontPath == fontEntity.path
- val fontFamily = remember(fontEntity.path) {
- try { FontFamily(Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default }
+ items(customFontFamilies) { family ->
+ val baseFont = family.variants.firstOrNull {
+ val variant = it.variant
+ variant != null &&
+ variant.weight == FontWeight.Normal &&
+ variant.style == androidx.compose.ui.text.font.FontStyle.Normal
+ }?.font ?: family.variants.first().font
+ val isSelected = family.variants.any { it.font.path == currentCustomFontPath }
+ val fontFamily = remember(baseFont.path) {
+ getComposeFontFamily(ReaderFont.ORIGINAL, baseFont.path)
}
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "picker.row family='${family.familyName}' base='${baseFont.displayName}' " +
+ "selected=$isSelected variants=${
+ family.variants.joinToString { "${it.font.displayName}:${it.variant}" }
+ }"
+ )
ListItem(
headlineContent = {
- Text(fontEntity.displayName, fontFamily = fontFamily)
+ Text(family.familyName, fontFamily = fontFamily)
+ },
+ supportingContent = {
+ Text(
+ buildString {
+ append(family.fontFaceSummary())
+ if (family.hasVariableWeightFace()) append(" - Variable weight")
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
},
trailingContent = {
if (isSelected) {
@@ -873,7 +992,12 @@ fun FontSelectionSheetContent(
)
}
},
- modifier = Modifier.clickable { onFontSelected(ReaderFont.ORIGINAL, fontEntity.path) },
+ modifier = Modifier.clickable {
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "picker.selected family='${family.familyName}' base='${baseFont.displayName}' path='${baseFont.path}'"
+ )
+ onFontSelected(ReaderFont.ORIGINAL, baseFont.path)
+ },
colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors()
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
@@ -887,6 +1011,18 @@ fun FontSelectionSheetContent(
}
}
+private fun CustomFontEntity.toSharedCustomFontItem(): CustomFontItem {
+ return CustomFontItem(
+ id = id,
+ displayName = displayName,
+ fileName = fileName,
+ fileExtension = fileExtension,
+ path = path,
+ timestamp = timestamp,
+ isDeleted = isDeleted
+ )
+}
+
private const val REMOVE_EDGE_PADDING_KEY = "reader_remove_edge_padding"
fun saveRemoveEdgePadding(context: Context, enabled: Boolean) {
@@ -915,6 +1051,8 @@ fun VisualOptionsSheet(
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val configuration = LocalConfiguration.current
+ val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
@@ -924,6 +1062,8 @@ fun VisualOptionsSheet(
Column(
modifier = Modifier
.fillMaxWidth()
+ .heightIn(max = maxSheetHeight)
+ .verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 8.dp)
) {
Row(
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt
index 42ce113..d0d5e0c 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt
@@ -34,6 +34,8 @@ import androidx.compose.ui.input.key.type
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
+import com.aryan.reader.paginatedreader.AndroidEpubKeyCommand
+import com.aryan.reader.paginatedreader.androidEpubKeyCommandOrNull
import com.aryan.reader.RenderMode
@Composable
@@ -150,3 +152,45 @@ fun Modifier.volumeScrollHandler(
}
true
}
+
+fun Modifier.epubReaderKeyboardNavigationHandler(
+ enabled: Boolean,
+ renderMode: RenderMode,
+ isRightToLeftPagination: Boolean,
+ verticalLineScrollPx: Int,
+ onVerticalScrollBy: (Int) -> Unit,
+ onNextPage: () -> Unit,
+ onPreviousPage: () -> Unit,
+ onFirstPage: () -> Unit,
+ onLastPage: () -> Unit
+): Modifier = this.onPreviewKeyEvent { keyEvent ->
+ if (!enabled) return@onPreviewKeyEvent false
+ val command = androidEpubKeyCommandOrNull(
+ keyCode = keyEvent.nativeKeyEvent.keyCode,
+ rightToLeftPagination = isRightToLeftPagination,
+ isCtrlPressed = keyEvent.nativeKeyEvent.isCtrlPressed
+ ) ?: return@onPreviewKeyEvent false
+ if (keyEvent.type != KeyEventType.KeyDown) {
+ return@onPreviewKeyEvent when (command) {
+ AndroidEpubKeyCommand.SCROLL_UP,
+ AndroidEpubKeyCommand.SCROLL_DOWN -> renderMode == RenderMode.VERTICAL_SCROLL
+ else -> true
+ }
+ }
+
+ when (command) {
+ AndroidEpubKeyCommand.SCROLL_UP -> {
+ if (renderMode != RenderMode.VERTICAL_SCROLL) return@onPreviewKeyEvent false
+ onVerticalScrollBy(-verticalLineScrollPx)
+ }
+ AndroidEpubKeyCommand.SCROLL_DOWN -> {
+ if (renderMode != RenderMode.VERTICAL_SCROLL) return@onPreviewKeyEvent false
+ onVerticalScrollBy(verticalLineScrollPx)
+ }
+ AndroidEpubKeyCommand.PREVIOUS_PAGE -> onPreviousPage()
+ AndroidEpubKeyCommand.NEXT_PAGE -> onNextPage()
+ AndroidEpubKeyCommand.FIRST_PAGE -> onFirstPage()
+ AndroidEpubKeyCommand.LAST_PAGE -> onLastPage()
+ }
+ true
+}
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
index 7781952..2703448 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
@@ -328,16 +328,15 @@ private fun handleVerticalAutoAdvance(
val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex)
if (!nativeChunks.isNullOrEmpty()) {
- val resumeIdx = findTtsChunkResumeIndex(
+ val startChunkIndex = resolveTtsContinuationStartIndex(
chunks = nativeChunks,
+ loadedChunkCount = loadedChunkCount,
sourceCfi = lastReadCfi,
startOffsetInSource = currentState.startOffsetInSource,
- currentText = currentState.currentText,
- currentChunkIndexFallback = currentState.currentChunkIndex
+ currentText = currentState.currentText
)
- if (resumeIdx != null && resumeIdx + 1 < nativeChunks.size) {
- val startChunkIndex = resumeIdx + 1
+ if (startChunkIndex != null) {
val token = getAuthToken()
ttsController.start(
chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt
index e02e82a..0fd123e 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt
@@ -78,6 +78,29 @@ internal fun findTtsChunkResumeIndex(
return currentChunkIndexFallback.takeIf { it in chunks.indices }
}
+internal fun resolveTtsContinuationStartIndex(
+ chunks: List,
+ loadedChunkCount: Int,
+ sourceCfi: String?,
+ startOffsetInSource: Int,
+ currentText: String?
+): Int? {
+ val matchedResumeIndex = findTtsChunkResumeIndex(
+ chunks = chunks,
+ sourceCfi = sourceCfi,
+ startOffsetInSource = startOffsetInSource,
+ currentText = currentText,
+ currentChunkIndexFallback = -1
+ )
+
+ val matchedNextIndex = matchedResumeIndex?.plus(1)
+ if (matchedNextIndex != null && matchedNextIndex in chunks.indices) {
+ return matchedNextIndex
+ }
+
+ return loadedChunkCount.takeIf { it in chunks.indices }
+}
+
private fun cfiPathContains(parentPath: String, childPath: String): Boolean {
if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false
val parentParts = parentPath.split('/').filter { it.isNotEmpty() }
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/AndroidEpubKeyCommands.kt b/app/src/main/java/com/aryan/reader/paginatedreader/AndroidEpubKeyCommands.kt
new file mode 100644
index 0000000..bbfa901
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/AndroidEpubKeyCommands.kt
@@ -0,0 +1,39 @@
+package com.aryan.reader.paginatedreader
+
+import android.view.KeyEvent
+
+internal enum class AndroidEpubKeyCommand {
+ PREVIOUS_PAGE,
+ NEXT_PAGE,
+ SCROLL_UP,
+ SCROLL_DOWN,
+ FIRST_PAGE,
+ LAST_PAGE
+}
+
+internal fun androidEpubKeyCommandOrNull(
+ keyCode: Int,
+ rightToLeftPagination: Boolean = false,
+ isCtrlPressed: Boolean = false
+): AndroidEpubKeyCommand? {
+ if (isCtrlPressed) return null
+ return when (keyCode) {
+ KeyEvent.KEYCODE_DPAD_LEFT -> if (rightToLeftPagination) {
+ AndroidEpubKeyCommand.NEXT_PAGE
+ } else {
+ AndroidEpubKeyCommand.PREVIOUS_PAGE
+ }
+ KeyEvent.KEYCODE_DPAD_RIGHT -> if (rightToLeftPagination) {
+ AndroidEpubKeyCommand.PREVIOUS_PAGE
+ } else {
+ AndroidEpubKeyCommand.NEXT_PAGE
+ }
+ KeyEvent.KEYCODE_DPAD_UP -> AndroidEpubKeyCommand.SCROLL_UP
+ KeyEvent.KEYCODE_DPAD_DOWN -> AndroidEpubKeyCommand.SCROLL_DOWN
+ KeyEvent.KEYCODE_PAGE_UP -> AndroidEpubKeyCommand.PREVIOUS_PAGE
+ KeyEvent.KEYCODE_PAGE_DOWN -> AndroidEpubKeyCommand.NEXT_PAGE
+ KeyEvent.KEYCODE_MOVE_HOME -> AndroidEpubKeyCommand.FIRST_PAGE
+ KeyEvent.KEYCODE_MOVE_END -> AndroidEpubKeyCommand.LAST_PAGE
+ else -> null
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
index f9642f6..d06b6ac 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
@@ -202,6 +202,7 @@ class BookPaginator(
private val chapterTextRangeIndex = ConcurrentHashMap>()
private val chapterPageNavigationIndex = ConcurrentHashMap>()
private val chapterAnchorPageIndex = ConcurrentHashMap>()
+ private val expandedAllFontFaces = expandFontFacesWithSiblings(allFontFaces, extractionBasePath)
private var pageCountsAreAccurate by mutableStateOf(false)
private val finalizedChapterCounts = ConcurrentHashMap.newKeySet()
@@ -385,7 +386,7 @@ class BookPaginator(
append("-pageCache:$LATEST_PAGE_CACHE_VERSION")
append("-ua:${userAgentStylesheet.hashCode()}")
append("-css:${bookCss.hashCode()}")
- append("-fonts:${allFontFaces.hashCode()}")
+ append("-fonts:${expandedAllFontFaces.hashCode()}")
}
val hash = configString.hashCode()
return hash
@@ -872,7 +873,7 @@ class BookPaginator(
density = density.density,
constraintsMaxWidth = constraints.maxWidth,
constraintsMaxHeight = constraints.maxHeight,
- fontFaces = this.allFontFaces,
+ fontFaces = expandedAllFontFaces,
styleConfigHash = currentConfigHash,
bookReplacementPreferencesJson = ReaderBookReplacementPreferencesJson.encode(
bookReplacementPreferences.scopedToFile(bookReplacementFileId),
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/EpubFontFaceSiblings.kt b/app/src/main/java/com/aryan/reader/paginatedreader/EpubFontFaceSiblings.kt
new file mode 100644
index 0000000..d3f7264
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/EpubFontFaceSiblings.kt
@@ -0,0 +1,132 @@
+package com.aryan.reader.paginatedreader
+
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import com.aryan.reader.ReaderFontDiagnosticsTag
+import com.aryan.reader.readerFontDiagnosticSummary
+import com.aryan.reader.shared.detectFontVariant
+import com.aryan.reader.shared.familyFilenameSignature
+import com.aryan.reader.shared.fontWeightCssDescriptor
+import timber.log.Timber
+import java.io.File
+
+private val supportedEpubFontExtensions = setOf("ttf", "otf", "woff", "woff2")
+
+fun expandFontFacesWithSiblings(
+ fontFaces: List,
+ extractionPath: String
+): List {
+ if (fontFaces.isEmpty()) return emptyList()
+
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "epub.siblings.start inputCount=${fontFaces.size} extractionPath='$extractionPath'"
+ )
+
+ val result = fontFaces.toMutableList()
+ val existingKeys = result.mapTo(mutableSetOf()) { it.variantKey() }
+ val extractionRoot = File(extractionPath)
+
+ fontFaces.forEach { fontFace ->
+ val sourceFile = fontFace.resolvedFile(extractionRoot).takeIf { it.isFile } ?: return@forEach
+ val sourceSignature = sourceFile.familyFilenameSignature()
+ if (sourceSignature.isBlank()) return@forEach
+ val parent = sourceFile.parentFile ?: return@forEach
+
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "epub.siblings.source family='${fontFace.fontFamily}' src='${fontFace.src}' " +
+ "file='${sourceFile.name}' " +
+ readerFontDiagnosticSummary(sourceFile.nameWithoutExtension)
+ )
+
+ parent.listFiles()
+ ?.asSequence()
+ ?.filter { candidate ->
+ candidate.isFile &&
+ candidate.extension.lowercase() in supportedEpubFontExtensions &&
+ candidate.nameWithoutExtension.familyFilenameSignature() == sourceSignature
+ }
+ ?.forEach { candidate ->
+ val variant = candidate.nameWithoutExtension.detectFontVariant()
+ if (variant == null) {
+ Timber.tag(ReaderFontDiagnosticsTag).w(
+ "epub.siblings.skipNoVariant file='${candidate.name}' " +
+ readerFontDiagnosticSummary(candidate.nameWithoutExtension)
+ )
+ return@forEach
+ }
+ val src = candidate.toFontFaceSrc(extractionRoot)
+ val inferred = fontFace.copy(
+ src = src,
+ fontWeight = variant.weight,
+ fontStyle = variant.style
+ )
+ if (existingKeys.add(inferred.variantKey())) {
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "epub.siblings.add family='${fontFace.fontFamily}' src='$src' variant=$variant"
+ )
+ result += inferred
+ } else {
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "epub.siblings.skipDuplicate family='${fontFace.fontFamily}' src='$src' variant=$variant"
+ )
+ }
+ }
+ }
+
+ Timber.tag(ReaderFontDiagnosticsTag).i("epub.siblings.done outputCount=${result.size}")
+ return result
+}
+
+fun buildEpubFontFaceCss(
+ fontFaces: List,
+ extractionPath: String
+): String {
+ val extractionRoot = File(extractionPath)
+ return expandFontFacesWithSiblings(fontFaces, extractionPath)
+ .distinctBy { it.variantKey() }
+ .mapNotNull { fontFace ->
+ val file = fontFace.resolvedFile(extractionRoot).takeIf { it.isFile } ?: return@mapNotNull null
+ val family = fontFace.fontFamily.cssString()
+ val url = file.toURI().toString().cssUrlString()
+ val weight = file.nameWithoutExtension.fontWeightCssDescriptor(fontFace.fontWeight ?: FontWeight.Normal)
+ val style = if (fontFace.fontStyle == FontStyle.Italic) "italic" else "normal"
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "epub.css.face family='$family' file='${file.name}' fontWeight='$weight' fontStyle='$style' " +
+ readerFontDiagnosticSummary(file.nameWithoutExtension)
+ )
+ "@font-face { font-family: '$family'; src: url('$url'); font-weight: $weight; font-style: $style; }"
+ }
+ .joinToString(separator = " ")
+}
+
+private fun FontFaceInfo.resolvedFile(extractionRoot: File): File {
+ val source = File(src)
+ return if (source.isAbsolute) source else File(extractionRoot, src)
+}
+
+private fun FontFaceInfo.variantKey(): String {
+ return listOf(
+ fontFamily.trim().lowercase(),
+ src.replace('\\', '/').lowercase(),
+ fontWeight?.weight ?: FontWeight.Normal.weight,
+ fontStyle ?: FontStyle.Normal
+ ).joinToString(separator = "|")
+}
+
+private fun File.toFontFaceSrc(extractionRoot: File): String {
+ val relative = runCatching {
+ extractionRoot.toPath().relativize(toPath()).toString()
+ }.getOrNull()
+ return relative
+ ?.takeIf { !it.startsWith("..") && it.isNotBlank() }
+ ?.replace(File.separatorChar, '/')
+ ?: absolutePath
+}
+
+private fun File.familyFilenameSignature(): String {
+ return nameWithoutExtension.familyFilenameSignature()
+}
+
+private fun String.cssString(): String = replace("\\", "\\\\").replace("'", "\\'")
+
+private fun String.cssUrlString(): String = replace("\\", "\\\\").replace("'", "%27")
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt
index ae1f204..b94b224 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt
@@ -24,6 +24,9 @@ import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
+import com.aryan.reader.ReaderFontDiagnosticsTag
+import com.aryan.reader.readerFontDiagnosticSummary
+import com.aryan.reader.shared.supportsVariableWeightAxis
import java.io.File
import java.security.MessageDigest
@@ -41,7 +44,11 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map
if (fontFaces.isEmpty()) {
return emptyMap()
}
- Timber.d("Loading ${fontFaces.size} font faces from extraction path: $extractionPath")
+ val expandedFontFaces = expandFontFacesWithSiblings(fontFaces, extractionPath)
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "native.load.start inputCount=${fontFaces.size} expandedCount=${expandedFontFaces.size} extractionPath='$extractionPath'"
+ )
+ Timber.d("Loading ${expandedFontFaces.size} font faces from extraction path: $extractionPath")
// 1. Define a stable, global font cache directory.
// This assumes the parent of the extraction path is a stable base directory for epubs.
@@ -55,21 +62,39 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map
// e.g., "d0e205bf-65cc-4ab4-93cc-cd2d613a7bb3.epub" from a longer temp path.
val bookId = File(extractionPath).name.substringBeforeLast("_")
- val fontsByFamily = fontFaces.groupBy {
+ val fontsByFamily = expandedFontFaces.groupBy {
it.fontFamily.trim().removeSurrounding("'").removeSurrounding("\"").lowercase()
}
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "native.load.grouped families=${
+ fontsByFamily.mapValues { (_, infos) ->
+ infos.joinToString { "${it.src}:${it.fontWeight}:${it.fontStyle}" }
+ }
+ }"
+ )
Timber.d("Grouped font faces by normalized family: ${fontsByFamily.keys}")
return fontsByFamily.mapValues { (familyName, fontInfos) ->
- val fontList = fontInfos.mapNotNull { fontInfo ->
+ val seenVariants = mutableSetOf()
+ val fontList = fontInfos.flatMap { fontInfo ->
try {
Timber.d("Attempting to load font '$familyName' from resolved src path: '${fontInfo.src}'")
- var fontFile = File(extractionPath, fontInfo.src)
+ var fontFile = File(fontInfo.src).let { source ->
+ if (source.isAbsolute) source else File(extractionPath, fontInfo.src)
+ }
if (!fontFile.exists()) {
+ Timber.tag(ReaderFontDiagnosticsTag).w(
+ "native.load.missing family='$familyName' src='${fontInfo.src}' resolved='${fontFile.absolutePath}'"
+ )
Timber.w("Font file not found at: ${fontFile.absolutePath}")
- return@mapNotNull null
+ return@flatMap emptyList()
}
+ val sourceName = fontFile.nameWithoutExtension
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "native.load.candidate family='$familyName' src='${fontInfo.src}' file='${fontFile.name}' " +
+ readerFontDiagnosticSummary(sourceName)
+ )
// Handle WOFF2 conversion and global caching
if (fontFile.extension.equals("woff2", ignoreCase = true)) {
@@ -80,9 +105,15 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map
if (cachedTtfFile.exists()) {
// Use the globally cached TTF file if it exists
fontFile = cachedTtfFile
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "native.load.woff2CacheHit src='${fontInfo.src}' cached='${cachedTtfFile.absolutePath}'"
+ )
Timber.d("Using globally cached TTF for '${fontInfo.src}'")
} else {
// Convert and save the TTF to the global cache if it doesn't exist
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "native.load.woff2Convert src='${fontInfo.src}' source='${fontFile.absolutePath}'"
+ )
Timber.d("Converting woff2 font: ${fontFile.name}")
val woff2Data = fontFile.readBytes()
val ttfData = Woff2Converter.convertWoff2ToTtf(woff2Data)
@@ -90,31 +121,69 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map
if (ttfData != null) {
cachedTtfFile.writeBytes(ttfData)
fontFile = cachedTtfFile // Use the newly created TTF file
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "native.load.woff2Converted src='${fontInfo.src}' cached='${cachedTtfFile.absolutePath}' bytes=${cachedTtfFile.length()}"
+ )
Timber.d("Successfully converted and globally cached woff2 as '${cachedTtfFile.name}'")
} else {
+ Timber.tag(ReaderFontDiagnosticsTag).e(
+ "native.load.woff2ConvertFailed src='${fontInfo.src}' source='${fontFile.absolutePath}'"
+ )
Timber.e("Failed to convert woff2 font: ${fontFile.name}")
- return@mapNotNull null
+ return@flatMap emptyList()
}
}
}
- Font(
- fontFile,
- fontInfo.fontWeight ?: FontWeight.Normal,
- fontInfo.fontStyle ?: FontStyle.Normal
+ val weights = if (sourceName.supportsVariableWeightAxis()) {
+ variableEpubFontWeights
+ } else {
+ listOf(fontInfo.fontWeight ?: FontWeight.Normal)
+ }
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "native.load.registerPlan family='$familyName' file='${fontFile.name}' " +
+ "style=${fontInfo.fontStyle ?: FontStyle.Normal} weights=${weights.joinToString { it.weight.toString() }}"
)
+ weights.mapNotNull { weight ->
+ val style = fontInfo.fontStyle ?: FontStyle.Normal
+ if (seenVariants.add("${weight.weight}|$style")) {
+ Font(fontFile, weight, style)
+ } else {
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "native.load.skipDuplicate family='$familyName' file='${fontFile.name}' weight=${weight.weight} style=$style"
+ )
+ null
+ }
+ }
} catch (e: Exception) {
+ Timber.tag(ReaderFontDiagnosticsTag).e(e, "native.load.failed family='$familyName' src='${fontInfo.src}'")
Timber.e(e, "Error loading font: ${fontInfo.src}")
- null
+ emptyList()
}
}
if (fontList.isNotEmpty()) {
+ Timber.tag(ReaderFontDiagnosticsTag).i(
+ "native.load.loaded family='$familyName' registeredVariants=${seenVariants.joinToString()}"
+ )
Timber.d("Loaded family '$familyName' with ${fontList.size} font styles.")
FontFamily(fontList)
} else {
+ Timber.tag(ReaderFontDiagnosticsTag).w("native.load.empty family='$familyName'")
Timber.w("Could not load any font styles for family '$familyName'.")
null
}
}.filterValues { it != null }.mapValues { it.value!! }
}
+
+private val variableEpubFontWeights = listOf(
+ FontWeight.Thin,
+ FontWeight.ExtraLight,
+ FontWeight.Light,
+ FontWeight.Normal,
+ FontWeight.Medium,
+ FontWeight.SemiBold,
+ FontWeight.Bold,
+ FontWeight.ExtraBold,
+ FontWeight.Black
+)
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
index 3c46fff..5686027 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
@@ -5,14 +5,13 @@ package com.aryan.reader.paginatedreader
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
-import android.content.ClipData
-import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import android.widget.Toast
import com.aryan.reader.BuildConfig
+import com.aryan.reader.copyPlainTextToClipboard
import androidx.compose.ui.unit.isSpecified
import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -175,6 +174,7 @@ import com.aryan.reader.epubreader.UserHighlight
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.shared.ReaderBookReplacementPreferences
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
+import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
@@ -192,6 +192,8 @@ import timber.log.Timber
import java.io.File
import java.net.URI
import java.net.URLDecoder
+import java.nio.charset.StandardCharsets
+import java.util.Base64
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.sqrt
@@ -243,7 +245,8 @@ data class NativeVerticalLocation(
val firstVisibleItemSize: Int,
val isAtStart: Boolean,
val isAtEnd: Boolean,
- val visibleTextRanges: List = emptyList()
+ val visibleTextRanges: List = emptyList(),
+ val chapterPageInfo: NativeVerticalChapterPageInfo? = null
)
data class NativeVerticalVisibleTextRange(
@@ -253,6 +256,34 @@ data class NativeVerticalVisibleTextRange(
val endCharOffset: Int
)
+fun NativeVerticalLocation.locatorForPersistence(): Locator? {
+ val visibleRange = visibleTextRanges.firstOrNull()
+ return if (visibleRange != null) {
+ Locator(
+ chapterIndex = visibleRange.chapterIndex,
+ blockIndex = visibleRange.blockIndex,
+ charOffset = visibleRange.startCharOffset
+ )
+ } else {
+ locator
+ }
+}
+
+internal fun shouldFallbackNativeVerticalInitialScrollToCompatPage(
+ hasInitialLocator: Boolean,
+ didLocatorScroll: Boolean
+): Boolean = !hasInitialLocator && !didLocatorScroll
+
+internal fun nativeVerticalCenteredScrollDelta(
+ targetOffsetInViewport: Float,
+ viewportHeight: Float
+): Float = targetOffsetInViewport - (viewportHeight * 0.5f)
+
+data class NativeVerticalChapterPageInfo(
+ val currentPage: Int,
+ val totalPages: Int
+)
+
private data class SelectionBlockKey(
val pageIndex: Int,
val blockIndex: Int,
@@ -318,7 +349,7 @@ internal fun nativeVerticalInitialChapterPrefetchOrder(
chapterCount: Int,
initialChapter: Int,
forwardCount: Int = 2,
- backwardCount: Int = 1
+ backwardCount: Int = 0
): List {
if (chapterCount <= 0) return emptyList()
val start = initialChapter.coerceIn(0, chapterCount - 1)
@@ -1026,6 +1057,68 @@ internal fun nativeVerticalProgressForCompatPage(pageIndex: Int, totalPageCount:
.coerceIn(0f, 100f)
}
+internal fun nativeVerticalChapterPageInfo(
+ chapterCharOffset: Int?,
+ chapterLengthChars: Int,
+ chapterPageCount: Int?,
+ compatPageIndex: Int,
+ chapterStartPageIndex: Int?
+): NativeVerticalChapterPageInfo? {
+ val total = chapterPageCount?.takeIf { it > 0 } ?: return null
+ val pageIndexInChapter = if (chapterCharOffset != null && chapterLengthChars > 0) {
+ ((chapterCharOffset.coerceIn(0, chapterLengthChars).toFloat() / chapterLengthChars.toFloat()) * (total - 1))
+ .roundToInt()
+ } else if (chapterStartPageIndex != null) {
+ compatPageIndex - chapterStartPageIndex
+ } else {
+ 0
+ }.coerceIn(0, total - 1)
+ return NativeVerticalChapterPageInfo(
+ currentPage = pageIndexInChapter + 1,
+ totalPages = total
+ )
+}
+
+internal fun nativeVerticalChapterPageInfoForScroll(
+ itemChapterIndices: List,
+ itemWeights: List,
+ firstVisibleItemIndex: Int,
+ firstVisibleItemScrollOffset: Int,
+ firstVisibleItemSize: Int,
+ chapterPageCount: Int?
+): NativeVerticalChapterPageInfo? {
+ val total = chapterPageCount?.takeIf { it > 0 } ?: return null
+ if (itemChapterIndices.isEmpty() || itemWeights.isEmpty()) {
+ return NativeVerticalChapterPageInfo(currentPage = 1, totalPages = total)
+ }
+ val safeIndex = firstVisibleItemIndex.coerceIn(0, minOf(itemChapterIndices.lastIndex, itemWeights.lastIndex))
+ val chapterIndex = itemChapterIndices[safeIndex]
+ val chapterItems = itemChapterIndices.indices.filter { index ->
+ index < itemWeights.size && itemChapterIndices[index] == chapterIndex
+ }
+ val totalChapterWeight = chapterItems.sumOf { itemWeights[it].coerceAtLeast(0) }
+ if (totalChapterWeight <= 0) {
+ return NativeVerticalChapterPageInfo(currentPage = 1, totalPages = total)
+ }
+ val completedWeight = chapterItems
+ .filter { it < safeIndex }
+ .sumOf { itemWeights[it].coerceAtLeast(0) }
+ val currentWeight = itemWeights[safeIndex].coerceAtLeast(0)
+ val currentFraction = if (firstVisibleItemSize > 0) {
+ (firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat())
+ .coerceIn(0f, 1f)
+ } else {
+ 0f
+ }
+ val chapterProgress = ((completedWeight + currentWeight * currentFraction) / totalChapterWeight.toFloat())
+ .coerceIn(0f, 1f)
+ val pageIndexInChapter = (chapterProgress * (total - 1)).roundToInt().coerceIn(0, total - 1)
+ return NativeVerticalChapterPageInfo(
+ currentPage = pageIndexInChapter + 1,
+ totalPages = total
+ )
+}
+
internal fun nativeVerticalProgressToItemIndex(
itemWeights: List,
progressPercent: Float
@@ -1119,31 +1212,45 @@ private fun findNativeVerticalFlowItemIndexForProgress(
)
}
-private fun estimateNativeVerticalScrollProgressPercent(
- items: List,
+internal fun estimateNativeVerticalWeightedScrollProgressPercent(
+ itemWeights: List,
firstVisibleItemIndex: Int,
firstVisibleItemScrollOffset: Int,
firstVisibleItemSize: Int
): Float? {
- if (items.isEmpty()) return null
- val totalWeight = items.sumOf { it.locationWeight }.takeIf { it > 0 } ?: return null
- val safeIndex = firstVisibleItemIndex.coerceIn(0, items.lastIndex)
- val completedWeight = items
+ if (itemWeights.isEmpty()) return null
+ val totalWeight = itemWeights.sumOf { it }.takeIf { it > 0 } ?: return null
+ val safeIndex = firstVisibleItemIndex.coerceIn(0, itemWeights.lastIndex)
+ val completedWeight = itemWeights
.take(safeIndex)
- .sumOf { it.locationWeight }
- val currentItem = items[safeIndex]
+ .sum()
+ val currentItemWeight = itemWeights[safeIndex]
val currentFraction = if (firstVisibleItemSize > 0) {
(firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat())
.coerceIn(0f, 1f)
} else {
0f
}
- val weightedPosition = completedWeight + (currentItem.locationWeight * currentFraction)
+ val weightedPosition = completedWeight + (currentItemWeight * currentFraction)
return ((weightedPosition.toDouble() / totalWeight.toDouble()) * 100.0)
.toFloat()
.coerceIn(0f, 100f)
}
+private fun estimateNativeVerticalScrollProgressPercent(
+ items: List,
+ firstVisibleItemIndex: Int,
+ firstVisibleItemScrollOffset: Int,
+ firstVisibleItemSize: Int
+): Float? {
+ return estimateNativeVerticalWeightedScrollProgressPercent(
+ itemWeights = items.map { it.locationWeight },
+ firstVisibleItemIndex = firstVisibleItemIndex,
+ firstVisibleItemScrollOffset = firstVisibleItemScrollOffset,
+ firstVisibleItemSize = firstVisibleItemSize
+ )
+}
+
private fun findNativeVerticalFlowItemIndexForLocator(
items: List,
chapters: List,
@@ -1333,7 +1440,7 @@ private fun resolveNativeVerticalVisibleTextRanges(
val start = blockStart + (firstVisibleOffset ?: 0)
val end = blockStart + (lastVisibleOffset ?: block.content.text.length)
- NativeVerticalVisibleTextRange(
+ bounds.top to NativeVerticalVisibleTextRange(
chapterIndex = chapterIndex,
blockIndex = block.blockIndex,
startCharOffset = start,
@@ -1341,6 +1448,8 @@ private fun resolveNativeVerticalVisibleTextRanges(
)
}
}
+ .sortedBy { it.first }
+ .map { it.second }
.toList()
}
@@ -1903,6 +2012,37 @@ private fun imageContentScale(style: BlockStyle): ContentScale {
}
}
+internal fun nativeVerticalSvgContentFromDataUri(source: String): String? {
+ if (!source.startsWith("data:image/svg+xml", ignoreCase = true)) return null
+ val commaIndex = source.indexOf(',')
+ if (commaIndex < 0) return null
+ val metadata = source.substring(0, commaIndex)
+ val payload = source.substring(commaIndex + 1)
+ return runCatching {
+ if (metadata.contains(";base64", ignoreCase = true)) {
+ String(Base64.getDecoder().decode(payload), StandardCharsets.UTF_8)
+ } else {
+ URLDecoder.decode(payload.replace("+", "%2B"), "UTF-8")
+ }
+ }.getOrNull()
+}
+
+internal fun nativeVerticalImageModelData(source: String): Any {
+ val trimmed = source.trim()
+ return when {
+ trimmed.startsWith("