Desktop app (#308)

* Implement build profiles and feature policy for offline desktop builds

* Introduce unified cross-platform Settings Hub

* Refactor main settings into a hierarchical page-based navigation model

* Refactor library projection to use shared multiplatform logic

* Refactor UI state consumption by removing intermediate screen models

* Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic

* Refactor state management for tabs, selection, and pinning to use shared bridge logic

* Refactor file type management and validation into a centralized shared module

* Centralize file type resolution and improve handling of unknown types

* Centralize book import logic with SharedImportPlanner

* Refactor magnifier geometry logic and coordinate mapping

* Properly handle orientation changes in scroll-locked PDF reader

* Add screen orientation controls to EPUB and PDF readers

* Implement right-to-left (RTL) pagination support and refactor reader menus

* Separate right-to-left pagination settings for PDF and EPUB

* Ensure PDF page data is scoped by document key for multi tab support

* Implement theme-aware link styling for the epub reader

* Implement jump history for back and forward navigation in the epub reader

* Improve locator handling and navigation logic in paginated reader mode

* Implement stable pagination navigation and location tracking

* Centralize banner message management and auto-dismiss logic in MainViewModel

* Implement zoom and pan state preservation for PDF pan lock mode

* Enhance reader navigation UI and workspace layout management in desktop app

* Refactor reader navigation sidebar and relocate search controls in desktop app

* Enhance reader UI with redesigned selection menus and bottom sheet overlays

* Implement custom highlight palettes and reader theme customization in desktop app

* Implement cross-platform modal layer and refine reader UI styling

* Improve highlight accuracy and implement metadata enrichment on book open in desktop app

* Implement two-page spread layout for paginated reader on desktop

* Implement persistent caching for book loading and pagination in desktop app

* Implement persistent caching for book loading and pagination in desktop app

* Optimize reader settings updates by separating layout and appearance changes in desktop app

* Improve desktop window branding and native Windows styling

* Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app

* Refine selection handle positioning and interaction logic

* Implement EPUB selection debug logging and improve handle targeting

* Optimize desktop book loading performance and UI responsiveness

* Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer.

* Implement smooth zoom preview for the PDF reader in desktop app

* Optimize PDF rendering performance and responsiveness in the desktop reader

* Implement conditional diagnostic logging and update desktop build configuration

* Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling

* Added management options for annotations and highlights in the sidebar in desktop app

* Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability.

* Refined library filters and enhanced OPDS functionality in desktop app

* Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app

* Added PPTX support including document parsing, rendering, and indexing

* Improved PPTX rendering and layout accuracy

* Implemented text autofit support for PPTX rendering

* Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity

* Improved EPUB pagination accuracy and added layout telemetry in desktop app

* Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app

* Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling

* Implemented billing account obfuscation

* Implemented hierarchical folder navigation and improved library selection functionality in desktop app

* Implemented platform-aware directory resolution and multi-platform native library support for desktop

* Added full-screen mode for the reader workspace

* Added PDF zoom indicator and interactive vertical scrollbar with page tooltips

* Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management

* Updated PDF indexing to replace existing page text and removed search result item keys

* Implemented "preparing" foreground notification for TTS service

* Optimized PDF rendering performance by pre-calculating page-specific annotations

* Refactored desktop packaging tasks and improved distribution configuration

* Optimized EPUB parser memory usage and added path traversal protection

* Refactored WorkManager monitoring logic and added work pruning

* Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks

* Implemented bitmap size limits and scaling to prevent canvas rendering errors

* Split long text paragraphs into multiple semantic blocks during HTML parsing

* Implemented local ActionMode for text selection to prevent platform crashes

* Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic

* Added desktop startup splash screen and deferred WebView initialization

* Reorganized settings hub and added separate PDF reader defaults

* Implemented embedded cover extraction and metadata support for MOBI and FB2 formats

* Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance.

* Implemented procedurally generated book covers and replaced static placeholders

* Redesigned search UI with a top bar and results overlay in desktop app

* Added PDF page gap and overlay visibility options and implemented DesktopBookImporter

* Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop

* Implemented PDF viewport persistence for zoom and scroll positions in desktop app

* Improved desktop fullscreen implementation and state restoration

* Implemented desktop window state persistence

* Implemented flavor-based branding and ProGuard configuration for desktop builds

* Implemented precise reader positioning and improved highlight rendering logic in desktop app

* Added support for user-editable book metadata

* Enhanced book metadata support and integrated info/edit dialogs

* Implemented embedded EPUB metadata editing

* Improved highlight mapping and added custom scrollbar styling for the reader.

* Reduced desktop WebView bundle size by excluding unused locales and runtime files

* Added neutral pan mode as the default PDF interaction state.

* Refactored library empty states and updated primary navigation tabs in desktop app

* Implemented native paginated reader and unified content rendering architecture in desktop epub reader

* Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing.

* Improved pagination overflow detection in desktop

* Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
Aryan 2026-05-15 22:36:51 +05:30 committed by GitHub
parent c0d0e57e79
commit b20ade9946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
247 changed files with 43321 additions and 7087 deletions

View file

@ -86,6 +86,17 @@ class DesktopAiByokStoreTest {
assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel)
}
@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")
val store = DesktopAiByokStore(settingsFile.toFile(), ThrowingAvailabilitySecretCodec)
val loaded = store.load()
assertEquals("", loaded.geminiKey)
assertEquals("", loaded.groqKey)
}
private object ReversibleSecretCodec : DesktopSecretCodec {
override val isAvailable: Boolean = true
@ -103,4 +114,12 @@ class DesktopAiByokStoreTest {
override fun protect(value: String): String = ""
override fun unprotect(value: String): String = ""
}
private object ThrowingAvailabilitySecretCodec : DesktopSecretCodec {
override val isAvailable: Boolean
get() = error("Secure storage should not be checked for a missing settings file.")
override fun protect(value: String): String = ""
override fun unprotect(value: String): String = ""
}
}

View file

@ -0,0 +1,76 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.ImportedBookFile
import java.io.File
import java.nio.file.Files
import java.security.MessageDigest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class DesktopBookImporterTest {
@Test
fun `prepare imports copies supported file into app storage without source folder`() {
val tempRoot = Files.createTempDirectory("episteme-book-importer-test").toFile()
try {
val source = File(tempRoot, "Book.md").apply { writeText("# Hello") }
val store = File(tempRoot, "books")
val importer = DesktopBookImporter(store)
val result = importer.prepareImports(listOf(source.toImportedBookFile()))
assertEquals(0, result.failedCount)
val prepared = result.files.single()
val copied = File(assertNotNull(prepared.localPath))
assertEquals("Book.md", prepared.name)
assertEquals(source.sha256(), prepared.id)
assertNull(prepared.uriString)
assertNull(prepared.sourceFolder)
assertEquals(store.canonicalFile, copied.parentFile.canonicalFile)
assertNotEquals(source.canonicalFile, copied.canonicalFile)
assertEquals("# Hello", copied.readText())
} finally {
tempRoot.deleteRecursively()
}
}
@Test
fun `prepare imports leaves unsupported files uncopied for shared planner`() {
val tempRoot = Files.createTempDirectory("episteme-book-importer-test").toFile()
try {
val source = File(tempRoot, "Archive.zip").apply { writeText("zip") }
val store = File(tempRoot, "books")
val importer = DesktopBookImporter(store)
val result = importer.prepareImports(listOf(source.toImportedBookFile(sourceFolder = tempRoot.absolutePath)))
assertEquals(0, result.failedCount)
val prepared = result.files.single()
assertEquals(source.absolutePath, prepared.localPath)
assertNull(prepared.sourceFolder)
assertNull(prepared.id)
assertFalse(store.listFiles().orEmpty().any { it.isFile })
} finally {
tempRoot.deleteRecursively()
}
}
private fun File.toImportedBookFile(sourceFolder: String? = null): ImportedBookFile {
return ImportedBookFile(
name = name,
uriString = null,
localPath = absolutePath,
size = length(),
sourceFolder = sourceFolder
)
}
private fun File.sha256(): String {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(readBytes())
return digest.digest().joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
}
}

View file

@ -0,0 +1,89 @@
package com.aryan.reader.desktop
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
import kotlin.test.assertTrue
class DesktopBuildProfileTest {
@Test
fun `standard desktop flavor keeps online features available`() {
val profile = desktopBuildProfileForFlavor("standard")
assertEquals(DesktopFlavorStandard, profile.flavor)
assertEquals(EpistemeDesktopStandardAppName, profile.appName)
assertEquals("Standard edition", profile.buildLabel)
assertEquals(SharedFeaturePolicy.Standard, profile.featurePolicy)
assertTrue(profile.featurePolicy.networkAccess)
}
@Test
fun `oss offline desktop flavor disables network backed features`() {
val profile = desktopBuildProfileForFlavor("oss-offline")
assertEquals(DesktopFlavorOssOffline, profile.flavor)
assertEquals(EpistemeDesktopOssAppName, profile.appName)
assertEquals("Offline OSS edition", profile.buildLabel)
assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy)
assertFalse(profile.featurePolicy.networkAccess)
assertFalse(profile.featurePolicy.aiAndCloud)
assertFalse(profile.featurePolicy.opdsCatalogs)
assertFalse(profile.featurePolicy.googleFontsDownload)
}
@Test
fun `oss desktop flavor aliases resolve to offline oss profile`() {
val profile = desktopBuildProfileForFlavor("oss")
assertEquals(DesktopFlavorOssOffline, profile.flavor)
assertEquals(EpistemeDesktopOssAppName, profile.appName)
assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy)
}
@Test
fun `desktop diagnostics are disabled unless explicitly enabled`() {
assertFalse(desktopDiagnosticsFlag(null))
assertFalse(desktopDiagnosticsFlag(""))
assertFalse(desktopDiagnosticsFlag("false"))
assertFalse(desktopDiagnosticsFlag("1"))
assertTrue(desktopDiagnosticsFlag("true"))
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

@ -76,6 +76,21 @@ class DesktopCustomFontStoreTest {
assertEquals(listOf("Inter", "Literata"), googleFontsFromJson("""["Inter", "", " Literata "]"""))
}
@Test
fun `download google font fails before network when downloads are disabled`() {
val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile()
try {
val store = DesktopCustomFontStore(
fontsDir = File(tempRoot, "store"),
googleFontsDownloadAvailable = { false }
)
assertTrue(store.downloadGoogleFont("Inter").isFailure)
} finally {
tempRoot.deleteRecursively()
}
}
private fun File.toFontItem(): CustomFontItem {
return CustomFontItem(
id = nameWithoutExtension,

View file

@ -24,6 +24,9 @@ class DesktopFolderMetadataExtractorTest {
<metadata>
<dc:title>Direct EPUB</dc:title>
<dc:creator>Ada Lovelace</dc:creator>
<dc:description>&lt;p&gt;Metadata summary&lt;/p&gt;</dc:description>
<meta name="calibre:series" content="Computing Notes" />
<meta name="calibre:series_index" content="2" />
<meta name="cover" content="cover-image" />
</metadata>
<manifest>
@ -42,12 +45,49 @@ class DesktopFolderMetadataExtractorTest {
val enriched = result.books.single()
assertEquals("Direct EPUB", enriched.title)
assertEquals("Ada Lovelace", enriched.author)
assertEquals("<p>Metadata summary</p>", enriched.description)
assertEquals("Computing Notes", enriched.seriesName)
assertEquals(2.0, enriched.seriesIndex)
assertEquals("Direct EPUB", enriched.originalTitle)
assertEquals("Ada Lovelace", enriched.originalAuthor)
assertEquals("Computing Notes", enriched.originalSeriesName)
assertEquals(2.0, enriched.originalSeriesIndex)
assertEquals("<p>Metadata summary</p>", enriched.originalDescription)
assertEquals(epub.lastModified(), enriched.fileContentModifiedTimestamp)
assertTrue(enriched.folderTextMetadataParsed)
assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile)
assertEquals(1, result.stats.updatedBooks)
assertEquals(1, result.stats.coversUpdated)
}
@Test
fun `opened epub gets embedded cover`() = withCoverCacheDir { tempDir ->
val epub = File(tempDir, "opened.epub")
writeEpub(
target = epub,
opf = """
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata>
<dc:title>Opened EPUB</dc:title>
<dc:creator>Mary Shelley</dc:creator>
<meta name="cover" content="cover-image" />
</metadata>
<manifest>
<item id="cover-image" href="images/cover.png" media-type="image/png" />
</manifest>
</package>
""".trimIndent()
)
val book = bookFor(epub, FileType.EPUB, title = null)
val enriched = DesktopFolderMetadataExtractor.enrichOpenedBook(book)
assertEquals("Opened EPUB", enriched.title)
assertEquals("Mary Shelley", enriched.author)
assertTrue(enriched.folderTextMetadataParsed)
assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile)
}
@Test
fun `direct imported text file gets generated cover`() = withCoverCacheDir { tempDir ->
val textFile = File(tempDir, "notes.txt").apply { writeText("Notes") }

View file

@ -0,0 +1,108 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
import com.aryan.reader.shared.SharedFolderBookMetadata
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.SyncedFolder
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class DesktopLocalFolderSyncTest {
@Test
fun `metadata-only sync imports sidecar metadata without scanning physical files`() {
val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile()
try {
File(root, "New.pdf").writeText("%PDF")
val existingFile = File(root, "Existing.pdf")
val existingId = "local_Existing.pdf"
writeMetadataSidecar(
root = root,
metadata = metadata(
id = existingId,
title = "Remote Title",
progress = 72f,
modified = 2_000L
)
)
val existingBook = BookItem(
id = existingId,
path = existingFile.absolutePath,
type = FileType.PDF,
displayName = existingFile.name,
timestamp = 100L,
title = "Local Title",
progressPercentage = 5f,
sourceFolder = root.absolutePath
)
val result = DesktopLocalFolderSync.sync(
state = SharedReaderScreenState(
rawLibraryBooks = listOf(existingBook),
syncedFolders = listOf(syncedFolder(root))
),
shelfRefs = emptyList(),
nowMillis = 3_000L,
metadataOnly = true
)
assertEquals(1, result.state.rawLibraryBooks.size)
val syncedBook = result.state.rawLibraryBooks.single()
assertEquals(existingId, syncedBook.id)
assertEquals("Local Title", syncedBook.title)
assertEquals(72f, syncedBook.progressPercentage)
assertNull(result.state.rawLibraryBooks.firstOrNull { it.id == "local_New.pdf" })
assertEquals(0, result.stats.scannedFiles)
assertEquals(0, result.stats.newBooks)
assertEquals(0, result.stats.removedBooks)
assertEquals(1, result.stats.remoteMetadataUpdates)
} finally {
root.deleteRecursively()
}
}
private fun syncedFolder(root: File): SyncedFolder {
return SyncedFolder(
uriString = root.absolutePath,
name = root.name,
lastScanTime = 0L,
allowedFileTypes = setOf(FileType.PDF)
)
}
private fun writeMetadataSidecar(root: File, metadata: SharedFolderBookMetadata) {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
File(syncDir, ".${metadata.bookId}.json").writeText(metadata.toJsonString())
}
private fun metadata(
id: String,
title: String,
progress: Float,
modified: Long
): SharedFolderBookMetadata {
return SharedFolderBookMetadata(
bookId = id,
title = title,
author = null,
displayName = "Existing.pdf",
type = FileType.PDF.name,
lastChapterIndex = null,
lastPage = null,
lastPositionCfi = null,
progressPercentage = progress,
isRecent = true,
lastModifiedTimestamp = modified,
bookmarksJson = null,
locatorBlockIndex = null,
locatorCharOffset = null,
customName = null,
highlightsJson = null
)
}
}

View file

@ -4,6 +4,7 @@ import java.io.File
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class DesktopOpdsRepositoryTest {
@ -27,6 +28,45 @@ class DesktopOpdsRepositoryTest {
assertEquals("pass", custom.password)
}
@Test
fun `desktop opds http blocks before network in offline flavor`() {
withSystemProperty(DesktopFlavorProperty, DesktopFlavorOssOffline) {
assertFailsWith<IllegalStateException> {
DesktopOpdsHttp.fetchString("https://example.org/opds", null, null)
}
}
}
@Test
fun `desktop opds http creates basic authorization header for challenged catalogs`() {
assertEquals(
"Basic dXNlcjpwYXNz",
DesktopOpdsHttp.authorizationHeaderForChallenge(
challenge = "Basic realm=\"Catalog\"",
url = "https://example.org/opds",
username = "user",
password = "pass"
)
)
}
@Test
fun `desktop opds http creates digest authorization header for challenged catalogs`() {
assertEquals(
"Digest username=\"Mufasa\", realm=\"testrealm@host.com\", nonce=\"abcdef\", " +
"uri=\"/dir/index.atom?x=1\", response=\"ca833912ad1f4339630e23476d538d67\", " +
"qop=auth, nc=00000001, cnonce=\"0a4f113b\", opaque=\"xyz\"",
DesktopOpdsHttp.authorizationHeaderForChallenge(
challenge = "Digest realm=\"testrealm@host.com\", nonce=\"abcdef\", qop=\"auth\", opaque=\"xyz\"",
url = "https://example.org/dir/index.atom?x=1",
username = "Mufasa",
password = "Circle Of Life",
cnonce = "0a4f113b",
nonceCount = "00000001"
)
)
}
private fun DesktopOpdsRepository.addCatalogForTest(
title: String,
url: String,
@ -53,4 +93,26 @@ class DesktopOpdsRepositoryTest {
dir.deleteRecursively()
}
}
private fun withSystemProperty(
key: String,
value: String?,
block: () -> Unit
) {
val previous = System.getProperty(key)
try {
if (value == null) {
System.clearProperty(key)
} else {
System.setProperty(key, value)
}
block()
} finally {
if (previous == null) {
System.clearProperty(key)
} else {
System.setProperty(key, previous)
}
}
}
}

View file

@ -0,0 +1,52 @@
package com.aryan.reader.desktop
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.PdfDisplayMode
import com.aryan.reader.shared.ReaderTheme
import kotlin.test.Test
import kotlin.test.assertEquals
class DesktopPdfThemeTest {
@Test
fun `desktop pdf defaults to vertical display mode`() {
assertEquals(PdfDisplayMode.VERTICAL_SCROLL, DesktopDefaultPdfDisplayMode)
assertEquals(8.dp, DesktopDefaultPdfVerticalPageGap)
}
@Test
fun `page background follows android pdf theme defaults`() {
val noTheme = ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false)
val reverse = ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true)
val sepia = ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false)
assertEquals(Color.White, desktopPdfPageBackgroundColor(noTheme, PdfDisplayMode.VERTICAL_SCROLL))
assertEquals(Color.Black, desktopPdfPageBackgroundColor(noTheme, PdfDisplayMode.PAGINATION))
assertEquals(Color.Black, desktopPdfPageBackgroundColor(reverse, PdfDisplayMode.VERTICAL_SCROLL))
assertEquals(Color.White, desktopPdfPageBackgroundColor(reverse, PdfDisplayMode.PAGINATION))
assertEquals(Color(0xFFFBF0D9), desktopPdfPageBackgroundColor(sepia, PdfDisplayMode.VERTICAL_SCROLL))
}
@Test
fun `vertical viewport uses app gap color only when page gaps are visible`() {
val pageBackground = Color.White
val gapBackground = Color(0xFFE2E2E2)
assertEquals(
gapBackground,
desktopPdfVerticalViewportBackgroundColor(
pageBackgroundColor = pageBackground,
gapBackgroundColor = gapBackground,
isPageGapVisible = true
)
)
assertEquals(
pageBackground,
desktopPdfVerticalViewportBackgroundColor(
pageBackgroundColor = pageBackground,
gapBackgroundColor = gapBackground,
isPageGapVisible = false
)
)
}
}

View file

@ -0,0 +1,72 @@
package com.aryan.reader.desktop
import kotlin.test.Test
import kotlin.test.assertEquals
class DesktopPlatformPathsTest {
@Test
fun `desktop platform detects linux x64 resource names`() {
val platform = currentDesktopPlatform(osName = "Linux", osArch = "amd64")
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)
}
@Test
fun `desktop platform keeps existing windows resource names`() {
val platform = currentDesktopPlatform(osName = "Windows 11", osArch = "amd64")
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)
}
@Test
fun `linux user directories follow xdg environment variables`() {
val platform = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
val env = mapOf(
"XDG_DATA_HOME" to "/tmp/xdg-data",
"XDG_CONFIG_HOME" to "/tmp/xdg-config",
"XDG_CACHE_HOME" to "/tmp/xdg-cache"
)
assertEquals("/tmp/xdg-data/episteme", desktopUserDataRoot(platform, env::get, "/home/reader").portablePath())
assertEquals("/tmp/xdg-config/episteme", desktopUserConfigRoot(platform, env::get, "/home/reader").portablePath())
assertEquals("/tmp/xdg-cache/episteme", desktopUserCacheRoot(platform, env::get, "/home/reader").portablePath())
}
@Test
fun `linux user directories ignore relative xdg environment values`() {
val platform = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
val env = mapOf(
"XDG_DATA_HOME" to "relative-data",
"XDG_CONFIG_HOME" to "relative-config",
"XDG_CACHE_HOME" to "relative-cache"
)
assertEquals("/home/reader/.local/share/episteme", desktopUserDataRoot(platform, env::get, "/home/reader").portablePath())
assertEquals("/home/reader/.config/episteme", desktopUserConfigRoot(platform, env::get, "/home/reader").portablePath())
assertEquals("/home/reader/.cache/episteme", desktopUserCacheRoot(platform, env::get, "/home/reader").portablePath())
}
@Test
fun `windows user directories keep appdata compatible root`() {
val platform = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64)
val env = mapOf("APPDATA" to "C:/Users/reader/AppData/Roaming")
assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserDataRoot(platform, env::get, "C:/Users/reader").portablePath())
assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserConfigRoot(platform, env::get, "C:/Users/reader").portablePath())
assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserCacheRoot(platform, env::get, "C:/Users/reader").portablePath())
}
}
private fun java.io.File.portablePath(): String {
return path.replace('\\', '/')
}

View file

@ -0,0 +1,182 @@
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.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.ReaderPlatform
import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.pdf.PdfZoomSpec
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.assertTrue
class DesktopReaderDefaultsTest {
@Test
fun `desktop open book dialog accepts every shared desktop readable format`() {
assertEquals(
SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP),
desktopBookFileTypesForDialog()
)
assertTrue(FileType.PDF in desktopBookFileTypesForDialog())
}
@Test
fun `desktop uses global reader defaults when book has no local settings`() {
val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL)
val book = bookItem("without-local")
assertEquals(defaults, resolvedDesktopReaderSettings(book, defaults))
}
@Test
fun `desktop keeps local book reader settings ahead of global defaults`() {
val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL)
val local = ReaderSettings(fontSize = 17, readingMode = ReaderReadingMode.PAGINATED, themeId = "sepia")
val book = bookItem("with-local").copy(readerSettings = local)
assertEquals(local, resolvedDesktopReaderSettings(book, defaults))
}
@Test
fun `desktop pdf zoom allows deeper page magnification`() {
val sharedDefaultMax = PdfZoomSpec().max
val letterPageScale = DesktopPdfZoomSpec.safeRenderScale(
pageWidth = 612f,
pageHeight = 792f,
requestedScale = 6f
)
assertEquals(8f, DesktopPdfZoomSpec.max)
assertTrue(letterPageScale > sharedDefaultMax)
}
@Test
fun `desktop pdf touchpad zoom factors zoom in and out`() {
val zoomSpec = PdfZoomSpec(min = 0.5f, max = 8f, default = 1f)
assertTrue(desktopPdfScrollZoomFactor(-1f) > 1.1f)
assertTrue(desktopPdfScrollZoomFactor(1f) < 0.9f)
assertEquals(8f, desktopPdfZoomTarget(currentZoom = 7.8f, zoomSpec = zoomSpec, factor = 2f))
assertEquals(0.5f, desktopPdfZoomTarget(currentZoom = 0.6f, zoomSpec = zoomSpec, factor = 0.1f))
}
@Test
fun `desktop paginated pdf page changes avoid high resolution first render`() {
assertEquals(
DesktopPdfPaginationFastFirstRenderMaxScale,
desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = false)
)
assertEquals(
6f,
desktopPdfPaginationFirstRenderScale(
requestedScale = 6f,
hasPageRender = false,
isOpeningRender = true
)
)
assertEquals(
6f,
desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = true)
)
assertEquals(
1.25f,
desktopPdfPaginationFirstRenderScale(requestedScale = 1.25f, hasPageRender = false)
)
assertEquals(
0.75f,
desktopPdfPaginationFirstRenderScale(requestedScale = 0.75f, hasPageRender = false)
)
}
@Test
fun `desktop pdf anchored zoom keeps cursor content stable`() {
assertEquals(
300,
desktopPdfAnchoredScrollTarget(currentScroll = 100, anchor = 100f, oldZoom = 1f, newZoom = 2f)
)
assertEquals(
25,
desktopPdfAnchoredScrollTarget(currentScroll = 150, anchor = 100f, oldZoom = 2f, newZoom = 1f)
)
assertEquals(
100,
desktopPdfAnchoredLazyItemScrollOffset(itemOffset = 0, anchor = 100f, oldZoom = 1f, newZoom = 2f)
)
assertEquals(
200,
desktopPdfAnchoredLazyItemScrollOffset(itemOffset = -50, anchor = 100f, oldZoom = 1f, newZoom = 2f)
)
assertEquals(
IntOffset(100, 100),
desktopPdfAnchoredPageScrollDelta(
viewportRootOffset = Offset.Zero,
oldPageRootOffset = Offset.Zero,
currentPageRootOffset = Offset.Zero,
anchor = Offset(100f, 100f),
oldZoom = 1f,
newZoom = 2f
)
)
assertEquals(
IntOffset(0, 0),
desktopPdfAnchoredPageScrollDelta(
viewportRootOffset = Offset.Zero,
oldPageRootOffset = Offset.Zero,
currentPageRootOffset = Offset(-100f, -100f),
anchor = Offset(100f, 100f),
oldZoom = 1f,
newZoom = 2f
)
)
val offCenterPivot = desktopPdfZoomPreviewPivotFraction(
viewportRootOffset = Offset(20f, 30f),
pageRootOffset = Offset(120f, 230f),
anchor = Offset(250f, 450f),
pageCanvasSize = IntSize(500, 1000)
) ?: error("Expected off-center pivot")
assertEquals(0.3f, offCenterPivot.x, 0.0001f)
assertEquals(0.25f, offCenterPivot.y, 0.0001f)
val clampedPivot = desktopPdfZoomPreviewPivotFraction(
viewportRootOffset = Offset.Zero,
pageRootOffset = Offset.Zero,
anchor = Offset(900f, -20f),
pageCanvasSize = IntSize(500, 1000)
) ?: error("Expected clamped pivot")
assertEquals(1f, clampedPivot.x, 0.0001f)
assertEquals(0f, clampedPivot.y, 0.0001f)
val firstPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation(
viewportRootOffset = Offset.Zero,
pageRootOffset = Offset(0f, 0f),
anchor = Offset(100f, 200f),
previewScale = 2f
) ?: error("Expected first page document translation")
assertEquals(-100f, firstPageDocumentTranslation.x, 0.0001f)
assertEquals(-200f, firstPageDocumentTranslation.y, 0.0001f)
val secondPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation(
viewportRootOffset = Offset.Zero,
pageRootOffset = Offset(0f, 900f),
anchor = Offset(100f, 200f),
previewScale = 2f
) ?: error("Expected second page document translation")
assertEquals(-100f, secondPageDocumentTranslation.x, 0.0001f)
assertEquals(700f, secondPageDocumentTranslation.y, 0.0001f)
}
private fun bookItem(id: String): BookItem {
return BookItem(
id = id,
path = "C:/Books/$id.epub",
type = FileType.EPUB,
displayName = "$id.epub",
timestamp = 1L
)
}
}

View file

@ -0,0 +1,58 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.ReaderFeatureSurface
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DesktopStartupTest {
@Test
fun `startup splash uses compact branded feedback`() {
val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("standard"))
assertEquals(EpistemeDesktopWindowTitle, spec.title)
assertTrue(spec.message.isNotBlank())
assertTrue(spec.width in 320..480)
assertTrue(spec.height in 180..280)
}
@Test
fun `oss startup splash uses oss branding`() {
val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("oss-offline"))
assertEquals(EpistemeDesktopOssAppName, spec.title)
}
@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))
}
@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)
)
)
assertFalse(
shouldStartDesktopWebViewRuntime(
requested = true,
state = DesktopWebViewRuntimeState(restartRequired = true)
)
)
assertFalse(
shouldStartDesktopWebViewRuntime(
requested = true,
state = DesktopWebViewRuntimeState(errorMessage = "missing bundle")
)
)
}
}

View file

@ -0,0 +1,46 @@
package com.aryan.reader.desktop
import androidx.compose.ui.graphics.Color
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DesktopWindowPolishTest {
@Test
fun `desktop window defaults use app branding and a useful first launch size`() {
val defaults = epistemeDesktopWindowDefaults(desktopBuildProfileForFlavor("standard"))
assertEquals(EpistemeDesktopWindowTitle, defaults.title)
assertEquals(EpistemeDesktopWindowIconResource, defaults.iconResourcePath)
assertTrue(defaults.defaultSize.width.value > defaults.minimumSize.width.toFloat())
assertTrue(defaults.defaultSize.height.value > defaults.minimumSize.height.toFloat())
assertEquals(EpistemeDesktopWindowMinimumWidthPx, defaults.minimumSize.width)
assertEquals(EpistemeDesktopWindowMinimumHeightPx, defaults.minimumSize.height)
}
@Test
fun `oss desktop window defaults use oss branding`() {
val defaults = epistemeDesktopWindowDefaults(desktopBuildProfileForFlavor("oss-offline"))
assertEquals(EpistemeDesktopOssAppName, defaults.title)
}
@Test
fun `desktop chrome colors choose dark mode from dark theme surfaces`() {
val darkChrome = desktopWindowChromeColors(
captionColor = Color(0xFF12140E),
textColor = Color(0xFFE2E3D8),
borderColor = Color(0xFF0C0F09)
)
val lightChrome = desktopWindowChromeColors(
captionColor = Color(0xFFF9FAEF),
textColor = Color(0xFF1A1C16),
borderColor = Color(0xFFFFFFFF)
)
assertTrue(darkChrome.useDarkMode)
assertFalse(lightChrome.useDarkMode)
}
}

View file

@ -0,0 +1,37 @@
package com.aryan.reader.desktop
import kotlin.io.path.createTempFile
import kotlin.test.Test
import kotlin.test.assertEquals
class DesktopWindowStateStoreTest {
@Test
fun `desktop window state round trips through config store`() {
val file = createTempFile("episteme-window-state", ".json").toFile()
val store = DesktopWindowStateStore(file)
val snapshot = DesktopWindowStateSnapshot(
placement = DesktopSavedWindowPlacement.FLOATING,
widthDp = 1440f,
heightDp = 900f,
xDp = 120f,
yDp = 80f
)
store.save(snapshot)
assertEquals(snapshot, store.load())
}
@Test
fun `desktop window state clamps too small saved bounds`() {
val snapshot = DesktopWindowStateSnapshot(
placement = DesktopSavedWindowPlacement.FLOATING,
widthDp = 12f,
heightDp = 34f
).sanitized()
assertEquals(EpistemeDesktopWindowMinimumWidthPx.toFloat(), snapshot.widthDp)
assertEquals(EpistemeDesktopWindowMinimumHeightPx.toFloat(), snapshot.heightDp)
}
}