Windows (#291)
* 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:
parent
88c7fa7b5c
commit
8366d76dcd
214 changed files with 53372 additions and 4702 deletions
|
|
@ -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 = ""
|
||||
}
|
||||
}
|
||||
|
|
@ -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="
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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="
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue