Windows ga (#358)

* Enhance Cloud TTS with navigation controls and shared UI overlay in desktop app

* Refactor Cloud TTS voice settings and remove standalone settings overlay on desktop app

* Persist reader window state and improve slider interaction in desktop app

* Improve PDF page transitions and refine focus management in desktop app

* Refactor scrollbar interaction and adjust desktop modal focus handling

* Improve PDF sidecar synchronization and cross-platform metadata compatibility

* Refactor PDF annotation comment logic to shared module and implement Desktop UI

* Refactor reader screen to use tap-to-toggle and full-width styling in desktop app

* Refactor reader workspace layout and chrome-panel interactions

* Implement global search keyboard shortcuts and focusable chrome layers

* Implement flavor-specific legal links and update the About UI

* Refactor reader UI controls on desktop app

* Enhance desktop folder sync with background metadata extraction and improved error handling

* Refactor Library UI and remove redundant Home tab in desktop app

* Add custom tooltips to reader icon buttons in desktop app

* Integrate app theme controls into reader interfaces on desktop

* Add right-to-left pagination support and improve focus restoration on desktop app

* Improve EPUB pagination geometry and diagnostic logging for layout cutoffs on desktop app

* Update desktop reader defaults and implement settings migration

* Implement block-based position tracking in ReaderLocator

* Enhance EPUB highlighting reliability in desktop app

* Add support for custom reader themes and update highlight palette logic in desktop app

* Replace the Tools panel with a "More" dropdown menu and refactor account UI

* Implement account profile header in desktop sidebar

* Implement cloud sync reliability improvements and sidebar toggle on desktop app

* Improve EPUB annotation synchronization and highlight mapping accuracy in desktop app

* Integrate WebView2 for EPUB vertical rendering on Windows

* Refactor reader layout logic and enhance WebView2 diagnostics

* Improve vertical reading layout and WebView2 resizing on Desktop

* Refine vertical reading mode layout and margin handling

* Enhance reader locator precision and Desktop mode-switching reliability

* Implement chapter-level caching and warm-start pagination in desktop app

* Replace bundled KCEF with native system webviews via SWT

* Refactor EPUB page info bar visibility and layout logic

* Improve PDF toolbar persistence and fix tab reactivation logic

* Enable multi-selection and bulk operations for custom fonts

* Refactor instrumentation tests

* Add EPUB UI test fixture and initial instrumentation tests

* Expand EpubReader UI tests and improve accessibility

* Add instrumentation tests and test tags for library and reader screens

* Enhance OPDS parser logic and catalog integration

* Add support for toggling local synchronization on a per-folder basis.

* Implement tri-state sizing for the TTS overlay

* Persist TTS overlay size across sessions

* Refactor reader brightness control and add incremental step buttons

* Improve CSS support, pagination control, and style-aware semantic caching

* Improve link handling, interaction, and diagnostics in the paginated reader

* crash fixes

* Implement persistent pending removal for external files

* Implement book-specific word replacements

* Add native vertical reading mode with custom renderer

* Implement text selection and navigation improvements for the native vertical reader

* Implement locator-based navigation and improved vertical scrolling in native vertical mode in epub

* Implement lazy loading and chapter prefetching for native vertical reader

* Improve window lifecycle and disposal handling on Desktop

* Optimize vertical reading performance in desktop app

* Enhance TTS start accuracy and diagnostic logging on desktop

* Refactor AI settings visibility on desktop

* Improve pagination height measurement and enhance cutoff diagnostics

* Implement lifecycle management and improve justified text splitting for pagination

* Refine AI usage tracking and force AI feature visibility on Desktop

* Add descriptive context comments and usage examples to string and plural resources.

* Optimize performance and memory usage in search and state mapping

* Replace reader page sliders with minimal slider and navigation controls

* Add support for CBT comic archives

* Harden file path validation and XML parsing to prevent security vulnerabilities

* Implement local account profile caching and optimize desktop performance

* Improve desktop persistence reliability and add Linux secure storage support

* Improved PDF zoom stability and layout prediction during zoom commits

* Improved PDF spread layout prediction, reader focus restoration, and account profile caching

* Enhance highlight precision and scoping using block-local offsets and CFIs

* Enhance cloud book content synchronization and background downloads

* Implement granular timestamp tracking for reading positions and PDF annotations

* Restrict diagnostic logging and stack traces to debug builds

* Refine PDF page gaps and reader chrome interaction logic

* Refactor PDF highlight rendering and overhaul Desktop sidebar UI

* Implement a new interaction dock and undo/redo history for PDF annotations in desktop

* Enhance PDF color picker and improve navigation scroll restoration

* Add highlight palette customization and improve selection menu UI in desktop app epub reader

* Enhance desktop shelf management and library organization
This commit is contained in:
Aryan 2026-06-02 00:51:42 +05:30 committed by GitHub
parent 5971eaa571
commit 83dcafa4b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
444 changed files with 47279 additions and 8096 deletions

View file

@ -1,9 +1,12 @@
import org.gradle.api.GradleException
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.JavaExec
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.Sync
import org.gradle.api.tasks.TaskAction
@ -11,7 +14,13 @@ import org.gradle.jvm.tasks.Jar
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.gradle.work.DisableCachingByDefault
import java.io.File
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.Properties
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
plugins {
alias(libs.plugins.kotlin.multiplatform)
@ -19,33 +28,6 @@ plugins {
alias(libs.plugins.compose.multiplatform)
}
@DisableCachingByDefault(because = "Verification task has no outputs.")
abstract class CheckBundledWebViewRuntimeTask : DefaultTask() {
@get:Input
abstract val bundleRootPath: Property<String>
@get:Input
abstract val osName: Property<String>
@get:Input
abstract val osArch: Property<String>
@get:Input
abstract val requiredPaths: ListProperty<String>
@TaskAction
fun checkRuntime() {
val bundleRoot = File(bundleRootPath.get())
val missingFiles = requiredPaths.get().filterNot { bundleRoot.resolve(it).exists() }
if (missingFiles.isNotEmpty()) {
throw GradleException(
"Missing bundled KCEF runtime at ${bundleRoot.absolutePath}. " +
"Expected ${missingFiles.joinToString()} for ${osName.get()} ${osArch.get()} desktop packages."
)
}
}
}
@DisableCachingByDefault(because = "Verification task has no outputs.")
abstract class CheckBundledPdfiumRuntimeTask : DefaultTask() {
@get:Input
@ -67,6 +49,179 @@ abstract class CheckBundledPdfiumRuntimeTask : DefaultTask() {
}
}
@DisableCachingByDefault(because = "Renames package output produced by jpackage.")
abstract class RenameDesktopMsiOutputTask : DefaultTask() {
@get:Input
abstract val msiDirectoryPath: Property<String>
@get:Input
abstract val packageName: Property<String>
@get:Input
abstract val packageVersion: Property<String>
@get:Input
abstract val architecture: Property<String>
@TaskAction
fun renameOutput() {
val msiDirectory = File(msiDirectoryPath.get())
val outputPackageName = packageName.get()
val outputPackageVersion = packageVersion.get()
val source = msiDirectory.resolve("$outputPackageName-$outputPackageVersion.msi")
if (!source.isFile) return
val target = msiDirectory.resolve("$outputPackageName-$outputPackageVersion-${architecture.get()}.msi")
if (target.exists() && !target.delete()) {
throw GradleException("Could not replace existing MSI at ${target.absolutePath}.")
}
if (!source.renameTo(target)) {
throw GradleException("Could not rename MSI from ${source.absolutePath} to ${target.absolutePath}.")
}
}
}
@DisableCachingByDefault(because = "Generates local desktop service config for native packages.")
abstract class GenerateDesktopCloudConfigTask : DefaultTask() {
@get:Input
abstract val configValues: MapProperty<String, String>
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun generate() {
val file = outputFile.get().asFile
file.parentFile.mkdirs()
file.writeText(
configValues.get().entries.joinToString(separator = "\n", postfix = "\n") { (key, value) ->
"$key=${value.replace("\\", "\\\\").replace("\n", "")}"
}
)
}
}
@DisableCachingByDefault(because = "Verification task has no outputs.")
abstract class VerifyDesktopNativePackagingTask : DefaultTask() {
@get:Input
abstract val supportedHost: Property<Boolean>
@get:Input
abstract val hostOsId: Property<String>
@get:Input
abstract val hostArchId: Property<String>
@get:Input
abstract val missingStandardServiceConfig: ListProperty<String>
@TaskAction
fun verify() {
if (!supportedHost.get()) {
throw GradleException(
"Desktop native packaging is currently release-supported only on Windows x64 and Linux x64. " +
"Current host: ${hostOsId.get()} ${hostArchId.get()}."
)
}
val missing = missingStandardServiceConfig.get()
if (missing.isNotEmpty()) {
throw GradleException(
"Standard desktop packages require account/sync service config. Missing: " +
missing.joinToString(", ") + ". " +
"Set DESKTOP_FIREBASE_WEB_API_KEY and DESKTOP_GOOGLE_OAUTH_CLIENT_ID, " +
"use -PdesktopFlavor=oss for the offline build, or set " +
"-PdesktopAllowUnconfiguredStandardServices=true for a local non-GA package."
)
}
}
}
@DisableCachingByDefault(because = "Strips stale jar signatures in-place after ProGuard rewrites signed dependencies.")
abstract class StripInvalidJarSignaturesTask : DefaultTask() {
@get:Input
abstract val jarDirectoryPath: Property<String>
@TaskAction
fun stripSignatures() {
val jarDirectory = File(jarDirectoryPath.get())
if (!jarDirectory.isDirectory) return
var strippedJarCount = 0
jarDirectory.walkTopDown()
.filter { it.isFile && it.extension.equals("jar", ignoreCase = true) }
.forEach { jar ->
val strippedEntries = stripInvalidJarSignatures(jar)
if (strippedEntries > 0) {
strippedJarCount += 1
logger.lifecycle("Stripped $strippedEntries stale jar signature entr${if (strippedEntries == 1) "y" else "ies"} from ${jar.name}")
}
}
if (strippedJarCount > 0) {
logger.lifecycle("Stripped stale jar signatures from $strippedJarCount ProGuard output jar${if (strippedJarCount == 1) "" else "s"}.")
}
}
private fun stripInvalidJarSignatures(jar: File): Int {
val temp = Files.createTempFile(jar.parentFile.toPath(), "${jar.nameWithoutExtension}-unsigned-", ".jar")
var strippedEntries = 0
ZipFile(jar).use { source ->
ZipOutputStream(Files.newOutputStream(temp)).use { target ->
val seenEntries = mutableSetOf<String>()
val entries = source.entries()
while (entries.hasMoreElements()) {
val sourceEntry = entries.nextElement()
val entryName = sourceEntry.name
if (!seenEntries.add(entryName)) continue
if (isJarSignatureResource(entryName)) {
strippedEntries += 1
continue
}
val targetEntry = ZipEntry(entryName)
if (sourceEntry.time >= 0) {
targetEntry.time = sourceEntry.time
}
target.putNextEntry(targetEntry)
if (!sourceEntry.isDirectory) {
source.getInputStream(sourceEntry).use { input ->
input.copyTo(target)
}
}
target.closeEntry()
}
}
}
if (strippedEntries > 0) {
try {
Files.move(temp, jar.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(temp, jar.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
} else {
Files.deleteIfExists(temp)
}
return strippedEntries
}
private fun isJarSignatureResource(entryName: String): Boolean {
val normalized = entryName.replace('\\', '/').uppercase()
if (!normalized.startsWith("META-INF/")) return false
val metaInfName = normalized.removePrefix("META-INF/")
if (metaInfName.contains("/")) return false
return metaInfName.startsWith("SIG-") ||
metaInfName.endsWith(".SF") ||
metaInfName.endsWith(".DSA") ||
metaInfName.endsWith(".RSA") ||
metaInfName.endsWith(".EC")
}
}
fun desktopOsId(osName: String = System.getProperty("os.name")): String {
val normalized = osName.lowercase()
return when {
@ -86,24 +241,27 @@ fun desktopArchId(osArch: String = System.getProperty("os.arch")): String {
}
}
fun desktopKcefBundleDirectoryName(
fun desktopSwtArtifactId(
osName: String = System.getProperty("os.name"),
osArch: String = System.getProperty("os.arch")
): String {
): String? {
return when (desktopOsId(osName)) {
"windows" -> "kcef-bundle"
"linux" -> "kcef-bundle-linux-${desktopArchId(osArch)}"
"macos" -> "kcef-bundle-macos-${desktopArchId(osArch)}"
else -> "kcef-bundle-${desktopArchId(osArch)}"
}
}
"windows" -> when (desktopArchId(osArch)) {
"arm64" -> "org.eclipse.swt.win32.win32.aarch64"
else -> "org.eclipse.swt.win32.win32.x86_64"
}
fun bundledWebViewRequiredPaths(osName: String, osArch: String): List<String> {
return when (desktopOsId(osName)) {
"windows" -> listOf("jcef.dll", "libcef.dll")
"linux" -> listOf("libcef.so", "chrome-sandbox", "icudtl.dat", "locales")
"macos" -> listOf("jcef Helper.app", "Chromium Embedded Framework.framework")
else -> emptyList()
"linux" -> when (desktopArchId(osArch)) {
"arm64" -> "org.eclipse.swt.gtk.linux.aarch64"
else -> "org.eclipse.swt.gtk.linux.x86_64"
}
"macos" -> when (desktopArchId(osArch)) {
"arm64" -> "org.eclipse.swt.cocoa.macosx.aarch64"
else -> "org.eclipse.swt.cocoa.macosx.x86_64"
}
else -> null
}
}
@ -313,25 +471,82 @@ fun normalizeDesktopPackageArchitecture(osArch: String): String {
}
}
fun renameDesktopMsiOutput(
msiDirectory: File,
packageName: String,
packageVersion: String,
architecture: String
) {
val source = msiDirectory.resolve("$packageName-$packageVersion.msi")
if (!source.isFile) return
val target = msiDirectory.resolve("$packageName-$packageVersion-$architecture.msi")
if (target.exists() && !target.delete()) {
throw GradleException("Could not replace existing MSI at ${target.absolutePath}.")
}
if (!source.renameTo(target)) {
throw GradleException("Could not rename MSI from ${source.absolutePath} to ${target.absolutePath}.")
fun desktopDefaultPackageFormats(osName: String = System.getProperty("os.name")): String {
return when (desktopOsId(osName)) {
"windows" -> "msi"
"linux" -> "deb,rpm"
"macos" -> "dmg"
else -> ""
}
}
val desktopVersionName = "1.0.0"
fun desktopTargetFormatForId(format: String): TargetFormat {
return when (format.lowercase()) {
"exe" -> TargetFormat.Exe
"msi" -> TargetFormat.Msi
"deb" -> TargetFormat.Deb
"rpm" -> TargetFormat.Rpm
"dmg" -> TargetFormat.Dmg
"pkg" -> TargetFormat.Pkg
else -> throw GradleException(
"Unsupported desktopPackageFormats entry '$format'. " +
"Use one or more of: msi, exe, deb, rpm, dmg, pkg."
)
}
}
fun desktopPackageFormatId(format: TargetFormat): String {
return when (format) {
TargetFormat.Exe -> "exe"
TargetFormat.Msi -> "msi"
TargetFormat.Deb -> "deb"
TargetFormat.Rpm -> "rpm"
TargetFormat.Dmg -> "dmg"
TargetFormat.Pkg -> "pkg"
else -> format.name.lowercase()
}
}
fun desktopPackageFormatSupportedOnHost(
format: TargetFormat,
osName: String = System.getProperty("os.name")
): Boolean {
return when (desktopOsId(osName)) {
"windows" -> format == TargetFormat.Msi || format == TargetFormat.Exe
"linux" -> format == TargetFormat.Deb || format == TargetFormat.Rpm
"macos" -> format == TargetFormat.Dmg || format == TargetFormat.Pkg
else -> false
}
}
fun normalizeDesktopPackageFormats(
rawFormats: String,
osName: String = System.getProperty("os.name")
): List<TargetFormat> {
val formats = rawFormats
.split(',', ';', ' ', '\n', '\t')
.map { it.trim() }
.filter { it.isNotBlank() }
.map(::desktopTargetFormatForId)
.distinct()
if (formats.isEmpty()) {
throw GradleException(
"desktopPackageFormats resolved to no package formats for ${desktopOsId(osName)}. " +
"Set -PdesktopPackageFormats=msi on Windows or -PdesktopPackageFormats=deb,rpm on Linux."
)
}
val unsupported = formats.filterNot { desktopPackageFormatSupportedOnHost(it, osName) }
if (unsupported.isNotEmpty()) {
throw GradleException(
"desktopPackageFormats=${formats.joinToString(",") { desktopPackageFormatId(it) }} does not match " +
"the current packaging host ${desktopOsId(osName)}. Unsupported here: " +
unsupported.joinToString(",") { desktopPackageFormatId(it) } + "."
)
}
return formats
}
val desktopVersionName = "1.0.1"
val desktopFlavor = providers.gradleProperty("desktopFlavor")
.orElse("standard")
.map(::normalizeDesktopFlavor)
@ -359,7 +574,21 @@ val desktopVendor = providers.gradleProperty("desktopVendor").orElse("Aryan")
val desktopOsName = System.getProperty("os.name")
val desktopOsArch = System.getProperty("os.arch")
val desktopPackageArchitecture = normalizeDesktopPackageArchitecture(desktopOsArch)
val desktopPackageTargetFormats = providers.gradleProperty("desktopPackageFormats")
.orElse(desktopDefaultPackageFormats(desktopOsName))
.map { normalizeDesktopPackageFormats(it, desktopOsName) }
.get()
val desktopNativePackageSupportedHost = desktopOsId(desktopOsName) in setOf("windows", "linux") &&
desktopArchId(desktopOsArch) == "x64"
val desktopReleaseProguardEnabled = providers.gradleProperty("desktopReleaseProguard")
.map { it.equals("true", ignoreCase = true) }
.orElse(false)
.get()
val desktopSwtVersion = "3.133.0"
val desktopSwtDependency = desktopSwtArtifactId(desktopOsName, desktopOsArch)
?.let { artifactId -> "org.eclipse.platform:$artifactId:$desktopSwtVersion" }
val generatedDesktopResourcesDir = layout.buildDirectory.dir("generated/desktopAppResources")
val generatedDesktopCloudConfigFile = layout.buildDirectory.file("generated/desktopCloudConfig/desktop-cloud.properties")
val generatedDesktopStringResourcesDir = layout.buildDirectory.dir("generated/desktopStringResources")
val rootLocalProperties = Properties()
val rootLocalPropertiesFile = rootProject.file("local.properties")
@ -375,60 +604,26 @@ fun desktopConfigValue(vararg keys: String): String {
}
val desktopCloudConfig = mapOf(
"AI_WORKER_URL" to desktopConfigValue("DESKTOP_AI_WORKER_URL", "AI_WORKER_URL"),
"TTS_WORKER_URL" to desktopConfigValue("DESKTOP_TTS_WORKER_URL", "TTS_WORKER_URL", "AI_WORKER_URL"),
"TTS_WORKER_URL" to desktopConfigValue("DESKTOP_TTS_WORKER_URL", "TTS_WORKER_URL"),
"FIREBASE_WEB_API_KEY" to desktopConfigValue("DESKTOP_FIREBASE_WEB_API_KEY", "FIREBASE_WEB_API_KEY", "GOOGLE_API_KEY"),
"FIREBASE_PROJECT_ID" to desktopConfigValue("DESKTOP_FIREBASE_PROJECT_ID", "FIREBASE_PROJECT_ID").ifBlank { "reader-9fc469d7" },
"GOOGLE_OAUTH_CLIENT_ID" to desktopConfigValue("DESKTOP_GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_WEB_CLIENT_ID", "DEFAULT_WEB_CLIENT_ID"),
"GOOGLE_OAUTH_CLIENT_SECRET" to desktopConfigValue("DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_WEB_CLIENT_SECRET", "DEFAULT_WEB_CLIENT_SECRET")
)
val bundledWebViewDir = layout.projectDirectory.dir(desktopKcefBundleDirectoryName(desktopOsName, desktopOsArch))
val desktopAllowUnconfiguredStandardServices = providers.gradleProperty("desktopAllowUnconfiguredStandardServices")
.map { it.equals("true", ignoreCase = true) }
.orElse(false)
.get()
val desktopMissingStandardServiceConfig = if (isOssOfflineDesktop || desktopAllowUnconfiguredStandardServices) {
emptyList()
} else {
listOf("FIREBASE_WEB_API_KEY", "GOOGLE_OAUTH_CLIENT_ID")
.filter { key -> desktopCloudConfig[key].isNullOrBlank() }
}
val bundledPdfiumDir = layout.projectDirectory.dir(
"../third_party/pdfium/${desktopPdfiumDirectoryName(desktopOsName, desktopOsArch)}"
)
val bundledPdfiumLibraryPath = desktopPdfiumLibraryPath(desktopOsName, desktopOsArch)
val bundledWebViewKeptLocales = setOf(
"ar.pak",
"de.pak",
"en-GB.pak",
"en-US.pak",
"es-419.pak",
"es.pak",
"fr.pak",
"hi.pak",
"pt-BR.pak",
"ru.pak",
"tr.pak",
"vi.pak"
)
val bundledWebViewTrimmedRuntimeFiles = listOf(
"ct.sym",
"jawt.lib",
"jvm.lib",
"jaccessinspector.exe",
"jaccesswalker.exe",
"jabswitch.exe",
"javac.exe",
"javadoc.exe",
"jcmd.exe",
"jdb.exe",
"jfr.exe",
"jhsdb.exe",
"jinfo.exe",
"jmap.exe",
"jps.exe",
"jrunscript.exe",
"jstack.exe",
"jstat.exe",
"jwebserver.exe",
"keytool.exe",
"kinit.exe",
"klist.exe",
"ktab.exe",
"rmiregistry.exe",
"serialver.exe",
"server/classes.jsa",
"server/classes_nocoops.jsa"
)
val desktopWindowsIconFile = layout.projectDirectory.file("src/desktopMain/resources/episteme.ico")
val desktopLinuxIconFile = layout.projectDirectory.file("src/desktopMain/resources/episteme_icon.png")
val desktopWindowsUpgradeUuid = if (isOssOfflineDesktop) {
@ -455,49 +650,23 @@ val desktopPackagingJavaHome = findDesktopPackagingJavaHome(
osName = desktopOsName
)?.absolutePath
val checkBundledWebViewRuntime by tasks.registering(CheckBundledWebViewRuntimeTask::class) {
val requiredPaths = bundledWebViewRequiredPaths(desktopOsName, desktopOsArch)
bundleRootPath.set(bundledWebViewDir.asFile.absolutePath)
osName.set(desktopOsName)
osArch.set(desktopOsArch)
this.requiredPaths.set(requiredPaths)
}
val checkBundledPdfiumRuntime by tasks.registering(CheckBundledPdfiumRuntimeTask::class) {
bundleRootPath.set(bundledPdfiumDir.asFile.absolutePath)
libraryPath.set(bundledPdfiumLibraryPath)
}
val generateDesktopCloudConfig by tasks.registering(GenerateDesktopCloudConfigTask::class) {
configValues.set(desktopCloudConfig)
outputFile.set(generatedDesktopCloudConfigFile)
}
val prepareBundledDesktopResources by tasks.registering(Sync::class) {
dependsOn(checkBundledWebViewRuntime, checkBundledPdfiumRuntime)
from(bundledWebViewDir) {
exclude(bundledWebViewTrimmedRuntimeFiles)
val localeExcludes = bundledWebViewDir.asFile
.resolve("locales")
.listFiles { file -> file.isFile && file.extension.equals("pak", ignoreCase = true) }
.orEmpty()
.map { it.name }
.filterNot { it in bundledWebViewKeptLocales }
.map { "locales/$it" }
exclude(localeExcludes)
into("common/kcef-bundle")
}
dependsOn(checkBundledPdfiumRuntime, generateDesktopCloudConfig)
from(bundledPdfiumDir) {
into("common/third_party/pdfium/${desktopPdfiumDirectoryName(desktopOsName, desktopOsArch)}")
}
into("common") {
from(
providers.provider {
temporaryDir.resolve("desktop-cloud.properties").also { file ->
file.parentFile.mkdirs()
file.writeText(
desktopCloudConfig.entries.joinToString(separator = "\n", postfix = "\n") { (key, value) ->
"$key=${value.replace("\\", "\\\\").replace("\n", "")}"
}
)
}
}
)
from(generatedDesktopCloudConfigFile)
}
into(generatedDesktopResourcesDir)
}
@ -512,6 +681,13 @@ val prepareDesktopStringResources by tasks.registering(Sync::class) {
into(generatedDesktopStringResourcesDir)
}
val verifyDesktopNativePackaging by tasks.registering(VerifyDesktopNativePackagingTask::class) {
supportedHost.set(desktopNativePackageSupportedHost)
hostOsId.set(desktopOsId(desktopOsName))
hostArchId.set(desktopArchId(desktopOsArch))
missingStandardServiceConfig.set(desktopMissingStandardServiceConfig)
}
kotlin {
jvm("desktop")
jvmToolchain(21)
@ -523,12 +699,14 @@ kotlin {
implementation(project(":shared"))
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation("io.github.kevinnzou:compose-webview-multiplatform:2.0.3")
desktopSwtDependency?.let { dependency ->
compileOnly(dependency)
runtimeOnly(dependency)
}
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("net.java.dev.jna:jna:5.17.0")
implementation("org.apache.commons:commons-compress:1.28.0")
implementation("org.apache.thrift:libthrift:0.22.0")
implementation("org.tukaani:xz:1.10")
implementation("com.twelvemonkeys.imageio:imageio-webp:3.13.1")
}
@ -554,6 +732,10 @@ compose.desktop {
jvmArgs("-Depisteme.desktop.version=${desktopResolvedVersionName.get()}")
buildTypes.release.proguard {
// ProGuard still rewrites and shrinks release jars even when optimization and
// obfuscation are disabled. That has produced invalid stack-map frames in large
// Compose/PDF lambdas and stripped WebView bridge behavior in packaged MSIs.
isEnabled.set(desktopReleaseProguardEnabled)
obfuscate.set(false)
// Compose/Kotlin generated methods can produce very large stack-map frames.
// ProGuard optimization has emitted invalid frames for SharedAppTheme in release builds.
@ -562,8 +744,17 @@ compose.desktop {
}
nativeDistributions {
targetFormats(TargetFormat.Exe, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm)
modules("java.net.http")
targetFormats(*desktopPackageTargetFormats.toTypedArray())
modules(
"java.datatransfer",
"java.desktop",
"java.logging",
"java.management",
"java.net.http",
"jdk.charsets",
"jdk.httpserver",
"jdk.unsupported"
)
packageName = desktopPackageName
packageVersion = desktopPackageVersion.get()
description = desktopPackageDescription
@ -612,19 +803,41 @@ tasks.withType<Jar>().configureEach {
}
}
val stripReleaseProguardJarSignatures = if (desktopReleaseProguardEnabled) {
tasks.registering(StripInvalidJarSignaturesTask::class) {
dependsOn("proguardReleaseJars")
jarDirectoryPath.set(layout.buildDirectory.dir("compose/tmp/main-release/proguard").map { it.asFile.absolutePath })
}
} else {
null
}
tasks.matching {
it.name in setOf(
"createReleaseDistributable",
"packageReleaseDistributionForCurrentOS",
"packageReleaseExe",
"packageReleaseMsi",
"packageReleaseDeb",
"packageReleaseRpm",
"runReleaseDistributable"
)
}.configureEach {
stripReleaseProguardJarSignatures?.let { dependsOn(it) }
}
mapOf(
"packageMsi" to "main",
"packageReleaseMsi" to "main-release"
).forEach { (taskName, distributionName) ->
val renameTask = tasks.register<RenameDesktopMsiOutputTask>("rename${taskName.replaceFirstChar(Char::titlecase)}Output") {
msiDirectoryPath.set(layout.buildDirectory.dir("compose/binaries/$distributionName/msi").get().asFile.absolutePath)
packageName.set(desktopPackageName)
packageVersion.set(desktopPackageVersion.get())
architecture.set(desktopPackageArchitecture)
}
tasks.matching { it.name == taskName }.configureEach {
doLast {
renameDesktopMsiOutput(
msiDirectory = layout.buildDirectory.dir("compose/binaries/$distributionName/msi").get().asFile,
packageName = desktopPackageName,
packageVersion = desktopPackageVersion.get(),
architecture = desktopPackageArchitecture
)
}
finalizedBy(renameTask)
}
}
@ -648,6 +861,7 @@ tasks.matching {
"runReleaseDistributable"
)
}.configureEach {
dependsOn(verifyDesktopNativePackaging)
dependsOn(prepareBundledDesktopResources)
inputs.dir(generatedDesktopResourcesDir)
.withPropertyName("bundledDesktopResources")

View file

@ -1,5 +1,3 @@
-keep class org.cef.** { *; }
-keep class org.apache.thrift.** { *; }
-keep class io.ktor.serialization.kotlinx.** { *; }
-keep class io.ktor.serialization.kotlinx.json.** { *; }
-keep class com.sun.jna.** { *; }
@ -7,17 +5,13 @@
-keep class * extends com.sun.jna.Structure { *; }
-keep class kotlinx.coroutines.swing.SwingDispatcherFactory
# Desktop release shrinking sees optional integrations from JCEF/KCEF, JOGL, Commons
# Compress Pack200, and OkHttp platform probes. These references are not bundled for
# the Windows MSI path, so keep ProGuard from treating them as release blockers.
-dontwarn com.jetbrains.cef.**
# Desktop release shrinking sees optional integrations from JOGL, Commons Compress
# Pack200, and OkHttp platform probes, so keep ProGuard from treating them as blockers.
-dontwarn com.jetbrains.JBR
-dontwarn org.cef.**
-dontwarn com.jogamp.**
-dontwarn jogamp.**
-dontwarn org.apache.commons.compress.harmony.pack200.**
-dontwarn org.objectweb.asm.**
-dontwarn org.apache.thrift.**
-dontwarn io.ktor.serialization.kotlinx.**
-dontwarn com.sun.jna.**
-dontwarn org.eclipse.swt.**

View file

@ -8,18 +8,49 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.util.Properties
internal data class DesktopAccountProfile(
val isProUser: Boolean = false,
val credits: Int = 0
val credits: Int = 0,
val fetchedAtEpochMillis: Long = 0L
)
// Credits and Pro status are server-owned, so startup only trusts a recent snapshot.
internal const val DesktopAccountProfileCacheTtlMillis: Long = 30L * 60L * 1000L
internal fun DesktopAccountProfile.isFresh(
nowEpochMillis: Long = System.currentTimeMillis(),
ttlMillis: Long = DesktopAccountProfileCacheTtlMillis
): Boolean {
if (fetchedAtEpochMillis <= 0L || ttlMillis <= 0L) return false
val ageMillis = nowEpochMillis - fetchedAtEpochMillis
return ageMillis in 0L..ttlMillis
}
internal class DesktopAccountProfileRepository(
private val config: DesktopCloudConfig
private val config: DesktopCloudConfig,
private val store: DesktopAccountProfileStore = DesktopAccountProfileStore()
) {
fun cachedProfile(
uid: String,
nowEpochMillis: Long = System.currentTimeMillis()
): DesktopAccountProfile? {
return store.load(uid)?.takeIf { profile -> profile.isFresh(nowEpochMillis) }
}
fun saveFetchedProfile(uid: String, profile: DesktopAccountProfile) {
store.save(uid, profile)
}
fun clearCachedProfiles() {
store.clear()
}
suspend fun fetchProfile(uid: String, idToken: String): DesktopAccountProfile = withContext(Dispatchers.IO) {
if (uid.isBlank() || idToken.isBlank()) return@withContext DesktopAccountProfile()
val url = "https://firestore.googleapis.com/v1/projects/${urlEncode(config.firebaseProjectId)}/databases/(default)/documents/users/${urlEncode(uid)}"
@ -31,23 +62,60 @@ internal class DesktopAccountProfileRepository(
readTimeout = 20_000
}
try {
if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) return@withContext DesktopAccountProfile()
if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
return@withContext DesktopAccountProfile(fetchedAtEpochMillis = System.currentTimeMillis())
}
val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (connection.responseCode !in 200..299) {
throw IllegalStateException("Could not check account status: HTTP ${connection.responseCode}")
}
val fields = DesktopAccountJson.parseToJsonElement(text).jsonObject["fields"].jsonObjectOrNull()
DesktopAccountProfile(
val profile = DesktopAccountProfile(
isProUser = fields?.booleanField("isPro") == true,
credits = fields?.numberField("credits")?.toInt() ?: 0
credits = fields?.numberField("credits")?.toInt() ?: 0,
fetchedAtEpochMillis = System.currentTimeMillis()
)
profile
} finally {
connection.disconnect()
}
}
}
internal class DesktopAccountProfileStore(
private val settingsFile: File = File(desktopUserConfigRoot(), "account_profile.properties")
) {
fun load(uid: String): DesktopAccountProfile? {
if (uid.isBlank() || !settingsFile.isFile) return null
val properties = Properties()
return runCatching {
settingsFile.inputStream().use(properties::load)
if (properties.getProperty("uid", "") != uid) return null
DesktopAccountProfile(
isProUser = properties.getProperty("isProUser", "false").toBooleanStrictOrNull() ?: false,
credits = properties.getProperty("credits", "0").toIntOrNull() ?: 0,
fetchedAtEpochMillis = properties.getProperty("fetchedAtEpochMillis", "0").toLongOrNull() ?: 0L
)
}.getOrNull()
}
fun save(uid: String, profile: DesktopAccountProfile) {
if (uid.isBlank()) return
val properties = Properties().apply {
setProperty("uid", uid)
setProperty("isProUser", profile.isProUser.toString())
setProperty("credits", profile.credits.toString())
setProperty("fetchedAtEpochMillis", profile.fetchedAtEpochMillis.toString())
}
settingsFile.storePropertiesAtomically(properties, "Episteme desktop account profile")
}
fun clear() {
settingsFile.delete()
}
}
private val DesktopAccountJson = Json { ignoreUnknownKeys = true }
private fun JsonObject?.booleanField(key: String): Boolean? {

View file

@ -13,6 +13,7 @@ import com.sun.jna.win32.StdCallLibrary
import java.io.File
import java.util.Base64
import java.util.Properties
import java.util.concurrent.TimeUnit
private const val WINDOWS_CRED_TYPE_GENERIC = 1
private const val WINDOWS_CRED_PERSIST_LOCAL_MACHINE = 2
@ -60,7 +61,7 @@ internal class DesktopAiByokStore(
loadedSettings.copy(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID)
} else {
loadedSettings
}
}.toDesktopPersistableAiSettings()
if (secureStorageAvailable &&
(legacyGeminiKey.isNotBlank() || legacyGroqKey.isNotBlank() || settings != loadedSettings)
) {
@ -82,7 +83,7 @@ internal class DesktopAiByokStore(
}
fun save(settings: ReaderAiByokSettings) {
val sanitized = settings.sanitized()
val sanitized = settings.toDesktopPersistableAiSettings()
logDesktopTts(
"settings_save_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " +
"secureStorage=${secretCodec.isAvailable} geminiKey=${sanitized.geminiKey.isNotBlank()} " +
@ -101,9 +102,7 @@ internal class DesktopAiByokStore(
setProperty("ttsSpeakerId", sanitized.ttsSpeakerId)
}
settingsFile.parentFile?.mkdirs()
settingsFile.outputStream().use { output ->
properties.store(output, "Episteme desktop AI keys and models")
}
settingsFile.storePropertiesAtomically(properties, "Episteme desktop AI keys and models")
logDesktopTts(
"settings_save_complete geminiProtected=${properties.getProperty(GeminiKey, "").isNotBlank()} " +
"groqProtected=${properties.getProperty(GroqKey, "").isNotBlank()}"
@ -173,10 +172,10 @@ internal interface DesktopSecretCodec {
companion object {
fun platform(): DesktopSecretCodec {
val osName = System.getProperty("os.name").orEmpty()
val codec = if (osName.startsWith("Windows", ignoreCase = true)) {
WindowsSecretCodec
} else {
UnavailableDesktopSecretCodec
val codec = when {
osName.startsWith("Windows", ignoreCase = true) -> WindowsSecretCodec
osName.contains("Linux", ignoreCase = true) -> LinuxSecretToolCodec()
else -> UnavailableDesktopSecretCodec
}
logDesktopTts("settings_platform os=\"${osName.desktopTtsPreview()}\" codec=${codec.name}")
return codec
@ -193,6 +192,160 @@ private object UnavailableDesktopSecretCodec : DesktopSecretCodec {
override fun unprotect(value: String): String = ""
}
internal data class DesktopSecretCommandResult(
val exitCode: Int,
val stdout: String,
val stderr: String
) {
val isSuccess: Boolean get() = exitCode == 0
val errorSummary: String
get() = stderr.ifBlank { stdout }.desktopTtsPreview(240).ifBlank { "exit code $exitCode" }
}
internal interface DesktopSecretCommandRunner {
fun isExecutableAvailable(command: String): Boolean
fun run(command: List<String>, input: String? = null, timeoutMillis: Long = 5_000L): DesktopSecretCommandResult
}
private object DesktopProcessSecretCommandRunner : DesktopSecretCommandRunner {
override fun isExecutableAvailable(command: String): Boolean {
val path = System.getenv("PATH").orEmpty()
return path.split(File.pathSeparator)
.asSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.any { directory ->
File(directory, command).let { it.isFile && it.canExecute() }
}
}
override fun run(command: List<String>, input: String?, timeoutMillis: Long): DesktopSecretCommandResult {
require(command.isNotEmpty()) { "Secret command cannot be empty." }
val process = ProcessBuilder(command).start()
input?.let { value ->
process.outputStream.use { output ->
output.write(value.toByteArray(Charsets.UTF_8))
}
} ?: process.outputStream.close()
val completed = process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)
if (!completed) {
process.destroyForcibly()
throw IllegalStateException("Timed out waiting for ${command.first()} secure storage command.")
}
return DesktopSecretCommandResult(
exitCode = process.exitValue(),
stdout = process.inputStream.readBytes().toString(Charsets.UTF_8),
stderr = process.errorStream.readBytes().toString(Charsets.UTF_8)
)
}
}
internal class LinuxSecretToolCodec(
private val commandRunner: DesktopSecretCommandRunner = DesktopProcessSecretCommandRunner
) : DesktopSecretCodec {
override val name: String = "linux-secret-tool"
override val isAvailable: Boolean by lazy {
val available = commandRunner.isExecutableAvailable(SecretToolCommand) &&
runCatching {
commandRunner.run(listOf(SecretToolCommand, "--help"), timeoutMillis = 3_000L).isSuccess
}.getOrDefault(false)
logDesktopTts("settings_linux_secret_tool_available available=$available")
available
}
override fun protect(value: String): String {
return protect("secret", value)
}
override fun unprotect(value: String): String {
return unprotect("secret", value)
}
override fun protect(keyName: String, value: String): String {
if (!isAvailable) {
throw IllegalStateException(
"Linux Secret Service is unavailable. Install libsecret-tools and make sure a desktop keyring is running."
)
}
val key = linuxSecretKey(keyName)
logDesktopTts("settings_linux_secret_tool_write_start key=$keyName valueChars=${value.length}")
val result = commandRunner.run(
command = listOf(
SecretToolCommand,
"store",
"--label",
"Episteme $keyName",
SecretToolApplicationAttribute,
SecretToolApplicationValue,
SecretToolKeyAttribute,
key
),
input = value,
timeoutMillis = 15_000L
)
logDesktopTts("settings_linux_secret_tool_write_result key=$keyName exit=${result.exitCode}")
if (!result.isSuccess) {
throw IllegalStateException("Linux Secret Service write failed: ${result.errorSummary}")
}
return Prefix + key
}
override fun unprotect(keyName: String, value: String): String {
if (!isAvailable) return ""
val key = value.removePrefix(Prefix).takeIf { value.startsWith(Prefix) } ?: linuxSecretKey(keyName)
logDesktopTts("settings_linux_secret_tool_read_start key=$keyName")
val result = commandRunner.run(
command = listOf(
SecretToolCommand,
"lookup",
SecretToolApplicationAttribute,
SecretToolApplicationValue,
SecretToolKeyAttribute,
key
),
timeoutMillis = 8_000L
)
logDesktopTts("settings_linux_secret_tool_read_result key=$keyName exit=${result.exitCode} chars=${result.stdout.length}")
if (!result.isSuccess) {
throw IllegalStateException("Linux Secret Service read failed: ${result.errorSummary}")
}
return result.stdout.trimEnd('\r', '\n')
}
override fun delete(keyName: String) {
val key = linuxSecretKey(keyName)
runCatching {
commandRunner.run(
command = listOf(
SecretToolCommand,
"clear",
SecretToolApplicationAttribute,
SecretToolApplicationValue,
SecretToolKeyAttribute,
key
),
timeoutMillis = 8_000L
)
}.onFailure { error ->
logDesktopTts("settings_linux_secret_tool_delete_failed key=$keyName error=\"${error.desktopTtsSummary()}\"")
}
}
private fun linuxSecretKey(keyName: String): String {
return "Episteme.Reader.$keyName"
}
private companion object {
const val Prefix = "secret-tool:"
const val SecretToolCommand = "secret-tool"
const val SecretToolApplicationAttribute = "application"
const val SecretToolApplicationValue = "Episteme.Reader"
const val SecretToolKeyAttribute = "key"
}
}
private object WindowsSecretCodec : DesktopSecretCodec {
override val name: String = "windows"

View file

@ -14,11 +14,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -42,13 +38,8 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.ReaderCloudTtsState
import com.aryan.reader.shared.ReaderCloudTtsVoices
import com.aryan.reader.shared.ReaderTtsCacheSummary
import com.aryan.reader.shared.RecapResult
import com.aryan.reader.shared.SummarizationResult
import com.aryan.reader.shared.readerCloudTtsVoiceById
import com.aryan.reader.shared.ui.SharedMarkdownText
import com.aryan.reader.shared.ui.readerString
@ -345,169 +336,3 @@ private fun DesktopSummaryCachePanel(
}
}
}
@Composable
internal fun DesktopCloudTtsChromeControls(
settings: ReaderAiByokSettings,
cloudTts: ReaderCloudTtsState,
credits: Int,
showCredits: Boolean,
onRead: () -> Unit,
onPauseResume: () -> Unit,
onStop: () -> Unit,
onOpenSettings: () -> Unit
) {
val sanitized = settings.sanitized()
val voice = readerCloudTtsVoiceById(sanitized.ttsSpeakerId)
val ttsBusy = cloudTts.isLoading || cloudTts.isPlaying || cloudTts.isPaused
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(8.dp),
tonalElevation = 1.dp
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.VolumeUp, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Column(modifier = Modifier.weight(1f)) {
Text(
when {
cloudTts.isLoading -> readerString("desktop_preparing_audio", "Preparing audio")
cloudTts.isPaused -> readerString("desktop_paused", "Paused")
cloudTts.isPlaying -> readerString("label_reading", "Reading")
sanitized.isCloudTtsAvailable -> readerString("desktop_cloud_tts_ready", "Cloud TTS ready")
else -> readerString("desktop_cloud_tts_unavailable", "Cloud TTS unavailable")
},
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
Text(
cloudTts.errorMessage
?: cloudTts.progress.currentPositionLabel
?: cloudTts.statusMessage
?: voice?.let { "${it.name}: ${it.description}" }
?: "",
style = MaterialTheme.typography.labelSmall,
color = if (cloudTts.errorMessage != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (showCredits) {
AssistChip(onClick = {}, label = { Text(readerString("credits_count", "%1\$d credits", credits)) })
}
if (cloudTts.isPlaying || cloudTts.isPaused) {
TextButton(onClick = onPauseResume) {
Text(if (cloudTts.isPaused) readerString("tooltip_tts_resume", "Resume") else readerString("tooltip_tts_pause", "Pause"))
}
}
TextButton(
enabled = sanitized.isCloudTtsAvailable || ttsBusy,
onClick = { if (ttsBusy) onStop() else onRead() }
) {
Text(if (ttsBusy) readerString("action_stop", "Stop") else readerString("action_read", "Read"))
}
IconButton(onClick = onOpenSettings) {
Icon(Icons.Default.Settings, contentDescription = readerString("desktop_cloud_tts_settings", "Cloud TTS settings"))
}
}
}
}
@Composable
internal fun DesktopCloudTtsSettingsOverlay(
settings: ReaderAiByokSettings,
isTtsActive: Boolean,
showCredits: Boolean,
credits: Int,
cacheSummary: ReaderTtsCacheSummary = ReaderTtsCacheSummary(),
onClearCache: (() -> Unit)? = null,
onSettingsChange: (ReaderAiByokSettings) -> Unit
) {
val sanitized = settings.sanitized()
Surface(
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(8.dp),
tonalElevation = 4.dp,
shadowElevation = 8.dp
) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(
if (isTtsActive) {
readerString("desktop_stop_reading_change_voices", "Stop reading to change voices.")
} else {
readerString("desktop_choose_cloud_tts_voice", "Choose the Gemini voice used for cloud read aloud.")
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (showCredits) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(10.dp)
) {
Text(
readerString("credits_count", "%1\$d credits", credits),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
ReaderCloudTtsVoices.forEach { voice ->
FilterChip(
selected = sanitized.ttsSpeakerId == voice.id,
enabled = !isTtsActive,
onClick = { onSettingsChange(sanitized.copy(ttsSpeakerId = voice.id)) },
label = {
Column {
Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
voice.description,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
)
}
}
if (cacheSummary.hasCachedAudio) {
HorizontalDivider()
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(readerString("desktop_voice_cache", "Voice cache"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(
cacheSummary.currentVoiceLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (cacheSummary.hasCurrentVoiceCachedAudio && onClearCache != null) {
TextButton(enabled = !isTtsActive, onClick = onClearCache) {
Text(readerString("desktop_clear_voice_cache", "Clear voice cache"))
}
}
}
}
}
}
}

View file

@ -38,7 +38,6 @@ import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import com.aryan.reader.shared.AppContrastOption
import com.aryan.reader.shared.AppThemeMode
import com.aryan.reader.shared.ReaderFeatureSurface
import com.aryan.reader.shared.ui.SharedAppTheme
import com.aryan.reader.shared.ui.readerString
import kotlinx.coroutines.Dispatchers
@ -58,6 +57,8 @@ import java.util.concurrent.atomic.AtomicReference
internal val DesktopDefaultAppSeedColor = Color(0xFFFFB300)
private val DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis = longArrayOf(160L, 200L)
internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) {
configureComposeSwingInterop()
application {
@ -190,35 +191,69 @@ internal const val ComposeInteropBlendingProperty = "compose.interop.blending"
internal const val ComposeInteropBlendingEnabled = "true"
private const val DesktopWindowStatePersistDebounceMillis = 450L
internal fun configureComposeSwingInterop() {
// Must run before Compose creates the desktop window. Vertical EPUB embeds a Swing-backed
// JCEF WebView, and current Compose interop can leave a stale black native rectangle after
// that reader surface is removed unless interop blending is enabled.
if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) {
System.setProperty(ComposeInteropBlendingProperty, ComposeInteropBlendingEnabled)
internal fun composeInteropBlendingDefault(
platform: DesktopPlatform = currentDesktopPlatform()
): String? {
return if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) {
null
} else {
ComposeInteropBlendingEnabled
}
}
internal fun configureComposeSwingInterop(
platform: DesktopPlatform = currentDesktopPlatform()
) {
// Must run before Compose creates the desktop window. Vertical EPUB embeds native SWT/AWT
// browser surfaces; the blending path can prevent those native children from painting.
if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) {
composeInteropBlendingDefault(platform)?.let { defaultValue ->
System.setProperty(ComposeInteropBlendingProperty, defaultValue)
}
}
logDesktopWebView2(
"compose_interop platform=${platform.os} blending=${System.getProperty(ComposeInteropBlendingProperty).orEmpty().ifBlank { "default" }}"
)
}
@Composable
private fun DesktopWindowStatePersistenceEffect(
internal fun DesktopWindowStatePersistenceEffect(
windowState: WindowState,
store: DesktopWindowStateStore,
enabled: Boolean
enabled: Boolean,
transformSnapshot: (DesktopWindowStateSnapshot) -> DesktopWindowStateSnapshot? = { it },
onSnapshotSaved: (DesktopWindowStateSnapshot) -> Unit = {}
) {
val persistenceEnabled by rememberUpdatedState(enabled)
val latestTransformSnapshot by rememberUpdatedState(transformSnapshot)
val latestOnSnapshotSaved by rememberUpdatedState(onSnapshotSaved)
LaunchedEffect(windowState, store) {
snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) }
.distinctUntilChanged()
.collectLatest { snapshot ->
if (!persistenceEnabled || snapshot == null) return@collectLatest
val persistableSnapshot = latestTransformSnapshot(snapshot) ?: return@collectLatest
delay(DesktopWindowStatePersistDebounceMillis)
if (persistenceEnabled) {
withContext(Dispatchers.IO) {
store.save(snapshot)
store.save(persistableSnapshot)
}
latestOnSnapshotSaved(persistableSnapshot)
}
}
}
DisposableEffect(windowState, store) {
onDispose {
if (persistenceEnabled) {
DesktopWindowStateSnapshot.fromWindowState(windowState)
?.let(latestTransformSnapshot)
?.let { snapshot ->
runCatching { store.save(snapshot) }
latestOnSnapshotSaved(snapshot)
}
}
}
}
}
@Composable
@ -259,6 +294,14 @@ internal fun DesktopReaderFullscreenEffect(
}
awtWindow.refreshDesktopReaderWindowFocus()
}
if (!enabled) {
for (delayMillis in DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis) {
delay(delayMillis)
EventQueue.invokeLater {
awtWindow.refreshDesktopReaderWindowFocus()
}
}
}
}
DisposableEffect(awtWindow) {
@ -444,16 +487,43 @@ private fun java.awt.Window.refreshDesktopReaderWindowFocus() {
internal fun DesktopReaderFullscreenKeyEffect(
enabled: Boolean,
onKeyPressed: (AwtKeyEvent) -> Boolean
) {
DesktopReaderKeyDispatcherEffect(
enabled = enabled,
allowChromeModalWindows = false,
onKeyPressed = onKeyPressed
)
}
@Composable
internal fun DesktopReaderKeyDispatcherEffect(
enabled: Boolean,
allowChromeModalWindows: Boolean = false,
allowPanelModalWindows: Boolean = false,
dispatchWhenOwnerWindowActive: Boolean = true,
onKeyPressed: (AwtKeyEvent) -> Boolean
) {
val currentOnKeyPressed by rememberUpdatedState(onKeyPressed)
DisposableEffect(enabled) {
DisposableEffect(
enabled,
allowChromeModalWindows,
allowPanelModalWindows,
dispatchWhenOwnerWindowActive
) {
if (!enabled) {
onDispose {}
} else {
val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
val dispatcher = java.awt.KeyEventDispatcher { event ->
val modalWindowActive = focusManager.activeWindow?.isDesktopReaderModalWindow() == true
!modalWindowActive && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event)
val keyWindow = focusManager.focusedWindow ?: focusManager.activeWindow
val activeReaderModalKind = keyWindow?.desktopReaderModalWindowKind()
val activeWindowAllowed = desktopReaderKeyDispatchAllowedForActiveWindowKind(
activeReaderModalKind = activeReaderModalKind,
allowChromeModalWindows = allowChromeModalWindows,
allowPanelModalWindows = allowPanelModalWindows,
dispatchWhenOwnerWindowActive = dispatchWhenOwnerWindowActive
)
activeWindowAllowed && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event)
}
focusManager.addKeyEventDispatcher(dispatcher)
onDispose {
@ -463,18 +533,62 @@ internal fun DesktopReaderFullscreenKeyEffect(
}
}
private fun java.awt.Window.isDesktopReaderModalWindow(): Boolean {
internal enum class DesktopReaderModalWindowKind {
CHROME,
PANEL,
POPUP
}
internal fun desktopReaderKeyDispatchAllowedForActiveWindowKind(
activeReaderModalKind: DesktopReaderModalWindowKind?,
allowChromeModalWindows: Boolean,
allowPanelModalWindows: Boolean,
dispatchWhenOwnerWindowActive: Boolean
): Boolean {
return when (activeReaderModalKind) {
null -> dispatchWhenOwnerWindowActive
DesktopReaderModalWindowKind.CHROME -> allowChromeModalWindows
DesktopReaderModalWindowKind.PANEL -> allowPanelModalWindows
DesktopReaderModalWindowKind.POPUP -> false
}
}
private fun java.awt.Window.desktopReaderModalWindowKind(): DesktopReaderModalWindowKind? {
val windowTitle = when (this) {
is java.awt.Dialog -> title
is Frame -> title
else -> ""
}
return name?.startsWith(DesktopReaderModalWindowNamePrefix) == true ||
windowTitle.startsWith("Reader Panel") ||
windowTitle.startsWith("Reader Popup")
return desktopReaderModalWindowKind(
windowName = name.orEmpty(),
windowTitle = windowTitle
)
}
private const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:"
internal fun desktopReaderModalWindowKind(
windowName: String,
windowTitle: String
): DesktopReaderModalWindowKind? {
return when {
windowName == "${DesktopReaderModalWindowNamePrefix}ChromeTop" ||
windowName == "${DesktopReaderModalWindowNamePrefix}ChromeBottom" ||
windowTitle.startsWith("Reader Chrome") -> DesktopReaderModalWindowKind.CHROME
windowName == "${DesktopReaderModalWindowNamePrefix}Panel" ||
windowName == "${DesktopReaderModalWindowNamePrefix}PanelLeft" ||
windowName == "${DesktopReaderModalWindowNamePrefix}PanelRight" ||
windowTitle.startsWith("Reader Panel") ||
windowTitle.startsWith("Reader Navigation") ||
windowTitle.startsWith("Reader Tools") -> DesktopReaderModalWindowKind.PANEL
windowName.startsWith(DesktopReaderModalWindowNamePrefix) ||
windowTitle.startsWith("Reader Popup") -> DesktopReaderModalWindowKind.POPUP
else -> null
}
}
internal const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:"
internal data class DesktopWebViewRuntimeState(
val initialized: Boolean = false,
@ -483,15 +597,43 @@ internal data class DesktopWebViewRuntimeState(
val errorMessage: String? = null
)
internal fun shouldRequestDesktopWebViewRuntime(readerSurface: ReaderFeatureSurface?): Boolean {
return readerSurface == ReaderFeatureSurface.TEXT_READER
internal enum class DesktopEpubWebViewBackend(
val logName: String,
val displayName: String
) {
WINDOWS_WEBVIEW2("webview2", "Microsoft Edge WebView2"),
WEBKIT("webkit", "WebKit"),
UNSUPPORTED("unsupported", "native webview")
}
internal fun shouldStartDesktopWebViewRuntime(
requested: Boolean,
state: DesktopWebViewRuntimeState
internal fun desktopEpubWebViewBackend(
platform: DesktopPlatform = currentDesktopPlatform()
): DesktopEpubWebViewBackend {
return when (platform.os) {
DesktopOperatingSystem.WINDOWS -> DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2
DesktopOperatingSystem.LINUX,
DesktopOperatingSystem.MACOS -> DesktopEpubWebViewBackend.WEBKIT
DesktopOperatingSystem.OTHER -> DesktopEpubWebViewBackend.UNSUPPORTED
}
}
internal fun desktopEpubWebViewUsesNativeSwtBrowser(
platform: DesktopPlatform = currentDesktopPlatform()
): Boolean {
return requested && !state.initialized && !state.restartRequired && state.errorMessage == null
return desktopEpubWebViewBackend(platform) != DesktopEpubWebViewBackend.UNSUPPORTED
}
internal fun desktopEpubWebViewUsesWebView2(
platform: DesktopPlatform = currentDesktopPlatform()
): Boolean {
return desktopEpubWebViewBackend(platform) == DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2
}
internal fun desktopEpubWebViewCanRender(
state: DesktopWebViewRuntimeState,
platform: DesktopPlatform = currentDesktopPlatform()
): Boolean {
return desktopEpubWebViewUsesNativeSwtBrowser(platform)
}
@Composable
@ -499,7 +641,10 @@ internal fun DesktopWebViewRuntimeIndicator(
state: DesktopWebViewRuntimeState,
modifier: Modifier = Modifier
) {
val platform = currentDesktopPlatform()
val message = when {
!desktopEpubWebViewUsesNativeSwtBrowser(platform) ->
readerString("desktop_webview_unsupported", "Embedded webview is unavailable on this desktop platform.")
state.errorMessage != null -> readerString("desktop_webview_start_error", "Embedded webview could not start: %1\$s", state.errorMessage)
state.restartRequired -> readerString("desktop_webview_restart_required", "Embedded webview installed. Restart Episteme to finish setup.")
state.downloadProgress >= 0f -> readerString("desktop_webview_preparing_progress", "Preparing bundled embedded webview %1\$d%%", state.downloadProgress.toInt())
@ -515,7 +660,9 @@ internal fun DesktopWebViewRuntimeIndicator(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (state.errorMessage == null && !state.restartRequired) {
CircularProgressIndicator()
if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) {
CircularProgressIndicator()
}
}
Text(
text = message,

View file

@ -7,8 +7,12 @@ import com.aryan.reader.shared.SharedLibrarySnapshot
import com.aryan.reader.shared.SharedLibraryStateProjector
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.ShelfRecord
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.SharedEpubBook
import com.aryan.reader.shared.reader.SharedEpubChapter
import com.aryan.reader.shared.ui.SharedAppTab
internal val DesktopInitialAppTab = SharedAppTab.LIBRARY
internal fun desktopEmptyReaderBook(): SharedEpubBook {
val noBookOpen = loadDesktopStringResolver().string("desktop_no_book_open", "No book open")
@ -27,11 +31,37 @@ internal fun desktopEmptyReaderBook(): SharedEpubBook {
}
internal fun SharedLibrarySnapshot.withDesktopDefaults(): SharedLibrarySnapshot {
return if (appSeedColor == null) {
copy(appSeedColor = DesktopDefaultAppSeedColor)
val shouldMigrateReaderDefaults = desktopReaderDefaultsVersion < DesktopReaderDefaultsVersion
val migratedTextDefaults = if (shouldMigrateReaderDefaults && readerDefaultSettings == ReaderSettings()) {
DesktopDefaultTextReaderSettings
} else {
this
readerDefaultSettings
}
val migratedPdfDefaults = if (shouldMigrateReaderDefaults && pdfReaderDefaultSettings == ReaderSettings(themeId = "no_theme")) {
DesktopDefaultPdfReaderSettings
} else {
pdfReaderDefaultSettings
}
val migratedBooks = if (shouldMigrateReaderDefaults) {
books.map { book ->
when {
book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.TEXT) &&
book.readerSettings == ReaderSettings() -> book.copy(readerSettings = migratedTextDefaults)
book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.PDF) &&
book.readerSettings == ReaderSettings(themeId = "no_theme") -> book.copy(readerSettings = migratedPdfDefaults)
else -> book
}
}
} else {
books
}
return copy(
books = migratedBooks,
appSeedColor = appSeedColor ?: DesktopDefaultAppSeedColor,
readerDefaultSettings = migratedTextDefaults,
pdfReaderDefaultSettings = migratedPdfDefaults,
desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion
)
}
internal fun SharedLibrarySnapshot.toDesktopReaderScreenState(): SharedReaderScreenState {
@ -54,6 +84,7 @@ internal fun SharedLibrarySnapshot.toDesktopReaderScreenState(): SharedReaderScr
appSeedColor = appSeedColor,
appFontPreference = appFontPreference,
customAppThemes = customAppThemes,
customReaderThemes = customReaderThemes,
readerDefaultSettings = readerDefaultSettings,
pdfReaderDefaultSettings = pdfReaderDefaultSettings,
readerToolbarPreferences = readerToolbarPreferences,
@ -116,8 +147,10 @@ internal fun SharedReaderScreenState.toDesktopLibrarySnapshot(
appSeedColor = appSeedColor,
appFontPreference = appFontPreference,
customAppThemes = customAppThemes,
customReaderThemes = customReaderThemes,
readerDefaultSettings = readerDefaultSettings,
pdfReaderDefaultSettings = pdfReaderDefaultSettings,
desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion,
readerToolbarPreferences = readerToolbarPreferences,
readerHighlightPalette = readerHighlightPalette,
pdfHighlighterPalette = pdfHighlighterPalette,

View file

@ -0,0 +1,56 @@
package com.aryan.reader.desktop
import java.io.File
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.Properties
internal fun File.writeTextAtomically(text: String) {
parentFile?.mkdirs()
val temp = createSiblingTempFile()
try {
temp.writeText(text)
moveReplacing(temp, this)
} finally {
runCatching { if (temp.exists()) temp.delete() }
}
}
internal fun File.storePropertiesAtomically(properties: Properties, comments: String) {
parentFile?.mkdirs()
val temp = createSiblingTempFile()
try {
temp.outputStream().use { output ->
properties.store(output, comments)
}
moveReplacing(temp, this)
} finally {
runCatching { if (temp.exists()) temp.delete() }
}
}
private fun File.createSiblingTempFile(): File {
val directory = parentFile ?: File(".")
directory.mkdirs()
val prefix = ".$name."
return Files.createTempFile(directory.toPath(), prefix, ".tmp").toFile()
}
private fun moveReplacing(source: File, target: File) {
target.parentFile?.mkdirs()
try {
Files.move(
source.toPath(),
target.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
source.toPath(),
target.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
}
}

View file

@ -2,7 +2,9 @@ package com.aryan.reader.desktop
import com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.SharedFeaturePolicy
import java.io.File
import com.aryan.reader.shared.SharedLegalLinks
import com.aryan.reader.shared.SharedLegalProfile
import com.aryan.reader.shared.sharedLegalLinksForProfile
internal const val DesktopFlavorProperty = "episteme.desktop.flavor"
internal const val DesktopVersionProperty = "episteme.desktop.version"
@ -16,10 +18,21 @@ internal data class DesktopBuildProfile(
val flavor: String,
val appName: String,
val buildLabel: String,
val featurePolicy: SharedFeaturePolicy
val featurePolicy: SharedFeaturePolicy,
val legalProfile: SharedLegalProfile = if (featurePolicy.byokAi) {
SharedLegalProfile.OSS
} else {
SharedLegalProfile.STANDARD
}
) {
val isOssOffline: Boolean get() = flavor == DesktopFlavorOssOffline
val aiKeySettingsAvailable: Boolean
get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && legalProfile != SharedLegalProfile.OSS
val byokAiAvailable: Boolean get() = featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess
val creditBackedCloudTtsControlsAvailable: Boolean
get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && !byokAiAvailable
val legalLinks: SharedLegalLinks
get() = sharedLegalLinksForProfile(legalProfile)
}
internal fun currentDesktopBuildProfile(): DesktopBuildProfile {
@ -35,13 +48,15 @@ internal fun desktopBuildProfileForFlavor(rawFlavor: String?): DesktopBuildProfi
flavor = DesktopFlavorOssOffline,
appName = EpistemeDesktopOssAppName,
buildLabel = "Offline OSS edition",
featurePolicy = SharedFeaturePolicy.OssOffline
featurePolicy = SharedFeaturePolicy.OssOffline,
legalProfile = SharedLegalProfile.OSS
)
else -> DesktopBuildProfile(
flavor = DesktopFlavorStandard,
appName = EpistemeDesktopStandardAppName,
buildLabel = "Standard edition",
featurePolicy = SharedFeaturePolicy.Standard
featurePolicy = SharedFeaturePolicy.Standard,
legalProfile = SharedLegalProfile.STANDARD
)
}
}
@ -59,48 +74,12 @@ internal fun ReaderAiByokSettings.withDesktopFeaturePolicy(
featurePolicy: SharedFeaturePolicy
): ReaderAiByokSettings {
return if (featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess) {
sanitized()
toDesktopPersistableAiSettings()
} else {
ReaderAiByokSettings(hideReaderAiFeatures = true)
ReaderAiByokSettings()
}
}
internal fun bundledDesktopWebViewDir(): File {
val platform = currentDesktopPlatform()
val resourceDir = System.getProperty(ComposeApplicationResourcesDirProperty)
?.takeIf { it.isNotBlank() }
?.let(::File)
return listOfNotNull(
resourceDir?.resolve("kcef-bundle"),
File(System.getProperty("user.dir"), "kcef-bundle"),
File(System.getProperty("user.dir"), "desktopApp/${platform.kcefBundleDirectoryName}"),
File(System.getProperty("user.dir"), "desktopApp/kcef-bundle"),
File("desktopApp/${platform.kcefBundleDirectoryName}"),
File("desktopApp/kcef-bundle"),
File(platform.kcefBundleDirectoryName),
File("kcef-bundle")
).firstOrNull(::isBundledDesktopWebViewPresent)
?: resourceDir?.resolve("kcef-bundle")
?: File(platform.kcefBundleDirectoryName)
}
internal fun isBundledDesktopWebViewPresent(
dir: File,
platform: DesktopPlatform = currentDesktopPlatform()
): Boolean {
return dir.isDirectory &&
bundledDesktopWebViewRequiredPaths(platform).all { requiredPath ->
dir.resolve(requiredPath).exists()
}
}
internal fun bundledDesktopWebViewRequiredPaths(
platform: DesktopPlatform = currentDesktopPlatform()
): List<String> {
return when (platform.os) {
DesktopOperatingSystem.WINDOWS -> listOf("jcef.dll", "libcef.dll")
DesktopOperatingSystem.LINUX -> listOf("libcef.so", "chrome-sandbox", "icudtl.dat", "locales")
DesktopOperatingSystem.MACOS -> listOf("jcef Helper.app", "Chromium Embedded Framework.framework")
DesktopOperatingSystem.OTHER -> emptyList()
}
internal fun ReaderAiByokSettings.toDesktopPersistableAiSettings(): ReaderAiByokSettings {
return sanitized().copy(hideReaderAiFeatures = false)
}

View file

@ -21,7 +21,14 @@ internal data class DesktopCloudConfig(
}
internal fun loadDesktopCloudConfig(): DesktopCloudConfig {
val resourceProperties = Properties().apply {
return desktopCloudConfigFromProperties(
resourceProperties = loadDesktopCloudResourceProperties(),
localProperties = loadDesktopLocalProperties()
)
}
private fun loadDesktopCloudResourceProperties(): Properties {
return Properties().apply {
val classLoader = DesktopCloudConfig::class.java.classLoader
val stream = classLoader.getResourceAsStream("desktop-cloud.properties")
?: classLoader.getResourceAsStream("common/desktop-cloud.properties")
@ -35,18 +42,27 @@ internal fun loadDesktopCloudConfig(): DesktopCloudConfig {
}
stream?.use { input -> load(input) }
}
val localProperties = Properties().apply {
File("local.properties")
.takeIf { it.isFile }
}
private fun loadDesktopLocalProperties(file: File = File("local.properties")): Properties {
return Properties().apply {
file.takeIf { it.isFile }
?.inputStream()
?.use { input -> load(input) }
}
}
internal fun desktopCloudConfigFromProperties(
resourceProperties: Properties,
localProperties: Properties = Properties(),
systemProperty: (String) -> String? = { key -> System.getProperty("episteme.desktop.$key") },
environment: (String) -> String? = { key -> System.getenv(key) }
): DesktopCloudConfig {
fun value(vararg keys: String): String {
return keys.firstNotNullOfOrNull { key ->
System.getProperty("episteme.desktop.$key")
?: System.getenv("EPISTEME_DESKTOP_${key.uppercase()}")
?: System.getenv(key)
systemProperty(key)
?: environment("EPISTEME_DESKTOP_${key.uppercase()}")
?: environment(key)
?: localProperties.getProperty("DESKTOP_$key")
?: localProperties.getProperty(key)
?: resourceProperties.getProperty(key)
@ -56,7 +72,7 @@ internal fun loadDesktopCloudConfig(): DesktopCloudConfig {
val aiWorkerUrl = value("AI_WORKER_URL").ifBlank {
"https://reader-ai.aryanrajttps.workers.dev"
}
val ttsWorkerUrl = value("TTS_WORKER_URL").ifBlank { aiWorkerUrl }
val ttsWorkerUrl = value("TTS_WORKER_URL")
return DesktopCloudConfig(
aiWorkerUrl = aiWorkerUrl,

View file

@ -32,6 +32,7 @@ import java.net.http.HttpResponse
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Duration
import java.time.Instant
import java.util.Collections
import java.util.UUID
@ -50,6 +51,8 @@ internal data class DesktopCloudBookMetadata(
val isRecent: Boolean = true,
val isDeleted: Boolean = false,
val lastModifiedTimestamp: Long = 0L,
val readingPositionModifiedTimestamp: Long = 0L,
val annotationModifiedTimestamp: Long = 0L,
val bookmarksJson: String? = null,
val hasAnnotations: Boolean = false,
val fileContentModifiedTimestamp: Long = 0L,
@ -83,7 +86,8 @@ internal data class DesktopCloudFontMetadata(
internal data class DesktopDriveFile(
val id: String,
val name: String
val name: String,
val modifiedTimeMillis: Long = 0L
)
internal class DesktopFirestoreRepository(
@ -266,6 +270,10 @@ internal class DesktopGoogleDriveRepository(
listFiles(accessToken = accessToken, query = null)
}
suspend fun getFileByName(accessToken: String, fileName: String): DesktopDriveFile? = withContext(Dispatchers.IO) {
listFiles(accessToken, "name = '${driveQueryStringValue(fileName)}' and trashed = false").firstOrNull()
}
suspend fun uploadFont(accessToken: String, fileName: String, file: File, extension: String): DesktopDriveFile? =
uploadNamedFile(
accessToken = accessToken,
@ -293,18 +301,20 @@ internal class DesktopGoogleDriveRepository(
suspend fun uploadAnnotationFile(accessToken: String, bookId: String, file: File): DesktopDriveFile? {
return uploadNamedFile(
accessToken = accessToken,
fileName = "annotation_$bookId.json",
fileName = desktopCloudAnnotationDriveFileName(bookId),
file = file,
contentType = "application/json"
)
}
suspend fun downloadAnnotationFile(accessToken: String, bookId: String, destination: File): Boolean {
val fileId = listFiles(accessToken, "name = '${driveQueryStringValue("annotation_$bookId.json")}' and trashed = false")
.firstOrNull()
?.id
val driveFile = getFileByName(accessToken, desktopCloudAnnotationDriveFileName(bookId))
?: return false
return downloadFile(accessToken, fileId, destination)
return downloadFile(accessToken, driveFile.id, destination).also { downloaded ->
if (downloaded && driveFile.modifiedTimeMillis > 0L) {
destination.setLastModified(driveFile.modifiedTimeMillis)
}
}
}
suspend fun downloadFile(accessToken: String, fileId: String, destination: File): Boolean = withContext(Dispatchers.IO) {
@ -375,9 +385,9 @@ internal class DesktopGoogleDriveRepository(
}.toByteArray(Charsets.UTF_8)
val suffix = "\r\n--$boundary--\r\n".toByteArray(Charsets.UTF_8)
val uploadUri = if (existingFileId == null) {
URI.create("https://www.googleapis.com/upload/drive/v3/files?${query("uploadType" to "multipart", "fields" to "id,name")}")
URI.create("https://www.googleapis.com/upload/drive/v3/files?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}")
} else {
URI.create("https://www.googleapis.com/upload/drive/v3/files/${pathEncode(existingFileId)}?${query("uploadType" to "multipart", "fields" to "id,name")}")
URI.create("https://www.googleapis.com/upload/drive/v3/files/${pathEncode(existingFileId)}?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}")
}
val request = HttpRequest.newBuilder(uploadUri)
.timeout(Duration.ofMinutes(5))
@ -399,14 +409,15 @@ internal class DesktopGoogleDriveRepository(
val root = DesktopCloudJson.parseToJsonElement(response.body()).jsonObject
DesktopDriveFile(
id = root.string("id").orEmpty(),
name = root.string("name").orEmpty()
name = root.string("name").orEmpty(),
modifiedTimeMillis = parseDriveModifiedTimeMillis(root.string("modifiedTime"))
)
}
private fun listFiles(accessToken: String, query: String?): List<DesktopDriveFile> {
val params = buildList {
add("spaces" to "appDataFolder")
add("fields" to "files(id,name)")
add("fields" to "files(id,name,modifiedTime)")
if (!query.isNullOrBlank()) add("q" to query)
}
val request = HttpRequest.newBuilder(
@ -426,11 +437,20 @@ internal class DesktopGoogleDriveRepository(
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
val id = obj.string("id") ?: return@mapNotNull null
val name = obj.string("name") ?: return@mapNotNull null
DesktopDriveFile(id = id, name = name)
DesktopDriveFile(
id = id,
name = name,
modifiedTimeMillis = parseDriveModifiedTimeMillis(obj.string("modifiedTime"))
)
}
}
}
private fun parseDriveModifiedTimeMillis(value: String?): Long {
if (value.isNullOrBlank()) return 0L
return runCatching { Instant.parse(value).toEpochMilli() }.getOrDefault(0L)
}
private data class DesktopFirestoreDocument(
val id: String,
val fields: JsonObject?
@ -464,6 +484,8 @@ private fun DesktopCloudBookMetadata.toFirestoreFields(): Map<String, JsonElemen
"isRecent" to firestoreBoolean(isRecent),
"isDeleted" to firestoreBoolean(isDeleted),
"lastModifiedTimestamp" to firestoreLong(lastModifiedTimestamp),
"readingPositionModifiedTimestamp" to firestoreLong(readingPositionModifiedTimestamp),
"annotationModifiedTimestamp" to firestoreLong(annotationModifiedTimestamp),
"bookmarksJson" to firestoreNullableString(bookmarksJson),
"hasAnnotations" to firestoreBoolean(hasAnnotations),
"fileContentModifiedTimestamp" to firestoreLong(fileContentModifiedTimestamp),
@ -512,6 +534,8 @@ private fun JsonObject.toBookMetadata(documentId: String): DesktopCloudBookMetad
isRecent = booleanField("isRecent") ?: true,
isDeleted = booleanField("isDeleted") ?: false,
lastModifiedTimestamp = longField("lastModifiedTimestamp"),
readingPositionModifiedTimestamp = longField("readingPositionModifiedTimestamp"),
annotationModifiedTimestamp = longField("annotationModifiedTimestamp"),
bookmarksJson = stringField("bookmarksJson"),
hasAnnotations = booleanField("hasAnnotations") ?: false,
fileContentModifiedTimestamp = longField("fileContentModifiedTimestamp"),

View file

@ -11,54 +11,118 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
internal object DesktopCloudSidecarSync {
fun localAnnotationDebugSummary(book: BookItem): String {
val path = book.path?.takeIf { it.isNotBlank() } ?: return "path=null type=${book.type}"
if (book.type != FileType.PDF) return "path=${path.logPreview(140)} type=${book.type}"
val annotationFile = desktopPdfAnnotationFile(path)
val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path)
val bookmarkFile = desktopPdfBookmarkFile(path)
val richTextFile = desktopPdfRichTextFile(path)
return "path=${path.logPreview(140)} " +
"annotations{exists=${annotationFile.isFile} syncable=${annotationFile.hasSyncablePdfAnnotations()} " +
"bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFile()}} " +
"deletedAnnotations{exists=${deletedAnnotationFile.isFile} count=${deletedAnnotationFile.annotationDeletionCount()} " +
"bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModifiedIfFile()}} " +
"bookmarks{exists=${bookmarkFile.isFile} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModifiedIfFile()}} " +
"text{exists=${richTextFile.isFile} syncable=${richTextFile.hasSyncablePdfRichText()} " +
"bytes=${richTextFile.length()} ts=${richTextFile.lastModifiedIfFile()}} " +
"payloadTs=${localAnnotationPayloadTimestamp(book)} totalTs=${localAnnotationTimestamp(book)}"
}
fun hasLocalAnnotationData(book: BookItem): Boolean {
val path = book.path?.takeIf { it.isNotBlank() } ?: return false
if (book.type != FileType.PDF) return false
return desktopPdfAnnotationFile(path).isFile ||
return desktopPdfAnnotationFile(path).hasSyncablePdfAnnotations() ||
desktopPdfAnnotationDeletionFile(path).hasSyncablePdfAnnotationDeletions() ||
desktopPdfBookmarkFile(path).isFile ||
desktopPdfRichTextFile(path).isFile
desktopPdfRichTextFile(path).hasSyncablePdfRichText()
}
fun localAnnotationTimestamp(book: BookItem): Long {
val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L
if (book.type != FileType.PDF) return 0L
return maxOf(
desktopPdfAnnotationFile(path).lastModifiedIfFile(),
desktopPdfBookmarkFile(path).lastModifiedIfFile(),
desktopPdfRichTextFile(path).lastModifiedIfFile()
localAnnotationPayloadTimestamp(path),
desktopPdfBookmarkFile(path).lastModifiedIfFile()
)
}
fun localAnnotationPayloadTimestamp(book: BookItem): Long {
val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L
if (book.type != FileType.PDF) return 0L
return localAnnotationPayloadTimestamp(path)
}
fun markAnnotationPayloadSynced(book: BookItem, timestamp: Long) {
val path = book.path?.takeIf { it.isNotBlank() } ?: return
if (book.type != FileType.PDF || timestamp <= 0L) return
listOf(desktopPdfAnnotationFile(path), desktopPdfAnnotationDeletionFile(path), desktopPdfRichTextFile(path))
.filter { it.isFile }
.forEach { it.setLastModified(timestamp) }
}
fun recordAnnotationDeletions(book: BookItem, annotationIds: Collection<String>) {
val path = book.path?.takeIf { it.isNotBlank() } ?: return
if (book.type != FileType.PDF) return
recordAnnotationDeletions(path, book.id, annotationIds)
}
fun recordAnnotationDeletions(documentPath: String, logBookId: String, annotationIds: Collection<String>) {
val ids = annotationIds.mapNotNull { it.takeIf(String::isNotBlank) }.toSet()
if (ids.isEmpty()) return
val file = desktopPdfAnnotationDeletionFile(documentPath)
val existing = if (file.isFile) {
SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText())
} else {
emptyMap()
}
val now = System.currentTimeMillis()
val next = existing.toMutableMap()
ids.forEach { id -> next[id] = maxOf(next[id] ?: 0L, now) }
val nextJson = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(next)
if (file.isFile && file.readText() == nextJson) return
file.parentFile?.mkdirs()
file.writeText(nextJson)
logDesktopCloudAnnotations {
"desktop.local.mark_deleted_annotations book=$logBookId ids=${ids.sorted()} " +
"bytes=${file.length()} ts=${file.lastModified()}"
}
}
fun exportAnnotationBundle(book: BookItem): File? {
val path = book.path?.takeIf { it.isNotBlank() } ?: return null
if (book.type != FileType.PDF) return null
val annotationFile = desktopPdfAnnotationFile(path)
val bookmarkFile = desktopPdfBookmarkFile(path)
val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path)
val richTextFile = desktopPdfRichTextFile(path)
logDesktopCloudAnnotations {
"desktop.export.inspect book=${book.id} ${localAnnotationDebugSummary(book)}"
}
val data = buildMap {
if (annotationFile.isFile) {
val annotations = SharedPdfAnnotationSerializer.decode(annotationFile.readText())
put(
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS,
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
)
}
if (bookmarkFile.isFile) {
cloudSidecarJson.parseElementOrNull(bookmarkFile.readText())?.let { put("bookmarks", it) }
}
if (richTextFile.isFile) {
cloudSidecarJson.parseElementOrNull(richTextFile.readText())?.let { element ->
put("text", SharedPdfRichTextSerializer.encodeElement(SharedPdfRichTextSerializer.decodeElement(element)))
desktopPdfAnnotationElementForSync(annotationFile.readText())?.let { annotations ->
put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations)
}
}
if (deletedAnnotationFile.hasSyncablePdfAnnotationDeletions()) {
val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(deletedAnnotationFile.readText())
put(
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS,
SharedPdfAnnotationSidecarCodec.encodeAnnotationDeletionsElement(deletions)
)
}
if (richTextFile.isFile) {
desktopPdfRichTextElementForSync(richTextFile.readText())?.let { put("text", it) }
}
}
if (data.isEmpty()) {
logDesktopCloudAnnotations { "desktop.export.skip book=${book.id} reason=no_syncable_payload" }
return null
}
if (data.isEmpty()) return null
val payload = JsonObject(mapOf("version" to JsonPrimitive(2)) + data)
val canonical = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(
cloudSidecarJson.encodeToString(JsonElement.serializer(), payload)
@ -69,48 +133,129 @@ internal object DesktopCloudSidecarSync {
)
tempFile.parentFile?.mkdirs()
tempFile.writeText(canonical)
logDesktopCloudAnnotations {
"desktop.export.bundle_ready book=${book.id} keys=${data.keys.toList()} " +
"canonicalBytes=${canonical.length} fileBytes=${tempFile.length()} temp=${tempFile.name}"
}
return tempFile
}
fun importAnnotationBundle(book: BookItem, rawJson: String, timestamp: Long): Boolean {
val path = book.path?.takeIf { it.isNotBlank() } ?: return false
if (book.type != FileType.PDF) return false
val root = cloudSidecarJson.parseElementOrNull(rawJson)?.jsonObjectOrNull() ?: return false
val path = book.path?.takeIf { it.isNotBlank() } ?: run {
logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=missing_path bytes=${rawJson.length}" }
return false
}
if (book.type != FileType.PDF) {
logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=not_pdf type=${book.type} bytes=${rawJson.length}" }
return false
}
val root = cloudSidecarJson.parseElementOrNull(rawJson)?.jsonObjectOrNull() ?: run {
logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=parse_failed bytes=${rawJson.length}" }
return false
}
val data = root["data"]?.jsonObjectOrNull() ?: root
val canonicalData = SharedPdfAnnotationSidecarCodec.withCanonicalAnnotations(data)
val annotationFile = desktopPdfAnnotationFile(path)
val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path)
val bookmarkFile = desktopPdfBookmarkFile(path)
val richTextFile = desktopPdfRichTextFile(path)
logDesktopCloudAnnotations {
"desktop.import.inspect book=${book.id} remoteTs=$timestamp rawBytes=${rawJson.length} " +
"rawKeys=${data.keys.toList()} canonicalKeys=${canonicalData.keys.toList()} " +
localAnnotationDebugSummary(book)
}
if (canonicalData.hasPdfAnnotationPayload()) {
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(canonicalData)
annotationFile.parentFile?.mkdirs()
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
annotationFile.setLastModified(timestamp)
if (annotations.isEmpty()) {
if (annotationFile.isFile) {
val deleted = annotationFile.delete()
logDesktopCloudAnnotations { "desktop.import.delete_annotations book=${book.id} deleted=$deleted" }
} else {
logDesktopCloudAnnotations { "desktop.import.annotations_empty book=${book.id} existing=false" }
}
} else {
annotationFile.parentFile?.mkdirs()
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
annotationFile.setLastModified(timestamp)
logDesktopCloudAnnotations {
"desktop.import.write_annotations book=${book.id} count=${annotations.size} " +
"bytes=${annotationFile.length()} ts=${annotationFile.lastModified()}"
}
}
} else if (annotationFile.isFile) {
annotationFile.delete()
val deleted = annotationFile.delete()
logDesktopCloudAnnotations { "desktop.import.delete_annotations_missing_payload book=${book.id} deleted=$deleted" }
} else {
logDesktopCloudAnnotations { "desktop.import.no_annotation_payload book=${book.id} existing=false" }
}
val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromData(canonicalData)
if (deletions.isEmpty()) {
if (deletedAnnotationFile.isFile) {
val deleted = deletedAnnotationFile.delete()
logDesktopCloudAnnotations { "desktop.import.delete_annotation_tombstones book=${book.id} deleted=$deleted" }
}
} else {
deletedAnnotationFile.parentFile?.mkdirs()
deletedAnnotationFile.writeText(SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(deletions))
deletedAnnotationFile.setLastModified(timestamp)
logDesktopCloudAnnotations {
"desktop.import.write_annotation_tombstones book=${book.id} count=${deletions.size} " +
"bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModified()}"
}
}
canonicalData["bookmarks"]?.let { bookmarks ->
bookmarkFile.parentFile?.mkdirs()
bookmarkFile.writeText(cloudSidecarJson.encodeToString(JsonElement.serializer(), bookmarks))
bookmarkFile.setLastModified(timestamp)
} ?: run {
if (bookmarkFile.isFile) bookmarkFile.delete()
logDesktopCloudAnnotations {
"desktop.import.write_bookmarks book=${book.id} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModified()}"
}
}
canonicalData["text"]?.let { richText ->
val richDocument = SharedPdfRichTextSerializer.decodeElement(richText)
richTextFile.parentFile?.mkdirs()
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
richTextFile.setLastModified(timestamp)
if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) {
if (richTextFile.isFile) {
val deleted = richTextFile.delete()
logDesktopCloudAnnotations { "desktop.import.delete_text_empty book=${book.id} deleted=$deleted" }
} else {
logDesktopCloudAnnotations { "desktop.import.text_empty book=${book.id} existing=false" }
}
} else {
richTextFile.parentFile?.mkdirs()
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
richTextFile.setLastModified(timestamp)
logDesktopCloudAnnotations {
"desktop.import.write_text book=${book.id} textChars=${richDocument.text.length} " +
"spans=${richDocument.spans.size} bytes=${richTextFile.length()} ts=${richTextFile.lastModified()}"
}
}
} ?: run {
if (richTextFile.isFile) richTextFile.delete()
if (richTextFile.isFile) {
val deleted = richTextFile.delete()
logDesktopCloudAnnotations { "desktop.import.delete_text_missing book=${book.id} deleted=$deleted" }
} else {
logDesktopCloudAnnotations { "desktop.import.no_text_payload book=${book.id} existing=false" }
}
}
logDesktopCloudAnnotations {
"desktop.import.done book=${book.id} remoteTs=$timestamp ${localAnnotationDebugSummary(book)}"
}
return true
}
}
private fun localAnnotationPayloadTimestamp(path: String): Long {
return maxOf(
desktopPdfAnnotationFile(path).lastModifiedIfSyncableAnnotations(),
desktopPdfAnnotationDeletionFile(path).lastModifiedIfSyncableAnnotationDeletions(),
desktopPdfRichTextFile(path).lastModifiedIfSyncableRichText()
)
}
private val cloudSidecarJson = Json {
ignoreUnknownKeys = true
prettyPrint = true
@ -136,3 +281,31 @@ private fun JsonObject.hasPdfAnnotationPayload(): Boolean {
private fun File.lastModifiedIfFile(): Long {
return if (isFile) lastModified() else 0L
}
private fun File.hasSyncablePdfAnnotations(): Boolean {
return isFile && desktopPdfAnnotationElementForSync(readText()) != null
}
private fun File.lastModifiedIfSyncableAnnotations(): Long {
return if (hasSyncablePdfAnnotations()) lastModified() else 0L
}
private fun File.annotationDeletionCount(): Int {
return if (isFile) SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(readText()).size else 0
}
private fun File.hasSyncablePdfAnnotationDeletions(): Boolean {
return annotationDeletionCount() > 0
}
private fun File.lastModifiedIfSyncableAnnotationDeletions(): Long {
return if (hasSyncablePdfAnnotationDeletions()) lastModified() else 0L
}
private fun File.hasSyncablePdfRichText(): Boolean {
return isFile && desktopPdfRichTextElementForSync(readText()) != null
}
private fun File.lastModifiedIfSyncableRichText(): Long {
return if (hasSyncablePdfRichText()) lastModified() else 0L
}

View file

@ -7,9 +7,17 @@ import com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.EpubBookmark
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.SharedCloudBookMetadataWinner
import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.ShelfRecord
import com.aryan.reader.shared.sharedCloudBookMetadataWinner
import com.aryan.reader.shared.shouldDownloadRemoteCloudBookContent
import com.aryan.reader.shared.shouldUploadLocalCloudBookContent
import com.aryan.reader.shared.sharedCloudBookContentFileName
import com.aryan.reader.shared.toStablePositionCfi
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
import com.aryan.reader.shared.pdf.SharedPdfReaderViewport
import com.aryan.reader.shared.reader.ReaderBookmark
import java.io.File
@ -31,7 +39,8 @@ internal data class DesktopCloudSyncResult(
val shelfRefs: List<BookShelfRef>,
val customFonts: List<CustomFontItem>,
val uploadedBooks: Int = 0,
val downloadedBooks: Int = 0
val downloadedBooks: Int = 0,
val pendingContentDownloads: Int = 0
)
internal class DesktopCloudSync(
@ -47,13 +56,22 @@ internal class DesktopCloudSync(
var customFonts = input.customFonts
var uploadedBooks = 0
var downloadedBooks = 0
var pendingContentDownloads = 0
logDesktopCloudSync {
"desktop.engine.full_sync.start user=${input.userId} device=${input.deviceId} " +
"localBooks=${input.state.rawLibraryBooks.size} includeFolderBooks=${input.includeFolderBooks}"
}
val remoteBooks = firestoreRepository.getAllBooks(input.userId, input.idToken)
.filterNot { isDesktopPdfReflowBookId(it.bookId) }
.filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) }
val remoteShelves = firestoreRepository.getAllShelves(input.userId, input.idToken)
val remoteFonts = firestoreRepository.getAllFonts(input.userId, input.idToken)
var driveFiles = driveRepository.getFiles(input.driveAccessToken).associateBy { it.name }
logDesktopCloudSync {
"desktop.engine.full_sync.loaded user=${input.userId} remoteBooks=${remoteBooks.size} " +
"remoteShelves=${remoteShelves.size} remoteFonts=${remoteFonts.size} driveFiles=${driveFiles.size}"
}
val localBooks = state.rawLibraryBooks
.filterNot { isDesktopPdfReflowBookId(it.id) }
@ -71,6 +89,7 @@ internal class DesktopCloudSync(
when {
local != null && remote == null -> {
logDesktopCloudSync { "desktop.engine.book_decision action=upload_new ${local.desktopCloudSyncSummary()}" }
uploadBookAndMetadata(input, local, uploadContent = true)?.let { synced ->
state = state.upsertCloudBook(synced)
uploadedBooks += 1
@ -78,22 +97,33 @@ internal class DesktopCloudSync(
}
local == null && remote != null -> {
if (remote.isDeleted) return@forEach
if (remote.isDeleted) {
logDesktopCloudSync { "desktop.engine.book_decision action=skip_deleted_remote_only ${remote.desktopCloudSyncSummary()}" }
return@forEach
}
logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_new ${remote.desktopCloudSyncSummary()}" }
val downloaded = downloadRemoteBook(input.driveAccessToken, remote, null, driveFiles)
val remoteBook = downloaded ?: remote.toDesktopBookItem()
if (downloaded == null) {
pendingContentDownloads += 1
logDesktopCloudSync {
"desktop.engine.book_decision action=defer_remote_new_pending_content " +
remote.desktopCloudSyncSummary()
}
return@forEach
}
val remoteBook = downloaded
state = state.upsertCloudBook(remoteBook)
if (downloaded != null) downloadedBooks += 1
downloadedBooks += 1
importDesktopPdfBookmarksMetadata(remoteBook, remote.bookmarksJson, remote.lastModifiedTimestamp)
if (remote.hasAnnotations) {
downloadAnnotations(input.driveAccessToken, remoteBook, remote.lastModifiedTimestamp)
val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp(
remoteAnnotationDriveFileTimestamp(remote.bookId, driveFiles)
)
downloadAnnotations(input.driveAccessToken, remoteBook, remoteAnnotationTimestamp)
}
}
local != null && remote != null -> {
if (remote.isDeleted) {
state = state.removeCloudBook(bookId)
return@forEach
}
val remoteBook = remote.toDesktopBookItem(existing = local)
val shouldDownloadContent = shouldDownloadRemoteBookContent(local, remote)
val downloaded = if (shouldDownloadContent) {
@ -101,24 +131,165 @@ internal class DesktopCloudSync(
} else {
null
}
val localSidecarTimestampBeforeMerge = DesktopCloudSidecarSync.localAnnotationTimestamp(local)
val localMetadataTimestamp = maxOf(local.timestamp, localSidecarTimestampBeforeMerge)
if (localMetadataTimestamp > remote.lastModifiedTimestamp) {
uploadBookAndMetadata(input, local, uploadContent = shouldUploadLocalBookContent(local, remote))?.let { synced ->
state = state.upsertCloudBook(synced)
uploadedBooks += 1
if (shouldDownloadContent && downloaded == null) {
pendingContentDownloads += 1
}
val localContentAvailable = local.path?.let(::File)?.isFile == true
if (shouldDownloadContent && downloaded == null && !localContentAvailable) {
logDesktopCloudSync {
"desktop.engine.book_decision action=defer_existing_pending_content book=$bookId " +
local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary()
}
} else if (remote.lastModifiedTimestamp > local.timestamp || downloaded != null) {
state = state.upsertCloudBook(downloaded ?: remoteBook)
state = state.removeCloudBook(bookId)
return@forEach
}
val localSidecarTimestampBeforeMerge = DesktopCloudSidecarSync.localAnnotationTimestamp(local)
val metadataWinner = sharedCloudBookMetadataWinner(
localModifiedTimestamp = local.timestamp,
remoteModifiedTimestamp = remote.lastModifiedTimestamp
)
val localMetadataWins = metadataWinner == SharedCloudBookMetadataWinner.LOCAL
val localReadingTimestamp = local.effectiveCloudReadingPositionModifiedTimestamp()
val remoteReadingTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp()
val remoteAnnotationDriveTimestamp = remoteAnnotationDriveFileTimestamp(bookId, driveFiles)
val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp(remoteAnnotationDriveTimestamp)
val localReadingPositionShouldUpload = localReadingTimestamp > remoteReadingTimestamp
val localAnnotationsShouldUpload = shouldUploadLocalAnnotations(
local = local,
remote = remote,
remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp,
localSidecarTimestamp = localSidecarTimestampBeforeMerge
)
logDesktopCloudAnnotations {
"desktop.sync.inspect book=$bookId remoteHas=${remote.hasAnnotations} " +
"remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " +
"remoteDriveAnnTs=$remoteAnnotationDriveTimestamp localTs=${local.timestamp} " +
"remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " +
"localShouldUpload=$localAnnotationsShouldUpload " +
DesktopCloudSidecarSync.localAnnotationDebugSummary(local)
}
logDesktopCloudSync {
"desktop.engine.book_compare book=$bookId winner=$metadataWinner shouldDownloadContent=$shouldDownloadContent " +
"downloadedContent=${downloaded != null} sidecarTs=$localSidecarTimestampBeforeMerge " +
"uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " +
local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary()
}
val localSidecarTimestamp = DesktopCloudSidecarSync.localAnnotationTimestamp(downloaded ?: local)
val needsAnnotationDownload = remote.hasAnnotations &&
(remote.lastModifiedTimestamp > localSidecarTimestamp || localSidecarTimestamp == 0L)
if (remote.isDeleted) {
if (localMetadataWins) {
logDesktopCloudSync { "desktop.engine.book_decision action=resurrect_upload_local book=$bookId" }
uploadBookAndMetadata(
input = input,
book = local,
uploadContent = shouldUploadLocalBookContent(local, null),
uploadAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(local)
)?.let { synced ->
state = state.upsertCloudBook(synced)
uploadedBooks += 1
}
} else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE) {
logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_delete book=$bookId" }
state = state.removeCloudBook(bookId)
} else {
logDesktopCloudSync { "desktop.engine.book_decision action=skip_equal_delete book=$bookId" }
}
return@forEach
}
if (localMetadataWins) {
logDesktopCloudSync {
"desktop.engine.book_decision action=upload_local book=$bookId " +
"uploadContent=${shouldUploadLocalBookContent(local, remote)} " +
"uploadAnnotations=$localAnnotationsShouldUpload " +
"preserveRemoteReading=${remoteReadingTimestamp > localReadingTimestamp}"
}
val localForMetadata = if (remoteReadingTimestamp > localReadingTimestamp) {
local.withCloudReadingPosition(remote)
} else {
local
}
val bookForMetadata = localForMetadata.withDownloadedCloudContent(downloaded, replacePath = false)
uploadBookAndMetadata(
input = input,
book = bookForMetadata,
uploadContent = shouldUploadLocalBookContent(local, remote),
uploadAnnotations = localAnnotationsShouldUpload,
remoteHasAnnotations = remote.hasAnnotations,
remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp,
remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp
)?.let { synced ->
state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded))
uploadedBooks += 1
}
} else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE || downloaded != null) {
logDesktopCloudSync {
"desktop.engine.book_decision action=apply_remote book=$bookId " +
"metadataWinner=$metadataWinner downloadedContent=${downloaded != null}"
}
val mergedBook = downloaded ?: remoteBook
state = state.upsertCloudBook(mergedBook)
importDesktopPdfBookmarksMetadata(mergedBook, remote.bookmarksJson, remote.lastModifiedTimestamp)
}
if (!localMetadataWins && (localAnnotationsShouldUpload || localReadingPositionShouldUpload)) {
val metadataBook = state.rawLibraryBooks.firstOrNull { it.id == bookId }
?: remoteBook
logDesktopCloudAnnotations {
"desktop.sync.upload_local_supplement book=$bookId winner=$metadataWinner " +
"remoteHas=${remote.hasAnnotations} remoteTs=${remote.lastModifiedTimestamp} " +
"remoteAnnTs=$remoteAnnotationTimestamp " +
"localSidecarTs=$localSidecarTimestampBeforeMerge " +
"uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " +
"localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp"
}
logDesktopCloudSync {
"desktop.engine.book_decision action=upload_local_supplement book=$bookId " +
"metadataWinner=$metadataWinner sidecarTs=$localSidecarTimestampBeforeMerge " +
"uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " +
metadataBook.desktopCloudSyncSummary()
}
uploadBookAndMetadata(
input = input,
book = metadataBook,
uploadContent = false,
uploadAnnotations = localAnnotationsShouldUpload,
remoteHasAnnotations = remote.hasAnnotations,
remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp,
remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp
)?.let { synced ->
state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded))
uploadedBooks += 1
}
}
val localSidecarTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(downloaded ?: local)
val needsAnnotationDownload = !localMetadataWins &&
!localAnnotationsShouldUpload &&
remote.hasAnnotations &&
(remoteAnnotationTimestamp > localSidecarTimestamp || localSidecarTimestamp == 0L)
if (needsAnnotationDownload) {
logDesktopCloudAnnotations {
"desktop.sync.download_remote_annotations book=$bookId remoteTs=${remote.lastModifiedTimestamp} " +
"remoteAnnTs=$remoteAnnotationTimestamp " +
"localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " +
"localShouldUpload=$localAnnotationsShouldUpload"
}
logDesktopCloudSync {
"desktop.engine.sidecar_download_start book=$bookId remoteTs=${remote.lastModifiedTimestamp} " +
"remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins"
}
val targetBook = downloaded ?: state.rawLibraryBooks.firstOrNull { it.id == bookId } ?: local
downloadAnnotations(input.driveAccessToken, targetBook, remote.lastModifiedTimestamp)
downloadAnnotations(input.driveAccessToken, targetBook, remoteAnnotationTimestamp)
} else {
logDesktopCloudAnnotations {
"desktop.sync.skip_remote_annotations book=$bookId remoteHas=${remote.hasAnnotations} " +
"remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " +
"localShouldUpload=$localAnnotationsShouldUpload"
}
logDesktopCloudSync {
"desktop.engine.sidecar_download_skip book=$bookId remoteHasAnnotations=${remote.hasAnnotations} " +
"localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins"
}
}
}
}
@ -135,18 +306,49 @@ internal class DesktopCloudSync(
val localFile = book.path?.let(::File)
when {
localFile?.isFile == true && driveFiles[driveName] == null -> {
if (driveRepository.uploadFile(input.driveAccessToken, book.id, localFile, book.type) != null) {
uploadedBooks += 1
val remote = remoteBooksMap[book.id]
if (remote == null || shouldUploadLocalBookContent(book, remote)) {
logDesktopCloudSync { "desktop.engine.content_upload_missing_remote book=${book.id} driveName=$driveName" }
uploadBookAndMetadata(
input = input,
book = book,
uploadContent = true,
uploadAnnotations = false,
remoteHasAnnotations = remote?.hasAnnotations == true,
remoteAnnotationModifiedTimestamp = remote?.effectiveCloudAnnotationModifiedTimestamp(
remoteAnnotationDriveFileTimestamp(book.id, driveFiles)
) ?: 0L,
remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp
)?.let { synced ->
state = state.upsertCloudBook(synced)
uploadedBooks += 1
}
} else {
pendingContentDownloads += 1
logDesktopCloudSync {
"desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName " +
"localContentTs=${book.fileContentModifiedTimestamp} remoteContentTs=${remote.fileContentModifiedTimestamp}"
}
}
}
(localFile == null || !localFile.isFile) && driveFiles[driveName] != null -> {
val remote = remoteBooksMap[book.id] ?: return@forEach
downloadRemoteBook(input.driveAccessToken, remote, book, driveFiles)?.let { downloaded ->
logDesktopCloudSync { "desktop.engine.content_download_missing_local book=${book.id} driveName=$driveName" }
val downloaded = downloadRemoteBook(input.driveAccessToken, remote, book, driveFiles)
if (downloaded != null) {
state = state.upsertCloudBook(downloaded)
downloadedBooks += 1
} else {
pendingContentDownloads += 1
}
}
(localFile == null || !localFile.isFile) && driveFiles[driveName] == null -> {
pendingContentDownloads += 1
logDesktopCloudSync { "desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName" }
state = state.removeCloudBook(book.id)
}
}
}
@ -172,55 +374,205 @@ internal class DesktopCloudSync(
remoteFonts = remoteFonts
)
logDesktopCloudSync {
"desktop.engine.full_sync.complete user=${input.userId} uploaded=$uploadedBooks downloaded=$downloadedBooks " +
"pendingContent=$pendingContentDownloads books=${state.rawLibraryBooks.size}"
}
return DesktopCloudSyncResult(
state = state,
shelfRecords = shelfRecords,
shelfRefs = shelfRefs,
customFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() },
uploadedBooks = uploadedBooks,
downloadedBooks = downloadedBooks
downloadedBooks = downloadedBooks,
pendingContentDownloads = pendingContentDownloads
)
}
suspend fun uploadBookAndMetadata(
input: DesktopCloudSyncInput,
book: BookItem,
uploadContent: Boolean
uploadContent: Boolean,
uploadAnnotations: Boolean = true,
remoteHasAnnotations: Boolean = false,
remoteAnnotationModifiedTimestamp: Long = 0L,
remoteContentModifiedTimestamp: Long? = null
): BookItem? {
if (isDesktopPdfReflowBookId(book.id)) return null
if (book.sourceFolder != null) return null
if (book.path?.startsWith("opds-pse") == true) return null
if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) return null
if (isDesktopPdfReflowBookId(book.id)) {
logDesktopCloudSync { "desktop.upload.skip reason=reflow ${book.desktopCloudSyncSummary()}" }
return null
}
if (book.sourceFolder != null) {
logDesktopCloudSync { "desktop.upload.skip reason=folder_book ${book.desktopCloudSyncSummary()}" }
return null
}
if (book.path?.startsWith("opds-pse") == true) {
logDesktopCloudSync { "desktop.upload.skip reason=opds_stream ${book.desktopCloudSyncSummary()}" }
return null
}
if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) {
logDesktopCloudSync { "desktop.upload.skip reason=manual_only ${book.desktopCloudSyncSummary()}" }
return null
}
logDesktopCloudSync {
"desktop.upload.start uploadContent=$uploadContent uploadAnnotations=$uploadAnnotations " +
"remoteHasAnnotations=$remoteHasAnnotations ${book.desktopCloudSyncSummary()}"
}
if (uploadContent) {
val source = book.path?.let(::File)?.takeIf { it.isFile }
if (source != null && driveRepository.uploadFile(input.driveAccessToken, book.id, source, book.type) == null) {
logDesktopCloudSync { "desktop.upload.content_failed book=${book.id} path=${source.absolutePath}" }
return null
}
logDesktopCloudSync { "desktop.upload.content_success book=${book.id} path=${source?.absolutePath ?: "none"}" }
}
val bundle = DesktopCloudSidecarSync.exportAnnotationBundle(book)
val hasLocalAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(book)
val shouldUploadAnnotations = uploadAnnotations || (!remoteHasAnnotations && hasLocalAnnotations)
val bundle = if (shouldUploadAnnotations) DesktopCloudSidecarSync.exportAnnotationBundle(book) else null
var uploadedAnnotationTimestamp = 0L
logDesktopCloudAnnotations {
"desktop.upload.annotation_decision book=${book.id} uploadAnnotations=$uploadAnnotations " +
"remoteHas=$remoteHasAnnotations hasLocal=$hasLocalAnnotations shouldUpload=$shouldUploadAnnotations " +
"bundleBytes=${bundle?.length() ?: 0L} " + DesktopCloudSidecarSync.localAnnotationDebugSummary(book)
}
try {
if (bundle != null && driveRepository.uploadAnnotationFile(input.driveAccessToken, book.id, bundle) == null) {
return null
if (bundle != null) {
val mergedRemoteIntoUpload = mergeRemoteAnnotationsIntoUploadBundle(
accessToken = input.driveAccessToken,
book = book,
bundle = bundle,
remoteHasAnnotations = remoteHasAnnotations
)
val uploadedAnnotationFile = driveRepository.uploadAnnotationFile(input.driveAccessToken, book.id, bundle)
if (uploadedAnnotationFile == null) {
logDesktopCloudAnnotations { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" }
logDesktopCloudSync { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" }
return null
}
uploadedAnnotationTimestamp = uploadedAnnotationFile.modifiedTimeMillis
if (mergedRemoteIntoUpload) {
val appliedMergedLocal = DesktopCloudSidecarSync.importAnnotationBundle(
book = book,
rawJson = bundle.readText(),
timestamp = uploadedAnnotationTimestamp
)
logDesktopCloudAnnotations {
"desktop.upload.local_apply_merged book=${book.id} applied=$appliedMergedLocal " +
"driveTs=$uploadedAnnotationTimestamp bytes=${bundle.length()}"
}
}
DesktopCloudSidecarSync.markAnnotationPayloadSynced(book, uploadedAnnotationTimestamp)
}
if (bundle != null) {
logDesktopCloudAnnotations {
"desktop.upload.sidecar_success book=${book.id} bytes=${bundle.length()} driveTs=$uploadedAnnotationTimestamp"
}
} else {
logDesktopCloudAnnotations {
"desktop.upload.sidecar_skipped book=${book.id} shouldUpload=$shouldUploadAnnotations hasLocal=$hasLocalAnnotations"
}
}
logDesktopCloudSync {
"desktop.upload.sidecar_decision book=${book.id} hasLocal=$hasLocalAnnotations " +
"shouldUpload=$shouldUploadAnnotations uploaded=${bundle != null} bytes=${bundle?.length() ?: 0L}"
}
} finally {
bundle?.delete()
}
val now = System.currentTimeMillis()
val syncedBook = book.copy(timestamp = now)
val syncedBook = book.copy(
timestamp = now,
readingPositionModifiedTimestamp = book.effectiveCloudReadingPositionModifiedTimestamp()
)
val localAnnotationTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(book)
val syncedAnnotationTimestamp = if (bundle != null) {
uploadedAnnotationTimestamp.takeIf { it > 0L } ?: maxOf(localAnnotationTimestamp, now)
} else if (remoteHasAnnotations) {
remoteAnnotationModifiedTimestamp
} else {
0L
}
val syncedHasAnnotations = if (uploadAnnotations) {
syncedAnnotationTimestamp > 0L || (bundle != null && hasLocalAnnotations)
} else {
remoteHasAnnotations || syncedAnnotationTimestamp > 0L || bundle != null || hasLocalAnnotations
}
firestoreRepository.syncBookMetadata(
userId = input.userId,
book = syncedBook.toDesktopCloudBookMetadata(
hasAnnotations = bundle != null,
timestamp = now
hasAnnotations = syncedHasAnnotations,
timestamp = now,
annotationModifiedTimestamp = syncedAnnotationTimestamp,
contentTimestampOverride = if (uploadContent) null else remoteContentModifiedTimestamp
),
originDeviceId = input.deviceId,
idToken = input.idToken
)
logDesktopCloudSync {
"desktop.upload.metadata_success user=${input.userId} device=${input.deviceId} " +
"oldTs=${book.timestamp} newTs=$now hasAnnotations=$syncedHasAnnotations " +
syncedBook.desktopCloudSyncSummary("synced")
}
logDesktopCloudAnnotations {
"desktop.upload.metadata_success book=${book.id} oldTs=${book.timestamp} newTs=$now " +
"readTs=${syncedBook.effectiveCloudReadingPositionModifiedTimestamp()} " +
"annTs=$syncedAnnotationTimestamp hasAnnotations=$syncedHasAnnotations"
}
return syncedBook
}
private suspend fun mergeRemoteAnnotationsIntoUploadBundle(
accessToken: String,
book: BookItem,
bundle: File,
remoteHasAnnotations: Boolean
): Boolean {
if (!remoteHasAnnotations || !bundle.isFile) return false
val remoteTemp = File(desktopUserCacheRoot(), "remote_annotation_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json")
try {
val didDownload = driveRepository.downloadAnnotationFile(accessToken, book.id, remoteTemp)
if (!didDownload || !remoteTemp.isFile) {
logDesktopCloudAnnotations {
"desktop.upload.merge_remote_missing book=${book.id} didDownload=$didDownload " +
"tempExists=${remoteTemp.exists()} localBytes=${bundle.length()}"
}
return false
}
val localRaw = bundle.readText()
val remoteRaw = remoteTemp.readText()
val mergedRaw = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson(
localDataJson = localRaw,
remoteDataJson = remoteRaw,
preferRemoteOnConflict = false
)
val localCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(localRaw)
val remoteCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(remoteRaw)
val mergedCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(mergedRaw)
if (mergedRaw != localRaw) {
bundle.writeText(mergedRaw)
logDesktopCloudAnnotations {
"desktop.upload.merge_remote_applied book=${book.id} localCount=$localCount " +
"remoteCount=$remoteCount mergedCount=$mergedCount mergedBytes=${bundle.length()}"
}
return true
} else {
logDesktopCloudAnnotations {
"desktop.upload.merge_remote_noop book=${book.id} localCount=$localCount " +
"remoteCount=$remoteCount mergedCount=$mergedCount"
}
}
} catch (error: Exception) {
logDesktopCloudAnnotations {
"desktop.upload.merge_remote_failed book=${book.id} error=${error.message.orEmpty().logPreview(240)}"
}
} finally {
remoteTemp.delete()
}
return false
}
suspend fun deleteBooksFromCloud(
userId: String,
idToken: String,
@ -247,7 +599,7 @@ internal class DesktopCloudSync(
desktopCloudBookDriveFileName(book.id, book.type)
?.let { driveFiles[it]?.id }
?.let { driveRepository.deleteDriveFile(accessToken, it) }
driveFiles["annotation_${book.id}.json"]?.id
driveFiles[desktopCloudAnnotationDriveFileName(book.id)]?.id
?.let { driveRepository.deleteDriveFile(accessToken, it) }
}
}
@ -293,8 +645,32 @@ internal class DesktopCloudSync(
private suspend fun downloadAnnotations(accessToken: String, book: BookItem, timestamp: Long): Boolean {
val temp = File(desktopUserCacheRoot(), "temp_download_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json")
return try {
if (!driveRepository.downloadAnnotationFile(accessToken, book.id, temp) || !temp.isFile) return false
DesktopCloudSidecarSync.importAnnotationBundle(book, temp.readText(), timestamp)
logDesktopCloudAnnotations {
"desktop.download.start book=${book.id} remoteTs=$timestamp temp=${temp.name} " +
DesktopCloudSidecarSync.localAnnotationDebugSummary(book)
}
logDesktopCloudSync { "desktop.sidecar_download.start book=${book.id} remoteTs=$timestamp temp=${temp.name}" }
if (!driveRepository.downloadAnnotationFile(accessToken, book.id, temp) || !temp.isFile) {
logDesktopCloudAnnotations {
"desktop.download.missing book=${book.id} remoteTs=$timestamp tempExists=${temp.exists()} tempBytes=${temp.length()}"
}
logDesktopCloudSync { "desktop.sidecar_download.missing book=${book.id} remoteTs=$timestamp" }
return false
}
val raw = temp.readText()
logDesktopCloudAnnotations {
"desktop.download.success book=${book.id} remoteTs=$timestamp bytes=${raw.length}"
}
val appliedTimestamp = timestamp.takeIf { it > 0L } ?: temp.lastModified().takeIf { it > 0L } ?: 0L
val applied = DesktopCloudSidecarSync.importAnnotationBundle(book, raw, appliedTimestamp)
logDesktopCloudAnnotations {
"desktop.download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp applied=$applied " +
DesktopCloudSidecarSync.localAnnotationDebugSummary(book)
}
logDesktopCloudSync {
"desktop.sidecar_download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp bytes=${temp.length()} applied=$applied"
}
applied
} finally {
temp.delete()
}
@ -311,16 +687,23 @@ internal class DesktopCloudSync(
val driveFile = driveFiles[driveName] ?: return null
val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null
val destination = bookImporter.createBookFile("${remote.bookId.toDesktopSafeFileName()}.$extension")
logDesktopCloudSync { "desktop.content_download.start book=${remote.bookId} driveName=$driveName remoteContentTs=${remote.fileContentModifiedTimestamp}" }
if (!driveRepository.downloadFile(accessToken, driveFile.id, destination)) {
destination.delete()
logDesktopCloudSync { "desktop.content_download.failed book=${remote.bookId} driveName=$driveName" }
return null
}
val contentTimestamp = remote.fileContentModifiedTimestamp.takeIf { it > 0L } ?: destination.lastModified()
if (contentTimestamp > 0L) destination.setLastModified(contentTimestamp)
return remote.toDesktopBookItem(existing = existing, downloadedPath = destination.absolutePath).copy(
val downloaded = remote.toDesktopBookItem(existing = existing, downloadedPath = destination.absolutePath).copy(
fileSize = destination.length(),
fileContentModifiedTimestamp = contentTimestamp
)
logDesktopCloudSync {
"desktop.content_download.success book=${remote.bookId} bytes=${destination.length()} contentTs=$contentTimestamp " +
downloaded.desktopCloudSyncSummary("downloaded")
}
return downloaded
}
private suspend fun syncFonts(
@ -447,18 +830,28 @@ internal class DesktopCloudSync(
internal fun BookItem.toDesktopCloudBookMetadata(
hasAnnotations: Boolean,
timestamp: Long = this.timestamp
timestamp: Long = this.timestamp,
annotationModifiedTimestamp: Long = 0L,
contentTimestampOverride: Long? = null
): DesktopCloudBookMetadata {
val position = readerPosition
val bookmarksJson = readerBookmarks
.mapNotNull { it.toDesktopCloudEpubBookmarkOrNull() }
.takeIf { it.isNotEmpty() }
?.let(EpubAnnotationSerializer::bookmarksToJson)
val highlightsJson = readerHighlights
.takeIf { it.isNotEmpty() }
?.let(EpubAnnotationSerializer::highlightsToJson)
val position = readerPosition.takeIf { type.usesCloudLocatorMetadata() }
val supportsReaderAnnotations = type.usesCloudLocatorMetadata()
val bookmarksJson = desktopPdfBookmarksMetadataJson(this)
?: if (supportsReaderAnnotations) {
readerBookmarks
.mapNotNull { it.toDesktopCloudEpubBookmarkOrNull() }
.let(EpubAnnotationSerializer::bookmarksToJson)
} else {
null
}
val highlightsJson = if (supportsReaderAnnotations) {
EpubAnnotationSerializer.highlightsToJson(readerHighlights)
} else {
null
}
val localFile = path?.let(::File)
val contentTimestamp = fileContentModifiedTimestamp.takeIf { it > 0L }
val contentTimestamp = contentTimestampOverride
?: fileContentModifiedTimestamp.takeIf { it > 0L }
?: localFile?.takeIf { it.isFile }?.lastModified()
?: 0L
return DesktopCloudBookMetadata(
@ -469,13 +862,15 @@ internal fun BookItem.toDesktopCloudBookMetadata(
type = type.name,
lastPositionCfi = position?.cloudPositionCfi(),
lastChapterIndex = position?.chapterIndex,
locatorBlockIndex = null,
locatorCharOffset = null,
lastPage = position?.pageIndex ?: lastPageIndex,
locatorBlockIndex = position?.blockIndex,
locatorCharOffset = position?.charOffset,
lastPage = if (type.usesCloudLocatorMetadata()) position?.pageIndex ?: lastPageIndex else lastPageIndex,
progressPercentage = progressPercentage,
isRecent = isRecent,
isDeleted = false,
lastModifiedTimestamp = timestamp,
readingPositionModifiedTimestamp = effectiveCloudReadingPositionModifiedTimestamp(),
annotationModifiedTimestamp = annotationModifiedTimestamp,
bookmarksJson = bookmarksJson,
hasAnnotations = hasAnnotations,
fileContentModifiedTimestamp = contentTimestamp,
@ -498,11 +893,39 @@ internal fun DesktopCloudBookMetadata.toDesktopBookItem(
): BookItem {
val type = fileType()
val pageIndex = lastPage
val locator = ReaderLocator.fromLegacy(
chapterIndex = lastChapterIndex,
cfi = lastPositionCfi,
pageIndex = pageIndex
)
val locator = if (type.usesCloudLocatorMetadata()) {
ReaderLocator.fromLegacy(
chapterIndex = lastChapterIndex,
cfi = lastPositionCfi,
pageIndex = pageIndex
).withFallbacks(
blockIndex = locatorBlockIndex,
charOffset = locatorCharOffset
)
} else {
null
}
val remoteReadingTimestamp = effectiveCloudReadingPositionModifiedTimestamp()
val localReadingTimestamp = existing?.effectiveCloudReadingPositionModifiedTimestamp() ?: 0L
val useRemoteReadingPosition = existing == null ||
remoteReadingTimestamp > localReadingTimestamp ||
(localReadingTimestamp == 0L && hasCloudReadingPosition())
val restoredPageIndex = if (useRemoteReadingPosition) pageIndex ?: existing?.lastPageIndex else existing?.lastPageIndex
val restoredReaderPosition = if (type.usesCloudLocatorMetadata()) {
if (useRemoteReadingPosition) {
locator?.takeIf {
it.chapterIndex != null ||
it.pageIndex != null ||
it.cfi != null ||
it.startOffset != null ||
it.blockIndex != null
} ?: existing?.readerPosition
} else {
existing?.readerPosition
}
} else {
null
}
return BookItem(
id = bookId,
path = downloadedPath ?: existing?.path,
@ -518,7 +941,7 @@ internal fun DesktopCloudBookMetadata.toDesktopBookItem(
originalSeriesName = originalSeriesName ?: existing?.originalSeriesName,
originalSeriesIndex = originalSeriesIndex ?: existing?.originalSeriesIndex,
originalDescription = originalDescription ?: existing?.originalDescription,
progressPercentage = progressPercentage ?: existing?.progressPercentage,
progressPercentage = if (useRemoteReadingPosition) progressPercentage ?: existing?.progressPercentage else existing?.progressPercentage,
isRecent = isRecent,
fileSize = existing?.fileSize ?: 0L,
fileContentModifiedTimestamp = fileContentModifiedTimestamp.takeIf { it > 0L }
@ -529,12 +952,10 @@ internal fun DesktopCloudBookMetadata.toDesktopBookItem(
seriesName = seriesName ?: existing?.seriesName,
seriesIndex = seriesIndex ?: existing?.seriesIndex,
tags = existing?.tags.orEmpty(),
lastPageIndex = pageIndex ?: existing?.lastPageIndex,
readerPosition = locator.takeIf {
it.chapterIndex != null || it.pageIndex != null || it.cfi != null || it.startOffset != null
} ?: existing?.readerPosition,
lastPageIndex = restoredPageIndex,
readerPosition = restoredReaderPosition,
readerSettings = existing?.readerSettings,
readerBookmarks = if (bookmarksJson.isNullOrBlank()) {
readerBookmarks = if (type == FileType.PDF || bookmarksJson.isNullOrBlank()) {
existing?.readerBookmarks.orEmpty()
} else {
EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson).map { bookmark ->
@ -552,10 +973,101 @@ internal fun DesktopCloudBookMetadata.toDesktopBookItem(
} else {
EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
},
pdfReaderViewport = existing?.pdfReaderViewport
pdfReaderViewport = if (useRemoteReadingPosition) remotePdfViewport(existing, pageIndex) else existing?.pdfReaderViewport,
readingPositionModifiedTimestamp = if (useRemoteReadingPosition) remoteReadingTimestamp else localReadingTimestamp
)
}
internal fun BookItem.withCloudReadingPosition(remote: DesktopCloudBookMetadata): BookItem {
val remoteType = remote.fileType()
val pageIndex = remote.lastPage
val locator = if (remoteType.usesCloudLocatorMetadata()) {
ReaderLocator.fromLegacy(
chapterIndex = remote.lastChapterIndex,
cfi = remote.lastPositionCfi,
pageIndex = pageIndex
).withFallbacks(
blockIndex = remote.locatorBlockIndex,
charOffset = remote.locatorCharOffset
).takeIf {
it.chapterIndex != null ||
it.pageIndex != null ||
it.cfi != null ||
it.startOffset != null ||
it.blockIndex != null
}
} else {
null
}
return copy(
lastPageIndex = pageIndex ?: lastPageIndex,
readerPosition = if (remoteType.usesCloudLocatorMetadata()) locator ?: readerPosition else null,
progressPercentage = remote.progressPercentage ?: progressPercentage,
pdfReaderViewport = if (remoteType.usesCloudLocatorMetadata()) {
pdfReaderViewport
} else {
remote.remotePdfViewport(this, pageIndex)
},
readingPositionModifiedTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp()
)
}
private fun DesktopCloudBookMetadata.remotePdfViewport(
existing: BookItem?,
pageIndex: Int?
): SharedPdfReaderViewport? {
if (fileType().usesCloudLocatorMetadata() || pageIndex == null) return existing?.pdfReaderViewport
val base = existing?.pdfReaderViewport ?: SharedPdfReaderViewport()
return base.copy(
pageIndex = pageIndex,
horizontalScrollOffset = 0,
paginatedVerticalScrollOffset = 0,
verticalFirstPageIndex = pageIndex,
verticalFirstPageScrollOffset = 0
)
}
private fun FileType.usesCloudLocatorMetadata(): Boolean {
return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this)
}
internal fun BookItem.hasCloudReadingPosition(): Boolean {
return lastPageIndex != null ||
readerPosition != null ||
(progressPercentage ?: 0f) > 0f
}
internal fun BookItem.effectiveCloudReadingPositionModifiedTimestamp(): Long {
return readingPositionModifiedTimestamp.takeIf { it > 0L }
?: timestamp.takeIf { hasCloudReadingPosition() }
?: 0L
}
internal fun DesktopCloudBookMetadata.hasCloudReadingPosition(): Boolean {
return lastChapterIndex != null ||
lastPage != null ||
!lastPositionCfi.isNullOrBlank() ||
locatorBlockIndex != null ||
locatorCharOffset != null ||
(progressPercentage ?: 0f) > 0f
}
internal fun DesktopCloudBookMetadata.effectiveCloudReadingPositionModifiedTimestamp(): Long {
return readingPositionModifiedTimestamp.takeIf { it > 0L }
?: lastModifiedTimestamp.takeIf { hasCloudReadingPosition() }
?: 0L
}
internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(): Long {
return annotationModifiedTimestamp.takeIf { it > 0L }
?: 0L
}
internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(sidecarModifiedTimestamp: Long): Long {
return sidecarModifiedTimestamp.takeIf { it > 0L }
?: effectiveCloudAnnotationModifiedTimestamp()
}
internal fun CustomFontItem.toDesktopCloudFontMetadata(): DesktopCloudFontMetadata {
return DesktopCloudFontMetadata(
id = id,
@ -568,8 +1080,7 @@ internal fun CustomFontItem.toDesktopCloudFontMetadata(): DesktopCloudFontMetada
}
internal fun desktopCloudBookDriveFileName(bookId: String, type: FileType): String? {
val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null
return "$bookId.$extension"
return sharedCloudBookContentFileName(bookId, type)
}
private data class DesktopCloudShelfRecord(
@ -613,18 +1124,53 @@ private fun shouldDownloadRemoteBookContent(local: BookItem, remote: DesktopClou
?: localFile?.takeIf { it.isFile }?.lastModified()
?: 0L
return local.sourceFolder == null &&
!remote.isDeleted &&
remote.fileType() == local.type &&
remote.fileContentModifiedTimestamp > 0L &&
(localFile == null || !localFile.isFile || remote.fileContentModifiedTimestamp > localTimestamp)
shouldDownloadRemoteCloudBookContent(
localFileAvailable = localFile?.isFile == true,
localContentModifiedTimestamp = localTimestamp,
remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp,
remoteDeleted = remote.isDeleted
)
}
private fun shouldUploadLocalBookContent(local: BookItem, remote: DesktopCloudBookMetadata?): Boolean {
val localFile = local.path?.let(::File)?.takeIf { it.isFile } ?: return false
val localTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } ?: localFile.lastModified()
return local.sourceFolder == null &&
localTimestamp > 0L &&
localTimestamp > (remote?.fileContentModifiedTimestamp ?: 0L)
shouldUploadLocalCloudBookContent(
localFileAvailable = true,
localContentModifiedTimestamp = localTimestamp,
remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp
)
}
private fun shouldUploadLocalAnnotations(
local: BookItem,
remote: DesktopCloudBookMetadata?,
remoteAnnotationModifiedTimestamp: Long = remote?.effectiveCloudAnnotationModifiedTimestamp() ?: 0L,
localSidecarTimestamp: Long = DesktopCloudSidecarSync.localAnnotationTimestamp(local)
): Boolean {
return DesktopCloudSidecarSync.hasLocalAnnotationData(local) &&
(remote == null || !remote.hasAnnotations || localSidecarTimestamp > remoteAnnotationModifiedTimestamp)
}
private fun remoteAnnotationDriveFileTimestamp(
bookId: String,
driveFiles: Map<String, DesktopDriveFile>
): Long {
return driveFiles[desktopCloudAnnotationDriveFileName(bookId)]?.modifiedTimeMillis ?: 0L
}
internal fun desktopCloudAnnotationDriveFileName(bookId: String): String = "annotation_$bookId.json"
private fun BookItem.withDownloadedCloudContent(downloaded: BookItem?, replacePath: Boolean = true): BookItem {
if (downloaded == null) return this
return copy(
path = if (replacePath) downloaded.path ?: path else path,
fileSize = downloaded.fileSize.takeIf { it > 0L } ?: fileSize,
fileContentModifiedTimestamp = downloaded.fileContentModifiedTimestamp.takeIf { it > 0L }
?: fileContentModifiedTimestamp
)
}
private fun desktopShelfTimestamp(record: ShelfRecord, refs: List<BookShelfRef>): Long {
@ -634,17 +1180,7 @@ private fun desktopShelfTimestamp(record: ShelfRecord, refs: List<BookShelfRef>)
}
private fun ReaderLocator.cloudPositionCfi(): String? {
cfi?.let { return it }
val chapter = chapterIndex
val start = startOffset
val end = endOffset ?: start
return if (chapter != null && start != null && end != null) {
"desktop:$chapter:$start:$end"
} else if (chapter != null && pageIndex != null) {
"desktop:$chapter:$pageIndex"
} else {
null
}
return toStablePositionCfi()
}
private fun ReaderBookmark.toDesktopCloudEpubBookmarkOrNull(): EpubBookmark? {

View file

@ -0,0 +1,82 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.SharedFileCapabilities
internal const val DesktopCloudSyncLogTag = "EpistemeCloudSync"
internal const val DesktopCloudAnnotationSyncLogTag = "EpistemeCloudAnnotations"
internal fun logDesktopCloudSync(message: () -> String) {
logDesktopDiagnostic(DesktopCloudSyncLogTag, message)
}
internal fun logDesktopCloudAnnotations(message: () -> String) {
logDesktopDiagnostic(DesktopCloudAnnotationSyncLogTag, message)
}
internal fun BookItem.desktopCloudSyncSummary(prefix: String = "local"): String {
val position = readerPosition
val page = if (type.usesCloudLocatorForDiagnostics()) {
position?.pageIndex ?: lastPageIndex
} else {
lastPageIndex
}
return "$prefix{id=$id type=$type ts=$timestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " +
"contentTs=$fileContentModifiedTimestamp " +
"page=$page chapter=${position?.chapterIndex} " +
"block=${position?.blockIndex} char=${position?.charOffset} progress=$progressPercentage " +
"cfi=${position?.cfi.cloudSyncPreview()} sourceFolder=${sourceFolder != null} " +
"bookmarks=${readerBookmarks.size} highlights=${readerHighlights.size}}"
}
internal fun DesktopCloudBookMetadata.desktopCloudSyncSummary(prefix: String = "remote"): String {
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " +
"annTs=${effectiveCloudAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted " +
"recent=$isRecent hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
"highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
}
internal fun BookItem.hasSameCloudReaderPosition(other: BookItem): Boolean {
val thisPage = if (type.usesCloudLocatorForDiagnostics()) readerPosition?.pageIndex ?: lastPageIndex else lastPageIndex
val otherPage = if (other.type.usesCloudLocatorForDiagnostics()) {
other.readerPosition?.pageIndex ?: other.lastPageIndex
} else {
other.lastPageIndex
}
val thisProgress = progressPercentage
val otherProgress = other.progressPercentage
val progressMatches = when {
thisProgress == null && otherProgress == null -> true
thisProgress != null && otherProgress != null -> kotlin.math.abs(thisProgress - otherProgress) < 0.001f
else -> false
}
val locatorMatches = if (type.usesCloudLocatorForDiagnostics() || other.type.usesCloudLocatorForDiagnostics()) {
readerPosition == other.readerPosition
} else {
true
}
return thisPage == otherPage &&
locatorMatches &&
progressMatches
}
private fun com.aryan.reader.shared.FileType.usesCloudLocatorForDiagnostics(): Boolean {
return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this)
}
private fun String?.cloudSyncPreview(maxLength: Int = 80): String {
val value = this ?: return "null"
return if (value.length <= maxLength) value else value.take(maxLength) + "..."
}
private fun String?.cloudSyncAnnotationSummary(): String {
val value = this?.trim() ?: return "null"
return when {
value.isEmpty() -> "blank"
value == "[]" -> "empty"
else -> "present(${value.length})"
}
}

View file

@ -1,6 +1,7 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.opds.OpdsCatalog
import com.aryan.reader.shared.opds.OpdsStreamReference
import com.sun.jna.Library
@ -8,8 +9,9 @@ import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.ptr.PointerByReference
import org.apache.commons.compress.archivers.sevenz.SevenZFile
import java.awt.Font
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import java.awt.Color
import java.awt.Font
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
@ -24,7 +26,7 @@ import javax.imageio.ImageIO
import kotlin.math.roundToInt
internal object DesktopComicArchive {
private val comicTypes = setOf(FileType.CBZ, FileType.CBR, FileType.CB7)
private val comicTypes = SharedFileCapabilities.comicArchiveTypes
private val imageExtensions = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
fun canLoad(type: FileType): Boolean = type in comicTypes
@ -36,6 +38,7 @@ internal object DesktopComicArchive {
FileType.CBZ -> loadZip(file)
FileType.CBR -> loadRar(file)
FileType.CB7 -> loadSevenZ(file)
FileType.CBT -> loadTar(file)
else -> error("${type.name} is not a comic archive type.")
}
}
@ -162,6 +165,31 @@ internal object DesktopComicArchive {
}
}
private fun loadTar(file: File): DesktopComicDocument {
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
return try {
val extracted = mutableListOf<ExtractedComicPage>()
TarArchiveInputStream(file.inputStream().buffered()).use { archive ->
var entry = archive.nextEntry
while (entry != null) {
val name = entry.name.orEmpty()
if (!entry.isDirectory && name.isComicImageName()) {
val target = File(tempDir, "page_${extracted.size}.${name.imageExtension()}")
target.outputStream().use { output ->
archive.copyTo(output)
}
extracted += ExtractedComicPage(name = name, file = target)
}
entry = archive.nextEntry
}
}
documentFromExtracted(file, extracted, tempDir)
} catch (throwable: Throwable) {
runCatching { tempDir.deleteRecursively() }
throw throwable
}
}
private fun loadWithArchiveCommand(file: File): DesktopComicDocument {
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
return try {

View file

@ -1,16 +1,40 @@
package com.aryan.reader.desktop
internal const val DesktopDiagnosticsProperty = "episteme.desktop.diagnostics"
private const val DesktopDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
private const val DesktopDiagnosticsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS"
private const val DesktopDiagnosticsTagsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS_TAGS"
private val DesktopDiagnosticTags: Set<String> =
listOfNotNull(
System.getProperty(DesktopDiagnosticsTagsProperty),
System.getenv(DesktopDiagnosticsTagsEnv)
)
.joinToString(" ")
.split(',', ';', ' ', '\t', '\n')
.mapNotNull { rawTag ->
rawTag.trim()
.takeIf { it.isNotBlank() }
?.lowercase()
}
.toSet()
internal val DesktopDiagnosticsEnabled: Boolean =
desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty))
desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty)) ||
desktopDiagnosticsFlag(System.getenv(DesktopDiagnosticsEnv)) ||
DesktopDiagnosticTags.isNotEmpty()
internal fun desktopDiagnosticsFlag(rawValue: String?): Boolean {
return rawValue?.trim()?.equals("true", ignoreCase = true) == true
}
internal inline fun logDesktopDiagnostic(tag: String, message: () -> String) {
if (DesktopDiagnosticsEnabled) {
private fun isDesktopDiagnosticTagEnabled(tag: String): Boolean {
if (DesktopDiagnosticTags.isEmpty()) return true
return "*" in DesktopDiagnosticTags || tag.lowercase() in DesktopDiagnosticTags
}
internal fun logDesktopDiagnostic(tag: String, message: () -> String) {
if (DesktopDiagnosticsEnabled && isDesktopDiagnosticTagEnabled(tag)) {
println("$tag ${message()}")
}
}

View file

@ -1,6 +1,7 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.toStableReaderPositionCfi
import com.aryan.reader.shared.ui.SharedNativeReaderLinkClick
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
@ -46,7 +47,8 @@ internal data class DesktopEpubHandledLink(
internal enum class DesktopReaderSelectionAction {
DEFINE,
SPEAK,
SEARCH
SEARCH,
PALETTE
}
internal enum class DesktopReaderKeyNavigation {
@ -59,7 +61,10 @@ internal enum class DesktopReaderKeyNavigation {
EXIT_FULLSCREEN
}
internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): DesktopReaderKeyNavigation? {
internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(
fullscreen: Boolean,
rightToLeftPagination: Boolean = false
): DesktopReaderKeyNavigation? {
if (id != AwtKeyEvent.KEY_PRESSED) return null
if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) {
return DesktopReaderKeyNavigation.EXIT_FULLSCREEN
@ -71,9 +76,17 @@ internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean):
return DesktopReaderKeyNavigation.NEXT_SEARCH
}
return when (keyCode) {
AwtKeyEvent.VK_RIGHT,
AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) {
DesktopReaderKeyNavigation.PREVIOUS
} else {
DesktopReaderKeyNavigation.NEXT
}
AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) {
DesktopReaderKeyNavigation.NEXT
} else {
DesktopReaderKeyNavigation.PREVIOUS
}
AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT
AwtKeyEvent.VK_LEFT,
AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS
AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST
AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST
@ -83,7 +96,8 @@ internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean):
internal data class DesktopReaderSelectionActionPayload(
val action: DesktopReaderSelectionAction,
val text: String
val text: String,
val locator: ReaderLocator? = null
)
internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? {
@ -128,9 +142,27 @@ internal fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionP
"define" -> DesktopReaderSelectionAction.DEFINE
"speak" -> DesktopReaderSelectionAction.SPEAK
"web-search", "search" -> DesktopReaderSelectionAction.SEARCH
"palette" -> DesktopReaderSelectionAction.PALETTE
else -> return@runCatching null
}
DesktopReaderSelectionActionPayload(action, text)
val locator = obj["locator"]
?.takeUnless { it is JsonNull }
?.jsonObject
?.let { locatorObj ->
ReaderLocator(
chapterIndex = locatorObj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
chapterId = locatorObj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
href = locatorObj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
pageIndex = locatorObj["pageIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
startOffset = locatorObj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
endOffset = locatorObj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
blockIndex = locatorObj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
charOffset = locatorObj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
textQuote = locatorObj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
cfi = locatorObj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi()
)
}
DesktopReaderSelectionActionPayload(action, text, locator)
}.getOrNull()
parse(this)?.let { return it }
@ -181,11 +213,15 @@ internal fun String.readerPositionOrNull(): DesktopReaderPosition? {
?: return@runCatching null
val locator = ReaderLocator(
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
href = obj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
pageIndex = pageIndex,
startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
blockIndex = obj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
charOffset = obj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi()
)
DesktopReaderPosition(pageIndex, locator)
}.getOrNull()
@ -284,9 +320,7 @@ private fun String.readerHrefFromIntercept(): String? {
val trimmed = trim()
if (trimmed.isBlank()) return null
if (trimmed.equals("about:blank", ignoreCase = true)) return null
if (trimmed.startsWith("file:///kcefbrowser/", ignoreCase = true)) return null
if (trimmed.startsWith("file:/kcefbrowser/", ignoreCase = true)) return null
if (trimmed.startsWith("file://", ignoreCase = true)) return null
if (trimmed.startsWith("file:/", ignoreCase = true)) return null
if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}"
if (trimmed.startsWith("data:", ignoreCase = true)) return null
if (trimmed.startsWith("blob:", ignoreCase = true)) return null
@ -298,9 +332,13 @@ internal fun ReaderLocator.toReaderLocatorJson(): String {
append("{")
val values = buildList {
chapterIndex?.let { add("\"chapterIndex\":$it") }
chapterId?.let { add("\"chapterId\":${it.toJsonStringLiteral()}") }
href?.let { add("\"href\":${it.toJsonStringLiteral()}") }
pageIndex?.let { add("\"pageIndex\":$it") }
startOffset?.let { add("\"startOffset\":$it") }
endOffset?.let { add("\"endOffset\":$it") }
blockIndex?.let { add("\"blockIndex\":$it") }
charOffset?.let { add("\"charOffset\":$it") }
cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") }
textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") }
}

View file

@ -11,6 +11,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.reader.ReaderLayoutSignature
import com.aryan.reader.shared.reader.ReaderPage
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderViewportSpec
import com.aryan.reader.shared.reader.SharedEpubBook
@ -28,6 +30,44 @@ internal data class DesktopEpubPaginationDensity(
val fontScale: Float
)
internal fun desktopMeasuredPaginationReady(
request: DesktopEpubPaginationRequest?,
completedRequest: DesktopEpubPaginationRequest?,
currentPages: List<ReaderPage>,
measuredPages: List<ReaderPage>
): Boolean {
return request != null &&
completedRequest == request &&
measuredPages.isNotEmpty() &&
currentPages.samePageLayoutAs(measuredPages)
}
internal fun desktopPaginatedLayoutReadyForDisplay(
readingMode: ReaderReadingMode,
measuredPagesApplied: Boolean
): Boolean {
return readingMode != ReaderReadingMode.PAGINATED || measuredPagesApplied
}
internal fun desktopPagesWithMeasuredChapter(
currentPages: List<ReaderPage>,
chapterIndex: Int,
measuredChapterPages: List<ReaderPage>
): List<ReaderPage> {
if (currentPages.isEmpty() || measuredChapterPages.isEmpty()) return currentPages
val firstChapterPage = currentPages.indexOfFirst { it.chapterIndex == chapterIndex }
if (firstChapterPage < 0) return currentPages
val lastChapterPage = currentPages.indexOfLast { it.chapterIndex == chapterIndex }
val combined = currentPages.take(firstChapterPage) +
measuredChapterPages +
currentPages.drop(lastChapterPage + 1)
return combined.mapIndexed { index, page -> page.copy(pageIndex = index) }
}
internal fun List<ReaderPage>.firstPageIndexForChapter(chapterIndex: Int): Int? {
return indexOfFirst { it.chapterIndex == chapterIndex }.takeIf { it >= 0 }
}
internal fun SharedEpubBook.desktopPaginationContentSignature(): Int {
return chapters.fold(31 * id.hashCode() + css.hashCode()) { acc, chapter ->
31 * acc +

View file

@ -1,57 +1,85 @@
package com.aryan.reader.desktop
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.UserHighlight
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.ui.ReaderContentNavigationTarget
import com.multiplatform.webview.jsbridge.IJsMessageHandler
import com.multiplatform.webview.jsbridge.JsMessage
import com.multiplatform.webview.jsbridge.rememberWebViewJsBridge
import com.multiplatform.webview.request.RequestInterceptor
import com.multiplatform.webview.request.WebRequest
import com.multiplatform.webview.request.WebRequestInterceptResult
import com.multiplatform.webview.web.LoadingState
import com.multiplatform.webview.web.WebContent
import com.multiplatform.webview.web.WebView
import com.multiplatform.webview.web.WebViewNavigator
import com.multiplatform.webview.web.WebViewState
import com.multiplatform.webview.web.rememberWebViewNavigator
import kotlinx.coroutines.launch
import java.awt.AWTEvent
import java.awt.Toolkit
import java.awt.event.AWTEventListener
import java.awt.event.MouseEvent
@Composable
internal fun DesktopEpubWebView(
html: String,
appearanceScript: String,
highlightPaletteScript: String,
navigationTarget: ReaderContentNavigationTarget,
highlights: List<UserHighlight>,
onHighlightCreated: (UserHighlight) -> Unit,
onHighlightSelected: (String) -> Unit,
isFullscreen: Boolean,
onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit,
onSelectionAction: (DesktopReaderSelectionAction, String) -> Unit,
onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit,
onLinkClicked: (DesktopEpubLinkClick) -> Unit,
onVisiblePageChanged: (Int, ReaderLocator?) -> Unit,
onPointerActivity: () -> Unit = {},
networkAccessEnabled: Boolean,
backgroundColor: Color,
modifier: Modifier = Modifier
) {
val backend = desktopEpubWebViewBackend()
LaunchedEffect(html, networkAccessEnabled, highlights.size, navigationTarget.readingMode, backend) {
logDesktopWebView2(
"backend_selected backend=${backend.logName} htmlChars=${html.length} htmlHash=${html.hashCode()} " +
"network=$networkAccessEnabled highlights=${highlights.size} navMode=${navigationTarget.readingMode}"
)
logDesktopReaderOpenTrace {
"event=desktop_webview_selected backend=${backend.logName} htmlChars=${html.length} " +
"htmlHash=${html.hashCode()} network=$networkAccessEnabled highlights=${highlights.size} " +
"navMode=${navigationTarget.readingMode}"
}
}
DesktopNativeSwtEpubWebView(
html = html,
appearanceScript = appearanceScript,
highlightPaletteScript = highlightPaletteScript,
navigationTarget = navigationTarget,
highlights = highlights,
onHighlightCreated = onHighlightCreated,
onHighlightSelected = onHighlightSelected,
isFullscreen = isFullscreen,
onKeyboardNavigation = onKeyboardNavigation,
onSelectionAction = onSelectionAction,
onLinkClicked = onLinkClicked,
onVisiblePageChanged = onVisiblePageChanged,
onPointerActivity = onPointerActivity,
networkAccessEnabled = networkAccessEnabled,
backgroundColor = backgroundColor,
modifier = modifier
)
}
internal data class DesktopEpubBridgeHandler(
val methodName: String,
val onMessage: (String) -> Unit
)
@Composable
internal fun rememberDesktopEpubBridgeHandlers(
onHighlightCreated: (UserHighlight) -> Unit,
onHighlightSelected: (String) -> Unit,
onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit,
onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit,
onLinkClicked: (DesktopEpubLinkClick) -> Unit,
onVisiblePageChanged: (Int, ReaderLocator?) -> Unit,
onPointerActivity: () -> Unit
): List<DesktopEpubBridgeHandler> {
val latestOnHighlightCreated by rememberUpdatedState(onHighlightCreated)
val latestOnHighlightSelected by rememberUpdatedState(onHighlightSelected)
val latestOnKeyboardNavigation by rememberUpdatedState(onKeyboardNavigation)
@ -60,81 +88,98 @@ internal fun DesktopEpubWebView(
val latestOnVisiblePageChanged by rememberUpdatedState(onVisiblePageChanged)
val latestOnPointerActivity by rememberUpdatedState(onPointerActivity)
val scope = rememberCoroutineScope()
val linkRequestInterceptor = remember(scope, networkAccessEnabled) {
object : RequestInterceptor {
override fun onInterceptUrlRequest(
request: WebRequest,
navigator: WebViewNavigator
): WebRequestInterceptResult {
if (!networkAccessEnabled && request.url.isRemoteNetworkUrl()) {
logEpubLink("request_blocked_offline url=\"${request.url.logPreview()}\"")
return WebRequestInterceptResult.Reject
}
if (!request.isForMainFrame) return WebRequestInterceptResult.Allow
val link = request.url.readerLinkClickFromIntercept() ?: return WebRequestInterceptResult.Allow
logEpubLink(
"request_intercept method=${request.method} redirect=${request.isRedirect} " +
"url=\"${request.url.logPreview()}\" href=\"${link.href.logPreview()}\""
)
scope.launch {
latestOnLinkClicked(link.copy(source = "request"))
}
return WebRequestInterceptResult.Reject
}
}
}
val navigator = rememberWebViewNavigator(requestInterceptor = linkRequestInterceptor)
val bridge = rememberWebViewJsBridge()
DisposableEffect(bridge) {
val handlers = listOf(
desktopEpubBridgeHandler("readerHighlightCreated") { message ->
val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(message.params)
return remember(scope) {
listOf(
DesktopEpubBridgeHandler("readerHighlightCreated") { params ->
logEpubHighlightFlow("bridge_received method=readerHighlightCreated params=\"${params.logPreview(900)}\"")
val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(params)
if (highlight == null) {
logEpubSelectionDebug("highlight_parse_failed params=${message.params.logPreview(900)}")
logEpubHighlightFlow("bridge_parse_failed method=readerHighlightCreated")
logEpubSelectionDebug("highlight_parse_failed params=${params.logPreview(900)}")
} else {
logEpubHighlightFlow(
"bridge_parse_success id=${highlight.id} color=${highlight.color.id} " +
"chapter=${highlight.chapterIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " +
"page=${highlight.locator.pageIndex} textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\""
)
logDesktopHighlightMap(
"bridge_highlight_created id=${highlight.id} color=${highlight.color.id} " +
"chapter=${highlight.chapterIndex} locatorChapter=${highlight.locator.chapterIndex} " +
"page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " +
"block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " +
"chapterId=${highlight.locator.chapterId.orEmpty().logPreview()} href=${highlight.locator.href.orEmpty().logPreview()} " +
"textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\""
)
scope.launch { latestOnHighlightCreated(highlight) }
}
},
desktopEpubBridgeHandler("readerHighlightClicked") { message ->
message.params.readerHighlightClickOrNull()?.let { highlightClick ->
DesktopEpubBridgeHandler("readerHighlightClicked") { params ->
params.readerHighlightClickOrNull()?.let { highlightClick ->
scope.launch { latestOnHighlightSelected(highlightClick.highlightId) }
}
},
desktopEpubBridgeHandler("readerPositionChanged") { message ->
message.params.readerPositionOrNull()?.let { position ->
DesktopEpubBridgeHandler("readerPositionChanged") { params ->
params.readerPositionOrNull()?.let { position ->
logDesktopPositionTrace(
"event=bridge_position_changed page=${position.pageIndex} " +
"locator=${position.locator.desktopPositionTraceSummary()}"
)
logDesktopHighlightMap(
"bridge_position_changed page=${position.pageIndex} chapter=${position.locator?.chapterIndex} " +
"offsets=${position.locator?.startOffset}..${position.locator?.endOffset} " +
"block=${position.locator?.blockIndex} char=${position.locator?.charOffset} " +
"chapterId=${position.locator?.chapterId.orEmpty().logPreview()} href=${position.locator?.href.orEmpty().logPreview()} " +
"text=\"${position.locator?.textQuote.orEmpty().logPreview(120)}\" " +
"cfi=\"${position.locator?.cfi.orEmpty().logPreview(160)}\""
)
logDesktopTtsStartTrace {
"event=bridge_position_changed page=${position.pageIndex} " +
"locator=${position.locator.desktopPositionTraceSummary(160)}"
}
scope.launch { latestOnVisiblePageChanged(position.pageIndex, position.locator) }
}
},
desktopEpubBridgeHandler("readerSelectionAction") { message ->
val selectionAction = message.params.readerSelectionActionOrNull()
DesktopEpubBridgeHandler("readerDesktopPositionTraceLog") { params ->
logDesktopPositionTrace(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
},
DesktopEpubBridgeHandler("readerTtsStartTraceLog") { params ->
logDesktopTtsStartTrace { params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900) }
},
DesktopEpubBridgeHandler("readerSelectionAction") { params ->
val selectionAction = params.readerSelectionActionOrNull()
if (selectionAction != null) {
scope.launch { latestOnSelectionAction(selectionAction.action, selectionAction.text) }
scope.launch { latestOnSelectionAction(selectionAction) }
}
},
desktopEpubBridgeHandler("readerKeyNavigation") { message ->
message.params.readerKeyNavigationOrNull()?.let { action ->
DesktopEpubBridgeHandler("readerKeyNavigation") { params ->
params.readerKeyNavigationOrNull()?.let { action ->
scope.launch { latestOnKeyboardNavigation(action) }
}
},
desktopEpubBridgeHandler("readerPointerActivity") { _ ->
DesktopEpubBridgeHandler("readerPointerActivity") {
scope.launch { latestOnPointerActivity() }
},
desktopEpubBridgeHandler("readerTtsHighlightLog") { message ->
logDesktopTts("epub_highlight_js ${message.params.logPreview(500)}")
DesktopEpubBridgeHandler("readerTtsHighlightLog") { params ->
logDesktopTts("epub_highlight_js ${params.logPreview(500)}")
},
desktopEpubBridgeHandler("readerSelectionDebugLog") { message ->
logEpubSelectionDebug(message.params.readerSelectionDebugMessageOrNull() ?: message.params.logPreview(900))
DesktopEpubBridgeHandler("readerSelectionDebugLog") { params ->
logEpubSelectionDebug(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
},
desktopEpubBridgeHandler("readerPaginationLayoutLog") { message ->
logEpubPagination(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900))
DesktopEpubBridgeHandler("readerHighlightFlowLog") { params ->
logEpubHighlightFlow(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
},
desktopEpubBridgeHandler("readerGapLayoutLog") { message ->
logReaderGap(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900))
DesktopEpubBridgeHandler("readerDesktopHighlightMapLog") { params ->
logDesktopHighlightMap(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
},
desktopEpubBridgeHandler("readerLinkClicked") { message ->
logEpubLink("bridge_message params=\"${message.params.logPreview()}\"")
val link = message.params.readerLinkClickOrNull()
DesktopEpubBridgeHandler("readerPaginationLayoutLog") { params ->
logEpubPagination(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900))
},
DesktopEpubBridgeHandler("readerGapLayoutLog") { params ->
logReaderGap(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900))
},
DesktopEpubBridgeHandler("readerLinkClicked") { params ->
logEpubLink("bridge_message params=\"${params.logPreview()}\"")
val link = params.readerLinkClickOrNull()
if (link == null) {
logEpubLink("bridge_message_ignored reason=parse_failed")
} else {
@ -146,214 +191,70 @@ internal fun DesktopEpubWebView(
}
}
)
handlers.forEach { bridge.register(it) }
onDispose {
handlers.forEach { bridge.unregister(it) }
}
}
val state = remember {
WebViewState(
WebContent.Data(
data = html,
baseUrl = null,
encoding = "utf-8",
mimeType = "text/html",
historyUrl = null
)
)
}
LaunchedEffect(html) {
navigator.loadHtml(
html = html,
baseUrl = null,
mimeType = "text/html",
encoding = "utf-8",
historyUrl = null
)
}
DisposableEffect(Unit) {
var lastActivityAt = 0L
var lastMouseX: Int? = null
var lastMouseY: Int? = null
val listener = AWTEventListener { event ->
val mouseEvent = event as? MouseEvent ?: return@AWTEventListener
if (
mouseEvent.id != MouseEvent.MOUSE_MOVED &&
mouseEvent.id != MouseEvent.MOUSE_DRAGGED &&
mouseEvent.id != MouseEvent.MOUSE_PRESSED &&
mouseEvent.id != MouseEvent.MOUSE_WHEEL
) {
return@AWTEventListener
}
if (mouseEvent.id == MouseEvent.MOUSE_MOVED || mouseEvent.id == MouseEvent.MOUSE_DRAGGED) {
val screenX = mouseEvent.xOnScreen
val screenY = mouseEvent.yOnScreen
if (lastMouseX == screenX && lastMouseY == screenY) return@AWTEventListener
lastMouseX = screenX
lastMouseY = screenY
} else {
lastMouseX = mouseEvent.xOnScreen
lastMouseY = mouseEvent.yOnScreen
}
val now = mouseEvent.`when`.takeIf { it > 0L } ?: System.currentTimeMillis()
if (now - lastActivityAt < 120L) return@AWTEventListener
lastActivityAt = now
scope.launch { latestOnPointerActivity() }
}
val eventMask = AWTEvent.MOUSE_MOTION_EVENT_MASK or
AWTEvent.MOUSE_EVENT_MASK or
AWTEvent.MOUSE_WHEEL_EVENT_MASK
Toolkit.getDefaultToolkit().addAWTEventListener(listener, eventMask)
onDispose {
Toolkit.getDefaultToolkit().removeAWTEventListener(listener)
}
}
Box(modifier = modifier) {
WebView(
state = state,
modifier = Modifier.fillMaxSize(),
captureBackPresses = false,
navigator = navigator,
webViewJsBridge = bridge
)
LaunchedEffect(state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
navigator.evaluateJavaScript(DesktopEpubKeyNavigationScript)
}
LaunchedEffect(isFullscreen, state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
navigator.evaluateJavaScript("window.readerDesktopFullscreen = ${if (isFullscreen) "true" else "false"};")
}
LaunchedEffect(html, state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
navigator.evaluateJavaScript("window.readerPaginationLayoutLog && window.readerPaginationLayoutLog('desktop_finished');")
}
LaunchedEffect(appearanceScript, state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
navigator.evaluateJavaScript(appearanceScript)
}
LaunchedEffect(
navigationTarget.autoScroll,
navigationTarget.readingMode,
state.loadingState
) {
if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect
if (!state.loadingState.isFinished()) return@LaunchedEffect
val autoScroll = navigationTarget.autoScroll.sanitized()
val command = if (autoScroll.enabled) {
"window.readerAutoScroll && window.readerAutoScroll.start(${autoScroll.speed});"
} else {
"window.readerAutoScroll && window.readerAutoScroll.stop();"
}
navigator.evaluateJavaScript(command)
}
LaunchedEffect(
navigationTarget.requestId,
navigationTarget.readingMode,
state.loadingState
) {
if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect
if (!state.loadingState.isFinished()) return@LaunchedEffect
val locator = navigationTarget.locator ?: return@LaunchedEffect
navigator.evaluateJavaScript("window.readerScrollToLocator && window.readerScrollToLocator(${locator.toReaderLocatorJson()});")
}
LaunchedEffect(
navigationTarget.ttsRequestId,
navigationTarget.ttsLocator,
navigationTarget.readingMode,
state.loadingState
) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
val locator = navigationTarget.ttsLocator
val command = if (locator == null) {
logDesktopTts(
"epub_highlight_command clear mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId}"
)
"window.readerSetTtsLocator && window.readerSetTtsLocator(null, false);"
} else {
val follow = navigationTarget.readingMode == ReaderReadingMode.VERTICAL
logDesktopTts(
"epub_highlight_command set mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId} " +
"follow=$follow chapter=${locator.chapterIndex} page=${locator.pageIndex} " +
"offsets=${locator.startOffset}..${locator.endOffset} cfi=\"${locator.cfi.orEmpty().logPreview()}\" " +
"text=\"${locator.textQuote.orEmpty().logPreview()}\""
)
"window.readerSetTtsLocator && window.readerSetTtsLocator(${locator.toReaderLocatorJson()}, $follow);"
}
navigator.evaluateJavaScript(command)
}
LaunchedEffect(highlights, state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
val highlightsJson = EpubAnnotationSerializer.highlightsToJson(highlights)
navigator.evaluateJavaScript("window.readerApplyHighlights && window.readerApplyHighlights($highlightsJson);")
}
val loadingState = state.loadingState
if (loadingState is LoadingState.Loading) {
LinearProgressIndicator(
progress = { loadingState.progress },
modifier = Modifier.fillMaxWidth()
)
}
}
}
private fun desktopEpubBridgeHandler(
methodName: String,
onMessage: (JsMessage) -> Unit
): IJsMessageHandler {
return object : IJsMessageHandler {
override fun methodName(): String = methodName
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
onMessage(message)
}
}
}
private fun LoadingState.isFinished(): Boolean = this is LoadingState.Finished
private val DesktopEpubKeyNavigationScript = """
internal val DesktopEpubKeyNavigationScript = """
(function () {
if (!window.readerDesktopPointerActivityInstalled) {
window.readerDesktopPointerActivityInstalled = true;
var lastPointerActivityAt = 0;
var lastPointerX = null;
var lastPointerY = null;
function notifyPointerActivity(event, requireMovement) {
if (requireMovement && event) {
var x = Math.round(event.screenX || event.clientX || 0);
var y = Math.round(event.screenY || event.clientY || 0);
if (lastPointerX === x && lastPointerY === y) return;
lastPointerX = x;
lastPointerY = y;
}
var now = Date.now();
if (now - lastPointerActivityAt < 120) return;
lastPointerActivityAt = now;
if (!window.readerDesktopChromeTapInstalled) {
window.readerDesktopChromeTapInstalled = true;
var chromeTapStart = null;
var lastChromeTapNotifiedAt = 0;
function notifyChromeTap() {
if (!window.kmpJsBridge || !window.kmpJsBridge.callNative) return;
window.kmpJsBridge.callNative('readerPointerActivity', '{}');
lastChromeTapNotifiedAt = Date.now();
}
document.addEventListener('mousemove', function (event) { notifyPointerActivity(event, true); }, true);
document.addEventListener('pointermove', function (event) { notifyPointerActivity(event, true); }, true);
document.addEventListener('pointerdown', function (event) { notifyPointerActivity(event, false); }, true);
document.addEventListener('wheel', function (event) { notifyPointerActivity(event, false); }, true);
function chromeTapIgnored(target) {
if (!target || !target.closest) return false;
return !!target.closest(
'a[href], button, input, textarea, select, [contenteditable="true"], #reader-selection-menu, .reader-selection-handle'
);
}
function hasActiveReaderSelection() {
var selection = window.getSelection && window.getSelection();
return !!selection && selection.toString().trim().length > 0;
}
function beginChromeTap(event) {
if (event.button !== undefined && event.button !== 0) return;
if (chromeTapIgnored(event.target)) {
chromeTapStart = null;
return;
}
chromeTapStart = {
pointerId: event.pointerId,
x: event.clientX || 0,
y: event.clientY || 0,
at: Date.now()
};
}
function finishChromeTap(event) {
if (!chromeTapStart) return;
if (event.pointerId !== undefined && chromeTapStart.pointerId !== undefined && event.pointerId !== chromeTapStart.pointerId) return;
var dx = (event.clientX || 0) - chromeTapStart.x;
var dy = (event.clientY || 0) - chromeTapStart.y;
var elapsed = Date.now() - chromeTapStart.at;
chromeTapStart = null;
if ((dx * dx + dy * dy) > 64 || elapsed > 650) return;
if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return;
notifyChromeTap();
}
function maybeNotifyChromeTapFromClick(event) {
if (Date.now() - lastChromeTapNotifiedAt < 250) return;
if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return;
notifyChromeTap();
}
document.addEventListener('pointerdown', beginChromeTap, true);
document.addEventListener('pointerup', finishChromeTap, true);
document.addEventListener('pointercancel', function () { chromeTapStart = null; }, true);
document.addEventListener('click', function (event) {
if (window.PointerEvent) {
maybeNotifyChromeTapFromClick(event);
return;
}
beginChromeTap(event);
finishChromeTap(event);
}, true);
}
if (window.readerDesktopKeyNavigationInstalled) return;
window.readerDesktopKeyNavigationInstalled = true;

View file

@ -0,0 +1,13 @@
package com.aryan.reader.desktop
internal data class DesktopFeatureNoticePlacement(
val readerWindowId: String? = null
) {
fun rendersInMainWindow(): Boolean = readerWindowId == null
fun rendersInReaderWindow(windowId: String): Boolean = readerWindowId == windowId
}
internal fun desktopFeatureNoticePlacement(readerWindowId: String?): DesktopFeatureNoticePlacement {
return DesktopFeatureNoticePlacement(readerWindowId = readerWindowId?.takeIf { it.isNotBlank() })
}

View file

@ -68,7 +68,7 @@ internal class DesktopFirebaseAuthRepository(
googleAccessTokenExpiresAtEpochMillis = googleTokens.expiresAtEpochMillis
)
session = nextSession
store.save(nextSession)
persistSession(nextSession)
return nextSession
}
@ -86,24 +86,30 @@ internal class DesktopFirebaseAuthRepository(
val current = session ?: store.load()?.also { session = it } ?: return null
if (current.isGoogleAccessTokenFresh) return current.googleAccessToken
if (current.googleRefreshToken.isBlank()) return null
return runCatching {
val refreshed = runCatching {
refreshGoogleAccessToken(current)
}.onSuccess { refreshed ->
session = refreshed
store.save(refreshed)
}.getOrNull()?.googleAccessToken
}.getOrNull() ?: return null
session = refreshed
persistSession(refreshed)
return refreshed.googleAccessToken
}
private suspend fun refreshSessionIfNeeded(current: DesktopAuthSession): DesktopAuthSession? {
if (current.isFresh) return current
return runCatching {
val refreshed = runCatching {
refreshFirebaseSession(current)
}.onSuccess { refreshed ->
session = refreshed
store.save(refreshed)
}.onFailure {
signOut()
}.getOrNull()
}.getOrNull() ?: return null
session = refreshed
persistSession(refreshed)
return refreshed
}
private suspend fun persistSession(session: DesktopAuthSession) {
withContext(Dispatchers.IO) {
store.save(session)
}
}
private suspend fun requestGoogleOAuthCode(openUrl: (String) -> Unit): DesktopOAuthCode = withContext(Dispatchers.IO) {
@ -364,24 +370,19 @@ internal class DesktopAuthStore(
}
fun save(session: DesktopAuthSession) {
val protectedRefreshToken = protectRequired(RefreshTokenKey, session.refreshToken)
val protectedGoogleRefreshToken = session.googleRefreshToken
.takeIf { it.isNotBlank() }
?.let { protectRequired(GoogleRefreshTokenKey, it) }
val properties = Properties().apply {
setProperty("uid", session.user.uid)
setProperty("displayName", session.user.displayName.orEmpty())
setProperty("photoUrl", session.user.photoUrl.orEmpty())
setProperty("email", session.user.email.orEmpty())
runCatching { secretCodec.protect(RefreshTokenKey, session.refreshToken) }
.getOrNull()
?.takeIf { it.isNotBlank() }
?.let { setProperty(RefreshTokenKey, it) }
runCatching { secretCodec.protect(GoogleRefreshTokenKey, session.googleRefreshToken) }
.getOrNull()
?.takeIf { it.isNotBlank() }
?.let { setProperty(GoogleRefreshTokenKey, it) }
}
settingsFile.parentFile?.mkdirs()
settingsFile.outputStream().use { output ->
properties.store(output, "Episteme desktop account")
setProperty(RefreshTokenKey, protectedRefreshToken)
protectedGoogleRefreshToken?.let { setProperty(GoogleRefreshTokenKey, it) }
}
settingsFile.storePropertiesAtomically(properties, "Episteme desktop account")
}
fun clear() {
@ -394,6 +395,17 @@ internal class DesktopAuthStore(
const val RefreshTokenKey = "firebaseRefreshTokenProtected"
const val GoogleRefreshTokenKey = "googleRefreshTokenProtected"
}
private fun protectRequired(keyName: String, value: String): String {
if (value.isBlank()) {
throw IllegalArgumentException("Cannot save a desktop account without a refresh token.")
}
val protectedValue = secretCodec.protect(keyName, value)
if (protectedValue.isBlank()) {
throw IllegalStateException("Desktop secure key storage returned an empty value for $keyName.")
}
return protectedValue
}
}
private val DesktopAuthJson = Json { ignoreUnknownKeys = true }

View file

@ -59,6 +59,16 @@ object DesktopFolderMetadataExtractor {
return enrichBooks(books) { book -> book.sourceFolder == sourceFolder }
}
fun enrichFolderBooks(
books: List<BookItem>,
sourceFolders: Set<String>
): DesktopFolderMetadataExtractionResult {
if (sourceFolders.isEmpty()) {
return DesktopFolderMetadataExtractionResult(books)
}
return enrichBooks(books) { book -> book.sourceFolder in sourceFolders }
}
fun enrichImportedBooks(
books: List<BookItem>,
importedBookIds: Set<String>
@ -465,7 +475,7 @@ object DesktopFolderMetadataExtractor {
return when (type) {
FileType.PDF -> Color(156, 65, 70)
FileType.EPUB -> Color(0, 108, 76)
FileType.CBZ, FileType.CBR, FileType.CB7 -> Color(112, 93, 73)
FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT -> Color(112, 93, 73)
FileType.MD -> Color(83, 101, 120)
FileType.HTML -> Color(122, 87, 42)
FileType.TXT -> Color(74, 92, 112)

View file

@ -0,0 +1,16 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.SharedReaderScreenState
internal fun desktopFolderSyncCompletedState(
state: SharedReaderScreenState,
message: String,
failedFolderCount: Int,
showBanner: Boolean
): SharedReaderScreenState {
return if (showBanner) {
state.withBanner(message, isError = failedFolderCount > 0)
} else {
state
}
}

View file

@ -33,6 +33,7 @@ import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.WebSocket
import java.net.http.WebSocketHandshakeException
import java.nio.ByteBuffer
import java.util.Base64
import java.util.concurrent.CompletableFuture
@ -56,7 +57,7 @@ class DesktopGeminiCloudTtsAdapter(
private val networkAccess: () -> Boolean = { true },
private val workerUrlProvider: () -> String = { "" },
private val authTokenProvider: suspend () -> String? = { null },
private val useWorkerProvider: () -> Boolean = { false },
private val useWorkerProvider: () -> Boolean = { true },
private val onWorkerUsageCompleted: suspend () -> Unit = {},
httpClient: HttpClient? = null,
private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot())
@ -75,14 +76,15 @@ class DesktopGeminiCloudTtsAdapter(
@Volatile
private var activePlayer: DesktopStreamingPcmPlayer? = null
val isPlaybackActive: Boolean
get() = activePlayer != null || activeWebSocket != null || activeLine != null
override val isAvailable: Boolean
get() {
val settings = settingsProvider().sanitized()
return networkAccess() && if (useWorkerProvider()) {
settings.serverBackedCloudTts && workerUrlProvider().isNotBlank()
} else {
settings.isByokCloudTtsAvailable
}
return networkAccess() &&
(settings.isByokCloudTtsAvailable ||
(useWorkerProvider() && settings.serverBackedCloudTts && workerUrlProvider().isNotBlank()))
}
override suspend fun speak(text: String) {
@ -125,6 +127,12 @@ class DesktopGeminiCloudTtsAdapter(
)
}
.filter { it.text.isNotBlank() }
logDesktopTtsStartTrace {
"event=adapter_speak_chunks book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " +
"inputChunks=${chunks.size} sequenceChunks=${sequenceChunks.size} " +
"inputFirst=${chunks.firstOrNull().desktopTtsStartTraceSummary(160)} " +
"sequenceFirstText=\"${sequenceChunks.firstOrNull()?.text.orEmpty().desktopTtsPreview(180)}\""
}
logDesktopTts(
"chunk_sequence_speak_start book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " +
"chunks=${sequenceChunks.size} totalTextChars=${sequenceChunks.sumOf { it.text.length }}"
@ -182,28 +190,27 @@ class DesktopGeminiCloudTtsAdapter(
onChunkStart: suspend (Int) -> Unit
) = withContext(Dispatchers.IO) {
val settings = settingsProvider().sanitized()
val useWorker = useWorkerProvider()
val authToken = if (useWorker) authTokenProvider() else null
val useWorker = useWorkerProvider() && !settings.isByokCloudTtsAvailable
val totalTextChars = chunks.sumOf { it.text.length }
logDesktopTts(
"stream_start book=\"${bookTitle.desktopTtsPreview()}\" chunks=${chunks.size} totalTextChars=$totalTextChars keyPresent=${settings.geminiKey.isNotBlank()} " +
"ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\" " +
"available=${settings.isCloudTtsAvailable} worker=$useWorker"
"available=${settings.isCloudTtsAvailable} serverBacked=${settings.serverBackedCloudTts} worker=$useWorker"
)
if (!networkAccess()) {
logDesktopTts("stream_blocked reason=network_disabled")
throw IllegalStateException("Cloud TTS is unavailable in this desktop build.")
}
if (!settings.isCloudTtsAvailable) {
logDesktopTts("stream_blocked reason=not_available")
throw IllegalStateException(
if (useWorker) {
"Cloud TTS needs a signed-in account with credits."
} else {
"Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected."
}
)
if (useWorker) {
if (!settings.serverBackedCloudTts || workerUrlProvider().isBlank()) {
logDesktopTts("stream_blocked reason=server_backed_not_available")
throw IllegalStateException("Cloud TTS needs a signed-in account with credits.")
}
} else if (!settings.isByokCloudTtsAvailable) {
logDesktopTts("stream_blocked reason=byok_not_available")
throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.")
}
val authToken = if (useWorker) authTokenProvider() else null
if (useWorker && authToken.isNullOrBlank()) {
logDesktopTts("stream_blocked reason=missing_auth_token")
throw IllegalStateException("Sign in with Google to use cloud TTS.")
@ -220,6 +227,7 @@ class DesktopGeminiCloudTtsAdapter(
val messageBuffer = StringBuilder()
var webSocket: WebSocket? = null
var activeTempCacheFile: File? = null
var workerGeneratedAudio = false
fun handleMessage(message: String) {
handleGeminiTtsMessage(
@ -329,7 +337,7 @@ class DesktopGeminiCloudTtsAdapter(
.get(15, TimeUnit.SECONDS)
}.getOrElse { error ->
logDesktopTts("ws_connect_failed error=\"${error.desktopTtsSummary()}\"")
throw error
throw IllegalStateException(desktopTtsConnectionMessage(error), error)
}
activeWebSocket = connectedWebSocket
webSocket = connectedWebSocket
@ -361,6 +369,10 @@ class DesktopGeminiCloudTtsAdapter(
currentTurnAudioBytesReceived.set(0)
currentTurnComplete.set(turnComplete)
logDesktopTts("sequence_turn_start index=${index + 1}/${chunks.size} textChars=${text.length}")
logDesktopTtsStartTrace {
"event=adapter_turn_start index=${index + 1}/${chunks.size} chapter=\"${chunk.chapterTitle.orEmpty().desktopTtsPreview()}\" " +
"textChars=${text.length} text=\"${text.desktopTtsPreview(220)}\""
}
withContext(callbackContext) {
onChunkStart(index)
}
@ -420,6 +432,10 @@ class DesktopGeminiCloudTtsAdapter(
logDesktopTts("stream_failed reason=empty_turn_audio index=${index + 1}/${chunks.size}")
throw IllegalStateException("Cloud TTS returned no audio for a text chunk.")
}
if (useWorker) {
workerGeneratedAudio = true
onWorkerUsageCompleted()
}
activeCacheOutput.getAndSet(null)?.close()
runCatching {
patchReaderTtsWavHeader(tempCacheFile, turnAudioBytes.toInt())
@ -454,8 +470,15 @@ class DesktopGeminiCloudTtsAdapter(
activeWebSocket = null
activePlayer = null
logDesktopTts("stream_complete chunks=${chunks.size} audioBytes=${audioBytesReceived.get()}")
if (useWorker) onWorkerUsageCompleted()
if (useWorker && workerGeneratedAudio) onWorkerUsageCompleted()
} catch (error: Throwable) {
if (useWorker && desktopTtsShouldRefreshAccountAfterError(error)) {
try {
onWorkerUsageCompleted()
} catch (_: Throwable) {
// Keep the original TTS failure as the visible error.
}
}
currentTurnComplete.set(null)
activeCacheOutput.getAndSet(null)?.let { output -> runCatching { output.close() } }
activeTempCacheFile?.delete()
@ -629,6 +652,39 @@ private fun ByteArray.upsample16BitMonoLe2x(): ByteArray {
return output
}
private fun desktopTtsConnectionMessage(error: Throwable): String {
val causes = generateSequence(error) { it.cause }.toList()
val handshake = causes.filterIsInstance<WebSocketHandshakeException>().firstOrNull()
return when (handshake?.response?.statusCode()) {
401 -> "Sign in again to use cloud TTS."
402 -> "Out of credits. Pro and credits can only be purchased from the Android app."
403 -> "Cloud TTS is unavailable for this account."
405 -> "Cloud TTS is not configured for this desktop build."
426 -> "Cloud TTS is not configured for this desktop build."
502 -> "Cloud TTS service is temporarily unavailable."
else -> {
val details = causes
.joinToString(" ") { it.message.orEmpty() }
.trim()
when {
details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) ->
"Out of credits. Pro and credits can only be purchased from the Android app."
details.contains("401") || details.contains("Unauthorized", ignoreCase = true) ->
"Sign in again to use cloud TTS."
else -> "Cloud TTS failed to connect."
}
}
}
}
private fun desktopTtsShouldRefreshAccountAfterError(error: Throwable): Boolean {
val details = generateSequence(error) { it.cause }
.joinToString(" ") { it.message.orEmpty() }
return details.contains("Out of credits", ignoreCase = true) ||
details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) ||
details.contains("402", ignoreCase = true)
}
private class DesktopStreamingPcmPlayer(
private val onLineChanged: (SourceDataLine?) -> Unit
) {
@ -742,7 +798,6 @@ private class DesktopStreamingPcmPlayer(
openLine(24_000f)
}.onFailure { secondError ->
logDesktopTts("play_fallback_failed sampleRate=24000 error=\"${secondError.desktopTtsSummary()}\"")
secondError.printStackTrace()
}.getOrElse {
throw firstError
}

View file

@ -3,18 +3,38 @@ package com.aryan.reader.desktop
import com.aryan.reader.shared.SharedLibrarySnapshot
import com.aryan.reader.shared.SharedLibrarySnapshotJson
import java.io.File
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
class DesktopLibraryDatabase(
private val databaseFile: File = defaultDatabaseFile()
) {
fun load(): SharedLibrarySnapshot {
if (!databaseFile.exists()) return SharedLibrarySnapshot()
return SharedLibrarySnapshotJson.decodeOrEmpty(databaseFile.readText())
return loadFile(databaseFile)
?: loadFile(backupFile())
?: SharedLibrarySnapshot()
}
fun save(snapshot: SharedLibrarySnapshot) {
databaseFile.parentFile?.mkdirs()
databaseFile.writeText(SharedLibrarySnapshotJson.encode(snapshot))
val encoded = SharedLibrarySnapshotJson.encode(snapshot)
databaseFile.writeTextAtomically(encoded)
runCatching {
backupFile().writeTextAtomically(encoded)
}
}
private fun loadFile(file: File): SharedLibrarySnapshot? {
if (!file.isFile) return null
val raw = runCatching { file.readText() }.getOrNull() ?: return null
val isJsonObject = runCatching {
libraryDatabaseJson.parseToJsonElement(raw).jsonObject
}.isSuccess
if (!isJsonObject) return null
return SharedLibrarySnapshotJson.decodeOrEmpty(raw)
}
private fun backupFile(): File {
return File(databaseFile.parentFile ?: File("."), "${databaseFile.name}.bak")
}
companion object {
@ -23,3 +43,5 @@ class DesktopLibraryDatabase(
}
}
}
private val libraryDatabaseJson = Json { ignoreUnknownKeys = true }

View file

@ -1,5 +1,6 @@
package com.aryan.reader.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -19,12 +20,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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 androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -44,7 +47,6 @@ import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reduce
import com.aryan.reader.shared.ui.NonReaderLibraryTab
import com.aryan.reader.shared.ui.SharedLibraryScreen
import com.aryan.reader.shared.ui.SharedShelvesScreen
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
import com.aryan.reader.shared.ui.readerString
import java.io.File
@ -117,82 +119,62 @@ internal fun resolvedDesktopReaderSettings(
@Composable
internal fun DesktopReaderOpeningScreen(
opening: DesktopReaderOpening
opening: DesktopReaderOpening,
readerSettings: ReaderSettings? = null
) {
LaunchedEffect(opening.requestId) {
logDesktopReaderOpenTrace {
opening.openTracePrefix("desktop_opening_screen_composed")
}
}
val background = readerSettings?.desktopOpeningBackgroundColor() ?: MaterialTheme.colorScheme.background
val foreground = readerSettings?.desktopOpeningForegroundColor() ?: MaterialTheme.colorScheme.onBackground
Box(
modifier = Modifier.fillMaxSize().padding(32.dp),
modifier = Modifier
.fillMaxSize()
.background(background)
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CircularProgressIndicator()
CircularProgressIndicator(color = foreground)
Text(
text = readerString("desktop_opening_title", "Opening %1\$s", opening.title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = foreground,
textAlign = TextAlign.Center
)
Text(
text = opening.formatLabel,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = foreground.copy(alpha = 0.72f),
textAlign = TextAlign.Center
)
}
}
}
@Composable
internal fun HomeScreen(
state: SharedReaderScreenState,
selectedLibraryTab: NonReaderLibraryTab,
onLibraryTabChange: (NonReaderLibraryTab) -> Unit,
onStateChange: (SharedReaderScreenState) -> Unit,
onImportBooks: () -> Unit,
onImportFolder: () -> Unit,
onRead: (BookItem) -> Unit,
onSelect: (String) -> Unit,
onClearSelection: () -> Unit,
onRemoveSelected: () -> Unit,
onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit,
onCreateShelf: () -> Unit,
onCreateSmartShelf: () -> Unit,
onRenameShelf: (Shelf) -> Unit,
onDeleteShelf: (Shelf) -> Unit,
onRemoveFolder: (Shelf) -> Unit,
onTagSelectedBooks: () -> Unit,
onAddSelectedBooksToShelf: () -> Unit,
onSyncFolderMetadata: () -> Unit,
onScanFolders: () -> Unit,
onTogglePinned: (BookItem) -> Unit
) {
LibraryScreen(
state = state,
selectedLibraryTab = selectedLibraryTab,
onLibraryTabChange = onLibraryTabChange,
onStateChange = onStateChange,
onImportBooks = onImportBooks,
onImportFolder = onImportFolder,
onRead = onRead,
onSelect = onSelect,
onClearSelection = onClearSelection,
onRemoveSelected = onRemoveSelected,
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onCreateShelf = onCreateShelf,
onCreateSmartShelf = onCreateSmartShelf,
onRenameShelf = onRenameShelf,
onDeleteShelf = onDeleteShelf,
onRemoveFolder = onRemoveFolder,
onTagSelectedBooks = onTagSelectedBooks,
onAddSelectedBooksToShelf = onAddSelectedBooksToShelf,
onSyncFolderMetadata = onSyncFolderMetadata,
onScanFolders = onScanFolders,
onTogglePinned = onTogglePinned
)
private fun ReaderSettings.desktopOpeningBackgroundColor(): Color {
return backgroundColorArgb?.toDesktopOpeningComposeColor()
?: if (darkMode) Color(0xFF171A17) else Color(0xFFFFFCF5)
}
private fun ReaderSettings.desktopOpeningForegroundColor(): Color {
return textColorArgb?.toDesktopOpeningComposeColor()
?: if (darkMode) Color(0xFFE7E3D8) else Color(0xFF24231F)
}
private fun Long.toDesktopOpeningComposeColor(): Color {
val value = this and 0xFFFFFFFFL
val alpha = ((value shr 24) and 0xFF) / 255f
val red = ((value shr 16) and 0xFF) / 255f
val green = ((value shr 8) and 0xFF) / 255f
val blue = (value and 0xFF) / 255f
return Color(red = red, green = green, blue = blue, alpha = alpha.takeIf { it > 0f } ?: 1f)
}
@Composable
@ -209,12 +191,15 @@ internal fun LibraryScreen(
onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit,
onCreateShelf: () -> Unit,
onCreateShelfWithBooks: (String, Set<String>) -> Unit,
onCreateSmartShelf: () -> Unit,
onRenameShelf: (Shelf) -> Unit,
onDeleteShelf: (Shelf) -> Unit,
onRemoveFolder: (Shelf) -> Unit,
onTagSelectedBooks: () -> Unit,
onAddSelectedBooksToShelf: () -> Unit,
onAddBooksToShelf: (Set<String>) -> Unit,
onManageShelfBooks: (Shelf) -> Unit,
onImportFolder: () -> Unit,
onSyncFolderMetadata: () -> Unit,
onScanFolders: () -> Unit,
@ -233,12 +218,15 @@ internal fun LibraryScreen(
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onCreateShelf = onCreateShelf,
onCreateShelfWithBooks = onCreateShelfWithBooks,
onCreateSmartShelf = onCreateSmartShelf,
onRenameShelf = onRenameShelf,
onDeleteShelf = onDeleteShelf,
onRemoveFolder = onRemoveFolder,
onTagSelectedBooks = onTagSelectedBooks,
onAddSelectedBooksToShelf = onAddSelectedBooksToShelf,
onAddBooksToShelf = onAddBooksToShelf,
onManageShelfBooks = onManageShelfBooks,
onImportFolder = onImportFolder,
onSyncFolderMetadata = onSyncFolderMetadata,
onScanFolders = onScanFolders,
@ -248,39 +236,6 @@ internal fun LibraryScreen(
)
}
@Composable
internal fun ShelvesScreen(
shelves: List<Shelf>,
selectedBookIds: Set<String>,
pinnedBookIds: Set<String>,
onRead: (BookItem) -> Unit,
onSelect: (String) -> Unit,
onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit,
onTogglePinned: (BookItem) -> Unit,
onCreateShelf: () -> Unit,
onCreateSmartShelf: () -> Unit,
onRenameShelf: (Shelf) -> Unit,
onDeleteShelf: (Shelf) -> Unit,
onRemoveFolder: (Shelf) -> Unit
) {
SharedShelvesScreen(
shelves = shelves,
selectedBookIds = selectedBookIds,
pinnedBookIds = pinnedBookIds,
onOpenBook = onRead,
onToggleSelection = onSelect,
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onTogglePinned = onTogglePinned,
onCreateShelf = onCreateShelf,
onCreateSmartShelf = onCreateSmartShelf,
onRenameShelf = onRenameShelf,
onDeleteShelf = onDeleteShelf,
onRemoveFolder = onRemoveFolder
)
}
private data class DesktopSmartRuleDraft(
val field: SmartField = SmartField.TITLE,
val operator: SmartOperator = SmartOperator.CONTAINS,

View file

@ -24,6 +24,7 @@ import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
import com.aryan.reader.shared.pdf.SharedPdfRichTextLog
import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer
import com.aryan.reader.shared.toSharedFolderBookMetadata
import com.aryan.reader.shared.toStablePositionCfi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
@ -46,7 +47,8 @@ data class DesktopLocalFolderSyncResult(
val metadataStats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats(),
val idMigrations: Map<String, String> = emptyMap(),
val removedBookIds: Set<String> = emptySet(),
val failedFolders: List<String> = emptyList()
val failedFolders: List<String> = emptyList(),
val processedFolderUris: List<String> = emptyList()
)
object DesktopLocalFolderSync {
@ -56,10 +58,18 @@ object DesktopLocalFolderSync {
if (!folder.isDirectory) return false
return folder.walkTopDown()
.onEnter { it == folder || it.shouldEnterSyncedFolder() }
.onFail { file, error ->
logDesktopFolderSync(
"folder.supportedFiles.skipInaccessible path=\"${file.absolutePath.folderSyncPreview()}\" " +
"error=${error.folderSyncSummary()}"
)
}
.any { file ->
file.isFile &&
file.shouldSyncBookFile() &&
SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes
runCatching {
file.isFile &&
file.shouldSyncBookFile() &&
SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes
}.getOrDefault(false)
}
}
@ -68,9 +78,11 @@ object DesktopLocalFolderSync {
shelfRefs: List<BookShelfRef>,
targetFolder: File? = null,
nowMillis: Long = System.currentTimeMillis(),
metadataOnly: Boolean = false
metadataOnly: Boolean = false,
extractMetadata: Boolean = true
): DesktopLocalFolderSyncResult {
val requestedFolders = foldersToSync(state, targetFolder, nowMillis)
.filter { it.localSyncEnabled }
val mode = if (metadataOnly) "metadata" else "full"
logDesktopFolderSync(
"sync.start mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " +
@ -84,6 +96,7 @@ object DesktopLocalFolderSync {
val allMigrations = linkedMapOf<String, String>()
val allRemovedBookIds = linkedSetOf<String>()
val failedFolders = mutableListOf<String>()
val processedFolderUris = mutableListOf<String>()
requestedFolders.forEach { folder ->
val root = File(folder.uriString)
@ -95,6 +108,7 @@ object DesktopLocalFolderSync {
failedFolders += folder.name
return@forEach
}
processedFolderUris += folder.uriString
logDesktopFolderSync(
"folder.start mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
@ -138,8 +152,27 @@ object DesktopLocalFolderSync {
logDesktopFolderSync(
"folder.sidecars.importCheck mode=$mode name=\"${folder.name.folderSyncPreview()}\" books=${syncedBooks.size}"
)
importAnnotationSidecars(root, syncedBooks)
if (!metadataOnly) {
runCatching {
importAnnotationSidecars(root, syncedBooks)
}.onFailure { error ->
logDesktopFolderSync(
"annotation.import.failed mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
"root=\"${root.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}"
)
}
syncedBooks.forEach { book ->
remoteMetadata[book.id]?.let { metadata ->
runCatching {
importDesktopPdfBookmarksMetadata(book, metadata.bookmarksJson, metadata.lastModifiedTimestamp)
}.onFailure { error ->
logDesktopFolderSync(
"metadata.bookmarks.importFailed book=${book.id} " +
"error=${error.folderSyncSummary()}"
)
}
}
}
if (!metadataOnly && extractMetadata) {
val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks(
books = nextState.rawLibraryBooks,
sourceFolder = folder.uriString
@ -173,7 +206,8 @@ object DesktopLocalFolderSync {
metadataStats = totalMetadataStats,
idMigrations = allMigrations,
removedBookIds = allRemovedBookIds,
failedFolders = failedFolders
failedFolders = failedFolders,
processedFolderUris = processedFolderUris
)
logDesktopFolderSync(
"sync.done mode=$mode failed=${failedFolders.size} new=${totalStats.newBooks} " +
@ -188,8 +222,13 @@ object DesktopLocalFolderSync {
savePdfAnnotationSidecar(book)
}
fun deleteSyncDataFolder(root: File): Boolean {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
return !syncDir.exists() || syncDir.isDirectory && syncDir.deleteRecursively()
}
fun saveBookMetadata(book: BookItem) {
val metadata = book.toSharedFolderBookMetadata()
val metadata = book.toDesktopFolderBookMetadata()
if (metadata == null) {
logDesktopFolderSync(
"metadata.export.skipClean book=${book.id} title=\"${book.title.orEmpty().folderSyncPreview()}\" " +
@ -242,11 +281,9 @@ object DesktopLocalFolderSync {
val data = buildMap {
if (annotationFile.isFile) {
val annotationJson = annotationFile.readText().trim()
val annotations = SharedPdfAnnotationSerializer.decode(annotationJson)
put(
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS,
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
)
desktopPdfAnnotationElementForSync(annotationJson)?.let { annotations ->
put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations)
}
}
if (bookmarkFile.isFile) {
val bookmarksJson = bookmarkFile.readText().trim()
@ -267,7 +304,7 @@ object DesktopLocalFolderSync {
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length} " +
"textLen=${richTextDocument.text.length} spans=${richTextDocument.spans.size}"
)
put("text", SharedPdfRichTextSerializer.encodeElement(richTextDocument))
desktopPdfRichTextElementForSync(richTextJson)?.let { put("text", it) }
}
}
}
@ -279,9 +316,9 @@ object DesktopLocalFolderSync {
return
}
val timestamp = maxOf(
annotationFile.lastModifiedIfFile(),
annotationFile.lastModifiedIfSyncableAnnotations(),
bookmarkFile.lastModifiedIfFile(),
richTextFile.lastModifiedIfFile(),
richTextFile.lastModifiedIfSyncableRichText(),
System.currentTimeMillis()
)
val dataJson = desktopFolderSyncJson.encodeToString(
@ -329,7 +366,15 @@ object DesktopLocalFolderSync {
val rootPath = root.toPath().toAbsolutePath().normalize()
return root.walkTopDown()
.onEnter { it == root || it.shouldEnterSyncedFolder() }
.filter { it.isFile && it.shouldSyncBookFile() }
.onFail { file, error ->
logDesktopFolderSync(
"folder.scan.skipInaccessible root=\"${root.absolutePath.folderSyncPreview()}\" " +
"path=\"${file.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}"
)
}
.filter { file ->
runCatching { file.isFile && file.shouldSyncBookFile() }.getOrDefault(false)
}
.mapNotNull { file ->
val type = SharedFileCapabilities.fileTypeForName(file.name)
.takeIf { it in desktopSyncableTypes }
@ -344,8 +389,8 @@ object DesktopLocalFolderSync {
sourceFolder = sourceFolder,
relativePath = relativePath,
type = type,
size = file.length(),
lastModified = file.lastModified()
size = runCatching { file.length() }.getOrDefault(0L),
lastModified = runCatching { file.lastModified() }.getOrDefault(0L)
)
}
.toList()
@ -559,13 +604,18 @@ object DesktopLocalFolderSync {
}
if (sidecar.data.hasPdfAnnotationPayload()) {
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(sidecar.data)
annotationFile.parentFile?.mkdirs()
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
annotationFile.setLastModified(sidecar.timestamp)
logDesktopFolderSync(
"annotation.import.writeAnnotations book=${book.id} count=${annotations.size} " +
"file=\"${annotationFile.absolutePath.folderSyncPreview()}\""
)
if (annotations.isEmpty()) {
if (annotationFile.isFile) annotationFile.delete()
logDesktopFolderSync("annotation.import.deleteEmptyAnnotations book=${book.id}")
} else {
annotationFile.parentFile?.mkdirs()
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
annotationFile.setLastModified(sidecar.timestamp)
logDesktopFolderSync(
"annotation.import.writeAnnotations book=${book.id} count=${annotations.size} " +
"file=\"${annotationFile.absolutePath.folderSyncPreview()}\""
)
}
}
sidecar.data["bookmarks"]?.let { bookmarks ->
bookmarkFile.parentFile?.mkdirs()
@ -582,13 +632,18 @@ object DesktopLocalFolderSync {
"textLen=${richDocument.text.length} spans=${richDocument.spans.size} " +
"file=\"${richTextFile.absolutePath.richSyncPreview()}\""
)
richTextFile.parentFile?.mkdirs()
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
richTextFile.setLastModified(sidecar.timestamp)
logDesktopFolderSync(
"annotation.import.writeText book=${book.id} textLen=${richDocument.text.length} " +
"spans=${richDocument.spans.size} file=\"${richTextFile.absolutePath.folderSyncPreview()}\""
)
if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) {
if (richTextFile.isFile) richTextFile.delete()
logDesktopFolderSync("annotation.import.deleteEmptyText book=${book.id}")
} else {
richTextFile.parentFile?.mkdirs()
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
richTextFile.setLastModified(sidecar.timestamp)
logDesktopFolderSync(
"annotation.import.writeText book=${book.id} textLen=${richDocument.text.length} " +
"spans=${richDocument.spans.size} file=\"${richTextFile.absolutePath.folderSyncPreview()}\""
)
}
}
}
}
@ -814,6 +869,56 @@ private fun File.lastModifiedIfFile(): Long {
return if (isFile) lastModified() else 0L
}
private fun File.hasSyncablePdfAnnotations(): Boolean {
return isFile && desktopPdfAnnotationElementForSync(readText()) != null
}
private fun File.lastModifiedIfSyncableAnnotations(): Long {
return if (hasSyncablePdfAnnotations()) lastModified() else 0L
}
private fun File.hasSyncablePdfRichText(): Boolean {
return isFile && desktopPdfRichTextElementForSync(readText()) != null
}
private fun File.lastModifiedIfSyncableRichText(): Long {
return if (hasSyncablePdfRichText()) lastModified() else 0L
}
private fun BookItem.toDesktopFolderBookMetadata(): SharedFolderBookMetadata? {
val base = toSharedFolderBookMetadata()
val pdfBookmarksJson = desktopPdfBookmarksMetadataJson(this)
if (base == null && pdfBookmarksJson == null) return null
val timestamp = maxOf(
base?.lastModifiedTimestamp ?: 0L,
desktopPdfBookmarkMetadataTimestamp(this),
this.timestamp
)
return (base ?: SharedFolderBookMetadata(
bookId = id,
title = null,
author = null,
displayName = displayName,
type = type.name,
lastChapterIndex = readerPosition?.chapterIndex,
lastPage = readerPosition?.pageIndex ?: lastPageIndex,
lastPositionCfi = readerPosition?.toStablePositionCfi(),
progressPercentage = progressPercentage ?: 0f,
isRecent = isRecent,
lastModifiedTimestamp = timestamp,
bookmarksJson = null,
locatorBlockIndex = readerPosition?.blockIndex,
locatorCharOffset = readerPosition?.charOffset,
customName = null,
highlightsJson = null
)).copy(
lastModifiedTimestamp = timestamp,
bookmarksJson = pdfBookmarksJson ?: base?.bookmarksJson
)
}
private fun uniqueFolderSyncTempName(baseName: String): String {
val stem = baseName.removeSuffix(".tmp")
val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}"

View file

@ -16,6 +16,7 @@ import kotlinx.serialization.json.jsonPrimitive
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.ceil
internal class DesktopPaidAiAdapter(
private val config: DesktopCloudConfig,
@ -25,7 +26,7 @@ internal class DesktopPaidAiAdapter(
private val currentSignedIn: () -> Boolean,
private val currentIsProUser: () -> Boolean,
private val currentCredits: () -> Int,
private val onUsageCompleted: suspend () -> Unit = {}
private val onUsageReported: (DesktopPaidAiUsage) -> Unit = {}
) : AiAdapter {
override val isAvailable: Boolean
get() = networkAccess() &&
@ -196,13 +197,18 @@ internal class DesktopPaidAiAdapter(
val responseCode = connection.responseCode
val stream = if (responseCode in 200..299) connection.inputStream else connection.errorStream
if (responseCode in 200..299) {
val parsed = readWorkerStream(stream, onChunk, onUsageReceived)
val parsed = readWorkerStream(
stream = stream,
onChunk = onChunk,
onUsageReceived = onUsageReceived,
onUsageReported = onUsageReported
)
if (parsed.text.isBlank()) throw IllegalStateException("The AI service returned an empty response.")
onUsageCompleted()
return@runCatching parsed
}
val responseText = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (connection.responseCode == 402 || responseText.contains("INSUFFICIENT_CREDITS")) {
onUsageReported(DesktopPaidAiUsage())
throw IllegalStateException("Out of credits. Pro and credits can only be purchased from the Android app.")
}
if (connection.responseCode == 401) {
@ -222,6 +228,19 @@ internal class DesktopPaidAiAdapter(
}
}
internal data class DesktopPaidAiUsage(
val cost: Double? = null,
val freeRemaining: Int? = null
)
internal fun desktopCreditsAfterPaidAiUsage(currentCredits: Int, cost: Double?): Int {
val deducted = cost
?.takeIf { it.isFinite() && it > 0.0 }
?.let { ceil(it).toInt() }
?: return currentCredits
return (currentCredits - deducted).coerceAtLeast(0)
}
private data class DesktopPaidAiResponse(
val text: String,
val cost: Double? = null,
@ -233,18 +252,35 @@ private val DesktopPaidAiJson = Json { ignoreUnknownKeys = true }
private fun readWorkerStream(
stream: InputStream?,
onChunk: (String) -> Unit,
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit,
onUsageReported: (DesktopPaidAiUsage) -> Unit
): DesktopPaidAiResponse {
val output = StringBuilder()
var cost: Double? = null
var freeRemaining: Int? = null
var paidUsageReported = false
var freeUsageReported = false
stream?.bufferedReader(Charsets.UTF_8)?.useLines { lines ->
lines.forEach { line ->
val parsed = parseWorkerStreamLine(line) ?: return@forEach
val parsed = try {
parseWorkerStreamLine(line)
} catch (error: IllegalStateException) {
if (desktopPaidAiShouldRefreshAccountAfterError(error)) {
onUsageReported(DesktopPaidAiUsage())
}
throw error
} ?: return@forEach
parsed.cost?.let { cost = it }
parsed.freeRemaining?.let { freeRemaining = it }
if (parsed.cost != null || parsed.freeRemaining != null) {
onUsageReceived(parsed.cost, parsed.freeRemaining)
if (parsed.cost != null && !paidUsageReported) {
paidUsageReported = true
onUsageReported(DesktopPaidAiUsage(cost = parsed.cost, freeRemaining = parsed.freeRemaining))
} else if (parsed.freeRemaining != null && !freeUsageReported) {
freeUsageReported = true
onUsageReported(DesktopPaidAiUsage(freeRemaining = parsed.freeRemaining))
}
}
parsed.chunk?.let { chunk ->
output.append(chunk)
@ -275,6 +311,14 @@ private data class DesktopPaidAiStreamLine(
val freeRemaining: Int? = null
)
private fun desktopPaidAiShouldRefreshAccountAfterError(error: Throwable): Boolean {
val details = generateSequence(error) { it.cause }
.joinToString(" ") { it.message.orEmpty() }
return details.contains("Out of credits", ignoreCase = true) ||
details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) ||
details.contains("402", ignoreCase = true)
}
private fun workerErrorMessage(errorBody: String): String? {
return when {
errorBody.contains("INSUFFICIENT_CREDITS") -> "Out of credits. Pro and credits can only be purchased from the Android app."

View file

@ -9,14 +9,17 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Search
@ -40,24 +43,32 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.pdf.DEFAULT_SHARED_PDF_COMMENT_AUTHOR
import com.aryan.reader.shared.pdf.PdfAnnotationKind
import com.aryan.reader.shared.pdf.PdfInkTool
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
import com.aryan.reader.shared.pdf.pdfCommentChildren
import com.aryan.reader.shared.pdf.sharedPdfStrokePercent
import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange
import com.aryan.reader.shared.pdf.sharedPdfTextStyle
import com.aryan.reader.shared.pdf.visiblePdfAnnotationComments
import com.aryan.reader.shared.pdf.withoutPdfCommentThread
import com.aryan.reader.shared.pdf.withSharedPdfTextStyle
import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog
import com.aryan.reader.shared.ui.SharedPdfTextStyleControls
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
import com.aryan.reader.shared.ui.readerString
import java.text.DateFormat
import java.util.Date
import java.util.UUID
internal val DesktopPdfAnnotationTools = listOf(
PdfInkTool.PEN,
@ -69,6 +80,11 @@ internal val DesktopPdfAnnotationTools = listOf(
PdfInkTool.ERASER
)
private enum class DesktopPdfAnnotationSheetSection {
NOTE,
COMMENTS
}
@Composable
internal fun DesktopPdfAnnotationEditor(
annotation: SharedPdfAnnotation,
@ -82,12 +98,51 @@ internal fun DesktopPdfAnnotationEditor(
onSearch: () -> Unit
) {
val highlighterColors = remember(highlighterPalette) {
SharedPdfAndroidHighlightColors.palette
SharedPdfHighlighterPalette(highlighterPalette).sanitized().colors
}
var editingHighlighterSlot by remember(annotation.id, highlighterColors) { mutableStateOf<Int?>(null) }
var editingHighlighterDraftColors by remember(annotation.id, highlighterColors) {
mutableStateOf<List<Int>>(emptyList())
}
val isHighlighterAnnotation = annotation.kind == PdfAnnotationKind.HIGHLIGHT ||
annotation.tool == PdfInkTool.HIGHLIGHTER ||
annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND
var selectedSection by remember(annotation.id) { mutableStateOf(DesktopPdfAnnotationSheetSection.NOTE) }
var commentText by remember(annotation.id) { mutableStateOf("") }
var replyTargetId by remember(annotation.id) { mutableStateOf<String?>(null) }
var editingCommentId by remember(annotation.id) { mutableStateOf<String?>(null) }
var commentAuthor by remember(annotation.id) {
mutableStateOf(
annotation.comments
.lastOrNull { it.author.isNotBlank() }
?.author
?: DEFAULT_SHARED_PDF_COMMENT_AUTHOR
)
}
fun updateComments(nextComments: List<SharedPdfAnnotationComment>) {
onUpdate(annotation.copy(comments = nextComments))
}
fun highlighterDraftColors(): List<Int> {
return editingHighlighterDraftColors.ifEmpty { highlighterColors }
}
fun updateHighlighterDraft(slotIndex: Int, color: Color): List<Int> {
val nextColors = highlighterDraftColors().toMutableList()
if (slotIndex in nextColors.indices) {
nextColors[slotIndex] = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
editingHighlighterDraftColors = nextColors
}
return nextColors
}
fun openHighlighterEditor(slotIndex: Int) {
if (editingHighlighterSlot == null) {
editingHighlighterDraftColors = highlighterColors
}
editingHighlighterSlot = slotIndex
}
Surface(
color = MaterialTheme.colorScheme.surface,
@ -127,7 +182,7 @@ internal fun DesktopPdfAnnotationEditor(
)
Text(
"\"${annotation.text}\"",
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
maxLines = 4,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f),
@ -185,12 +240,7 @@ internal fun DesktopPdfAnnotationEditor(
modifier = Modifier
.size(26.dp)
.clickable {
val nextColor = if (isHighlighterAnnotation) {
SharedPdfAndroidHighlightColors.nearestArgb(argb)
} else {
argb
}
onUpdate(annotation.copy(colorArgb = nextColor))
onUpdate(annotation.copy(colorArgb = argb))
},
color = Color(argb),
shape = RoundedCornerShape(13.dp),
@ -217,24 +267,103 @@ internal fun DesktopPdfAnnotationEditor(
)
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(15.dp))
.clickable {
editingHighlighterSlot = highlighterColors
.indexOf(annotation.colorArgb)
.takeIf { it >= 0 }
?: 0
openHighlighterEditor(
highlighterColors
.indexOf(annotation.colorArgb)
.takeIf { it >= 0 }
?: 0
)
}
)
}
}
SharedStableOutlinedTextField(
value = annotation.note.orEmpty(),
onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) },
label = { Text(readerString("label_note", "Note")) },
minLines = 3,
maxLines = 5,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
selectionKey = annotation.id
DesktopPdfAnnotationSheetTabs(
selectedSection = selectedSection,
commentCount = annotation.comments.count { it.contents.isNotBlank() },
onSectionChange = { selectedSection = it }
)
if (selectedSection == DesktopPdfAnnotationSheetSection.NOTE) {
SharedStableOutlinedTextField(
value = annotation.note.orEmpty(),
onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) },
label = { Text(readerString("label_note", "Note")) },
minLines = 3,
maxLines = 5,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
selectionKey = annotation.id
)
} else {
DesktopPdfHighlightCommentsEditor(
comments = annotation.comments,
commentText = commentText,
commentAuthor = commentAuthor,
replyTargetId = replyTargetId,
editingCommentId = editingCommentId,
onCommentTextChange = { commentText = it },
onCommentAuthorChange = { commentAuthor = it },
onReply = { comment ->
editingCommentId = null
replyTargetId = comment.id
commentText = ""
},
onCancelReply = { replyTargetId = null },
onEdit = { comment ->
editingCommentId = comment.id
replyTargetId = null
commentText = comment.contents
commentAuthor = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }
},
onCancelEdit = {
editingCommentId = null
commentText = ""
},
onDelete = { comment ->
val nextComments = annotation.comments.withoutPdfCommentThread(comment.id)
updateComments(nextComments)
if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) {
replyTargetId = null
}
if (editingCommentId != null && (editingCommentId == comment.id || nextComments.none { it.id == editingCommentId })) {
editingCommentId = null
commentText = ""
}
},
onAddComment = {
val contents = commentText.trim()
if (contents.isNotBlank()) {
val now = System.currentTimeMillis()
val author = commentAuthor.trim().ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }
val nextComments = if (editingCommentId != null) {
annotation.comments.map { comment ->
if (comment.id == editingCommentId) {
comment.copy(
author = author,
contents = contents,
modifiedAt = now
)
} else {
comment
}
}
} else {
annotation.comments + SharedPdfAnnotationComment(
id = UUID.randomUUID().toString(),
parentId = replyTargetId,
author = author,
contents = contents,
createdAt = now,
modifiedAt = now
)
}
updateComments(nextComments)
commentText = ""
replyTargetId = null
editingCommentId = null
}
}
)
}
}
if (annotation.kind == PdfAnnotationKind.INK) {
val strokeRange = annotation.tool.sharedPdfStrokeWidthRange()
@ -261,23 +390,29 @@ internal fun DesktopPdfAnnotationEditor(
}
}
editingHighlighterSlot?.let { requestedSlot ->
val slot = requestedSlot.coerceIn(0, highlighterColors.lastIndex)
val initialColor = Color(highlighterColors[slot]).copy(alpha = 1f)
val draftColors = highlighterDraftColors()
val safeDraftColors = draftColors.ifEmpty { SharedPdfHighlighterPalette.defaultColors }
val slot = requestedSlot.coerceIn(0, safeDraftColors.lastIndex)
val initialColor = remember(slot) { Color(safeDraftColors[slot]).copy(alpha = 1f) }
SharedHsvColorPickerDialog(
initialColor = initialColor,
title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1),
onDismiss = { editingHighlighterSlot = null },
onSave = { color ->
val nextArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
val syncedArgb = SharedPdfAndroidHighlightColors.nearestArgb(nextArgb)
val nextColors = updateHighlighterDraft(slot, color)
onHighlighterPaletteChange(
SharedPdfHighlighterPalette(highlighterColors).withColorAt(
slotIndex = slot,
colorArgb = nextArgb
)
SharedPdfHighlighterPalette(nextColors).sanitized()
)
onUpdate(annotation.copy(colorArgb = syncedArgb))
onUpdate(annotation.copy(colorArgb = nextArgb))
editingHighlighterSlot = null
},
resetColor = Color(SharedPdfHighlighterPalette.defaultColors.getOrElse(slot) {
SharedPdfHighlighterPalette.defaultColors.first()
}).copy(alpha = 1f),
stateKey = slot,
onLiveColorChange = { color ->
updateHighlighterDraft(slot, color)
}
) { liveColor ->
Row(
@ -285,7 +420,7 @@ internal fun DesktopPdfAnnotationEditor(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
highlighterColors.forEachIndexed { index, argb ->
highlighterDraftColors().forEachIndexed { index, argb ->
val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f)
Box(
modifier = Modifier
@ -294,10 +429,14 @@ internal fun DesktopPdfAnnotationEditor(
.background(color)
.border(
width = if (index == slot) 3.dp else 1.dp,
color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f),
color = if (index == slot) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)
},
shape = RoundedCornerShape(21.dp)
)
.clickable { editingHighlighterSlot = index },
.clickable { openHighlighterEditor(index) },
contentAlignment = Alignment.Center
) {
Text(
@ -313,6 +452,265 @@ internal fun DesktopPdfAnnotationEditor(
}
}
@Composable
private fun DesktopPdfAnnotationSheetTabs(
selectedSection: DesktopPdfAnnotationSheetSection,
commentCount: Int,
onSectionChange: (DesktopPdfAnnotationSheetSection) -> Unit
) {
Surface(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(modifier = Modifier.padding(4.dp)) {
DesktopPdfAnnotationSheetTab(
label = readerString("label_note", "Note"),
selected = selectedSection == DesktopPdfAnnotationSheetSection.NOTE,
modifier = Modifier.weight(1f),
onClick = { onSectionChange(DesktopPdfAnnotationSheetSection.NOTE) }
)
DesktopPdfAnnotationSheetTab(
label = "${readerString("label_comments", "Comments")} ($commentCount)",
selected = selectedSection == DesktopPdfAnnotationSheetSection.COMMENTS,
modifier = Modifier.weight(1f),
onClick = { onSectionChange(DesktopPdfAnnotationSheetSection.COMMENTS) }
)
}
}
}
@Composable
private fun DesktopPdfAnnotationSheetTab(
label: String,
selected: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Surface(
color = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(6.dp),
modifier = modifier
.height(40.dp)
.clip(RoundedCornerShape(6.dp))
.clickable(onClick = onClick)
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun DesktopPdfHighlightCommentsEditor(
comments: List<SharedPdfAnnotationComment>,
commentText: String,
commentAuthor: String,
replyTargetId: String?,
editingCommentId: String?,
onCommentTextChange: (String) -> Unit,
onCommentAuthorChange: (String) -> Unit,
onReply: (SharedPdfAnnotationComment) -> Unit,
onCancelReply: () -> Unit,
onEdit: (SharedPdfAnnotationComment) -> Unit,
onCancelEdit: () -> Unit,
onDelete: (SharedPdfAnnotationComment) -> Unit,
onAddComment: () -> Unit
) {
val visibleComments = comments.visiblePdfAnnotationComments()
val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId }
val editingComment = visibleComments.firstOrNull { it.id == editingCommentId }
Column {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 220.dp)
.verticalScroll(rememberScrollState())
) {
DesktopPdfHighlightCommentThread(
comments = visibleComments,
parentId = null,
depth = 0,
visitedIds = emptySet(),
onReply = onReply,
onEdit = onEdit,
onDelete = onDelete
)
}
if (editingComment != null || replyTarget != null) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (editingComment != null) {
readerString("label_editing_comment", "Editing comment")
} else {
readerString(
"label_replying_to",
"Replying to %1\$s",
replyTarget?.author?.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }.orEmpty()
)
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
TextButton(onClick = if (editingComment != null) onCancelEdit else onCancelReply) {
Text(readerString("action_cancel", "Cancel"))
}
}
}
SharedStableOutlinedTextField(
value = commentAuthor,
onValueChange = onCommentAuthorChange,
label = { Text(readerString("author", "Author")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
selectionKey = "comment-author-${editingCommentId ?: replyTargetId ?: "new"}"
)
Spacer(Modifier.height(8.dp))
SharedStableOutlinedTextField(
value = commentText,
onValueChange = onCommentTextChange,
placeholder = { Text(readerString("placeholder_add_comment", "Add a comment...")) },
modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
minLines = 3,
maxLines = 4,
shape = RoundedCornerShape(12.dp),
selectionKey = "comment-text-${editingCommentId ?: replyTargetId ?: "new"}"
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onAddComment, enabled = commentText.isNotBlank()) {
Text(
readerString(
if (editingComment != null) "action_save_comment" else "action_add_comment",
if (editingComment != null) "Save Comment" else "Add Comment"
)
)
}
}
}
}
@Composable
private fun DesktopPdfHighlightCommentThread(
comments: List<SharedPdfAnnotationComment>,
parentId: String?,
depth: Int,
visitedIds: Set<String>,
onReply: (SharedPdfAnnotationComment) -> Unit,
onEdit: (SharedPdfAnnotationComment) -> Unit,
onDelete: (SharedPdfAnnotationComment) -> Unit
) {
comments.pdfCommentChildren(parentId).forEach { comment ->
if (comment.id in visitedIds) return@forEach
DesktopPdfHighlightCommentItem(
comment = comment,
depth = depth,
onReply = { onReply(comment) },
onEdit = { onEdit(comment) },
onDelete = { onDelete(comment) }
)
DesktopPdfHighlightCommentThread(
comments = comments,
parentId = comment.id,
depth = depth + 1,
visitedIds = visitedIds + comment.id,
onReply = onReply,
onEdit = onEdit,
onDelete = onDelete
)
}
}
@Composable
private fun DesktopPdfHighlightCommentItem(
comment: SharedPdfAnnotationComment,
depth: Int,
onReply: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
val indentSize = (depth * 16).dp
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = indentSize, top = 6.dp, bottom = 6.dp)
) {
if (depth > 0) {
Box(
modifier = Modifier
.width(2.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outlineVariant)
)
Spacer(modifier = Modifier.width(12.dp))
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR },
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
val timestamp = comment.createdAt.formatDesktopPdfCommentTimestamp()
if (timestamp.isNotBlank()) {
Text(
text = timestamp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.height(2.dp))
Text(
text = comment.contents,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
Row {
TextButton(onClick = onReply) {
Text(readerString("action_reply", "Reply"))
}
TextButton(onClick = onEdit) {
Text(readerString("label_edit", "Edit"))
}
TextButton(onClick = onDelete) {
Text(readerString("action_delete", "Delete"), color = MaterialTheme.colorScheme.error)
}
}
}
}
}
private fun Long.formatDesktopPdfCommentTimestamp(): String {
if (this <= 0L) return ""
return runCatching {
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(this))
}.getOrDefault("")
}
@Composable
private fun DesktopBottomSheetToolButton(
icon: ImageVector,

View file

@ -1,7 +1,6 @@
package com.aryan.reader.desktop
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -20,12 +19,17 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.BuiltInPdfReaderThemes
import com.aryan.reader.shared.PdfDisplayMode
@ -33,9 +37,11 @@ import com.aryan.reader.shared.ReaderTheme
import com.aryan.reader.shared.reader.ReaderSettings
internal enum class DesktopPdfInspectorTab(val title: String) {
VIEW("View"),
APPEARANCE("Appearance"),
APP_THEME("App theme"),
VISUAL("Visual"),
MARKUP("Markup"),
ASSIST("Assist")
TTS("TTS")
}
internal data class DesktopPdfThemeStyle(
@ -56,15 +62,25 @@ internal fun DesktopPdfThemedPageImage(
modifier: Modifier = Modifier
) {
Box(modifier = modifier.background(themeStyle.pageBackgroundColor)) {
Image(
bitmap = bitmap,
contentDescription = contentDescription,
colorFilter = themeStyle.colorFilter,
modifier = Modifier.fillMaxSize()
)
val textureBitmap = themeStyle.textureBitmap
if (textureBitmap != null && themeStyle.textureAlpha > 0f) {
Canvas(modifier = Modifier.fillMaxSize()) {
Canvas(
modifier = Modifier
.fillMaxSize()
.semantics { this.contentDescription = contentDescription }
) {
drawImage(
image = bitmap,
srcOffset = IntOffset.Zero,
srcSize = IntSize(bitmap.width, bitmap.height),
dstOffset = IntOffset.Zero,
dstSize = IntSize(
size.width.toInt().coerceAtLeast(1),
size.height.toInt().coerceAtLeast(1)
),
colorFilter = themeStyle.colorFilter,
filterQuality = FilterQuality.High
)
if (textureBitmap != null && themeStyle.textureAlpha > 0f) {
drawRect(
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
size = size,
@ -77,7 +93,7 @@ internal fun DesktopPdfThemedPageImage(
}
internal fun ReaderSettings?.toDesktopPdfReaderSettings(): ReaderSettings {
val defaults = ReaderSettings(themeId = "no_theme")
val defaults = DesktopDefaultPdfReaderSettings
val settings = this ?: defaults
val themeId = settings.themeId
val hasPdfTheme = BuiltInPdfReaderThemes.any { it.id == themeId }

View file

@ -35,7 +35,6 @@ import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.ZoomOut
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -49,6 +48,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -56,6 +56,7 @@ import androidx.compose.ui.zIndex
import com.aryan.reader.shared.SearchHighlightMode
import com.aryan.reader.shared.pdf.SharedPdfSearchResult
import com.aryan.reader.shared.ui.ReaderMinimalSlider
import com.aryan.reader.shared.ui.ReaderTooltipIconButton
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
import com.aryan.reader.shared.ui.readerString
import kotlinx.coroutines.delay
@ -82,12 +83,11 @@ internal fun DesktopPdfFullscreenBottomChrome(
val chromeBackground = MaterialTheme.colorScheme.surfaceVariant
val chromeContent = MaterialTheme.colorScheme.onSurface
val sliderActive = MaterialTheme.colorScheme.primary
val sliderInactive = MaterialTheme.colorScheme.surfaceVariant
val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 0.dp),
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp),
.fillMaxWidth(),
shape = RoundedCornerShape(0.dp),
color = chromeBackground,
contentColor = chromeContent,
tonalElevation = 0.dp,
@ -113,7 +113,11 @@ internal fun DesktopPdfFullscreenBottomChrome(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onPrevious, enabled = canGoPrevious) {
ReaderTooltipIconButton(
tooltip = readerString("desktop_previous_page", "Previous page"),
onClick = onPrevious,
enabled = canGoPrevious
) {
Icon(
Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = readerString("desktop_previous_page", "Previous page"),
@ -136,7 +140,11 @@ internal fun DesktopPdfFullscreenBottomChrome(
thumbColor = sliderActive,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onNext, enabled = canGoNext) {
ReaderTooltipIconButton(
tooltip = readerString("desktop_next_page", "Next page"),
onClick = onNext,
enabled = canGoNext
) {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = readerString("desktop_next_page", "Next page"),
@ -171,10 +179,10 @@ internal fun DesktopPdfBottomChrome(
val chromeBackground = MaterialTheme.colorScheme.surfaceVariant
val chromeContent = MaterialTheme.colorScheme.onSurface
val sliderActive = MaterialTheme.colorScheme.primary
val sliderInactive = MaterialTheme.colorScheme.surfaceVariant
val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(6.dp),
shape = RoundedCornerShape(0.dp),
color = chromeBackground,
contentColor = chromeContent,
tonalElevation = 0.dp,
@ -199,7 +207,11 @@ internal fun DesktopPdfBottomChrome(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onPrevious, enabled = canGoPrevious) {
ReaderTooltipIconButton(
tooltip = readerString("desktop_previous_page", "Previous page"),
onClick = onPrevious,
enabled = canGoPrevious
) {
Icon(
Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = readerString("desktop_previous_page", "Previous page"),
@ -230,7 +242,11 @@ internal fun DesktopPdfBottomChrome(
style = MaterialTheme.typography.labelSmall,
color = chromeContent.copy(alpha = 0.72f)
)
IconButton(onClick = onNext, enabled = canGoNext) {
ReaderTooltipIconButton(
tooltip = readerString("desktop_next_page", "Next page"),
onClick = onNext,
enabled = canGoNext
) {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = readerString("desktop_next_page", "Next page"),
@ -298,7 +314,7 @@ internal fun DesktopPdfSearchTopBar(
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(6.dp),
shape = RoundedCornerShape(0.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp
) {
@ -307,7 +323,11 @@ internal fun DesktopPdfSearchTopBar(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
ReaderTooltipIconButton(
tooltip = readerString("tooltip_close_search_desc", "Exit search and go back to the reader"),
onClick = onClose,
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Close, contentDescription = readerString("content_desc_close_search", "Close search"))
}
SharedStableOutlinedTextField(
@ -318,7 +338,10 @@ internal fun DesktopPdfSearchTopBar(
modifier = Modifier.weight(1f).focusRequester(focusRequester),
trailingIcon = if (query.isNotEmpty()) {
{
IconButton(onClick = { onQueryChange("") }) {
ReaderTooltipIconButton(
tooltip = readerString("tooltip_clear_search_desc", "Erase your current search query and start over"),
onClick = { onQueryChange("") }
) {
Icon(Icons.Default.Close, contentDescription = readerString("tooltip_clear_search", "Clear search"))
}
}
@ -327,7 +350,16 @@ internal fun DesktopPdfSearchTopBar(
},
selectionKey = "desktop-pdf-search"
)
IconButton(onClick = onToggleResults, modifier = Modifier.size(36.dp)) {
val resultsTooltip = if (showResultsPanel) {
readerString("tooltip_hide_results_desc", "Collapse the search results panel")
} else {
readerString("tooltip_show_results_desc", "Expand the panel to see all search matches")
}
ReaderTooltipIconButton(
tooltip = resultsTooltip,
onClick = onToggleResults,
modifier = Modifier.size(36.dp)
) {
Icon(
if (showResultsPanel) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (showResultsPanel) {
@ -507,7 +539,15 @@ private fun DesktopPdfSearchNavigationPill(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
IconButton(onClick = onToggleHighlightMode, modifier = Modifier.size(36.dp)) {
ReaderTooltipIconButton(
tooltip = if (highlightMode == SearchHighlightMode.ALL) {
readerString("desktop_show_current_match_only", "Show current match only")
} else {
readerString("desktop_show_all_search_matches", "Show all search matches")
},
onClick = onToggleHighlightMode,
modifier = Modifier.size(36.dp)
) {
Icon(
if (highlightMode == SearchHighlightMode.ALL) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = readerString("content_desc_toggle_search_highlights", "Toggle search highlights"),
@ -518,7 +558,12 @@ private fun DesktopPdfSearchNavigationPill(
}
)
}
IconButton(onClick = onPrevious, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) {
ReaderTooltipIconButton(
tooltip = readerString("tooltip_prev_result_desc", "Jump to the previous search match in the document"),
onClick = onPrevious,
enabled = resultCount > 0,
modifier = Modifier.size(36.dp)
) {
Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = readerString("desktop_previous_search_result", "Previous search result"))
}
Text(
@ -531,7 +576,12 @@ private fun DesktopPdfSearchNavigationPill(
fontWeight = FontWeight.SemiBold,
modifier = Modifier.clickable(onClick = onShowResults).padding(horizontal = 8.dp)
)
IconButton(onClick = onNext, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) {
ReaderTooltipIconButton(
tooltip = readerString("tooltip_next_result_desc", "Jump to the next search match in the document"),
onClick = onNext,
enabled = resultCount > 0,
modifier = Modifier.size(36.dp)
) {
Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = readerString("desktop_next_search_result", "Next search result"))
}
}

View file

@ -5,50 +5,46 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ZoomIn
import androidx.compose.material.icons.filled.ZoomOut
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
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 androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.BuiltInPdfReaderThemes
import com.aryan.reader.shared.PdfDisplayMode
import com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.ReaderAutoScrollState
import com.aryan.reader.shared.ReaderExtrasState
import com.aryan.reader.shared.ReaderExternalLookupAction
import com.aryan.reader.shared.ReaderTtsReadScope
import com.aryan.reader.shared.ReaderTheme
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.pdf.PdfInkTool
import com.aryan.reader.shared.pdf.PdfSpreadLayout
import com.aryan.reader.shared.pdf.PdfZoomSpec
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
import com.aryan.reader.shared.pdf.SharedPdfRichTextController
import com.aryan.reader.shared.pdf.SharedPdfTextStyleConfig
@ -56,8 +52,6 @@ import com.aryan.reader.shared.pdf.currentSharedPdfTextStyleConfig
import com.aryan.reader.shared.pdf.updateCurrentSharedPdfTextStyle
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.ui.ReaderMinimalSlider
import com.aryan.reader.shared.ui.SharedPdfAnnotationToolDock
import com.aryan.reader.shared.ui.SharedPdfHighlighterPaletteEditor
import com.aryan.reader.shared.ui.SharedPdfTextAnnotationDock
import com.aryan.reader.shared.ui.SharedReaderThemeControls
@ -68,131 +62,93 @@ import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll
@Composable
internal fun DesktopPdfInspectorPanel(
document: DesktopPdfDocument,
pageIndex: Int,
displayMode: PdfDisplayMode,
pdfReaderSettings: ReaderSettings,
appThemeControls: (@Composable () -> Unit)? = null,
customReaderThemes: List<ReaderTheme>,
onCustomReaderThemesChange: (List<ReaderTheme>) -> Unit,
customTextureIds: List<String>,
onImportTexture: ((ReaderSettings) -> ReaderSettings?)?,
onReaderSettingsChange: (ReaderSettings) -> Unit,
zoomControlScale: Float,
zoomSpec: PdfZoomSpec,
isTextSelectionMode: Boolean,
selectedTool: PdfInkTool,
isRichTextMode: Boolean,
selectedColor: Int,
strokeWidth: Float,
pdfHighlighterColors: List<Int>,
pdfHighlighterPalette: SharedPdfHighlighterPalette,
isHighlighterSnapEnabled: Boolean,
effectiveTextStyleConfig: SharedPdfTextStyleConfig,
richTextController: SharedPdfRichTextController,
pdfExtrasState: ReaderExtrasState,
aiByokSettings: ReaderAiByokSettings,
externalLookupAvailable: Boolean,
cloudTtsFeatureAvailable: Boolean,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
pageText: () -> String,
onDisplayModeSelected: (PdfDisplayMode) -> Unit,
onPageScrub: (Float) -> Unit,
onPageScrubFinished: () -> Unit,
onZoomOut: () -> Unit,
onZoomIn: () -> Unit,
onZoomChange: (Float) -> Unit,
onSelectPanMode: () -> Unit,
onTextSelectionModeToggle: () -> Unit,
onRichTextModeToggle: () -> Unit,
onToolSelected: (PdfInkTool) -> Unit,
onColorSelected: (Int) -> Unit,
onStrokeWidthChange: (Float) -> Unit,
onUndoPage: () -> Unit,
onClearPage: () -> Unit,
onHighlighterSnapChange: (Boolean) -> Unit,
onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit,
onTextStyleChange: (SharedPdfTextStyleConfig) -> Unit,
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
onOpenAiHub: (() -> Unit)? = null,
onCloudTtsStart: (ReaderTtsReadScope) -> Unit,
onCloudTtsPauseResume: () -> Unit,
onCloudTtsStop: () -> Unit,
onCloudTtsClearCache: () -> Unit,
onAutoScrollChange: (ReaderAutoScrollState) -> Unit,
onCloudTtsVoiceChange: (String) -> Unit,
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit
) {
var selectedPdfInspectorTab by remember(document.handleId) { mutableStateOf(DesktopPdfInspectorTab.VIEW) }
val viewInspectorListState = rememberLazyListState()
val inspectorTabs = remember(appThemeControls != null) {
desktopPdfInspectorTabs(appThemeControlsAvailable = appThemeControls != null)
}
var selectedPdfInspectorTab by remember(document.handleId) { mutableStateOf(DesktopPdfInspectorTab.VISUAL) }
LaunchedEffect(inspectorTabs) {
if (selectedPdfInspectorTab !in inspectorTabs) {
selectedPdfInspectorTab = DesktopPdfInspectorTab.VISUAL.takeIf { it in inspectorTabs }
?: inspectorTabs.first()
}
}
val appThemeInspectorListState = rememberLazyListState()
val appearanceInspectorListState = rememberLazyListState()
val visualInspectorListState = rememberLazyListState()
val markupInspectorListState = rememberLazyListState()
val assistInspectorListState = rememberLazyListState()
val ttsInspectorListState = rememberLazyListState()
val pdfInspectorListState = when (selectedPdfInspectorTab) {
DesktopPdfInspectorTab.VIEW -> viewInspectorListState
DesktopPdfInspectorTab.APP_THEME -> appThemeInspectorListState
DesktopPdfInspectorTab.APPEARANCE -> appearanceInspectorListState
DesktopPdfInspectorTab.VISUAL -> visualInspectorListState
DesktopPdfInspectorTab.MARKUP -> markupInspectorListState
DesktopPdfInspectorTab.ASSIST -> assistInspectorListState
DesktopPdfInspectorTab.TTS -> ttsInspectorListState
}
Surface(
modifier = Modifier
.width(340.dp)
.fillMaxHeight(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(0.dp)
) {
Column(modifier = Modifier.fillMaxSize()) {
DesktopPdfInspectorHeader(
tabs = inspectorTabs,
selectedTab = selectedPdfInspectorTab,
onTabSelected = { selectedPdfInspectorTab = it }
)
HorizontalDivider()
DesktopPdfInspectorContent(
document = document,
pageIndex = pageIndex,
displayMode = displayMode,
pdfReaderSettings = pdfReaderSettings,
appThemeControls = appThemeControls,
customReaderThemes = customReaderThemes,
onCustomReaderThemesChange = onCustomReaderThemesChange,
customTextureIds = customTextureIds,
onImportTexture = onImportTexture,
onReaderSettingsChange = onReaderSettingsChange,
zoomControlScale = zoomControlScale,
zoomSpec = zoomSpec,
isTextSelectionMode = isTextSelectionMode,
selectedTool = selectedTool,
isRichTextMode = isRichTextMode,
selectedColor = selectedColor,
strokeWidth = strokeWidth,
pdfHighlighterColors = pdfHighlighterColors,
pdfHighlighterPalette = pdfHighlighterPalette,
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
effectiveTextStyleConfig = effectiveTextStyleConfig,
richTextController = richTextController,
pdfExtrasState = pdfExtrasState,
aiByokSettings = aiByokSettings,
externalLookupAvailable = externalLookupAvailable,
cloudTtsFeatureAvailable = cloudTtsFeatureAvailable,
ttsReplacementPreferences = ttsReplacementPreferences,
pageText = pageText,
selectedTab = selectedPdfInspectorTab,
listState = pdfInspectorListState,
onDisplayModeSelected = onDisplayModeSelected,
onPageScrub = onPageScrub,
onPageScrubFinished = onPageScrubFinished,
onZoomOut = onZoomOut,
onZoomIn = onZoomIn,
onZoomChange = onZoomChange,
onSelectPanMode = onSelectPanMode,
onTextSelectionModeToggle = onTextSelectionModeToggle,
onRichTextModeToggle = onRichTextModeToggle,
onToolSelected = onToolSelected,
onColorSelected = onColorSelected,
onStrokeWidthChange = onStrokeWidthChange,
onUndoPage = onUndoPage,
onClearPage = onClearPage,
onHighlighterSnapChange = onHighlighterSnapChange,
onHighlighterPaletteChange = onHighlighterPaletteChange,
onTextStyleChange = onTextStyleChange,
onExternalLookup = onExternalLookup,
onOpenAiHub = onOpenAiHub,
onCloudTtsStart = onCloudTtsStart,
onCloudTtsPauseResume = onCloudTtsPauseResume,
onCloudTtsStop = onCloudTtsStop,
onCloudTtsClearCache = onCloudTtsClearCache,
onAutoScrollChange = onAutoScrollChange,
onCloudTtsVoiceChange = onCloudTtsVoiceChange,
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange
)
}
@ -201,32 +157,33 @@ internal fun DesktopPdfInspectorPanel(
@Composable
private fun DesktopPdfInspectorHeader(
tabs: List<DesktopPdfInspectorTab>,
selectedTab: DesktopPdfInspectorTab,
onTabSelected: (DesktopPdfInspectorTab) -> Unit
) {
Column(
modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
ScrollableTabRow(
selectedTabIndex = tabs.indexOf(selectedTab).coerceAtLeast(0),
edgePadding = 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Text(readerString("desktop_pdf_tools", "PDF tools"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
ScrollableTabRow(
selectedTabIndex = selectedTab.ordinal,
edgePadding = 0.dp,
modifier = Modifier.fillMaxWidth()
) {
DesktopPdfInspectorTab.values().forEach { tab ->
Tab(
selected = selectedTab == tab,
onClick = { onTabSelected(tab) },
text = {
Text(
tab.localizedTitle(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
tabs.forEach { tab ->
Tab(
selected = selectedTab == tab,
onClick = { onTabSelected(tab) },
icon = {
Icon(
tab.icon(),
contentDescription = null
)
},
text = {
Text(
tab.localizedTitle(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
@ -234,56 +191,31 @@ private fun DesktopPdfInspectorHeader(
@Composable
private fun ColumnScope.DesktopPdfInspectorContent(
document: DesktopPdfDocument,
pageIndex: Int,
displayMode: PdfDisplayMode,
pdfReaderSettings: ReaderSettings,
appThemeControls: (@Composable () -> Unit)?,
customReaderThemes: List<ReaderTheme>,
onCustomReaderThemesChange: (List<ReaderTheme>) -> Unit,
customTextureIds: List<String>,
onImportTexture: ((ReaderSettings) -> ReaderSettings?)?,
onReaderSettingsChange: (ReaderSettings) -> Unit,
zoomControlScale: Float,
zoomSpec: PdfZoomSpec,
isTextSelectionMode: Boolean,
selectedTool: PdfInkTool,
isRichTextMode: Boolean,
selectedColor: Int,
strokeWidth: Float,
pdfHighlighterColors: List<Int>,
pdfHighlighterPalette: SharedPdfHighlighterPalette,
isHighlighterSnapEnabled: Boolean,
effectiveTextStyleConfig: SharedPdfTextStyleConfig,
richTextController: SharedPdfRichTextController,
pdfExtrasState: ReaderExtrasState,
aiByokSettings: ReaderAiByokSettings,
externalLookupAvailable: Boolean,
cloudTtsFeatureAvailable: Boolean,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
pageText: () -> String,
selectedTab: DesktopPdfInspectorTab,
listState: LazyListState,
onDisplayModeSelected: (PdfDisplayMode) -> Unit,
onPageScrub: (Float) -> Unit,
onPageScrubFinished: () -> Unit,
onZoomOut: () -> Unit,
onZoomIn: () -> Unit,
onZoomChange: (Float) -> Unit,
onSelectPanMode: () -> Unit,
onTextSelectionModeToggle: () -> Unit,
onRichTextModeToggle: () -> Unit,
onToolSelected: (PdfInkTool) -> Unit,
onColorSelected: (Int) -> Unit,
onStrokeWidthChange: (Float) -> Unit,
onUndoPage: () -> Unit,
onClearPage: () -> Unit,
onHighlighterSnapChange: (Boolean) -> Unit,
onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit,
onTextStyleChange: (SharedPdfTextStyleConfig) -> Unit,
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
onOpenAiHub: (() -> Unit)?,
onCloudTtsStart: (ReaderTtsReadScope) -> Unit,
onCloudTtsPauseResume: () -> Unit,
onCloudTtsStop: () -> Unit,
onCloudTtsClearCache: () -> Unit,
onAutoScrollChange: (ReaderAutoScrollState) -> Unit,
onCloudTtsVoiceChange: (String) -> Unit,
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit
) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
@ -296,14 +228,54 @@ private fun ColumnScope.DesktopPdfInspectorContent(
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
when (selectedTab) {
DesktopPdfInspectorTab.VIEW -> {
DesktopPdfInspectorTab.APP_THEME -> {
appThemeControls?.let { controls ->
item {
controls()
}
}
}
DesktopPdfInspectorTab.APPEARANCE -> {
item {
DesktopPdfInspectorSection(readerString("label_reading", "Reading")) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
DesktopPdfInspectorSection(readerString("desktop_pdf_theme", "PDF theme")) {
SharedReaderThemeControls(
settings = pdfReaderSettings,
builtInThemes = BuiltInPdfReaderThemes,
customThemes = customReaderThemes,
onCustomThemesChange = onCustomReaderThemesChange,
customTextureIds = customTextureIds,
onImportTexture = onImportTexture,
texturePreviewContent = { textureId, previewModifier ->
DesktopReaderTexturePreview(textureId = textureId, modifier = previewModifier)
},
onSettingsChange = onReaderSettingsChange
)
}
}
}
DesktopPdfInspectorTab.VISUAL -> {
item {
DesktopPdfInspectorSection(readerString("visual_options_title", "Visual options")) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.horizontalScroll(rememberScrollState())
) {
FilterChip(
selected = displayMode == PdfDisplayMode.PAGINATION,
onClick = { onDisplayModeSelected(PdfDisplayMode.PAGINATION) },
label = { Text(readerString("desktop_page", "Page")) }
selected = displayMode == PdfDisplayMode.PAGINATION && !pdfReaderSettings.rightToLeftPagination,
onClick = {
onReaderSettingsChange(pdfReaderSettings.copy(rightToLeftPagination = false))
onDisplayModeSelected(PdfDisplayMode.PAGINATION)
},
label = { Text(readerString("menu_reading_mode_paginated", "Paginated (left-to-right)")) }
)
FilterChip(
selected = displayMode == PdfDisplayMode.PAGINATION && pdfReaderSettings.rightToLeftPagination,
onClick = {
onReaderSettingsChange(pdfReaderSettings.copy(rightToLeftPagination = true))
onDisplayModeSelected(PdfDisplayMode.PAGINATION)
},
label = { Text(readerString("menu_right_to_left_pagination", "Paginated (right-to-left)")) }
)
FilterChip(
selected = displayMode == PdfDisplayMode.VERTICAL_SCROLL,
@ -348,53 +320,11 @@ private fun ColumnScope.DesktopPdfInspectorContent(
)
}
}
}
}
item {
DesktopPdfInspectorSection(readerString("visual_options_progress_bar_position", "Position")) {
val pageRange = if (displayMode == PdfDisplayMode.PAGINATION) {
PdfSpreadLayout.pageRangeLabel(pageIndex, document.pageCount, pdfReaderSettings)
} else {
"${pageIndex + 1}"
}
Text(
if ('-' in pageRange) {
readerString("desktop_pdf_pages_of_count", "Pages %1\$s of %2\$d", pageRange, document.pageCount)
} else {
readerString("desktop_pdf_page_of_count", "Page %1\$s of %2\$d", pageRange, document.pageCount)
},
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (document.pageCount > 1) {
ReaderMinimalSlider(
value = pageIndex.toFloat(),
onValueChange = onPageScrub,
onValueChangeFinished = onPageScrubFinished,
valueRange = 0f..(document.pageCount - 1).toFloat()
)
}
}
}
item {
DesktopPdfInspectorSection(readerString("app_theme_appearance", "Appearance")) {
SharedReaderThemeControls(
settings = pdfReaderSettings,
builtInThemes = BuiltInPdfReaderThemes,
customTextureIds = customTextureIds,
onImportTexture = onImportTexture,
onSettingsChange = onReaderSettingsChange
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
readerString("visual_options_title", "Visual options"),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
DesktopPdfVisualOptionSwitch(
title = readerString("visual_options_remove_page_gap", "Remove gap between pages"),
description = readerString(
"desktop_remove_gap_between_pages_desc",
"Applies to vertical reading mode."
"Applies to vertical reading and two-page spreads."
),
checked = !pdfReaderSettings.pdfVerticalPageGapVisible,
onCheckedChange = { removeGap ->
@ -418,43 +348,11 @@ private fun ColumnScope.DesktopPdfInspectorContent(
)
}
}
item {
DesktopPdfInspectorSection(readerString("desktop_zoom", "Zoom")) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onZoomOut) {
Icon(Icons.Default.ZoomOut, contentDescription = readerString("desktop_zoom_out", "Zoom out"))
}
Text(
"${(zoomControlScale * 100).toInt()}%",
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center
)
IconButton(onClick = onZoomIn) {
Icon(Icons.Default.ZoomIn, contentDescription = readerString("desktop_zoom_in", "Zoom in"))
}
}
Slider(
value = zoomControlScale,
onValueChange = onZoomChange,
valueRange = zoomSpec.min..zoomSpec.max
)
}
}
}
DesktopPdfInspectorTab.MARKUP -> {
item {
DesktopPdfInspectorSection(readerString("desktop_interaction", "Interaction")) {
DesktopPdfInspectorSection(readerString("desktop_document_text", "Document text")) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
FilterChip(
selected = !isTextSelectionMode && selectedTool == PdfInkTool.NONE && !isRichTextMode,
onClick = onSelectPanMode,
label = { Text(readerString("desktop_pan", "Pan")) }
)
FilterChip(
selected = isTextSelectionMode,
onClick = onTextSelectionModeToggle,
label = { Text(readerString("desktop_select_text", "Select text")) }
)
FilterChip(
selected = isRichTextMode,
onClick = onRichTextModeToggle,
@ -463,24 +361,6 @@ private fun ColumnScope.DesktopPdfInspectorContent(
}
}
}
item {
DesktopPdfInspectorSection(readerString("desktop_annotation_tools", "Annotation tools")) {
SharedPdfAnnotationToolDock(
selectedTool = selectedTool,
selectedColor = selectedColor,
strokeWidth = strokeWidth,
tools = DesktopPdfAnnotationTools,
highlighterPalette = pdfHighlighterColors,
onToolSelected = onToolSelected,
onColorSelected = onColorSelected,
onStrokeWidthChange = onStrokeWidthChange,
onUndo = onUndoPage,
onClearPage = onClearPage,
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
onHighlighterSnapChange = onHighlighterSnapChange
)
}
}
item {
DesktopPdfInspectorSection(readerString("desktop_highlighter_palette", "Highlighter palette")) {
SharedPdfHighlighterPaletteEditor(
@ -510,21 +390,14 @@ private fun ColumnScope.DesktopPdfInspectorContent(
}
}
}
DesktopPdfInspectorTab.ASSIST -> {
DesktopPdfInspectorTab.TTS -> {
item {
DesktopPdfExtrasPanel(
pageText = pageText(),
DesktopPdfTtsPanel(
extrasState = pdfExtrasState,
aiByokSettings = aiByokSettings,
externalLookupAvailable = externalLookupAvailable,
cloudTtsFeatureAvailable = cloudTtsFeatureAvailable,
onExternalLookup = onExternalLookup,
onOpenAiHub = onOpenAiHub,
onCloudTtsStart = onCloudTtsStart,
onCloudTtsPauseResume = onCloudTtsPauseResume,
onCloudTtsStop = onCloudTtsStop,
onCloudTtsClearCache = onCloudTtsClearCache,
onAutoScrollChange = onAutoScrollChange,
onCloudTtsVoiceChange = onCloudTtsVoiceChange,
ttsReplacementPreferences = ttsReplacementPreferences,
ttsReplacementBookId = document.path,
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange
@ -543,8 +416,29 @@ private fun ColumnScope.DesktopPdfInspectorContent(
@Composable
private fun DesktopPdfInspectorTab.localizedTitle(): String {
return when (this) {
DesktopPdfInspectorTab.VIEW -> readerString("desktop_view", "View")
DesktopPdfInspectorTab.APP_THEME -> readerString("app_theme_title", "App theme")
DesktopPdfInspectorTab.APPEARANCE -> readerString("desktop_pdf_theme", "PDF theme")
DesktopPdfInspectorTab.VISUAL -> readerString("visual_options_title", "Visual")
DesktopPdfInspectorTab.MARKUP -> readerString("desktop_markup", "Markup")
DesktopPdfInspectorTab.ASSIST -> readerString("desktop_assist", "Assist")
DesktopPdfInspectorTab.TTS -> readerString("menu_tts_settings", "TTS")
}
}
private fun DesktopPdfInspectorTab.icon(): ImageVector {
return when (this) {
DesktopPdfInspectorTab.APP_THEME -> Icons.Default.Palette
DesktopPdfInspectorTab.APPEARANCE -> Icons.Default.Palette
DesktopPdfInspectorTab.VISUAL -> Icons.Default.Tune
DesktopPdfInspectorTab.MARKUP -> Icons.Default.Edit
DesktopPdfInspectorTab.TTS -> Icons.AutoMirrored.Filled.VolumeUp
}
}
private fun desktopPdfInspectorTabs(appThemeControlsAvailable: Boolean): List<DesktopPdfInspectorTab> {
return buildList {
add(DesktopPdfInspectorTab.APPEARANCE)
if (appThemeControlsAvailable) add(DesktopPdfInspectorTab.APP_THEME)
add(DesktopPdfInspectorTab.VISUAL)
add(DesktopPdfInspectorTab.TTS)
}
}

View file

@ -23,7 +23,8 @@ internal enum class DesktopPdfKeyCommand {
internal fun KeyEvent.desktopPdfKeyCommandOrNull(
fullscreen: Boolean,
editingText: Boolean
editingText: Boolean,
rightToLeftPagination: Boolean = false
): DesktopPdfKeyCommand? {
if (type != KeyEventType.KeyDown) return null
if (fullscreen && key == Key.Escape) {
@ -33,8 +34,16 @@ internal fun KeyEvent.desktopPdfKeyCommandOrNull(
return null
}
return when {
key == Key.DirectionLeft -> DesktopPdfKeyCommand.PREVIOUS_PAGE
key == Key.DirectionRight -> DesktopPdfKeyCommand.NEXT_PAGE
key == Key.DirectionLeft -> if (rightToLeftPagination) {
DesktopPdfKeyCommand.NEXT_PAGE
} else {
DesktopPdfKeyCommand.PREVIOUS_PAGE
}
key == Key.DirectionRight -> if (rightToLeftPagination) {
DesktopPdfKeyCommand.PREVIOUS_PAGE
} else {
DesktopPdfKeyCommand.NEXT_PAGE
}
key == Key.DirectionUp -> DesktopPdfKeyCommand.SCROLL_UP
key == Key.DirectionDown -> DesktopPdfKeyCommand.SCROLL_DOWN
key == Key.PageUp -> DesktopPdfKeyCommand.PREVIOUS_PAGE
@ -50,7 +59,8 @@ internal fun KeyEvent.desktopPdfKeyCommandOrNull(
internal fun AwtKeyEvent.desktopPdfKeyCommandOrNull(
fullscreen: Boolean,
editingText: Boolean
editingText: Boolean,
rightToLeftPagination: Boolean = false
): DesktopPdfKeyCommand? {
if (id != AwtKeyEvent.KEY_PRESSED) return null
if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) {
@ -60,8 +70,16 @@ internal fun AwtKeyEvent.desktopPdfKeyCommandOrNull(
return null
}
return when (keyCode) {
AwtKeyEvent.VK_LEFT -> DesktopPdfKeyCommand.PREVIOUS_PAGE
AwtKeyEvent.VK_RIGHT -> DesktopPdfKeyCommand.NEXT_PAGE
AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) {
DesktopPdfKeyCommand.NEXT_PAGE
} else {
DesktopPdfKeyCommand.PREVIOUS_PAGE
}
AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) {
DesktopPdfKeyCommand.PREVIOUS_PAGE
} else {
DesktopPdfKeyCommand.NEXT_PAGE
}
AwtKeyEvent.VK_UP -> DesktopPdfKeyCommand.SCROLL_UP
AwtKeyEvent.VK_DOWN -> DesktopPdfKeyCommand.SCROLL_DOWN
AwtKeyEvent.VK_PAGE_UP -> DesktopPdfKeyCommand.PREVIOUS_PAGE

View file

@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@ -38,9 +39,11 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface
@ -59,6 +62,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -66,7 +70,6 @@ import com.aryan.reader.shared.PdfTocEntry
import com.aryan.reader.shared.pdf.PdfAnnotationKind
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.shared.pdf.SharedPdfBookmark
import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation
import com.aryan.reader.shared.ui.SharedReaderVerticalScrollbar
import com.aryan.reader.shared.ui.readerString
import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll
@ -199,27 +202,28 @@ internal fun desktopVisiblePdfTocEntries(
return result
}
internal fun desktopPdfSidebarHighlights(annotations: List<SharedPdfAnnotation>): List<SharedPdfAnnotation> {
return annotations
.filter { it.kind == PdfAnnotationKind.HIGHLIGHT }
.sortedBy { it.pageIndex }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DesktopPdfNavigationSidebar(
document: DesktopPdfDocument,
pageIndex: Int,
sortedAnnotations: List<SharedPdfAnnotation>,
sortedEmbeddedAnnotations: List<SharedPdfEmbeddedAnnotation>,
sortedHighlights: List<SharedPdfAnnotation>,
bookmarks: List<SharedPdfBookmark>,
selectedAnnotationId: String?,
selectedEmbeddedAnnotationId: String?,
onPageSelected: (Int) -> Unit,
onAnnotationOpened: (SharedPdfAnnotation) -> Unit,
onAnnotationSelected: (SharedPdfAnnotation) -> Unit,
onAnnotationDeleted: (SharedPdfAnnotation) -> Unit,
onEmbeddedAnnotationOpened: (SharedPdfEmbeddedAnnotation) -> Unit,
onEmbeddedAnnotationSelected: (SharedPdfEmbeddedAnnotation) -> Unit
onAnnotationDeleted: (SharedPdfAnnotation) -> Unit
) {
val documentHandleId = document.handleId
val tabs = listOf(
readerString("desktop_toc", "TOC"),
readerString("tab_annotations", "Annotations"),
readerString("tab_highlights", "Highlights"),
readerString("tab_bookmarks", "Bookmarks"),
readerString("tab_pages", "Pages")
)
@ -344,180 +348,158 @@ internal fun DesktopPdfNavigationSidebar(
}
}
1 -> {
if (sortedAnnotations.isEmpty() && sortedEmbeddedAnnotations.isEmpty()) {
DesktopPdfNavigationEmpty(readerString("desktop_no_annotations_yet", "No annotations yet"))
if (sortedHighlights.isEmpty()) {
DesktopPdfNavigationEmpty(readerString("no_highlights_yet", "No highlights yet"))
} else {
val annotationsListState = rememberLazyListState()
var annotationMenuExpandedFor by remember { mutableStateOf<SharedPdfAnnotation?>(null) }
var embeddedAnnotationMenuExpandedFor by remember { mutableStateOf<SharedPdfEmbeddedAnnotation?>(null) }
var deleteAnnotationConfirmFor by remember { mutableStateOf<SharedPdfAnnotation?>(null) }
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = annotationsListState,
modifier = Modifier
.fillMaxSize()
.sharedAcceleratedLazyWheelScroll(annotationsListState)
.padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(sortedAnnotations, key = { "nav_annotation_${it.id}" }) { annotation ->
Surface(
color = if (annotation.id == selectedAnnotationId) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
.weight(1f)
.clickable { onAnnotationOpened(annotation) }
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(
annotation.desktopLabel(),
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
annotation.note?.takeIf { it.isNotBlank() }?.let { note ->
Text(
note,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Box {
IconButton(onClick = { annotationMenuExpandedFor = annotation }) {
Icon(Icons.Default.MoreVert, contentDescription = readerString("desktop_annotation_options", "Annotation options"))
}
DropdownMenu(
expanded = annotationMenuExpandedFor == annotation,
onDismissRequest = { annotationMenuExpandedFor = null }
) {
DropdownMenuItem(
text = {
Text(
if (annotation.note.isNullOrBlank() &&
annotation.kind != PdfAnnotationKind.TEXT
) {
readerString("menu_add_note", "Add note")
} else {
readerString("action_edit", "Edit")
}
)
},
onClick = {
annotationMenuExpandedFor = null
onAnnotationSelected(annotation)
}
)
DropdownMenuItem(
text = { Text(readerString("action_delete", "Delete")) },
onClick = {
annotationMenuExpandedFor = null
deleteAnnotationConfirmFor = annotation
}
)
}
}
}
}
}
items(sortedEmbeddedAnnotations, key = { "nav_embedded_${it.id}" }) { annotation ->
Surface(
color = if (annotation.id == selectedEmbeddedAnnotationId) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
.weight(1f)
.clickable { onEmbeddedAnnotationOpened(annotation) }
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(
annotation.author.ifBlank { readerString("desktop_pdf_comment", "PDF comment") },
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
annotation.contents.takeIf { it.isNotBlank() }?.let { contents ->
Text(
contents,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Box {
IconButton(onClick = { embeddedAnnotationMenuExpandedFor = annotation }) {
Icon(Icons.Default.MoreVert, contentDescription = readerString("desktop_comment_options", "Comment options"))
}
DropdownMenu(
expanded = embeddedAnnotationMenuExpandedFor == annotation,
onDismissRequest = { embeddedAnnotationMenuExpandedFor = null }
) {
DropdownMenuItem(
text = { Text(readerString("desktop_open_comment", "Open comment")) },
onClick = {
embeddedAnnotationMenuExpandedFor = null
onEmbeddedAnnotationSelected(annotation)
}
)
}
}
}
}
}
val highlightsListState = rememberLazyListState()
var deleteHighlightConfirmFor by remember { mutableStateOf<SharedPdfAnnotation?>(null) }
var filterWithNotesOnly by remember { mutableStateOf(false) }
val filteredHighlights = remember(sortedHighlights, filterWithNotesOnly) {
if (filterWithNotesOnly) {
sortedHighlights.filter { !it.note.isNullOrBlank() }
} else {
sortedHighlights
}
SharedReaderVerticalScrollbar(
listState = annotationsListState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
deleteAnnotationConfirmFor?.let { annotation ->
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = !filterWithNotesOnly,
onClick = { filterWithNotesOnly = false },
label = { Text(readerString("read_status_all", "All")) }
)
FilterChip(
selected = filterWithNotesOnly,
onClick = { filterWithNotesOnly = true },
label = { Text(readerString("filter_with_notes", "With notes")) }
)
}
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
LazyColumn(
state = highlightsListState,
modifier = Modifier
.fillMaxSize()
.sharedAcceleratedLazyWheelScroll(highlightsListState)
.padding(end = 12.dp)
) {
items(filteredHighlights, key = { "nav_highlight_${it.id}" }) { highlight ->
ListItem(
headlineContent = {
Text(
text = highlight.text.ifBlank {
readerString(
"msg_highlighted_section_default",
"Highlighted section"
)
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold
)
},
supportingContent = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(12.dp)
.background(Color(highlight.colorArgb).copy(alpha = 1f), CircleShape)
)
Spacer(Modifier.width(8.dp))
Text(
readerString("pdf_page_short", "Page %1\$d", highlight.pageIndex + 1),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
highlight.note?.takeIf { it.isNotBlank() }?.let { note ->
Spacer(Modifier.height(8.dp))
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = note,
style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
},
trailingContent = {
Box {
var highlightMenuExpanded by remember(highlight.id) { mutableStateOf(false) }
IconButton(onClick = { highlightMenuExpanded = true }) {
Icon(
Icons.Default.MoreVert,
contentDescription = readerString("content_desc_options", "Options")
)
}
DropdownMenu(
expanded = highlightMenuExpanded,
onDismissRequest = { highlightMenuExpanded = false }
) {
DropdownMenuItem(
text = {
Text(
if (highlight.note.isNullOrBlank()) {
readerString("menu_add_note", "Add note")
} else {
readerString("menu_edit_note", "Edit note")
}
)
},
onClick = {
onAnnotationSelected(highlight)
highlightMenuExpanded = false
}
)
DropdownMenuItem(
text = { Text(readerString("action_delete", "Delete")) },
onClick = {
deleteHighlightConfirmFor = highlight
highlightMenuExpanded = false
}
)
}
}
},
modifier = Modifier.clickable { onAnnotationOpened(highlight) }
)
HorizontalDivider()
}
}
SharedReaderVerticalScrollbar(
listState = highlightsListState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
deleteHighlightConfirmFor?.let { highlight ->
AlertDialog(
onDismissRequest = { deleteAnnotationConfirmFor = null },
title = { Text(readerString("desktop_delete_annotation_title", "Delete annotation?")) },
text = { Text(readerString("desktop_delete_annotation_desc", "This removes the annotation from this PDF.")) },
onDismissRequest = { deleteHighlightConfirmFor = null },
title = { Text(readerString("dialog_delete_highlight", "Delete highlight?")) },
text = { Text(readerString("dialog_delete_highlight_desc", "This removes the highlight from this PDF.")) },
confirmButton = {
TextButton(
onClick = {
deleteAnnotationConfirmFor = null
onAnnotationDeleted(annotation)
onAnnotationDeleted(highlight)
deleteHighlightConfirmFor = null
}
) {
Text(readerString("action_delete", "Delete"), color = MaterialTheme.colorScheme.error)
Text(readerString("action_delete", "Delete"))
}
},
dismissButton = {
TextButton(onClick = { deleteAnnotationConfirmFor = null }) {
TextButton(onClick = { deleteHighlightConfirmFor = null }) {
Text(readerString("action_cancel", "Cancel"))
}
}

View file

@ -1,5 +1,7 @@
package com.aryan.reader.desktop
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
@ -67,6 +69,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
private const val DesktopVerticalPdfPageTurnAnimationMillis = 140
@Composable
internal fun DesktopVerticalPdfPage(
document: DesktopPdfDocument,
@ -96,6 +100,8 @@ internal fun DesktopVerticalPdfPage(
themeStyle: DesktopPdfThemeStyle,
shouldRender: Boolean,
zoomPreview: DesktopPdfZoomPreview?,
zoomPreviewAnchorPageRootOffset: Offset? = null,
zoomPreviewScrollBounds: DesktopPdfZoomScrollBounds? = null,
zoomViewportRootOffset: Offset,
showPageNumberOverlay: Boolean = true,
onSelectPage: (Int) -> Unit,
@ -116,13 +122,16 @@ internal fun DesktopVerticalPdfPage(
onTextDraftChanged: (String, IntSize) -> Unit,
onTextDraftBoundsChanged: (PdfPageBounds) -> Unit,
onPan: (Offset) -> Unit,
onPageSizeChanged: (Int, IntSize) -> Unit = { _, _ -> },
onPagePositioned: (Int, Offset) -> Unit
) {
val documentHandleId = document.handleId
val density = LocalDensity.current
var renderedPage by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfPageRender?>(null) }
var renderError by remember(documentHandleId, pageIndex) { mutableStateOf<String?>(null) }
var isRendering by remember(documentHandleId, pageIndex) { mutableStateOf(true) }
var renderedPage by remember(documentHandleId) { mutableStateOf<DesktopPdfPageRender?>(null) }
var renderedPageIndex by remember(documentHandleId) { mutableStateOf<Int?>(null) }
var renderedPageScale by remember(documentHandleId) { mutableStateOf<Float?>(null) }
var renderError by remember(documentHandleId) { mutableStateOf<String?>(null) }
var isRendering by remember(documentHandleId) { mutableStateOf(true) }
var pageCanvasSize by remember(documentHandleId, pageIndex) { mutableStateOf(IntSize.Zero) }
var pageRootOffset by remember(documentHandleId, pageIndex) { mutableStateOf(Offset.Zero) }
var selectionStartIndex by remember(documentHandleId, pageIndex) { mutableStateOf<Int?>(null) }
@ -156,34 +165,73 @@ internal fun DesktopVerticalPdfPage(
LaunchedEffect(documentHandleId, pageIndex, scale, shouldRender) {
if (!shouldRender) {
logPdfZoomSettle {
"item_render_skip page=${pageIndex + 1} reason=outside_window scale=${scale.formatLogFloat()}"
}
renderedPage = null
renderedPageIndex = null
renderedPageScale = null
renderError = null
isRendering = false
clearInteractionState()
return@LaunchedEffect
}
val hasPageRender = renderedPage != null
val hasPageRender = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex)
logPdfZoomSettle {
"item_render_effect page=${pageIndex + 1} scale=${scale.formatLogFloat()} shouldRender=$shouldRender " +
"hasRender=$hasPageRender renderedPage=${renderedPageIndex?.plus(1) ?: "none"} " +
"renderScale=${renderedPageScale?.formatLogFloat() ?: "none"}"
}
if (!hasPageRender) {
renderedPage = null
renderedPageIndex = null
renderedPageScale = null
isRendering = true
}
renderError = null
val pageSize = document.pageSizes.getOrNull(pageIndex)
if (pageSize == null) {
renderedPage = null
renderedPageIndex = null
renderedPageScale = null
renderError = failedRenderMessage
isRendering = false
return@LaunchedEffect
}
val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
if (hasPageRender && !desktopPdfRenderScaleNeedsUpgrade(renderedPageScale, safeScale)) {
logPdfZoomSettle {
"item_render_skip page=${pageIndex + 1} reason=no_scale_upgrade " +
"safeScale=${safeScale.formatLogFloat()} existingScale=${renderedPageScale?.formatLogFloat() ?: "none"}"
}
isRendering = false
return@LaunchedEffect
}
logPdfZoomSettle {
"item_render_scheduled page=${pageIndex + 1} safeScale=${safeScale.formatLogFloat()} " +
"delayMs=${if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L} hasRender=$hasPageRender"
}
delay(if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L)
isRendering = true
val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
val renderStartedAt = System.currentTimeMillis()
val result = withContext(Dispatchers.IO) {
runCatching { DesktopPdfium.renderPage(document, pageIndex, safeScale) }
}
result.getOrNull()?.let { renderedPage = it }
val renderElapsedMs = System.currentTimeMillis() - renderStartedAt
result.getOrNull()?.let {
renderedPage = it
renderedPageIndex = pageIndex
renderedPageScale = safeScale
}
val renderedCurrentPage = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex)
renderError = result.exceptionOrNull()?.message
?: if (renderedPage == null) failedRenderMessage else null
?: if (!renderedCurrentPage && renderedPage == null) failedRenderMessage else null
isRendering = false
logPdfZoomSettle {
"item_render_end page=${pageIndex + 1} safeScale=${safeScale.formatLogFloat()} " +
"elapsedMs=$renderElapsedMs success=${result.isSuccess} bitmap=${renderedPage?.width ?: 0}x${renderedPage?.height ?: 0} " +
"canvas=${pageCanvasSize.formatLogSize()} root=${pageRootOffset.formatLogOffset()}"
}
}
LaunchedEffect(isTextSelectionMode) {
@ -205,6 +253,8 @@ internal fun DesktopVerticalPdfPage(
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
val pageSize = document.pageSizes.getOrNull(pageIndex)
val displayPageIndex = renderedPageIndex ?: pageIndex
val displayPageIsCurrent = displayPageIndex == pageIndex
val placeholderScale = zoomSpec.clamp(scale)
val placeholderWidthDp = with(density) { ((pageSize?.width ?: 612f) * placeholderScale).toDp() }
val placeholderHeightDp = with(density) { ((pageSize?.height ?: 792f) * placeholderScale).toDp() }
@ -224,24 +274,60 @@ internal fun DesktopVerticalPdfPage(
.size(placeholderWidthDp, placeholderHeightDp)
.onGloballyPositioned { coordinates ->
val rootOffset = coordinates.positionInRoot()
if (rootOffset != pageRootOffset) {
logPdfZoomSettle {
"item_layout page=${pageIndex + 1} prevRoot=${pageRootOffset.formatLogOffset()} " +
"nextRoot=${rootOffset.formatLogOffset()} scale=${scale.formatLogFloat()} " +
"preview=${zoomPreview != null} canvas=${pageCanvasSize.formatLogSize()}"
}
}
pageRootOffset = rootOffset
onPagePositioned(pageIndex, rootOffset)
}
.onSizeChanged { pageCanvasSize = it }
.onSizeChanged { size ->
if (pageCanvasSize != size) {
logPdfZoomSettle {
"item_size page=${pageIndex + 1} prev=${pageCanvasSize.formatLogSize()} " +
"next=${size.formatLogSize()} scale=${scale.formatLogFloat()} " +
"preview=${zoomPreview != null} renderScale=${pageRenderScale.formatLogFloat()}"
}
}
pageCanvasSize = size
onPageSizeChanged(pageIndex, size)
}
.desktopPdfDocumentZoomPreviewLayer(
preview = zoomPreview,
currentZoom = scale,
viewportRootOffset = zoomViewportRootOffset,
pageRootOffset = pageRootOffset
pageRootOffset = pageRootOffset,
anchorPageRootOffset = zoomPreviewAnchorPageRootOffset,
scrollBounds = zoomPreviewScrollBounds
)
.background(themeStyle.pageBackgroundColor, RoundedCornerShape(2.dp))
.pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, selectedTool, isRichTextMode) {
if (isRichTextMode) return@pointerInput
.pointerInput(
pageIndex,
displayPageIsCurrent,
pageCanvasSize,
isTextSelectionMode,
selectedTool,
isRichTextMode
) {
if (!displayPageIsCurrent || isRichTextMode) return@pointerInput
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val point = event.changes.firstOrNull()?.position ?: continue
if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) {
if (isTextSelectionMode) {
logPdfChromeTap {
"page_press source=vertical_page page=${pageIndex + 1} " +
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " +
"consumedBefore=${event.changes.any { it.isConsumed }} " +
"selectionActive=${currentTextSelection != null} " +
"selectionMenuOpen=${selectionMenuOffset != null} " +
"selectedTool=$selectedTool richText=$isRichTextMode"
}
}
val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) {
currentAnnotations.asReversed().firstOrNull {
it.isDesktopTextSelectionHighlight &&
@ -252,6 +338,10 @@ internal fun DesktopVerticalPdfPage(
null
}
if (highlightHit != null) {
logPdfChromeTap {
"page_press_consume source=vertical_page page=${pageIndex + 1} " +
"reason=text_selection_highlight annotation=${highlightHit.id}"
}
onSelectPage(pageIndex)
onAnnotationSelected(highlightHit)
clearInteractionState()
@ -261,6 +351,10 @@ internal fun DesktopVerticalPdfPage(
if (selectedTool != PdfInkTool.TEXT) {
val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize)
if (linkTarget != null) {
logPdfChromeTap {
"page_press_consume source=vertical_page page=${pageIndex + 1} " +
"reason=link target=${linkTarget.formatLogTarget()}"
}
logPdfLink(
"tap_hit mode=vertical page=${pageIndex + 1} " +
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " +
@ -277,6 +371,10 @@ internal fun DesktopVerticalPdfPage(
it.sharedPdfEmbeddedHitTest(point, pageCanvasSize)
}
if (embeddedHit != null) {
logPdfChromeTap {
"page_press_consume source=vertical_page page=${pageIndex + 1} " +
"reason=embedded_annotation annotation=${embeddedHit.id}"
}
onSelectPage(pageIndex)
onEmbeddedAnnotationSelected(embeddedHit)
clearInteractionState()
@ -285,7 +383,16 @@ internal fun DesktopVerticalPdfPage(
currentTextSelection != null &&
selectionMenuOffset == null
) {
logPdfChromeTap {
"page_press_passthrough source=vertical_page page=${pageIndex + 1} " +
"action=clear_selection consumed=false"
}
clearSelection()
} else if (isTextSelectionMode) {
logPdfChromeTap {
"page_press_passthrough source=vertical_page page=${pageIndex + 1} " +
"action=none consumed=false"
}
}
} else if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) {
val selection = currentTextSelection
@ -304,33 +411,41 @@ internal fun DesktopVerticalPdfPage(
}
}
}
.pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, isRichTextMode) {
if (isRichTextMode || !isTextSelectionMode) return@pointerInput
detectTapGestures(
onLongPress = { point ->
val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize)
if (selection != null) {
onSelectPage(pageIndex)
selectionStartIndex = null
selectionEndIndex = null
selectionStartHit = null
selectionEndHit = null
activeSelectionHandle = null
textSelection = selection
selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point)
logPdfSelection(
"long_press page=${pageIndex + 1} " +
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " +
"range=${selection.startIndex}..${selection.endIndex} " +
"chars=${selection.text.length} " +
"text=\"${selection.text.logPreview()}\""
)
}
.pointerInput(pageIndex, displayPageIsCurrent, pageCanvasSize, isTextSelectionMode, isRichTextMode) {
if (!displayPageIsCurrent || isRichTextMode || !isTextSelectionMode) return@pointerInput
detectDesktopPdfTextSelectionLongPress(
source = "vertical_page",
pageIndex = pageIndex
) { point ->
val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize)
logPdfChromeTap {
"long_press_selection source=vertical_page page=${pageIndex + 1} " +
"selectionFound=${selection != null} " +
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()}"
}
)
if (selection != null) {
onSelectPage(pageIndex)
selectionStartIndex = null
selectionEndIndex = null
selectionStartHit = null
selectionEndHit = null
activeSelectionHandle = null
textSelection = selection
selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point)
logPdfSelection(
"long_press page=${pageIndex + 1} " +
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " +
"range=${selection.startIndex}..${selection.endIndex} " +
"chars=${selection.text.length} " +
"text=\"${selection.text.logPreview()}\""
)
}
}
}
.pointerInput(pageIndex, selectedTool, isTextSelectionMode, isRichTextMode) {
if (isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) return@pointerInput
.pointerInput(pageIndex, displayPageIsCurrent, selectedTool, isTextSelectionMode, isRichTextMode) {
if (!displayPageIsCurrent || isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) {
return@pointerInput
}
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture
@ -371,9 +486,10 @@ internal fun DesktopVerticalPdfPage(
isRichTextMode,
pageCanvasSize,
renderedPageWidth,
renderedPageHeight
renderedPageHeight,
displayPageIsCurrent
) {
if (renderedPageWidth > 0 && renderedPageHeight > 0) {
if (displayPageIsCurrent && renderedPageWidth > 0 && renderedPageHeight > 0) {
if (isRichTextMode) return@pointerInput
if (isTextSelectionMode) {
var latestSelectionDragPoint: Offset? = null
@ -638,8 +754,21 @@ internal fun DesktopVerticalPdfPage(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
renderedPage != null -> {
renderError != null && renderedPageIndex != pageIndex -> Text(
renderError ?: readerString("desktop_failed_render_page", "Failed to render page."),
color = MaterialTheme.colorScheme.error
)
renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) -> {
val currentRenderedPageIndex = renderedPageIndex!!
Crossfade(
targetState = currentRenderedPageIndex,
animationSpec = tween(DesktopVerticalPdfPageTurnAnimationMillis),
label = "DesktopVerticalPdfPage"
) { pageIndex ->
val pageRender = renderedPage!!
val pageEmbeddedAnnotations = remember(document.embeddedAnnotations, pageIndex) {
document.embeddedAnnotations.filter { it.pageIndex == pageIndex }
}
val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) {
annotations
.filter { it.pageIndex == pageIndex }
@ -806,6 +935,10 @@ internal fun DesktopVerticalPdfPage(
.matchParentSize()
.pointerInput(pageIndex, selectionMenuOffset) {
detectTapGestures {
logPdfChromeTap {
"selection_menu_scrim_tap source=vertical_page page=${pageIndex + 1} " +
"consumedByScrim=true"
}
clearSelection()
}
}
@ -842,6 +975,7 @@ internal fun DesktopVerticalPdfPage(
showSearch = externalLookupAvailable,
onClear = ::clearSelection
)
}
}
isRendering -> CircularProgressIndicator()
renderError != null -> Text(

View file

@ -1,6 +1,11 @@
package com.aryan.reader.desktop
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.unit.IntSize
import com.aryan.reader.shared.pdf.PdfAnnotationKind
import com.aryan.reader.shared.pdf.PdfInkTool
@ -9,11 +14,17 @@ import com.aryan.reader.shared.pdf.PdfPageBounds
import com.aryan.reader.shared.pdf.PdfPagePoint
import com.aryan.reader.shared.pdf.PdfSelectionGeometry
import com.aryan.reader.shared.pdf.PdfTextCharBounds
import com.aryan.reader.shared.pdf.PdfZoomSpec
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.shared.pdf.SharedPdfInkRenderer
import com.aryan.reader.shared.pdf.SharedPdfReaderAction
import com.aryan.reader.shared.pdf.SharedPdfReaderState
import com.aryan.reader.shared.pdf.SharedPdfTextDraft
import com.aryan.reader.shared.pdf.reduce
import com.aryan.reader.shared.ui.sharedPdfHitTest
import com.aryan.reader.shared.ui.toSharedPdfPoint
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
internal val PdfInkTool.isDesktopHighlighter: Boolean
get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND
@ -24,6 +35,24 @@ internal val SharedPdfAnnotation.isDesktopTextSelectionHighlight: Boolean
rangeStartIndex != null &&
rangeEndIndex != null
internal fun SharedPdfReaderState.withDesktopPdfTextSelectionHighlightAdded(
annotation: SharedPdfAnnotation,
zoomSpec: PdfZoomSpec = PdfZoomSpec()
): SharedPdfReaderState {
val next = reduce(SharedPdfReaderAction.AnnotationAdded(annotation), zoomSpec)
return if (annotation.isDesktopTextSelectionHighlight) {
next.reduce(SharedPdfReaderAction.AnnotationSelected(null), zoomSpec)
} else {
next
}
}
internal fun SharedPdfReaderState.withDesktopPdfTextHighlightSheetDismissed(
zoomSpec: PdfZoomSpec = PdfZoomSpec()
): SharedPdfReaderState {
return reduce(SharedPdfReaderAction.AnnotationSelected(null), zoomSpec)
}
internal fun List<PdfPagePoint>.withDesktopPdfDragPoint(
point: Offset,
canvasSize: IntSize,
@ -46,6 +75,95 @@ internal fun List<PdfPagePoint>.withDesktopPdfDragPoint(
return this + nextPoint
}
internal suspend fun PointerInputScope.detectDesktopPdfTextSelectionLongPress(
source: String,
pageIndex: Int,
onLongPress: (Offset) -> Unit
) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val secondaryDown = currentEvent.buttons.isSecondaryPressed
logPdfChromeTap {
"long_press_down source=$source page=${pageIndex + 1} " +
"x=${down.position.x.formatLogFloat()} y=${down.position.y.formatLogFloat()} " +
"downConsumed=${down.isConsumed} secondary=$secondaryDown"
}
if (down.isConsumed || secondaryDown) {
logPdfChromeTap {
"long_press_skip source=$source page=${pageIndex + 1} " +
"reason=${if (down.isConsumed) "down_consumed" else "secondary_button"}"
}
return@awaitEachGesture
}
val pointerId = down.id
val start = down.position
var latestPosition = start
var canceledBeforeLongPress = false
var longPressReached = false
var cancelReason = ""
try {
withTimeout(viewConfiguration.longPressTimeoutMillis) {
while (true) {
val event = awaitPointerEvent()
if (event.buttons.isSecondaryPressed) {
canceledBeforeLongPress = true
cancelReason = "secondary_button"
return@withTimeout
}
val change = event.changes.firstOrNull { it.id == pointerId }
if (change == null) {
canceledBeforeLongPress = true
cancelReason = "pointer_lost"
return@withTimeout
}
latestPosition = change.position
val distance = (latestPosition - start).getDistance()
when {
change.isConsumed -> {
canceledBeforeLongPress = true
cancelReason = "change_consumed"
return@withTimeout
}
change.changedToUp() || !change.pressed -> {
canceledBeforeLongPress = true
cancelReason = "up_before_long_press"
return@withTimeout
}
distance > viewConfiguration.touchSlop -> {
canceledBeforeLongPress = true
cancelReason = "moved distance=${distance.formatLogFloat()}"
return@withTimeout
}
}
}
}
} catch (_: TimeoutCancellationException) {
longPressReached = !canceledBeforeLongPress
}
if (!longPressReached) {
logPdfChromeTap {
"long_press_cancel source=$source page=${pageIndex + 1} " +
"reason=${cancelReason.ifBlank { "unknown" }} " +
"x=${latestPosition.x.formatLogFloat()} y=${latestPosition.y.formatLogFloat()}"
}
return@awaitEachGesture
}
logPdfChromeTap {
"long_press_reached source=$source page=${pageIndex + 1} " +
"x=${latestPosition.x.formatLogFloat()} y=${latestPosition.y.formatLogFloat()}"
}
onLongPress(latestPosition)
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == pointerId } ?: return@awaitEachGesture
change.consume()
if (change.changedToUp() || !change.pressed) return@awaitEachGesture
}
}
}
internal data class DesktopPdfCharHit(
val index: Int,
val source: String,
@ -318,7 +436,7 @@ private fun DesktopPdfTextChar.toPdfTextCharBounds(): PdfTextCharBounds {
}
internal const val DesktopPdfSelectionPreviewThrottleMillis = 32L
internal const val DesktopPdfZoomCommitDebounceMillis = 180L
internal const val DesktopPdfZoomCommitDebounceMillis = 260L
internal const val DesktopPdfZoomRenderDebounceMillis = 300L
internal const val DesktopPdfViewportPersistDebounceMillis = 300L
internal const val DesktopPdfPaginationPrefetchDelayMillis = 450L

View file

@ -0,0 +1,28 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.PdfDisplayMode
import com.aryan.reader.shared.pdf.PdfSpreadLayout
import com.aryan.reader.shared.reader.ReaderSettings
import kotlin.math.roundToInt
internal fun desktopPdfPageScrubTarget(
value: Float,
pageCount: Int,
displayMode: PdfDisplayMode,
settings: ReaderSettings
): Int {
val clampedPage = value.roundToInt().coerceIn(0, (pageCount - 1).coerceAtLeast(0))
return if (displayMode == PdfDisplayMode.PAGINATION) {
PdfSpreadLayout.normalizePageIndex(clampedPage, pageCount, settings)
} else {
clampedPage
}
}
internal fun desktopPdfPageScrubCommitTarget(
previewPage: Int?,
currentPage: Int,
pageCount: Int
): Int {
return (previewPage ?: currentPage).coerceIn(0, (pageCount - 1).coerceAtLeast(0))
}

View file

@ -53,7 +53,6 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.pdf.PdfPageBounds
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
@ -292,11 +291,15 @@ internal fun PdfSelectionMenu(
val anchor = menuOffset ?: return
val selectionBounds = selection.canvasBounds(canvasSize)
val paletteColors = remember(highlighterPalette) {
SharedPdfAndroidHighlightColors.palette
SharedPdfHighlighterPalette(highlighterPalette).sanitized().colors
}
val density = LocalDensity.current
var editingHighlighterSlot by remember(selection.startIndex, selection.endIndex, paletteColors) {
mutableStateOf<Int?>(null)
}
var editingHighlighterDraftColors by remember(selection.startIndex, selection.endIndex, paletteColors) {
mutableStateOf<List<Int>>(emptyList())
}
val actions = buildList {
add(PdfSelectionMenuAction(readerString("action_copy", "Copy"), DesktopPdfSelectionMenuIcons.Copy, onCopy))
if (showDefine) add(PdfSelectionMenuAction(readerString("action_define", "Define"), DesktopPdfSelectionMenuIcons.Dictionary, onDefine))
@ -304,13 +307,38 @@ internal fun PdfSelectionMenu(
if (showSearch) add(PdfSelectionMenuAction(readerString("action_search", "Search"), DesktopPdfSelectionMenuIcons.Search, onSearch))
add(PdfSelectionMenuAction(readerString("action_clear", "Clear"), Icons.Default.Close, onClear, isDestructive = true))
}
val estimatedHeight = PdfSelectionMenuPaletteHeightPx +
(((actions.size + 2) / 3).coerceAtLeast(1) * PdfSelectionMenuActionRowHeightPx)
fun highlighterDraftColors(): List<Int> {
return editingHighlighterDraftColors.ifEmpty { paletteColors }
}
fun updateHighlighterDraft(slotIndex: Int, color: Color): List<Int> {
val nextColors = highlighterDraftColors().toMutableList()
if (slotIndex in nextColors.indices) {
nextColors[slotIndex] = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
editingHighlighterDraftColors = nextColors
}
return nextColors
}
fun openHighlighterEditor(slotIndex: Int) {
if (editingHighlighterSlot == null) {
editingHighlighterDraftColors = paletteColors
}
editingHighlighterSlot = slotIndex
}
val actionRowCount = ((actions.size + 2) / 3).coerceAtLeast(1)
val popupWidthPx = with(density) { PdfSelectionMenuWidth.toPx() }
val estimatedHeightPx = with(density) {
PdfSelectionMenuPaletteHeight.toPx() +
(actionRowCount * PdfSelectionMenuActionRowHeight.toPx())
}
val placement = sharedSelectionMenuPlacement(
viewport = SharedSelectionMenuViewport(canvasSize.width, canvasSize.height),
popup = SharedSelectionMenuSize(
width = PdfSelectionMenuWidthPx.roundToInt(),
height = estimatedHeight.roundToInt()
width = popupWidthPx.roundToInt(),
height = estimatedHeightPx.roundToInt()
),
selection = if (selectionBounds != null) {
SharedSelectionMenuRect(
@ -327,8 +355,8 @@ internal fun PdfSelectionMenu(
bottom = anchor.y
)
},
marginPx = PdfSelectionMenuMarginPx,
gapPx = PdfSelectionMenuAnchorGapPx
marginPx = with(density) { PdfSelectionMenuMargin.toPx() },
gapPx = with(density) { PdfSelectionMenuAnchorGap.toPx() }
)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
Surface(
@ -385,7 +413,7 @@ internal fun PdfSelectionMenu(
)
)
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(16.dp))
.clickable { editingHighlighterSlot = 0 }
.clickable { openHighlighterEditor(0) }
)
}
HorizontalDivider()
@ -436,20 +464,27 @@ internal fun PdfSelectionMenu(
}
}
editingHighlighterSlot?.let { requestedSlot ->
val slot = requestedSlot.coerceIn(0, paletteColors.lastIndex)
val initialColor = Color(paletteColors[slot]).copy(alpha = 1f)
val draftColors = highlighterDraftColors()
val safeDraftColors = draftColors.ifEmpty { SharedPdfHighlighterPalette.defaultColors }
val slot = requestedSlot.coerceIn(0, safeDraftColors.lastIndex)
val initialColor = remember(slot) { Color(safeDraftColors[slot]).copy(alpha = 1f) }
SharedHsvColorPickerDialog(
initialColor = initialColor,
title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1),
onDismiss = { editingHighlighterSlot = null },
onSave = { color ->
val nextColors = updateHighlighterDraft(slot, color)
onHighlighterPaletteChange(
SharedPdfHighlighterPalette(paletteColors).withColorAt(
slotIndex = slot,
colorArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
)
SharedPdfHighlighterPalette(nextColors).sanitized()
)
editingHighlighterSlot = null
},
resetColor = Color(SharedPdfHighlighterPalette.defaultColors.getOrElse(slot) {
SharedPdfHighlighterPalette.defaultColors.first()
}).copy(alpha = 1f),
stateKey = slot,
onLiveColorChange = { color ->
updateHighlighterDraft(slot, color)
}
) { liveColor ->
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@ -458,7 +493,7 @@ internal fun PdfSelectionMenu(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
paletteColors.forEachIndexed { index, argb ->
highlighterDraftColors().forEachIndexed { index, argb ->
val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f)
Box(
modifier = Modifier
@ -467,10 +502,14 @@ internal fun PdfSelectionMenu(
.background(color)
.border(
width = if (index == slot) 3.dp else 1.dp,
color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f),
color = if (index == slot) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)
},
shape = RoundedCornerShape(21.dp)
)
.clickable { editingHighlighterSlot = index },
.clickable { openHighlighterEditor(index) },
contentAlignment = Alignment.Center
) {
Text(
@ -494,11 +533,11 @@ private data class PdfSelectionMenuAction(
val isDestructive: Boolean = false
)
private const val PdfSelectionMenuWidthPx = 220f
private const val PdfSelectionMenuPaletteHeightPx = 54f
private const val PdfSelectionMenuActionRowHeightPx = 66f
private const val PdfSelectionMenuAnchorGapPx = 16f
private const val PdfSelectionMenuMarginPx = 6f
private val PdfSelectionMenuWidth = 220.dp
private val PdfSelectionMenuPaletteHeight = 54.dp
private val PdfSelectionMenuActionRowHeight = 66.dp
private val PdfSelectionMenuAnchorGap = 16.dp
private val PdfSelectionMenuMargin = 6.dp
private const val DesktopPdfSelectionHandleTouchWidthPx = 44f
private const val DesktopPdfSelectionHandleTouchTopPx = 8f
private const val DesktopPdfSelectionHandleTouchBottomPx = 40f

View file

@ -36,18 +36,40 @@ internal fun DesktopPdfAnnotationSidecarEffect(
emptyList()
}
onAnnotationsLoaded(loadedAnnotations)
logDesktopCloudAnnotations {
"desktop.local.load_annotations document=$documentHandleId count=${loadedAnnotations.size} " +
"exists=${annotationFile.exists()} bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFileForCloudLog()}"
}
onAnnotationsLoadedChange(true)
}
LaunchedEffect(documentHandleId, annotations, annotationsLoaded) {
if (!annotationsLoaded) return@LaunchedEffect
withContext(Dispatchers.IO) {
val changed = withContext(Dispatchers.IO) {
runCatching {
annotationFile.parentFile?.mkdirs()
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
}
val nextJson = SharedPdfAnnotationSerializer.encode(annotations)
when {
annotations.isEmpty() && annotationFile.isFile -> {
annotationFile.delete()
}
annotations.isEmpty() -> false
annotationFile.isFile && annotationFile.readText() == nextJson -> false
else -> {
annotationFile.parentFile?.mkdirs()
annotationFile.writeText(nextJson)
true
}
}
}.getOrDefault(false)
}
if (changed) {
logDesktopCloudAnnotations {
"desktop.local.save_annotations document=$documentHandleId count=${annotations.size} " +
"bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFileForCloudLog()} " +
"path=${annotationFile.absolutePath.logPreview(140)}"
}
onLocalSidecarsChanged()
}
onLocalSidecarsChanged()
}
}
@ -76,13 +98,23 @@ internal fun DesktopPdfBookmarkSidecarEffect(
LaunchedEffect(documentHandleId, bookmarks, bookmarksLoaded) {
if (!bookmarksLoaded) return@LaunchedEffect
withContext(Dispatchers.IO) {
val changed = withContext(Dispatchers.IO) {
runCatching {
bookmarkFile.parentFile?.mkdirs()
bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks))
}
val nextJson = SharedPdfBookmarkSerializer.encode(bookmarks)
when {
bookmarks.isEmpty() && !bookmarkFile.isFile -> false
bookmarkFile.isFile && bookmarkFile.readText() == nextJson -> false
else -> {
bookmarkFile.parentFile?.mkdirs()
bookmarkFile.writeText(nextJson)
true
}
}
}.getOrDefault(false)
}
if (changed) {
onLocalSidecarsChanged()
}
onLocalSidecarsChanged()
}
}
@ -177,3 +209,7 @@ internal fun DesktopPdfSearchResultsEffect(
onSearchResultsChange(results)
}
}
private fun File.lastModifiedIfFileForCloudLog(): Long {
return if (isFile) lastModified() else 0L
}

View file

@ -1,27 +1,49 @@
package com.aryan.reader.desktop
import java.io.File
import java.security.MessageDigest
import java.util.Base64
private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t1"
private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t2"
internal fun desktopPdfAnnotationFile(documentPath: String): File {
val safeName = documentPath.hashCode().toString().replace("-", "n")
return File(desktopUserDataRoot(), "annotations/pdf_$safeName.json")
val safeName = desktopPdfDocumentKey(documentPath)
val legacyName = desktopPdfLegacyDocumentKey(documentPath)
return sidecarFileWithLegacyMigration(
file = File(desktopUserDataRoot(), "annotations/pdf_$safeName.json"),
legacyFile = File(desktopUserDataRoot(), "annotations/pdf_$legacyName.json")
)
}
internal fun desktopPdfAnnotationDeletionFile(documentPath: String): File {
val safeName = desktopPdfDocumentKey(documentPath)
val legacyName = desktopPdfLegacyDocumentKey(documentPath)
return sidecarFileWithLegacyMigration(
file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_deleted_annotations.json"),
legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_deleted_annotations.json")
)
}
internal fun desktopPdfBookmarkFile(documentPath: String): File {
val safeName = documentPath.hashCode().toString().replace("-", "n")
return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json")
val safeName = desktopPdfDocumentKey(documentPath)
val legacyName = desktopPdfLegacyDocumentKey(documentPath)
return sidecarFileWithLegacyMigration(
file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json"),
legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_bookmarks.json")
)
}
internal fun desktopPdfRichTextFile(documentPath: String): File {
val safeName = documentPath.hashCode().toString().replace("-", "n")
return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json")
val safeName = desktopPdfDocumentKey(documentPath)
val legacyName = desktopPdfLegacyDocumentKey(documentPath)
return sidecarFileWithLegacyMigration(
file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json"),
legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_rich_text.json")
)
}
internal fun desktopPdfSearchIndexFile(documentPath: String): File {
val safeName = documentPath.hashCode().toString().replace("-", "n")
val safeName = desktopPdfDocumentKey(documentPath)
return File(desktopUserCacheRoot(), "search/pdf_${safeName}_text_index.tsv")
}
@ -38,7 +60,7 @@ internal fun restoreDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFil
if (parts.size == 2) parts[0] to parts[1] else null
}
.toMap()
val isFresh = metadata["pathHash"] == document.path.hashCode().toString() &&
val isFresh = metadata["pathKey"] == desktopPdfDocumentKey(document.path) &&
metadata["fileSize"] == sourceFile.length().toString() &&
metadata["lastModified"] == sourceFile.lastModified().toString() &&
metadata["pageCount"] == document.pageCount.toString()
@ -65,7 +87,7 @@ internal fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile:
val encoder = Base64.getEncoder()
val payload = buildString {
appendLine(DesktopPdfSearchIndexHeader)
appendLine("pathHash\t${document.path.hashCode()}")
appendLine("pathKey\t${desktopPdfDocumentKey(document.path)}")
appendLine("fileSize\t${sourceFile.length()}")
appendLine("lastModified\t${sourceFile.lastModified()}")
appendLine("pageCount\t${document.pageCount}")
@ -81,3 +103,30 @@ internal fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile:
indexFile.writeText(payload, Charsets.UTF_8)
}
}
internal fun desktopPdfDocumentKey(documentPath: String): String {
val normalizedPath = runCatching { File(documentPath).canonicalPath }
.getOrElse { documentPath.trim() }
return sha256Hex(normalizedPath).take(32)
}
private fun desktopPdfLegacyDocumentKey(documentPath: String): String {
return documentPath.hashCode().toString().replace("-", "n")
}
private fun sidecarFileWithLegacyMigration(file: File, legacyFile: File): File {
if (!file.exists() && legacyFile.isFile && legacyFile != file) {
runCatching {
file.parentFile?.mkdirs()
if (!legacyFile.renameTo(file)) {
legacyFile.copyTo(file, overwrite = false)
}
}
}
return file
}
private fun sha256Hex(value: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
return digest.joinToString("") { "%02x".format(it) }
}

View file

@ -0,0 +1,156 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
import com.aryan.reader.shared.pdf.SharedPdfBookmark
import com.aryan.reader.shared.pdf.SharedPdfBookmarkSerializer
import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
private val desktopPdfSyncJson = Json {
ignoreUnknownKeys = true
prettyPrint = true
encodeDefaults = true
}
internal fun desktopPdfAnnotationElementForSync(rawJson: String): JsonElement? {
val annotations = SharedPdfAnnotationSerializer.decode(rawJson)
if (annotations.isEmpty()) return null
return SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
}
internal fun desktopPdfRichTextElementForSync(rawJson: String): JsonElement? {
val element = runCatching { desktopPdfSyncJson.parseToJsonElement(rawJson) }.getOrNull()
?: return null
val document = SharedPdfRichTextSerializer.decodeElement(element)
if (document.text.isEmpty() && document.spans.isEmpty()) return null
return SharedPdfRichTextSerializer.encodeElement(document)
}
internal fun desktopPdfBookmarksMetadataJson(book: BookItem): String? {
if (book.type != FileType.PDF) return null
val path = book.path?.takeIf { it.isNotBlank() } ?: return null
val bookmarkFile = desktopPdfBookmarkFile(path).takeIf { it.isFile } ?: return null
return desktopPdfBookmarksMetadataJson(
bookmarks = SharedPdfBookmarkSerializer.decode(bookmarkFile.readText()),
lastPageIndex = book.lastPageIndex
)
}
internal fun desktopPdfBookmarksMetadataJson(
bookmarks: List<SharedPdfBookmark>,
lastPageIndex: Int?
): String {
val totalPages = maxOf(
(lastPageIndex ?: 0) + 1,
(bookmarks.maxOfOrNull { it.pageIndex } ?: 0) + 1
).coerceAtLeast(1)
return desktopPdfSyncJson.encodeToString(
JsonElement.serializer(),
JsonArray(
bookmarks.map { bookmark ->
JsonObject(
mapOf(
"pageIndex" to JsonPrimitive(bookmark.pageIndex.coerceAtLeast(0)),
"title" to JsonPrimitive(bookmark.label.ifBlank { "Page ${bookmark.pageIndex + 1}" }),
"totalPages" to JsonPrimitive(totalPages)
)
)
}
)
)
}
internal fun desktopPdfBookmarkMetadataTimestamp(book: BookItem): Long {
if (book.type != FileType.PDF) return 0L
val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L
return desktopPdfBookmarkFile(path).lastModifiedIfFile()
}
internal fun importDesktopPdfBookmarksMetadata(
book: BookItem,
bookmarksJson: String?,
timestamp: Long
): Boolean {
if (book.type != FileType.PDF) return false
val path = book.path?.takeIf { it.isNotBlank() } ?: return false
val rawJson = bookmarksJson?.takeIf { it.isNotBlank() } ?: return false
val bookmarks = desktopPdfBookmarksFromMetadataJson(rawJson)
val bookmarkFile = desktopPdfBookmarkFile(path)
val localTimestamp = bookmarkFile.lastModifiedIfFile()
if (timestamp <= localTimestamp + 1000L) return false
if (bookmarks.isEmpty()) {
if (bookmarkFile.isFile) bookmarkFile.delete()
return true
}
bookmarkFile.parentFile?.mkdirs()
bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks))
bookmarkFile.setLastModified(timestamp)
return true
}
internal fun desktopPdfBookmarksFromMetadataJson(rawJson: String): List<SharedPdfBookmark> {
val root = runCatching { desktopPdfSyncJson.parseToJsonElement(rawJson) }.getOrNull()
?: return emptyList()
root.jsonArrayOrNull()?.let { androidBookmarks ->
return androidBookmarks.mapNotNull { element ->
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
val pageIndex = obj.int("pageIndex") ?: return@mapNotNull null
SharedPdfBookmark(
pageIndex = pageIndex.coerceAtLeast(0),
label = obj.string("title") ?: obj.string("label") ?: "Page ${pageIndex + 1}",
createdAt = obj.longString("createdAt") ?: 0L
)
}
}
return SharedPdfBookmarkSerializer.decode(rawJson)
}
private fun File.lastModifiedIfFile(): Long {
return if (isFile()) lastModified() else 0L
}
private fun JsonElement.jsonArrayOrNull(): JsonArray? {
if (this is JsonNull) return null
return runCatching { jsonArray }.getOrNull()
}
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
if (this is JsonNull) return null
return runCatching { jsonObject }.getOrNull()
}
private fun JsonObject.string(name: String): String? {
return this[name]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
}
private fun JsonObject.int(name: String): Int? {
return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull
}
private fun JsonObject.longString(name: String): Long? {
return string(name)?.toLongOrNull()
?: this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toLongOrNull()
}

View file

@ -2,12 +2,15 @@ package com.aryan.reader.desktop
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.PdfDisplayMode
import com.aryan.reader.shared.ReaderTheme
import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp
internal val DesktopDefaultPdfDisplayMode = PdfDisplayMode.VERTICAL_SCROLL
internal val DesktopDefaultPdfDisplayMode = PdfDisplayMode.PAGINATION
internal val DesktopDefaultPdfVerticalPageGap = 8.dp
internal val DesktopDefaultPdfSpreadPageGap = 18.dp
internal fun desktopPdfPageBackgroundColor(
theme: ReaderTheme,
@ -28,3 +31,26 @@ internal fun desktopPdfVerticalViewportBackgroundColor(
): Color {
return if (isPageGapVisible) gapBackgroundColor else pageBackgroundColor
}
internal fun desktopPdfViewportBackgroundColor(
displayMode: PdfDisplayMode,
pageBackgroundColor: Color,
appBackgroundColor: Color,
isVerticalPageGapVisible: Boolean
): Color {
return when (displayMode) {
PdfDisplayMode.VERTICAL_SCROLL -> desktopPdfVerticalViewportBackgroundColor(
pageBackgroundColor = pageBackgroundColor,
gapBackgroundColor = appBackgroundColor,
isPageGapVisible = isVerticalPageGapVisible
)
PdfDisplayMode.PAGINATION -> appBackgroundColor
}
}
internal fun desktopPdfSpreadPageGapDp(
isPageGapVisible: Boolean
): Dp = pdfVerticalPageGapDp(
isPageGapVisible = isPageGapVisible,
defaultGap = DesktopDefaultPdfSpreadPageGap
)

View file

@ -22,6 +22,7 @@ import kotlin.math.exp
import kotlin.math.roundToInt
private const val DesktopPdfZoomGestureFrameMillis = 16L
private const val DesktopPdfZoomPreviewTolerance = 0.0001f
internal const val DesktopPdfPaginationFastFirstRenderMaxScale = 2.0f
internal fun desktopPdfScrollZoomFactor(scrollDelta: Float): Float {
@ -117,19 +118,320 @@ internal fun desktopPdfPaginationFirstRenderScale(
return requestedScale.coerceAtMost(DesktopPdfPaginationFastFirstRenderMaxScale)
}
internal fun desktopPdfRenderBelongsToPage(
renderedPageIndex: Int?,
requestedPageIndex: Int
): Boolean {
return renderedPageIndex == requestedPageIndex
}
internal fun desktopPdfRenderScaleNeedsUpgrade(
renderedScale: Float?,
requestedScale: Float
): Boolean {
if (renderedScale == null) return true
if (!requestedScale.isFinite() || requestedScale <= 0f) return false
if (!renderedScale.isFinite() || renderedScale <= 0f) return true
return requestedScale - renderedScale > DesktopPdfRenderScaleTolerance
}
internal fun desktopPdfSpreadZoomAnchorPageIndex(
viewportRootOffset: Offset,
anchor: Offset?,
visiblePageIndices: List<Int>,
pageRootOffsets: Map<Int, Offset>,
pageSizes: Map<Int, IntSize>,
fallbackPageIndex: Int
): Int {
if (anchor == null || visiblePageIndices.isEmpty()) return fallbackPageIndex
if (!anchor.x.isFinite() || !anchor.y.isFinite()) return fallbackPageIndex
val rootAnchor = viewportRootOffset + anchor
if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return fallbackPageIndex
val candidates = visiblePageIndices.mapNotNull { pageIndex ->
pageRootOffsets[pageIndex]?.let { root ->
pageIndex to root
}
}
if (candidates.isEmpty()) return fallbackPageIndex
candidates.firstOrNull { (pageIndex, root) ->
val size = pageSizes[pageIndex] ?: return@firstOrNull false
val width = size.width.toFloat()
val height = size.height.toFloat()
rootAnchor.x >= root.x &&
rootAnchor.x <= root.x + width &&
rootAnchor.y >= root.y &&
rootAnchor.y <= root.y + height
}?.let { return it.first }
return candidates.minByOrNull { (pageIndex, root) ->
val size = pageSizes[pageIndex]
val dx: Float
val dy: Float
if (size == null) {
dx = rootAnchor.x - root.x
dy = rootAnchor.y - root.y
} else {
val right = root.x + size.width.toFloat()
val bottom = root.y + size.height.toFloat()
dx = when {
rootAnchor.x < root.x -> root.x - rootAnchor.x
rootAnchor.x > right -> rootAnchor.x - right
else -> 0f
}
dy = when {
rootAnchor.y < root.y -> root.y - rootAnchor.y
rootAnchor.y > bottom -> rootAnchor.y - bottom
else -> 0f
}
}
dx * dx + dy * dy
}?.first ?: fallbackPageIndex
}
internal data class DesktopPdfZoomPreview(
val baseZoom: Float,
val zoom: Float,
val anchor: Offset?,
val displayMode: PdfDisplayMode,
val pageIndex: Int?
val pageIndex: Int?,
val viewportRootOffset: Offset = Offset.Zero,
val pageRootOffset: Offset? = null,
val commitTargetHorizontalScroll: Int? = null,
val commitTargetVerticalScroll: Int? = null,
val diagnosticSequence: Int = 0
)
internal data class DesktopPdfZoomScrollBounds(
val currentHorizontalScroll: Int? = null,
val maxHorizontalScroll: Int? = null,
val currentVerticalScroll: Int? = null,
val maxVerticalScroll: Int? = null
)
internal interface DesktopPdfLayoutScrollPrediction {
val maxHorizontalScroll: Int
val maxVerticalScroll: Int
}
internal data class DesktopPdfSinglePageLayoutPrediction(
val rootOffset: Offset,
override val maxHorizontalScroll: Int,
override val maxVerticalScroll: Int
) : DesktopPdfLayoutScrollPrediction
internal data class DesktopPdfSpreadLayoutPrediction(
val pageRootOffsets: Map<Int, Offset>,
override val maxHorizontalScroll: Int,
override val maxVerticalScroll: Int
) : DesktopPdfLayoutScrollPrediction
internal data class DesktopPdfCachedPageRender(
val render: DesktopPdfPageRender,
val scale: Float
)
internal data class DesktopPdfNavigationZoomSnapshot(
val zoom: Float,
val horizontalScroll: Int,
val verticalScroll: Int
)
internal fun desktopPdfNavigationZoomSnapshot(
preview: DesktopPdfZoomPreview?,
currentHorizontalScroll: Int,
currentVerticalScroll: Int
): DesktopPdfNavigationZoomSnapshot? {
val activePreview = preview ?: return null
val baseZoom = activePreview.baseZoom.takeIf { it.isFinite() && it > 0f } ?: return null
val targetZoom = activePreview.zoom.takeIf { it.isFinite() && it > 0f } ?: return null
val anchor = activePreview.anchor
return DesktopPdfNavigationZoomSnapshot(
zoom = targetZoom,
horizontalScroll = anchor?.let {
desktopPdfAnchoredScrollTarget(currentHorizontalScroll, it.x, baseZoom, targetZoom)
} ?: currentHorizontalScroll.coerceAtLeast(0),
verticalScroll = anchor?.let {
desktopPdfAnchoredScrollTarget(currentVerticalScroll, it.y, baseZoom, targetZoom)
} ?: currentVerticalScroll.coerceAtLeast(0)
)
}
internal fun desktopPdfZoomPreviewMatchesScale(
preview: DesktopPdfZoomPreview,
scale: Float
): Boolean {
return abs(preview.baseZoom - scale) <= DesktopPdfZoomPreviewTolerance ||
abs(preview.zoom - scale) <= DesktopPdfZoomPreviewTolerance
}
internal fun desktopPdfReachableScrollDelta(
currentScroll: Int?,
maxScroll: Int?,
requestedDelta: Int
): Int {
if (currentScroll == null || maxScroll == null) return requestedDelta
val safeMax = maxScroll.coerceAtLeast(0)
val safeCurrent = currentScroll.coerceIn(0, safeMax)
val targetScroll = (safeCurrent + requestedDelta).coerceIn(0, safeMax)
return targetScroll - safeCurrent
}
internal fun desktopPdfReachableScrollDelta(
requestedDelta: IntOffset,
scrollBounds: DesktopPdfZoomScrollBounds?
): IntOffset {
if (scrollBounds == null) return requestedDelta
return IntOffset(
x = desktopPdfReachableScrollDelta(
currentScroll = scrollBounds.currentHorizontalScroll,
maxScroll = scrollBounds.maxHorizontalScroll,
requestedDelta = requestedDelta.x
),
y = desktopPdfReachableScrollDelta(
currentScroll = scrollBounds.currentVerticalScroll,
maxScroll = scrollBounds.maxVerticalScroll,
requestedDelta = requestedDelta.y
)
)
}
internal fun desktopPdfZoomScrollBoundsWithCommitTargets(
preview: DesktopPdfZoomPreview?,
currentHorizontalScroll: Int,
maxHorizontalScroll: Int,
currentVerticalScroll: Int? = null,
maxVerticalScroll: Int? = null
): DesktopPdfZoomScrollBounds {
return DesktopPdfZoomScrollBounds(
currentHorizontalScroll = currentHorizontalScroll,
maxHorizontalScroll = maxOf(maxHorizontalScroll, preview?.commitTargetHorizontalScroll ?: 0),
currentVerticalScroll = currentVerticalScroll,
maxVerticalScroll = maxVerticalScroll?.let {
maxOf(it, preview?.commitTargetVerticalScroll ?: 0)
}
)
}
internal fun desktopPdfSinglePageLayoutPrediction(
viewportRootOffset: Offset,
viewportSize: IntSize,
pageCanvasSize: IntSize,
horizontalScroll: Int,
verticalScroll: Int,
paddingPx: Float
): DesktopPdfSinglePageLayoutPrediction? {
if (viewportSize.width <= 0 || viewportSize.height <= 0) return null
if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null
if (!viewportRootOffset.x.isFinite() || !viewportRootOffset.y.isFinite()) return null
if (!paddingPx.isFinite() || paddingPx < 0f) return null
val viewportWidth = viewportSize.width.toFloat()
val viewportHeight = viewportSize.height.toFloat()
val pageWidth = pageCanvasSize.width.toFloat()
val contentWidth = (viewportWidth - paddingPx * 2f).coerceAtLeast(0f)
val maxHorizontalScroll = (pageWidth + paddingPx * 2f - viewportWidth)
.roundToInt()
.coerceAtLeast(0)
val maxVerticalScroll = (pageCanvasSize.height.toFloat() + paddingPx * 2f - viewportHeight)
.roundToInt()
.coerceAtLeast(0)
val safeHorizontalScroll = horizontalScroll.coerceIn(0, maxHorizontalScroll)
val safeVerticalScroll = verticalScroll.coerceIn(0, maxVerticalScroll)
val pageX = if (pageWidth <= contentWidth) {
((viewportWidth - pageWidth) / 2f) - safeHorizontalScroll.toFloat()
} else {
paddingPx - safeHorizontalScroll.toFloat()
}
val pageY = paddingPx - safeVerticalScroll.toFloat()
return DesktopPdfSinglePageLayoutPrediction(
rootOffset = Offset(
x = viewportRootOffset.x + pageX,
y = viewportRootOffset.y + pageY
),
maxHorizontalScroll = maxHorizontalScroll,
maxVerticalScroll = maxVerticalScroll
)
}
internal fun desktopPdfSpreadLayoutPrediction(
viewportRootOffset: Offset,
viewportSize: IntSize,
visiblePageIndices: List<Int>,
pageCanvasSizes: Map<Int, IntSize>,
horizontalScroll: Int,
verticalScroll: Int,
paddingPx: Float,
pageGapPx: Float
): DesktopPdfSpreadLayoutPrediction? {
if (viewportSize.width <= 0 || viewportSize.height <= 0) return null
if (visiblePageIndices.isEmpty()) return null
if (!viewportRootOffset.x.isFinite() || !viewportRootOffset.y.isFinite()) return null
if (!paddingPx.isFinite() || paddingPx < 0f) return null
if (!pageGapPx.isFinite() || pageGapPx < 0f) return null
val pageSizes = visiblePageIndices.map { pageIndex ->
val pageSize = pageCanvasSizes[pageIndex] ?: return null
if (pageSize.width <= 0 || pageSize.height <= 0) return null
pageSize
}
val viewportWidth = viewportSize.width.toFloat()
val viewportHeight = viewportSize.height.toFloat()
val rowWidth = pageSizes.sumOf { it.width }.toFloat() +
(pageGapPx * (pageSizes.size - 1).coerceAtLeast(0))
val rowHeight = pageSizes.maxOf { it.height }.toFloat()
val contentWidth = (viewportWidth - paddingPx * 2f).coerceAtLeast(0f)
val maxHorizontalScroll = (rowWidth + paddingPx * 2f - viewportWidth)
.roundToInt()
.coerceAtLeast(0)
val maxVerticalScroll = (rowHeight + paddingPx * 2f - viewportHeight)
.roundToInt()
.coerceAtLeast(0)
val safeHorizontalScroll = horizontalScroll.coerceIn(0, maxHorizontalScroll)
val safeVerticalScroll = verticalScroll.coerceIn(0, maxVerticalScroll)
val rowX = if (rowWidth <= contentWidth) {
((viewportWidth - rowWidth) / 2f) - safeHorizontalScroll.toFloat()
} else {
paddingPx - safeHorizontalScroll.toFloat()
}
val rowY = paddingPx - safeVerticalScroll.toFloat()
var pageX = viewportRootOffset.x + rowX
val pageY = viewportRootOffset.y + rowY
val roots = visiblePageIndices.mapIndexed { index, pageIndex ->
val root = Offset(pageX, pageY)
pageX += pageSizes[index].width.toFloat() + pageGapPx
pageIndex to root
}.toMap()
return DesktopPdfSpreadLayoutPrediction(
pageRootOffsets = roots,
maxHorizontalScroll = maxHorizontalScroll,
maxVerticalScroll = maxVerticalScroll
)
}
internal fun desktopPdfZoomCommitPreviewTranslation(
viewportRootOffset: Offset,
oldPageRootOffset: Offset?,
currentAnchorPageRootOffset: Offset,
anchor: Offset?,
oldZoom: Float,
newZoom: Float,
currentZoom: Float,
scrollBounds: DesktopPdfZoomScrollBounds? = null
): Offset? {
if (oldPageRootOffset == null || anchor == null) return null
if (abs(currentZoom - newZoom) > DesktopPdfZoomPreviewTolerance) return null
val pageDelta = desktopPdfAnchoredPageScrollDelta(
viewportRootOffset = viewportRootOffset,
oldPageRootOffset = oldPageRootOffset,
currentPageRootOffset = currentAnchorPageRootOffset,
anchor = anchor,
oldZoom = oldZoom,
newZoom = newZoom
) ?: return null
val reachableDelta = desktopPdfReachableScrollDelta(pageDelta, scrollBounds)
return Offset(
x = if (reachableDelta.x == 0) 0f else -reachableDelta.x.toFloat(),
y = if (reachableDelta.y == 0) 0f else -reachableDelta.y.toFloat()
)
}
internal fun desktopPdfZoomPreviewPivotFraction(
viewportRootOffset: Offset,
pageRootOffset: Offset,
@ -167,14 +469,39 @@ internal fun Modifier.desktopPdfZoomPreviewLayer(
currentZoom: Float,
viewportRootOffset: Offset,
pageRootOffset: Offset,
pageCanvasSize: IntSize
pageCanvasSize: IntSize,
commitPageRootOffset: Offset? = null,
scrollBounds: DesktopPdfZoomScrollBounds? = null
): Modifier {
val activePreview = preview ?: return this
if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return this
if (!currentZoom.isFinite() || currentZoom <= 0f) return this
if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this
val previewScale = activePreview.zoom / currentZoom
if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this
val commitTranslation = desktopPdfZoomCommitPreviewTranslation(
viewportRootOffset = activePreview.viewportRootOffset,
oldPageRootOffset = activePreview.pageRootOffset,
currentAnchorPageRootOffset = commitPageRootOffset ?: pageRootOffset,
anchor = activePreview.anchor,
oldZoom = activePreview.baseZoom,
newZoom = activePreview.zoom,
currentZoom = currentZoom,
scrollBounds = scrollBounds
)
if (
!previewScale.isFinite() ||
(abs(previewScale - 1f) < DesktopPdfZoomPreviewTolerance && commitTranslation == null)
) {
return this
}
logPdfZoomSettle {
"preview_layer seq=${activePreview.diagnosticSequence} kind=page currentZoom=${currentZoom.formatLogFloat()} " +
"previewZoom=${activePreview.zoom.formatLogFloat()} scale=${previewScale.formatLogFloat()} " +
"pageRoot=${pageRootOffset.formatLogOffset()} commitRoot=${commitPageRootOffset.formatLogOffset()} " +
"commit=${commitTranslation.formatLogOffset()} " +
"h=${scrollBounds?.currentHorizontalScroll ?: "none"}/${scrollBounds?.maxHorizontalScroll ?: "none"} " +
"v=${scrollBounds?.currentVerticalScroll ?: "none"}/${scrollBounds?.maxVerticalScroll ?: "none"}"
}
val transformOrigin = activePreview.anchor?.let { anchor ->
desktopPdfZoomPreviewPivotFraction(
viewportRootOffset = viewportRootOffset,
@ -188,6 +515,8 @@ internal fun Modifier.desktopPdfZoomPreviewLayer(
return graphicsLayer {
scaleX = previewScale
scaleY = previewScale
translationX = commitTranslation?.x ?: 0f
translationY = commitTranslation?.y ?: 0f
this.transformOrigin = transformOrigin
}
}
@ -196,13 +525,38 @@ internal fun Modifier.desktopPdfDocumentZoomPreviewLayer(
preview: DesktopPdfZoomPreview?,
currentZoom: Float,
viewportRootOffset: Offset,
pageRootOffset: Offset
pageRootOffset: Offset,
anchorPageRootOffset: Offset? = null,
scrollBounds: DesktopPdfZoomScrollBounds? = null
): Modifier {
val activePreview = preview ?: return this
if (!currentZoom.isFinite() || currentZoom <= 0f) return this
if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this
val previewScale = activePreview.zoom / currentZoom
if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this
val commitTranslation = desktopPdfZoomCommitPreviewTranslation(
viewportRootOffset = activePreview.viewportRootOffset,
oldPageRootOffset = activePreview.pageRootOffset,
currentAnchorPageRootOffset = anchorPageRootOffset ?: pageRootOffset,
anchor = activePreview.anchor,
oldZoom = activePreview.baseZoom,
newZoom = activePreview.zoom,
currentZoom = currentZoom,
scrollBounds = scrollBounds
)
if (
!previewScale.isFinite() ||
(abs(previewScale - 1f) < DesktopPdfZoomPreviewTolerance && commitTranslation == null)
) {
return this
}
logPdfZoomSettle {
"preview_layer seq=${activePreview.diagnosticSequence} kind=document currentZoom=${currentZoom.formatLogFloat()} " +
"previewZoom=${activePreview.zoom.formatLogFloat()} scale=${previewScale.formatLogFloat()} " +
"pageRoot=${pageRootOffset.formatLogOffset()} anchorRoot=${(anchorPageRootOffset ?: pageRootOffset).formatLogOffset()} " +
"commit=${commitTranslation.formatLogOffset()} h=${scrollBounds?.currentHorizontalScroll ?: "none"}/" +
"${scrollBounds?.maxHorizontalScroll ?: "none"} v=${scrollBounds?.currentVerticalScroll ?: "none"}/" +
"${scrollBounds?.maxVerticalScroll ?: "none"}"
}
val translation = activePreview.anchor?.let { anchor ->
desktopPdfDocumentZoomPreviewTranslation(
viewportRootOffset = viewportRootOffset,
@ -214,8 +568,8 @@ internal fun Modifier.desktopPdfDocumentZoomPreviewLayer(
return graphicsLayer {
scaleX = previewScale
scaleY = previewScale
translationX = translation.x
translationY = translation.y
translationX = translation.x + (commitTranslation?.x ?: 0f)
translationY = translation.y + (commitTranslation?.y ?: 0f)
transformOrigin = TransformOrigin(0f, 0f)
}
}

View file

@ -24,14 +24,6 @@ internal data class DesktopPlatform(
val isLinux: Boolean get() = os == DesktopOperatingSystem.LINUX
val isWindows: Boolean get() = os == DesktopOperatingSystem.WINDOWS
val kcefBundleDirectoryName: String
get() = when (os) {
DesktopOperatingSystem.WINDOWS -> "kcef-bundle"
DesktopOperatingSystem.LINUX -> "kcef-bundle-linux-${architecture.resourceName}"
DesktopOperatingSystem.MACOS -> "kcef-bundle-macos-${architecture.resourceName}"
DesktopOperatingSystem.OTHER -> "kcef-bundle-${architecture.resourceName}"
}
val pdfiumDirectoryName: String
get() = when (os) {
DesktopOperatingSystem.WINDOWS -> "win-${architecture.resourceName}-v8"
@ -140,7 +132,7 @@ private fun xdgBase(
): File {
return env(envName)
?.takeIf { it.isNotBlank() }
?.takeIf { it.startsWith("/") }
?.let(::File)
?.takeIf { it.isAbsolute }
?: File(userHome, fallbackRelativePath)
}

View file

@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Verified
@ -55,7 +54,7 @@ internal fun DesktopProScreen(
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(Icons.Default.Star, contentDescription = null, modifier = Modifier.size(30.dp), tint = MaterialTheme.colorScheme.primary)
Column(Modifier.weight(1f)) {
Text(readerString("desktop_pro_and_credits", "Pro and credits"), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(readerString("desktop_account_and_credits", "Account & credits"), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(
readerString("desktop_pro_sign_in_desc", "Sign in to check your account status on desktop."),
style = MaterialTheme.typography.bodyMedium,
@ -73,7 +72,7 @@ internal fun DesktopProScreen(
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Default.Verified, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Text(readerString("desktop_account", "Account"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Text(readerString("desktop_account_overview", "Account overview"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
}
if (user == null) {
Text(
@ -92,11 +91,13 @@ internal fun DesktopProScreen(
Text(readerString("drawer_sign_in", "Sign in with Google"))
}
} else {
Text(user.displayName ?: user.email ?: readerString("desktop_signed_in", "Signed in"), style = MaterialTheme.typography.titleMedium)
user.email?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(user.displayName ?: user.email ?: readerString("desktop_signed_in", "Signed in"), style = MaterialTheme.typography.titleMedium)
user.email?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
OutlinedButton(onClick = onRefresh, enabled = !isBusy) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
@ -107,33 +108,26 @@ internal fun DesktopProScreen(
}
}
}
statusMessage?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerLow,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Default.Cloud, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Text(readerString("desktop_access", "Desktop access"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
}
Text(
if (isProUser) {
readerString("desktop_pro_unlocked_account", "Pro is unlocked for this account.")
} else {
readerString("desktop_pro_not_unlocked_account", "Pro is not unlocked for this account.")
},
style = MaterialTheme.typography.titleMedium
)
Text(readerString("desktop_credits_available_format", "%1\$d credits available", credits), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary)
HorizontalDivider()
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(18.dp)) {
DesktopAccountValue(
label = readerString("desktop_plan", "Plan"),
value = if (isProUser) {
readerString("desktop_pro_unlocked_account", "Pro is unlocked for this account.")
} else {
readerString("desktop_pro_not_unlocked_account", "Pro is not unlocked for this account.")
},
modifier = Modifier.weight(1f)
)
DesktopAccountValue(
label = readerString("credits_tab", "Credits"),
value = readerString("desktop_credits_available_format", "%1\$d credits available", credits),
modifier = Modifier.weight(1f)
)
}
Text(
readerString(
"desktop_pro_purchase_android_desc",
@ -142,9 +136,25 @@ internal fun DesktopProScreen(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
statusMessage?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
Spacer(Modifier.height(12.dp))
}
}
@Composable
private fun DesktopAccountValue(
label: String,
value: String,
modifier: Modifier = Modifier
) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
}

View file

@ -0,0 +1,114 @@
package com.aryan.reader.desktop
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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 androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.foundation.shape.CircleShape
import com.aryan.reader.shared.UserData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image as SkiaImage
@Composable
internal fun DesktopProfileAvatar(
user: UserData,
modifier: Modifier = Modifier
) {
val photoUrl = user.photoUrl?.takeIf { it.isNotBlank() }
var bitmap by remember(photoUrl) { mutableStateOf(photoUrl?.let(DesktopProfileAvatarCache::peek)) }
LaunchedEffect(photoUrl) {
bitmap = if (photoUrl == null) {
null
} else {
withContext(Dispatchers.IO) {
DesktopProfileAvatarCache.load(photoUrl)
}
}
}
val imageBitmap = bitmap
if (imageBitmap != null) {
Image(
bitmap = imageBitmap,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier.clip(CircleShape)
)
} else {
DesktopProfileAvatarFallback(user = user, modifier = modifier)
}
}
@Composable
private fun DesktopProfileAvatarFallback(
user: UserData,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Box(contentAlignment = Alignment.Center) {
val initial = (user.displayName ?: user.email)
?.trim()
?.firstOrNull()
?.uppercase()
if (initial != null) {
Text(initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
} else {
Icon(Icons.Default.AccountCircle, contentDescription = null)
}
}
}
}
private object DesktopProfileAvatarCache {
private const val MaxEntries = 24
private val cache = object : LinkedHashMap<String, ImageBitmap>(MaxEntries, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>?): Boolean {
return size > MaxEntries
}
}
fun peek(url: String): ImageBitmap? {
return synchronized(cache) { cache[url] }
}
fun load(url: String): ImageBitmap? {
peek(url)?.let { return it }
val bitmap = runCatching {
DesktopOpdsHttp.fetchBytes(url, catalog = null).toImageBitmap()
}.getOrNull() ?: return null
synchronized(cache) {
cache[url] = bitmap
}
return bitmap
}
private fun ByteArray.toImageBitmap(): ImageBitmap? {
return runCatching { SkiaImage.makeFromEncoded(this).toComposeImageBitmap() }.getOrNull()
}
}

View file

@ -0,0 +1,91 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.PdfDisplayMode
import com.aryan.reader.shared.ReaderFeatureSurface
import com.aryan.reader.shared.ReaderPlatform
import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSettings
internal const val DesktopReaderDefaultsVersion = 1
internal enum class DesktopReaderSettingsEngine {
TEXT,
PDF
}
internal val DesktopDefaultTextReaderSettings = ReaderSettings(
readingMode = ReaderReadingMode.PAGINATED,
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
)
internal val DesktopDefaultPdfReaderSettings = ReaderSettings(
themeId = "no_theme",
readingMode = ReaderReadingMode.PAGINATED,
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
)
internal fun FileType.desktopReaderSettingsEngine(): DesktopReaderSettingsEngine? {
return when (SharedFileCapabilities.surfaceFor(this, ReaderPlatform.DESKTOP)) {
ReaderFeatureSurface.PDF_VIEWER -> DesktopReaderSettingsEngine.PDF
ReaderFeatureSurface.EPUB_READER,
ReaderFeatureSurface.TEXT_READER -> DesktopReaderSettingsEngine.TEXT
null -> null
}
}
internal fun BookItem.usesDesktopReaderSettingsEngine(engine: DesktopReaderSettingsEngine): Boolean {
return type.desktopReaderSettingsEngine() == engine
}
internal fun List<BookItem>.withDesktopReaderEngineSettings(
engine: DesktopReaderSettingsEngine,
settings: ReaderSettings
): List<BookItem> {
return map { book ->
if (book.usesDesktopReaderSettingsEngine(engine)) {
book.copy(readerSettings = settings)
} else {
book
}
}
}
internal fun SharedReaderScreenState.withDesktopReaderEngineDefaultSettings(
engine: DesktopReaderSettingsEngine,
settings: ReaderSettings
): SharedReaderScreenState {
val engineSettings = if (engine == DesktopReaderSettingsEngine.PDF) {
settings.toDesktopPdfReaderSettings()
} else {
settings
}
return when (engine) {
DesktopReaderSettingsEngine.TEXT -> copy(
readerDefaultSettings = engineSettings,
rawLibraryBooks = rawLibraryBooks.withDesktopReaderEngineSettings(engine, engineSettings)
)
DesktopReaderSettingsEngine.PDF -> copy(
pdfReaderDefaultSettings = engineSettings,
rawLibraryBooks = rawLibraryBooks.withDesktopReaderEngineSettings(engine, engineSettings)
)
}
}
internal fun ReaderSettings.toDesktopPdfDisplayMode(): PdfDisplayMode {
return when (readingMode) {
ReaderReadingMode.PAGINATED -> PdfDisplayMode.PAGINATION
ReaderReadingMode.VERTICAL -> PdfDisplayMode.VERTICAL_SCROLL
}
}
internal fun PdfDisplayMode.toDesktopReaderReadingMode(): ReaderReadingMode {
return when (this) {
PdfDisplayMode.PAGINATION -> ReaderReadingMode.PAGINATED
PdfDisplayMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL
}
}

View file

@ -1,14 +1,26 @@
package com.aryan.reader.desktop
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import com.aryan.reader.shared.ReaderLocator
private const val PdfZoomPerfLogTag = "EpistemePdfZoomPerf"
private const val PdfZoomSettleLogTag = "EpistemePdfZoomSettle"
private const val PdfLinkLogTag = "EpistemePdfLink"
private const val PdfChromeTapLogTag = "EpistemePdfChromeTap"
private const val EpubLinkLogTag = "EpistemeEpubLink"
private const val EpubPaginationLogTag = "EpistemeEpubPagination"
private const val EpubCutoffLogTag = "EpistemeEpubCutoff"
private const val ReaderGapLogTag = "EpistemeReaderGap"
private const val EpubSelectionDebugLogTag = "EPUB_SELECTION_DEBUG"
private const val EpubHighlightFlowLogTag = "EpistemeEpubHighlightFlow"
private const val DesktopHighlightMapLogTag = "EpistemeDesktopHighlightMap"
private const val DesktopPositionTraceLogTag = "EpistemeDesktopPositionTrace"
private const val DesktopReaderCloseLogTag = "EpistemeDesktopReaderClose"
private const val DesktopNativeWebViewLogTag = "EpistemeNativeWebView"
private const val WebViewLayoutLogTag = "EpistemeWebViewLayout"
private const val ReaderModeSwitchLogTag = "EpistemeReaderModeSwitch"
internal fun logPdfSelection(message: String) {
}
@ -21,10 +33,26 @@ internal fun logPdfZoomPerf(message: () -> String) {
logDesktopDiagnostic(PdfZoomPerfLogTag, message)
}
internal fun logPdfZoomSettle(message: String) {
logDesktopDiagnostic(PdfZoomSettleLogTag) { message }
}
internal fun logPdfZoomSettle(message: () -> String) {
logDesktopDiagnostic(PdfZoomSettleLogTag, message)
}
internal fun logPdfLink(message: String) {
logDesktopDiagnostic(PdfLinkLogTag) { message }
}
internal fun logPdfChromeTap(message: String) {
logDesktopDiagnostic(PdfChromeTapLogTag) { message }
}
internal fun logPdfChromeTap(message: () -> String) {
logDesktopDiagnostic(PdfChromeTapLogTag, message)
}
internal fun logEpubLink(message: String) {
logDesktopDiagnostic(EpubLinkLogTag) { message }
}
@ -33,6 +61,10 @@ internal fun logEpubPagination(message: String) {
logDesktopDiagnostic(EpubPaginationLogTag) { message }
}
internal fun logEpubCutoff(message: String) {
logDesktopDiagnostic(EpubCutoffLogTag) { message }
}
internal fun logReaderGap(message: String) {
logDesktopDiagnostic(ReaderGapLogTag) { message }
}
@ -41,6 +73,38 @@ internal fun logEpubSelectionDebug(message: String) {
logDesktopDiagnostic(EpubSelectionDebugLogTag) { message }
}
internal fun logEpubHighlightFlow(message: String) {
logDesktopDiagnostic(EpubHighlightFlowLogTag) { message }
}
internal fun logDesktopHighlightMap(message: String) {
logDesktopDiagnostic(DesktopHighlightMapLogTag) { message }
}
internal fun logDesktopPositionTrace(message: String) {
logDesktopDiagnostic(DesktopPositionTraceLogTag) { message }
}
internal fun logDesktopPositionTrace(message: () -> String) {
logDesktopDiagnostic(DesktopPositionTraceLogTag, message)
}
internal fun logDesktopReaderClose(message: String) {
logDesktopDiagnostic(DesktopReaderCloseLogTag) { message }
}
internal fun logDesktopWebView2(message: String) {
logDesktopDiagnostic(DesktopNativeWebViewLogTag) { message }
}
internal fun logWebViewLayoutDiag(message: String) {
logDesktopDiagnostic(WebViewLayoutLogTag) { message }
}
internal fun logReaderModeSwitch(message: String) {
logDesktopDiagnostic(ReaderModeSwitchLogTag) { message }
}
internal fun DesktopPdfLinkTarget.formatLogTarget(): String {
return "dest=${destPageIndex?.let { it + 1 } ?: "null"} uri=\"${uri.orEmpty().logPreview()}\""
}
@ -54,6 +118,11 @@ internal fun Offset?.formatLogOffset(): String {
return "${x.formatLogFloat()},${y.formatLogFloat()}"
}
internal fun IntOffset?.formatLogIntOffset(): String {
if (this == null) return "none"
return "${this.x},${this.y}"
}
internal fun IntSize.formatLogSize(): String {
return "${width}x${height}"
}
@ -66,3 +135,12 @@ internal fun DesktopPdfCharHit?.formatLogHit(prefix: String): String {
"${prefix}X=${point.x.formatLogFloat()} ${prefix}Y=${point.y.formatLogFloat()} " +
"${prefix}Nx=${normalized.x.formatLogFloat()} ${prefix}Ny=${normalized.y.formatLogFloat()}"
}
internal fun ReaderLocator?.desktopPositionTraceSummary(maxTextLength: Int = 90): String {
if (this == null) return "null"
return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " +
"offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " +
"block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " +
"chapterId=\"${chapterId.orEmpty().logPreview(80)}\" href=\"${href.orEmpty().logPreview(120)}\" " +
"cfi=\"${cfi.orEmpty().logPreview(180)}\" text=\"${textQuote.orEmpty().logPreview(maxTextLength)}\""
}

View file

@ -0,0 +1,29 @@
package com.aryan.reader.desktop
internal const val DesktopReaderOpenTraceTag = "EpistemeDesktopOpenTrace"
internal fun logDesktopReaderOpenTrace(message: () -> String) {
logDesktopDiagnostic(DesktopReaderOpenTraceTag, message)
}
internal fun Long.elapsedOpenTraceMs(nowNanos: Long = System.nanoTime()): Long {
return ((nowNanos - this).coerceAtLeast(0L)) / 1_000_000L
}
internal fun DesktopReaderOpening.elapsedOpenTraceMs(nowNanos: Long = System.nanoTime()): Long {
return startedAtNanos.elapsedOpenTraceMs(nowNanos)
}
internal fun DesktopReaderOpening.openTracePrefix(event: String): String {
return "event=$event requestId=$requestId bookId=\"${bookId.logPreview(80)}\" " +
"title=\"${title.logPreview(120)}\" format=\"$formatLabel\" elapsedMs=${elapsedOpenTraceMs()}"
}
internal fun DesktopReaderOpenResult.openTraceKind(): String {
return when (this) {
is DesktopReaderOpenResult.Failure -> "failure"
is DesktopReaderOpenResult.PasswordRequired -> "password_required"
is DesktopReaderOpenResult.Pdf -> "pdf"
is DesktopReaderOpenResult.Text -> "text"
}
}

View file

@ -10,7 +10,8 @@ internal data class DesktopReaderOpening(
val title: String,
val formatLabel: String,
val returnTab: SharedAppTab,
val password: String? = null
val password: String? = null,
val startedAtNanos: Long = System.nanoTime()
)
internal sealed interface DesktopReaderOpenResult {

View file

@ -26,7 +26,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@ -49,11 +48,8 @@ import com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.ReaderAiModelOption
import com.aryan.reader.shared.ReaderAiModelOptions
import com.aryan.reader.shared.ReaderAiResultState
import com.aryan.reader.shared.ReaderAutoScrollState
import com.aryan.reader.shared.ReaderCloudTtsVoices
import com.aryan.reader.shared.ReaderExtrasState
import com.aryan.reader.shared.ReaderExternalLookupAction
import com.aryan.reader.shared.ReaderTtsReadScope
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.maskedReaderAiKey
import com.aryan.reader.shared.ui.SharedMarkdownText
@ -197,7 +193,7 @@ internal fun DesktopAiByokSettingsDialog(
DesktopSavedAiKeyRow(
label = readerString("provider_gemini", "Gemini"),
keyValue = sanitized.geminiKey,
onClear = { onSettingsChange(sanitized.copy(geminiKey = "", ttsModel = "")) }
onClear = { onSettingsChange(sanitized.copy(geminiKey = "")) }
)
DesktopSavedAiKeyRow(
label = readerString("provider_groq", "Groq"),
@ -250,26 +246,6 @@ internal fun DesktopAiByokSettingsDialog(
HorizontalDivider()
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(readerString("options_show_ai_in_reader", "Show AI in reader"), style = MaterialTheme.typography.titleMedium)
Text(
readerString(
"desktop_show_ai_in_reader_desc",
"Matches the Android hide toggle for smart dictionary, summaries, and recaps."
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = !sanitized.hideReaderAiFeatures,
onCheckedChange = { enabled ->
onSettingsChange(sanitized.copy(hideReaderAiFeatures = !enabled))
}
)
}
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(readerString("ai_settings_use_one_model", "Use one model for all features"), style = MaterialTheme.typography.titleMedium)
@ -320,31 +296,6 @@ internal fun DesktopAiByokSettingsDialog(
options = listOf(ReaderAiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)),
onSelected = { onSettingsChange(sanitized.copy(ttsModel = it)) }
)
Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
ReaderCloudTtsVoices.chunked(3).forEach { rowVoices ->
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
rowVoices.forEach { voice ->
FilterChip(
selected = sanitized.ttsSpeakerId == voice.id,
onClick = { onSettingsChange(sanitized.copy(ttsSpeakerId = voice.id)) },
label = {
Column {
Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
voice.description,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
)
}
}
}
}
}
},
confirmButton = {
@ -405,53 +356,21 @@ private fun DesktopAiModelSelector(
}
@Composable
internal fun DesktopPdfExtrasPanel(
pageText: String,
internal fun DesktopPdfTtsPanel(
extrasState: ReaderExtrasState,
aiByokSettings: ReaderAiByokSettings,
externalLookupAvailable: Boolean,
cloudTtsFeatureAvailable: Boolean,
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
onOpenAiHub: (() -> Unit)? = null,
onCloudTtsStart: (ReaderTtsReadScope) -> Unit,
onCloudTtsPauseResume: () -> Unit,
onCloudTtsStop: () -> Unit,
onCloudTtsClearCache: () -> Unit,
onAutoScrollChange: (ReaderAutoScrollState) -> Unit,
onCloudTtsVoiceChange: (String) -> Unit,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
ttsReplacementBookId: String,
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit
) {
val settings = aiByokSettings.sanitized()
val autoScroll = extrasState.autoScroll.sanitized()
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(readerString("desktop_extras", "Extras"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (externalLookupAvailable) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
ReaderExternalLookupAction.entries.forEach { action ->
FilterChip(
selected = false,
enabled = pageText.isNotBlank(),
onClick = { onExternalLookup(action, pageText) },
label = { Text(action.title) }
)
}
}
}
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(readerString("menu_auto_scroll", "Auto Scroll"), modifier = Modifier.weight(1f))
Switch(
checked = autoScroll.enabled,
onCheckedChange = { onAutoScrollChange(autoScroll.copy(enabled = it)) }
)
}
Slider(
value = autoScroll.speed,
onValueChange = { onAutoScrollChange(autoScroll.copy(speed = it).sanitized()) },
valueRange = 12f..160f
)
Text(readerString("menu_tts_settings", "TTS"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
if (cloudTtsFeatureAvailable) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
@ -476,40 +395,38 @@ internal fun DesktopPdfExtrasPanel(
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
TextButton(
enabled = settings.isCloudTtsAvailable || ttsBusy,
onClick = {
if (ttsBusy) {
onCloudTtsStop()
} else {
onCloudTtsStart(ReaderTtsReadScope.BOOK)
}
}
) {
Text(if (ttsBusy) readerString("action_stop", "Stop") else readerString("action_read", "Read"))
}
}
if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), fontWeight = FontWeight.SemiBold)
if (ttsBusy) {
Text(
readerString("desktop_stop_reading_change_voices", "Stop reading to change voices."),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
TextButton(onClick = onCloudTtsPauseResume) {
Text(if (extrasState.cloudTts.isPaused) readerString("tooltip_tts_resume", "Resume") else readerString("tooltip_tts_pause", "Pause"))
ReaderCloudTtsVoices.forEach { voice ->
FilterChip(
selected = settings.ttsSpeakerId == voice.id,
enabled = !ttsBusy,
onClick = { onCloudTtsVoiceChange(voice.id) },
label = {
Column {
Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
voice.description,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
TextButton(
enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(),
onClick = { onCloudTtsStart(ReaderTtsReadScope.PAGE) }
) {
Text(readerString("desktop_page", "Page"))
}
TextButton(
enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(),
onClick = { onCloudTtsStart(ReaderTtsReadScope.BOOK) }
) {
Text(readerString("desktop_from_here", "From here"))
}
}
val cacheSummary = extrasState.cloudTts.cacheSummary
if (cacheSummary.hasCachedAudio) {
Text(
@ -529,12 +446,5 @@ internal fun DesktopPdfExtrasPanel(
bookId = ttsReplacementBookId,
onPreferencesChange = onTtsReplacementPreferencesChange
)
if (settings.areReaderAiFeaturesAvailable && onOpenAiHub != null) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
TextButton(onClick = onOpenAiHub) {
Text(readerString("desktop_ai_hub", "AI hub"))
}
}
}
}
}

View file

@ -1,5 +1,7 @@
package com.aryan.reader.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ColumnScope
@ -10,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -17,6 +20,8 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
@ -34,19 +39,23 @@ import com.aryan.reader.shared.ReaderAutoScrollState
import com.aryan.reader.shared.ReaderExtrasState
import com.aryan.reader.shared.ReaderExternalLookupAction
import com.aryan.reader.shared.ReaderHighlightPalette
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.ReaderToolbarPreferences
import com.aryan.reader.shared.ReaderTtsChunk
import com.aryan.reader.shared.ReaderTtsReadScope
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTheme
import com.aryan.reader.shared.reader.ReaderEngine
import com.aryan.reader.shared.reader.ReaderImageReference
import com.aryan.reader.shared.reader.ReaderLinkTarget
import com.aryan.reader.shared.reader.ReaderPage
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.ReaderSessionState
import com.aryan.reader.shared.reader.ReaderViewportSpec
import com.aryan.reader.shared.reader.SharedEpubPaginationCache
import com.aryan.reader.shared.reader.SharedMeasuredEpubPaginator
import com.aryan.reader.shared.reader.isRightToLeftPaginationEnabled
import com.aryan.reader.shared.reader.layoutSignature
import com.aryan.reader.shared.reduce
import com.aryan.reader.shared.ui.DesktopEpubNativeImage
@ -54,7 +63,12 @@ import com.aryan.reader.shared.ui.ReaderContentRenderPlan
import com.aryan.reader.shared.ui.SharedNativePaginatedReader
import com.aryan.reader.shared.ui.SharedNativeReaderSelectionAction
import com.aryan.reader.shared.ui.SharedReaderScreen
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.awt.EventQueue
import java.awt.Window
import java.awt.event.KeyEvent as AwtKeyEvent
@Composable
@ -64,8 +78,12 @@ internal fun DesktopReaderScreen(
onSessionChange: (ReaderSessionState) -> Unit,
onReturnToLibrary: (() -> Unit)? = null,
onFullscreenChange: (Boolean) -> Unit = {},
readerAwtWindow: Window? = null,
toolbarPreferences: ReaderToolbarPreferences,
onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit,
appThemeControls: (@Composable () -> Unit)? = null,
customReaderThemes: List<ReaderTheme>,
onCustomReaderThemesChange: (List<ReaderTheme>) -> Unit,
highlightPalette: ReaderHighlightPalette,
onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
@ -80,13 +98,13 @@ internal fun DesktopReaderScreen(
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
onAiAction: (ReaderAiFeature, String) -> Unit,
onAiResultDismiss: () -> Unit,
onCloudTtsToggle: (String) -> Unit,
onCloudTtsToggle: (String, ReaderLocator?) -> Unit,
onCloudTtsStart: (ReaderTtsReadScope, List<ReaderTtsChunk>) -> Unit,
onCloudTtsPauseResume: () -> Unit,
onCloudTtsStop: () -> Unit,
onCloudTtsClearCache: () -> Unit,
onCloudTtsVoiceChange: (String) -> Unit,
onOpenAiHub: (() -> Unit)? = null,
onAutoScrollChange: (ReaderAutoScrollState) -> Unit,
onDownloadReaderImage: (ReaderImageReference) -> Unit,
readerTextureDataUri: (String) -> String?,
readerCustomTextureIds: List<String>,
@ -119,6 +137,18 @@ internal fun DesktopReaderScreen(
cacheWriteScope = paginationCacheWriteScope
)
}
LaunchedEffect(session.reader.book.id) {
logDesktopReaderOpenTrace {
"event=desktop_text_reader_screen_composed bookId=\"${session.reader.book.id.logPreview(120)}\" " +
"title=\"${session.reader.book.title.logPreview(120)}\" mode=${session.reader.settings.readingMode} " +
"chapters=${session.reader.book.chapters.size} pages=${session.reader.pages.size} " +
"currentPage=${session.reader.currentPageIndex + 1} " +
"textChars=${session.reader.book.chapters.sumOf { it.plainText.length }} " +
"htmlChars=${session.reader.book.chapters.sumOf { it.htmlContent.length }} " +
"semanticBlocks=${session.reader.book.chapters.sumOf { it.semanticBlocks.size }} " +
"bookmarks=${session.bookmarks.size} highlights=${session.highlights.size}"
}
}
var readerViewport by remember(session.reader.book.id) { mutableStateOf(ReaderViewportSpec(0, 0)) }
val paginationLayoutSignature = session.reader.settings.layoutSignature()
val paginationContentSignature = remember(session.reader.book) {
@ -152,16 +182,40 @@ internal fun DesktopReaderScreen(
var completedMeasuredPaginationRequest by remember(session.reader.book.id) {
mutableStateOf<DesktopEpubPaginationRequest?>(null)
}
var completedMeasuredPaginationPages by remember(session.reader.book.id) {
mutableStateOf(emptyList<ReaderPage>())
}
var warmMeasuredPaginationRequest by remember(session.reader.book.id) {
mutableStateOf<DesktopEpubPaginationRequest?>(null)
}
var warmMeasuredPaginationPages by remember(session.reader.book.id) {
mutableStateOf(emptyList<ReaderPage>())
}
var runningMeasuredPaginationRequest by remember(session.reader.book.id) {
mutableStateOf<DesktopEpubPaginationRequest?>(null)
}
val paginatedLayoutReady = session.reader.settings.readingMode != ReaderReadingMode.PAGINATED ||
(measuredPaginationRequest != null && completedMeasuredPaginationRequest == measuredPaginationRequest)
val measuredPaginationPagesApplied = desktopMeasuredPaginationReady(
request = measuredPaginationRequest,
completedRequest = completedMeasuredPaginationRequest,
currentPages = session.reader.pages,
measuredPages = completedMeasuredPaginationPages
)
val warmMeasuredPaginationPagesApplied = desktopMeasuredPaginationReady(
request = measuredPaginationRequest,
completedRequest = warmMeasuredPaginationRequest,
currentPages = session.reader.pages,
measuredPages = warmMeasuredPaginationPages
)
val paginatedLayoutReady = desktopPaginatedLayoutReadyForDisplay(
readingMode = session.reader.settings.readingMode,
measuredPagesApplied = measuredPaginationPagesApplied
)
val latestSession by rememberUpdatedState(session)
val latestOnSessionChange by rememberUpdatedState(onSessionChange)
var externalLinkDialogUrl by remember { mutableStateOf<String?>(null) }
var lastHandledLink by remember { mutableStateOf<DesktopEpubHandledLink?>(null) }
var isFullscreen by remember(session.reader.book.id) { mutableStateOf(false) }
val desktopReaderExtrasState = readerExtrasState.copy(autoScroll = ReaderAutoScrollState())
val currentReaderFullscreen by rememberUpdatedState(isFullscreen)
val currentOnReaderFullscreenChange by rememberUpdatedState(onFullscreenChange)
@ -175,9 +229,12 @@ internal fun DesktopReaderScreen(
onDismiss = { externalLinkDialogUrl = null }
)
fun handleReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean {
val action = event.desktopReaderKeyNavigationOrNull(fullscreen = isFullscreen) ?: return false
fun handleReaderAwtKeyEvent(event: AwtKeyEvent): Boolean {
val currentSession = latestSession
val action = event.desktopReaderKeyNavigationOrNull(
fullscreen = isFullscreen,
rightToLeftPagination = currentSession.reader.settings.isRightToLeftPaginationEnabled()
) ?: return false
val nextSession = currentSession.reduceDesktopReaderKeyNavigation(action, readerEngine)
if (nextSession == null) {
if (action == DesktopReaderKeyNavigation.EXIT_FULLSCREEN && isFullscreen) {
@ -188,6 +245,43 @@ internal fun DesktopReaderScreen(
}
return true
}
fun handleReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean {
if (latestSession.isSearchActive) {
if (event.id == AwtKeyEvent.KEY_PRESSED && isFullscreen && event.keyCode == AwtKeyEvent.VK_ESCAPE) {
setReaderFullscreen(false)
return true
}
return false
}
return handleReaderAwtKeyEvent(event)
}
fun handleReaderGlobalShortcutAwtKeyEvent(event: AwtKeyEvent): Boolean {
if (event.id != AwtKeyEvent.KEY_PRESSED || !event.isControlDown) return false
val action = when (event.keyCode) {
AwtKeyEvent.VK_F -> DesktopReaderKeyNavigation.SEARCH
AwtKeyEvent.VK_G -> DesktopReaderKeyNavigation.NEXT_SEARCH
else -> return false
}
val nextSession = latestSession.reduceDesktopReaderKeyNavigation(action, readerEngine) ?: return false
latestOnSessionChange(nextSession)
return true
}
DesktopReaderKeyDispatcherEffect(
enabled = externalLinkDialogUrl == null,
allowChromeModalWindows = true,
onKeyPressed = { event -> handleReaderGlobalShortcutAwtKeyEvent(event) }
)
DesktopReaderKeyDispatcherEffect(
enabled = externalLinkDialogUrl == null && !session.isSearchActive,
allowPanelModalWindows = true,
dispatchWhenOwnerWindowActive = false,
onKeyPressed = { event -> handleReaderAwtKeyEvent(event) }
)
DesktopReaderFullscreenKeyEffect(
enabled = isFullscreen && externalLinkDialogUrl == null,
onKeyPressed = { event -> handleReaderFullscreenAwtKeyEvent(event) }
@ -196,6 +290,9 @@ internal fun DesktopReaderScreen(
LaunchedEffect(session.reader.settings.readingMode) {
if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) {
completedMeasuredPaginationRequest = null
completedMeasuredPaginationPages = emptyList()
warmMeasuredPaginationRequest = null
warmMeasuredPaginationPages = emptyList()
runningMeasuredPaginationRequest = null
}
}
@ -220,15 +317,113 @@ internal fun DesktopReaderScreen(
)
return@LaunchedEffect
}
delay(280L)
val settings = latestSession.reader.settings
if (settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect
if (settings.layoutSignature() != request.layoutSignature) return@LaunchedEffect
runningMeasuredPaginationRequest = request
try {
val cacheProbeStartedAt = System.nanoTime()
val cacheProbeSettings = latestSession.reader.settings
val cachedPages = if (
cacheProbeSettings.readingMode == ReaderReadingMode.PAGINATED &&
cacheProbeSettings.layoutSignature() == request.layoutSignature
) {
withContext(Dispatchers.Default) {
epubPaginationCache.loadMemory(
book = session.reader.book,
settings = cacheProbeSettings,
viewport = request.viewport,
density = request.density.density,
fontScale = request.density.fontScale
)
}
} else {
null
}
val settingsAfterCacheProbe = latestSession.reader.settings
if (settingsAfterCacheProbe.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect
if (settingsAfterCacheProbe.layoutSignature() != request.layoutSignature) return@LaunchedEffect
if (cachedPages != null) {
val cacheLayoutChanged = !latestSession.reader.pages.samePageLayoutAs(cachedPages)
logEpubPagination(
"cache_warm_result book=\"${session.reader.book.title.logPreview()}\" pages=${cachedPages.size} " +
"layoutChanged=$cacheLayoutChanged viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
"elapsedMs=${cacheProbeStartedAt.elapsedMillis()}"
)
if (cacheLayoutChanged) {
val cacheApplySession = latestSession
latestOnSessionChange(
readerEngine.replacePages(
state = cacheApplySession,
pages = cachedPages,
reflowAnchor = readerEngine.reflowAnchorFor(cacheApplySession)
)
)
}
completedMeasuredPaginationPages = cachedPages
completedMeasuredPaginationRequest = request
return@LaunchedEffect
}
val warmStartSession = latestSession
val warmAnchor = readerEngine.reflowAnchorFor(warmStartSession)
val warmChapterIndex = warmAnchor?.chapterIndex
?: warmStartSession.reader.currentPage?.chapterIndex
?: 0
val warmFirstPageIndex = warmStartSession.reader.pages.firstPageIndexForChapter(warmChapterIndex) ?: 0
val warmStartedAt = System.nanoTime()
logEpubPagination(
"chapter_warm_start book=\"${session.reader.book.title.logPreview()}\" chapter=$warmChapterIndex " +
"firstPage=${warmFirstPageIndex + 1} viewport=${request.viewport.widthPx}x${request.viewport.heightPx}"
)
val cachedWarmChapterPages = epubPaginationCache.loadChapter(
book = session.reader.book,
settings = settingsAfterCacheProbe,
viewport = request.viewport,
chapterIndex = warmChapterIndex,
density = request.density.density,
fontScale = request.density.fontScale
)
val warmChapterPages = cachedWarmChapterPages ?: withContext(Dispatchers.Default) {
measuredPaginator.paginateChapterWindow(
book = session.reader.book,
settings = settingsAfterCacheProbe,
viewport = request.viewport,
chapterIndex = warmChapterIndex,
firstPageIndex = warmFirstPageIndex
)
}
val warmPages = desktopPagesWithMeasuredChapter(
currentPages = warmStartSession.reader.pages,
chapterIndex = warmChapterIndex,
measuredChapterPages = warmChapterPages
)
val warmLayoutChanged = warmPages.isNotEmpty() && !warmStartSession.reader.pages.samePageLayoutAs(warmPages)
logEpubPagination(
"chapter_warm_result book=\"${session.reader.book.title.logPreview()}\" chapter=$warmChapterIndex " +
"source=${if (cachedWarmChapterPages != null) "cache" else "measured"} " +
"chapterPages=${warmChapterPages.size} pages=${warmPages.size} layoutChanged=$warmLayoutChanged " +
"elapsedMs=${warmStartedAt.elapsedMillis()}"
)
if (warmLayoutChanged) {
logReaderModeSwitch(
"pagination_warm_apply_dispatch requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
"chapter=$warmChapterIndex chapterPages=${warmChapterPages.size} currentPages=${warmStartSession.reader.pages.size}"
)
latestOnSessionChange(
readerEngine.replacePages(
state = warmStartSession,
pages = warmPages,
reflowAnchor = warmAnchor
)
)
warmMeasuredPaginationPages = warmPages
warmMeasuredPaginationRequest = request
}
val reflowStartSession = latestSession
val reflowStartRequestId = reflowStartSession.navigationRequestId
val reflowAnchor = readerEngine.reflowAnchorFor(reflowStartSession)
val settings = reflowStartSession.reader.settings
if (settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect
if (settings.layoutSignature() != request.layoutSignature) return@LaunchedEffect
logEpubPagination(
"reflow_start book=\"${session.reader.book.title.logPreview()}\" " +
"viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
@ -237,17 +432,35 @@ internal fun DesktopReaderScreen(
"pageWidthSetting=${settings.pageWidth} oldPages=${reflowStartSession.reader.pages.size} " +
"anchorPage=${reflowAnchor?.pageIndex} anchorOffsets=${reflowAnchor?.startOffset}..${reflowAnchor?.endOffset}"
)
val pages = measuredPaginator.paginate(
book = session.reader.book,
settings = settings,
viewport = request.viewport
)
val pages = withContext(Dispatchers.Default) {
measuredPaginator.paginate(
book = session.reader.book,
settings = settings,
viewport = request.viewport,
readCache = true
)
}
val layoutChanged = pages.isNotEmpty() && !latestSession.reader.pages.samePageLayoutAs(pages)
logEpubPagination(
"reflow_result book=\"${session.reader.book.title.logPreview()}\" pages=${pages.size} " +
"layoutChanged=$layoutChanged currentPages=${latestSession.reader.pages.size}"
)
val currentVisiblePageDetails = latestSession.reader.visiblePages.map { page ->
"${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}"
}
val measuredCurrentPageDetails = pages.getOrNull(latestSession.reader.currentPageIndex)
?.let { page -> "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" }
?: "none"
logReaderModeSwitch(
"pagination_result requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
"measuredPages=${pages.size} currentPages=${latestSession.reader.pages.size} layoutChanged=$layoutChanged " +
"currentVisible=$currentVisiblePageDetails measuredAtCurrent=$measuredCurrentPageDetails"
)
if (layoutChanged) {
logReaderModeSwitch(
"pagination_apply_dispatch requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
"measuredPages=${pages.size} currentPages=${latestSession.reader.pages.size}"
)
latestOnSessionChange(
readerEngine.replacePages(
state = latestSession,
@ -258,8 +471,16 @@ internal fun DesktopReaderScreen(
)
}
if (pages.isNotEmpty()) {
completedMeasuredPaginationPages = pages
completedMeasuredPaginationRequest = request
}
} catch (error: Throwable) {
if (error is CancellationException) throw error
logEpubPagination(
"reflow_failed book=\"${session.reader.book.title.logPreview()}\" " +
"viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
"error=\"${error.message.orEmpty().logPreview(300)}\""
)
} finally {
if (runningMeasuredPaginationRequest == request) {
runningMeasuredPaginationRequest = null
@ -267,16 +488,45 @@ internal fun DesktopReaderScreen(
}
}
val handleDesktopSelectionAction: (DesktopReaderSelectionAction, String) -> Unit = { action, text ->
LaunchedEffect(
measuredPaginationRequest,
completedMeasuredPaginationRequest,
completedMeasuredPaginationPages,
session.reader.pages
) {
val request = measuredPaginationRequest ?: return@LaunchedEffect
val measuredPages = completedMeasuredPaginationPages
if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect
if (completedMeasuredPaginationRequest != request || measuredPages.isEmpty()) return@LaunchedEffect
if (session.reader.pages.samePageLayoutAs(measuredPages)) return@LaunchedEffect
val currentVisiblePageDetails = session.reader.visiblePages.map { page ->
"${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}"
}
logReaderModeSwitch(
"pagination_apply_pending requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
"currentPages=${session.reader.pages.size} measuredPages=${measuredPages.size} " +
"currentVisible=$currentVisiblePageDetails"
)
onSessionChange(
readerEngine.replacePages(
state = session,
pages = measuredPages,
reflowAnchor = readerEngine.reflowAnchorFor(session)
)
)
}
val handleDesktopSelectionAction: (DesktopReaderSelectionAction, String, ReaderLocator?) -> Unit = { action, text, locator ->
val settings = aiByokSettings.sanitized()
when (action) {
DesktopReaderSelectionAction.DEFINE -> {
if (settings.areReaderAiFeaturesAvailable) onAiAction(ReaderAiFeature.DEFINE, text)
}
DesktopReaderSelectionAction.SPEAK -> {
if (settings.isCloudTtsAvailable) onCloudTtsToggle(text)
if (settings.isCloudTtsAvailable) onCloudTtsToggle(text, locator)
}
DesktopReaderSelectionAction.SEARCH -> onExternalLookup(ReaderExternalLookupAction.SEARCH, text)
DesktopReaderSelectionAction.PALETTE -> Unit
}
}
val nativeSelectionActions = buildSet {
@ -285,14 +535,14 @@ internal fun DesktopReaderScreen(
if (externalLookupAvailable) add(SharedNativeReaderSelectionAction.SEARCH)
if (settings.isCloudTtsAvailable) add(SharedNativeReaderSelectionAction.SPEAK)
}
val handleNativeSelectionAction: (SharedNativeReaderSelectionAction, String) -> Unit = { action, text ->
val handleNativeSelectionAction: (SharedNativeReaderSelectionAction, String, ReaderLocator?) -> Unit = { action, text, locator ->
when (action) {
SharedNativeReaderSelectionAction.DEFINE ->
handleDesktopSelectionAction(DesktopReaderSelectionAction.DEFINE, text)
handleDesktopSelectionAction(DesktopReaderSelectionAction.DEFINE, text, locator)
SharedNativeReaderSelectionAction.SPEAK ->
handleDesktopSelectionAction(DesktopReaderSelectionAction.SPEAK, text)
handleDesktopSelectionAction(DesktopReaderSelectionAction.SPEAK, text, locator)
SharedNativeReaderSelectionAction.SEARCH ->
handleDesktopSelectionAction(DesktopReaderSelectionAction.SEARCH, text)
handleDesktopSelectionAction(DesktopReaderSelectionAction.SEARCH, text, locator)
}
}
val handleDesktopEpubLinkClicked: (DesktopEpubLinkClick) -> Unit = { link ->
@ -340,6 +590,9 @@ internal fun DesktopReaderScreen(
onFullscreenChange = ::setReaderFullscreen,
toolbarPreferences = toolbarPreferences,
onToolbarPreferencesChange = onToolbarPreferencesChange,
appThemeControls = appThemeControls,
customReaderThemes = customReaderThemes,
onCustomReaderThemesChange = onCustomReaderThemesChange,
highlightPalette = highlightPalette,
onHighlightPaletteChange = onHighlightPaletteChange,
ttsReplacementPreferences = ttsReplacementPreferences,
@ -347,7 +600,7 @@ internal fun DesktopReaderScreen(
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange,
onPickCustomFont = onPickCustomFont,
customFonts = customFonts,
readerExtrasState = readerExtrasState,
readerExtrasState = desktopReaderExtrasState,
aiByokSettings = aiByokSettings,
externalLookupAvailable = externalLookupAvailable,
cloudTtsControlsAvailable = cloudTtsControlsAvailable,
@ -359,8 +612,8 @@ internal fun DesktopReaderScreen(
onCloudTtsPauseResume = onCloudTtsPauseResume,
onCloudTtsStop = onCloudTtsStop,
onCloudTtsClearCache = onCloudTtsClearCache,
onCloudTtsVoiceChange = onCloudTtsVoiceChange,
onOpenAiHub = onOpenAiHub,
onAutoScrollChange = onAutoScrollChange,
onDownloadReaderImage = onDownloadReaderImage,
readerImagePreviewContent = { image, previewModifier ->
DesktopEpubNativeImage(
@ -369,35 +622,143 @@ internal fun DesktopReaderScreen(
)
},
readerTextureDataUri = readerTextureDataUri,
readerTexturePreviewContent = { textureId, previewModifier ->
DesktopReaderTexturePreview(textureId = textureId, modifier = previewModifier)
},
readerCustomTextureIds = readerCustomTextureIds,
onImportReaderTexture = onImportReaderTexture,
bottomChromeExtraContent = bottomChromeExtraContent,
useDetachedChromeLayer = useDetachedChromeLayer,
useDetachedPanelLayer = useDetachedPanelLayer
) { renderPlan, onVisiblePageChanged, onHighlightSelected, onChromeActivity ->
Surface(
color = renderPlan.background,
shape = RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp))
.onSizeChanged { size ->
val next = ReaderViewportSpec(size.width, size.height)
logReaderGap(
"desktop_epub_reader_surface size=${size.width}x${size.height} " +
"mode=${session.reader.settings.readingMode} " +
"page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}"
) { renderPlan, onVisiblePageChanged, onHighlightSelected, onOpenHighlightPaletteManager, onChromeActivity ->
val renderPlanModeKey = renderPlan.desktopReaderSurfaceModeKey()
val readerSurfaceKey = renderPlan.desktopReaderSurfaceContentKey(paginatedLayoutReady)
val readerModeSwitchLayoutModifier =
if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages) {
Modifier.onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
logReaderModeSwitch(
"native_surface_layout modeKey=$renderPlanModeKey surfaceKey=$readerSurfaceKey " +
"paginatedReady=$paginatedLayoutReady size=${coordinates.size.width}x${coordinates.size.height} " +
"windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " +
"${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()}"
)
if (next != readerViewport) {
logEpubPagination(
"viewport_changed width=${next.widthPx} height=${next.heightPx} " +
"previous=${readerViewport.widthPx}x${readerViewport.heightPx}"
)
readerViewport = next
}
}
} else {
Modifier
}
val readerSurfaceModifier = Modifier
.fillMaxWidth()
.weight(1f)
.onSizeChanged { size ->
val next = ReaderViewportSpec(size.width, size.height)
logReaderGap(
"desktop_epub_reader_surface size=${size.width}x${size.height} " +
"mode=${session.reader.settings.readingMode} " +
"page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}"
)
logEpubCutoff(
"cutoff_probe layer=desktop_surface size=${size.width}x${size.height} " +
"mode=${session.reader.settings.readingMode} spread=${session.reader.settings.pageSpreadMode} " +
"page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)} " +
"margins=${session.reader.settings.resolvedHorizontalMargin}x${session.reader.settings.resolvedVerticalMargin} " +
"pageWidthSetting=${session.reader.settings.pageWidth}"
)
logWebViewLayoutDiag(
"compose_reader_surface size=${size.width}x${size.height} " +
"renderPlan=${if (renderPlan is ReaderContentRenderPlan.WebDocument) "web" else "native"} " +
"mode=${session.reader.settings.readingMode} " +
"fullscreen=$isFullscreen margins=${session.reader.settings.resolvedHorizontalMargin}x${session.reader.settings.resolvedVerticalMargin} " +
"pageWidth=${session.reader.settings.pageWidth} fontSize=${session.reader.settings.fontSize} " +
"lineSpacing=${session.reader.settings.lineSpacing} textAlign=${session.reader.settings.textAlign} " +
"paragraphSpacing=${session.reader.settings.paragraphSpacing} imageScale=${session.reader.settings.imageScale}"
)
logDesktopReaderOpenTrace {
"event=desktop_reader_surface_size bookId=\"${session.reader.book.id.logPreview(120)}\" " +
"title=\"${session.reader.book.title.logPreview(120)}\" size=${size.width}x${size.height} " +
"renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} mode=${session.reader.settings.readingMode} " +
"page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}"
}
if (next != readerViewport) {
logEpubPagination(
"viewport_changed width=${next.widthPx} height=${next.heightPx} " +
"previous=${readerViewport.widthPx}x${readerViewport.heightPx}"
)
readerViewport = next
}
}
LaunchedEffect(
renderPlanModeKey,
session.reader.settings.readingMode,
paginatedLayoutReady
) {
logReaderModeSwitch(
"surface_state modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " +
"renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " +
"paginatedReady=$paginatedLayoutReady runningPagination=${runningMeasuredPaginationRequest != null} " +
"completedPagination=${completedMeasuredPaginationRequest != null} measuredApplied=$measuredPaginationPagesApplied " +
"warmApplied=$warmMeasuredPaginationPagesApplied warmPageCount=${warmMeasuredPaginationPages.size} " +
"completedMatchesRequest=${completedMeasuredPaginationRequest == measuredPaginationRequest} " +
"measuredPageCount=${completedMeasuredPaginationPages.size} " +
"currentPage=${session.reader.currentPageIndex + 1} " +
"pageCount=${session.reader.pages.size} visiblePages=${session.reader.visiblePages.map { it.pageIndex + 1 }} " +
"fullscreen=$isFullscreen surfaceKey=$readerSurfaceKey"
)
logDesktopReaderOpenTrace {
"event=desktop_render_plan_ready bookId=\"${session.reader.book.id.logPreview(120)}\" " +
"title=\"${session.reader.book.title.logPreview(120)}\" " +
"renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} mode=${session.reader.settings.readingMode} " +
"viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " +
"page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)} " +
"htmlChars=${(renderPlan as? ReaderContentRenderPlan.WebDocument)?.html?.length ?: 0} " +
"paginatedReady=$paginatedLayoutReady"
}
if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages) {
cleanupRetiredDesktopWebView2InteropHosts(
readerAwtWindow,
"native_surface_state_ready_$paginatedLayoutReady"
)
logDesktopWebView2ModeSwitchSnapshot("surface_state_${renderPlanModeKey}_after_sweep_request")
readerAwtWindow.requestDesktopReaderModeSwitchRepaint(
"native_surface_state_ready_$paginatedLayoutReady"
)
DesktopReaderModeSwitchProbeDelaysMillis.forEach { delayMillis ->
delay(delayMillis)
cleanupRetiredDesktopWebView2InteropHosts(
readerAwtWindow,
"native_probe_after_${delayMillis}ms_ready_$paginatedLayoutReady"
)
readerAwtWindow.requestDesktopReaderModeSwitchRepaint(
"native_probe_after_${delayMillis}ms_ready_$paginatedLayoutReady"
)
logReaderModeSwitch(
"native_probe_after delayMs=$delayMillis modeKey=$renderPlanModeKey " +
"viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " +
"paginatedReady=$paginatedLayoutReady currentPage=${session.reader.currentPageIndex + 1} " +
"visiblePages=${session.reader.visiblePages.map { it.pageIndex + 1 }}"
)
logDesktopWebView2ModeSwitchSnapshot("native_probe_after_${delayMillis}ms")
}
} else {
logDesktopWebView2ModeSwitchSnapshot("surface_state_$renderPlanModeKey")
}
}
DisposableEffect(renderPlanModeKey) {
logReaderModeSwitch(
"surface_enter modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " +
"renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()}"
)
logDesktopWebView2ModeSwitchSnapshot("surface_enter_$renderPlanModeKey")
onDispose {
logReaderModeSwitch(
"surface_exit modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " +
"renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()}"
)
logDesktopWebView2ModeSwitchSnapshot("surface_exit_$renderPlanModeKey")
}
}
@Composable
fun ReaderSurfaceContent() {
if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages && !paginatedLayoutReady) {
DesktopEpubPaginationPreparing(
active = runningMeasuredPaginationRequest != null,
@ -406,14 +767,41 @@ internal fun DesktopReaderScreen(
} else {
when (renderPlan) {
is ReaderContentRenderPlan.WebDocument -> {
if (webViewRuntimeState.initialized) {
val canRenderWebDocument = desktopEpubWebViewCanRender(webViewRuntimeState)
LaunchedEffect(
renderPlan.html,
canRenderWebDocument,
webViewRuntimeState,
webViewNetworkAccessEnabled
) {
logDesktopWebView2(
"reader_screen_web_document canRender=$canRenderWebDocument " +
"backend=${desktopEpubWebViewBackend().logName} " +
"runtimeInitialized=${webViewRuntimeState.initialized} restart=${webViewRuntimeState.restartRequired} " +
"error=${webViewRuntimeState.errorMessage != null} network=$webViewNetworkAccessEnabled " +
"htmlChars=${renderPlan.html.length} htmlHash=${renderPlan.html.hashCode()}"
)
}
if (canRenderWebDocument) {
DesktopEpubWebView(
html = renderPlan.html,
appearanceScript = renderPlan.appearanceScript,
highlightPaletteScript = renderPlan.highlightPaletteScript,
navigationTarget = renderPlan.navigationTarget,
highlights = renderPlan.highlights,
onHighlightCreated = { highlight ->
onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine))
logEpubHighlightFlow(
"state_reduce_start id=${highlight.id} before=${session.highlights.size} " +
"color=${highlight.color.id} chapter=${highlight.chapterIndex} " +
"offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " +
"page=${highlight.locator.pageIndex} textChars=${highlight.text.length}"
)
val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine)
logEpubHighlightFlow(
"state_reduce_done id=${highlight.id} after=${nextSession.highlights.size} " +
"contains=${nextSession.highlights.any { it.id == highlight.id }}"
)
onSessionChange(nextSession)
},
onHighlightSelected = onHighlightSelected,
isFullscreen = isFullscreen,
@ -427,11 +815,18 @@ internal fun DesktopReaderScreen(
onSessionChange(nextSession)
}
},
onSelectionAction = handleDesktopSelectionAction,
onSelectionAction = { payload ->
if (payload.action == DesktopReaderSelectionAction.PALETTE) {
onOpenHighlightPaletteManager()
} else {
handleDesktopSelectionAction(payload.action, payload.text, payload.locator)
}
},
onLinkClicked = handleDesktopEpubLinkClicked,
onVisiblePageChanged = onVisiblePageChanged,
onPointerActivity = onChromeActivity,
networkAccessEnabled = webViewNetworkAccessEnabled,
backgroundColor = renderPlan.background,
modifier = Modifier.fillMaxSize()
)
} else {
@ -442,36 +837,157 @@ internal fun DesktopReaderScreen(
}
}
is ReaderContentRenderPlan.NativePaginatedPages -> {
SharedNativePaginatedReader(
renderPlan = renderPlan,
readerFontFamily = renderPlan.settings.toDesktopReaderFontFamily(),
searchHighlight = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f),
onVisiblePageChanged = onVisiblePageChanged,
enabledSelectionActions = nativeSelectionActions,
onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) },
onSelectionAction = handleNativeSelectionAction,
onHighlightCreated = { highlight ->
onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine))
},
onHighlightSelected = onHighlightSelected,
onLinkClicked = { link ->
handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick())
},
imageContent = { image, imageModifier ->
DesktopEpubNativeImage(
image = image,
modifier = imageModifier
)
},
modifier = Modifier.fillMaxSize()
)
LaunchedEffect(renderPlan.visiblePages, paginatedLayoutReady) {
val pageDetails = renderPlan.visiblePages.joinToString(prefix = "[", postfix = "]") { page ->
"${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}"
}
logReaderModeSwitch(
"native_reader_render paginatedReady=$paginatedLayoutReady " +
"visiblePages=${renderPlan.visiblePages.map { it.pageIndex + 1 }} " +
"pageDetails=$pageDetails " +
"background=${renderPlan.background} foreground=${renderPlan.foreground}"
)
}
Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
logReaderModeSwitch(
"native_reader_content_layout size=${coordinates.size.width}x${coordinates.size.height} " +
"windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " +
"${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()} " +
"visiblePages=${renderPlan.visiblePages.map { it.pageIndex + 1 }}"
)
}
) {
SharedNativePaginatedReader(
renderPlan = renderPlan,
readerFontFamily = renderPlan.settings.toDesktopReaderFontFamily(),
searchHighlight = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f),
onVisiblePageChanged = onVisiblePageChanged,
enabledSelectionActions = nativeSelectionActions,
onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) },
onSelectionAction = handleNativeSelectionAction,
onOpenHighlightPaletteManager = onOpenHighlightPaletteManager,
onHighlightCreated = { highlight ->
logDesktopHighlightMap(
"native_state_reduce_start id=${highlight.id.logPreview(80)} before=${session.highlights.size} " +
"color=${highlight.color.id} chapter=${highlight.chapterIndex} " +
"page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " +
"block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " +
"textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview(160)}\""
)
val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine)
logDesktopHighlightMap(
"native_state_reduce_done id=${highlight.id.logPreview(80)} after=${nextSession.highlights.size} " +
"contains=${nextSession.highlights.any { it.id == highlight.id }}"
)
onSessionChange(nextSession)
},
onHighlightSelected = onHighlightSelected,
onLinkClicked = { link ->
handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick())
},
onReaderTap = onChromeActivity,
imageContent = { image, imageModifier ->
DesktopEpubNativeImage(
image = image,
modifier = imageModifier
)
},
modifier = Modifier.fillMaxSize()
)
}
}
}
}
}
key(readerSurfaceKey) {
if (renderPlan is ReaderContentRenderPlan.WebDocument) {
Box(
modifier = readerSurfaceModifier
.fillMaxSize()
.background(renderPlan.background)
) {
ReaderSurfaceContent()
}
} else {
Surface(
color = renderPlan.background,
shape = RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp),
modifier = readerSurfaceModifier
.fillMaxSize()
.clip(RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp))
.then(readerModeSwitchLayoutModifier)
) {
ReaderSurfaceContent()
}
}
}
}
}
private fun ReaderContentRenderPlan.desktopReaderSurfaceModeKey(): String {
return when (this) {
is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web"
is ReaderContentRenderPlan.NativePaginatedPages -> "desktop-reader-native"
}
}
private fun ReaderContentRenderPlan.desktopReaderSurfaceModeLabel(): String {
return when (this) {
is ReaderContentRenderPlan.WebDocument -> "web"
is ReaderContentRenderPlan.NativePaginatedPages -> "native"
}
}
private fun ReaderContentRenderPlan.desktopReaderSurfaceContentKey(paginatedLayoutReady: Boolean): String {
return when (this) {
is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web"
is ReaderContentRenderPlan.NativePaginatedPages ->
"desktop-reader-native-${if (paginatedLayoutReady) "ready" else "preparing"}"
}
}
private fun Window?.requestDesktopReaderModeSwitchRepaint(reason: String) {
val targetWindow = this
EventQueue.invokeLater {
if (targetWindow == null) {
logReaderModeSwitch("awt_repaint_skip reason=$reason window=null")
return@invokeLater
}
if (!targetWindow.isDisplayable) {
logReaderModeSwitch(
"awt_repaint_skip reason=$reason window=${targetWindow.javaClass.simpleName} " +
"displayable=false visible=${targetWindow.isVisible} showing=${targetWindow.isShowing} " +
"size=${targetWindow.width}x${targetWindow.height}"
)
return@invokeLater
}
targetWindow.invalidate()
targetWindow.validate()
targetWindow.repaint()
(targetWindow as? javax.swing.RootPaneContainer)?.contentPane?.let { contentPane ->
contentPane.invalidate()
contentPane.validate()
contentPane.repaint()
}
logReaderModeSwitch(
"awt_repaint reason=$reason window=${targetWindow.javaClass.simpleName} " +
"visible=${targetWindow.isVisible} displayable=${targetWindow.isDisplayable} " +
"showing=${targetWindow.isShowing} size=${targetWindow.width}x${targetWindow.height}"
)
}
}
private val DesktopReaderModeSwitchProbeDelaysMillis = longArrayOf(120L, 350L, 900L)
private fun Long.elapsedMillis(): Long {
return ((System.nanoTime() - this) / 1_000_000L).coerceAtLeast(0L)
}
private fun ReaderImageReference.toDesktopPreviewSemanticImage(): SemanticImage {
return SemanticImage(
path = source,

View file

@ -0,0 +1,43 @@
package com.aryan.reader.desktop
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
@Composable
internal fun DesktopReaderTexturePreview(
textureId: String,
modifier: Modifier = Modifier
) {
val bitmap = remember(textureId) { DesktopReaderTextures.imageBitmapFor(textureId) }
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier
)
} else {
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Text(
"Aa",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
}
}
}

View file

@ -35,7 +35,7 @@ internal fun List<ReaderPage>.samePageLayoutAs(other: List<ReaderPage>): Boolean
left.startOffset == right.startOffset &&
left.endOffset == right.endOffset &&
left.text.length == right.text.length &&
left.semanticBlocks.size == right.semanticBlocks.size
left.semanticBlocks == right.semanticBlocks
}
}

View file

@ -1,19 +1,53 @@
package com.aryan.reader.desktop
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowPlacement
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.ReaderCloudTtsState
import com.aryan.reader.shared.ReaderExtrasState
import com.aryan.reader.shared.RecapResult
import com.aryan.reader.shared.SummarizationResult
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSessionState
import kotlinx.coroutines.Job
internal const val DesktopReaderWindowDefaultWidthDp = 1120f
internal const val DesktopReaderWindowDefaultHeightDp = 760f
internal val DesktopReaderWindowDefaultSize = DpSize(
DesktopReaderWindowDefaultWidthDp.dp,
DesktopReaderWindowDefaultHeightDp.dp
)
internal fun DesktopWindowStateSnapshot.toReaderWindowPlacement(): WindowPlacement {
return when (placement) {
DesktopSavedWindowPlacement.FULLSCREEN -> WindowPlacement.Floating
else -> toWindowPlacement()
}
}
internal fun DesktopWindowStateSnapshot.toPersistableReaderWindowSnapshot(): DesktopWindowStateSnapshot? {
if (placement == DesktopSavedWindowPlacement.FULLSCREEN) return null
return sanitized()
}
internal fun shouldResetDesktopTextReaderWindowSurface(
previousMode: ReaderReadingMode,
currentMode: ReaderReadingMode,
usesNativeWebView: Boolean
): Boolean {
return usesNativeWebView &&
previousMode == ReaderReadingMode.VERTICAL &&
currentMode == ReaderReadingMode.PAGINATED
}
internal data class DesktopReaderWindowState(
val id: String,
val opening: DesktopReaderOpening,
val content: DesktopReaderWindowContent = DesktopReaderWindowContent.Opening,
val focusRequestId: Long = 0L,
val fullscreen: Boolean = false
val fullscreen: Boolean = false,
val surfaceResetId: Long = 0L
) {
val bookId: String
get() = opening.bookId
@ -57,7 +91,6 @@ internal sealed interface DesktopReaderWindowContent {
val isSummaryLoading: Boolean = false,
val isRecapLoading: Boolean = false,
val recapProgressMessage: String? = null,
val showCloudTtsSettings: Boolean = false,
val ttsJob: Job? = null
) : DesktopReaderWindowContent
}

View file

@ -1,18 +1,39 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.ReaderTtsChunk
private const val DesktopTtsLogTag = "EpistemeDesktopTts"
private const val DesktopTtsStartTraceLogTag = "EpistemeDesktopTtsStartTrace"
private val DesktopTtsSensitiveQueryRegex = Regex("""(?i)([?&](?:key|token)=)[^&\s"]+""")
private val DesktopTtsSensitiveLabelRegex = Regex(
"""(?i)\b((?:geminiKey|groqKey|api[_-]?key|authorization|token)\s*[:=]\s*)[^\s,;"]+"""
)
internal fun logDesktopTts(message: String) {
logDesktopDiagnostic(DesktopTtsLogTag) { message }
}
internal fun logDesktopTtsStartTrace(message: () -> String) {
logDesktopDiagnostic(DesktopTtsStartTraceLogTag, message)
}
internal fun ReaderTtsChunk?.desktopTtsStartTraceSummary(maxTextLength: Int = 120): String {
if (this == null) return "null"
return "index=$index page=${pageIndex + 1} chapter=$chapterIndex " +
"offsets=$startOffset..$endOffset sourceCfi=\"${sourceCfi.orEmpty().logPreview(160)}\" " +
"textChars=${text.length} spokenChars=${spokenText.length} " +
"text=\"${text.logPreview(maxTextLength)}\" spoken=\"${spokenText.logPreview(maxTextLength)}\""
}
internal fun Throwable.desktopTtsSummary(): String {
val type = this::class.java.simpleName.ifBlank { "Throwable" }
return "$type: ${message.orEmpty().desktopTtsPreview(220)}"
}
internal fun String.desktopTtsPreview(maxLength: Int = 120): String {
return replace(Regex("\\s+"), " ")
return replace(DesktopTtsSensitiveQueryRegex) { match -> match.groupValues[1] + "<redacted>" }
.replace(DesktopTtsSensitiveLabelRegex) { match -> match.groupValues[1] + "<redacted>" }
.replace(Regex("\\s+"), " ")
.trim()
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
.replace("\"", "\\\"")

View file

@ -151,5 +151,9 @@ internal class DesktopWindowStateStore(
fun defaultWindowStateFile(): File {
return File(desktopUserConfigRoot(), "window_state.json")
}
fun defaultReaderWindowStateFile(): File {
return File(desktopUserConfigRoot(), "reader_window_state.json")
}
}
}

View file

@ -86,6 +86,54 @@ class DesktopAiByokStoreTest {
assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel)
}
@Test
fun `save with blank key clears protected secret entry`() {
val settingsFile = Files.createTempDirectory("reader-ai-store-clear").resolve("ai-byok.properties")
val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec)
store.save(
ReaderAiByokSettings(
geminiKey = "gemini_secret",
groqKey = "groq_secret",
modelForAll = "groq:qwen/qwen3-32b"
)
)
store.save(
ReaderAiByokSettings(
groqKey = "groq_secret",
modelForAll = "groq:qwen/qwen3-32b"
)
)
val raw = settingsFile.readText()
assertFalse(raw.contains("geminiKeyProtected="))
assertTrue(raw.contains("groqKeyProtected="))
val loaded = store.load()
assertEquals("", loaded.geminiKey)
assertEquals("groq_secret", loaded.groqKey)
assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll)
}
@Test
fun `load ignores legacy hidden reader ai preference on desktop`() {
val settingsFile = Files.createTempDirectory("reader-ai-store-visible").resolve("ai-byok.properties")
settingsFile.writeText(
"""
hideReaderAiFeatures=true
modelForAll=groq:qwen/qwen3-32b
useOneModel=true
""".trimIndent()
)
val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec)
val loaded = store.load()
assertFalse(loaded.hideReaderAiFeatures)
assertTrue(loaded.useOneModel)
assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll)
}
@Test
fun `load does not probe secure storage when settings file is missing`() {
val settingsFile = Files.createTempDirectory("reader-ai-store-missing").resolve("ai-byok.properties")

View file

@ -0,0 +1,85 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.UserData
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DesktopAuthStoreTest {
@Test
fun `save protects refresh tokens and load restores the account`() {
val settingsFile = Files.createTempDirectory("reader-auth-store")
.resolve("auth.properties")
.toFile()
val store = DesktopAuthStore(settingsFile, ReversibleSecretCodec)
store.save(testSession())
val raw = settingsFile.readText()
assertFalse(raw.contains("firebase_refresh"))
assertFalse(raw.contains("google_refresh"))
assertTrue(raw.contains("firebaseRefreshTokenProtected="))
assertTrue(raw.contains("googleRefreshTokenProtected="))
val loaded = DesktopAuthStore(settingsFile, ReversibleSecretCodec).load()
assertEquals("user-1", loaded?.user?.uid)
assertEquals("reader@example.com", loaded?.user?.email)
assertEquals("firebase_refresh", loaded?.refreshToken)
assertEquals("google_refresh", loaded?.googleRefreshToken)
}
@Test
fun `save fails without leaving a partial account file when secure storage is unavailable`() {
val settingsFile = Files.createTempDirectory("reader-auth-store-unavailable")
.resolve("auth.properties")
.toFile()
val store = DesktopAuthStore(settingsFile, ThrowingSecretCodec)
assertFailsWith<IllegalStateException> {
store.save(testSession())
}
assertFalse(settingsFile.exists())
}
private fun testSession(): DesktopAuthSession {
return DesktopAuthSession(
user = UserData(
uid = "user-1",
displayName = "Reader",
photoUrl = null,
email = "reader@example.com"
),
idToken = "id_token",
refreshToken = "firebase_refresh",
expiresAtEpochMillis = 123L,
googleAccessToken = "google_access",
googleRefreshToken = "google_refresh",
googleAccessTokenExpiresAtEpochMillis = 456L
)
}
private object ReversibleSecretCodec : DesktopSecretCodec {
override val isAvailable: Boolean = true
override fun protect(value: String): String {
return "test:" + value.reversed()
}
override fun unprotect(value: String): String {
return value.removePrefix("test:").reversed()
}
}
private object ThrowingSecretCodec : DesktopSecretCodec {
override val isAvailable: Boolean = false
override fun protect(value: String): String {
throw IllegalStateException("Secure storage unavailable")
}
override fun unprotect(value: String): String = ""
}
}

View file

@ -1,9 +1,8 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID
import com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.SharedFeaturePolicy
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@ -18,9 +17,13 @@ class DesktopBuildProfileTest {
assertEquals(EpistemeDesktopStandardAppName, profile.appName)
assertEquals("Standard edition", profile.buildLabel)
assertEquals(SharedFeaturePolicy.Standard, profile.featurePolicy)
assertTrue(profile.legalLinks.privacyPolicyUrl.endsWith("/privacy-policy.html"))
assertTrue(profile.legalLinks.termsUrl.endsWith("/terms-and-conditions.html"))
assertTrue(profile.featurePolicy.networkAccess)
assertFalse(profile.featurePolicy.byokAi)
assertFalse(profile.byokAiAvailable)
assertTrue(profile.aiKeySettingsAvailable)
assertTrue(profile.creditBackedCloudTtsControlsAvailable)
}
@Test
@ -31,12 +34,16 @@ class DesktopBuildProfileTest {
assertEquals(EpistemeDesktopOssAppName, profile.appName)
assertEquals("Offline OSS edition", profile.buildLabel)
assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy)
assertTrue(profile.legalLinks.privacyPolicyUrl.endsWith("/oss-privacy-policy.html"))
assertTrue(profile.legalLinks.termsUrl.endsWith("/oss-terms-of-service.html"))
assertFalse(profile.featurePolicy.networkAccess)
assertFalse(profile.featurePolicy.aiAndCloud)
assertTrue(profile.featurePolicy.byokAi)
assertFalse(profile.byokAiAvailable)
assertFalse(profile.aiKeySettingsAvailable)
assertFalse(profile.featurePolicy.opdsCatalogs)
assertFalse(profile.featurePolicy.googleFontsDownload)
assertFalse(profile.creditBackedCloudTtsControlsAvailable)
}
@Test
@ -54,23 +61,104 @@ class DesktopBuildProfileTest {
geminiKey = "gemini_secret",
modelForAll = "gemini:gemini-flash-lite-latest"
)
val onlineOssPolicy = SharedFeaturePolicy(
networkAccess = true,
aiAndCloud = true,
byokAi = true
val onlineOssPolicy = SharedFeaturePolicy.OssOnline
val onlineOssProfile = DesktopBuildProfile(
flavor = "oss-online",
appName = "Episteme oss",
buildLabel = "OSS edition",
featurePolicy = onlineOssPolicy
)
assertTrue(
assertTrue(onlineOssProfile.byokAiAvailable)
assertFalse(onlineOssProfile.aiKeySettingsAvailable)
assertEquals(settings, settings.withDesktopFeaturePolicy(onlineOssPolicy))
assertFalse(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.Standard).hideReaderAiFeatures)
assertFalse(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.OssOffline).hideReaderAiFeatures)
val byokCloudTtsSettings = settings.copy(
geminiKey = "gemini_secret",
ttsModel = GEMINI_CLOUD_TTS_MODEL_ID
)
val desktopByokSettings = byokCloudTtsSettings.withDesktopFeaturePolicy(onlineOssPolicy)
assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, desktopByokSettings.ttsModel)
assertTrue(desktopByokSettings.isCloudTtsAvailable)
assertFalse(
DesktopBuildProfile(
flavor = "oss-online",
appName = "Episteme oss",
buildLabel = "OSS edition",
featurePolicy = onlineOssPolicy
).byokAiAvailable
).creditBackedCloudTtsControlsAvailable
)
assertEquals(settings, settings.withDesktopFeaturePolicy(onlineOssPolicy))
assertTrue(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.Standard).hideReaderAiFeatures)
assertTrue(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.OssOffline).hideReaderAiFeatures)
}
@Test
fun `desktop tts worker requires its own configured endpoint`() {
val config = DesktopCloudConfig(
aiWorkerUrl = "https://example.com/ai",
ttsWorkerUrl = "",
firebaseWebApiKey = "",
firebaseProjectId = "",
googleOAuthClientId = "",
googleOAuthClientSecret = ""
)
assertTrue(config.isAiWorkerConfigured)
assertFalse(config.isTtsWorkerConfigured)
}
@Test
fun `desktop cloud tts adapter allows byok before credit worker`() {
val byokAdapter = DesktopGeminiCloudTtsAdapter(
settingsProvider = {
ReaderAiByokSettings(
geminiKey = "gemini_secret",
ttsModel = GEMINI_CLOUD_TTS_MODEL_ID
)
},
networkAccess = { true },
workerUrlProvider = { "" }
)
val workerAdapter = DesktopGeminiCloudTtsAdapter(
settingsProvider = { ReaderAiByokSettings(serverBackedCloudTts = true) },
networkAccess = { true },
workerUrlProvider = { "https://example.com/tts" }
)
val unavailableAdapter = DesktopGeminiCloudTtsAdapter(
settingsProvider = { ReaderAiByokSettings() },
networkAccess = { true },
workerUrlProvider = { "https://example.com/tts" }
)
assertTrue(byokAdapter.isAvailable)
assertTrue(workerAdapter.isAvailable)
assertFalse(unavailableAdapter.isAvailable)
}
@Test
fun `desktop persisted AI settings keep Android model controls and force visibility`() {
val settings = ReaderAiByokSettings(
geminiKey = " gemini_secret ",
groqKey = " groq_secret ",
useOneModel = true,
modelForAll = "groq:qwen/qwen3-32b",
defineModel = "gemini:gemini-flash-lite-latest",
summarizeModel = "groq:llama-3.3-70b-versatile",
recapModel = "gemini:gemini-2.5-flash-lite",
hideReaderAiFeatures = true
)
val persisted = settings.toDesktopPersistableAiSettings()
assertEquals("gemini_secret", persisted.geminiKey)
assertEquals("groq_secret", persisted.groqKey)
assertTrue(persisted.useOneModel)
assertEquals("groq:qwen/qwen3-32b", persisted.modelForAll)
assertEquals("gemini:gemini-flash-lite-latest", persisted.defineModel)
assertEquals("groq:llama-3.3-70b-versatile", persisted.summarizeModel)
assertEquals("gemini:gemini-2.5-flash-lite", persisted.recapModel)
assertFalse(persisted.hideReaderAiFeatures)
}
@Test
@ -84,36 +172,4 @@ class DesktopBuildProfileTest {
assertTrue(desktopDiagnosticsFlag(" TRUE "))
}
@Test
fun `bundled webview detection requires cef binaries`() {
val dir = Files.createTempDirectory("episteme-kcef-test").toFile()
try {
val windowsX64 = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64)
assertFalse(isBundledDesktopWebViewPresent(dir, windowsX64))
File(dir, "jcef.dll").writeText("jcef")
File(dir, "libcef.dll").writeText("cef")
assertTrue(isBundledDesktopWebViewPresent(dir, windowsX64))
} finally {
dir.deleteRecursively()
}
}
@Test
fun `linux bundled webview detection requires cef shared library and resources`() {
val dir = Files.createTempDirectory("episteme-linux-kcef-test").toFile()
try {
val linuxX64 = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
assertFalse(isBundledDesktopWebViewPresent(dir, linuxX64))
File(dir, "libcef.so").writeText("cef")
File(dir, "chrome-sandbox").writeText("sandbox")
File(dir, "icudtl.dat").writeText("icu")
File(dir, "locales").mkdir()
assertTrue(isBundledDesktopWebViewPresent(dir, linuxX64))
} finally {
dir.deleteRecursively()
}
}
}

View file

@ -0,0 +1,60 @@
package com.aryan.reader.desktop
import java.util.Properties
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class DesktopCloudConfigTest {
@Test
fun `packaged resource config can enable desktop Google sign in`() {
val config = desktopCloudConfigFromProperties(
resourceProperties = properties(
"FIREBASE_WEB_API_KEY" to "firebase-key",
"FIREBASE_PROJECT_ID" to "reader-project",
"GOOGLE_OAUTH_CLIENT_ID" to "oauth-client",
"GOOGLE_OAUTH_CLIENT_SECRET" to "oauth-secret"
),
systemProperty = { null },
environment = { null }
)
assertTrue(config.isAuthConfigured)
assertEquals("firebase-key", config.firebaseWebApiKey)
assertEquals("reader-project", config.firebaseProjectId)
assertEquals("oauth-client", config.googleOAuthClientId)
assertEquals("oauth-secret", config.googleOAuthClientSecret)
}
@Test
fun `local desktop keys override packaged resource config`() {
val config = desktopCloudConfigFromProperties(
resourceProperties = properties(
"FIREBASE_WEB_API_KEY" to "packaged-firebase-key",
"FIREBASE_PROJECT_ID" to "packaged-project",
"GOOGLE_OAUTH_CLIENT_ID" to "packaged-oauth-client",
"GOOGLE_OAUTH_CLIENT_SECRET" to "packaged-oauth-secret"
),
localProperties = properties(
"DESKTOP_FIREBASE_WEB_API_KEY" to "local-firebase-key",
"DESKTOP_FIREBASE_PROJECT_ID" to "local-project",
"DESKTOP_GOOGLE_OAUTH_CLIENT_ID" to "local-oauth-client",
"DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET" to "local-oauth-secret"
),
systemProperty = { null },
environment = { null }
)
assertTrue(config.isAuthConfigured)
assertEquals("local-firebase-key", config.firebaseWebApiKey)
assertEquals("local-project", config.firebaseProjectId)
assertEquals("local-oauth-client", config.googleOAuthClientId)
assertEquals("local-oauth-secret", config.googleOAuthClientSecret)
}
}
private fun properties(vararg values: Pair<String, String>): Properties {
return Properties().apply {
values.forEach { (key, value) -> setProperty(key, value) }
}
}

View file

@ -5,10 +5,16 @@ import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.HighlightColor
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.UserHighlight
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
import com.aryan.reader.shared.pdf.SharedPdfBookmark
import com.aryan.reader.shared.pdf.SharedPdfReaderViewport
import com.aryan.reader.shared.pdf.SharedPdfRichDocument
import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer
import com.aryan.reader.shared.reader.ReaderBookmark
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DesktopCloudSyncMappingTest {
@ -71,6 +77,7 @@ class DesktopCloudSyncMappingTest {
assertEquals(2, metadata.lastChapterIndex)
assertEquals(4, metadata.lastPage)
assertEquals(42f, metadata.progressPercentage)
assertEquals(1_000L, metadata.readingPositionModifiedTimestamp)
assertTrue(assertNotNull(metadata.bookmarksJson).contains("desktop:2:30:44"))
assertTrue(assertNotNull(metadata.highlightsJson).contains("highlighted text"))
assertEquals(book.id, restored.id)
@ -88,6 +95,212 @@ class DesktopCloudSyncMappingTest {
assertEquals("desktop:2:50:64", restored.readerHighlights.single().locator.cfi)
}
@Test
fun `metadata only upload can preserve remote content timestamp`() {
val book = BookItem(
id = "book-1",
path = null,
type = FileType.PDF,
displayName = "Book.pdf",
timestamp = 1_000L,
fileContentModifiedTimestamp = 111L
)
val metadata = book.toDesktopCloudBookMetadata(
hasAnnotations = false,
timestamp = 2_000L,
contentTimestampOverride = 999L
)
assertEquals(999L, metadata.fileContentModifiedTimestamp)
}
@Test
fun `metadata upload keeps reading position timestamp separate from upload timestamp`() {
val book = BookItem(
id = "book-1",
path = null,
type = FileType.PDF,
displayName = "Book.pdf",
timestamp = 1_000L,
lastPageIndex = 12,
progressPercentage = 20f,
readingPositionModifiedTimestamp = 1_500L
)
val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = true, timestamp = 3_000L)
assertEquals(3_000L, metadata.lastModifiedTimestamp)
assertEquals(1_500L, metadata.readingPositionModifiedTimestamp)
assertEquals(0L, metadata.annotationModifiedTimestamp)
assertEquals(12, metadata.lastPage)
}
@Test
fun `metadata upload keeps annotation timestamp separate from upload timestamp`() {
val book = BookItem(
id = "book-1",
path = null,
type = FileType.PDF,
displayName = "Book.pdf",
timestamp = 1_000L
)
val metadata = book.toDesktopCloudBookMetadata(
hasAnnotations = true,
timestamp = 3_000L,
annotationModifiedTimestamp = 2_250L
)
assertEquals(3_000L, metadata.lastModifiedTimestamp)
assertEquals(2_250L, metadata.annotationModifiedTimestamp)
assertEquals(2_250L, metadata.effectiveCloudAnnotationModifiedTimestamp())
}
@Test
fun `annotation freshness does not fall back to book metadata timestamp`() {
val metadata = DesktopCloudBookMetadata(
bookId = "book-1",
type = FileType.PDF.name,
lastModifiedTimestamp = 5_000L,
hasAnnotations = true
)
assertEquals(0L, metadata.effectiveCloudAnnotationModifiedTimestamp())
assertEquals(3_000L, metadata.effectiveCloudAnnotationModifiedTimestamp(sidecarModifiedTimestamp = 3_000L))
}
@Test
fun `desktop drive file names use shared cloud content extension`() {
assertEquals("book-1.epub", desktopCloudBookDriveFileName("book-1", FileType.EPUB))
assertEquals("book-1.md", desktopCloudBookDriveFileName("book-1", FileType.MD))
assertEquals("book-1.mobi", desktopCloudBookDriveFileName("book-1", FileType.MOBI))
assertNull(desktopCloudBookDriveFileName("book-1", FileType.UNKNOWN))
}
@Test
fun `empty epub annotations upload as empty arrays`() {
val book = BookItem(
id = "book-1",
path = "C:/books/Book.epub",
type = FileType.EPUB,
displayName = "Book.epub",
timestamp = 1_000L
)
val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false)
assertEquals("[]", metadata.bookmarksJson)
assertEquals("[]", metadata.highlightsJson)
}
@Test
fun `remote pdf metadata moves stale desktop viewport to remote page`() {
val existing = BookItem(
id = "book-1",
path = "C:/books/Book.pdf",
type = FileType.PDF,
displayName = "Book.pdf",
timestamp = 1_000L,
lastPageIndex = 264,
progressPercentage = 33.125f,
readerPosition = ReaderLocator(pageIndex = 264),
pdfReaderViewport = SharedPdfReaderViewport(
pageIndex = 264,
verticalFirstPageIndex = 264,
verticalFirstPageScrollOffset = 120
)
)
val remote = DesktopCloudBookMetadata(
bookId = "book-1",
displayName = "Book.pdf",
type = FileType.PDF.name,
lastModifiedTimestamp = 2_000L,
lastPage = 69,
progressPercentage = 8.75f
)
val restored = remote.toDesktopBookItem(existing = existing)
assertEquals(69, restored.lastPageIndex)
assertEquals(8.75f, restored.progressPercentage)
assertNull(restored.readerPosition)
assertEquals(69, restored.pdfReaderViewport?.pageIndex)
assertEquals(69, restored.pdfReaderViewport?.verticalFirstPageIndex)
assertEquals(0, restored.pdfReaderViewport?.verticalFirstPageScrollOffset)
}
@Test
fun `remote metadata with older reading timestamp preserves newer local pdf position`() {
val existing = BookItem(
id = "book-1",
path = "C:/books/Book.pdf",
type = FileType.PDF,
displayName = "Book.pdf",
timestamp = 4_000L,
lastPageIndex = 88,
progressPercentage = 44f,
pdfReaderViewport = SharedPdfReaderViewport(pageIndex = 88, verticalFirstPageIndex = 88),
readingPositionModifiedTimestamp = 4_000L
)
val remote = DesktopCloudBookMetadata(
bookId = "book-1",
displayName = "Book.pdf",
type = FileType.PDF.name,
lastModifiedTimestamp = 6_000L,
readingPositionModifiedTimestamp = 3_000L,
lastPage = 12,
progressPercentage = 6f,
hasAnnotations = true
)
val restored = remote.toDesktopBookItem(existing = existing)
assertEquals(6_000L, restored.timestamp)
assertEquals(88, restored.lastPageIndex)
assertEquals(44f, restored.progressPercentage)
assertEquals(88, restored.pdfReaderViewport?.pageIndex)
assertEquals(4_000L, restored.readingPositionModifiedTimestamp)
}
@Test
fun `pdf metadata upload ignores stale text locator page`() {
val book = BookItem(
id = "book-1",
path = "C:/books/Book.pdf",
type = FileType.PDF,
displayName = "Book.pdf",
timestamp = 1_000L,
lastPageIndex = 264,
progressPercentage = 33.125f,
readerPosition = ReaderLocator(pageIndex = 69)
)
val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false)
assertEquals(264, metadata.lastPage)
assertNull(metadata.lastPositionCfi)
}
@Test
fun `comic metadata upload ignores stale text locator page`() {
val book = BookItem(
id = "book-1",
path = "C:/books/Book.cbt",
type = FileType.CBT,
displayName = "Book.cbt",
timestamp = 1_000L,
lastPageIndex = 42,
progressPercentage = 33.125f,
readerPosition = ReaderLocator(pageIndex = 12)
)
val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false)
assertEquals(42, metadata.lastPage)
assertNull(metadata.lastPositionCfi)
}
@Test
fun `remote metadata without annotation json preserves existing desktop annotations`() {
val existingBookmark = ReaderBookmark(
@ -127,4 +340,52 @@ class DesktopCloudSyncMappingTest {
assertEquals(listOf(existingHighlight), restored.readerHighlights)
assertEquals(existing.path, restored.path)
}
@Test
fun `desktop pdf bookmarks map to android metadata json`() {
val metadataJson = desktopPdfBookmarksMetadataJson(
bookmarks = listOf(
SharedPdfBookmark(
pageIndex = 3,
label = "Important page",
createdAt = 1_234L
)
),
lastPageIndex = 9
)
val restored = desktopPdfBookmarksFromMetadataJson(metadataJson)
assertTrue(metadataJson.contains("\"pageIndex\""))
assertTrue(metadataJson.contains("\"title\""))
assertTrue(metadataJson.contains("\"totalPages\""))
assertEquals(1, restored.size)
assertEquals(3, restored.single().pageIndex)
assertEquals("Important page", restored.single().label)
}
@Test
fun `android pdf bookmark metadata keeps titles on desktop`() {
val restored = desktopPdfBookmarksFromMetadataJson(
"""[{"pageIndex":2,"title":"Android bookmark","totalPages":8}]"""
)
assertEquals(1, restored.size)
assertEquals(2, restored.single().pageIndex)
assertEquals("Android bookmark", restored.single().label)
}
@Test
fun `empty desktop pdf annotations are not exported as cloud annotation data`() {
val emptyAnnotationsJson = SharedPdfAnnotationSerializer.encode(emptyList())
assertNull(desktopPdfAnnotationElementForSync(emptyAnnotationsJson))
}
@Test
fun `empty desktop pdf rich text is not exported as cloud annotation data`() {
val emptyRichTextJson = SharedPdfRichTextSerializer.encode(SharedPdfRichDocument())
assertNull(desktopPdfRichTextElementForSync(emptyRichTextJson))
}
}

View file

@ -1,6 +1,8 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.FileType
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import java.io.File
import java.nio.file.Files
import java.util.Base64
@ -35,11 +37,65 @@ class DesktopComicArchiveTest {
}
}
@Test
fun `closing stale comic document does not close replacement with same path`() = withTempDir { dir ->
val cbz = File(dir, "comic.cbz")
ZipOutputStream(cbz.outputStream()).use { zip ->
zip.putNextEntry(ZipEntry("pages/001.png"))
zip.write(onePixelPngBytes())
zip.closeEntry()
}
val staleDocument = DesktopPdfium.loadComic(cbz, FileType.CBZ)
val activeDocument = DesktopPdfium.loadComic(cbz, FileType.CBZ)
try {
staleDocument.close()
val image = DesktopPdfium.renderPageBufferedImage(activeDocument, pageIndex = 0, scale = 4f)
assertEquals(4, image.width)
assertEquals(4, image.height)
} finally {
staleDocument.close()
activeDocument.close()
}
}
@Test
fun `cbt archive loads image pages for pdf reader surface`() = withTempDir { dir ->
val cbt = File(dir, "comic.cbt")
TarArchiveOutputStream(cbt.outputStream()).use { tar ->
val bytes = onePixelPngBytes()
val entry = TarArchiveEntry("pages/001.png").apply {
size = bytes.size.toLong()
}
tar.putArchiveEntry(entry)
tar.write(bytes)
tar.closeArchiveEntry()
tar.finish()
}
val document = DesktopPdfium.loadComic(cbt, FileType.CBT)
try {
assertEquals(1, document.pageCount)
assertEquals(1f, document.pageSizes.single().width)
assertEquals(1f, document.pageSizes.single().height)
val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex = 0, scale = 8f)
assertEquals(8, image.width)
assertEquals(8, image.height)
} finally {
document.close()
}
}
@Test
fun `desktop comic types are routed through shared reader capability map`() {
assertTrue(DesktopComicArchive.canLoad(FileType.CBZ))
assertTrue(DesktopComicArchive.canLoad(FileType.CBR))
assertTrue(DesktopComicArchive.canLoad(FileType.CB7))
assertTrue(DesktopComicArchive.canLoad(FileType.CBT))
}
private fun withTempDir(block: (File) -> Unit) {

View file

@ -7,7 +7,7 @@ class DesktopComposeInteropTest {
@Test
fun `desktop enables Compose interop blending before app startup`() {
withSystemProperty(ComposeInteropBlendingProperty, null) {
configureComposeSwingInterop()
configureComposeSwingInterop(nonNativeWebViewPlatform)
assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty))
}
@ -16,7 +16,7 @@ class DesktopComposeInteropTest {
@Test
fun `desktop treats blank Compose interop blending value as unset`() {
withSystemProperty(ComposeInteropBlendingProperty, " ") {
configureComposeSwingInterop()
configureComposeSwingInterop(nonNativeWebViewPlatform)
assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty))
}
@ -25,7 +25,7 @@ class DesktopComposeInteropTest {
@Test
fun `desktop preserves explicit Compose interop blending override`() {
withSystemProperty(ComposeInteropBlendingProperty, "false") {
configureComposeSwingInterop()
configureComposeSwingInterop(nonNativeWebViewPlatform)
assertEquals("false", System.getProperty(ComposeInteropBlendingProperty))
}
@ -52,4 +52,11 @@ class DesktopComposeInteropTest {
}
}
}
private companion object {
val nonNativeWebViewPlatform = DesktopPlatform(
os = DesktopOperatingSystem.OTHER,
architecture = DesktopArchitecture.X64
)
}
}

View file

@ -0,0 +1,112 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.ReaderLocator
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class DesktopEpubBridgeParsingTest {
@Test
fun `reader position bridge keeps semantic locator fields`() {
val position = """
{
"pageIndex": 12,
"chapterIndex": 2,
"chapterId": "chap-2",
"href": "text/chapter2.xhtml",
"startOffset": 140,
"endOffset": 140,
"blockIndex": 9,
"charOffset": 140,
"textQuote": "quoted text",
"cfi": "desktop-scroll:10:100:/4/2:3"
}
""".trimIndent().readerPositionOrNull()
assertEquals(12, position?.pageIndex)
assertEquals(2, position?.locator?.chapterIndex)
assertEquals("chap-2", position?.locator?.chapterId)
assertEquals("text/chapter2.xhtml", position?.locator?.href)
assertEquals(9, position?.locator?.blockIndex)
assertEquals(140, position?.locator?.charOffset)
assertEquals("quoted text", position?.locator?.textQuote)
assertEquals("/4/2:3", position?.locator?.cfi)
}
@Test
fun `locator json sent to web view includes semantic position fields`() {
val json = ReaderLocator(
chapterIndex = 2,
chapterId = "chap-2",
href = "text/chapter2.xhtml",
pageIndex = 12,
startOffset = 140,
endOffset = 155,
blockIndex = 9,
charOffset = 140,
textQuote = "quoted text",
cfi = "/4/2:3"
).toReaderLocatorJson()
assertTrue(json.contains("\"chapterId\":\"chap-2\""))
assertTrue(json.contains("\"href\":\"text/chapter2.xhtml\""))
assertTrue(json.contains("\"blockIndex\":9"))
assertTrue(json.contains("\"charOffset\":140"))
}
@Test
fun `selection action bridge keeps locator fields for selected tts`() {
val payload = """
{
"action": "speak",
"text": "selected text",
"locator": {
"chapterIndex": 3,
"chapterId": "chap-3",
"href": "text/chapter3.xhtml",
"pageIndex": 41,
"startOffset": 900,
"endOffset": 913,
"blockIndex": 7,
"charOffset": 900,
"textQuote": "selected text",
"cfi": "desktop-scroll:10:20:/4/8:12|/4/8:25"
}
}
""".trimIndent().readerSelectionActionOrNull()
assertEquals(DesktopReaderSelectionAction.SPEAK, payload?.action)
assertEquals("selected text", payload?.text)
assertEquals(3, payload?.locator?.chapterIndex)
assertEquals("chap-3", payload?.locator?.chapterId)
assertEquals("text/chapter3.xhtml", payload?.locator?.href)
assertEquals(41, payload?.locator?.pageIndex)
assertEquals(900, payload?.locator?.startOffset)
assertEquals(913, payload?.locator?.endOffset)
assertEquals(7, payload?.locator?.blockIndex)
assertEquals(900, payload?.locator?.charOffset)
assertEquals("selected text", payload?.locator?.textQuote)
assertEquals("/4/8:12|/4/8:25", payload?.locator?.cfi)
}
@Test
fun `selection action bridge parses highlight palette manager action`() {
val payload = """
{
"action": "palette",
"text": "selected text"
}
""".trimIndent().readerSelectionActionOrNull()
assertEquals(DesktopReaderSelectionAction.PALETTE, payload?.action)
assertEquals("selected text", payload?.text)
}
@Test
fun `desktop epub chrome tap script keeps click fallback for pointer-capable webviews`() {
assertTrue(DesktopEpubKeyNavigationScript.contains("var lastChromeTapNotifiedAt = 0;"))
assertTrue(DesktopEpubKeyNavigationScript.contains("function maybeNotifyChromeTapFromClick(event)"))
assertTrue(DesktopEpubKeyNavigationScript.contains("if (window.PointerEvent) {"))
assertTrue(DesktopEpubKeyNavigationScript.contains("maybeNotifyChromeTapFromClick(event);"))
}
}

View file

@ -0,0 +1,119 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.reader.ReaderPage
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.ReaderViewportSpec
import com.aryan.reader.shared.reader.layoutSignature
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DesktopEpubPaginationTest {
@Test
fun `measured pagination is not ready until measured pages are applied`() {
val request = desktopPaginationRequest()
val currentPages = listOf(readerPage(text = "old page"))
val measuredPages = listOf(readerPage(text = "measured page"))
assertFalse(
desktopMeasuredPaginationReady(
request = request,
completedRequest = request,
currentPages = currentPages,
measuredPages = measuredPages
)
)
}
@Test
fun `measured pagination is ready when current pages match measured pages`() {
val request = desktopPaginationRequest()
val measuredPages = listOf(readerPage(text = "measured page"))
assertTrue(
desktopMeasuredPaginationReady(
request = request,
completedRequest = request,
currentPages = measuredPages,
measuredPages = measuredPages
)
)
}
@Test
fun `paginated display waits for completed measured pages`() {
assertFalse(
desktopPaginatedLayoutReadyForDisplay(
readingMode = ReaderReadingMode.PAGINATED,
measuredPagesApplied = false
)
)
assertTrue(
desktopPaginatedLayoutReadyForDisplay(
readingMode = ReaderReadingMode.PAGINATED,
measuredPagesApplied = true
)
)
assertTrue(
desktopPaginatedLayoutReadyForDisplay(
readingMode = ReaderReadingMode.VERTICAL,
measuredPagesApplied = false
)
)
}
@Test
fun `measured chapter warm start replaces only that chapter and renumbers pages`() {
val currentPages = listOf(
readerPage(text = "chapter 0 page", chapterIndex = 0, pageIndex = 0),
readerPage(text = "chapter 1 old a", chapterIndex = 1, pageIndex = 1),
readerPage(text = "chapter 1 old b", chapterIndex = 1, pageIndex = 2),
readerPage(text = "chapter 2 page", chapterIndex = 2, pageIndex = 3)
)
val measuredChapter = listOf(
readerPage(text = "chapter 1 measured", chapterIndex = 1, pageIndex = 1)
)
val pages = desktopPagesWithMeasuredChapter(
currentPages = currentPages,
chapterIndex = 1,
measuredChapterPages = measuredChapter
)
assertEquals(listOf(0, 1, 2), pages.map { it.pageIndex })
assertEquals(listOf(0, 1, 2), pages.map { it.chapterIndex })
assertEquals("chapter 1 measured", pages[1].text)
}
private fun desktopPaginationRequest(): DesktopEpubPaginationRequest {
return DesktopEpubPaginationRequest(
bookId = "book",
chapterSignature = 1,
layoutSignature = ReaderSettings(
readingMode = ReaderReadingMode.PAGINATED,
pageSpreadMode = ReaderPageSpreadMode.SINGLE
).layoutSignature(),
viewport = ReaderViewportSpec(widthPx = 1200, heightPx = 900),
density = DesktopEpubPaginationDensity(density = 1f, fontScale = 1f),
cacheGeneration = 0
)
}
private fun readerPage(
text: String,
chapterIndex: Int = 0,
pageIndex: Int = 0
): ReaderPage {
return ReaderPage(
pageIndex = pageIndex,
chapterIndex = chapterIndex,
chapterTitle = "Chapter",
text = text,
startOffset = 0,
endOffset = text.length
)
}
}

View file

@ -0,0 +1,24 @@
package com.aryan.reader.desktop
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DesktopFeatureNoticePlacementTest {
@Test
fun `main notice renders only in the main window`() {
val placement = desktopFeatureNoticePlacement(readerWindowId = null)
assertTrue(placement.rendersInMainWindow())
assertFalse(placement.rendersInReaderWindow("reader-1"))
}
@Test
fun `reader notice renders only in the matching reader window`() {
val placement = desktopFeatureNoticePlacement(readerWindowId = "reader-1")
assertFalse(placement.rendersInMainWindow())
assertTrue(placement.rendersInReaderWindow("reader-1"))
assertFalse(placement.rendersInReaderWindow("reader-2"))
}
}

View file

@ -0,0 +1,55 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.SharedLibrarySnapshot
import com.aryan.reader.shared.SharedLibrarySnapshotJson
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class DesktopLibraryDatabaseTest {
@Test
fun `save writes readable library and backup snapshots`() {
val databaseFile = Files.createTempDirectory("reader-library-db")
.resolve("library.json")
.toFile()
val database = DesktopLibraryDatabase(databaseFile)
val snapshot = SharedLibrarySnapshot(
recentFilesLimit = 37,
openTabIds = listOf("book-a"),
activeTabBookId = "book-a"
)
database.save(snapshot)
val loaded = database.load()
assertEquals(37, loaded.recentFilesLimit)
assertEquals(listOf("book-a"), loaded.openTabIds)
assertEquals("book-a", loaded.activeTabBookId)
assertTrue(databaseFile.isFile)
assertTrue(databaseFile.parentFile.resolve("library.json.bak").isFile)
}
@Test
fun `load falls back to backup when primary library is corrupt`() {
val databaseFile = Files.createTempDirectory("reader-library-db-corrupt")
.resolve("library.json")
.toFile()
val backupSnapshot = SharedLibrarySnapshot(
recentFilesLimit = 19,
openTabIds = listOf("backup-book"),
activeTabBookId = "backup-book"
)
databaseFile.parentFile.mkdirs()
databaseFile.writeText("""{"books":[""")
databaseFile.parentFile
.resolve("library.json.bak")
.writeText(SharedLibrarySnapshotJson.encode(backupSnapshot))
val loaded = DesktopLibraryDatabase(databaseFile).load()
assertEquals(19, loaded.recentFilesLimit)
assertEquals(listOf("backup-book"), loaded.openTabIds)
assertEquals("backup-book", loaded.activeTabBookId)
}
}

View file

@ -11,8 +11,37 @@ import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DesktopLocalFolderSyncTest {
@Test
fun `target folder sync imports files before desktop metadata extraction`() {
val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile()
try {
val bookFile = File(root, "Notes.txt").apply { writeText("Notes") }
val result = DesktopLocalFolderSync.sync(
state = SharedReaderScreenState(),
shelfRefs = emptyList(),
targetFolder = root,
nowMillis = 3_000L,
extractMetadata = false
)
val syncedBook = result.state.rawLibraryBooks.single()
assertEquals("local_Notes.txt", syncedBook.id)
assertEquals(bookFile.absolutePath, syncedBook.path)
assertEquals(root.absolutePath, syncedBook.sourceFolder)
assertEquals(listOf(root.absolutePath), result.processedFolderUris)
assertEquals(1, result.state.syncedFolders.size)
assertEquals(1, result.stats.newBooks)
assertEquals(0, result.metadataStats.updatedBooks)
assertNull(syncedBook.coverImagePath)
} finally {
root.deleteRecursively()
}
}
@Test
fun `metadata-only sync imports sidecar metadata without scanning physical files`() {
val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile()
@ -61,6 +90,40 @@ class DesktopLocalFolderSyncTest {
assertEquals(0, result.stats.newBooks)
assertEquals(0, result.stats.removedBooks)
assertEquals(1, result.stats.remoteMetadataUpdates)
assertTrue(result.processedFolderUris.contains(root.absolutePath))
} finally {
root.deleteRecursively()
}
}
@Test
fun `disabled folder is not scanned or written`() {
val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile()
try {
File(root, "Notes.txt").writeText("Notes")
val existingBook = BookItem(
id = "local_Existing.pdf",
path = File(root, "Existing.pdf").absolutePath,
type = FileType.PDF,
displayName = "Existing.pdf",
timestamp = 100L,
progressPercentage = 50f,
sourceFolder = root.absolutePath
)
val result = DesktopLocalFolderSync.sync(
state = SharedReaderScreenState(
rawLibraryBooks = listOf(existingBook),
syncedFolders = listOf(syncedFolder(root).copy(localSyncEnabled = false))
),
shelfRefs = emptyList(),
nowMillis = 3_000L
)
assertEquals(listOf(existingBook), result.state.rawLibraryBooks)
assertTrue(result.processedFolderUris.isEmpty())
assertEquals(0, result.stats.newBooks)
assertTrue(!File(root, LOCAL_FOLDER_SYNC_DATA_DIR).exists())
} finally {
root.deleteRecursively()
}

View file

@ -0,0 +1,15 @@
package com.aryan.reader.desktop
import kotlin.test.Test
import kotlin.test.assertEquals
class DesktopPaidAiUsageTest {
@Test
fun `desktop paid AI usage applies an optimistic integer credit decrement`() {
assertEquals(9, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 1.0))
assertEquals(7, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 2.2))
assertEquals(10, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 0.0))
assertEquals(10, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = null))
assertEquals(0, desktopCreditsAfterPaidAiUsage(currentCredits = 1, cost = 4.0))
}
}

View file

@ -0,0 +1,56 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.pdf.PdfAnnotationKind
import com.aryan.reader.shared.pdf.PdfInkTool
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class DesktopPdfNavigationSidebarTest {
@Test
fun `sidebar highlights exclude ink and text annotations`() {
val result = desktopPdfSidebarHighlights(
listOf(
annotation(id = "ink", pageIndex = 0, kind = PdfAnnotationKind.INK, createdAt = 1L),
annotation(id = "later-highlight", pageIndex = 2, kind = PdfAnnotationKind.HIGHLIGHT, createdAt = 4L),
annotation(id = "text", pageIndex = 1, kind = PdfAnnotationKind.TEXT, createdAt = 1L),
annotation(
id = "first-same-page-highlight",
pageIndex = 1,
kind = PdfAnnotationKind.HIGHLIGHT,
createdAt = 3L
),
annotation(
id = "second-same-page-highlight",
pageIndex = 1,
kind = PdfAnnotationKind.HIGHLIGHT,
createdAt = 2L
)
)
)
assertEquals(
listOf("first-same-page-highlight", "second-same-page-highlight", "later-highlight"),
result.map { it.id }
)
assertTrue(result.all { it.kind == PdfAnnotationKind.HIGHLIGHT })
}
private fun annotation(
id: String,
pageIndex: Int,
kind: PdfAnnotationKind,
createdAt: Long
): SharedPdfAnnotation {
return SharedPdfAnnotation(
id = id,
pageIndex = pageIndex,
kind = kind,
tool = if (kind == PdfAnnotationKind.TEXT) PdfInkTool.TEXT else PdfInkTool.HIGHLIGHTER,
text = "selected text",
colorArgb = 0x55FFEB3B,
createdAt = createdAt
)
}
}

View file

@ -4,6 +4,7 @@ import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.SharedLibraryStateProjector
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.ui.toNonReaderLibraryOrganizationModel
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
@ -85,6 +86,7 @@ class DesktopPdfReflowTest {
assertEquals(listOf(source.id), projected.libraryBooks.map { it.id })
assertTrue(projected.rawLibraryBooks.any { it.id == reflow.id })
assertTrue(projected.recentBooks.none { it.id == reflow.id })
assertEquals(1, projected.toNonReaderLibraryOrganizationModel().allBooksCount)
assertEquals(listOf(reflow.id), projected.openTabIds)
assertEquals(reflow.id, projected.activeTabBookId)
}

View file

@ -0,0 +1,60 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.PdfDisplayMode
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
import com.aryan.reader.shared.reader.ReaderSettings
import kotlin.test.Test
import kotlin.test.assertEquals
class DesktopPdfScrubbingTest {
@Test
fun `scrub target clamps to valid page range`() {
val settings = ReaderSettings()
assertEquals(
0,
desktopPdfPageScrubTarget(
value = -10f,
pageCount = 6,
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
settings = settings
)
)
assertEquals(
5,
desktopPdfPageScrubTarget(
value = 99f,
pageCount = 6,
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
settings = settings
)
)
}
@Test
fun `paginated scrub target normalizes to spread start`() {
val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE)
assertEquals(
2,
desktopPdfPageScrubTarget(
value = 3f,
pageCount = 8,
displayMode = PdfDisplayMode.PAGINATION,
settings = settings
)
)
}
@Test
fun `scrub commit prefers preview before page state catches up`() {
assertEquals(
7,
desktopPdfPageScrubCommitTarget(
previewPage = 7,
currentPage = 2,
pageCount = 10
)
)
}
}

View file

@ -0,0 +1,16 @@
package com.aryan.reader.desktop
import kotlin.test.Test
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
class DesktopPdfSidecarsTest {
@Test
fun `pdf sidecar keys avoid String hashCode collisions`() {
val first = desktopPdfDocumentKey("C:/Books/Aa.pdf")
val second = desktopPdfDocumentKey("C:/Books/BB.pdf")
assertTrue("C:/Books/Aa.pdf".hashCode() == "C:/Books/BB.pdf".hashCode())
assertNotEquals(first, second)
}
}

View file

@ -0,0 +1,69 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.pdf.PdfAnnotationKind
import com.aryan.reader.shared.pdf.PdfInkTool
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.shared.pdf.SharedPdfReaderAction
import com.aryan.reader.shared.pdf.SharedPdfReaderState
import com.aryan.reader.shared.pdf.reduce
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DesktopPdfTextHighlightStateTest {
@Test
fun `text selection highlight keeps chosen text selection mode after creation`() {
val annotation = textSelectionHighlight()
val state = SharedPdfReaderState.initial(pageCount = 1)
.reduce(SharedPdfReaderAction.TextSelectionModeChanged(true))
val next = state.withDesktopPdfTextSelectionHighlightAdded(annotation)
assertEquals(listOf(annotation), next.annotations)
assertTrue(next.isTextSelectionMode)
assertEquals(PdfInkTool.NONE, next.selectedTool)
assertNull(next.selectedAnnotationId)
}
@Test
fun `dismissing selected text highlight sheet keeps chosen text selection mode`() {
val annotation = textSelectionHighlight()
val state = SharedPdfReaderState.initial(pageCount = 1)
.reduce(SharedPdfReaderAction.TextSelectionModeChanged(true))
.copy(annotations = listOf(annotation), selectedAnnotationId = annotation.id)
val next = state.withDesktopPdfTextHighlightSheetDismissed()
assertTrue(next.isTextSelectionMode)
assertEquals(PdfInkTool.NONE, next.selectedTool)
assertNull(next.selectedAnnotationId)
}
@Test
fun `dismissing non text highlight annotation keeps text selection mode unchanged`() {
val annotation = textSelectionHighlight().copy(rangeStartIndex = null, rangeEndIndex = null)
val state = SharedPdfReaderState.initial(pageCount = 1)
.reduce(SharedPdfReaderAction.TextSelectionModeChanged(true))
.copy(annotations = listOf(annotation), selectedAnnotationId = annotation.id)
val next = state.withDesktopPdfTextHighlightSheetDismissed()
assertTrue(next.isTextSelectionMode)
assertNull(next.selectedAnnotationId)
}
private fun textSelectionHighlight(): SharedPdfAnnotation {
return SharedPdfAnnotation(
id = "highlight-1",
pageIndex = 0,
kind = PdfAnnotationKind.HIGHLIGHT,
tool = PdfInkTool.HIGHLIGHTER,
text = "selected text",
colorArgb = 0x55FFEB3B,
rangeStartIndex = 1,
rangeEndIndex = 12,
createdAt = 1L
)
}
}

View file

@ -9,9 +9,10 @@ import kotlin.test.assertEquals
class DesktopPdfThemeTest {
@Test
fun `desktop pdf defaults to vertical display mode`() {
assertEquals(PdfDisplayMode.VERTICAL_SCROLL, DesktopDefaultPdfDisplayMode)
fun `desktop pdf defaults to paginated display mode`() {
assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfDisplayMode)
assertEquals(8.dp, DesktopDefaultPdfVerticalPageGap)
assertEquals(18.dp, DesktopDefaultPdfSpreadPageGap)
}
@Test
@ -49,4 +50,35 @@ class DesktopPdfThemeTest {
)
)
}
@Test
fun `pagination viewport uses app theme color outside pages`() {
val pageBackground = Color.Black
val appBackground = Color(0xFFE2E2E2)
assertEquals(
appBackground,
desktopPdfViewportBackgroundColor(
displayMode = PdfDisplayMode.PAGINATION,
pageBackgroundColor = pageBackground,
appBackgroundColor = appBackground,
isVerticalPageGapVisible = false
)
)
assertEquals(
appBackground,
desktopPdfViewportBackgroundColor(
displayMode = PdfDisplayMode.PAGINATION,
pageBackgroundColor = pageBackground,
appBackgroundColor = appBackground,
isVerticalPageGapVisible = true
)
)
}
@Test
fun `spread page gap follows pdf page gap visibility setting`() {
assertEquals(18.dp, desktopPdfSpreadPageGapDp(isPageGapVisible = true))
assertEquals(0.dp, desktopPdfSpreadPageGapDp(isPageGapVisible = false))
}
}

View file

@ -10,7 +10,6 @@ class DesktopPlatformPathsTest {
assertEquals(DesktopOperatingSystem.LINUX, platform.os)
assertEquals(DesktopArchitecture.X64, platform.architecture)
assertEquals("kcef-bundle-linux-x64", platform.kcefBundleDirectoryName)
assertEquals("linux-x64-v8", platform.pdfiumDirectoryName)
assertEquals("lib", platform.pdfiumLibraryDirectoryName)
assertEquals("libpdfium.so", platform.pdfiumLibraryFileName)
@ -22,7 +21,6 @@ class DesktopPlatformPathsTest {
assertEquals(DesktopOperatingSystem.WINDOWS, platform.os)
assertEquals(DesktopArchitecture.X64, platform.architecture)
assertEquals("kcef-bundle", platform.kcefBundleDirectoryName)
assertEquals("win-x64-v8", platform.pdfiumDirectoryName)
assertEquals("bin", platform.pdfiumLibraryDirectoryName)
assertEquals("pdfium.dll", platform.pdfiumLibraryFileName)

View file

@ -5,13 +5,17 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.PdfDisplayMode
import com.aryan.reader.shared.ReaderPlatform
import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.SharedLibrarySnapshot
import com.aryan.reader.shared.pdf.PdfZoomSpec
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSettings
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DesktopReaderDefaultsTest {
@ -42,6 +46,96 @@ class DesktopReaderDefaultsTest {
assertEquals(local, resolvedDesktopReaderSettings(book, defaults))
}
@Test
fun `desktop library defaults migrate untouched reader defaults to two page pagination`() {
val migrated = SharedLibrarySnapshot().withDesktopDefaults()
assertEquals(DesktopReaderDefaultsVersion, migrated.desktopReaderDefaultsVersion)
assertEquals(ReaderReadingMode.PAGINATED, migrated.readerDefaultSettings.readingMode)
assertEquals(ReaderPageSpreadMode.TWO_PAGE, migrated.readerDefaultSettings.pageSpreadMode)
assertEquals(ReaderReadingMode.PAGINATED, migrated.pdfReaderDefaultSettings.readingMode)
assertEquals(ReaderPageSpreadMode.TWO_PAGE, migrated.pdfReaderDefaultSettings.pageSpreadMode)
assertEquals("no_theme", migrated.pdfReaderDefaultSettings.themeId)
}
@Test
fun `desktop reader settings engines are separated by shared reader surface`() {
assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.EPUB.desktopReaderSettingsEngine())
assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.MOBI.desktopReaderSettingsEngine())
assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.DOCX.desktopReaderSettingsEngine())
assertEquals(DesktopReaderSettingsEngine.PDF, FileType.PDF.desktopReaderSettingsEngine())
assertEquals(DesktopReaderSettingsEngine.PDF, FileType.CBZ.desktopReaderSettingsEngine())
assertEquals(DesktopReaderSettingsEngine.PDF, FileType.CBT.desktopReaderSettingsEngine())
assertEquals(DesktopReaderSettingsEngine.PDF, FileType.PPTX.desktopReaderSettingsEngine())
}
@Test
fun `desktop engine settings update only matching reader family books`() {
val textSettings = ReaderSettings(themeId = "sepia", readingMode = ReaderReadingMode.PAGINATED)
val pdfSettings = ReaderSettings(themeId = "reverse", readingMode = ReaderReadingMode.PAGINATED)
val books = listOf(
bookItem("epub"),
bookItem("mobi").copy(path = "C:/Books/mobi.mobi", type = FileType.MOBI, displayName = "mobi.mobi"),
bookItem("pdf").copy(path = "C:/Books/pdf.pdf", type = FileType.PDF, displayName = "pdf.pdf")
)
val withTextDefaults = books.withDesktopReaderEngineSettings(DesktopReaderSettingsEngine.TEXT, textSettings)
assertEquals(textSettings, withTextDefaults[0].readerSettings)
assertEquals(textSettings, withTextDefaults[1].readerSettings)
assertEquals(null, withTextDefaults[2].readerSettings)
val withPdfDefaults = withTextDefaults.withDesktopReaderEngineSettings(DesktopReaderSettingsEngine.PDF, pdfSettings)
assertEquals(textSettings, withPdfDefaults[0].readerSettings)
assertEquals(textSettings, withPdfDefaults[1].readerSettings)
assertEquals(pdfSettings, withPdfDefaults[2].readerSettings)
}
@Test
fun `desktop pdf display mode is carried by pdf reader settings`() {
assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfDisplayMode)
assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfReaderSettings.toDesktopPdfDisplayMode())
assertEquals(
PdfDisplayMode.VERTICAL_SCROLL,
ReaderSettings(readingMode = ReaderReadingMode.VERTICAL).toDesktopPdfDisplayMode()
)
}
@Test
fun `desktop pdf initial page is normalized before paginated spread display`() {
val spreadSettings = ReaderSettings(
readingMode = ReaderReadingMode.PAGINATED,
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
)
assertEquals(
2,
desktopPdfInitialPageIndex(
requestedPageIndex = 3,
pageCount = 10,
displayMode = PdfDisplayMode.PAGINATION,
settings = spreadSettings
)
)
assertEquals(
3,
desktopPdfInitialPageIndex(
requestedPageIndex = 3,
pageCount = 10,
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
settings = spreadSettings
)
)
assertEquals(
3,
desktopPdfInitialPageIndex(
requestedPageIndex = 3,
pageCount = 10,
displayMode = PdfDisplayMode.PAGINATION,
settings = spreadSettings.copy(pageSpreadMode = ReaderPageSpreadMode.SINGLE)
)
)
}
@Test
fun `desktop pdf zoom allows deeper page magnification`() {
val sharedDefaultMax = PdfZoomSpec().max
@ -65,6 +159,26 @@ class DesktopReaderDefaultsTest {
assertEquals(0.5f, desktopPdfZoomTarget(currentZoom = 0.6f, zoomSpec = zoomSpec, factor = 0.1f))
}
@Test
fun `desktop pdf page navigation commits pending zoom preview position`() {
val preview = DesktopPdfZoomPreview(
baseZoom = 1f,
zoom = 2f,
anchor = Offset(100f, 80f),
displayMode = PdfDisplayMode.PAGINATION,
pageIndex = 0
)
val snapshot = desktopPdfNavigationZoomSnapshot(
preview = preview,
currentHorizontalScroll = 40,
currentVerticalScroll = 20
) ?: error("Expected navigation zoom snapshot")
assertEquals(2f, snapshot.zoom)
assertEquals(180, snapshot.horizontalScroll)
assertEquals(120, snapshot.verticalScroll)
}
@Test
fun `desktop paginated pdf page changes avoid high resolution first render`() {
assertEquals(
@ -93,6 +207,247 @@ class DesktopReaderDefaultsTest {
)
}
@Test
fun `desktop pdf only displays renders for the requested page`() {
assertTrue(desktopPdfRenderBelongsToPage(renderedPageIndex = 0, requestedPageIndex = 0))
assertFalse(desktopPdfRenderBelongsToPage(renderedPageIndex = null, requestedPageIndex = 0))
assertFalse(desktopPdfRenderBelongsToPage(renderedPageIndex = 1, requestedPageIndex = 0))
}
@Test
fun `desktop pdf render scale rerenders only for missing or lower quality renders`() {
assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = null, requestedScale = 1f))
assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = 1.02f))
assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = Float.NaN, requestedScale = 1f))
assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = 1.005f))
assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 2f, requestedScale = 1f))
assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = Float.NaN))
}
@Test
fun `desktop pdf spread zoom anchors to page under cursor`() {
val visiblePages = listOf(199, 200)
val pageRoots = mapOf(
199 to Offset(424f, 30f),
200 to Offset(972f, 30f)
)
val pageSizes = mapOf(
199 to IntSize(525, 693),
200 to IntSize(525, 693)
)
assertEquals(
200,
desktopPdfSpreadZoomAnchorPageIndex(
viewportRootOffset = Offset.Zero,
anchor = Offset(1048.75f, 465f),
visiblePageIndices = visiblePages,
pageRootOffsets = pageRoots,
pageSizes = pageSizes,
fallbackPageIndex = 199
)
)
assertEquals(
199,
desktopPdfSpreadZoomAnchorPageIndex(
viewportRootOffset = Offset.Zero,
anchor = Offset(500f, 465f),
visiblePageIndices = visiblePages,
pageRootOffsets = pageRoots,
pageSizes = pageSizes,
fallbackPageIndex = 199
)
)
assertEquals(
199,
desktopPdfSpreadZoomAnchorPageIndex(
viewportRootOffset = Offset.Zero,
anchor = null,
visiblePageIndices = visiblePages,
pageRootOffsets = pageRoots,
pageSizes = pageSizes,
fallbackPageIndex = 199
)
)
val fittedSpread = desktopPdfSpreadLayoutPrediction(
viewportRootOffset = Offset.Zero,
viewportSize = IntSize(1920, 991),
visiblePageIndices = visiblePages,
pageCanvasSizes = mapOf(
199 to IntSize(667, 881),
200 to IntSize(667, 881)
),
horizontalScroll = 0,
verticalScroll = 112,
paddingPx = 30f,
pageGapPx = 22.5f
) ?: error("Expected fitted spread prediction")
assertEquals(0, fittedSpread.maxHorizontalScroll)
assertEquals(0, fittedSpread.maxVerticalScroll)
assertEquals(282f, fittedSpread.pageRootOffsets[199]?.x ?: -1f, 0.5f)
assertEquals(972f, fittedSpread.pageRootOffsets[200]?.x ?: -1f, 0.5f)
assertEquals(30f, fittedSpread.pageRootOffsets[200]?.y ?: -1f, 0.0001f)
val scrollableSpread = desktopPdfSpreadLayoutPrediction(
viewportRootOffset = Offset.Zero,
viewportSize = IntSize(1920, 991),
visiblePageIndices = visiblePages,
pageCanvasSizes = mapOf(
199 to IntSize(1371, 1810),
200 to IntSize(1371, 1810)
),
horizontalScroll = 696,
verticalScroll = 575,
paddingPx = 30f,
pageGapPx = 22.5f
) ?: error("Expected scrollable spread prediction")
assertEquals(905, scrollableSpread.maxHorizontalScroll)
assertEquals(879, scrollableSpread.maxVerticalScroll)
assertEquals(-666f, scrollableSpread.pageRootOffsets[199]?.x ?: 0f, 0.5f)
assertEquals(728f, scrollableSpread.pageRootOffsets[200]?.x ?: 0f, 0.5f)
assertEquals(-545f, scrollableSpread.pageRootOffsets[200]?.y ?: 0f, 0.0001f)
}
@Test
fun `desktop pdf zoom preview bridges committed anchored zoom`() {
val preview = DesktopPdfZoomPreview(
baseZoom = 1f,
zoom = 2f,
anchor = Offset(100f, 100f),
displayMode = PdfDisplayMode.PAGINATION,
pageIndex = 0,
viewportRootOffset = Offset.Zero,
pageRootOffset = Offset.Zero
)
assertTrue(desktopPdfZoomPreviewMatchesScale(preview, 1f))
assertTrue(desktopPdfZoomPreviewMatchesScale(preview, 2f))
assertFalse(desktopPdfZoomPreviewMatchesScale(preview, 1.5f))
assertEquals(
Offset(-100f, -100f),
desktopPdfZoomCommitPreviewTranslation(
viewportRootOffset = Offset.Zero,
oldPageRootOffset = Offset.Zero,
currentAnchorPageRootOffset = Offset.Zero,
anchor = Offset(100f, 100f),
oldZoom = 1f,
newZoom = 2f,
currentZoom = 2f
)
)
assertEquals(
Offset.Zero,
desktopPdfZoomCommitPreviewTranslation(
viewportRootOffset = Offset.Zero,
oldPageRootOffset = Offset.Zero,
currentAnchorPageRootOffset = Offset(-100f, -100f),
anchor = Offset(100f, 100f),
oldZoom = 1f,
newZoom = 2f,
currentZoom = 2f
)
)
assertEquals(
null,
desktopPdfZoomCommitPreviewTranslation(
viewportRootOffset = Offset.Zero,
oldPageRootOffset = Offset.Zero,
currentAnchorPageRootOffset = Offset.Zero,
anchor = Offset(100f, 100f),
oldZoom = 1f,
newZoom = 2f,
currentZoom = 1f
)
)
assertEquals(0, desktopPdfReachableScrollDelta(currentScroll = 0, maxScroll = 0, requestedDelta = 100))
assertEquals(0, desktopPdfReachableScrollDelta(currentScroll = 0, maxScroll = 200, requestedDelta = -40))
assertEquals(-40, desktopPdfReachableScrollDelta(currentScroll = 80, maxScroll = 200, requestedDelta = -40))
assertEquals(
Offset.Zero,
desktopPdfZoomCommitPreviewTranslation(
viewportRootOffset = Offset.Zero,
oldPageRootOffset = Offset.Zero,
currentAnchorPageRootOffset = Offset.Zero,
anchor = Offset(100f, 100f),
oldZoom = 1f,
newZoom = 2f,
currentZoom = 2f,
scrollBounds = DesktopPdfZoomScrollBounds(
currentHorizontalScroll = 0,
maxHorizontalScroll = 0,
currentVerticalScroll = 0,
maxVerticalScroll = 0
)
)
)
assertEquals(
Offset(-50f, -25f),
desktopPdfZoomCommitPreviewTranslation(
viewportRootOffset = Offset.Zero,
oldPageRootOffset = Offset.Zero,
currentAnchorPageRootOffset = Offset.Zero,
anchor = Offset(100f, 100f),
oldZoom = 1f,
newZoom = 2f,
currentZoom = 2f,
scrollBounds = DesktopPdfZoomScrollBounds(
currentHorizontalScroll = 0,
maxHorizontalScroll = 50,
currentVerticalScroll = 0,
maxVerticalScroll = 25
)
)
)
val pendingCommitBounds = desktopPdfZoomScrollBoundsWithCommitTargets(
preview = preview.copy(
commitTargetHorizontalScroll = 300,
commitTargetVerticalScroll = 300
),
currentHorizontalScroll = 0,
maxHorizontalScroll = 0,
currentVerticalScroll = 0,
maxVerticalScroll = 0
)
assertEquals(300, pendingCommitBounds.maxHorizontalScroll)
assertEquals(300, pendingCommitBounds.maxVerticalScroll)
assertEquals(
Offset(-100f, -100f),
desktopPdfZoomCommitPreviewTranslation(
viewportRootOffset = Offset.Zero,
oldPageRootOffset = Offset.Zero,
currentAnchorPageRootOffset = Offset.Zero,
anchor = Offset(100f, 100f),
oldZoom = 1f,
newZoom = 2f,
currentZoom = 2f,
scrollBounds = pendingCommitBounds
)
)
val fittingPagePrediction = desktopPdfSinglePageLayoutPrediction(
viewportRootOffset = Offset.Zero,
viewportSize = IntSize(1920, 991),
pageCanvasSize = IntSize(1216, 1605),
horizontalScroll = 0,
verticalScroll = 0,
paddingPx = 30f
) ?: error("Expected fitting page prediction")
assertEquals(Offset(352f, 30f), fittingPagePrediction.rootOffset)
assertEquals(0, fittingPagePrediction.maxHorizontalScroll)
assertEquals(674, fittingPagePrediction.maxVerticalScroll)
val oversizedPagePrediction = desktopPdfSinglePageLayoutPrediction(
viewportRootOffset = Offset.Zero,
viewportSize = IntSize(1920, 991),
pageCanvasSize = IntSize(2498, 3298),
horizontalScroll = 409,
verticalScroll = 1122,
paddingPx = 30f
) ?: error("Expected oversized page prediction")
assertEquals(Offset(-379f, -1092f), oversizedPagePrediction.rootOffset)
assertEquals(638, oversizedPagePrediction.maxHorizontalScroll)
assertEquals(2367, oversizedPagePrediction.maxVerticalScroll)
}
@Test
fun `desktop pdf anchored zoom keeps cursor content stable`() {
assertEquals(

View file

@ -0,0 +1,174 @@
package com.aryan.reader.desktop
import java.awt.Canvas
import java.awt.event.KeyEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DesktopReaderKeyCommandsTest {
@Test
fun `ctrl f opens epub reader search`() {
assertEquals(
DesktopReaderKeyNavigation.SEARCH,
awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F')
.desktopReaderKeyNavigationOrNull(fullscreen = false)
)
}
@Test
fun `epub right to left pagination swaps physical arrow navigation`() {
assertEquals(
DesktopReaderKeyNavigation.PREVIOUS,
awtKeyEvent(KeyEvent.VK_RIGHT, 0, KeyEvent.CHAR_UNDEFINED)
.desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true)
)
assertEquals(
DesktopReaderKeyNavigation.NEXT,
awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED)
.desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true)
)
assertEquals(
DesktopReaderKeyNavigation.NEXT,
awtKeyEvent(KeyEvent.VK_PAGE_DOWN, 0, KeyEvent.CHAR_UNDEFINED)
.desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true)
)
}
@Test
fun `ctrl f opens pdf reader search while reading`() {
assertEquals(
DesktopPdfKeyCommand.SEARCH,
awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F')
.desktopPdfKeyCommandOrNull(fullscreen = false, editingText = false)
)
}
@Test
fun `ctrl f opens pdf reader search while text editing`() {
assertEquals(
DesktopPdfKeyCommand.SEARCH,
awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F')
.desktopPdfKeyCommandOrNull(fullscreen = false, editingText = true)
)
}
@Test
fun `pdf text editing keeps unmodified arrows for the editor`() {
assertNull(
awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED)
.desktopPdfKeyCommandOrNull(fullscreen = false, editingText = true)
)
}
@Test
fun `pdf right to left pagination swaps physical arrow navigation`() {
assertEquals(
DesktopPdfKeyCommand.PREVIOUS_PAGE,
awtKeyEvent(KeyEvent.VK_RIGHT, 0, KeyEvent.CHAR_UNDEFINED)
.desktopPdfKeyCommandOrNull(
fullscreen = false,
editingText = false,
rightToLeftPagination = true
)
)
assertEquals(
DesktopPdfKeyCommand.NEXT_PAGE,
awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED)
.desktopPdfKeyCommandOrNull(
fullscreen = false,
editingText = false,
rightToLeftPagination = true
)
)
assertEquals(
DesktopPdfKeyCommand.NEXT_PAGE,
awtKeyEvent(KeyEvent.VK_PAGE_DOWN, 0, KeyEvent.CHAR_UNDEFINED)
.desktopPdfKeyCommandOrNull(
fullscreen = false,
editingText = false,
rightToLeftPagination = true
)
)
}
@Test
fun `reader side panels can opt into global key dispatch without enabling popups`() {
assertEquals(
DesktopReaderModalWindowKind.PANEL,
desktopReaderModalWindowKind(
windowName = "${DesktopReaderModalWindowNamePrefix}PanelLeft",
windowTitle = "Reader Navigation"
)
)
assertTrue(
desktopReaderKeyDispatchAllowedForActiveWindowKind(
activeReaderModalKind = DesktopReaderModalWindowKind.PANEL,
allowChromeModalWindows = false,
allowPanelModalWindows = true,
dispatchWhenOwnerWindowActive = false
)
)
assertFalse(
desktopReaderKeyDispatchAllowedForActiveWindowKind(
activeReaderModalKind = DesktopReaderModalWindowKind.POPUP,
allowChromeModalWindows = true,
allowPanelModalWindows = true,
dispatchWhenOwnerWindowActive = true
)
)
}
@Test
fun `reader chrome and owner window dispatch remain separately gated`() {
assertEquals(
DesktopReaderModalWindowKind.CHROME,
desktopReaderModalWindowKind(
windowName = "${DesktopReaderModalWindowNamePrefix}ChromeTop",
windowTitle = "Reader Chrome Top"
)
)
assertEquals(
DesktopReaderModalWindowKind.POPUP,
desktopReaderModalWindowKind(
windowName = "${DesktopReaderModalWindowNamePrefix}Popup",
windowTitle = "Reader Popup"
)
)
assertNull(desktopReaderModalWindowKind(windowName = "", windowTitle = "Episteme"))
assertFalse(
desktopReaderKeyDispatchAllowedForActiveWindowKind(
activeReaderModalKind = null,
allowChromeModalWindows = false,
allowPanelModalWindows = true,
dispatchWhenOwnerWindowActive = false
)
)
assertTrue(
desktopReaderKeyDispatchAllowedForActiveWindowKind(
activeReaderModalKind = DesktopReaderModalWindowKind.CHROME,
allowChromeModalWindows = true,
allowPanelModalWindows = false,
dispatchWhenOwnerWindowActive = false
)
)
}
private fun awtKeyEvent(
keyCode: Int,
modifiers: Int,
keyChar: Char
): KeyEvent {
return KeyEvent(
Canvas(),
KeyEvent.KEY_PRESSED,
0L,
modifiers,
keyCode,
keyChar
)
}
}

View file

@ -0,0 +1,69 @@
package com.aryan.reader.desktop
import androidx.compose.ui.unit.sp
import com.aryan.reader.paginatedreader.CssStyle
import com.aryan.reader.paginatedreader.SemanticParagraph
import com.aryan.reader.shared.reader.ReaderPage
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DesktopReaderTypographyTest {
@Test
fun `same page layout includes semantic styling`() {
val plain = pageWith(
SemanticParagraph(
text = "Styled text",
spans = emptyList(),
style = CssStyle(),
elementId = null,
cfi = null,
startCharOffsetInSource = 0,
blockIndex = 0
)
)
val styled = pageWith(
SemanticParagraph(
text = "Styled text",
spans = emptyList(),
style = CssStyle(fontSize = 24.sp),
elementId = null,
cfi = null,
startCharOffsetInSource = 0,
blockIndex = 0
)
)
assertFalse(listOf(plain).samePageLayoutAs(listOf(styled)))
}
@Test
fun `same page layout still matches identical semantic pages`() {
val page = pageWith(
SemanticParagraph(
text = "Styled text",
spans = emptyList(),
style = CssStyle(fontSize = 24.sp),
elementId = null,
cfi = null,
startCharOffsetInSource = 0,
blockIndex = 0
)
)
assertTrue(listOf(page).samePageLayoutAs(listOf(page.copy())))
}
private fun pageWith(block: SemanticParagraph): ReaderPage {
return ReaderPage(
pageIndex = 0,
chapterIndex = 0,
chapterTitle = "One",
text = block.text,
startOffset = 0,
endOffset = block.text.length,
semanticBlocks = listOf(block)
)
}
}

View file

@ -1,14 +1,23 @@
package com.aryan.reader.desktop
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowPlacement
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.ui.SharedAppTab
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DesktopReaderWindowStateTest {
@Test
fun `desktop starts on library instead of home`() {
assertEquals(SharedAppTab.LIBRARY, DesktopInitialAppTab)
}
@Test
fun `opening a new reader creates a window`() {
val opening = readerOpening("book-1", requestId = 1)
@ -59,6 +68,67 @@ class DesktopReaderWindowStateTest {
assertEquals(2L, decision.windows.single().focusRequestId)
}
@Test
fun `reader window uses persisted size instead of hardcoded fallback`() {
val snapshot = DesktopWindowStateSnapshot(
placement = DesktopSavedWindowPlacement.FLOATING,
widthDp = 1340f,
heightDp = 840f
)
val size = snapshot.toWindowSize(DesktopReaderWindowDefaultSize)
assertEquals(1340.dp, size.width)
assertEquals(840.dp, size.height)
}
@Test
fun `reader window defaults preserve previous detached reader size`() {
assertEquals(1120.dp, DesktopReaderWindowDefaultSize.width)
assertEquals(760.dp, DesktopReaderWindowDefaultSize.height)
}
@Test
fun `reader window persistence ignores fullscreen snapshots`() {
val snapshot = DesktopWindowStateSnapshot(
placement = DesktopSavedWindowPlacement.FULLSCREEN,
widthDp = 1920f,
heightDp = 1080f
)
assertEquals(WindowPlacement.Floating, snapshot.toReaderWindowPlacement())
assertNull(snapshot.toPersistableReaderWindowSnapshot())
}
@Test
fun `native webview text reader resets surface when switching from vertical to paginated`() {
assertTrue(
shouldResetDesktopTextReaderWindowSurface(
previousMode = ReaderReadingMode.VERTICAL,
currentMode = ReaderReadingMode.PAGINATED,
usesNativeWebView = true
)
)
}
@Test
fun `text reader surface reset is limited to native webview vertical to paginated switches`() {
assertFalse(
shouldResetDesktopTextReaderWindowSurface(
previousMode = ReaderReadingMode.PAGINATED,
currentMode = ReaderReadingMode.VERTICAL,
usesNativeWebView = true
)
)
assertFalse(
shouldResetDesktopTextReaderWindowSurface(
previousMode = ReaderReadingMode.VERTICAL,
currentMode = ReaderReadingMode.PAGINATED,
usesNativeWebView = false
)
)
}
private fun readerOpening(bookId: String, requestId: Long): DesktopReaderOpening {
return DesktopReaderOpening(
requestId = requestId,

View file

@ -1,10 +1,13 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.ReaderFeatureSurface
import com.aryan.reader.shared.SharedReaderScreenState
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import java.io.File
import java.nio.file.Files
class DesktopStartupTest {
@Test
@ -25,34 +28,174 @@ class DesktopStartupTest {
}
@Test
fun `embedded webview starts only for epub backed reader surfaces`() {
assertTrue(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.EPUB_READER))
assertTrue(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.TEXT_READER))
assertFalse(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.PDF_VIEWER))
assertFalse(shouldRequestDesktopWebViewRuntime(null))
fun `desktop epub webview uses native browser backends without bundled runtime`() {
val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64)
val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64)
val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64)
assertEquals(DesktopEpubWebViewBackend.WEBKIT, desktopEpubWebViewBackend(linux))
assertEquals(DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2, desktopEpubWebViewBackend(windows))
assertEquals(DesktopEpubWebViewBackend.WEBKIT, desktopEpubWebViewBackend(macos))
assertEquals(DesktopEpubWebViewBackend.UNSUPPORTED, desktopEpubWebViewBackend(other))
assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(linux))
assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(windows))
assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(macos))
assertFalse(desktopEpubWebViewUsesNativeSwtBrowser(other))
}
@Test
fun `embedded webview startup skips terminal runtime states`() {
assertFalse(shouldStartDesktopWebViewRuntime(requested = false, state = DesktopWebViewRuntimeState()))
assertTrue(shouldStartDesktopWebViewRuntime(requested = true, state = DesktopWebViewRuntimeState()))
assertFalse(
shouldStartDesktopWebViewRuntime(
requested = true,
state = DesktopWebViewRuntimeState(initialized = true)
)
fun `native webviews can render without bundled runtime state`() {
val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64)
val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64)
val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64)
assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), windows))
assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), linux))
assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), macos))
assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(initialized = true), linux))
assertFalse(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(initialized = true), other))
}
@Test
fun `native webview unavailable messages point to the platform runtime`() {
val windowsMessage = desktopNativeWebViewUnavailableMessage(
backend = DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2,
detail = "missing runtime"
)
assertFalse(
shouldStartDesktopWebViewRuntime(
requested = true,
state = DesktopWebViewRuntimeState(restartRequired = true)
)
val linuxMessage = desktopNativeWebViewUnavailableMessage(
backend = DesktopEpubWebViewBackend.WEBKIT,
detail = "missing library"
)
assertFalse(
shouldStartDesktopWebViewRuntime(
requested = true,
state = DesktopWebViewRuntimeState(errorMessage = "missing bundle")
assertTrue(windowsMessage.contains("WebView2 Runtime"))
assertTrue(windowsMessage.contains("missing runtime"))
assertTrue(linuxMessage.contains("WebKitGTK"))
assertTrue(linuxMessage.contains("Linux distribution packages"))
assertTrue(linuxMessage.contains("missing library"))
}
@Test
fun `compose interop blending stays off by default for native swt webviews`() {
val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64)
val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64)
assertNull(composeInteropBlendingDefault(windows))
assertNull(composeInteropBlendingDefault(linux))
assertEquals(ComposeInteropBlendingEnabled, composeInteropBlendingDefault(other))
}
@Test
fun `silent startup folder sync does not surface missing folder banner`() {
val completed = desktopFolderSyncCompletedState(
state = SharedReaderScreenState(),
message = "Folder sync failed for 1 folder.",
failedFolderCount = 1,
showBanner = false
)
assertNull(completed.bannerMessage)
}
@Test
fun `manual folder sync still surfaces missing folder banner`() {
val completed = desktopFolderSyncCompletedState(
state = SharedReaderScreenState(),
message = "Folder sync failed for 1 folder.",
failedFolderCount = 1,
showBanner = true
)
assertEquals("Folder sync failed for 1 folder.", completed.bannerMessage?.message)
assertTrue(completed.bannerMessage?.isError == true)
}
@Test
fun `desktop account profile store restores cached profile for matching user`() {
val directory = Files.createTempDirectory("episteme-account-profile-test").toFile()
try {
val store = DesktopAccountProfileStore(File(directory, "account_profile.properties"))
store.save("user-1", DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 123L))
assertEquals(
DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 123L),
store.load("user-1")
)
assertNull(store.load("user-2"))
} finally {
directory.deleteRecursively()
}
}
@Test
fun `desktop account profile freshness uses fetched timestamp`() {
val now = 10_000L
val ttl = 1_000L
assertTrue(DesktopAccountProfile(fetchedAtEpochMillis = now - ttl).isFresh(now, ttl))
assertTrue(DesktopAccountProfile(fetchedAtEpochMillis = now - 1L).isFresh(now, ttl))
assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = now - ttl - 1L).isFresh(now, ttl))
assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = now + 1L).isFresh(now, ttl))
assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = 0L).isFresh(now, ttl))
}
@Test
fun `desktop account profile repository ignores stale startup cache`() {
val directory = Files.createTempDirectory("episteme-account-profile-policy-test").toFile()
try {
val store = DesktopAccountProfileStore(File(directory, "account_profile.properties"))
val repository = DesktopAccountProfileRepository(testDesktopCloudConfig(), store)
val now = DesktopAccountProfileCacheTtlMillis + 10_000L
val freshProfile = DesktopAccountProfile(
isProUser = true,
credits = 42,
fetchedAtEpochMillis = now - DesktopAccountProfileCacheTtlMillis + 1L
)
repository.saveFetchedProfile("user-1", freshProfile)
assertEquals(freshProfile, repository.cachedProfile("user-1", now))
repository.saveFetchedProfile(
"user-1",
freshProfile.copy(fetchedAtEpochMillis = now - DesktopAccountProfileCacheTtlMillis - 1L)
)
assertNull(repository.cachedProfile("user-1", now))
} finally {
directory.deleteRecursively()
}
}
@Test
fun `desktop account profile repository clear removes sign out cache`() {
val directory = Files.createTempDirectory("episteme-account-profile-clear-test").toFile()
try {
val store = DesktopAccountProfileStore(File(directory, "account_profile.properties"))
val repository = DesktopAccountProfileRepository(testDesktopCloudConfig(), store)
repository.saveFetchedProfile(
"user-1",
DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 10_000L)
)
repository.clearCachedProfiles()
assertNull(repository.cachedProfile("user-1", 10_001L))
assertNull(store.load("user-1"))
} finally {
directory.deleteRecursively()
}
}
private fun testDesktopCloudConfig(): DesktopCloudConfig {
return DesktopCloudConfig(
aiWorkerUrl = "",
ttsWorkerUrl = "",
firebaseWebApiKey = "",
firebaseProjectId = "reader-test",
googleOAuthClientId = "",
googleOAuthClientSecret = ""
)
}
}

View file

@ -72,6 +72,24 @@ class DesktopStringResourcesTest {
assertEquals("Don't skip %1${'$'}d file", parsed["quoted_count"]?.get("one"))
}
@Test
fun loadsAndroidToolbarTooltipDescriptionsForDesktop() {
val resources = DesktopAndroidStringResources.load(
locale = Locale.ENGLISH,
classLoader = Thread.currentThread().contextClassLoader
?: DesktopStringResourcesTest::class.java.classLoader
)
assertEquals(
"Exit search and go back to the reader",
resources.stringOrNull("tooltip_close_search_desc")
)
assertEquals(
"Jump to the next search match in the document",
resources.stringOrNull("tooltip_next_result_desc")
)
}
@Test
fun choosesDesktopPluralQuantityForSupportedLanguages() {
val slavicQuantities = setOf("one", "few", "many", "other")

View file

@ -0,0 +1,18 @@
package com.aryan.reader.desktop
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DesktopTtsLogTest {
@Test
fun `desktop tts preview redacts key and token query values`() {
val preview = "wss://example.test/live?key=gemini_secret&token=firebase_secret"
.desktopTtsPreview(300)
assertFalse(preview.contains("gemini_secret"))
assertFalse(preview.contains("firebase_secret"))
assertTrue(preview.contains("key=<redacted>"))
assertTrue(preview.contains("token=<redacted>"))
}
}

View file

@ -0,0 +1,44 @@
package com.aryan.reader.desktop
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DesktopWebView2LayoutTest {
@Test
fun `webview2 host bounds match the awt canvas logical size`() {
val bounds = desktopWebView2TargetBoundsForCanvas(width = 1440, height = 900)
assertEquals(DesktopWebView2TargetBounds(x = 0, y = 0, width = 1440, height = 900), bounds)
}
@Test
fun `webview2 host bounds are unavailable before the canvas has size`() {
assertNull(desktopWebView2TargetBoundsForCanvas(width = 0, height = 900))
assertNull(desktopWebView2TargetBoundsForCanvas(width = 1440, height = 0))
}
@Test
fun `webview2 awt canvas is not retired while host window is closing`() {
assertFalse(
desktopWebView2ShouldRetireAwtCanvas(
hostWindowClosing = true,
hostWindowDisplayable = true
)
)
assertFalse(
desktopWebView2ShouldRetireAwtCanvas(
hostWindowClosing = false,
hostWindowDisplayable = false
)
)
assertTrue(
desktopWebView2ShouldRetireAwtCanvas(
hostWindowClosing = false,
hostWindowDisplayable = true
)
)
}
}

View file

@ -34,4 +34,10 @@ class DesktopWindowStateStoreTest {
assertEquals(EpistemeDesktopWindowMinimumWidthPx.toFloat(), snapshot.widthDp)
assertEquals(EpistemeDesktopWindowMinimumHeightPx.toFloat(), snapshot.heightDp)
}
@Test
fun `reader window state uses a separate config file`() {
assertEquals("window_state.json", DesktopWindowStateStore.defaultWindowStateFile().name)
assertEquals("reader_window_state.json", DesktopWindowStateStore.defaultReaderWindowStateFile().name)
}
}

View file

@ -0,0 +1,55 @@
package com.aryan.reader.desktop
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class LinuxSecretToolCodecTest {
@Test
fun `secret tool codec stores looks up and clears secrets by key`() {
val runner = FakeSecretCommandRunner()
val codec = LinuxSecretToolCodec(runner)
assertTrue(codec.isAvailable)
val reference = codec.protect("firebaseRefreshTokenProtected", "linux_refresh")
assertEquals("linux_refresh", codec.unprotect("firebaseRefreshTokenProtected", reference))
codec.delete("firebaseRefreshTokenProtected")
assertTrue(runner.storedSecrets.isEmpty())
}
private class FakeSecretCommandRunner : DesktopSecretCommandRunner {
val storedSecrets = linkedMapOf<String, String>()
override fun isExecutableAvailable(command: String): Boolean {
return command == "secret-tool"
}
override fun run(
command: List<String>,
input: String?,
timeoutMillis: Long
): DesktopSecretCommandResult {
return when (command.getOrNull(1)) {
"--help" -> DesktopSecretCommandResult(0, "usage", "")
"store" -> {
storedSecrets[command.last()] = input.orEmpty()
DesktopSecretCommandResult(0, "", "")
}
"lookup" -> {
val secret = storedSecrets[command.last()]
if (secret == null) {
DesktopSecretCommandResult(1, "", "not found")
} else {
DesktopSecretCommandResult(0, "$secret\n", "")
}
}
"clear" -> {
storedSecrets.remove(command.last())
DesktopSecretCommandResult(0, "", "")
}
else -> DesktopSecretCommandResult(1, "", "unexpected command")
}
}
}
}