* Centralize library management logic and introduce support for plain text and HTML formats

* Centralize library management logic and introduce support for plain text and HTML formats

* Expand unit test coverage for library state management, UI models, and MainViewModel features.

* Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence

* Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges.

* Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin

* Add comprehensive unit tests

* Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic.

* Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version

* Folder import support for desktop app

* Introduce Smart Shelves with rule-based filtering in desktop version

* Implement shared EPUB annotation serialization and highlight rendering

* Centralize file type capabilities and platform-specific support logic

* Refactor reader state management to use a central reducer

* Implement customizable reader toolbar and advanced formatting settings in shared

* Implement locator-based navigation and customizable highlight palette for desktop app

* Enhance reader customization and expand search functionality in desktop app

* Redesign reader settings and tools into a tabbed control panel in desktop app

* Enhance reader navigation and highlight precision in desktop app

* Implement bidirectional position synchronization and dynamic highlights in the desktop reader

* Implement shared state management and enhanced search for the PDF reader in desktop app

* Add vertical scroll support to the desktop PDF reader

* Implement ink, text, and eraser annotation support in desktop PDF viewer

* Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app

* Implement link handling and navigation for PDF and EPUB readers in desktop app

* Implement PDF jump history for navigation in desktop app

* Enhance PDF ink rendering and annotation capabilities in desktop app

* Implement advanced PDF text annotations with inline editing and rich styling in desktop app

* Add move handle and movement logic for PDF text annotations in desktop app

* Implement local folder synchronization and metadata sidecar support in desktop app

* Implement book metadata extraction and drag-and-drop import for Desktop

* Implement dynamic and custom app theme management for desktop

* Introduce canonical PDF annotation codec and support for multi-segment highlights

* Implement rich text editing and pagination support for the PDF reader in desktop app

* Improve PDF rich text pagination, synchronization, and observability in desktop

* Hide trailing structural page breaks in rich text editor

* Implement a unified JVM book loader and expand supported formats on Desktop

* Add comic archive support for Desktop and enhance MOBI parsing

* Implement shared OPDS catalog support and UI for Android and Desktop

* Improve native WebView lifecycle and surface transition management on Desktop

* Enable Compose Swing interop blending and simplify Desktop WebView management

* Integrate BYOK AI features and Cloud TTS for desktop

* Enhance Desktop TTS with streaming audio and improved secure storage for AI key

* Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app

* Implement custom font management and utility screens in desktop app

* Implement PDFium-based PDF annotation export

* Remove PdfBox dependency and standardize PDF export via Pdfium

* Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app

* Implement reader themes and custom texture support in desktop app

* Redesign non-reader UI with responsive navigation and enhanced library management in desktop app

* Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app

* Exclude manual-only files from automated sync and import

* Implement customizable Text-to-Speech (TTS) word replacements

* Optimize reader performance with persistent layout caching and decoupled theme rendering

* Improve position restoration during reader reconfiguration in epub pagination

* Use independent thickness for eraser tool and stylus override
This commit is contained in:
Aryan 2026-05-10 10:07:37 +05:30 committed by GitHub
parent 88c7fa7b5c
commit 8366d76dcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
214 changed files with 53372 additions and 4702 deletions

View file

@ -0,0 +1,106 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID
import com.aryan.reader.shared.ReaderAiByokSettings
import java.nio.file.Files
import kotlin.io.path.readText
import kotlin.io.path.writeText
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DesktopAiByokStoreTest {
@Test
fun `save keeps keys out of plaintext settings file`() {
val settingsFile = Files.createTempDirectory("reader-ai-store").resolve("ai-byok.properties")
val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec)
store.save(
ReaderAiByokSettings(
geminiKey = "gemini_secret",
groqKey = "groq_secret",
modelForAll = "groq:qwen/qwen3-32b",
ttsModel = GEMINI_CLOUD_TTS_MODEL_ID
)
)
val raw = settingsFile.readText()
assertFalse(raw.contains("gemini_secret"))
assertFalse(raw.contains("groq_secret"))
assertTrue(raw.contains("geminiKeyProtected="))
assertTrue(raw.contains("groqKeyProtected="))
val loaded = store.load()
assertEquals("gemini_secret", loaded.geminiKey)
assertEquals("groq_secret", loaded.groqKey)
assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll)
assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel)
}
@Test
fun `load migrates legacy plaintext keys into protected entries`() {
val settingsFile = Files.createTempDirectory("reader-ai-store-legacy").resolve("ai-byok.properties")
settingsFile.writeText(
"""
geminiKey=old_gemini
groqKey=old_groq
modelForAll=groq:qwen/qwen3-32b
useOneModel=true
""".trimIndent()
)
val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec)
val loaded = store.load()
assertEquals("old_gemini", loaded.geminiKey)
assertEquals("old_groq", loaded.groqKey)
assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel)
val raw = settingsFile.readText()
assertFalse(raw.contains("geminiKey=old_gemini"))
assertFalse(raw.contains("groqKey=old_groq"))
assertTrue(raw.contains("geminiKeyProtected="))
assertTrue(raw.contains("groqKeyProtected="))
}
@Test
fun `model settings persist when secure key storage is unavailable`() {
val settingsFile = Files.createTempDirectory("reader-ai-store-unavailable").resolve("ai-byok.properties")
val store = DesktopAiByokStore(settingsFile.toFile(), UnavailableSecretCodec)
store.save(
ReaderAiByokSettings(
geminiKey = "session_only",
modelForAll = "groq:qwen/qwen3-32b",
ttsModel = GEMINI_CLOUD_TTS_MODEL_ID
)
)
val raw = settingsFile.readText()
assertFalse(raw.contains("session_only"))
assertFalse(raw.contains("geminiKeyProtected="))
val loaded = store.load()
assertEquals("", loaded.geminiKey)
assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll)
assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel)
}
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 UnavailableSecretCodec : DesktopSecretCodec {
override val isAvailable: Boolean = false
override fun protect(value: String): String = ""
override fun unprotect(value: String): String = ""
}
}

View file

@ -0,0 +1,59 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.FileType
import java.io.File
import java.nio.file.Files
import java.util.Base64
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class DesktopComicArchiveTest {
@Test
fun `cbz archive loads image pages for pdf reader surface`() = 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 document = DesktopPdfium.loadComic(cbz, FileType.CBZ)
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))
}
private fun withTempDir(block: (File) -> Unit) {
val dir = Files.createTempDirectory("reader-desktop-comic").toFile()
try {
block(dir)
} finally {
dir.deleteRecursively()
}
}
private fun onePixelPngBytes(): ByteArray {
return Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
}
}

View file

@ -0,0 +1,55 @@
package com.aryan.reader.desktop
import kotlin.test.Test
import kotlin.test.assertEquals
class DesktopComposeInteropTest {
@Test
fun `desktop enables Compose interop blending before app startup`() {
withSystemProperty(ComposeInteropBlendingProperty, null) {
configureComposeSwingInterop()
assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty))
}
}
@Test
fun `desktop treats blank Compose interop blending value as unset`() {
withSystemProperty(ComposeInteropBlendingProperty, " ") {
configureComposeSwingInterop()
assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty))
}
}
@Test
fun `desktop preserves explicit Compose interop blending override`() {
withSystemProperty(ComposeInteropBlendingProperty, "false") {
configureComposeSwingInterop()
assertEquals("false", System.getProperty(ComposeInteropBlendingProperty))
}
}
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,89 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.CustomFontItem
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 DesktopCustomFontStoreTest {
@Test
fun `import font copies supported file into desktop font store`() {
val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile()
try {
val source = File(tempRoot, "Literata.ttf").apply { writeText("font-bytes") }
val store = DesktopCustomFontStore(File(tempRoot, "store"))
val font = store.importFont(source).getOrThrow()
assertEquals("Literata", font.displayName)
assertEquals("ttf", font.fileExtension)
assertTrue(File(font.path).isFile)
assertEquals("font-bytes", File(font.path).readText())
} finally {
tempRoot.deleteRecursively()
}
}
@Test
fun `import font rejects unsupported extension`() {
val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile()
try {
val source = File(tempRoot, "not-a-font.txt").apply { writeText("nope") }
val store = DesktopCustomFontStore(File(tempRoot, "store"))
assertTrue(store.importFont(source).isFailure)
} finally {
tempRoot.deleteRecursively()
}
}
@Test
fun `delete font only removes files inside desktop font store`() {
val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile()
try {
val storeDir = File(tempRoot, "store").apply { mkdirs() }
val stored = File(storeDir, "font_a.ttf").apply { writeText("stored") }
val outside = File(tempRoot, "outside.ttf").apply { writeText("outside") }
val store = DesktopCustomFontStore(storeDir)
assertTrue(store.deleteFont(stored.toFontItem()))
assertFalse(stored.exists())
assertFalse(store.deleteFont(outside.toFontItem()))
assertTrue(outside.exists())
} finally {
tempRoot.deleteRecursively()
}
}
@Test
fun `google font css parser extracts first https font url`() {
val css = """
@font-face {
font-family: 'Literata';
src: url(https://fonts.gstatic.com/s/literata/v35/font.ttf) format('truetype');
}
""".trimIndent()
assertEquals("https://fonts.gstatic.com/s/literata/v35/font.ttf", googleFontDownloadUrlFromCss(css))
assertEquals("ttf", googleFontFileExtension("https://fonts.gstatic.com/s/literata/v35/font.ttf?foo=bar"))
}
@Test
fun `google fonts json parser ignores blank names`() {
assertEquals(listOf("Inter", "Literata"), googleFontsFromJson("""["Inter", "", " Literata "]"""))
}
private fun File.toFontItem(): CustomFontItem {
return CustomFontItem(
id = nameWithoutExtension,
displayName = nameWithoutExtension,
fileName = name,
fileExtension = extension,
path = absolutePath,
timestamp = 1L
)
}
}

View file

@ -0,0 +1,184 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import java.io.File
import java.nio.file.Files
import java.util.Base64
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class DesktopFolderMetadataExtractorTest {
@Test
fun `direct imported epub gets text metadata and embedded cover`() = withCoverCacheDir { tempDir ->
val epub = File(tempDir, "direct.epub")
writeEpub(
target = epub,
opf = """
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata>
<dc:title>Direct EPUB</dc:title>
<dc:creator>Ada Lovelace</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)
val result = DesktopFolderMetadataExtractor.enrichImportedBooks(
books = listOf(book),
importedBookIds = setOf(book.id)
)
val enriched = result.books.single()
assertEquals("Direct EPUB", enriched.title)
assertEquals("Ada Lovelace", enriched.author)
assertTrue(enriched.folderTextMetadataParsed)
assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile)
assertEquals(1, result.stats.updatedBooks)
assertEquals(1, result.stats.coversUpdated)
}
@Test
fun `direct imported text file gets generated cover`() = withCoverCacheDir { tempDir ->
val textFile = File(tempDir, "notes.txt").apply { writeText("Notes") }
val book = bookFor(textFile, FileType.TXT, title = "Notes")
val result = DesktopFolderMetadataExtractor.enrichImportedBooks(
books = listOf(book),
importedBookIds = setOf(book.id)
)
val enriched = result.books.single()
assertEquals("Notes", enriched.title)
assertFalse(enriched.folderTextMetadataParsed)
assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile)
assertEquals(1, result.stats.updatedBooks)
assertEquals(1, result.stats.coversUpdated)
}
@Test
fun `direct imported docx gets text metadata and generated cover`() = withCoverCacheDir { tempDir ->
val docx = File(tempDir, "direct.docx")
writeDocx(
target = docx,
title = "Direct DOCX",
author = "Grace Hopper",
bodyText = "Portable desktop document text."
)
val book = bookFor(docx, FileType.DOCX, title = null)
val result = DesktopFolderMetadataExtractor.enrichImportedBooks(
books = listOf(book),
importedBookIds = setOf(book.id)
)
val enriched = result.books.single()
assertEquals("Direct DOCX", enriched.title)
assertEquals("Grace Hopper", enriched.author)
assertTrue(enriched.folderTextMetadataParsed)
assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile)
assertEquals(1, result.stats.updatedBooks)
assertEquals(1, result.stats.coversUpdated)
}
private fun withCoverCacheDir(block: (File) -> Unit) {
val tempDir = Files.createTempDirectory("reader-desktop-covers").toFile()
val oldCacheDir = System.getProperty("reader.cover.cache.dir")
System.setProperty("reader.cover.cache.dir", File(tempDir, "covers").absolutePath)
try {
block(tempDir)
} finally {
if (oldCacheDir == null) {
System.clearProperty("reader.cover.cache.dir")
} else {
System.setProperty("reader.cover.cache.dir", oldCacheDir)
}
tempDir.deleteRecursively()
}
}
private fun bookFor(
file: File,
type: FileType,
title: String? = file.nameWithoutExtension
): BookItem {
return BookItem(
id = file.absolutePath,
path = file.absolutePath,
type = type,
displayName = file.name,
timestamp = 1L,
title = title,
fileSize = file.length(),
isRecent = false
)
}
private fun writeEpub(target: File, opf: String) {
ZipOutputStream(target.outputStream()).use { zip ->
zip.putText(
"META-INF/container.xml",
"""
<container>
<rootfiles>
<rootfile full-path="OEBPS/content.opf" />
</rootfiles>
</container>
""".trimIndent()
)
zip.putText("OEBPS/content.opf", opf)
zip.putBytes("OEBPS/images/cover.png", onePixelPngBytes())
}
}
private fun writeDocx(target: File, title: String, author: String, bodyText: String) {
ZipOutputStream(target.outputStream()).use { zip ->
zip.putText(
"docProps/core.xml",
"""
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>$title</dc:title>
<dc:creator>$author</dc:creator>
</cp:coreProperties>
""".trimIndent()
)
zip.putText(
"word/document.xml",
"""
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t>$bodyText</w:t></w:r></w:p>
</w:body>
</w:document>
""".trimIndent()
)
}
}
private fun ZipOutputStream.putText(name: String, value: String) {
putBytes(name, value.toByteArray(Charsets.UTF_8))
}
private fun ZipOutputStream.putBytes(name: String, value: ByteArray) {
putNextEntry(ZipEntry(name))
write(value)
closeEntry()
}
private fun onePixelPngBytes(): ByteArray {
return Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
}
}

View file

@ -0,0 +1,56 @@
package com.aryan.reader.desktop
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class DesktopOpdsRepositoryTest {
@Test
fun `desktop repository persists shared opds catalog rules`() = withTempDir { dir ->
var nextId = 0
val repository = DesktopOpdsRepository(
catalogFile = File(dir, "opds_catalogs.json"),
idFactory = { "catalog-${nextId++}" }
)
val defaults = repository.loadCatalogs()
assertEquals(2, defaults.size)
assertTrue(defaults.all { it.isDefault })
repository.addCatalogForTest(" Custom ", " https://example.org/opds ", " user ", " pass ")
val custom = repository.loadCatalogs().single { !it.isDefault }
assertEquals("Custom", custom.title)
assertEquals("https://example.org/opds", custom.url)
assertEquals("user", custom.username)
assertEquals("pass", custom.password)
}
private fun DesktopOpdsRepository.addCatalogForTest(
title: String,
url: String,
username: String?,
password: String?
) {
saveCatalogs(
com.aryan.reader.shared.opds.SharedOpdsCatalogs.addCatalog(
catalogs = loadCatalogs(),
title = title,
url = url,
username = username,
password = password,
idFactory = { "custom" }
)
)
}
private fun withTempDir(block: (File) -> Unit) {
val dir = Files.createTempDirectory("reader-desktop-opds").toFile()
try {
block(dir)
} finally {
dir.deleteRecursively()
}
}
}