* 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,527 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.DEFAULT_CLOUD_TTS_SPEAKER_ID
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID
import com.aryan.reader.shared.ReaderAiByokSettings
import com.sun.jna.Memory
import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.Structure
import com.sun.jna.WString
import com.sun.jna.ptr.PointerByReference
import com.sun.jna.win32.StdCallLibrary
import java.io.File
import java.util.Base64
import java.util.Properties
private const val WINDOWS_CRED_TYPE_GENERIC = 1
private const val WINDOWS_CRED_PERSIST_LOCAL_MACHINE = 2
private const val WINDOWS_ERROR_NOT_FOUND = 1168
internal class DesktopAiByokStore(
private val settingsFile: File = defaultSettingsFile(),
private val secretCodec: DesktopSecretCodec = DesktopSecretCodec.platform()
) {
val isSecureStorageAvailable: Boolean
get() = secretCodec.isAvailable.also { available ->
logDesktopTts("settings_secure_available codec=${secretCodec.name} available=$available")
}
fun load(): ReaderAiByokSettings {
logDesktopTts(
"settings_load_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " +
"exists=${settingsFile.exists()} secureStorage=${secretCodec.isAvailable}"
)
if (!settingsFile.exists()) {
logDesktopTts("settings_load_empty reason=file_missing")
return ReaderAiByokSettings()
}
val properties = Properties()
return runCatching {
settingsFile.inputStream().use(properties::load)
val legacyGeminiKey = properties.getProperty(LegacyGeminiKey, "")
val legacyGroqKey = properties.getProperty(LegacyGroqKey, "")
val loadedSettings = ReaderAiByokSettings(
geminiKey = loadSecret(properties, GeminiKey, legacyGeminiKey),
groqKey = loadSecret(properties, GroqKey, legacyGroqKey),
useOneModel = properties.getProperty("useOneModel", "true").toBooleanStrictOrNull() ?: true,
modelForAll = properties.getProperty("modelForAll", ""),
defineModel = properties.getProperty("defineModel", ""),
summarizeModel = properties.getProperty("summarizeModel", ""),
recapModel = properties.getProperty("recapModel", ""),
ttsModel = properties.getProperty("ttsModel", ""),
hideReaderAiFeatures = properties.getProperty("hideReaderAiFeatures", "false").toBooleanStrictOrNull() ?: false,
ttsSpeakerId = properties.getProperty("ttsSpeakerId", DEFAULT_CLOUD_TTS_SPEAKER_ID)
).sanitized()
val settings = if (loadedSettings.geminiKey.isNotBlank() && loadedSettings.ttsModel.isBlank()) {
loadedSettings.copy(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID)
} else {
loadedSettings
}
if (secretCodec.isAvailable &&
(legacyGeminiKey.isNotBlank() || legacyGroqKey.isNotBlank() || settings != loadedSettings)
) {
logDesktopTts(
"settings_load_migrate legacyGemini=${legacyGeminiKey.isNotBlank()} " +
"legacyGroq=${legacyGroqKey.isNotBlank()} autoTtsModel=${settings != loadedSettings}"
)
runCatching { save(settings) }
}
logDesktopTts(
"settings_load_complete geminiKey=${settings.geminiKey.isNotBlank()} groqKey=${settings.groqKey.isNotBlank()} " +
"ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" cloudAvailable=${settings.isCloudTtsAvailable}"
)
settings
}.getOrElse { error ->
logDesktopTts("settings_load_failed error=\"${error.desktopTtsSummary()}\"")
ReaderAiByokSettings()
}
}
fun save(settings: ReaderAiByokSettings) {
val sanitized = settings.sanitized()
logDesktopTts(
"settings_save_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " +
"secureStorage=${secretCodec.isAvailable} geminiKey=${sanitized.geminiKey.isNotBlank()} " +
"groqKey=${sanitized.groqKey.isNotBlank()} ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\""
)
val properties = Properties().apply {
setProtectedSecret(GeminiKey, sanitized.geminiKey)
setProtectedSecret(GroqKey, sanitized.groqKey)
setProperty("useOneModel", sanitized.useOneModel.toString())
setProperty("modelForAll", sanitized.modelForAll)
setProperty("defineModel", sanitized.defineModel)
setProperty("summarizeModel", sanitized.summarizeModel)
setProperty("recapModel", sanitized.recapModel)
setProperty("ttsModel", sanitized.ttsModel)
setProperty("hideReaderAiFeatures", sanitized.hideReaderAiFeatures.toString())
setProperty("ttsSpeakerId", sanitized.ttsSpeakerId)
}
settingsFile.parentFile?.mkdirs()
settingsFile.outputStream().use { output ->
properties.store(output, "Episteme desktop AI keys and models")
}
logDesktopTts(
"settings_save_complete geminiProtected=${properties.getProperty(GeminiKey, "").isNotBlank()} " +
"groqProtected=${properties.getProperty(GroqKey, "").isNotBlank()}"
)
}
private fun loadSecret(properties: Properties, key: String, legacyPlaintext: String): String {
val protectedValue = properties.getProperty(key, "")
val decrypted = protectedValue
.takeIf { it.isNotBlank() }
?.let {
runCatching { secretCodec.unprotect(key, it) }
.onFailure { error -> logDesktopTts("settings_secret_unprotect_failed key=$key error=\"${error.desktopTtsSummary()}\"") }
.getOrDefault("")
}
.orEmpty()
if (decrypted.isNotBlank()) return decrypted
return legacyPlaintext.takeIf { secretCodec.isAvailable }.orEmpty()
}
private fun Properties.setProtectedSecret(key: String, value: String) {
val trimmed = value.trim()
if (trimmed.isBlank()) {
secretCodec.delete(key)
return
}
runCatching { secretCodec.protect(key, trimmed) }
.onSuccess { protectedValue ->
if (protectedValue.isBlank()) {
logDesktopTts("settings_secret_protect_empty key=$key codec=${secretCodec.name}")
} else {
setProperty(key, protectedValue)
logDesktopTts("settings_secret_protect_success key=$key codec=${secretCodec.name} prefix=\"${protectedValue.substringBefore(':', protectedValue)}\"")
}
}
.onFailure { error ->
logDesktopTts("settings_secret_protect_failed key=$key codec=${secretCodec.name} error=\"${error.desktopTtsSummary()}\"")
}
}
companion object {
private const val GeminiKey = "geminiKeyProtected"
private const val GroqKey = "groqKeyProtected"
private const val LegacyGeminiKey = "geminiKey"
private const val LegacyGroqKey = "groqKey"
fun defaultSettingsFile(): File {
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
return File(baseDir, "Episteme/ai-byok.properties")
}
}
}
internal interface DesktopSecretCodec {
val name: String get() = this::class.java.simpleName.ifBlank { "DesktopSecretCodec" }
val isAvailable: Boolean
fun protect(value: String): String
fun unprotect(value: String): String
fun protect(keyName: String, value: String): String = protect(value)
fun unprotect(keyName: String, value: String): String = unprotect(value)
fun delete(keyName: String) = Unit
companion object {
fun platform(): DesktopSecretCodec {
val osName = System.getProperty("os.name").orEmpty()
val codec = if (osName.startsWith("Windows", ignoreCase = true)) {
WindowsSecretCodec
} else {
UnavailableDesktopSecretCodec
}
logDesktopTts("settings_platform os=\"${osName.desktopTtsPreview()}\" codec=${codec.name}")
return codec
}
}
}
private object UnavailableDesktopSecretCodec : DesktopSecretCodec {
override val name: String = "unavailable"
override val isAvailable: Boolean = false
override fun protect(value: String): String {
throw IllegalStateException("Secure key storage is unavailable on this operating system.")
}
override fun unprotect(value: String): String = ""
}
private object WindowsSecretCodec : DesktopSecretCodec {
override val name: String = "windows"
override val isAvailable: Boolean
get() {
val wincred = WindowsCredentialSecretCodec.isAvailable
val dpapi = WindowsDpapiSecretCodec.isAvailable
logDesktopTts("settings_windows_available wincred=$wincred dpapi=$dpapi")
return wincred || dpapi
}
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 {
val wincredFailure = runCatching { return WindowsCredentialSecretCodec.protect(keyName, value) }
.exceptionOrNull()
?.also { error -> logDesktopTts("settings_wincred_protect_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") }
val dpapiFailure = runCatching { return WindowsDpapiSecretCodec.protect(value) }
.exceptionOrNull()
?.also { error -> logDesktopTts("settings_dpapi_protect_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") }
throw IllegalStateException(
"No Windows secure key store write succeeded. " +
"Credential Manager: ${wincredFailure?.desktopTtsSummary() ?: "not attempted"}; " +
"DPAPI: ${dpapiFailure?.desktopTtsSummary() ?: "not attempted"}"
)
}
override fun unprotect(keyName: String, value: String): String {
return when {
value.startsWith(WindowsCredentialSecretCodec.Prefix) -> WindowsCredentialSecretCodec.unprotect(keyName, value)
value.startsWith(WindowsDpapiSecretCodec.Prefix) -> WindowsDpapiSecretCodec.unprotect(value)
else -> ""
}
}
override fun delete(keyName: String) {
if (WindowsCredentialSecretCodec.isAvailable) WindowsCredentialSecretCodec.delete(keyName)
}
}
private object WindowsCredentialSecretCodec : DesktopSecretCodec {
override val name: String = "wincred"
const val Prefix = "wincred:"
override val isAvailable: Boolean by lazy {
val probeKey = "probe"
val probe = "episteme-wincred-probe"
logDesktopTts("settings_wincred_probe_start")
runCatching {
val reference = protect(probeKey, probe)
val restored = unprotect(probeKey, reference)
val matches = restored == probe
logDesktopTts(
"settings_wincred_probe_result matches=$matches referencePrefix=\"${reference.substringBefore(':', reference)}\" " +
"restoredChars=${restored.length}"
)
matches
}.onFailure { error ->
logDesktopTts("settings_wincred_unavailable error=\"${error.desktopTtsSummary()}\"")
}.also {
runCatching { delete(probeKey) }
}.getOrDefault(false)
}
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 {
val target = credentialTarget(keyName)
logDesktopTts("settings_wincred_write_start key=$keyName target=\"$target\" valueChars=${value.length}")
val credential = WindowsCredential(target, value)
credential.write()
val ok = Advapi32.INSTANCE.CredWriteW(credential, 0)
val errorCode = Native.getLastError()
logDesktopTts("settings_wincred_write_result key=$keyName ok=$ok error=$errorCode")
if (!ok) throw IllegalStateException("Windows Credential Manager write failed: $errorCode")
return Prefix + target
}
override fun unprotect(keyName: String, value: String): String {
if (!value.startsWith(Prefix)) return ""
val target = value.removePrefix(Prefix).ifBlank { credentialTarget(keyName) }
logDesktopTts("settings_wincred_read_start key=$keyName target=\"$target\"")
val credentialPointer = PointerByReference()
val ok = Advapi32.INSTANCE.CredReadW(WString(target), WINDOWS_CRED_TYPE_GENERIC, 0, credentialPointer)
val errorCode = Native.getLastError()
logDesktopTts("settings_wincred_read_result key=$keyName ok=$ok error=$errorCode hasPointer=${credentialPointer.value != null}")
if (!ok) throw IllegalStateException("Windows Credential Manager read failed: $errorCode")
val pointer = credentialPointer.value ?: return ""
return try {
val credential = WindowsCredential(pointer)
val blobPointer = credential.CredentialBlob ?: return ""
logDesktopTts("settings_wincred_read_blob key=$keyName bytes=${credential.CredentialBlobSize}")
String(blobPointer.getByteArray(0, credential.CredentialBlobSize), Charsets.UTF_8)
} finally {
Advapi32.INSTANCE.CredFree(pointer)
}
}
override fun delete(keyName: String) {
val ok = Advapi32.INSTANCE.CredDeleteW(WString(credentialTarget(keyName)), WINDOWS_CRED_TYPE_GENERIC, 0)
val errorCode = Native.getLastError()
logDesktopTts("settings_wincred_delete_result key=$keyName ok=$ok error=$errorCode")
if (!ok && errorCode != WINDOWS_ERROR_NOT_FOUND) {
logDesktopTts("settings_wincred_delete_failed key=$keyName error=$errorCode")
}
}
private fun credentialTarget(keyName: String): String {
return "Episteme.Reader.AI.$keyName"
}
private interface Advapi32 : StdCallLibrary {
fun CredWriteW(credential: WindowsCredential, flags: Int): Boolean
fun CredReadW(targetName: WString, type: Int, flags: Int, credential: PointerByReference): Boolean
fun CredDeleteW(targetName: WString, type: Int, flags: Int): Boolean
fun CredFree(buffer: Pointer?)
companion object {
val INSTANCE: Advapi32 by lazy {
Native.load("Advapi32", Advapi32::class.java) as Advapi32
}
}
}
}
private object WindowsDpapiSecretCodec : DesktopSecretCodec {
override val name: String = "dpapi"
const val Prefix = "dpapi:"
override val isAvailable: Boolean by lazy {
logDesktopTts("settings_dpapi_probe_start")
runCatching {
Crypt32.INSTANCE
Kernel32.INSTANCE
val probe = "episteme-dpapi-probe"
val encrypted = protect(probe)
val restored = unprotect(encrypted)
val matches = restored == probe
logDesktopTts("settings_dpapi_probe_result matches=$matches encryptedChars=${encrypted.length} restoredChars=${restored.length}")
matches
}.onFailure { error ->
logDesktopTts("settings_dpapi_unavailable error=\"${error.desktopTtsSummary()}\"")
}.getOrDefault(false)
}
override fun protect(value: String): String {
val input = DataBlob(value.toByteArray(Charsets.UTF_8))
val output = DataBlob()
logDesktopTts("settings_dpapi_protect_start bytes=${value.toByteArray(Charsets.UTF_8).size}")
val ok = Crypt32.INSTANCE.CryptProtectData(input, null, null, null, null, 0, output)
val errorCode = Native.getLastError()
logDesktopTts("settings_dpapi_protect_result ok=$ok error=$errorCode")
if (!ok) throw IllegalStateException("Windows DPAPI protect failed: $errorCode")
return try {
Prefix + Base64.getEncoder().encodeToString(output.toByteArray())
} finally {
output.free()
}
}
override fun unprotect(value: String): String {
if (!value.startsWith(Prefix)) return ""
val encrypted = Base64.getDecoder().decode(value.removePrefix(Prefix))
val input = DataBlob(encrypted)
val output = DataBlob()
logDesktopTts("settings_dpapi_unprotect_start bytes=${encrypted.size}")
val ok = Crypt32.INSTANCE.CryptUnprotectData(input, null, null, null, null, 0, output)
val errorCode = Native.getLastError()
logDesktopTts("settings_dpapi_unprotect_result ok=$ok error=$errorCode")
if (!ok) throw IllegalStateException("Windows DPAPI unprotect failed: $errorCode")
return try {
String(output.toByteArray(), Charsets.UTF_8)
} finally {
output.free()
}
}
}
@Structure.FieldOrder("dwLowDateTime", "dwHighDateTime")
internal class WindowsFileTime : Structure() {
@JvmField
var dwLowDateTime: Int = 0
@JvmField
var dwHighDateTime: Int = 0
}
@Structure.FieldOrder(
"Flags",
"Type",
"TargetName",
"Comment",
"LastWritten",
"CredentialBlobSize",
"CredentialBlob",
"Persist",
"AttributeCount",
"Attributes",
"TargetAlias",
"UserName"
)
internal open class WindowsCredential : Structure {
@JvmField
var Flags: Int = 0
@JvmField
var Type: Int = WINDOWS_CRED_TYPE_GENERIC
@JvmField
var TargetName: WString? = null
@JvmField
var Comment: WString? = null
@JvmField
var LastWritten: WindowsFileTime = WindowsFileTime()
@JvmField
var CredentialBlobSize: Int = 0
@JvmField
var CredentialBlob: Pointer? = null
@JvmField
var Persist: Int = WINDOWS_CRED_PERSIST_LOCAL_MACHINE
@JvmField
var AttributeCount: Int = 0
@JvmField
var Attributes: Pointer? = null
@JvmField
var TargetAlias: WString? = null
@JvmField
var UserName: WString? = WString("Episteme")
private var blobMemory: Memory? = null
constructor() : super()
constructor(pointer: Pointer) : super(pointer) {
read()
}
constructor(target: String, secret: String) : super() {
val bytes = secret.toByteArray(Charsets.UTF_8)
TargetName = WString(target)
CredentialBlobSize = bytes.size
blobMemory = Memory(bytes.size.toLong()).also { memory ->
memory.write(0, bytes, 0, bytes.size)
CredentialBlob = memory
}
}
}
@Structure.FieldOrder("cbData", "pbData")
internal open class DataBlob() : Structure() {
@JvmField
var cbData: Int = 0
@JvmField
var pbData: Pointer? = null
private var memory: Memory? = null
constructor(bytes: ByteArray) : this() {
cbData = bytes.size
memory = Memory(bytes.size.toLong()).also { allocated ->
allocated.write(0, bytes, 0, bytes.size)
pbData = allocated
}
}
fun toByteArray(): ByteArray {
read()
return pbData?.getByteArray(0, cbData) ?: ByteArray(0)
}
fun free() {
pbData?.let { Kernel32.INSTANCE.LocalFree(it) }
pbData = null
cbData = 0
}
}
private interface Crypt32 : StdCallLibrary {
fun CryptProtectData(
pDataIn: DataBlob,
szDataDescr: String?,
pOptionalEntropy: DataBlob?,
pvReserved: Pointer?,
pPromptStruct: Pointer?,
dwFlags: Int,
pDataOut: DataBlob
): Boolean
fun CryptUnprotectData(
pDataIn: DataBlob,
ppszDataDescr: Pointer?,
pOptionalEntropy: DataBlob?,
pvReserved: Pointer?,
pPromptStruct: Pointer?,
dwFlags: Int,
pDataOut: DataBlob
): Boolean
companion object {
val INSTANCE: Crypt32 by lazy {
Native.load("Crypt32", Crypt32::class.java) as Crypt32
}
}
}
private interface Kernel32 : StdCallLibrary {
fun LocalFree(hMem: Pointer?): Pointer?
companion object {
val INSTANCE: Kernel32 by lazy {
Native.load("Kernel32", Kernel32::class.java) as Kernel32
}
}
}

View file

@ -0,0 +1,310 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.AiAdapter
import com.aryan.reader.shared.AiDefinitionResult
import com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.ReaderAiFeature
import com.aryan.reader.shared.ReaderByokTextRequest
import com.aryan.reader.shared.ReaderByokTextRequestResult
import com.aryan.reader.shared.ReaderByokTextRequests
import com.aryan.reader.shared.RecapResult
import com.aryan.reader.shared.SummarizationResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.net.HttpURLConnection
import java.net.URL
class DesktopByokAiAdapter(
private val settingsProvider: () -> ReaderAiByokSettings
) : AiAdapter {
override val isAvailable: Boolean
get() = settingsProvider().sanitized().areReaderAiFeaturesAvailable
override suspend fun define(text: String, context: String?): AiDefinitionResult {
val result = callTextAi(ReaderAiFeature.DEFINE, text, context)
return AiDefinitionResult(definition = result.getOrNull(), error = result.exceptionOrNull()?.message)
}
override suspend fun summarize(text: String): SummarizationResult {
val result = callTextAi(ReaderAiFeature.SUMMARIZE, text)
return SummarizationResult(summary = result.getOrNull(), error = result.exceptionOrNull()?.message)
}
override suspend fun recap(textBeforeCurrentLocation: String): RecapResult {
val result = callTextAi(ReaderAiFeature.RECAP, textBeforeCurrentLocation)
return RecapResult(recap = result.getOrNull(), error = result.exceptionOrNull()?.message)
}
suspend fun callTextAi(
feature: ReaderAiFeature,
text: String,
context: String? = null
): Result<String> = withContext(Dispatchers.IO) {
if (text.isBlank()) return@withContext Result.failure(IllegalArgumentException("There is no text to send."))
when (val requestResult = ReaderByokTextRequests.build(settingsProvider(), feature, text, context)) {
ReaderByokTextRequestResult.Hidden -> Result.failure(IllegalStateException("Reader AI features are hidden."))
is ReaderByokTextRequestResult.MissingKey -> {
Result.failure(IllegalStateException("Add a ${requestResult.provider.replaceFirstChar { it.uppercaseChar() }} API key in AI keys and models."))
}
is ReaderByokTextRequestResult.MissingModel -> {
Result.failure(IllegalStateException("Choose a model for ${requestResult.featureName} in AI keys and models."))
}
is ReaderByokTextRequestResult.Ready -> runCatching {
requestResult.request.execute()
}
}
}
private fun ReaderByokTextRequest.execute(): String {
var connection: HttpURLConnection? = null
try {
val url = if (model.provider == "groq") {
URL("https://api.groq.com/openai/v1/chat/completions")
} else {
URL("https://generativelanguage.googleapis.com/v1beta/models/${model.name}:streamGenerateContent?key=$apiKey")
}
connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json; charset=UTF-8")
setRequestProperty("Accept", "application/json")
if (model.provider == "groq") {
setRequestProperty("Authorization", "Bearer $apiKey")
}
connectTimeout = 15_000
readTimeout = 120_000
doOutput = true
doInput = true
}
val payload = if (model.provider == "groq") buildGroqPayload(this) else buildGeminiPayload(this)
connection.outputStream.use { output ->
output.write(payload.toByteArray(Charsets.UTF_8))
}
val responseCode = connection.responseCode
if (responseCode != HttpURLConnection.HTTP_OK) {
val errorBody = runCatching {
connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
}.getOrNull()
throw IllegalStateException("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}")
}
val text = if (model.provider == "groq") {
streamGroqResponse(connection)
} else {
streamGeminiResponse(connection)
}.trim()
if (text.isBlank()) throw IllegalStateException("The AI provider returned an empty response.")
return text
} finally {
connection?.disconnect()
}
}
private fun buildGroqPayload(request: ReaderByokTextRequest): String {
return buildJsonObject {
put("model", JsonPrimitive(request.model.name))
put(
"messages",
buildJsonArray {
add(buildJsonObject {
put("role", JsonPrimitive("system"))
put("content", JsonPrimitive(request.systemInstruction))
})
add(buildJsonObject {
put("role", JsonPrimitive("user"))
put("content", JsonPrimitive(request.userPrompt))
})
}
)
put("temperature", JsonPrimitive(request.temperature))
put("top_p", JsonPrimitive(0.95))
put("max_tokens", JsonPrimitive(request.maxTokens))
put("stream", JsonPrimitive(true))
if (request.model.name.contains("qwen")) put("reasoning_effort", JsonPrimitive("none"))
}.toString()
}
private fun buildGeminiPayload(request: ReaderByokTextRequest): String {
return buildJsonObject {
put(
"contents",
buildJsonArray {
add(buildJsonObject {
put("parts", buildJsonArray {
add(buildJsonObject { put("text", JsonPrimitive(request.userPrompt)) })
})
})
}
)
put(
"systemInstruction",
buildJsonObject {
put("parts", buildJsonArray {
add(buildJsonObject { put("text", JsonPrimitive(request.systemInstruction)) })
})
}
)
put(
"generationConfig",
buildJsonObject {
put("temperature", JsonPrimitive(request.temperature))
put("topP", JsonPrimitive(0.95))
put("topK", JsonPrimitive(40))
put("maxOutputTokens", JsonPrimitive(request.maxTokens))
put("response_mime_type", JsonPrimitive("text/plain"))
if (request.model.name.startsWith("gemini")) {
put(
"thinkingConfig",
buildJsonObject { put("thinkingBudget", JsonPrimitive(0)) }
)
}
}
)
}.toString()
}
private fun streamGeminiResponse(connection: HttpURLConnection): String {
val output = StringBuilder()
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
var buffer = ""
var line: String?
while (reader.readLine().also { line = it } != null) {
buffer += line
while (true) {
val start = buffer.indexOf('{')
if (start == -1) {
buffer = ""
break
}
var depth = 0
var end = -1
scan@ for (index in start until buffer.length) {
when (buffer[index]) {
'{' -> depth++
'}' -> {
depth--
if (depth == 0) {
end = index
break@scan
}
}
}
}
if (end == -1) break
val jsonObject = buffer.substring(start, end + 1)
buffer = buffer.substring(end + 1)
val parsed = runCatching { DesktopAiJson.parseToJsonElement(jsonObject).jsonObject }.getOrNull()
output.append(parsed.geminiTextChunk())
if (parsed.geminiFinishReason() == "SAFETY") {
throw IllegalStateException("Blocked for safety reasons.")
}
}
}
}
return output.toString()
}
private fun streamGroqResponse(connection: HttpURLConnection): String {
val output = StringBuilder()
var inThink = false
var thinkBuffer = ""
fun cleanChunk(text: String): String {
thinkBuffer += text
val cleaned = StringBuilder()
while (true) {
if (inThink) {
val end = thinkBuffer.indexOf("</think>")
if (end == -1) {
if (thinkBuffer.length > 7) thinkBuffer = thinkBuffer.takeLast(7)
break
}
inThink = false
thinkBuffer = thinkBuffer.substring(end + 8)
} else {
val start = thinkBuffer.indexOf("<think>")
if (start == -1) {
if (thinkBuffer.length > 6) {
cleaned.append(thinkBuffer.dropLast(6))
thinkBuffer = thinkBuffer.takeLast(6)
}
break
}
cleaned.append(thinkBuffer.substring(0, start))
inThink = true
thinkBuffer = thinkBuffer.substring(start + 7)
}
}
return cleaned.toString()
}
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
val trimmed = line!!.trim()
if (!trimmed.startsWith("data: ")) continue
val data = trimmed.removePrefix("data: ").trim()
if (data == "[DONE]") continue
val chunk = runCatching {
DesktopAiJson.parseToJsonElement(data).jsonObject["choices"]
?.jsonArray
?.firstOrNull()
?.jsonObject
?.get("delta")
?.jsonObject
?.get("content")
?.jsonPrimitive
?.contentOrNull
}.getOrNull().orEmpty()
output.append(cleanChunk(chunk))
}
}
if (!inThink && thinkBuffer.isNotBlank()) output.append(thinkBuffer)
return output.toString()
}
}
private val DesktopAiJson = Json { ignoreUnknownKeys = true }
private fun JsonObject?.geminiTextChunk(): String {
if (this == null) return ""
return this["candidates"]
?.jsonArrayOrNull()
?.firstOrNull()
?.jsonObjectOrNull()
?.get("content")
?.jsonObjectOrNull()
?.get("parts")
?.jsonArrayOrNull()
?.firstOrNull()
?.jsonObjectOrNull()
?.get("text")
?.jsonPrimitiveOrNull()
?.contentOrNull
.orEmpty()
}
private fun JsonObject?.geminiFinishReason(): String? {
if (this == null) return null
return this["candidates"]
?.jsonArrayOrNull()
?.firstOrNull()
?.jsonObjectOrNull()
?.get("finishReason")
?.jsonPrimitiveOrNull()
?.contentOrNull
}
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray
private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? = this as? JsonPrimitive

View file

@ -0,0 +1,586 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.opds.OpdsCatalog
import com.aryan.reader.shared.opds.OpdsStreamReference
import com.sun.jna.Library
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 java.awt.Color
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.OutputStream
import java.net.URL
import java.nio.file.Files
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
import javax.imageio.ImageIO
import kotlin.math.roundToInt
internal object DesktopComicArchive {
private val comicTypes = setOf(FileType.CBZ, FileType.CBR, FileType.CB7)
private val imageExtensions = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
fun canLoad(type: FileType): Boolean = type in comicTypes
fun load(file: File, type: FileType): DesktopComicDocument {
require(file.isFile) { "Missing comic archive: ${file.absolutePath}" }
require(canLoad(type)) { "${type.name} is not a comic archive type." }
return when (type) {
FileType.CBZ -> loadZip(file)
FileType.CBR -> loadRar(file)
FileType.CB7 -> loadSevenZ(file)
else -> error("${type.name} is not a comic archive type.")
}
}
fun loadOpdsStream(
path: String,
title: String,
reference: OpdsStreamReference,
catalog: OpdsCatalog?
): DesktopComicDocument {
val cacheDir = File(
DesktopLibraryDatabase.defaultDatabaseFile().parentFile,
"opds_stream_cache/${reference.id.hashCode()}"
).apply { mkdirs() }
val pages = (0 until reference.count).map { pageIndex ->
DesktopComicPage(
name = "opds_${pageIndex + 1}.jpg",
width = 800,
height = 1200,
source = OpdsStreamComicPageSource(
pageIndex = pageIndex,
urlTemplate = reference.urlTemplate,
catalog = catalog,
cacheDir = cacheDir
)
)
}
return DesktopComicDocument(
path = path,
title = title,
pages = pages,
closeAction = {}
)
}
private fun loadZip(file: File): DesktopComicDocument {
val zip = ZipFile(file)
return try {
val pages = zip.entries()
.asSequence()
.filter { entry -> !entry.isDirectory && entry.name.isComicImageName() }
.sortedBy { entry -> entry.name.comicSortKey() }
.mapNotNull { entry ->
val bytes = zip.getInputStream(entry).use { it.readBytes() }
val size = decodeImageSize(bytes) ?: return@mapNotNull null
DesktopComicPage(
name = entry.name,
width = size.first,
height = size.second,
source = ZipComicPageSource(zip, entry.name)
)
}
.toList()
require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
DesktopComicDocument(
path = file.absolutePath,
title = file.nameWithoutExtension,
pages = pages,
closeAction = { zip.close() }
)
} catch (throwable: Throwable) {
runCatching { zip.close() }
throw throwable
}
}
private fun loadRar(file: File): DesktopComicDocument {
val nativeResult = runCatching { loadRarWithNativeLibarchive(file) }
if (nativeResult.isSuccess) return nativeResult.getOrThrow()
return runCatching { loadWithArchiveCommand(file) }
.getOrElse { commandError ->
error(
"Could not open CBR with libarchive. " +
"Bundle archive.dll/libarchive for desktop, or keep Windows tar/bsdtar available. " +
"Native: ${nativeResult.exceptionOrNull()?.shortMessage().orEmpty()} " +
"Command: ${commandError.shortMessage()}"
)
}
}
private fun loadRarWithNativeLibarchive(file: File): DesktopComicDocument {
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
return try {
val extracted = DesktopLibarchive.extractImagePages(file, tempDir, imageExtensions)
documentFromExtracted(file, extracted, tempDir)
} catch (throwable: Throwable) {
runCatching { tempDir.deleteRecursively() }
throw throwable
}
}
private fun loadSevenZ(file: File): DesktopComicDocument {
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
val commonsResult = runCatching {
val extracted = mutableListOf<ExtractedComicPage>()
@Suppress("DEPRECATION")
SevenZFile(file).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.copyCurrentEntryTo(output)
}
extracted += ExtractedComicPage(name = name, file = target)
}
entry = archive.nextEntry
}
}
documentFromExtracted(file, extracted, tempDir)
}
if (commonsResult.isSuccess) return commonsResult.getOrThrow()
runCatching { tempDir.deleteRecursively() }
return runCatching { loadWithArchiveCommand(file) }
.getOrElse { commandError ->
error(
"Could not open CB7 with Commons Compress or system tar/bsdtar. " +
"Commons: ${commonsResult.exceptionOrNull()?.shortMessage().orEmpty()} " +
"Command: ${commandError.shortMessage()}"
)
}
}
private fun loadWithArchiveCommand(file: File): DesktopComicDocument {
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
return try {
val extracted = DesktopArchiveCommand.extractImagePages(file, tempDir, imageExtensions)
documentFromExtracted(file, extracted, tempDir)
} catch (throwable: Throwable) {
runCatching { tempDir.deleteRecursively() }
throw throwable
}
}
private fun documentFromExtracted(
file: File,
extracted: List<ExtractedComicPage>,
tempDir: File
): DesktopComicDocument {
val pages = extracted
.sortedBy { page -> page.name.comicSortKey() }
.mapNotNull { page ->
val bytes = page.file.readBytes()
val size = decodeImageSize(bytes) ?: return@mapNotNull null
DesktopComicPage(
name = page.name,
width = size.first,
height = size.second,
source = FileComicPageSource(page.file)
)
}
require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
return DesktopComicDocument(
path = file.absolutePath,
title = file.nameWithoutExtension,
pages = pages,
closeAction = { tempDir.deleteRecursively() }
)
}
private fun SevenZFile.copyCurrentEntryTo(output: OutputStream) {
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = read(buffer, 0, buffer.size)
if (read < 0) break
if (read > 0) output.write(buffer, 0, read)
}
}
private fun decodeImageSize(bytes: ByteArray): Pair<Int, Int>? {
val image = ByteArrayInputStream(bytes).use { input ->
ImageIO.read(input)
} ?: return null
return image.width.coerceAtLeast(1) to image.height.coerceAtLeast(1)
}
internal fun String.isComicImageName(): Boolean {
return imageExtension() in imageExtensions
}
internal fun String.imageExtension(): String {
return substringBefore('?')
.substringBefore('#')
.substringAfterLast('.', missingDelimiterValue = "img")
.lowercase()
.takeIf { it in imageExtensions }
?: "img"
}
internal fun String.comicSortKey(): String {
return replace('\\', '/').lowercase()
}
internal fun Throwable.shortMessage(): String {
return message
?.replace(Regex("\\s+"), " ")
?.take(240)
?.ifBlank { null }
?: javaClass.simpleName
}
internal data class ExtractedComicPage(
val name: String,
val file: File
)
}
internal class DesktopComicDocument(
val path: String,
val title: String,
pages: List<DesktopComicPage>,
private val closeAction: () -> Unit
) {
private val pages = pages.toList()
val pageCount: Int = pages.size
val pageSizes: List<DesktopPdfPageSize> = pages.map { page ->
DesktopPdfPageSize(page.width.toFloat(), page.height.toFloat())
}
fun renderPageBufferedImage(pageIndex: Int, scale: Float): BufferedImage {
val page = pages.getOrNull(pageIndex) ?: error("Invalid comic page index $pageIndex.")
val sourceImage = ByteArrayInputStream(page.source.readBytes()).use { input ->
ImageIO.read(input)
} ?: error("Could not decode comic page ${pageIndex + 1}.")
val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f
val sourceWidth = sourceImage.width.coerceAtLeast(1)
val sourceHeight = sourceImage.height.coerceAtLeast(1)
val width = (sourceWidth * safeScale).roundToInt().coerceAtLeast(1)
val height = (sourceHeight * safeScale).roundToInt().coerceAtLeast(1)
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
val graphics = image.createGraphics()
try {
graphics.color = Color.WHITE
graphics.fillRect(0, 0, width, height)
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR)
graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
graphics.drawImage(sourceImage, 0, 0, width, height, null)
} finally {
graphics.dispose()
sourceImage.flush()
}
return image
}
fun close() {
closeAction()
}
}
internal data class DesktopComicPage(
val name: String,
val width: Int,
val height: Int,
val source: ComicPageSource
)
internal interface ComicPageSource {
fun readBytes(): ByteArray
}
private class ZipComicPageSource(
private val zip: ZipFile,
private val entryName: String
) : ComicPageSource {
override fun readBytes(): ByteArray {
val entry = zip.getEntry(entryName) ?: error("Missing comic page entry: $entryName")
return zip.getInputStream(entry).use { it.readBytes() }
}
}
private class FileComicPageSource(
private val file: File
) : ComicPageSource {
override fun readBytes(): ByteArray = file.readBytes()
}
private class OpdsStreamComicPageSource(
private val pageIndex: Int,
private val urlTemplate: String,
private val catalog: OpdsCatalog?,
private val cacheDir: File
) : ComicPageSource {
override fun readBytes(): ByteArray {
val cachedFile = File(cacheDir, "page_$pageIndex.jpg")
if (cachedFile.isFile && cachedFile.length() > 0L) {
return cachedFile.readBytes()
}
return runCatching {
val bytes = DesktopOpdsHttp.fetchBytes(streamPageUrl(), catalog)
if (bytes.isNotEmpty()) {
cachedFile.writeBytes(bytes)
bytes
} else {
error("Empty OPDS stream page response.")
}
}.getOrElse {
errorPageBytes()
}
}
private fun streamPageUrl(): String {
return effectiveTemplate()
.replace("{pageNumber}", pageIndex.toString())
.replace("{page}", pageIndex.toString())
.replace("{maxWidth}", "1600")
.replace("{maxHeight}", "2400")
}
private fun effectiveTemplate(): String {
val catalogUrl = catalog?.url ?: return urlTemplate
if (!urlTemplate.startsWith("http", ignoreCase = true)) return urlTemplate
return runCatching {
val oldUrl = URL(urlTemplate)
val newUrl = URL(catalogUrl)
val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
val newBase = "${newUrl.protocol}://${newUrl.authority}"
urlTemplate.replace(oldBase, newBase)
}.getOrDefault(urlTemplate)
}
private fun errorPageBytes(): ByteArray {
val image = BufferedImage(800, 1200, BufferedImage.TYPE_INT_RGB)
val graphics = image.createGraphics()
try {
graphics.color = Color.DARK_GRAY
graphics.fillRect(0, 0, image.width, image.height)
graphics.color = Color.WHITE
graphics.font = Font(Font.SANS_SERIF, Font.BOLD, 36)
val text = "Page unavailable"
val metrics = graphics.fontMetrics
graphics.drawString(text, (image.width - metrics.stringWidth(text)) / 2, image.height / 2)
} finally {
graphics.dispose()
}
return ByteArrayOutputStream().use { output ->
ImageIO.write(image, "jpg", output)
output.toByteArray()
}
}
}
private object DesktopArchiveCommand {
private const val EXTRACT_TIMEOUT_SECONDS = 60L
fun extractImagePages(
file: File,
tempDir: File,
imageExtensions: Set<String>
): List<DesktopComicArchive.ExtractedComicPage> {
val command = resolveCommand()
?: error("No tar/bsdtar command was found on PATH.")
val names = listArchiveEntries(command, file)
.filter { name ->
val extension = name.substringBefore('?')
.substringBefore('#')
.substringAfterLast('.', missingDelimiterValue = "")
.lowercase()
extension in imageExtensions
}
.sortedBy { name -> with(DesktopComicArchive) { name.comicSortKey() } }
require(names.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
return names.mapIndexed { index, name ->
val extension = name.substringBefore('?')
.substringBefore('#')
.substringAfterLast('.', missingDelimiterValue = "img")
.lowercase()
.ifBlank { "img" }
val target = File(tempDir, "page_$index.$extension")
extractEntry(command, file, name, target)
DesktopComicArchive.ExtractedComicPage(name = name, file = target)
}
}
private fun resolveCommand(): String? {
val override = System.getProperty("reader.archive.command")
?: System.getenv("READER_ARCHIVE_COMMAND")
return listOfNotNull(override?.takeIf { it.isNotBlank() }, "bsdtar", "tar")
.firstOrNull(::isCommandAvailable)
}
private fun isCommandAvailable(command: String): Boolean {
return runCatching {
val process = ProcessBuilder(command, "--version")
.redirectErrorStream(true)
.start()
process.inputStream.use { it.readBytes() }
process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0
}.getOrDefault(false)
}
private fun listArchiveEntries(command: String, file: File): List<String> {
val process = ProcessBuilder(command, "-tf", file.absolutePath)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
error("$command timed out while listing ${file.name}.")
}
if (process.exitValue() != 0) {
error(output.ifBlank { "$command could not list ${file.name}." })
}
return output
.lineSequence()
.map { it.trim() }
.filter { it.isNotBlank() }
.toList()
}
private fun extractEntry(command: String, file: File, entryName: String, target: File) {
val process = ProcessBuilder(command, "-xOf", file.absolutePath, entryName)
.start()
val errorText = StringBuilder()
val errorThread = Thread {
process.errorStream.bufferedReader(Charsets.UTF_8).use { reader ->
errorText.append(reader.readText())
}
}.apply {
isDaemon = true
start()
}
process.inputStream.use { input ->
target.outputStream().use { output ->
input.copyTo(output)
}
}
val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
error("$command timed out while extracting $entryName.")
}
errorThread.join(1000)
if (process.exitValue() != 0) {
error(errorText.toString().ifBlank { "$command could not extract $entryName." })
}
}
}
private object DesktopLibarchive {
private const val ARCHIVE_OK = 0
private const val ARCHIVE_EOF = 1
private const val ARCHIVE_ENTRY_DIRECTORY = 0x4000
private const val BUFFER_SIZE = 128 * 1024
fun extractImagePages(
file: File,
tempDir: File,
imageExtensions: Set<String>
): List<DesktopComicArchive.ExtractedComicPage> {
val archive = api.archive_read_new()
?: error("libarchive could not allocate a reader.")
try {
checkArchive(api.archive_read_support_filter_all(archive), archive, "enable archive filters")
checkArchive(api.archive_read_support_format_all(archive), archive, "enable archive formats")
checkArchive(
api.archive_read_open_filename(archive, file.absolutePath, BUFFER_SIZE.toLong()),
archive,
"open ${file.name}"
)
val pages = mutableListOf<DesktopComicArchive.ExtractedComicPage>()
val entryRef = PointerByReference()
while (true) {
when (val status = api.archive_read_next_header(archive, entryRef)) {
ARCHIVE_OK -> Unit
ARCHIVE_EOF -> break
else -> checkArchive(status, archive, "read archive header")
}
val entry = entryRef.value ?: continue
val name = api.archive_entry_pathname_utf8(entry)
?: api.archive_entry_pathname(entry)
?: continue
val extension = name.substringBefore('?')
.substringBefore('#')
.substringAfterLast('.', missingDelimiterValue = "")
.lowercase()
if (api.archive_entry_filetype(entry) == ARCHIVE_ENTRY_DIRECTORY || extension !in imageExtensions) {
api.archive_read_data_skip(archive)
continue
}
val target = File(tempDir, "page_${pages.size}.${extension.ifBlank { "img" }}")
target.outputStream().use { output ->
val buffer = ByteArray(BUFFER_SIZE)
while (true) {
val read = api.archive_read_data(archive, buffer, buffer.size.toLong())
when {
read > 0 -> output.write(buffer, 0, read.toInt())
read == 0L -> break
else -> checkArchive(read.toInt(), archive, "extract $name")
}
}
}
pages += DesktopComicArchive.ExtractedComicPage(name = name, file = target)
}
return pages.sortedBy { page -> with(DesktopComicArchive) { page.name.comicSortKey() } }
} finally {
api.archive_read_free(archive)
}
}
private val api: LibarchiveLibrary by lazy {
val overridePath = System.getProperty("reader.libarchive.path")
?: System.getenv("READER_LIBARCHIVE_PATH")
val candidates = if (overridePath.isNullOrBlank()) {
listOf("archive", "libarchive", "libarchive-13", "libarchive-14")
} else {
listOf(File(overridePath).absolutePath, overridePath)
}
candidates.firstNotNullOfOrNull { candidate ->
runCatching { Native.load(candidate, LibarchiveLibrary::class.java) }
.getOrNull()
} ?: error(
"Native libarchive was not found. Set READER_LIBARCHIVE_PATH/reader.libarchive.path " +
"or bundle archive.dll/libarchive for this platform."
)
}
private fun checkArchive(status: Int, archive: Pointer, action: String) {
if (status >= ARCHIVE_OK) return
val message = api.archive_error_string(archive).orEmpty()
error("libarchive could not $action: ${message.ifBlank { "error code $status" }}")
}
@Suppress("FunctionName")
private interface LibarchiveLibrary : Library {
fun archive_read_new(): Pointer?
fun archive_read_support_filter_all(archive: Pointer): Int
fun archive_read_support_format_all(archive: Pointer): Int
fun archive_read_open_filename(archive: Pointer, fileName: String, blockSize: Long): Int
fun archive_read_next_header(archive: Pointer, entry: PointerByReference): Int
fun archive_read_data(archive: Pointer, buffer: ByteArray, size: Long): Long
fun archive_read_data_skip(archive: Pointer): Int
fun archive_read_free(archive: Pointer): Int
fun archive_error_string(archive: Pointer): String?
fun archive_entry_pathname_utf8(entry: Pointer): String?
fun archive_entry_pathname(entry: Pointer): String?
fun archive_entry_filetype(entry: Pointer): Int
}
}

View file

@ -0,0 +1,143 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.CustomFontItem
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
import java.net.HttpURLConnection
import java.net.URLEncoder
import java.net.URL
import java.util.UUID
class DesktopCustomFontStore(
private val fontsDir: File = defaultFontsDir()
) {
private var googleFontsCache: List<String>? = null
fun importFont(source: File, displayNameOverride: String? = null): Result<CustomFontItem> {
if (!source.isFile) {
return Result.failure(IllegalArgumentException("Choose a font file."))
}
val extension = source.extension.lowercase()
if (extension !in SupportedFontExtensions) {
return Result.failure(IllegalArgumentException("Unsupported font format. Use TTF, OTF, or WOFF2."))
}
return runCatching {
fontsDir.mkdirs()
val fontId = UUID.randomUUID().toString()
val fileName = "font_$fontId.$extension"
val destination = File(fontsDir, fileName)
source.inputStream().use { input ->
destination.outputStream().use { output -> input.copyTo(output) }
}
CustomFontItem(
id = fontId,
displayName = displayNameOverride?.takeIf { it.isNotBlank() }
?: source.nameWithoutExtension.ifBlank { "Imported font" },
fileName = fileName,
fileExtension = extension,
path = destination.absolutePath,
timestamp = System.currentTimeMillis()
)
}
}
fun deleteFont(font: CustomFontItem): Boolean {
val target = runCatching { File(font.path).canonicalFile }.getOrNull() ?: return false
val root = runCatching { fontsDir.canonicalFile }.getOrNull() ?: return false
val insideFontStore = generateSequence(target) { it.parentFile }.any { it == root }
if (!insideFontStore) return false
return !target.exists() || target.delete()
}
fun loadGoogleFontsList(): List<String> {
googleFontsCache?.let { return it }
val loaded = runCatching {
val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(GoogleFontsResource)
?: DesktopCustomFontStore::class.java.classLoader?.getResourceAsStream(GoogleFontsResource)
?: return@runCatching emptyList()
stream.bufferedReader(Charsets.UTF_8).use { reader ->
googleFontsFromJson(reader.readText())
}
}.getOrDefault(emptyList())
googleFontsCache = loaded
return loaded
}
fun downloadGoogleFont(fontName: String): Result<CustomFontItem> {
val normalizedFontName = fontName.trim()
if (normalizedFontName.isBlank()) {
return Result.failure(IllegalArgumentException("Choose a Google Font."))
}
return runCatching {
val encodedName = URLEncoder.encode(normalizedFontName, Charsets.UTF_8.name())
val cssConnection = URL("https://fonts.googleapis.com/css?family=$encodedName")
.openConnection() as HttpURLConnection
cssConnection.setRequestProperty("User-Agent", GoogleFontsSafariUserAgent)
cssConnection.connectTimeout = 15_000
cssConnection.readTimeout = 15_000
if (cssConnection.responseCode != HttpURLConnection.HTTP_OK) {
throw IllegalStateException("Font '$normalizedFontName' was not found on Google Fonts.")
}
val css = cssConnection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
val fontUrl = googleFontDownloadUrlFromCss(css)
?: throw IllegalStateException("Could not parse a download link for $normalizedFontName.")
val extension = googleFontFileExtension(fontUrl)
if (extension !in SupportedFontExtensions) {
throw IllegalStateException("Unsupported format ($extension) returned for $normalizedFontName.")
}
val tempFile = File.createTempFile("episteme_google_font_", ".$extension")
try {
val fontConnection = URL(fontUrl).openConnection() as HttpURLConnection
fontConnection.connectTimeout = 15_000
fontConnection.readTimeout = 30_000
fontConnection.inputStream.use { input ->
tempFile.outputStream().use { output -> input.copyTo(output) }
}
importFont(tempFile, displayNameOverride = normalizedFontName).getOrThrow()
} finally {
tempFile.delete()
}
}
}
companion object {
private val SupportedFontExtensions = setOf("ttf", "otf", "woff2")
private const val GoogleFontsResource = "google_fonts.json"
private const val GoogleFontsSafariUserAgent =
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1"
fun defaultFontsDir(): File {
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
return File(baseDir, "Episteme/custom_fonts")
}
}
}
internal fun googleFontDownloadUrlFromCss(css: String): String? {
return Regex("""url\((https://[^)]+)\)""")
.find(css)
?.groupValues
?.getOrNull(1)
}
internal fun googleFontFileExtension(fontUrl: String): String {
return fontUrl.substringBefore('?')
.substringAfterLast('.', "ttf")
.lowercase()
}
internal fun googleFontsFromJson(rawJson: String): List<String> {
return Json.parseToJsonElement(rawJson)
.jsonArray
.mapNotNull { element ->
runCatching { element.jsonPrimitive.content.trim().takeIf { it.isNotBlank() } }.getOrNull()
}
}

View file

@ -1,282 +1,12 @@
package com.aryan.reader.desktop
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import com.aryan.reader.paginatedreader.CssParser
import com.aryan.reader.paginatedreader.OptimizedCssRules
import com.aryan.reader.paginatedreader.UserAgentStylesheet
import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.reader.SharedEpubBook
import com.aryan.reader.shared.reader.SharedEpubChapter
import com.aryan.reader.shared.reader.SharedJvmBookLoader
import java.io.File
import java.util.Base64
import java.util.UUID
import java.util.zip.ZipFile
object DesktopEpubLoader {
fun load(file: File): SharedEpubBook {
ZipFile(file).use { zip ->
val container = zip.readText("META-INF/container.xml")
val opfPath = container
.substringAfter("full-path=\"", missingDelimiterValue = "")
.substringBefore("\"")
.ifBlank { error("EPUB container does not point to an OPF package.") }
val opf = zip.readText(opfPath)
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
.let { if (it.isBlank()) "" else "$it/" }
val title = opf.tagText("title").ifBlank { file.nameWithoutExtension }
val author = opf.tagText("creator").ifBlank { null }
val manifest = parseManifest(opf)
val cssByPath = loadCss(zip, manifest, basePath)
val cssRules = parseCssRules(cssByPath)
val spine = Regex("<itemref[^>]*idref=[\"']([^\"']+)[\"'][^>]*/?>")
.findAll(opf)
.mapNotNull { match -> manifest[match.groupValues[1]] }
.toList()
val chapterPaths = spine.ifEmpty {
manifest.values.filter { it.endsWith(".xhtml", ignoreCase = true) || it.endsWith(".html", ignoreCase = true) }
}
val chapters = chapterPaths.mapIndexedNotNull { index, href ->
val path = normalizeZipPath(basePath + href)
val html = zip.readTextOrNull(path) ?: return@mapIndexedNotNull null
val resourceReadyHtml = html.sanitizeReaderHtml().withEmbeddedResources(zip, path)
val text = htmlToText(html)
val semanticBlocks = runCatching {
htmlToSemanticBlocks(
html = resourceReadyHtml,
cssRules = cssRules,
textStyle = TextStyle(fontSize = 18.sp),
chapterAbsPath = path,
extractionBasePath = "",
density = Density(1f),
fontFamilyMap = emptyMap(),
constraints = Constraints(maxWidth = 980, maxHeight = 720)
)
}.getOrElse { emptyList() }
if (text.isBlank()) {
null
} else {
SharedEpubChapter(
id = "chapter_$index",
title = html.tagText("h1")
.ifBlank { html.tagText("h2") }
.ifBlank { html.tagText("title") }
.ifBlank { "Chapter ${index + 1}" },
plainText = text,
semanticBlocks = semanticBlocks,
htmlContent = resourceReadyHtml.extractBodyOrSelf(),
baseHref = path.substringBeforeLast('/', missingDelimiterValue = "")
)
}
}
return SharedEpubBook(
id = file.absolutePath,
fileName = file.name,
title = title,
author = author,
css = cssByPath,
chapters = chapters.ifEmpty {
listOf(
SharedEpubChapter(
id = UUID.randomUUID().toString(),
title = title,
plainText = "This EPUB opened, but no readable spine text was found by the lightweight desktop loader."
)
)
}
)
}
}
private fun parseManifest(opf: String): Map<String, String> {
return Regex("<item\\s+[^>]*>").findAll(opf).mapNotNull { match ->
val item = match.value
val id = item.attr("id")
val href = item.attr("href")
if (id.isBlank() || href.isBlank()) null else id to href
}.toMap()
}
private fun loadCss(zip: ZipFile, manifest: Map<String, String>, basePath: String): Map<String, String> {
return manifest.values
.filter { it.endsWith(".css", ignoreCase = true) }
.mapNotNull { href ->
val path = normalizeZipPath(basePath + href)
val css = zip.readTextOrNull(path)?.withEmbeddedCssResources(zip, path).orEmpty()
if (css.isBlank()) null else path to css
}
.toMap()
}
private fun parseCssRules(cssByPath: Map<String, String>): OptimizedCssRules {
val constraints = Constraints(maxWidth = 980, maxHeight = 720)
val baseRules = CssParser.parse(
cssContent = UserAgentStylesheet.default,
cssPath = null,
baseFontSizeSp = 18f,
density = 1f,
constraints = constraints,
isDarkTheme = false
).rules
return cssByPath.entries
.fold(baseRules) { rules, (path, css) ->
if (css.isBlank()) {
rules
} else {
rules.merge(
CssParser.parse(
cssContent = css,
cssPath = path,
baseFontSizeSp = 18f,
density = 1f,
constraints = constraints,
isDarkTheme = false
).rules
)
}
}
}
private fun ZipFile.readText(path: String): String {
val entry = getEntry(path) ?: error("Missing EPUB entry: $path")
return getInputStream(entry).bufferedReader().use { it.readText() }
}
private fun ZipFile.readTextOrNull(path: String): String? {
val entry = getEntry(path) ?: return null
return getInputStream(entry).bufferedReader().use { it.readText() }
}
private fun String.attr(name: String): String {
return Regex("""\b$name=["']([^"']+)["']""").find(this)?.groupValues?.get(1).orEmpty()
}
private fun String.tagText(tag: String): String {
return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>", RegexOption.IGNORE_CASE)
.find(this)
?.groupValues
?.get(1)
?.let(::htmlToText)
.orEmpty()
}
private fun normalizeZipPath(path: String): String {
val parts = ArrayDeque<String>()
path.split('/').forEach { part ->
when (part) {
"", "." -> Unit
".." -> if (parts.isNotEmpty()) parts.removeLast()
else -> parts.addLast(part)
}
}
return parts.joinToString("/")
}
private fun String.withEmbeddedResources(zip: ZipFile, chapterPath: String): String {
return replace(Regex("""(?i)\b(src|href)=["']([^"']+)["']""")) { match ->
val attr = match.groupValues[1]
val raw = match.groupValues[2]
if (attr.equals("href", ignoreCase = true) && !raw.looksLikeEmbeddableResource()) {
return@replace match.value
}
val dataUri = zip.toDataUri(raw, chapterPath)
if (dataUri != null) "$attr=\"$dataUri\"" else match.value
}
}
private fun String.looksLikeEmbeddableResource(): Boolean {
return substringBefore('#')
.substringBefore('?')
.substringAfterLast('.', "")
.lowercase() in setOf("css", "jpg", "jpeg", "png", "gif", "svg", "webp", "ttf", "otf", "woff", "woff2")
}
private fun String.sanitizeReaderHtml(): String {
return replace(Regex("(?is)<script\\b.*?</script>"), "")
.replace(Regex("(?is)<object\\b.*?</object>"), "")
.replace(Regex("(?is)<embed\\b[^>]*>"), "")
.replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "")
}
private fun String.withEmbeddedCssResources(zip: ZipFile, cssPath: String): String {
return replace(Regex("""url\((['"]?)([^)'"]+)\1\)""", RegexOption.IGNORE_CASE)) { match ->
val raw = match.groupValues[2].trim()
val dataUri = zip.toDataUri(raw, cssPath)
if (dataUri != null) "url('$dataUri')" else match.value
}
}
private fun ZipFile.toDataUri(rawRef: String, ownerPath: String): String? {
val ref = rawRef.substringBefore('#').trim()
if (ref.isBlank() || ref.startsWith("data:", ignoreCase = true)) return null
if (ref.startsWith("http://", ignoreCase = true) || ref.startsWith("https://", ignoreCase = true)) return null
val base = ownerPath.substringBeforeLast('/', missingDelimiterValue = "")
val path = normalizeZipPath(if (base.isBlank()) ref else "$base/$ref")
val entry = getEntry(path) ?: return null
val bytes = getInputStream(entry).use { it.readBytes() }
return "data:${mimeType(path)};base64,${Base64.getEncoder().encodeToString(bytes)}"
}
private fun mimeType(path: String): String {
return when (path.substringAfterLast('.', "").lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"svg" -> "image/svg+xml"
"webp" -> "image/webp"
"ttf" -> "font/ttf"
"otf" -> "font/otf"
"woff" -> "font/woff"
"woff2" -> "font/woff2"
"css" -> "text/css"
"js" -> "text/javascript"
else -> "application/octet-stream"
}
}
private fun String.extractBodyOrSelf(): String {
return Regex("(?is)<body\\b[^>]*>(.*?)</body>")
.find(this)
?.groupValues
?.get(1)
?.trim()
?: this
}
private fun htmlToText(html: String): String {
return html
.replace(Regex("(?is)<script.*?</script>"), "")
.replace(Regex("(?is)<style.*?</style>"), "")
.replace(Regex("(?i)<br\\s*/?>"), "\n")
.replace(Regex("(?i)</p\\s*>"), "\n\n")
.replace(Regex("(?i)</h[1-6]\\s*>"), "\n\n")
.replace(Regex("<[^>]+>"), " ")
.decodeEntities()
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
.replace(Regex(" *\\n *"), "\n")
.replace(Regex("\\n{3,}"), "\n\n")
.trim()
}
private fun String.decodeEntities(): String {
return replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
}
.replace(Regex("&#(\\d+);")) { match ->
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
}
return SharedJvmBookLoader.load(file, FileType.EPUB)
}
}

View file

@ -0,0 +1,530 @@
package com.aryan.reader.desktop
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.reader.SharedJvmBookLoader
import java.awt.Color
import java.awt.Font
import java.awt.GradientPaint
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.zip.ZipFile
import javax.imageio.ImageIO
import kotlin.math.max
data class DesktopFolderMetadataExtractionResult(
val books: List<BookItem>,
val stats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats()
)
data class DesktopFolderMetadataExtractionStats(
val processedBooks: Int = 0,
val updatedBooks: Int = 0,
val coversUpdated: Int = 0,
val failedBooks: Int = 0
) {
operator fun plus(other: DesktopFolderMetadataExtractionStats): DesktopFolderMetadataExtractionStats {
return DesktopFolderMetadataExtractionStats(
processedBooks = processedBooks + other.processedBooks,
updatedBooks = updatedBooks + other.updatedBooks,
coversUpdated = coversUpdated + other.coversUpdated,
failedBooks = failedBooks + other.failedBooks
)
}
}
object DesktopFolderMetadataExtractor {
private val textMetadataTypes = setOf(
FileType.PDF,
FileType.EPUB,
FileType.HTML,
FileType.MOBI,
FileType.FB2,
FileType.DOCX,
FileType.ODT,
FileType.FODT
)
private val generatedCoverTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP)
private val rasterCoverExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
fun enrichFolderBooks(
books: List<BookItem>,
sourceFolder: String
): DesktopFolderMetadataExtractionResult {
return enrichBooks(books) { book -> book.sourceFolder == sourceFolder }
}
fun enrichImportedBooks(
books: List<BookItem>,
importedBookIds: Set<String>
): DesktopFolderMetadataExtractionResult {
if (importedBookIds.isEmpty()) {
return DesktopFolderMetadataExtractionResult(books)
}
return enrichBooks(books) { book -> book.id in importedBookIds }
}
private fun enrichBooks(
books: List<BookItem>,
shouldConsider: (BookItem) -> Boolean
): DesktopFolderMetadataExtractionResult {
var stats = DesktopFolderMetadataExtractionStats()
val updatedBooks = books.map { book ->
if (!shouldConsider(book) || !book.needsFolderMetadataExtraction()) {
return@map book
}
stats = stats.copy(processedBooks = stats.processedBooks + 1)
val updated = runCatching { enrichBook(book) }
.onFailure { stats = stats.copy(failedBooks = stats.failedBooks + 1) }
.getOrDefault(book)
if (updated != book) {
stats = stats.copy(updatedBooks = stats.updatedBooks + 1)
if (updated.coverImagePath != book.coverImagePath) {
stats = stats.copy(coversUpdated = stats.coversUpdated + 1)
}
}
updated
}
return DesktopFolderMetadataExtractionResult(updatedBooks, stats)
}
private fun BookItem.needsFolderMetadataExtraction(): Boolean {
val path = path?.takeIf { it.isNotBlank() } ?: return false
val file = File(path)
if (!file.isFile) return false
val needsTextMetadata = type in textMetadataTypes && !folderTextMetadataParsed
val needsCover = type in generatedCoverTypes && coverImagePath?.let { File(it).isFile } != true
return needsTextMetadata || needsCover
}
private fun enrichBook(book: BookItem): BookItem {
val file = File(book.path.orEmpty())
val size = file.length().takeIf { it > 0L } ?: book.fileSize
var title = book.title
var author = book.author
var textMetadataParsed = book.folderTextMetadataParsed
var embeddedCover: EmbeddedCover? = null
when (book.type) {
FileType.EPUB -> {
val metadata = parseEpubMetadata(file)
title = sanitizeTitle(metadata.title) ?: title
author = sanitizeAuthor(metadata.author) ?: author
embeddedCover = metadata.cover
textMetadataParsed = true
}
FileType.PDF -> {
val metadata = runCatching { DesktopPdfium.extractMetadata(file) }.getOrNull()
title = sanitizeTitle(metadata?.title) ?: title
author = sanitizeAuthor(metadata?.author) ?: author
textMetadataParsed = true
}
FileType.HTML -> {
title = sanitizeTitle(parseHtmlTitle(file)) ?: title
textMetadataParsed = true
}
FileType.MOBI,
FileType.FB2,
FileType.DOCX,
FileType.ODT,
FileType.FODT -> {
runCatching { SharedJvmBookLoader.load(file, book.type) }
.onSuccess { loaded ->
title = sanitizeTitle(loaded.title) ?: title
author = sanitizeAuthor(loaded.author) ?: author
textMetadataParsed = true
}
}
else -> Unit
}
val coverPath = book.coverImagePath?.takeIf { File(it).isFile }
?: saveEmbeddedCover(book, embeddedCover)
?: renderReaderSurfaceCover(book, file)
?: saveGeneratedCover(book)
return book.copy(
title = title ?: file.nameWithoutExtension,
author = author,
fileSize = size,
coverImagePath = coverPath,
folderTextMetadataParsed = textMetadataParsed
)
}
private fun parseEpubMetadata(file: File): ExtractedBookMetadata {
ZipFile(file).use { zip ->
val containerXml = zip.readTextOrNull("META-INF/container.xml")
val opfPath = containerXml
?.let(::parseEpubRootfilePath)
?: zip.entries().asSequence()
.map { it.name }
.firstOrNull { it.endsWith(".opf", ignoreCase = true) }
?: return ExtractedBookMetadata()
val opf = zip.readTextOrNull(opfPath) ?: return ExtractedBookMetadata()
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
.let { if (it.isBlank()) "" else "$it/" }
val manifest = parseEpubManifest(opf)
val cover = findEpubCover(opf, manifest)
?.takeIf { it.isRasterCover }
?.let { item ->
val coverPath = normalizeZipPath(basePath + item.href)
zip.readBytesOrNull(coverPath)?.let { bytes ->
EmbeddedCover(bytes = bytes, extension = item.rasterExtension ?: "png")
}
}
return ExtractedBookMetadata(
title = opf.tagText("title"),
author = opf.tagText("creator"),
cover = cover
)
}
}
private fun parseEpubRootfilePath(containerXml: String): String? {
return Regex("""<rootfile\b[^>]*\bfull-path=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
.find(containerXml)
?.groupValues
?.get(1)
?.takeIf { it.isNotBlank() }
}
private fun parseEpubManifest(opf: String): List<EpubManifestItem> {
return Regex("""<item\s+[^>]*>""", RegexOption.IGNORE_CASE)
.findAll(opf)
.mapNotNull { match ->
val item = match.value
val id = item.attr("id")
val href = item.attr("href")
if (id.isBlank() || href.isBlank()) {
null
} else {
EpubManifestItem(
id = id,
href = href,
mediaType = item.attr("media-type"),
properties = item.attr("properties")
)
}
}
.toList()
}
private fun findEpubCover(opf: String, manifest: List<EpubManifestItem>): EpubManifestItem? {
val coverId = Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
.findAll(opf)
.firstOrNull { it.value.attr("name").equals("cover", ignoreCase = true) }
?.value
?.attr("content")
?.takeIf { it.isNotBlank() }
return manifest.firstOrNull { it.id == coverId }
?: manifest.firstOrNull { it.properties.split(Regex("\\s+")).any { property -> property == "cover-image" } }
?: manifest.firstOrNull { it.isRasterCover && it.href.contains("cover", ignoreCase = true) }
?: manifest.firstOrNull { it.isRasterCover && it.href.contains("front", ignoreCase = true) }
}
private fun parseHtmlTitle(file: File): String? {
return runCatching {
val head = file.inputStream().bufferedReader(Charsets.UTF_8).use { reader ->
buildString {
var remaining = 64 * 1024
val buffer = CharArray(2048)
while (remaining > 0) {
val read = reader.read(buffer, 0, minOf(buffer.size, remaining))
if (read <= 0) break
append(buffer, 0, read)
remaining -= read
if (contains("</title>", ignoreCase = true)) break
}
}
}
head.tagText("title")
}.getOrNull()
}
private fun saveEmbeddedCover(book: BookItem, cover: EmbeddedCover?): String? {
if (cover == null || cover.bytes.isEmpty()) return null
val extension = cover.extension.takeIf { it in rasterCoverExtensions } ?: return null
return runCatching {
deleteExistingCoverFiles(book)
val target = coverCacheFile(book, extension)
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "${target.name}.tmp")
temp.writeBytes(cover.bytes)
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
target.absolutePath
}.getOrNull()
}
private fun renderReaderSurfaceCover(book: BookItem, file: File): String? {
if (book.type == FileType.PDF && !DesktopPdfium.isAvailable()) return null
if (book.type != FileType.PDF && !DesktopComicArchive.canLoad(book.type)) return null
return runCatching {
val document = if (book.type == FileType.PDF) {
DesktopPdfium.load(file)
} else {
DesktopPdfium.loadComic(file, book.type)
}
try {
if (document.pageCount <= 0) {
null
} else {
val firstPage = document.pageSizes.first()
val scale = 800f / firstPage.height.coerceAtLeast(1f)
val image = DesktopPdfium.renderPageBufferedImage(
document = document,
pageIndex = 0,
scale = scale,
renderAnnotations = false
)
saveCoverImage(book, image)
}
} finally {
document.close()
}
}.getOrNull()
}
private fun saveGeneratedCover(book: BookItem): String? {
if (book.type !in generatedCoverTypes) return null
return saveCoverImage(book, generatedCoverImage(book))
}
private fun saveCoverImage(book: BookItem, image: BufferedImage): String? {
return runCatching {
deleteExistingCoverFiles(book)
val target = coverCacheFile(book, "png")
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "${target.name}.tmp")
ImageIO.write(image, "png", temp)
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
target.absolutePath
}.getOrNull()
}
private fun generatedCoverImage(book: BookItem): BufferedImage {
val width = 480
val height = 720
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
val base = coverColor(book.type)
val title = book.title?.takeIf { it.isNotBlank() }
?: book.displayName.substringBeforeLast('.', missingDelimiterValue = book.displayName)
val author = book.author?.takeIf { it.isNotBlank() }
val g = image.createGraphics()
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g.paint = GradientPaint(0f, 0f, base.brighter(), 0f, height.toFloat(), base.darker())
g.fillRect(0, 0, width, height)
g.color = Color(255, 255, 255, 36)
g.fillRoundRect(42, 42, width - 84, height - 84, 36, 36)
g.color = Color(255, 255, 255, 210)
g.font = Font("SansSerif", Font.BOLD, 34)
g.drawString(book.type.name, 64, 104)
g.font = Font("Serif", Font.BOLD, 48)
val titleLines = wrapText(title, g.fontMetrics, width - 128, maxLines = 6)
var y = 250
titleLines.forEach { line ->
g.drawString(line, 64, y)
y += 58
}
g.font = Font("SansSerif", Font.PLAIN, 28)
val footer = author ?: book.displayName
val footerLines = wrapText(footer, g.fontMetrics, width - 128, maxLines = 2)
val footerStart = max(y + 40, height - 150)
footerLines.forEachIndexed { index, line ->
g.drawString(line, 64, footerStart + index * 34)
}
} finally {
g.dispose()
}
return image
}
private fun wrapText(text: String, metrics: java.awt.FontMetrics, maxWidth: Int, maxLines: Int): List<String> {
val words = text.replace(Regex("\\s+"), " ").trim().split(' ').filter { it.isNotBlank() }
if (words.isEmpty()) return listOf("Untitled")
val lines = mutableListOf<String>()
var current = ""
for (word in words) {
val candidate = if (current.isBlank()) word else "$current $word"
if (metrics.stringWidth(candidate) <= maxWidth) {
current = candidate
} else {
if (current.isNotBlank()) lines += current
current = trimToWidth(word, metrics, maxWidth)
}
if (lines.size == maxLines) break
}
if (lines.size < maxLines && current.isNotBlank()) lines += current
return lines.take(maxLines)
}
private fun trimToWidth(text: String, metrics: java.awt.FontMetrics, maxWidth: Int): String {
if (metrics.stringWidth(text) <= maxWidth) return text
var candidate = text
while (candidate.length > 1 && metrics.stringWidth("$candidate...") > maxWidth) {
candidate = candidate.dropLast(1)
}
return "$candidate..."
}
private fun coverColor(type: FileType): Color {
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.MD -> Color(83, 101, 120)
FileType.HTML -> Color(122, 87, 42)
FileType.TXT -> Color(74, 92, 112)
else -> Color(93, 107, 130)
}
}
private fun coverCacheFile(book: BookItem, extension: String): File {
val key = book.path?.takeIf { it.isNotBlank() } ?: book.id
val hash = Integer.toUnsignedString(key.hashCode())
return File(coverCacheDir(), "cover_$hash.$extension")
}
private fun deleteExistingCoverFiles(book: BookItem) {
val key = book.path?.takeIf { it.isNotBlank() } ?: book.id
val hash = Integer.toUnsignedString(key.hashCode())
coverCacheDir().listFiles()
?.filter { it.isFile && it.name.startsWith("cover_$hash.") }
?.forEach { runCatching { it.delete() } }
}
private fun coverCacheDir(): File {
val overridePath = System.getProperty("reader.cover.cache.dir")
?: System.getenv("READER_COVER_CACHE_DIR")
if (!overridePath.isNullOrBlank()) {
return File(overridePath).apply { mkdirs() }
}
val root = DesktopLibraryDatabase.defaultDatabaseFile().parentFile
?: File(System.getProperty("user.home"), "AppData/Roaming/Episteme")
return File(root, "cover_cache").apply { mkdirs() }
}
private fun ZipFile.readTextOrNull(path: String): String? {
val entry = getEntry(path) ?: return null
return getInputStream(entry).bufferedReader(Charsets.UTF_8).use { it.readText() }
}
private fun ZipFile.readBytesOrNull(path: String): ByteArray? {
val entry = getEntry(path) ?: return null
return getInputStream(entry).use { it.readBytes() }
}
private fun String.attr(name: String): String {
return Regex("""\b$name=["']([^"']+)["']""", RegexOption.IGNORE_CASE)
.find(this)
?.groupValues
?.get(1)
.orEmpty()
}
private fun String.tagText(tag: String): String {
return Regex(
"<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>",
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
)
.find(this)
?.groupValues
?.get(1)
?.replace(Regex("<[^>]+>"), " ")
?.decodeEntities()
?.replace(Regex("\\s+"), " ")
?.trim()
.orEmpty()
}
private fun String.decodeEntities(): String {
return replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
}
.replace(Regex("&#(\\d+);")) { match ->
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
}
}
private fun normalizeZipPath(path: String): String {
val parts = ArrayDeque<String>()
path.split('/').forEach { part ->
when (part) {
"", "." -> Unit
".." -> if (parts.isNotEmpty()) parts.removeLast()
else -> parts.addLast(part)
}
}
return parts.joinToString("/")
}
private fun sanitizeTitle(value: String?): String? {
return value
?.trim()
?.takeIf { it.isNotBlank() && !it.equals("content", ignoreCase = true) }
}
private fun sanitizeAuthor(value: String?): String? {
return value
?.trim()
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
}
private val EpubManifestItem.isRasterCover: Boolean
get() = rasterExtension != null
private val EpubManifestItem.rasterExtension: String?
get() {
val extension = href.substringBefore('?')
.substringBefore('#')
.substringAfterLast('.', missingDelimiterValue = "")
.lowercase()
if (extension in rasterCoverExtensions) return extension
return when {
mediaType.equals("image/jpeg", ignoreCase = true) -> "jpg"
mediaType.equals("image/png", ignoreCase = true) -> "png"
mediaType.equals("image/gif", ignoreCase = true) -> "gif"
mediaType.equals("image/webp", ignoreCase = true) -> "webp"
mediaType.equals("image/bmp", ignoreCase = true) -> "bmp"
else -> null
}
}
private data class ExtractedBookMetadata(
val title: String? = null,
val author: String? = null,
val cover: EmbeddedCover? = null
)
private data class EmbeddedCover(
val bytes: ByteArray,
val extension: String
)
private data class EpubManifestItem(
val id: String,
val href: String,
val mediaType: String,
val properties: String
)
}

View file

@ -0,0 +1,732 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL
import com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.ReaderTtsCacheSummary
import com.aryan.reader.shared.ReaderTtsChunk
import com.aryan.reader.shared.ReaderTtsFileCacheManager
import com.aryan.reader.shared.ReaderTtsReadScope
import com.aryan.reader.shared.TtsAdapter
import com.aryan.reader.shared.createReaderTtsWavHeaderUnknownLength
import com.aryan.reader.shared.patchReaderTtsWavHeader
import com.aryan.reader.shared.splitReaderTextIntoTtsChunks
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
import java.io.FileOutputStream
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.WebSocket
import java.nio.ByteBuffer
import java.util.Base64
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionStage
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import javax.sound.sampled.AudioFormat
import javax.sound.sampled.AudioSystem
import javax.sound.sampled.SourceDataLine
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.coroutineContext
private data class DesktopTtsSequenceChunk(
val text: String,
val chapterTitle: String?
)
class DesktopGeminiCloudTtsAdapter(
private val settingsProvider: () -> ReaderAiByokSettings,
private val httpClient: HttpClient = HttpClient.newHttpClient(),
private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot())
) : TtsAdapter {
@Volatile
private var activeLine: SourceDataLine? = null
@Volatile
private var activeWebSocket: WebSocket? = null
@Volatile
private var activePlayer: DesktopStreamingPcmPlayer? = null
override val isAvailable: Boolean
get() = settingsProvider().sanitized().isCloudTtsAvailable
override suspend fun speak(text: String) {
val trimmed = text.trim()
logDesktopTts("speak_start textChars=${trimmed.length}")
if (trimmed.isBlank()) return
speakSequence(splitReaderTextIntoTtsChunks(trimmed).ifEmpty { listOf(trimmed.take(5_000)) })
logDesktopTts("speak_finished")
}
suspend fun speakSequence(
texts: List<String>,
onChunkStart: suspend (Int) -> Unit = {}
) {
val normalizedChunks = texts
.flatMap { text -> splitReaderTextIntoTtsChunks(text).ifEmpty { listOf(text.trim()) } }
.map { text -> DesktopTtsSequenceChunk(text = text.trim().take(5_000), chapterTitle = null) }
.filter { it.text.isNotBlank() }
logDesktopTts(
"sequence_speak_start chunks=${normalizedChunks.size} totalTextChars=${normalizedChunks.sumOf { it.text.length }}"
)
if (normalizedChunks.isEmpty()) return
val callbackContext = coroutineContext
stop()
streamSequence("Desktop selection", normalizedChunks, callbackContext, onChunkStart)
logDesktopTts("sequence_speak_finished chunks=${normalizedChunks.size}")
}
suspend fun speakChunks(
bookTitle: String,
readScope: ReaderTtsReadScope,
chunks: List<ReaderTtsChunk>,
onChunkStart: suspend (Int) -> Unit = {}
) {
val sequenceChunks = chunks
.map { chunk ->
DesktopTtsSequenceChunk(
text = chunk.spokenText.trim().ifBlank { chunk.text.trim() }.take(5_000),
chapterTitle = chunk.chapterTitle.ifBlank { readScope.label }
)
}
.filter { it.text.isNotBlank() }
logDesktopTts(
"chunk_sequence_speak_start book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " +
"chunks=${sequenceChunks.size} totalTextChars=${sequenceChunks.sumOf { it.text.length }}"
)
if (sequenceChunks.isEmpty()) return
val callbackContext = coroutineContext
stop()
streamSequence(bookTitle.ifBlank { "Untitled" }, sequenceChunks, callbackContext, onChunkStart)
logDesktopTts("chunk_sequence_speak_finished chunks=${sequenceChunks.size}")
}
override suspend fun pause() {
withContext(Dispatchers.IO) {
activePlayer?.pause()
}
}
override suspend fun resume() {
withContext(Dispatchers.IO) {
activePlayer?.resume()
}
}
fun cacheSummary(bookTitle: String, speakerId: String? = settingsProvider().sanitized().ttsSpeakerId): ReaderTtsCacheSummary {
return cacheManager.getCacheSummary(bookTitle.ifBlank { "Untitled" }, speakerId)
}
fun clearBookCacheForSpeaker(bookTitle: String, speakerId: String = settingsProvider().sanitized().ttsSpeakerId) {
cacheManager.clearBookCacheForSpeaker(bookTitle.ifBlank { "Untitled" }, speakerId)
}
fun clearBookCache(bookTitle: String) {
cacheManager.clearBookCache(bookTitle.ifBlank { "Untitled" })
}
override suspend fun stop() {
withContext(Dispatchers.IO) {
logDesktopTts("stop_requested hasWebSocket=${activeWebSocket != null} hasLine=${activeLine != null}")
runCatching { activeWebSocket?.abort() }
activeWebSocket = null
runCatching { activePlayer?.closeNow() }
activePlayer = null
runCatching { activeLine?.stop() }
runCatching { activeLine?.flush() }
runCatching { activeLine?.close() }
activeLine = null
logDesktopTts("stop_complete")
}
}
private suspend fun streamSequence(
bookTitle: String,
chunks: List<DesktopTtsSequenceChunk>,
callbackContext: CoroutineContext,
onChunkStart: suspend (Int) -> Unit
) = withContext(Dispatchers.IO) {
val settings = settingsProvider().sanitized()
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}"
)
if (!settings.isCloudTtsAvailable) {
logDesktopTts("stream_blocked reason=not_available")
throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.")
}
val audioBytesReceived = AtomicLong(0)
val currentTurnAudioBytesReceived = AtomicLong(0)
val player = DesktopStreamingPcmPlayer { activeLine = it }
activePlayer = player
val setupComplete = CompletableDeferred<Unit>()
val currentTurnComplete = AtomicReference<CompletableDeferred<Unit>?>(null)
val activeCacheOutput = AtomicReference<FileOutputStream?>(null)
val failure = CompletableDeferred<Throwable>()
val messageBuffer = StringBuilder()
var webSocket: WebSocket? = null
var activeTempCacheFile: File? = null
fun handleMessage(message: String) {
handleGeminiTtsMessage(
message = message,
setupComplete = setupComplete,
turnComplete = currentTurnComplete.get(),
failure = failure,
onAudioPart = { bytes ->
audioBytesReceived.addAndGet(bytes.size.toLong())
currentTurnAudioBytesReceived.addAndGet(bytes.size.toLong())
activeCacheOutput.get()?.let { output ->
runCatching { output.write(bytes) }
.onFailure { error ->
logDesktopTts("cache_write_failed error=\"${error.desktopTtsSummary()}\"")
failure.complete(error)
}
}
runCatching { player.write(bytes) }
.onFailure { error ->
logDesktopTts("stream_audio_write_failed error=\"${error.desktopTtsSummary()}\"")
failure.complete(error)
}
}
)
}
val listener = object : WebSocket.Listener {
override fun onOpen(webSocket: WebSocket) {
activeWebSocket = webSocket
webSocket.request(1)
logDesktopTts("ws_open send_setup model=\"$GEMINI_CLOUD_TTS_MODEL\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\"")
webSocket.sendText(buildGeminiTtsSetup(settings.ttsSpeakerId), true)
.whenComplete { _, error ->
if (error != null) {
logDesktopTts("ws_setup_send_failed error=\"${error.desktopTtsSummary()}\"")
failure.complete(error)
} else {
logDesktopTts("ws_setup_send_complete")
}
}
}
override fun onText(webSocket: WebSocket, data: CharSequence, last: Boolean): CompletionStage<*> {
messageBuffer.append(data)
logDesktopTts("ws_message_text chunkChars=${data.length} last=$last bufferChars=${messageBuffer.length}")
if (last) {
val message = messageBuffer.toString()
messageBuffer.clear()
handleMessage(message)
}
webSocket.request(1)
return CompletableFuture.completedFuture(null)
}
override fun onBinary(webSocket: WebSocket, data: ByteBuffer, last: Boolean): CompletionStage<*> {
val bytes = ByteArray(data.remaining())
data.get(bytes)
messageBuffer.append(bytes.decodeToString())
logDesktopTts("ws_message_binary chunkBytes=${bytes.size} last=$last bufferChars=${messageBuffer.length}")
if (last) {
val message = messageBuffer.toString()
messageBuffer.clear()
handleMessage(message)
}
webSocket.request(1)
return CompletableFuture.completedFuture(null)
}
override fun onError(webSocket: WebSocket, error: Throwable) {
logDesktopTts("ws_error error=\"${error.desktopTtsSummary()}\"")
failure.complete(error)
}
override fun onClose(webSocket: WebSocket, statusCode: Int, reason: String): CompletionStage<*> {
val activeTurn = currentTurnComplete.get()
logDesktopTts(
"ws_close status=$statusCode reason=\"${reason.desktopTtsPreview()}\" " +
"setupComplete=${setupComplete.isCompleted} turnComplete=${activeTurn?.isCompleted}"
)
if (!setupComplete.isCompleted && !failure.isCompleted) {
failure.complete(IllegalStateException("Cloud TTS connection closed before setup: $reason"))
} else if (activeTurn != null && !activeTurn.isCompleted && !failure.isCompleted) {
failure.complete(IllegalStateException("Cloud TTS connection closed: $reason"))
}
return CompletableFuture.completedFuture(null)
}
}
suspend fun ensureWebSocket(): WebSocket {
webSocket?.let { return it }
val encodedKey = URLEncoder.encode(settings.geminiKey, Charsets.UTF_8.name())
val uri = URI("wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$encodedKey")
logDesktopTts("ws_connect_start endpoint=GeminiLive keyChars=${settings.geminiKey.length}")
val connectedWebSocket = runCatching {
httpClient.newWebSocketBuilder()
.buildAsync(uri, listener)
.get(15, TimeUnit.SECONDS)
}.getOrElse { error ->
logDesktopTts("ws_connect_failed error=\"${error.desktopTtsSummary()}\"")
throw error
}
activeWebSocket = connectedWebSocket
webSocket = connectedWebSocket
logDesktopTts("ws_connect_complete")
logDesktopTts("setup_wait_start timeoutMs=15000")
withTimeout(15_000) {
select<Unit> {
setupComplete.onAwait { }
failure.onAwait { throw it }
}
}
logDesktopTts("setup_wait_complete")
return connectedWebSocket
}
try {
val totalChunksByChapter = chunks.groupingBy { it.chapterTitle }.eachCount()
chunks.forEach { chunk ->
cacheManager.saveTotalChunks(
bookTitle = bookTitle,
chapterTitle = chunk.chapterTitle,
totalChunks = totalChunksByChapter[chunk.chapterTitle] ?: chunks.size
)
}
chunks.forEachIndexed { index, chunk ->
val text = chunk.text
val turnComplete = CompletableDeferred<Unit>()
currentTurnAudioBytesReceived.set(0)
currentTurnComplete.set(turnComplete)
logDesktopTts("sequence_turn_start index=${index + 1}/${chunks.size} textChars=${text.length}")
withContext(callbackContext) {
onChunkStart(index)
}
val cacheFile = cacheManager.getCacheFile(bookTitle, chunk.chapterTitle, text, settings.ttsSpeakerId)
if (cacheFile.exists() && cacheFile.length() > 44) {
logDesktopTts(
"cache_hit index=${index + 1}/${chunks.size} bytes=${cacheFile.length()} " +
"file=\"${cacheFile.absolutePath.desktopTtsPreview(220)}\""
)
val cachedBytes = playCachedWav(cacheFile, player)
currentTurnAudioBytesReceived.set(cachedBytes)
audioBytesReceived.addAndGet(cachedBytes)
logDesktopTts("cache_play_complete index=${index + 1}/${chunks.size} audioBytes=$cachedBytes")
currentTurnComplete.compareAndSet(turnComplete, null)
return@forEachIndexed
}
val socket = ensureWebSocket()
val tempCacheFile = File(cacheFile.absolutePath + ".tmp")
activeTempCacheFile = tempCacheFile
runCatching {
tempCacheFile.parentFile?.mkdirs()
FileOutputStream(tempCacheFile).also { output ->
output.write(createReaderTtsWavHeaderUnknownLength(24_000))
activeCacheOutput.set(output)
}
}.onFailure { error ->
activeCacheOutput.set(null)
tempCacheFile.delete()
logDesktopTts("cache_prepare_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
}
try {
logDesktopTts("text_send_start index=${index + 1}/${chunks.size} textChars=${text.length}")
runCatching { socket.sendText(buildGeminiTtsTextInput(text), true).join() }
.onFailure { error ->
logDesktopTts("text_send_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
throw error
}
logDesktopTts("text_send_complete index=${index + 1}/${chunks.size}")
val turnTimeoutMs = (30_000L + text.length * 80L).coerceIn(60_000L, 600_000L)
logDesktopTts("turn_wait_start index=${index + 1}/${chunks.size} timeoutMs=$turnTimeoutMs")
withTimeout(turnTimeoutMs) {
select<Unit> {
turnComplete.onAwait { }
failure.onAwait { throw it }
}
}
val turnAudioBytes = currentTurnAudioBytesReceived.get()
logDesktopTts(
"turn_wait_complete index=${index + 1}/${chunks.size} " +
"turnAudioBytes=$turnAudioBytes totalAudioBytes=${audioBytesReceived.get()}"
)
if (turnAudioBytes == 0L) {
logDesktopTts("stream_failed reason=empty_turn_audio index=${index + 1}/${chunks.size}")
throw IllegalStateException("Cloud TTS returned no audio for a text chunk.")
}
activeCacheOutput.getAndSet(null)?.close()
runCatching {
patchReaderTtsWavHeader(tempCacheFile, turnAudioBytes.toInt())
if (cacheFile.exists()) cacheFile.delete()
if (!tempCacheFile.renameTo(cacheFile)) {
throw IllegalStateException("Could not move temp cache file into place.")
}
}.onSuccess {
logDesktopTts(
"cache_store_complete index=${index + 1}/${chunks.size} bytes=${cacheFile.length()} " +
"file=\"${cacheFile.absolutePath.desktopTtsPreview(220)}\""
)
}.onFailure { error ->
tempCacheFile.delete()
logDesktopTts("cache_store_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
}
activeTempCacheFile = null
} finally {
activeCacheOutput.getAndSet(null)?.let { output ->
runCatching { output.close() }
}
}
currentTurnComplete.compareAndSet(turnComplete, null)
}
if (audioBytesReceived.get() == 0L) {
logDesktopTts("stream_failed reason=empty_audio")
throw IllegalStateException("Cloud TTS returned no audio.")
}
player.drainAndClose()
webSocket?.let { socket -> runCatching { socket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join() } }
activeWebSocket = null
activePlayer = null
logDesktopTts("stream_complete chunks=${chunks.size} audioBytes=${audioBytesReceived.get()}")
} catch (error: Throwable) {
currentTurnComplete.set(null)
activeCacheOutput.getAndSet(null)?.let { output -> runCatching { output.close() } }
activeTempCacheFile?.delete()
activeTempCacheFile = null
runCatching { webSocket?.abort() }
activeWebSocket = null
activePlayer = null
player.closeNow()
throw error
}
}
}
private suspend fun playCachedWav(file: File, player: DesktopStreamingPcmPlayer): Long {
var totalBytes = 0L
file.inputStream().use { input ->
var skipped = 0L
while (skipped < 44L) {
val next = input.skip(44L - skipped)
if (next <= 0L) break
skipped += next
}
val buffer = ByteArray(8192)
while (true) {
coroutineContext.ensureActive()
val read = input.read(buffer)
if (read <= 0) break
player.write(buffer.copyOf(read))
totalBytes += read
}
}
return totalBytes
}
private fun defaultDesktopTtsCacheRoot(): File {
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
return File(baseDir, "Episteme/TTS_Cache")
}
private fun buildGeminiTtsSetup(speakerId: String): String {
val systemPrompt = """
You are a professional audiobook narrator.
Read the exact text provided, word for word, with neutral emotion and good pacing.
Do not add conversational filler, acknowledgments, extra words, summaries, or commentary.
Skip non-verbal symbols or formatting noise that cannot be read naturally.
""".trimIndent()
return buildJsonObject {
put(
"setup",
buildJsonObject {
put("model", JsonPrimitive("models/$GEMINI_CLOUD_TTS_MODEL"))
put(
"systemInstruction",
buildJsonObject {
put("parts", buildJsonArray {
add(buildJsonObject { put("text", JsonPrimitive(systemPrompt)) })
})
}
)
put(
"generationConfig",
buildJsonObject {
put("responseModalities", buildJsonArray { add(JsonPrimitive("AUDIO")) })
put(
"speechConfig",
buildJsonObject {
put(
"voiceConfig",
buildJsonObject {
put(
"prebuiltVoiceConfig",
buildJsonObject { put("voiceName", JsonPrimitive(speakerId)) }
)
}
)
}
)
}
)
}
)
}.toString()
}
private fun buildGeminiTtsTextInput(text: String): String {
return buildJsonObject {
put(
"realtimeInput",
buildJsonObject {
put("text", JsonPrimitive(text))
}
)
}.toString()
}
private fun handleGeminiTtsMessage(
message: String,
setupComplete: CompletableDeferred<Unit>,
turnComplete: CompletableDeferred<Unit>?,
failure: CompletableDeferred<Throwable>,
onAudioPart: (ByteArray) -> Unit
) {
logDesktopTts("message_handle chars=${message.length} preview=\"${message.desktopTtsPreview()}\"")
val json = runCatching { DesktopGeminiTtsJson.parseToJsonElement(message).jsonObject }.getOrElse { error ->
logDesktopTts("message_parse_failed error=\"${error.desktopTtsSummary()}\"")
return
}
json["error"]?.let { error ->
logDesktopTts("message_provider_error body=\"${error.toString().desktopTtsPreview(300)}\"")
failure.complete(IllegalStateException(error.toString()))
return
}
if (json.containsKey("setupComplete") || json.containsKey("setup_complete")) {
logDesktopTts("message_setup_complete")
setupComplete.complete(Unit)
}
val serverContent = json.jsonObjectValue("serverContent", "server_content") ?: return
val modelTurn = serverContent.jsonObjectValue("modelTurn", "model_turn")
val parts = modelTurn?.get("parts")?.jsonArray
parts?.forEach { part ->
val inlineData = part.jsonObjectOrNull()?.jsonObjectValue("inlineData", "inline_data")
val encoded = inlineData?.get("data")?.jsonPrimitive?.contentOrNull
if (!encoded.isNullOrBlank()) {
val decoded = Base64.getMimeDecoder().decode(encoded)
onAudioPart(decoded)
logDesktopTts("message_audio_part bytes=${decoded.size}")
}
}
if (serverContent.booleanValue("turnComplete", "turn_complete")) {
logDesktopTts("message_turn_complete")
turnComplete?.complete(Unit)
}
}
private val DesktopGeminiTtsJson = Json { ignoreUnknownKeys = true }
private fun JsonObject.jsonObjectValue(vararg keys: String): JsonObject? {
return keys.firstNotNullOfOrNull { key -> get(key) as? JsonObject }
}
private fun JsonObject.booleanValue(vararg keys: String): Boolean {
return keys.any { key -> get(key)?.jsonPrimitive?.booleanOrNull == true }
}
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
return this as? JsonObject
}
private fun ByteArray.upsample16BitMonoLe2x(): ByteArray {
if (size < 2) return this
val sampleCount = size / 2
val output = ByteArray(sampleCount * 4)
var outputIndex = 0
fun sampleAt(index: Int): Int {
val byteIndex = index * 2
val lo = this[byteIndex].toInt() and 0xFF
val hi = this[byteIndex + 1].toInt()
return (hi shl 8) or lo
}
fun writeSample(sample: Int) {
output[outputIndex] = (sample and 0xFF).toByte()
output[outputIndex + 1] = ((sample shr 8) and 0xFF).toByte()
outputIndex += 2
}
for (index in 0 until sampleCount) {
val current = sampleAt(index)
val next = sampleAt((index + 1).coerceAtMost(sampleCount - 1))
writeSample(current)
writeSample(((current + next) / 2).coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()))
}
return output
}
private class DesktopStreamingPcmPlayer(
private val onLineChanged: (SourceDataLine?) -> Unit
) {
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
private val stateLock = java.lang.Object()
private var line: SourceDataLine? = null
private var fallbackTo48Khz = true
@Volatile
private var closed = false
@Volatile
private var paused = false
private var bytesWritten = 0L
init {
logDesktopTts("play_stream_start mixers=\"${availableAudioMixers().desktopTtsPreview(260)}\"")
}
fun pause() {
synchronized(stateLock) {
if (closed || paused) return
paused = true
runCatching { line?.stop() }
logDesktopTts("play_stream_paused totalWritten=$bytesWritten")
}
}
fun resume() {
synchronized(stateLock) {
if (closed || !paused) return
paused = false
runCatching { line?.start() }
stateLock.notifyAll()
logDesktopTts("play_stream_resumed totalWritten=$bytesWritten")
}
}
fun write(pcm24Khz: ByteArray) {
if (closed || pcm24Khz.isEmpty()) return
waitIfPaused()
val activeLine = synchronized(stateLock) {
if (closed) return
line ?: openBestLine()
}
val bytes = if (fallbackTo48Khz) pcm24Khz.upsample16BitMonoLe2x() else pcm24Khz
var offset = 0
var lineStarted = activeLine.isRunning
val primeTargetBytes = (activeLine.bufferSize / 2).coerceAtLeast(8192)
while (offset < bytes.size && !closed) {
waitIfPaused()
val maxWrite = if (lineStarted) 8192 else primeTargetBytes
val written = activeLine.write(bytes, offset, (bytes.size - offset).coerceAtMost(maxWrite))
if (written <= 0) break
offset += written
bytesWritten += written
if (!lineStarted && (offset >= bytes.size || offset >= primeTargetBytes)) {
activeLine.start()
lineStarted = true
logDesktopTts("play_line_started_after_prime primeBytes=$offset")
}
}
if (!lineStarted && !closed) {
activeLine.start()
logDesktopTts("play_line_started_after_prime primeBytes=$offset")
}
logDesktopTts("play_stream_write inputBytes=${pcm24Khz.size} writtenBytes=$offset totalWritten=$bytesWritten")
}
fun drainAndClose() {
val activeLine = line
if (activeLine != null && !closed) {
logDesktopTts("play_stream_drain totalWritten=$bytesWritten")
runCatching { activeLine.drain() }
.onFailure { error -> logDesktopTts("play_stream_drain_failed error=\"${error.desktopTtsSummary()}\"") }
}
closeNow()
}
fun closeNow() {
val activeLine = synchronized(stateLock) {
if (closed) return
closed = true
paused = false
stateLock.notifyAll()
line.also { line = null }
}
activeLine?.let {
runCatching { it.stop() }
runCatching { it.flush() }
runCatching { it.close() }
}
onLineChanged(null)
logDesktopTts("play_stream_closed totalWritten=$bytesWritten")
}
private fun waitIfPaused() {
synchronized(stateLock) {
while (paused && !closed) {
stateLock.wait(100)
}
}
}
private fun openBestLine(): SourceDataLine {
fallbackTo48Khz = true
return runCatching {
openLine(48_000f)
}.getOrElse { firstError ->
logDesktopTts("play_primary_failed sampleRate=48000 error=\"${firstError.desktopTtsSummary()}\"")
fallbackTo48Khz = false
runCatching {
openLine(24_000f)
}.onFailure { secondError ->
logDesktopTts("play_fallback_failed sampleRate=24000 error=\"${secondError.desktopTtsSummary()}\"")
secondError.printStackTrace()
}.getOrElse {
throw firstError
}
}
}
private fun openLine(sampleRate: Float): SourceDataLine {
val format = AudioFormat(sampleRate, 16, 1, true, false)
val bufferBytes = sampleRate.toInt().coerceAtLeast(16_384)
logDesktopTts("play_line_request sampleRate=${sampleRate.toInt()} bufferBytes=$bufferBytes")
val openedLine = AudioSystem.getSourceDataLine(format)
openedLine.open(format, bufferBytes)
line = openedLine
onLineChanged(openedLine)
logDesktopTts(
"play_line_opened sampleRate=${sampleRate.toInt()} output48Khz=$fallbackTo48Khz " +
"line=\"${openedLine.lineInfo.toString().desktopTtsPreview(160)}\""
)
return openedLine
}
}
private fun availableAudioMixers(): String {
return runCatching {
AudioSystem.getMixerInfo()
.joinToString(limit = 8, truncated = "...") { "${it.name}/${it.description}" }
.ifBlank { "none" }
}.getOrDefault("unavailable")
}

View file

@ -1,66 +1,20 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.BookShelfRef
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.ShelfRecord
import com.aryan.reader.shared.Tag
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.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import com.aryan.reader.shared.SharedLibrarySnapshot
import com.aryan.reader.shared.SharedLibrarySnapshotJson
import java.io.File
data class DesktopLibrarySnapshot(
val books: List<BookItem> = emptyList(),
val shelfRecords: List<ShelfRecord> = emptyList(),
val shelfRefs: List<BookShelfRef> = emptyList(),
val tags: List<Tag> = emptyList()
)
class DesktopLibraryDatabase(
private val databaseFile: File = defaultDatabaseFile()
) {
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
fun load(): SharedLibrarySnapshot {
if (!databaseFile.exists()) return SharedLibrarySnapshot()
return SharedLibrarySnapshotJson.decodeOrEmpty(databaseFile.readText())
}
fun load(): DesktopLibrarySnapshot {
if (!databaseFile.exists()) return DesktopLibrarySnapshot()
val root = runCatching {
json.parseToJsonElement(databaseFile.readText()).jsonObject
}.getOrNull() ?: return DesktopLibrarySnapshot()
return DesktopLibrarySnapshot(
books = root.array("books").mapNotNull { it.asBookItemOrNull() },
shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() },
shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() },
tags = root.array("tags").mapNotNull { it.asTagOrNull() }
)
}
fun save(snapshot: DesktopLibrarySnapshot) {
fun save(snapshot: SharedLibrarySnapshot) {
databaseFile.parentFile?.mkdirs()
val root = JsonObject(
mapOf(
"schemaVersion" to JsonPrimitive(1),
"books" to JsonArray(snapshot.books.map { it.toJsonObject() }),
"shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }),
"bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }),
"tags" to JsonArray(snapshot.tags.map { it.toJsonObject() })
)
)
databaseFile.writeText(root.toString())
databaseFile.writeText(SharedLibrarySnapshotJson.encode(snapshot))
}
companion object {
@ -71,135 +25,3 @@ class DesktopLibraryDatabase(
}
}
}
private fun JsonObject.array(name: String): List<JsonElement> {
return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList())
}
private fun JsonObject.string(name: String): String? {
return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content
}
private fun JsonObject.long(name: String, fallback: Long = 0L): Long {
return this[name]?.jsonPrimitive?.longOrNull ?: fallback
}
private fun JsonObject.float(name: String): Float? {
return this[name]?.jsonPrimitive?.floatOrNull
}
private fun JsonObject.double(name: String): Double? {
return this[name]?.jsonPrimitive?.doubleOrNull
}
private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean {
return this[name]?.jsonPrimitive?.booleanOrNull ?: fallback
}
private fun JsonElement.asBookItemOrNull(): BookItem? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
val id = obj.string("id") ?: return null
val displayName = obj.string("displayName") ?: return null
val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN
return BookItem(
id = id,
path = obj.string("path"),
type = type,
displayName = displayName,
timestamp = obj.long("timestamp"),
title = obj.string("title"),
author = obj.string("author"),
progressPercentage = obj.float("progressPercentage"),
isRecent = obj.boolean("isRecent", true),
fileSize = obj.long("fileSize"),
sourceFolder = obj.string("sourceFolder"),
seriesName = obj.string("seriesName"),
seriesIndex = obj.double("seriesIndex"),
tags = obj.array("tags").mapNotNull { it.asTagOrNull() }
)
}
private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return ShelfRecord(
id = obj.string("id") ?: return null,
name = obj.string("name") ?: return null,
isSmart = obj.boolean("isSmart", false),
smartRulesJson = obj.string("smartRulesJson")
)
}
private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return BookShelfRef(
bookId = obj.string("bookId") ?: return null,
shelfId = obj.string("shelfId") ?: return null,
addedAt = obj.long("addedAt")
)
}
private fun JsonElement.asTagOrNull(): Tag? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return Tag(
id = obj.string("id") ?: return null,
name = obj.string("name") ?: return null,
color = obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull()
)
}
private fun BookItem.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"path" to path.asJson(),
"type" to JsonPrimitive(type.name),
"displayName" to JsonPrimitive(displayName),
"timestamp" to JsonPrimitive(timestamp),
"title" to title.asJson(),
"author" to author.asJson(),
"progressPercentage" to progressPercentage.asJson(),
"isRecent" to JsonPrimitive(isRecent),
"fileSize" to JsonPrimitive(fileSize),
"sourceFolder" to sourceFolder.asJson(),
"seriesName" to seriesName.asJson(),
"seriesIndex" to seriesIndex.asJson(),
"tags" to JsonArray(tags.map { it.toJsonObject() })
)
)
}
private fun ShelfRecord.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"name" to JsonPrimitive(name),
"isSmart" to JsonPrimitive(isSmart),
"smartRulesJson" to smartRulesJson.asJson()
)
)
}
private fun BookShelfRef.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"bookId" to JsonPrimitive(bookId),
"shelfId" to JsonPrimitive(shelfId),
"addedAt" to JsonPrimitive(addedAt)
)
)
}
private fun Tag.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"name" to JsonPrimitive(name),
"color" to color.asJson()
)
)
}
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull

View file

@ -0,0 +1,573 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.BookShelfRef
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.LOCAL_FOLDER_ANNOTATION_SUFFIX
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
import com.aryan.reader.shared.LocalFolderSyncEngine
import com.aryan.reader.shared.LocalFolderSyncStats
import com.aryan.reader.shared.ReaderPlatform
import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.SharedFolderBookMetadata
import com.aryan.reader.shared.SharedFolderScannedFile
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.SyncedFolder
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
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 kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import java.io.File
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
data class DesktopLocalFolderSyncResult(
val state: SharedReaderScreenState,
val shelfRefs: List<BookShelfRef>,
val stats: LocalFolderSyncStats,
val metadataStats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats(),
val idMigrations: Map<String, String> = emptyMap(),
val removedBookIds: Set<String> = emptySet(),
val failedFolders: List<String> = emptyList()
)
object DesktopLocalFolderSync {
private val desktopSyncableTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
fun hasSupportedFiles(folder: File): Boolean {
if (!folder.isDirectory) return false
return folder.walkTopDown()
.onEnter { it == folder || it.shouldEnterSyncedFolder() }
.any { file ->
file.isFile &&
file.shouldSyncBookFile() &&
SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes
}
}
fun sync(
state: SharedReaderScreenState,
shelfRefs: List<BookShelfRef>,
targetFolder: File? = null,
nowMillis: Long = System.currentTimeMillis()
): DesktopLocalFolderSyncResult {
val requestedFolders = foldersToSync(state, targetFolder, nowMillis)
var nextState = state
var nextShelfRefs = shelfRefs
var totalStats = LocalFolderSyncStats()
var totalMetadataStats = DesktopFolderMetadataExtractionStats()
val allMigrations = linkedMapOf<String, String>()
val allRemovedBookIds = linkedSetOf<String>()
val failedFolders = mutableListOf<String>()
requestedFolders.forEach { folder ->
val root = File(folder.uriString)
if (!root.isDirectory) {
failedFolders += folder.name
return@forEach
}
val scannedFiles = scanFolder(root = root, sourceFolder = folder.uriString)
val remoteMetadata = readAllMetadata(root)
val syncResult = LocalFolderSyncEngine.syncFolder(
state = nextState,
folder = folder,
files = scannedFiles,
remoteMetadata = remoteMetadata,
nowMillis = nowMillis
)
nextState = syncResult.state
nextShelfRefs = LocalFolderSyncEngine.applyIdMigrationsToShelfRefs(
nextShelfRefs,
syncResult.idMigrations
).filterNot { it.bookId in syncResult.removedBookIds }
allMigrations += syncResult.idMigrations
allRemovedBookIds += syncResult.removedBookIds
totalStats += syncResult.stats
var syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
importAnnotationSidecars(root, syncedBooks)
val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks(
books = nextState.rawLibraryBooks,
sourceFolder = folder.uriString
)
if (metadataResult.stats.updatedBooks > 0) {
nextState = nextState.copy(rawLibraryBooks = metadataResult.books)
syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
}
totalMetadataStats += metadataResult.stats
syncedBooks.forEach { book ->
saveBookMetadata(book)
savePdfAnnotationSidecar(book)
}
}
return DesktopLocalFolderSyncResult(
state = nextState,
shelfRefs = nextShelfRefs,
stats = totalStats,
metadataStats = totalMetadataStats,
idMigrations = allMigrations,
removedBookIds = allRemovedBookIds,
failedFolders = failedFolders
)
}
fun saveBookSidecars(book: BookItem) {
saveBookMetadata(book)
savePdfAnnotationSidecar(book)
}
fun saveBookMetadata(book: BookItem) {
val metadata = book.toSharedFolderBookMetadata() ?: return
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return
saveMetadataToFolder(root, metadata)
}
fun savePdfAnnotationSidecar(book: BookItem) {
val path = book.path?.takeIf { it.isNotBlank() } ?: return
if (book.type != FileType.PDF) return
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return
val annotationFile = desktopPdfAnnotationFile(path)
val bookmarkFile = desktopPdfBookmarkFile(path)
val richTextFile = desktopPdfRichTextFile(path)
val data = buildMap {
if (annotationFile.isFile) {
val annotationJson = annotationFile.readText().trim()
val annotations = SharedPdfAnnotationSerializer.decode(annotationJson)
put(
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS,
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
)
}
if (bookmarkFile.isFile) {
val bookmarksJson = bookmarkFile.readText().trim()
desktopFolderSyncJson.parseElementOrNull(bookmarksJson)?.let { put("bookmarks", it) }
}
if (richTextFile.isFile) {
val richTextJson = richTextFile.readText().trim()
val richTextElement = desktopFolderSyncJson.parseElementOrNull(richTextJson)
if (richTextElement == null) {
SharedPdfRichTextLog.d(
"desktop.sync.exportRichTextParseFailed book=${book.id} " +
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length}"
)
} else {
val richTextDocument = SharedPdfRichTextSerializer.decodeElement(richTextElement)
SharedPdfRichTextLog.d(
"desktop.sync.exportRichText book=${book.id} " +
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length} " +
"textLen=${richTextDocument.text.length} spans=${richTextDocument.spans.size}"
)
put("text", SharedPdfRichTextSerializer.encodeElement(richTextDocument))
}
}
}
if (data.isEmpty()) {
SharedPdfRichTextLog.d("desktop.sync.exportSkipNoSidecarData book=${book.id} pdfPath=\"${path.richSyncPreview()}\"")
return
}
val timestamp = maxOf(
annotationFile.lastModifiedIfFile(),
bookmarkFile.lastModifiedIfFile(),
richTextFile.lastModifiedIfFile(),
System.currentTimeMillis()
)
val dataJson = desktopFolderSyncJson.encodeToString(
JsonElement.serializer(),
JsonObject(data)
)
if (data.containsKey("text")) {
SharedPdfRichTextLog.d(
"desktop.sync.exportSidecar book=${book.id} timestamp=$timestamp " +
"keys=${data.keys.sorted()} root=\"${root.absolutePath.richSyncPreview()}\""
)
}
saveAnnotationSidecar(
root = root,
bookId = book.id,
jsonPayload = dataJson,
timestamp = timestamp
)
}
private fun foldersToSync(
state: SharedReaderScreenState,
targetFolder: File?,
nowMillis: Long
): List<SyncedFolder> {
if (targetFolder == null) return state.syncedFolders
val root = targetFolder.canonicalOrAbsolute()
val rootPath = root.absolutePath
val existing = state.syncedFolders.firstOrNull { File(it.uriString).canonicalOrAbsolute() == root }
return listOf(
existing ?: SyncedFolder(
uriString = rootPath,
name = root.name.takeIf { it.isNotBlank() } ?: rootPath,
lastScanTime = nowMillis,
allowedFileTypes = desktopSyncableTypes
)
)
}
private fun scanFolder(root: File, sourceFolder: String): List<SharedFolderScannedFile> {
val rootPath = root.toPath().toAbsolutePath().normalize()
return root.walkTopDown()
.onEnter { it == root || it.shouldEnterSyncedFolder() }
.filter { it.isFile && it.shouldSyncBookFile() }
.mapNotNull { file ->
val type = SharedFileCapabilities.fileTypeForName(file.name)
.takeIf { it in desktopSyncableTypes }
?: return@mapNotNull null
val relativePath = runCatching {
rootPath.relativize(file.toPath().toAbsolutePath().normalize())
.joinToString("/")
}.getOrNull() ?: file.name
SharedFolderScannedFile(
name = file.name,
path = file.absolutePath,
sourceFolder = sourceFolder,
relativePath = relativePath,
type = type,
size = file.length(),
lastModified = file.lastModified()
)
}
.toList()
}
private fun readAllMetadata(root: File): Map<String, SharedFolderBookMetadata> {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
if (!syncDir.isDirectory) return emptyMap()
return syncDir.listFiles().orEmpty()
.asSequence()
.filter { it.isFile }
.mapNotNull { file -> file.metadataBookIdOrNull()?.let { it to file } }
.groupBy({ it.first }, { it.second })
.mapNotNull { (bookId, files) ->
val best = files
.mapNotNull { file ->
runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull()
}
.filter { it.bookId == bookId }
.maxByOrNull { it.lastModifiedTimestamp }
best?.let { bookId to it }
}
.toMap()
}
private fun saveMetadataToFolder(root: File, metadata: SharedFolderBookMetadata) {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
val existing = resolveMetadataConflicts(syncDir, metadata.bookId, cleanup = true)
if (existing != null && existing.lastModifiedTimestamp > metadata.lastModifiedTimestamp) return
val target = File(syncDir, ".${metadata.bookId}.json")
val temp = File(syncDir, ".${metadata.bookId}.tmp")
runCatching {
temp.writeText(metadata.toJsonString())
moveReplacing(temp, target)
}.onFailure {
runCatching { temp.delete() }
}
}
private fun resolveMetadataConflicts(
syncDir: File,
bookId: String,
cleanup: Boolean
): SharedFolderBookMetadata? {
val candidates = syncDir.listFiles().orEmpty().filter { file ->
val normalized = file.name.removePrefix(".")
file.isFile && (
normalized == "$bookId.json" ||
normalized.startsWith("$bookId.sync-conflict") ||
normalized.startsWith("$bookId.json.sync-conflict")
)
}
if (candidates.isEmpty()) return null
val parsed = candidates.mapNotNull { file ->
val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull()
metadata?.takeIf { it.bookId == bookId }?.let { file to it }
}
val winner = parsed.maxByOrNull { it.second.lastModifiedTimestamp } ?: return null
if (cleanup) {
candidates
.filterNot { it == winner.first }
.forEach { runCatching { it.delete() } }
val correctName = ".${bookId}.json"
if (winner.first.name != correctName) {
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
}
}
return winner.second
}
private fun preloadAnnotationSidecars(root: File): Map<String, AnnotationSidecar> {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
if (!syncDir.isDirectory) return emptyMap()
return syncDir.listFiles().orEmpty()
.asSequence()
.filter { it.isFile }
.mapNotNull { file -> file.annotationBookIdOrNull()?.let { it to file } }
.groupBy({ it.first }, { it.second })
.mapNotNull { (bookId, files) ->
val best = files
.mapNotNull { it.readAnnotationSidecarOrNull() }
.maxByOrNull { it.timestamp }
best?.let { bookId to it }
}
.toMap()
}
private fun importAnnotationSidecars(root: File, books: List<BookItem>) {
if (books.isEmpty()) return
val sidecars = preloadAnnotationSidecars(root)
if (sidecars.isEmpty()) return
books.forEach { book ->
val path = book.path?.takeIf { it.isNotBlank() } ?: return@forEach
if (book.type != FileType.PDF) return@forEach
val sidecar = sidecars[book.id] ?: return@forEach
val annotationFile = desktopPdfAnnotationFile(path)
val bookmarkFile = desktopPdfBookmarkFile(path)
val richTextFile = desktopPdfRichTextFile(path)
val localTimestamp = maxOf(
annotationFile.lastModifiedIfFile(),
bookmarkFile.lastModifiedIfFile(),
richTextFile.lastModifiedIfFile()
)
if (sidecar.timestamp <= localTimestamp + 1000L) {
if (sidecar.data.containsKey("text") || richTextFile.isFile) {
SharedPdfRichTextLog.d(
"desktop.sync.importSkipOlder book=${book.id} sidecarTs=${sidecar.timestamp} " +
"localTs=$localTimestamp hasSidecarText=${sidecar.data.containsKey("text")} " +
"richFile=\"${richTextFile.absolutePath.richSyncPreview()}\""
)
}
return@forEach
}
if (sidecar.data.hasPdfAnnotationPayload()) {
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(sidecar.data)
annotationFile.parentFile?.mkdirs()
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
annotationFile.setLastModified(sidecar.timestamp)
}
sidecar.data["bookmarks"]?.let { bookmarks ->
bookmarkFile.parentFile?.mkdirs()
bookmarkFile.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), bookmarks))
bookmarkFile.setLastModified(sidecar.timestamp)
}
sidecar.data["text"]?.let { richText ->
val richDocument = SharedPdfRichTextSerializer.decodeElement(richText)
SharedPdfRichTextLog.d(
"desktop.sync.importRichText book=${book.id} timestamp=${sidecar.timestamp} " +
"textLen=${richDocument.text.length} spans=${richDocument.spans.size} " +
"file=\"${richTextFile.absolutePath.richSyncPreview()}\""
)
richTextFile.parentFile?.mkdirs()
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
richTextFile.setLastModified(sidecar.timestamp)
}
}
}
private fun saveAnnotationSidecar(
root: File,
bookId: String,
jsonPayload: String,
timestamp: Long
) {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
val data = desktopFolderSyncJson.parseElementOrNull(jsonPayload)?.jsonObjectOrNull() ?: return
val existing = resolveAnnotationConflicts(syncDir, bookId, cleanup = true)
if (existing != null && existing.timestamp >= timestamp) {
if (data.containsKey("text")) {
SharedPdfRichTextLog.d(
"desktop.sync.saveSidecarSkipExisting book=$bookId existingTs=${existing.timestamp} " +
"candidateTs=$timestamp targetRoot=\"${root.absolutePath.richSyncPreview()}\""
)
}
return
}
val wrapper = JsonObject(
mapOf(
"version" to JsonPrimitive(1),
"timestamp" to JsonPrimitive(timestamp),
"data" to data
)
)
val target = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json")
val temp = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.tmp")
runCatching {
temp.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), wrapper))
moveReplacing(temp, target)
if (data.containsKey("text")) {
SharedPdfRichTextLog.d(
"desktop.sync.saveSidecar book=$bookId timestamp=$timestamp " +
"target=\"${target.absolutePath.richSyncPreview()}\""
)
}
}.onFailure {
if (data.containsKey("text")) {
SharedPdfRichTextLog.d(
"desktop.sync.saveSidecarFailed book=$bookId timestamp=$timestamp " +
"target=\"${target.absolutePath.richSyncPreview()}\" error=${it.message}"
)
}
runCatching { temp.delete() }
}
}
private fun resolveAnnotationConflicts(
syncDir: File,
bookId: String,
cleanup: Boolean
): AnnotationSidecar? {
val candidates = syncDir.listFiles().orEmpty().filter { file ->
file.isFile && file.annotationBookIdOrNull() == bookId
}
if (candidates.isEmpty()) return null
val parsed = candidates.mapNotNull { file ->
file.readAnnotationSidecarOrNull()?.let { file to it }
}
val winner = parsed.maxByOrNull { it.second.timestamp } ?: return null
if (cleanup) {
candidates
.filterNot { it == winner.first }
.forEach { runCatching { it.delete() } }
val correctName = ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json"
if (winner.first.name != correctName) {
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
}
}
return winner.second
}
}
private data class AnnotationSidecar(
val timestamp: Long,
val data: JsonObject
)
private val desktopFolderSyncJson = Json {
ignoreUnknownKeys = true
prettyPrint = true
encodeDefaults = true
}
private fun File.shouldEnterSyncedFolder(): Boolean {
if (!isDirectory) return false
if (name == LOCAL_FOLDER_SYNC_DATA_DIR) return false
if (name.startsWith(".")) return false
return runCatching { !isHidden }.getOrDefault(true)
}
private fun File.shouldSyncBookFile(): Boolean {
if (name.startsWith(".")) return false
if (extension.equals("json", ignoreCase = true)) return false
return parentFile?.name != LOCAL_FOLDER_SYNC_DATA_DIR
}
private fun File.metadataBookIdOrNull(): String? {
val fileName = name
if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null
if (fileName.endsWith(".tmp") || fileName.contains(".syncthing.")) return null
if (!fileName.endsWith(".json") && !fileName.contains(".sync-conflict")) return null
val normalized = fileName.removePrefix(".")
val base = if (normalized.contains(".sync-conflict")) {
normalized.substringBefore(".sync-conflict")
} else {
normalized.substringBeforeLast(".json")
}
return base.removeSuffix(".json").takeIf { it.isNotBlank() }
}
private fun File.annotationBookIdOrNull(): String? {
var candidate = name
if (!candidate.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null
if (!candidate.endsWith(".json") || candidate.endsWith(".tmp")) return null
if (candidate.contains(".syncthing.")) return null
if (candidate.contains(".sync-conflict")) {
candidate = candidate.substringBefore(".sync-conflict")
}
candidate = candidate.substringBeforeLast(".json")
if (candidate.endsWith(LOCAL_FOLDER_ANNOTATION_SUFFIX)) {
candidate = candidate.substring(0, candidate.length - LOCAL_FOLDER_ANNOTATION_SUFFIX.length)
}
return candidate.removePrefix(".").takeIf { it.isNotBlank() }
}
private fun File.readAnnotationSidecarOrNull(): AnnotationSidecar? {
return runCatching {
val root = desktopFolderSyncJson.parseToJsonElement(readText()).jsonObject
val timestamp = root["timestamp"]?.jsonPrimitive?.longOrNull ?: 0L
val data = root["data"]?.jsonObjectOrNull() ?: error("Missing annotation sidecar data")
AnnotationSidecar(timestamp = timestamp, data = data)
}.getOrNull()
}
private fun Json.parseElementOrNull(raw: String): JsonElement? {
return runCatching { parseToJsonElement(raw) }.getOrNull()
}
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
if (this is JsonNull) return null
return runCatching { jsonObject }.getOrNull()
}
private fun JsonObject.hasPdfAnnotationPayload(): Boolean {
return containsKey(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) ||
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_INK) ||
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_TEXT_BOXES) ||
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_HIGHLIGHTS)
}
private fun File.canonicalOrAbsolute(): File {
return runCatching { canonicalFile }.getOrElse { absoluteFile }
}
private fun File.lastModifiedIfFile(): Long {
return if (isFile) lastModified() else 0L
}
private fun String.richSyncPreview(maxLength: Int = 160): String {
return replace(Regex("\\s+"), " ")
.trim()
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
.replace("\"", "\\\"")
}
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

@ -0,0 +1,190 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.opds.OpdsAcquisition
import com.aryan.reader.shared.opds.OpdsCatalog
import com.aryan.reader.shared.opds.OpdsEntry
import com.aryan.reader.shared.opds.OpdsFeed
import com.aryan.reader.shared.opds.SharedOpdsCatalogs
import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer
import com.aryan.reader.shared.opds.SharedOpdsParser
import com.aryan.reader.shared.opds.SharedOpdsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.net.Authenticator
import java.net.PasswordAuthentication
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
import java.util.UUID
internal class DesktopOpdsRepository(
private val catalogFile: File = defaultCatalogFile(),
private val idFactory: () -> String = { UUID.randomUUID().toString() }
) : SharedOpdsRepository {
private val parser = SharedOpdsParser()
override fun loadCatalogs(): List<OpdsCatalog> {
val rawJson = catalogFile.takeIf { it.exists() }?.readText()
val decodedCatalogs = SharedOpdsCatalogs.decode(rawJson)
val catalogs = decodedCatalogs.ifEmpty { SharedOpdsCatalogs.defaultCatalogs(idFactory) }
if (decodedCatalogs.isEmpty()) saveCatalogs(catalogs)
return catalogs
}
override fun saveCatalogs(catalogs: List<OpdsCatalog>) {
catalogFile.parentFile?.mkdirs()
catalogFile.writeText(SharedOpdsCatalogs.encode(catalogs))
}
override suspend fun fetchFeed(url: String, username: String?, password: String?): Result<OpdsFeed> = withContext(Dispatchers.IO) {
runCatching {
val response = DesktopOpdsHttp.fetchString(url, username, password)
if (response.statusCode !in 200..299) {
error("HTTP ${response.statusCode}")
}
if (response.body.isBlank()) error("Empty response body")
parser.parse(response.body, url)
}
}
override suspend fun getSearchTemplate(openSearchUrl: String, username: String?, password: String?): String? = withContext(Dispatchers.IO) {
runCatching {
val response = DesktopOpdsHttp.fetchString(openSearchUrl, username, password)
if (response.statusCode !in 200..299) return@withContext null
parser.extractOpenSearchTemplate(response.body, openSearchUrl)
}.getOrNull()
}
suspend fun downloadBook(
entry: OpdsEntry,
acquisition: OpdsAcquisition,
catalog: OpdsCatalog?,
onProgress: (Float?) -> Unit
): File = withContext(Dispatchers.IO) {
val response = DesktopOpdsHttp.fetchStream(acquisition.url, catalog?.username, catalog?.password)
if (response.statusCode !in 200..299) {
response.body.close()
error("HTTP ${response.statusCode}")
}
val contentLength = response.headers.firstValueAsLong("content-length").orElse(-1L)
val contentDisposition = response.headers.firstValue("content-disposition").orElse(null)
val urlName = runCatching {
URI(acquisition.url).path.substringAfterLast('/').takeIf { it.isNotBlank() }
}.getOrNull()
val extension = SharedOpdsDownloadNamer.resolveExtension(acquisition, contentDisposition, urlName)
val target = uniqueDownloadFile(SharedOpdsDownloadNamer.safeFileStem(entry.title), extension)
response.body.use { input ->
target.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var totalRead = 0L
var lastProgressAt = 0L
while (true) {
val read = input.read(buffer)
if (read < 0) break
if (read > 0) {
output.write(buffer, 0, read)
totalRead += read
if (contentLength > 0) {
val now = System.currentTimeMillis()
if (now - lastProgressAt >= 200L) {
onProgress((totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f))
lastProgressAt = now
}
}
}
}
}
}
onProgress(1f)
target
}
fun catalogById(id: String?): OpdsCatalog? {
if (id.isNullOrBlank()) return null
return loadCatalogs().firstOrNull { it.id == id }
}
private fun uniqueDownloadFile(stem: String, extension: String): File {
val dir = opdsDownloadsDir().apply { mkdirs() }
var candidate = File(dir, "$stem$extension")
var index = 1
while (candidate.exists()) {
candidate = File(dir, "${stem}_$index$extension")
index += 1
}
return candidate
}
companion object {
fun defaultCatalogFile(): File {
return File(DesktopLibraryDatabase.defaultDatabaseFile().parentFile, "opds_catalogs.json")
}
fun opdsDownloadsDir(): File {
return File(DesktopLibraryDatabase.defaultDatabaseFile().parentFile, "opds_downloads")
}
}
}
internal data class DesktopOpdsTextResponse(
val statusCode: Int,
val body: String
)
internal data class DesktopOpdsStreamResponse(
val statusCode: Int,
val headers: java.net.http.HttpHeaders,
val body: java.io.InputStream
)
internal object DesktopOpdsHttp {
fun fetchString(url: String, username: String?, password: String?): DesktopOpdsTextResponse {
val request = request(url).build()
val response = client(username, password).send(request, HttpResponse.BodyHandlers.ofString())
return DesktopOpdsTextResponse(response.statusCode(), response.body().orEmpty())
}
fun fetchStream(url: String, username: String?, password: String?): DesktopOpdsStreamResponse {
val request = request(url).build()
val response = client(username, password).send(request, HttpResponse.BodyHandlers.ofInputStream())
return DesktopOpdsStreamResponse(response.statusCode(), response.headers(), response.body())
}
fun fetchBytes(url: String, catalog: OpdsCatalog?): ByteArray {
val request = request(url).build()
val response = client(catalog?.username, catalog?.password).send(request, HttpResponse.BodyHandlers.ofByteArray())
if (response.statusCode() !in 200..299) {
error("HTTP ${response.statusCode()}")
}
return response.body()
}
private fun request(url: String): HttpRequest.Builder {
return HttpRequest.newBuilder(URI(url.trim()))
.timeout(Duration.ofSeconds(45))
.header("User-Agent", "EpistemeReader/1.0 (Desktop)")
}
private fun client(username: String?, password: String?): HttpClient {
val builder = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.followRedirects(HttpClient.Redirect.NORMAL)
if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
builder.authenticator(
object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication {
return PasswordAuthentication(username, password.toCharArray())
}
}
)
}
return builder.build()
}
}

View file

@ -0,0 +1,19 @@
package com.aryan.reader.desktop
private const val DesktopTtsLogTag = "EpistemeDesktopTts"
internal fun logDesktopTts(message: String) {
println("$DesktopTtsLogTag $message")
}
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+"), " ")
.trim()
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
.replace("\"", "\\\"")
}

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

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()
}
}
}