diff --git a/AGENTS.md b/AGENTS.md
index 600d948..6f0bc26 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -62,39 +62,15 @@
## Build
```bash
-# Required env on this server (proxy + Android SDK)
-export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
-export ANDROID_HOME=/home/dueattendant149/android-sdk
-export ANDROID_SDK_ROOT=/home/dueattendant149/android-sdk
./gradlew :app:assembleOssDebug
```
-**Known build workarounds (Phase 1 baseline):**
-- Gradle wrapper pinned to 8.13 (8.11.1 unavailable behind proxy; 9.5.1 incompatible with AGP 8.9).
-- `externalNativeBuild` temporarily disabled because NDK 27.0.12077973 is requested but only NDK 28.2.x is installed.
-- Network proxy is configured in `gradle.properties` (`systemProp.http[s].proxyHost/Port`).
-
## Status
- Phase 0: clone + package rename — completed
-- Phase 1: KMP → Android-only — completed
-- Phase 2: backend swap (bookshelf-api + ABS) — completed
- - Phase 2.1: bookshelf-api + ABS API services/models, Hilt DI, Retrofit/OkHttp
- - Phase 2.2: BookshelfViewModel + BookshelfLibraryScreen + nav entry
- - Phase 2.3: ServerSettingsScreen (URL/token config)
- - Phase 2.4: download/open ebook from server in reader
- - Phase 2.5: removed OPDS/Gutenberg/LocalFolder/CloudSync code + stubs
-- Phase 3: UI redesign (Myne-style) — completed
- - Material You 3 shape scheme (round cards 8-32dp)
- - Dynamic color (Android 12+ dynamicColorScheme + materialkolor seed)
- - BookshelfLibraryScreen + ServerSettingsScreen redesigned with round cards, CenterAlignedTopAppBar
-- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — completed
- - RSVP engine (RsvpEngine, RsvpTokenizer, RsvpToken, ReaderText)
- - Audio player (AudioPlaybackService, ExoPlayer, MediaSession, PlaybackModule)
- - TTS repository (RemoteTtsRepository + impl wrapping BookshelfApiRepository)
- - Workers (no-op stubs for CacheDownload, ProgressSync, TtsDownload)
-- Phase 5: reader engine integration — completed
- - Checkpoint model (serializable reading position)
- - ChapterDrawer (ModalBottomSheet chapter list)
- - Volume keys + chapter navigation already present
-- Phase 6: build, test, deploy — in progress
\ No newline at end of file
+- Phase 1: KMP → Android-only — pending
+- Phase 2: backend swap (bookshelf-api + ABS) — pending
+- Phase 3: UI redesign (Myne-style) — pending
+- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — pending
+- Phase 5: reader engine integration — pending
+- Phase 6: build, test, deploy — pending
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index cbfa2a8..0c234e9 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -11,7 +11,6 @@ plugins {
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.ksp)
- alias(libs.plugins.hilt)
id("com.diffplug.spotless") version "8.2.1"
alias(libs.plugins.kover)
}
@@ -47,9 +46,7 @@ fun configuredAppLocaleTags(): Set {
}
kotlin {
- jvmToolchain {
- languageVersion.set(JavaLanguageVersion.of(21))
- }
+ jvmToolchain(21)
}
android {
@@ -67,13 +64,11 @@ android {
.map { it.toAndroidResourceConfiguration() }
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
-/*
externalNativeBuild {
cmake {
cppFlags += ""
}
}
-*/
buildConfigField("boolean", "IS_PRO", "false")
buildConfigField("boolean", "IS_OFFLINE", "false")
}
@@ -167,14 +162,12 @@ android {
singleVariant("release") {
}
}
-/*
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
-*/
testOptions {
unitTests.isReturnDefaultValues = true
unitTests.all {
@@ -218,6 +211,8 @@ kover {
//noinspection UseTomlInstead
dependencies {
+ implementation(project(":shared"))
+
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
@@ -229,16 +224,6 @@ dependencies {
implementation(libs.androidx.material3.window.size.class1.android)
implementation(libs.androidx.credentials)
- // Hilt
- implementation(libs.hilt.android)
- ksp(libs.hilt.compiler)
- implementation(libs.hilt.navigation.compose)
-
- // Networking
- implementation(libs.retrofit)
- implementation(libs.retrofit.kotlinx.serialization)
- implementation(libs.okhttp.logging)
-
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt
index 8429f96..dd9963e 100644
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt
@@ -52,8 +52,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.platform.LocalContext
-import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
@@ -61,7 +59,6 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
-import org.dueattendant149.bookreader.bookshelf.BookshelfLibraryScreen
import org.dueattendant149.bookreader.epubreader.EpubReaderScreen
import org.dueattendant149.bookreader.feedback.FeedbackScreen
import org.dueattendant149.bookreader.feedback.SupportProjectScreen
@@ -82,8 +79,6 @@ object AppDestinations {
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
- const val BOOKSHELF_LIBRARY_ROUTE = "bookshelf_library"
- const val SERVER_SETTINGS_ROUTE = "server_settings"
}
fun shouldInterceptAppNavBack(
@@ -432,34 +427,6 @@ fun AppNavigation(
onBackClick = { navController.popBackStackIfReady() }
)
}
-
- composable(route = AppDestinations.BOOKSHELF_LIBRARY_ROUTE) {
- val bookshelfViewModel: org.dueattendant149.bookreader.bookshelf.BookshelfViewModel = hiltViewModel()
- LaunchedEffect(bookshelfViewModel) {
- bookshelfViewModel.downloadedFile.collect { uri ->
- Timber.d("Downloaded book $uri, opening in reader")
- viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = false)
- }
- }
- BookshelfLibraryScreen(
- viewModel = bookshelfViewModel,
- onItemClick = { item ->
- Timber.d("Bookshelf item selected: ${item.id}")
- bookshelfViewModel.downloadBook(item)
- },
- onOpenSettings = {
- navController.navigateIfReady(AppDestinations.SERVER_SETTINGS_ROUTE)
- }
- )
- }
-
- composable(route = AppDestinations.SERVER_SETTINGS_ROUTE) {
- val settingsViewModel: org.dueattendant149.bookreader.bookshelf.ServerSettingsViewModel = hiltViewModel()
- org.dueattendant149.bookreader.bookshelf.ServerSettingsScreen(
- viewModel = settingsViewModel,
- onBackClick = { navController.popBackStackIfReady() }
- )
- }
}
AnimatedVisibility(
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt
new file mode 100644
index 0000000..198c806
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt
@@ -0,0 +1,70 @@
+package org.dueattendant149.bookreader
+
+import android.util.Log
+import org.dueattendant149.bookreader.data.BookMetadata
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.effectiveAnnotationModifiedTimestamp
+import org.dueattendant149.bookreader.data.effectiveReadingPositionModifiedTimestamp
+import timber.log.Timber
+
+internal const val CloudSyncTraceTag = "EpistemeCloudSync"
+internal const val CloudAnnotationSyncTraceTag = "EpistemeCloudAnnotations"
+
+internal fun logCloudSyncTrace(message: () -> String) {
+ if (!BuildConfig.DEBUG) return
+ val text = message()
+ Log.d(CloudSyncTraceTag, text)
+ Timber.tag(CloudSyncTraceTag).d(text)
+}
+
+internal fun logCloudSyncError(error: Throwable, message: () -> String) {
+ if (!BuildConfig.DEBUG) return
+ val text = message()
+ Log.e(CloudSyncTraceTag, text, error)
+ Timber.tag(CloudSyncTraceTag).e(error, text)
+}
+
+internal fun logCloudAnnotationSyncTrace(message: () -> String) {
+ if (!BuildConfig.DEBUG) return
+ val text = message()
+ Log.d(CloudAnnotationSyncTraceTag, text)
+ Timber.tag(CloudAnnotationSyncTraceTag).d(text)
+}
+
+internal fun logCloudAnnotationSyncError(error: Throwable, message: () -> String) {
+ if (!BuildConfig.DEBUG) return
+ val text = message()
+ Log.e(CloudAnnotationSyncTraceTag, text, error)
+ Timber.tag(CloudAnnotationSyncTraceTag).e(error, text)
+}
+
+internal fun RecentFileItem.cloudSyncTraceSummary(prefix: String = "local"): String {
+ return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
+ "contentTs=$fileContentModifiedTimestamp " +
+ "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
+ "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
+ "bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
+}
+
+internal fun BookMetadata.cloudSyncTraceSummary(prefix: String = "remote"): String {
+ return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
+ "annTs=${effectiveAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
+ "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
+ "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
+ "hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
+ "highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
+}
+
+internal fun String?.cloudSyncPreview(maxLength: Int = 80): String {
+ val value = this ?: return "null"
+ return if (value.length <= maxLength) value else value.take(maxLength) + "..."
+}
+
+internal fun String?.cloudSyncAnnotationSummary(): String {
+ val value = this?.trim() ?: return "null"
+ return when {
+ value.isEmpty() -> "blank"
+ value == "[]" -> "empty"
+ else -> "present(${value.length})"
+ }
+}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTraceStub.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTraceStub.kt
deleted file mode 100644
index 5cd4c5f..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTraceStub.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.dueattendant149.bookreader
-
-/**
- * Stubs for removed CloudSyncTrace.kt. All calls are no-ops.
- */
-
-internal inline fun logCloudSyncTrace(crossinline message: () -> String) {
- // no-op: cloud sync removed
-}
-
-internal inline fun logCloudSyncError(throwable: Throwable, crossinline message: () -> String) {
- // no-op: cloud sync removed
-}
-
-internal inline fun logCloudSyncError(crossinline message: () -> String) {
- // no-op: cloud sync removed
-}
-
-internal inline fun logCloudAnnotationSyncTrace(crossinline message: () -> String) {
- // no-op: cloud annotation sync removed
-}
-
-internal inline fun logCloudAnnotationSyncError(throwable: Throwable, crossinline message: () -> String) {
- // no-op: cloud annotation sync removed
-}
-
-internal fun Any?.cloudSyncTraceSummary(label: String = ""): String = ""
-
-internal fun Any?.cloudSyncAnnotationSummary(): String = ""
-
-internal fun Any?.cloudSyncPreview(): String = this?.toString() ?: ""
-
-internal fun String.cloudSyncPreview(): String = this
-
-internal fun String.cloudSyncPreview(maxLen: Int): String = take(maxLen)
-
-internal fun String?.cloudSyncPreviewOrEmpty(): String = this ?: ""
\ No newline at end of file
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt
new file mode 100644
index 0000000..e4e67cd
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt
@@ -0,0 +1,773 @@
+/*
+ * Episteme Reader - A native Android document reader.
+ * Copyright (C) 2026 Episteme
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * mail: epistemereader@gmail.com
+ */
+// FolderSyncWorker.kt
+package org.dueattendant149.bookreader
+
+import android.content.Context
+import timber.log.Timber
+import androidx.core.net.toUri
+import androidx.documentfile.provider.DocumentFile
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkerParameters
+import androidx.work.WorkManager
+import org.dueattendant149.bookreader.data.RecentFileItem
+import org.dueattendant149.bookreader.data.RecentFilesRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import androidx.core.content.edit
+import org.dueattendant149.bookreader.data.LocalSyncUtils
+import org.dueattendant149.bookreader.data.FolderBookMetadata
+import org.dueattendant149.bookreader.data.toSharedFolderBookMetadata
+import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem
+import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
+import org.dueattendant149.bookreader.shared.EpubBookmark
+import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
+import org.dueattendant149.bookreader.shared.LocalFolderSyncEngine
+import org.dueattendant149.bookreader.shared.ReaderLocator
+import org.dueattendant149.bookreader.shared.SharedFolderScannedFile
+import org.dueattendant149.bookreader.shared.SharedReaderScreenState
+import org.dueattendant149.bookreader.shared.reader.ReaderBookmark
+import java.io.File
+import android.provider.DocumentsContract
+
+class FolderSyncWorker(
+ private val appContext: Context,
+ workerParams: WorkerParameters
+) : CoroutineWorker(appContext, workerParams) {
+
+ private val recentFilesRepository = RecentFilesRepository(appContext)
+
+ companion object {
+ const val WORK_NAME = "FolderSyncWorker"
+ const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime"
+ const val KEY_METADATA_ONLY = "key_metadata_only"
+ const val KEY_TARGET_FOLDER_URI = "key_target_folder_uri"
+ private val syncMutex = Mutex()
+ }
+
+ override suspend fun doWork(): Result {
+ val workerStart = ReaderPerfLog.nowNanos()
+ val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
+ val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
+ val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
+
+ val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null)
+ val folders = SyncedFolderPrefs.decodeSyncedFolders(
+ jsonString = jsonString,
+ legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
+ syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
+ )
+
+ if (folders.isEmpty()) {
+ ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
+ return Result.success()
+ }
+
+ val enabledFolders = folders.filter { it.localSyncEnabled }
+ val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
+ enabledFolders
+ } else {
+ enabledFolders.filter { it.uriString == targetFolderUri }
+ }
+
+ if (foldersToProcess.isEmpty()) {
+ ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri")
+ return Result.success()
+ }
+
+ ReaderPerfLog.d(
+ "FolderSync worker start folders=${foldersToProcess.size}/${folders.size} " +
+ "target=${targetFolderUri ?: "ALL"} metadataOnly=$isMetadataOnly"
+ )
+
+ return withContext(Dispatchers.IO) {
+ syncMutex.withLock {
+ var allSuccess = true
+
+ for (folderConfig in foldersToProcess) {
+ val success = performSyncForFolder(folderConfig, isMetadataOnly)
+ if (!success) allSuccess = false
+ }
+
+ if (jsonString != null) {
+ try {
+ val array = org.json.JSONArray(jsonString)
+ val now = System.currentTimeMillis()
+ val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString }
+ for (i in 0 until array.length()) {
+ val obj = array.getJSONObject(i)
+ if (obj.optString("uri") in processedUris) {
+ obj.put("lastScanTime", now)
+ }
+ }
+ prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) }
+ } catch (_: Exception) {}
+ }
+
+ val elapsed = ReaderPerfLog.elapsedMs(workerStart)
+ ReaderPerfLog.i(
+ "FolderSync worker finished status=${if (allSuccess) "success" else "failure"} " +
+ "folders=${foldersToProcess.size} elapsed=${elapsed}ms"
+ )
+
+ if (allSuccess) Result.success() else Result.failure()
+ }
+ }
+ }
+
+ private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean {
+ val folderUriString = folderConfig.uriString
+ val allowedFileTypes = folderConfig.allowedFileTypes
+ if (folderUriString.isBlank()) return true
+ val folderUri = folderUriString.toUri()
+ val folderStart = ReaderPerfLog.nowNanos()
+ var dirsScanned = 0
+ var filesSeen = 0
+ var supportedBooksSeen = 0
+ var dbFlushes = 0
+ var sidecarsImported = 0
+ var stoppedForUnlinkedFolder = false
+
+ try {
+ if (!isFolderStillLinked(folderUriString)) {
+ ReaderPerfLog.w("FolderSync folder skipped: no longer linked folder=$folderUriString")
+ return true
+ }
+
+ try {
+ appContext.contentResolver.takePersistableUriPermission(
+ folderUri,
+ android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+ } catch (_: SecurityException) {
+ return false
+ }
+
+ val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
+ if (documentTree == null || !documentTree.isDirectory) {
+ return false
+ }
+
+ ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration mapped-to-shared")
+
+ val folderMetadataMap = ReaderPerfLog.measureSuspend(
+ name = "FolderSync phase metadata-sidecars",
+ minLogMs = 25L,
+ details = { "metadataOnly=$metadataOnly" }
+ ) {
+ LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
+ }
+ ReaderPerfLog.d(
+ "FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString"
+ )
+
+ val existingFolderBooks = ReaderPerfLog.measureSuspend(
+ name = "FolderSync phase load-existing-db",
+ minLogMs = 25L
+ ) {
+ recentFilesRepository.getFilesBySourceFolder(folderUriString)
+ }
+ val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap()
+
+ val scanResult = if (metadataOnly) {
+ AndroidFolderScanResult()
+ } else {
+ ReaderPerfLog.measureSuspend(
+ name = "FolderSync phase scan-folder",
+ minLogMs = 25L
+ ) {
+ scanFolderFiles(
+ folderUri = folderUri,
+ folderUriString = folderUriString,
+ allowedFileTypes = allowedFileTypes
+ )
+ }
+ }
+ dirsScanned = scanResult.dirsScanned
+ filesSeen = scanResult.filesSeen
+ supportedBooksSeen = scanResult.files.size
+ stoppedForUnlinkedFolder = scanResult.stoppedForUnlinkedFolder
+
+ if (isStopped || stoppedForUnlinkedFolder) {
+ ReaderPerfLog.w(
+ "FolderSync folder aborted before shared engine stopped=$isStopped " +
+ "unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
+ )
+ return true
+ }
+
+ val nowMillis = System.currentTimeMillis()
+ val folder = SyncedFolder(
+ uriString = folderUriString,
+ name = documentTree.name ?: folderConfig.name,
+ lastScanTime = nowMillis,
+ allowedFileTypes = allowedFileTypes,
+ localSyncEnabled = true
+ )
+ val sharedState = SharedReaderScreenState(
+ rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
+ syncedFolders = listOf(folder)
+ )
+ val syncResult = LocalFolderSyncEngine.syncFolder(
+ state = sharedState,
+ folder = folder,
+ files = scanResult.files,
+ remoteMetadata = folderMetadataMap.mapValues { it.value.toSharedFolderBookMetadata() },
+ nowMillis = nowMillis,
+ metadataOnly = metadataOnly
+ )
+
+ if (syncResult.idMigrations.isNotEmpty()) {
+ val preloadedSidecars = ReaderPerfLog.measureSuspend(
+ name = "FolderSync phase migration-sidecars",
+ minLogMs = 25L
+ ) {
+ LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri).toMutableMap()
+ }
+ syncResult.idMigrations.forEach { (oldId, newId) ->
+ Timber.tag("FolderSync").i("Migrating folder book ID via shared engine $oldId -> $newId")
+ migrateFolderBookId(
+ folderUriString = folderUriString,
+ oldId = oldId,
+ newId = newId,
+ folderMetadataMap = folderMetadataMap,
+ preloadedSidecars = preloadedSidecars,
+ existingItemsMap = existingItemsMap
+ )
+ }
+ }
+
+ if (!isFolderStillLinked(folderUriString)) {
+ ReaderPerfLog.w("FolderSync folder abort: folder unlinked before DB write folder=$folderUriString")
+ stoppedForUnlinkedFolder = true
+ return true
+ }
+
+ val scannedFilesById = scanResult.files.associateBy { it.stableBookId }
+ val syncedItems = syncResult.state.rawLibraryBooks.map { book ->
+ val existing = existingItemsMap[book.id]
+ val metadata = appliedMetadataFor(
+ book = book,
+ existing = existing,
+ metadata = folderMetadataMap[book.id]
+ )
+ book.toFolderSyncRecentFileItem(
+ existing = existing,
+ appliedMetadata = metadata,
+ scannedFile = scannedFilesById[book.id],
+ nowMillis = nowMillis
+ )
+ }
+ val changedItems = syncedItems.filter { item -> existingItemsMap[item.bookId] != item }
+
+ changedItems
+ .filter { item ->
+ val previous = existingItemsMap[item.bookId]
+ previous != null && folderFileContentChanged(previous, item)
+ }
+ .forEach { item ->
+ Timber.tag("FolderSync").i("File content changed for ${item.displayName}; refreshing extracted metadata.")
+ recentFilesRepository.clearLocalCachesForBook(item.bookId)
+ }
+
+ if (changedItems.isNotEmpty()) {
+ recentFilesRepository.addRecentFiles(changedItems)
+ dbFlushes++
+ }
+
+ if (!metadataOnly && syncResult.removedBookIds.isNotEmpty()) {
+ Timber.tag("FolderSync").i("Cleaning up ${syncResult.removedBookIds.size} missing folder books.")
+ recentFilesRepository.deleteFilePermanently(syncResult.removedBookIds.toList())
+ }
+
+ val booksForAnnotationSync = if (metadataOnly) {
+ syncedItems
+ } else {
+ ReaderPerfLog.measureSuspend(
+ name = "FolderSync phase load-post-scan-db",
+ minLogMs = 25L
+ ) {
+ recentFilesRepository.getFilesBySourceFolder(folderUriString)
+ }
+ }
+ sidecarsImported += importAnnotationSidecarsForBooks(
+ folderUri = folderUri,
+ folderUriString = folderUriString,
+ books = booksForAnnotationSync,
+ phase = if (metadataOnly) "metadata-only" else "post-scan"
+ )
+
+ val elapsed = ReaderPerfLog.elapsedMs(folderStart)
+ ReaderPerfLog.i(
+ "FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " +
+ "dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " +
+ "new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " +
+ "remoteUpdates=${syncResult.stats.remoteMetadataUpdates} unchanged=${syncResult.stats.unchangedBooks} " +
+ "removed=${syncResult.stats.removedBooks} migrated=${syncResult.stats.migratedBooks} " +
+ "dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " +
+ "unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
+ )
+
+ if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
+ if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
+ ReaderPerfLog.i("FolderSync enqueue metadata extraction folder=$folderUriString")
+ val metaRequest = OneTimeWorkRequestBuilder()
+ .setInputData(
+ androidx.work.Data.Builder()
+ .putString(MetadataExtractionWorker.KEY_SOURCE_FOLDER_URI, folderUriString)
+ .build()
+ )
+ .build()
+ WorkManager.getInstance(appContext).enqueueUniqueWork(
+ MetadataExtractionWorker.WORK_NAME,
+ ExistingWorkPolicy.REPLACE,
+ metaRequest
+ )
+ } else {
+ ReaderPerfLog.d("FolderSync metadata extraction skipped: no pending books folder=$folderUriString")
+ }
+ }
+
+ return true
+
+ } catch (e: Exception) {
+ Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.")
+ return false
+ }
+ }
+
+ private suspend fun importAnnotationSidecarsForBooks(
+ folderUri: android.net.Uri,
+ folderUriString: String,
+ books: List,
+ phase: String
+ ): Int {
+ if (books.isEmpty()) {
+ ReaderPerfLog.d("FolderSync phase annotation-sidecars skipped phase=$phase reason=no-books folder=$folderUriString")
+ return 0
+ }
+
+ val preloadedSidecars = ReaderPerfLog.measureSuspend(
+ name = "FolderSync phase annotation-sidecars",
+ minLogMs = 25L,
+ details = { "phase=$phase" }
+ ) {
+ LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri)
+ }
+
+ ReaderPerfLog.d(
+ "FolderSync annotation-sidecars records=${preloadedSidecars.size} books=${books.size} phase=$phase folder=$folderUriString"
+ )
+
+ if (preloadedSidecars.isEmpty()) return 0
+
+ var imported = 0
+ Timber.tag("FolderAnnotationSync").d("Checking annotation sidecars phase=$phase for ${books.size} books...")
+ for (book in books) {
+ if (isStopped || !isFolderStillLinked(folderUriString)) break
+
+ val sidecarData = preloadedSidecars[book.bookId] ?: continue
+ val (remoteTs, jsonPayload) = sidecarData
+
+ val safeSlashBookId = book.bookId.replace("/", "_")
+ val safeRichTextBookId = book.bookId.replace("[^a-zA-Z0-9._-]".toRegex(), "_")
+ val localFiles = listOf(
+ File(appContext.filesDir, "annotations/annotation_$safeSlashBookId.json"),
+ File(appContext.filesDir, "rich_doc_${safeRichTextBookId}.json"),
+ File(appContext.filesDir, "page_layouts/layout_$safeSlashBookId.json"),
+ File(appContext.filesDir, "textboxes/textboxes_$safeSlashBookId.json"),
+ File(appContext.filesDir, "pdf_highlights/highlights_$safeSlashBookId.json")
+ )
+ val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
+
+ if (remoteTs > (localTs + 1000)) {
+ Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
+ recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
+ imported++
+ } else {
+ Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.")
+ }
+ }
+
+ ReaderPerfLog.i(
+ "FolderSync annotation-sidecars imported=$imported records=${preloadedSidecars.size} phase=$phase folder=$folderUriString"
+ )
+ return imported
+ }
+
+ private data class AndroidFolderScanResult(
+ val files: List = emptyList(),
+ val dirsScanned: Int = 0,
+ val filesSeen: Int = 0,
+ val stoppedForUnlinkedFolder: Boolean = false
+ )
+
+ private fun scanFolderFiles(
+ folderUri: android.net.Uri,
+ folderUriString: String,
+ allowedFileTypes: Set
+ ): AndroidFolderScanResult {
+ Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...")
+ val contentResolver = appContext.contentResolver
+ val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
+ val dirQueue = ArrayDeque()
+ val scannedFiles = mutableListOf()
+ var dirsScanned = 0
+ var filesSeen = 0
+ var stoppedForUnlinkedFolder = false
+ dirQueue.add(rootDocId)
+
+ val projection = arrayOf(
+ DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ DocumentsContract.Document.COLUMN_MIME_TYPE,
+ DocumentsContract.Document.COLUMN_SIZE,
+ DocumentsContract.Document.COLUMN_LAST_MODIFIED
+ )
+
+ while (dirQueue.isNotEmpty()) {
+ if (isStopped) break
+ if (!isFolderStillLinked(folderUriString)) {
+ ReaderPerfLog.w("FolderSync folder abort: folder unlinked during scan folder=$folderUriString")
+ stoppedForUnlinkedFolder = true
+ break
+ }
+ val currentDocId = dirQueue.removeFirst()
+ dirsScanned++
+ val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
+
+ try {
+ contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
+ val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
+ val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
+ val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
+ val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
+ val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
+
+ while (cursor.moveToNext() && !isStopped && !stoppedForUnlinkedFolder) {
+ val docId = cursor.getString(idCol)
+ val name = cursor.getString(nameCol) ?: ""
+ val mimeType = cursor.getString(mimeCol)
+ filesSeen++
+
+ if (filesSeen % 100 == 0 && !isFolderStillLinked(folderUriString)) {
+ ReaderPerfLog.w("FolderSync folder abort: folder unlinked after entries=$filesSeen folder=$folderUriString")
+ stoppedForUnlinkedFolder = true
+ break
+ }
+
+ if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
+ if (!name.startsWith(".") && name != LOCAL_FOLDER_SYNC_DATA_DIR) {
+ dirQueue.add(docId)
+ }
+ continue
+ }
+
+ val type = getFileType(name, mimeType)
+ if (
+ type == null ||
+ type !in allowedFileTypes ||
+ !isLocalFolderSyncEligibleFile(name, mimeType) ||
+ name.endsWith(".json") ||
+ name.startsWith(".")
+ ) {
+ continue
+ }
+
+ val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
+ val relativePath = buildRelativePath(rootDocId, docId, name)
+ scannedFiles += SharedFolderScannedFile(
+ name = name,
+ path = docUri.toString(),
+ sourceFolder = folderUriString,
+ relativePath = relativePath,
+ type = type,
+ size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L,
+ lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
+ )
+ }
+ }
+ } catch (e: Exception) {
+ Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
+ }
+
+ if (stoppedForUnlinkedFolder) break
+ }
+
+ return AndroidFolderScanResult(
+ files = scannedFiles,
+ dirsScanned = dirsScanned,
+ filesSeen = filesSeen,
+ stoppedForUnlinkedFolder = stoppedForUnlinkedFolder
+ )
+ }
+
+ private fun RecentFileItem.toFolderSyncSharedBookItem(): SharedBookItem {
+ return SharedBookItem(
+ id = bookId,
+ path = uriString,
+ type = type,
+ displayName = displayName,
+ timestamp = lastModifiedTimestamp,
+ coverImagePath = coverImagePath,
+ title = title,
+ author = author,
+ description = description,
+ originalTitle = originalTitle,
+ originalAuthor = originalAuthor,
+ originalSeriesName = originalSeriesName,
+ originalSeriesIndex = originalSeriesIndex,
+ originalDescription = originalDescription,
+ progressPercentage = progressPercentage,
+ isRecent = isRecent,
+ fileSize = fileSize,
+ fileContentModifiedTimestamp = fileContentModifiedTimestamp,
+ sourceFolder = sourceFolderUri,
+ folderTextMetadataParsed = folderTextMetadataParsed,
+ seriesName = seriesName,
+ seriesIndex = seriesIndex,
+ lastPageIndex = lastPage,
+ readerPosition = readerPositionOrNull(),
+ readerBookmarks = parseReaderBookmarks(),
+ readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson),
+ readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
+ )
+ }
+
+ private fun SharedBookItem.toFolderSyncRecentFileItem(
+ existing: RecentFileItem?,
+ appliedMetadata: FolderBookMetadata?,
+ scannedFile: SharedFolderScannedFile?,
+ nowMillis: Long
+ ): RecentFileItem {
+ val contentChanged = existing != null && folderFileContentChanged(existing, this)
+ val localModifiedTimestamp = when {
+ appliedMetadata != null -> appliedMetadata.lastModifiedTimestamp
+ contentChanged && fileContentModifiedTimestamp > 0L -> fileContentModifiedTimestamp
+ timestamp > 0L -> timestamp
+ else -> nowMillis
+ }
+ val legacyPosition = readerPosition
+ val mappedBookmarksJson = readerBookmarks.toAndroidBookmarksJson(id)
+ val mappedHighlightsJson = readerHighlights
+ .takeIf { it.isNotEmpty() }
+ ?.let(EpubAnnotationSerializer::highlightsToJson)
+ val bookmarksJson = if (appliedMetadata != null || existing == null) {
+ mappedBookmarksJson ?: appliedMetadata?.bookmarksJson ?: existing?.bookmarksJson
+ } else {
+ existing.bookmarksJson
+ }
+ val highlightsJson = if (appliedMetadata != null || existing == null) {
+ mappedHighlightsJson ?: appliedMetadata?.highlightsJson ?: existing?.highlightsJson
+ } else {
+ existing.highlightsJson
+ }
+
+ return RecentFileItem(
+ bookId = id,
+ uriString = path,
+ type = type,
+ displayName = scannedFile?.name ?: existing?.displayName ?: displayName,
+ timestamp = when {
+ existing == null -> timestamp.takeIf { it > 0L } ?: localModifiedTimestamp
+ appliedMetadata?.isRecent == true -> appliedMetadata.lastModifiedTimestamp
+ else -> existing.timestamp
+ },
+ coverImagePath = coverImagePath,
+ title = title,
+ author = author,
+ lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
+ lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
+ lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
+ locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
+ locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
+ progressPercentage = progressPercentage,
+ isRecent = isRecent,
+ isAvailable = true,
+ lastModifiedTimestamp = localModifiedTimestamp,
+ isDeleted = false,
+ bookmarksJson = bookmarksJson,
+ sourceFolderUri = sourceFolder,
+ isReflowPreferred = existing?.isReflowPreferred ?: false,
+ customName = appliedMetadata?.customName ?: existing?.customName,
+ highlightsJson = highlightsJson,
+ fileSize = fileSize,
+ fileContentModifiedTimestamp = fileContentModifiedTimestamp,
+ seriesName = seriesName,
+ seriesIndex = seriesIndex,
+ description = description,
+ originalTitle = originalTitle,
+ originalAuthor = originalAuthor,
+ originalSeriesName = originalSeriesName,
+ originalSeriesIndex = originalSeriesIndex,
+ originalDescription = originalDescription,
+ folderTextMetadataParsed = folderTextMetadataParsed,
+ folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
+ readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
+ tags = existing?.tags.orEmpty()
+ )
+ }
+
+ private fun appliedMetadataFor(
+ book: SharedBookItem,
+ existing: RecentFileItem?,
+ metadata: FolderBookMetadata?
+ ): FolderBookMetadata? {
+ if (metadata == null) return null
+ val existingModified = existing?.lastModifiedTimestamp ?: Long.MIN_VALUE
+ return metadata.takeIf { existing == null || it.lastModifiedTimestamp > existingModified }
+ }
+
+ private fun RecentFileItem.readerPositionOrNull(): ReaderLocator? {
+ if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null
+ return ReaderLocator.fromLegacy(
+ chapterIndex = lastChapterIndex,
+ cfi = lastPositionCfi,
+ pageIndex = lastPage
+ )
+ }
+
+ private fun RecentFileItem.parseReaderBookmarks(): List {
+ return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson)
+ .mapIndexed { index, bookmark ->
+ val locator = bookmark.locator.withFallbacks(
+ chapterIndex = bookmark.chapterIndex,
+ cfi = bookmark.cfi,
+ pageIndex = bookmark.pageInChapter?.minus(1),
+ textQuote = bookmark.snippet
+ )
+ val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0
+ ReaderBookmark(
+ id = "bookmark_${bookId}_$index",
+ pageIndex = pageIndex.coerceAtLeast(0),
+ chapterTitle = bookmark.chapterTitle,
+ preview = bookmark.snippet,
+ locator = locator
+ )
+ }
+ }
+
+ private fun List.toAndroidBookmarksJson(bookId: String): String? {
+ val bookmarks = mapIndexed { index, bookmark ->
+ val locator = bookmark.locator
+ val chapterIndex = locator.chapterIndex ?: 0
+ val cfi = locator.cfi ?: "android:$bookId:$index:${bookmark.pageIndex}"
+ EpubBookmark(
+ cfi = cfi,
+ chapterTitle = bookmark.chapterTitle,
+ label = null,
+ snippet = bookmark.preview,
+ pageInChapter = bookmark.pageIndex + 1,
+ totalPagesInChapter = null,
+ chapterIndex = chapterIndex,
+ locator = locator.withFallbacks(
+ chapterIndex = chapterIndex,
+ cfi = cfi,
+ pageIndex = bookmark.pageIndex,
+ textQuote = bookmark.preview
+ )
+ )
+ }
+ return bookmarks.takeIf { it.isNotEmpty() }?.let(EpubAnnotationSerializer::bookmarksToJson)
+ }
+
+ private fun folderFileContentChanged(previous: RecentFileItem, next: RecentFileItem): Boolean {
+ val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
+ val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
+ previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
+ return sizeChanged || modifiedChanged
+ }
+
+ private fun folderFileContentChanged(previous: RecentFileItem, next: SharedBookItem): Boolean {
+ val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
+ val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
+ previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
+ return sizeChanged || modifiedChanged
+ }
+
+ private fun isFolderStillLinked(folderUriString: String): Boolean {
+ val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
+ return SyncedFolderPrefs.isLocalSyncEnabled(
+ jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
+ legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
+ folderUriString = folderUriString,
+ syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
+ )
+ }
+
+ private fun getFileType(name: String, mimeType: String?): FileType? {
+ return resolveFileTypeFromMetadata(name, mimeType)
+ }
+
+ private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
+ val rootPath = rootDocId.substringAfter(':', "")
+ val docPath = docId.substringAfter(':', "")
+ if (docPath.isBlank()) return fallbackName
+ val relative = if (rootPath.isNotBlank() && docPath.startsWith(rootPath)) {
+ docPath.removePrefix(rootPath).trimStart('/')
+ } else {
+ docPath.substringAfterLast('/', fallbackName)
+ }
+ return relative.ifBlank { fallbackName }
+ }
+
+ private suspend fun migrateFolderBookId(
+ folderUriString: String,
+ oldId: String,
+ newId: String,
+ folderMetadataMap: MutableMap,
+ preloadedSidecars: MutableMap>,
+ existingItemsMap: MutableMap
+ ) {
+ if (oldId == newId) return
+
+ recentFilesRepository.migrateBookIdLocally(oldId, newId)
+
+ val oldMetadata = folderMetadataMap.remove(oldId)
+ if (oldMetadata != null && newId !in folderMetadataMap) {
+ val migratedMetadata = oldMetadata.copy(bookId = newId)
+ LocalSyncUtils.saveMetadataToFolder(appContext, folderUriString.toUri(), migratedMetadata)
+ folderMetadataMap[newId] = migratedMetadata
+ }
+
+ val oldSidecar = preloadedSidecars.remove(oldId)
+ if (oldSidecar != null && newId !in preloadedSidecars) {
+ LocalSyncUtils.saveAnnotationSidecar(
+ context = appContext,
+ sourceFolderUri = folderUriString.toUri(),
+ bookId = newId,
+ jsonPayload = oldSidecar.second,
+ timestamp = oldSidecar.first
+ )
+ preloadedSidecars[newId] = oldSidecar
+ }
+
+ LocalSyncUtils.deleteBookSidecars(appContext, folderUriString.toUri(), oldId)
+
+ existingItemsMap.remove(oldId)
+ recentFilesRepository.getFileByBookId(newId)?.let {
+ existingItemsMap[newId] = it
+ }
+ }
+}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorkerStub.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorkerStub.kt
deleted file mode 100644
index 0e2cd6a..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorkerStub.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package org.dueattendant149.bookreader
-
-import android.content.Context
-import androidx.work.CoroutineWorker
-import androidx.work.WorkerParameters
-
-/**
- * Stub for removed FolderSyncWorker. Keeps constants and a no-op CoroutineWorker
- * so existing WorkManager enqueue calls compile.
- */
-const val KEY_METADATA_ONLY = "metadata_only"
-const val KEY_TARGET_FOLDER_URI = "target_folder_uri"
-const val KEY_TRIGGER_REASON = "trigger_reason"
-
-class FolderSyncWorker(
- appContext: Context,
- params: WorkerParameters,
-) : CoroutineWorker(appContext, params) {
- override suspend fun doWork(): Result = Result.success()
-
- companion object {
- const val WORK_NAME = "folder_sync_work"
- const val WORK_NAME_ONETIME = "folder_sync_work_onetime"
- const val KEY_METADATA_ONLY = "metadata_only"
- const val KEY_TARGET_FOLDER_URI = "target_folder_uri"
- const val KEY_TRIGGER_REASON = "trigger_reason"
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt
index aec1d24..fa79d21 100644
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt
@@ -80,7 +80,6 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
-import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
@@ -144,7 +143,14 @@ import coil.compose.AsyncImage
import coil.decode.SvgDecoder
import org.dueattendant149.bookreader.data.RecentFileItem
import org.dueattendant149.bookreader.data.TagEntity
+import org.dueattendant149.bookreader.opds.OpdsAcquisition
+import org.dueattendant149.bookreader.opds.OpdsCatalog
+import org.dueattendant149.bookreader.opds.OpdsDownloadState
+import org.dueattendant149.bookreader.opds.OpdsEntry
+import org.dueattendant149.bookreader.opds.OpdsRepository
+import org.dueattendant149.bookreader.opds.OpdsViewModel
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
+import org.dueattendant149.bookreader.shared.opds.SharedOpdsLocalBookMatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -186,6 +192,9 @@ fun LibraryScreen(
add(context.getString(R.string.tab_all_books))
add(context.getString(R.string.tab_shelves))
add(context.getString(R.string.tab_folders))
+ if (!BuildConfig.IS_OFFLINE) {
+ add(context.getString(R.string.tab_catalogs))
+ }
}
}
val pagerState = rememberPagerState(
@@ -369,10 +378,17 @@ fun LibraryScreen(
viewModel.showBanner(context.getString(R.string.banner_downloaded, title))
viewModel.onFileSelected(uri, isFromRecent = false)
},
- onStreamOpdsBook = { _, _ -> },
- onDeleteCatalogStreams = { },
+ onStreamOpdsBook = { entry, catalog ->
+ viewModel.streamOpdsBook(
+ bookId = entry.id,
+ title = entry.title,
+ urlTemplate = entry.pseUrlTemplate!!,
+ pageCount = entry.pseCount!!,
+ catalogId = catalog?.id
+ )
+ },
+ onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog,
onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) },
- onBookshelfClick = { navController.navigate(AppDestinations.BOOKSHELF_LIBRARY_ROUTE) },
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
)
@@ -644,10 +660,9 @@ fun LibraryScreenContent(
onRemoveFolderClick: (SyncedFolder) -> Unit,
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onOpdsBookDownloaded: (Uri, String) -> Unit,
- onStreamOpdsBook: (Any, Any?) -> Unit,
+ onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit,
onSettingsClick: () -> Unit,
- onBookshelfClick: () -> Unit,
usePdfFileNameAsDisplayName: Boolean,
) {
val isBookContextualModeActive = selectedItems.isNotEmpty()
@@ -789,9 +804,6 @@ fun LibraryScreenContent(
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
}
}
- IconButton(onClick = onBookshelfClick) {
- Icon(Icons.Default.Cloud, contentDescription = "Server library")
- }
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
}
@@ -947,6 +959,30 @@ fun LibraryScreenContent(
selectedShelves = selectedShelves
)
}
+ 2 -> {
+ FolderSyncScreen(
+ syncedFolders = syncedFolders,
+ allRecentFiles = rawLibraryFiles,
+ onAddFolderClick = onSelectSyncFolderClick,
+ onRemoveFolderClick = onRemoveFolderClick,
+ onFolderLocalSyncChange = onFolderLocalSyncChange,
+ onEditFolderFiltersClick = onEditFolderFiltersClick,
+ onScanNowClick = onScanNowClick,
+ onSyncMetadataClick = onSyncMetadataClick,
+ isLoading = isLoading || isRefreshing
+ )
+ }
+ 3 -> {
+ if (!BuildConfig.IS_OFFLINE) {
+ OpdsTab(
+ localLibraryFiles = rawLibraryFiles,
+ onBookDownloaded = onOpdsBookDownloaded,
+ onReadBook = onItemClick,
+ onStreamBook = onStreamOpdsBook,
+ onDeleteCatalogStreams = onDeleteCatalogStreams
+ )
+ }
+ }
}
}
}
@@ -1979,7 +2015,395 @@ private fun DeleteShelvesConfirmationDialog(
}
@Composable
+private fun FolderSyncScreen(
+ syncedFolders: List,
+ allRecentFiles: List,
+ onAddFolderClick: () -> Unit,
+ onRemoveFolderClick: (SyncedFolder) -> Unit,
+ onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
+ onEditFolderFiltersClick: (SyncedFolder, Set) -> Unit,
+ onScanNowClick: () -> Unit,
+ onSyncMetadataClick: () -> Unit,
+ isLoading: Boolean
+) {
+ var editingFolder by remember { mutableStateOf(null) }
+ var disablingFolder by remember { mutableStateOf(null) }
+ val hasEnabledSyncFolders = syncedFolders.any { it.localSyncEnabled }
+ val folderStatsByUri = remember(allRecentFiles) {
+ allRecentFiles
+ .asSequence()
+ .filter { it.sourceFolderUri != null }
+ .groupBy { it.sourceFolderUri!! }
+ .mapValues { (_, files) ->
+ FolderFileStats(
+ totalBooks = files.size,
+ countsByType = files.groupingBy { it.type }.eachCount()
+ )
+ }
+ }
+
+ Scaffold(
+ floatingActionButton = {
+ if (syncedFolders.size < 10) {
+ ExtendedFloatingActionButton(
+ text = { Text(stringResource(R.string.fab_add_folder)) },
+ icon = { Icon(Icons.Default.Add, "Add") },
+ onClick = onAddFolderClick
+ )
+ }
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ if (syncedFolders.isNotEmpty()) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ FilledTonalButton(
+ onClick = onScanNowClick,
+ enabled = !isLoading && hasEnabledSyncFolders,
+ modifier = Modifier.weight(1f),
+ shape = MaterialTheme.shapes.small
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
+ } else {
+ Icon(Icons.Default.Search, null, modifier = Modifier.size(18.dp))
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(if (isLoading) stringResource(R.string.scanning) else stringResource(R.string.scan_all))
+ }
+
+ androidx.compose.material3.OutlinedButton(
+ onClick = onSyncMetadataClick,
+ enabled = !isLoading && hasEnabledSyncFolders,
+ modifier = Modifier.weight(1f),
+ shape = MaterialTheme.shapes.small
+ ) {
+ Icon(painterResource(id = R.drawable.sync), null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.sync_meta))
+ }
+ }
+ } else {
+ EmptyState(
+ title = stringResource(R.string.sync_local_folders),
+ message = stringResource(R.string.sync_folders_desc),
+ onSelectFileClick = onAddFolderClick,
+ primaryButtonText = stringResource(R.string.action_select_folder),
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ contentPadding = PaddingValues(bottom = 80.dp)
+ ) {
+ items(syncedFolders, key = { it.uriString }) { folder ->
+ FolderCard(
+ folder = folder,
+ stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
+ onRemoveClick = onRemoveFolderClick,
+ onLocalSyncToggleClick = { selectedFolder ->
+ if (selectedFolder.localSyncEnabled) {
+ disablingFolder = selectedFolder
+ } else {
+ onFolderLocalSyncChange(selectedFolder, true, false)
+ }
+ },
+ onEditFiltersClick = { editingFolder = folder }
+ )
+ }
+ }
+ }
+ }
+
+ editingFolder?.let { folder ->
+ EditFolderFiltersDialog(
+ folder = folder,
+ onConfirm = { newFilters ->
+ onEditFolderFiltersClick(folder, newFilters)
+ editingFolder = null
+ },
+ onDismiss = { editingFolder = null }
+ )
+ }
+
+ disablingFolder?.let { folder ->
+ AlertDialog(
+ onDismissRequest = { disablingFolder = null },
+ title = { Text(stringResource(R.string.dialog_disable_folder_local_sync_title)) },
+ text = {
+ Text(
+ stringResource(
+ R.string.dialog_disable_folder_local_sync_desc,
+ LOCAL_FOLDER_SYNC_DATA_DIR
+ )
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onFolderLocalSyncChange(folder, false, true)
+ disablingFolder = null
+ }
+ ) {
+ Text(stringResource(R.string.action_disable_remove_sync_data))
+ }
+ },
+ dismissButton = {
+ Row {
+ TextButton(onClick = { disablingFolder = null }) {
+ Text(stringResource(R.string.action_cancel))
+ }
+ TextButton(
+ onClick = {
+ onFolderLocalSyncChange(folder, false, false)
+ disablingFolder = null
+ }
+ ) {
+ Text(stringResource(R.string.action_disable_keep_sync_data))
+ }
+ }
+ }
+ )
+ }
+}
+
+private data class FolderFileStats(
+ val totalBooks: Int,
+ val countsByType: Map
+) {
+ companion object {
+ val Empty = FolderFileStats(totalBooks = 0, countsByType = emptyMap())
+ }
+}
+
+@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
+@Composable
+private fun FolderCard(
+ folder: SyncedFolder,
+ stats: FolderFileStats,
+ onRemoveClick: (SyncedFolder) -> Unit,
+ onLocalSyncToggleClick: (SyncedFolder) -> Unit,
+ onEditFiltersClick: (SyncedFolder) -> Unit
+) {
+ var showMenu by remember { mutableStateOf(false) }
+ val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
+ val lastScanText = if (folder.lastScanTime == 0L) stringResource(R.string.never) else dateFormat.format(Date(folder.lastScanTime))
+
+ androidx.compose.material3.ElevatedCard(
+ modifier = Modifier.fillMaxWidth(),
+ colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
+ Icon(
+ imageVector = Icons.Default.FolderSpecial,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = folder.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (!folder.localSyncEnabled) {
+ Text(
+ text = stringResource(R.string.folder_local_sync_disabled),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+
+ Box {
+ IconButton(onClick = { showMenu = true }) {
+ Icon(Icons.Default.MoreVert, "Options")
+ }
+ DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_edit_filters)) },
+ onClick = {
+ showMenu = false
+ onEditFiltersClick(folder)
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Text(
+ if (folder.localSyncEnabled) {
+ stringResource(R.string.menu_disable_folder_local_sync)
+ } else {
+ stringResource(R.string.menu_enable_folder_local_sync)
+ }
+ )
+ },
+ onClick = {
+ showMenu = false
+ onLocalSyncToggleClick(folder)
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_remove_folder)) },
+ onClick = {
+ showMenu = false
+ onRemoveClick(folder)
+ },
+ colors = androidx.compose.material3.MenuDefaults.itemColors(
+ textColor = MaterialTheme.colorScheme.error
+ )
+ )
+ }
+ }
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
+
+ Row(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.last_sync),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.Bold
+ )
+ Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
+ }
+
+ Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
+ Text(
+ text = stringResource(R.string.books_count),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.Bold
+ )
+ Text(text = stats.totalBooks.toString(), style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+
+ if (stats.countsByType.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(12.dp))
+ androidx.compose.foundation.layout.FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ stats.countsByType.forEach { (type, count) ->
+ AssistChip(
+ onClick = { },
+ label = { Text(stringResource(R.string.folder_filter_count, type.name, count)) }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
+@Composable
+private fun EditFolderFiltersDialog(
+ folder: SyncedFolder,
+ onConfirm: (Set) -> Unit,
+ onDismiss: () -> Unit
+) {
+ var selectedTypes by remember { mutableStateOf(folder.allowedFileTypes) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Column {
+ Text(
+ text = stringResource(R.string.filter_file_types),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = stringResource(R.string.filter_file_types_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ },
+ text = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))
+
+ androidx.compose.foundation.layout.FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ ANDROID_SYNCABLE_FILE_TYPES.forEach { type ->
+ val isSelected = type in selectedTypes
+ FilterChip(
+ selected = isSelected,
+ onClick = {
+ selectedTypes = if (isSelected) {
+ selectedTypes - type
+ } else {
+ selectedTypes + type
+ }
+ },
+ label = {
+ Text(
+ text = type.name,
+ style = MaterialTheme.typography.labelLarge
+ )
+ },
+ leadingIcon = if (isSelected) {
+ {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ } else null,
+ shape = MaterialTheme.shapes.medium
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ androidx.compose.material3.Button(
+ onClick = { onConfirm(selectedTypes) },
+ enabled = selectedTypes.isNotEmpty(),
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Text(stringResource(R.string.action_save))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.action_cancel))
+ }
+ }
+ )
+}
+
@OptIn(ExperimentalMaterial3Api::class)
+@Composable
fun LibraryFilterSheet(
filters: LibraryFilters,
allTags: List,
@@ -2107,3 +2531,889 @@ fun LibraryFilterSheet(
}
}
+@Composable
+fun OpdsTab(
+ localLibraryFiles: List,
+ onBookDownloaded: (Uri, String) -> Unit,
+ onReadBook: (RecentFileItem) -> Unit,
+ onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit,
+ onDeleteCatalogStreams: (String) -> Unit,
+ opdsViewModel: OpdsViewModel = viewModel()
+) {
+ val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
+ val downloadingState = uiState.downloadingState
+ val context = LocalContext.current
+ val coverImageLoader = rememberOpdsCoverImageLoader(uiState.currentCatalog)
+ var selectedEntry by remember { mutableStateOf(null) }
+ var showCatalogDialog by remember { mutableStateOf(false) }
+ var editingCatalog by remember { mutableStateOf(null) }
+ var catalogToDelete by remember { mutableStateOf(null) }
+
+ BackHandler(enabled = uiState.isViewingCatalog) {
+ opdsViewModel.navigateBack()
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ if (!uiState.isViewingCatalog) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ start = 16.dp,
+ end = 16.dp,
+ top = 16.dp,
+ bottom = 88.dp
+ ),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(uiState.catalogs, key = { it.id }) { catalog ->
+ OpdsCatalogCard(
+ catalog = catalog,
+ onClick = { opdsViewModel.openCatalog(catalog) },
+ onEdit = if (catalog.isDefault) null else {
+ {
+ editingCatalog = catalog
+ showCatalogDialog = true
+ }
+ },
+ onDelete = if (catalog.isDefault) null else {
+ { catalogToDelete = catalog }
+ })
+ }
+ }
+
+ ExtendedFloatingActionButton(
+ text = { Text(stringResource(R.string.fab_add_catalog)) },
+ icon = { Icon(Icons.Default.Add, "Add") },
+ onClick = {
+ editingCatalog = null
+ showCatalogDialog = true
+ },
+ modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
+ )
+ }
+ } else {
+ // Screen 2: Viewing a specific feed/catalog
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ Surface(
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 2.dp,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ var showSearch by remember { mutableStateOf(false) }
+ var query by remember { mutableStateOf("") }
+
+ val searchFocusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(showSearch) {
+ if (showSearch) {
+ delay(100)
+ searchFocusRequester.requestFocus()
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().height(64.dp)
+ .padding(horizontal = 4.dp)
+ ) {
+ IconButton(onClick = {
+ if (showSearch) {
+ showSearch = false
+ query = ""
+ } else {
+ opdsViewModel.navigateBack()
+ }
+ }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
+ }
+
+ if (showSearch) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = { query = it },
+ placeholder = { Text(stringResource(R.string.search_catalog_placeholder)) },
+ modifier = Modifier.weight(1f).padding(vertical = 4.dp)
+ .focusRequester(searchFocusRequester),
+ singleLine = true,
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ disabledContainerColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ ),
+ trailingIcon = {
+ IconButton(onClick = {
+ if (query.isNotBlank()) {
+ opdsViewModel.search(query)
+ showSearch = false
+ query = ""
+ }
+ }) {
+ Icon(Icons.Default.Search, "Search")
+ }
+ },
+ keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
+ imeAction = androidx.compose.ui.text.input.ImeAction.Search
+ ),
+ keyboardActions = androidx.compose.foundation.text.KeyboardActions(
+ onSearch = {
+ if (query.isNotBlank()) {
+ opdsViewModel.search(query)
+ showSearch = false
+ query = ""
+ }
+ })
+ )
+ } else {
+ Text(
+ text = uiState.currentFeed?.title ?: stringResource(R.string.status_loading),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
+ )
+ if (uiState.searchUrlTemplate != null) {
+ IconButton(onClick = { showSearch = true }) {
+ Icon(Icons.Default.Search, "Search")
+ }
+ }
+ }
+ }
+
+ if (uiState.isLoading) {
+ androidx.compose.material3.LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter)
+ )
+ }
+ }
+ }
+
+ if (uiState.currentFeed?.entries?.isEmpty() == true && !uiState.isLoading) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(stringResource(R.string.feed_empty))
+ }
+ } else {
+ val facets = uiState.currentFeed?.facets ?: emptyList()
+ if (facets.isNotEmpty()) {
+ val groups = facets.groupBy { it.group }
+ LazyRow(
+ modifier = Modifier.fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ groups.forEach { (groupName, groupFacets) ->
+ item(key = groupName) {
+ var expanded by remember { mutableStateOf(false) }
+ val activeFacet = groupFacets.find { it.isActive }
+ ?: groupFacets.firstOrNull()
+
+ Box {
+ FilterChip(
+ selected = activeFacet?.isActive == true,
+ onClick = { expanded = true },
+ label = { Text(stringResource(R.string.filter_facet, groupName, activeFacet?.title ?: stringResource(R.string.action_select))) },
+ trailingIcon = {
+ Icon(
+ Icons.Default.ArrowDropDown,
+ null
+ )
+ })
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false }) {
+ groupFacets.forEach { facet ->
+ DropdownMenuItem(
+ text = { Text(facet.title) },
+ onClick = {
+ expanded = false
+ opdsViewModel.openFeedUrl(facet.url)
+ },
+ trailingIcon = if (facet.isActive) {
+ { Icon(Icons.Default.Check, null) }
+ } else null)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ val entries = uiState.currentFeed?.entries ?: emptyList()
+ itemsIndexed(
+ entries,
+ key = { index, item -> "${item.id}_$index" }) { index, entry ->
+
+ if (index == entries.lastIndex) {
+ LaunchedEffect(index) { opdsViewModel.loadNextPage() }
+ }
+
+ if (entry.isNavigation) {
+ OpdsNavigationCard(entry) { opdsViewModel.openFeedUrl(it) }
+ } else {
+ OpdsBookCard(
+ entry = entry,
+ localLibraryFiles = localLibraryFiles,
+ downloadState = downloadingState[entry.id],
+ coverImageLoader = coverImageLoader,
+ onDownloadClick = { acquisition ->
+ opdsViewModel.downloadBook(
+ entry, acquisition, context
+ ) { downloadedUri ->
+ onBookDownloaded(downloadedUri, entry.title)
+ }
+ },
+ onReadClick = onReadBook,
+ onStreamClick = {
+ onStreamBook(
+ entry,
+ uiState.currentCatalog
+ )
+ },
+ onClick = { selectedEntry = entry })
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Error Banner overlay
+ uiState.errorMessage?.let { error ->
+ LaunchedEffect(error) {
+ delay(4000)
+ opdsViewModel.clearError()
+ }
+ Surface(
+ color = MaterialTheme.colorScheme.errorContainer,
+ shape = MaterialTheme.shapes.medium,
+ modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp)
+ .padding(bottom = 70.dp)
+ ) {
+ Text(
+ text = error,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ }
+
+ if (selectedEntry != null) {
+ OpdsBookDetailsSheet(
+ entry = selectedEntry!!,
+ localLibraryFiles = localLibraryFiles,
+ downloadState = downloadingState[selectedEntry!!.id],
+ coverImageLoader = coverImageLoader,
+ onDownloadFormat = { acquisition ->
+ opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
+ onBookDownloaded(downloadedUri, selectedEntry!!.title)
+ }
+ },
+ onReadClick = onReadBook,
+ onStreamClick = { selectedEntry?.let { onStreamBook(it, uiState.currentCatalog) } },
+ onAuthorOrCategoryClick = { url, fallbackName ->
+ if (url != null) opdsViewModel.openFeedUrl(url)
+ else opdsViewModel.search(fallbackName)
+ selectedEntry = null
+ },
+ onDismiss = { selectedEntry = null }
+ )
+ }
+ }
+
+ // Dynamic Add/Edit Dialog
+ if (showCatalogDialog) {
+ var newTitle by remember(editingCatalog) { mutableStateOf(editingCatalog?.title ?: "") }
+ var newUrl by remember(editingCatalog) { mutableStateOf(editingCatalog?.url ?: "") }
+ var newUsername by remember(editingCatalog) { mutableStateOf(editingCatalog?.username ?: "") }
+ var newPassword by remember(editingCatalog) { mutableStateOf(editingCatalog?.password ?: "") }
+
+ val isEditMode = editingCatalog != null
+
+ AlertDialog(
+ onDismissRequest = {
+ showCatalogDialog = false
+ editingCatalog = null
+ },
+ title = { Text(if (isEditMode) stringResource(R.string.edit_catalog) else stringResource(R.string.add_opds_catalog)) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(
+ value = newTitle,
+ onValueChange = { newTitle = it },
+ label = { Text(stringResource(R.string.catalog_name)) },
+ singleLine = true
+ )
+ OutlinedTextField(
+ value = newUrl,
+ onValueChange = { newUrl = it },
+ label = { Text(stringResource(R.string.url)) },
+ placeholder = { Text(stringResource(R.string.url_placeholder)) },
+ singleLine = true
+ )
+ Text(stringResource(R.string.auth_optional),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ OutlinedTextField(
+ value = newUsername,
+ onValueChange = { newUsername = it },
+ label = { Text(stringResource(R.string.username)) },
+ singleLine = true
+ )
+ OutlinedTextField(
+ value = newPassword,
+ onValueChange = { newPassword = it },
+ label = { Text(stringResource(R.string.password)) },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password)
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ if (isEditMode) {
+ opdsViewModel.updateCatalog(editingCatalog!!.id, newTitle, newUrl, newUsername, newPassword)
+ } else {
+ opdsViewModel.addCatalog(newTitle, newUrl, newUsername, newPassword)
+ }
+ showCatalogDialog = false
+ editingCatalog = null
+ },
+ enabled = newTitle.isNotBlank() && newUrl.isNotBlank()
+ ) { Text(stringResource(R.string.action_save)) }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ showCatalogDialog = false
+ editingCatalog = null
+ }) { Text(stringResource(R.string.action_cancel)) }
+ }
+ )
+ }
+
+ if (catalogToDelete != null) {
+ val streamedBooksCount = localLibraryFiles.count { it.uriString?.contains("catalogId=${catalogToDelete!!.id}") == true }
+ AlertDialog(
+ onDismissRequest = { catalogToDelete = null },
+ title = { Text(stringResource(R.string.delete_catalog)) },
+ text = {
+ Column {
+ Text(stringResource(R.string.delete_catalog_desc, catalogToDelete!!.title))
+ if (streamedBooksCount > 0) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ stringResource(R.string.delete_catalog_warning, streamedBooksCount),
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ opdsViewModel.removeCatalog(catalogToDelete!!.id)
+ if (streamedBooksCount > 0) {
+ onDeleteCatalogStreams(catalogToDelete!!.id)
+ }
+ catalogToDelete = null
+ },
+ colors = androidx.compose.material3.ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
+ ) { Text(stringResource(R.string.action_delete)) }
+ },
+ dismissButton = {
+ TextButton(onClick = { catalogToDelete = null }) { Text(stringResource(R.string.action_cancel)) }
+ }
+ )
+ }
+}
+
+@Composable
+private fun rememberOpdsCoverImageLoader(catalog: OpdsCatalog?): ImageLoader {
+ val context = LocalContext.current.applicationContext
+ val username = catalog?.username
+ val password = catalog?.password
+ val imageLoader = remember(context, username, password) {
+ ImageLoader.Builder(context)
+ .okHttpClient {
+ OpdsRepository.sharedHttpClient.newBuilder()
+ .authenticator(OpdsRepository.OpdsAuthenticator(username, password))
+ .build()
+ }
+ .components {
+ add(SvgDecoder.Factory())
+ }
+ .build()
+ }
+ DisposableEffect(imageLoader) {
+ onDispose { imageLoader.shutdown() }
+ }
+ return imageLoader
+}
+
+@Composable
+fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
+ Surface(
+ onClick = onClick,
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Icon(Icons.Default.FolderSpecial, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(catalog.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ if (catalog.isDefault) {
+ Spacer(modifier = Modifier.width(8.dp))
+ Surface(
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ shape = MaterialTheme.shapes.small
+ ) {
+ Text(stringResource(R.string.preset_label),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
+ )
+ }
+ }
+ }
+ Text(catalog.url, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ if (onEdit != null) {
+ IconButton(onClick = onEdit) {
+ Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.label_edit))
+ }
+ }
+ if (onDelete != null) {
+ IconButton(onClick = onDelete) {
+ Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_remove))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun OpdsNavigationCard(entry: OpdsEntry, onClick: (String) -> Unit) {
+ Surface(
+ onClick = { entry.navigationUrl?.let { onClick(it) } },
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
+ Spacer(modifier = Modifier.width(16.dp))
+ Column {
+ Text(entry.title, style = MaterialTheme.typography.titleMedium)
+ entry.summary?.let {
+ val cleanSummary = remember(it) { Jsoup.parse(it).text() }
+ Text(cleanSummary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun OpdsBookCard(
+ entry: OpdsEntry,
+ localLibraryFiles: List,
+ downloadState: OpdsDownloadState?,
+ coverImageLoader: ImageLoader,
+ onDownloadClick: (OpdsAcquisition) -> Unit,
+ onReadClick: (RecentFileItem) -> Unit,
+ onStreamClick: () -> Unit,
+ onClick: () -> Unit
+) {
+ val libraryItem = remember(entry, localLibraryFiles) {
+ SharedOpdsLocalBookMatcher.find(
+ entry = entry,
+ books = localLibraryFiles,
+ title = { it.title },
+ displayName = { it.displayName },
+ path = { it.uriString }
+ )
+ }
+ val isDownloading = downloadState?.isDownloading == true
+ val progress = downloadState?.progress
+ val uniqueAcquisitions = remember(entry.acquisitions) {
+ entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
+ }
+ var showFormatMenu by remember { mutableStateOf(false) }
+
+ Surface(
+ onClick = onClick,
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(modifier = Modifier.padding(12.dp)) {
+ AsyncImage(
+ model = entry.coverUrl,
+ contentDescription = null,
+ imageLoader = coverImageLoader,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(width = 70.dp, height = 100.dp)
+ .clip(MaterialTheme.shapes.small)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ entry.author?.let {
+ Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
+ }
+ entry.summary?.let {
+ val cleanSummary = remember(it) { Jsoup.parse(it).text() }
+ Text(cleanSummary, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (libraryItem != null) {
+ androidx.compose.material3.OutlinedButton(
+ onClick = { onReadClick(libraryItem) },
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
+ ) {
+ Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(R.string.action_read))
+ }
+ } else if (isDownloading) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(stringResource(R.string.status_downloading), style = MaterialTheme.typography.labelMedium)
+ Spacer(modifier = Modifier.weight(1f))
+ if (progress != null) {
+ Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium)
+ }
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ if (progress != null) {
+ androidx.compose.material3.LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
+ } else {
+ androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ }
+ }
+ } else {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (entry.isStreamable) {
+ FilledTonalButton(
+ onClick = onStreamClick,
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
+ ) {
+ Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(R.string.action_stream))
+ }
+ }
+
+ Box {
+ FilledTonalButton(
+ onClick = {
+ if (uniqueAcquisitions.size == 1) {
+ onDownloadClick(uniqueAcquisitions.first())
+ } else if (uniqueAcquisitions.size > 1) {
+ showFormatMenu = true
+ }
+ },
+ enabled = uniqueAcquisitions.isNotEmpty(),
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
+ ) {
+ if (uniqueAcquisitions.isEmpty()) {
+ Icon(Icons.Default.Info, null, modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(R.string.action_unavailable))
+ } else {
+ Icon(Icons.Default.Add, null, modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(R.string.action_download))
+ }
+ }
+ }
+ DropdownMenu(
+ expanded = showFormatMenu,
+ onDismissRequest = { showFormatMenu = false }
+ ) {
+ uniqueAcquisitions.forEach { acq ->
+ DropdownMenuItem(
+ text = { Text(acq.formatName) },
+ onClick = {
+ showFormatMenu = false
+ onDownloadClick(acq)
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OpdsBookDetailsSheet(
+ entry: OpdsEntry,
+ localLibraryFiles: List,
+ downloadState: OpdsDownloadState?,
+ coverImageLoader: ImageLoader,
+ onDownloadFormat: (OpdsAcquisition) -> Unit,
+ onReadClick: (RecentFileItem) -> Unit,
+ onStreamClick: () -> Unit,
+ onAuthorOrCategoryClick: (String?, String) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val libraryItem = remember(entry, localLibraryFiles) {
+ SharedOpdsLocalBookMatcher.find(
+ entry = entry,
+ books = localLibraryFiles,
+ title = { it.title },
+ displayName = { it.displayName },
+ path = { it.uriString }
+ )
+ }
+ val isDownloading = downloadState?.isDownloading == true
+ val progress = downloadState?.progress
+ val uniqueAcquisitions = remember(entry.acquisitions) {
+ entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
+ }
+
+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 8.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ AsyncImage(
+ model = entry.coverUrl,
+ contentDescription = null,
+ imageLoader = coverImageLoader,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(width = 110.dp, height = 160.dp)
+ .clip(MaterialTheme.shapes.medium)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ )
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = entry.title,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ lineHeight = 28.sp
+ )
+
+ if (entry.authors.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(4.dp))
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ entry.authors.forEach { author ->
+ Text(
+ text = author.name,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.clickable {
+ onAuthorOrCategoryClick(author.url, author.name)
+ }
+ )
+ }
+ }
+ }
+
+ entry.series?.takeIf { it.isNotBlank() }?.let { series ->
+ Spacer(modifier = Modifier.height(8.dp))
+ val seriesText = if (!entry.seriesIndex.isNullOrBlank()) "$series #${entry.seriesIndex}" else series
+ Text(
+ text = seriesText,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.clickable {
+ onAuthorOrCategoryClick(null, series)
+ }
+ )
+ }
+ }
+ }
+
+ if (libraryItem != null) {
+ androidx.compose.material3.Button(
+ onClick = {
+ onDismiss()
+ onReadClick(libraryItem)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Icon(Icons.Default.Check, contentDescription = stringResource(R.string.action_read))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.action_read), fontWeight = FontWeight.Bold)
+ }
+ }
+
+ if (isDownloading) {
+ Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(stringResource(R.string.status_downloading), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ Spacer(modifier = Modifier.weight(1f))
+ if (progress != null) {
+ Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.titleMedium)
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ if (progress != null) {
+ androidx.compose.material3.LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth().height(8.dp))
+ } else {
+ androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(8.dp))
+ }
+ }
+ } else if (uniqueAcquisitions.isNotEmpty() || entry.isStreamable) {
+ if (entry.isStreamable) {
+ androidx.compose.material3.Button(
+ onClick = {
+ onStreamClick()
+ onDismiss()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.action_stream_now), fontWeight = FontWeight.Bold)
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ if (uniqueAcquisitions.isNotEmpty()) {
+ Text(stringResource(R.string.download_format),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ uniqueAcquisitions.forEach { acq ->
+ FilledTonalButton(onClick = { onDownloadFormat(acq) }) {
+ Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(acq.formatName, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+ }
+ } else {
+ Text(stringResource(R.string.no_supported_formats), color = MaterialTheme.colorScheme.error)
+ }
+
+ if (entry.categories.isNotEmpty()) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ entry.categories.distinct().forEach { category ->
+ Surface(
+ shape = MaterialTheme.shapes.small,
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ onClick = { onAuthorOrCategoryClick(null, category) }
+ ) {
+ Text(
+ text = category,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
+ )
+ }
+ }
+ }
+ }
+
+ val hasSecondaryMeta = !entry.publisher.isNullOrBlank() || !entry.published.isNullOrBlank() || !entry.language.isNullOrBlank()
+ if (hasSecondaryMeta) {
+ Surface(
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ entry.publisher?.takeIf { it.isNotBlank() }?.let {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(stringResource(R.string.publisher), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ entry.published?.takeIf { it.isNotBlank() }?.let {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(stringResource(R.string.published), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ val cleanDate = it.substringBefore("T")
+ Text(cleanDate, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
+ }
+ }
+ entry.language?.takeIf { it.isNotBlank() }?.let {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(stringResource(R.string.language), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(it.uppercase(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
+ }
+ }
+ }
+ }
+ }
+
+ val summary = entry.summary
+ if (!summary.isNullOrBlank()) {
+ Text(stringResource(R.string.synopsis), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+
+ val cleanSummary = remember(summary) {
+ val preProcessed = summary
+ .replace("
", "\n")
+ .replace("
", "\n\n")
+ Jsoup.parse(preProcessed).text().trim()
+ }
+
+ Text(
+ text = cleanSummary,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ lineHeight = 24.sp,
+ modifier = Modifier.padding(bottom = 48.dp)
+ )
+ } else {
+ Spacer(modifier = Modifier.height(48.dp))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt
index 56d19a3..20d419c 100644
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt
@@ -24,11 +24,9 @@ import android.webkit.WebView
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.SvgDecoder
-import dagger.hilt.android.HiltAndroidApp
import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher
import timber.log.Timber // Add this
-@HiltAndroidApp
class MyApplication : Application(), ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt
new file mode 100644
index 0000000..2f53581
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt
@@ -0,0 +1,10 @@
+package org.dueattendant149.bookreader.opds
+
+typealias OpdsCatalog = org.dueattendant149.bookreader.shared.opds.OpdsCatalog
+typealias OpdsFacet = org.dueattendant149.bookreader.shared.opds.OpdsFacet
+typealias OpdsFeed = org.dueattendant149.bookreader.shared.opds.OpdsFeed
+typealias OpdsAuthor = org.dueattendant149.bookreader.shared.opds.OpdsAuthor
+typealias OpdsAcquisition = org.dueattendant149.bookreader.shared.opds.OpdsAcquisition
+typealias OpdsEntry = org.dueattendant149.bookreader.shared.opds.OpdsEntry
+typealias OpdsDownloadState = org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadState
+typealias OpdsScreenState = org.dueattendant149.bookreader.shared.opds.SharedOpdsScreenState
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt
new file mode 100644
index 0000000..d8af46f
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt
@@ -0,0 +1,3 @@
+package org.dueattendant149.bookreader.opds
+
+typealias OpdsParser = org.dueattendant149.bookreader.shared.opds.SharedOpdsParser
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt
new file mode 100644
index 0000000..b19d280
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt
@@ -0,0 +1,214 @@
+package org.dueattendant149.bookreader.opds
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import org.dueattendant149.bookreader.shared.opds.SharedOpdsCatalogs
+import org.dueattendant149.bookreader.shared.opds.SharedOpdsRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import timber.log.Timber
+import java.security.MessageDigest
+import java.util.UUID
+
+class OpdsRepository(context: Context) : SharedOpdsRepository {
+ private val prefs: SharedPreferences = context.getSharedPreferences("reader_opds_prefs", Context.MODE_PRIVATE)
+ private val parser = OpdsParser()
+
+ companion object {
+ private const val KEY_CATALOGS_JSON = "opds_catalogs_json"
+
+ val sharedHttpClient: OkHttpClient by lazy {
+ OkHttpClient.Builder()
+ .addInterceptor { chain ->
+ val originalRequest = chain.request()
+ val requestWithUserAgent = originalRequest.newBuilder()
+ .header("User-Agent", "EpistemeReader/1.0 (Android)")
+ .build()
+ chain.proceed(requestWithUserAgent)
+ }
+ .build()
+ }
+ }
+
+ private val httpClient = sharedHttpClient
+
+ override fun loadCatalogs(): List {
+ val jsonString = prefs.getString(KEY_CATALOGS_JSON, null)
+ val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString)
+ val catalogs = decodedCatalogs.ifEmpty {
+ SharedOpdsCatalogs.defaultCatalogs { UUID.randomUUID().toString() }
+ }
+ if (decodedCatalogs.isEmpty()) {
+ saveCatalogs(catalogs)
+ }
+ return catalogs
+ }
+
+ fun getCatalogs(): List = loadCatalogs()
+
+ override suspend fun getSearchTemplate(
+ openSearchUrl: String,
+ username: String?,
+ password: String?
+ ): String? = withContext(Dispatchers.IO) {
+ try {
+ val request = Request.Builder().url(openSearchUrl).build()
+ val response = getAuthenticatedClient(username, password).newCall(request).execute()
+ val body = response.body?.string() ?: return@withContext null
+ parser.extractOpenSearchTemplate(body, openSearchUrl)
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to fetch OpenSearch template")
+ null
+ }
+ }
+
+ fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) {
+ saveCatalogs(
+ SharedOpdsCatalogs.addCatalog(
+ catalogs = loadCatalogs(),
+ title = title,
+ url = url,
+ username = username,
+ password = password,
+ idFactory = { UUID.randomUUID().toString() }
+ )
+ )
+ }
+
+ fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
+ saveCatalogs(SharedOpdsCatalogs.updateCatalog(loadCatalogs(), id, title, url, username, password))
+ }
+
+ fun removeCatalog(id: String) {
+ saveCatalogs(SharedOpdsCatalogs.removeCatalog(loadCatalogs(), id))
+ }
+
+ override fun saveCatalogs(catalogs: List) {
+ prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) }
+ }
+
+ fun getAuthenticatedClient(username: String?, password: String?): OkHttpClient {
+ return httpClient.newBuilder()
+ .authenticator(OpdsAuthenticator(username, password))
+ .build()
+ }
+
+ class OpdsAuthenticator(private val user: String?, private val pass: String?) : okhttp3.Authenticator {
+ private var cnonceCount = 0
+
+ override fun authenticate(route: okhttp3.Route?, response: okhttp3.Response): Request? {
+ if (user.isNullOrBlank() || pass.isNullOrBlank()) return null
+
+ if (response.request.header("Authorization") != null) {
+ return null
+ }
+
+ val wwwAuth = response.header("WWW-Authenticate") ?: return null
+
+ if (wwwAuth.startsWith("Basic", ignoreCase = true)) {
+ val credential = okhttp3.Credentials.basic(user, pass)
+ return response.request.newBuilder().header("Authorization", credential).build()
+ }
+
+ if (wwwAuth.startsWith("Digest", ignoreCase = true)) {
+ val realm = extractParam(wwwAuth, "realm") ?: ""
+ val nonce = extractParam(wwwAuth, "nonce") ?: ""
+ val qop = selectAuthQop(extractParam(wwwAuth, "qop"))
+ val opaque = extractParam(wwwAuth, "opaque")
+
+ cnonceCount++
+ val nc = String.format("%08x", cnonceCount)
+ val cnonce = UUID.randomUUID().toString().replace("-", "")
+
+ val url = response.request.url
+ val uri = url.encodedPath + (if (url.encodedQuery != null) "?${url.encodedQuery}" else "")
+
+ val ha1 = md5("$user:$realm:$pass")
+ val ha2 = md5("${response.request.method}:$uri")
+
+ val responseHash = if (qop != null) {
+ md5("$ha1:$nonce:$nc:$cnonce:$qop:$ha2")
+ } else {
+ md5("$ha1:$nonce:$ha2")
+ }
+
+ val digestHeader = buildString {
+ append("Digest username=\"$user\", ")
+ append("realm=\"$realm\", ")
+ append("nonce=\"$nonce\", ")
+ append("uri=\"$uri\", ")
+ append("response=\"$responseHash\"")
+ if (qop != null) {
+ append(", qop=$qop, nc=$nc, cnonce=\"$cnonce\"")
+ }
+ if (opaque != null) {
+ append(", opaque=\"$opaque\"")
+ }
+ }
+
+ return response.request.newBuilder()
+ .header("Authorization", digestHeader)
+ .build()
+ }
+
+ return null
+ }
+
+ private fun extractParam(header: String, param: String): String? {
+ val match = Regex("$param=\"([^\"]+)\"").find(header) ?: Regex("$param=([^,\\s]+)").find(header)
+ return match?.groupValues?.get(1)
+ }
+
+ private fun selectAuthQop(value: String?): String? {
+ return value
+ ?.split(',')
+ ?.map { it.trim().trim('"') }
+ ?.firstOrNull { it.equals("auth", ignoreCase = true) }
+ }
+
+ private fun md5(input: String): String {
+ val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray())
+ return bytes.joinToString("") { "%02x".format(it) }
+ }
+ }
+
+
+ override suspend fun fetchFeed(url: String, username: String?, password: String?): Result = withContext(Dispatchers.IO) {
+ Timber.tag("OpdsDebug").d("Starting fetch for URL: $url")
+ try {
+ val client = getAuthenticatedClient(username, password)
+
+ val request = Request.Builder()
+ .url(url.trim())
+ .header("User-Agent", "EpistemeReader/1.0 (Android)")
+ .build()
+
+ Timber.tag("OpdsDebug").d("Executing network call...")
+ val response = client.newCall(request).execute()
+
+ Timber.tag("OpdsDebug").d("Response Code: ${response.code}")
+
+ if (!response.isSuccessful) {
+ val errorMsg = "HTTP ${response.code}: ${response.message}"
+ Timber.tag("OpdsDebug").e("Fetch failed: $errorMsg")
+ return@withContext Result.failure(Exception(errorMsg))
+ }
+
+ val bodyString = response.body?.string()
+ if (bodyString.isNullOrBlank()) {
+ return@withContext Result.failure(Exception("Empty response body"))
+ }
+
+ val feed = parser.parse(bodyString, url)
+
+ Timber.tag("OpdsDebug").d("Parsing complete. Found ${feed.entries.size} entries.")
+ Result.success(feed)
+ } catch (e: Exception) {
+ Timber.tag("OpdsDebug").e(e, "Exception during fetch/parse at URL: $url")
+ Result.failure(e)
+ }
+ }
+}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt
new file mode 100644
index 0000000..55388c2
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt
@@ -0,0 +1,172 @@
+package org.dueattendant149.bookreader.opds
+
+import android.app.Application
+import android.content.Context
+import android.net.Uri
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import org.dueattendant149.bookreader.R
+import org.dueattendant149.bookreader.shared.opds.SharedOpdsController
+import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadNamer
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.Request
+import okhttp3.Response
+import timber.log.Timber
+import java.io.File
+import java.util.UUID
+
+class OpdsViewModel(application: Application) : AndroidViewModel(application) {
+ private val repository = OpdsRepository(application)
+ private val controller = SharedOpdsController(
+ repository = repository,
+ feedLoadErrorMessage = { error ->
+ application.getString(R.string.opds_error_load_feed, error.message.orEmpty())
+ },
+ idFactory = { UUID.randomUUID().toString() }
+ )
+
+ private val _uiState = MutableStateFlow(controller.state)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun loadNextPage() {
+ viewModelScope.launch {
+ controller.loadNextPage(::emitState)
+ }
+ }
+
+ fun downloadBook(entry: OpdsEntry, acquisition: OpdsAcquisition, context: Context, onDownloaded: (Uri) -> Unit) {
+ val downloadUrl = acquisition.url
+ val catalog = _uiState.value.currentCatalog
+ viewModelScope.launch {
+ updateDownloadState(entry.id, OpdsDownloadState(isDownloading = true, progress = 0f))
+ try {
+ val tempFile = withContext(Dispatchers.IO) {
+ val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password)
+ val request = Request.Builder().url(downloadUrl).build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw OpdsDownloadFailedException(
+ context.getString(R.string.opds_error_download_failed, response.message)
+ )
+ }
+
+ val body = response.body
+ ?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response))
+ val contentLength = body.contentLength()
+ val ext = resolveOpdsDownloadExtension(acquisition, response)
+ val safeTitle = SharedOpdsDownloadNamer.safeFileStem(entry.title).take(50)
+ val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
+
+ body.byteStream().use { input ->
+ tempFile.outputStream().use { output ->
+ val buffer = ByteArray(8 * 1024)
+ var totalRead = 0L
+ var lastProgressUpdate = System.currentTimeMillis()
+
+ while (true) {
+ val bytesRead = input.read(buffer)
+ if (bytesRead == -1) break
+ output.write(buffer, 0, bytesRead)
+ totalRead += bytesRead
+
+ if (contentLength > 0) {
+ val now = System.currentTimeMillis()
+ if (now - lastProgressUpdate > 200) {
+ val progress = (totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f)
+ withContext(Dispatchers.Main) {
+ updateDownloadState(
+ entry.id,
+ OpdsDownloadState(isDownloading = true, progress = progress)
+ )
+ }
+ lastProgressUpdate = now
+ }
+ }
+ }
+ }
+ }
+ tempFile
+ }
+ }
+
+ onDownloaded(Uri.fromFile(tempFile))
+ } catch (e: Exception) {
+ Timber.e(e, "Download error")
+ val message = if (e is OpdsDownloadFailedException) {
+ e.message.orEmpty()
+ } else {
+ context.getString(R.string.opds_error_download_error, e.message.orEmpty())
+ }
+ emitState(controller.setErrorMessage(message))
+ } finally {
+ updateDownloadState(entry.id, null)
+ }
+ }
+ }
+
+ private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
+ return SharedOpdsDownloadNamer.resolveExtension(
+ acquisition = acquisition,
+ contentDisposition = response.header("Content-Disposition"),
+ urlPathSegment = Uri.parse(acquisition.url).lastPathSegment
+ )
+ }
+
+ fun addCatalog(title: String, url: String, username: String?, password: String?) {
+ emitState(controller.addCatalog(title, url, username, password))
+ }
+
+ fun removeCatalog(id: String) {
+ emitState(controller.removeCatalog(id))
+ }
+
+ fun openCatalog(catalog: OpdsCatalog) {
+ viewModelScope.launch {
+ controller.openCatalog(catalog, ::emitState)
+ }
+ }
+
+ fun openFeedUrl(url: String) {
+ viewModelScope.launch {
+ controller.openFeedUrl(url, ::emitState)
+ }
+ }
+
+ fun navigateBack(): Boolean {
+ val returnsToPreviousFeed = controller.hasFeedHistory()
+ viewModelScope.launch {
+ controller.navigateBack(::emitState)
+ }
+ return returnsToPreviousFeed
+ }
+
+ fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
+ emitState(controller.updateCatalog(id, title, url, username, password))
+ }
+
+ fun search(query: String) {
+ viewModelScope.launch {
+ controller.search(query, ::emitState)
+ }
+ }
+
+ fun clearError() {
+ emitState(controller.clearError())
+ }
+
+ private fun updateDownloadState(entryId: String, downloadState: OpdsDownloadState?) {
+ emitState(controller.updateDownloadState(entryId, downloadState))
+ }
+
+ private fun emitState(state: OpdsScreenState) {
+ _uiState.value = state
+ }
+
+ private class OpdsDownloadFailedException(message: String) : Exception(message)
+}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt
index 912a2d4..94c0e7d 100644
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt
@@ -783,7 +783,20 @@ class OpdsStreamDocumentWrapper(
) : ReaderDocument {
private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() }
- private val client = okhttp3.OkHttpClient.Builder().build()
+ private val catalog = catalogId?.let {
+ org.dueattendant149.bookreader.opds.OpdsRepository(context).getCatalogs().find { c -> c.id == it }
+ }
+
+ private val client = org.dueattendant149.bookreader.opds.OpdsRepository.sharedHttpClient.newBuilder()
+ .apply {
+ val streamCatalog = catalog
+ val username = streamCatalog?.username
+ val password = streamCatalog?.password
+ if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
+ authenticator(org.dueattendant149.bookreader.opds.OpdsRepository.OpdsAuthenticator(username, password))
+ }
+ }
+ .build()
private fun createErrorPageBytes(): ByteArray {
val bitmap = createBitmap(800, 1200)
@@ -814,7 +827,18 @@ class OpdsStreamDocumentWrapper(
}
}
- val finalUrlTemplate = urlTemplate
+ val streamCatalog = catalog
+ val finalUrlTemplate = if (streamCatalog != null && urlTemplate.startsWith("http")) {
+ try {
+ val oldUrl = java.net.URL(urlTemplate)
+ val newUrl = java.net.URL(streamCatalog.url)
+ val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
+ val newBase = "${newUrl.protocol}://${newUrl.authority}"
+ urlTemplate.replace(oldBase, newBase)
+ } catch (_: Exception) {
+ urlTemplate
+ }
+ } else urlTemplate
val url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString())
.replace("{maxWidth}", "1600")
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Shapes.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Shapes.kt
deleted file mode 100644
index a7e4dda..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Shapes.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package org.dueattendant149.bookreader.ui.theme
-
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Shapes
-import androidx.compose.ui.unit.dp
-
-/**
- * Myne-style Material You 3 shape scheme: generous rounded corners.
- */
-val AppShapes = Shapes(
- extraSmall = RoundedCornerShape(8.dp),
- small = RoundedCornerShape(12.dp),
- medium = RoundedCornerShape(16.dp),
- large = RoundedCornerShape(24.dp),
- extraLarge = RoundedCornerShape(32.dp),
-)
\ No newline at end of file
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt
index daf31e4..080c31c 100644
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt
@@ -178,7 +178,6 @@ fun AppTheme(
MaterialTheme(
colorScheme = finalColorScheme,
typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography,
- shapes = AppShapes,
content = content
)
}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsScreen.kt
deleted file mode 100644
index a668bc2..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsScreen.kt
+++ /dev/null
@@ -1,144 +0,0 @@
-package org.dueattendant149.bookreader.bookshelf
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material3.Button
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.input.PasswordVisualTransformation
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun ServerSettingsScreen(
- viewModel: ServerSettingsViewModel,
- onBackClick: () -> Unit,
-) {
- val uiState by viewModel.uiState.collectAsStateWithLifecycle()
- var bookshelfUrl by remember(uiState.bookshelfUrl) { mutableStateOf(uiState.bookshelfUrl) }
- var absUrl by remember(uiState.absUrl) { mutableStateOf(uiState.absUrl) }
- var absToken by remember(uiState.absToken) { mutableStateOf(uiState.absToken) }
-
- Scaffold(
- topBar = {
- androidx.compose.material3.CenterAlignedTopAppBar(
- title = { Text("Server settings") },
- navigationIcon = {
- IconButton(onClick = onBackClick) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
- }
- }
- )
- }
- ) { padding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding)
- .padding(horizontal = 16.dp, vertical = 8.dp)
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- SettingsSectionCard(title = "Bookshelf API") {
- OutlinedTextField(
- value = bookshelfUrl,
- onValueChange = {
- bookshelfUrl = it
- viewModel.updateBookshelfUrl(it)
- },
- label = { Text("https://books.dueattendant149.org/") },
- singleLine = true,
- modifier = Modifier.fillMaxWidth()
- )
- }
-
- SettingsSectionCard(title = "Audiobookshelf") {
- OutlinedTextField(
- value = absUrl,
- onValueChange = {
- absUrl = it
- viewModel.updateAbsUrl(it)
- },
- label = { Text("https://abs.dueattendant149.org/") },
- singleLine = true,
- modifier = Modifier.fillMaxWidth()
- )
- Spacer(modifier = Modifier.height(12.dp))
- OutlinedTextField(
- value = absToken,
- onValueChange = {
- absToken = it
- viewModel.updateAbsToken(it)
- },
- label = { Text("Bearer token") },
- singleLine = true,
- visualTransformation = PasswordVisualTransformation(),
- modifier = Modifier.fillMaxWidth()
- )
- }
-
- Button(
- onClick = viewModel::save,
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.large
- ) {
- Text("Save")
- }
-
- if (uiState.saved) {
- Text(
- "Saved",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.primary
- )
- }
- }
- }
-}
-
-@Composable
-private fun SettingsSectionCard(
- title: String,
- content: @Composable () -> Unit,
-) {
- Card(
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.large,
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow
- )
- ) {
- Column(modifier = Modifier.padding(20.dp)) {
- Text(
- text = title,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(bottom = 12.dp)
- )
- content()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsViewModel.kt
deleted file mode 100644
index eaaf09a..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsViewModel.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-package org.dueattendant149.bookreader.bookshelf
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
-import org.dueattendant149.bookreader.data.settings.ServerSettings
-import javax.inject.Inject
-
-data class ServerSettingsUiState(
- val bookshelfUrl: String = "",
- val absUrl: String = "",
- val absToken: String = "",
- val saved: Boolean = false,
-)
-
-@HiltViewModel
-class ServerSettingsViewModel
- @Inject
- constructor(
- private val serverSettings: ServerSettings,
- ) : ViewModel() {
-
- private val _uiState = MutableStateFlow(ServerSettingsUiState())
- val uiState: StateFlow = _uiState.asStateFlow()
-
- init {
- _uiState.value = ServerSettingsUiState(
- bookshelfUrl = serverSettings.getBookshelfUrl() ?: "",
- absUrl = serverSettings.getAbsUrl() ?: "",
- absToken = serverSettings.getAbsToken() ?: "",
- )
- }
-
- fun updateBookshelfUrl(url: String) {
- _uiState.value = _uiState.value.copy(bookshelfUrl = url, saved = false)
- }
-
- fun updateAbsUrl(url: String) {
- _uiState.value = _uiState.value.copy(absUrl = url, saved = false)
- }
-
- fun updateAbsToken(token: String) {
- _uiState.value = _uiState.value.copy(absToken = token, saved = false)
- }
-
- fun save() {
- viewModelScope.launch {
- serverSettings.setBookshelfUrl(_uiState.value.bookshelfUrl.trim())
- serverSettings.setAbsUrl(_uiState.value.absUrl.trim())
- serverSettings.setAbsToken(_uiState.value.absToken.trim())
- _uiState.value = _uiState.value.copy(saved = true)
- }
- }
- }
\ No newline at end of file
diff --git a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlaybackService.kt b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlaybackService.kt
deleted file mode 100644
index c3b4432..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlaybackService.kt
+++ /dev/null
@@ -1,118 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookreader.audio
-
-import android.app.Notification
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.content.Intent
-import android.os.Build
-import android.os.Bundle
-import androidx.annotation.OptIn
-import androidx.core.app.NotificationCompat
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.session.MediaSession
-import androidx.media3.session.MediaSessionService
-import dagger.hilt.android.AndroidEntryPoint
-import org.dueattendant149.bookreader.R
-import org.dueattendant149.bookreader.audio.Book
-import org.dueattendant149.bookreader.MainActivity
-import javax.inject.Inject
-
-@AndroidEntryPoint
-@OptIn(UnstableApi::class)
-class AudioPlaybackService : MediaSessionService() {
-
- @Inject
- lateinit var exoPlayer: ExoPlayer
-
- private var mediaSession: MediaSession? = null
-
- override fun onCreate() {
- super.onCreate()
- createNotificationChannel()
- val intent = Intent(this, MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
- }
- val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
- } else {
- PendingIntent.FLAG_UPDATE_CURRENT
- }
- val sessionActivity = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
- mediaSession = MediaSession.Builder(this, exoPlayer)
- .setSessionActivity(sessionActivity)
- .build()
- }
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- val book = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent?.getParcelableExtra(EXTRA_BOOK, Book::class.java)
- } else {
- @Suppress("DEPRECATION")
- intent?.getParcelableExtra(EXTRA_BOOK)
- }
-
- val notification = buildNotification(book)
- startForeground(NOTIFICATION_ID, notification)
-
- return super.onStartCommand(intent, flags, startId)
- }
-
- override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
- return mediaSession
- }
-
- override fun onTaskRemoved(rootIntent: Intent?) {
- val player = mediaSession?.player ?: exoPlayer
- if (!player.playWhenReady || player.playbackState == ExoPlayer.STATE_ENDED) {
- stopSelf()
- }
- }
-
- override fun onDestroy() {
- mediaSession?.run {
- player.release()
- release()
- }
- mediaSession = null
- super.onDestroy()
- }
-
- private fun createNotificationChannel() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val channel = NotificationChannel(
- CHANNEL_ID,
- getString(R.string.audio_playback_channel),
- NotificationManager.IMPORTANCE_LOW
- ).apply {
- description = getString(R.string.audio_playback_channel_description)
- }
- val notificationManager = getSystemService(NotificationManager::class.java)
- notificationManager.createNotificationChannel(channel)
- }
- }
-
- private fun buildNotification(book: Book?): Notification {
- return NotificationCompat.Builder(this, CHANNEL_ID)
- .setContentTitle(book?.title ?: getString(R.string.app_name))
- .setContentText(book?.author ?: "")
- .setSmallIcon(R.mipmap.ic_launcher)
- .setOngoing(true)
- .setSilent(true)
- .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
- .build()
- }
-
- companion object {
- const val EXTRA_BOOK = "extra_book"
- private const val CHANNEL_ID = "audio_playback"
- private const val NOTIFICATION_ID = 1
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlayerProgress.kt b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlayerProgress.kt
deleted file mode 100644
index bcf6258..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlayerProgress.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookreader.audio
-
-/**
- * Placeholder for the audio player progress state.
- *
- * TODO: When implementing the ExoPlayer/MediaSession audio player,
- * hook [org.dueattendant149.bookreader.domain.use_case.remote.SyncPlaybackProgressUseCase]
- * into the playback position update flow, e.g.:
- *
- * ```
- * val book = ...
- * val currentFile = player.currentMediaItem?.mediaId ?: book.audioCurrentFile
- * val position = player.currentPosition.coerceAtLeast(0L)
- * val duration = player.duration.coerceAtLeast(book.audioDuration)
- * syncPlaybackProgressUseCase(book, currentFile, position, duration)
- * ```
- */
-data class AudioPlayerProgress(
- val bookId: Int,
- val currentFile: String,
- val position: Long,
- val duration: Long,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioTrack.kt b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioTrack.kt
deleted file mode 100644
index 62de39f..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioTrack.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package org.dueattendant149.bookreader.audio
-
-data class AudioTrack(
- val fileId: String,
- val title: String,
- val durationMs: Long,
- val order: Int,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/audio/Book.kt b/app/src/main/java/org/dueattendant149/bookreader/audio/Book.kt
deleted file mode 100644
index 381f7be..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/audio/Book.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package org.dueattendant149.bookreader.audio
-
-/**
- * Minimal Book model stub for AudioPlaybackService.
- * Ported from Book's Story domain model.
- */
-data class Book(
- val id: Long = 0,
- val title: String = "",
- val author: String = "",
- val remoteId: String = "",
- val libraryId: String = "",
- val hasAudio: Boolean = false,
- val audioDuration: Long = 0L,
- val audioCurrentFile: String = "",
- val audioCurrentPosition: Long = 0L,
- val coverUrl: String = "",
-)
\ No newline at end of file
diff --git a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt
deleted file mode 100644
index aa8b896..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt
+++ /dev/null
@@ -1,222 +0,0 @@
-package org.dueattendant149.bookreader.bookshelf
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Settings
-import androidx.compose.material3.Card
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
-import timber.log.Timber
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun BookshelfLibraryScreen(
- viewModel: BookshelfViewModel,
- onItemClick: (UnifiedItemResponse) -> Unit,
- onOpenSettings: () -> Unit = {},
-) {
- val uiState by viewModel.uiState.collectAsStateWithLifecycle()
-
- LaunchedEffect(Unit) {
- if (uiState.libraries.isEmpty()) {
- viewModel.loadLibraries()
- }
- }
-
- LaunchedEffect(viewModel) {
- viewModel.downloadedFile.collect { uri ->
- Timber.d("Downloaded book to $uri, opening in reader")
- }
- }
-
- Scaffold(
- topBar = {
- androidx.compose.material3.CenterAlignedTopAppBar(
- title = { Text("Server Library") },
- actions = {
- IconButton(onClick = onOpenSettings) {
- Icon(Icons.Default.Settings, contentDescription = "Server settings")
- }
- }
- )
- }
- ) { padding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding)
- ) {
- if (uiState.isLoading && uiState.libraries.isEmpty()) {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- CircularProgressIndicator()
- }
- return@Scaffold
- }
-
- uiState.error?.let { error ->
- Text(
- text = error,
- color = MaterialTheme.colorScheme.error,
- modifier = Modifier.padding(16.dp)
- )
- }
-
- val selected = uiState.selectedLibrary
- if (selected == null) {
- Text(
- text = "Select a library",
- style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.padding(16.dp)
- )
- LazyColumn(
- contentPadding = PaddingValues(16.dp),
- modifier = Modifier.fillMaxWidth()
- ) {
- items(uiState.libraries) { library ->
- LibraryCard(
- library = library,
- onClick = { viewModel.selectLibrary(library) }
- )
- }
- }
- } else {
- Text(
- text = selected.name,
- style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.padding(16.dp)
- )
- if (uiState.isLoading) {
- LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
- }
- if (uiState.downloadingItem != null) {
- LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
- }
- LazyColumn(
- contentPadding = PaddingValues(16.dp),
- modifier = Modifier.fillMaxSize()
- ) {
- items(uiState.items) { item ->
- ItemCard(
- item = item,
- isDownloading = uiState.downloadingItem?.id == item.id,
- onClick = {
- viewModel.downloadBook(item)
- onItemClick(item)
- }
- )
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun LibraryCard(
- library: LibraryResponse,
- onClick: () -> Unit,
-) {
- Card(
- onClick = onClick,
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 6.dp),
- shape = MaterialTheme.shapes.large,
- colors = androidx.compose.material3.CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow
- )
- ) {
- Column(modifier = Modifier.padding(20.dp)) {
- Text(
- text = library.name,
- style = MaterialTheme.typography.titleMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- Text(
- text = "${library.itemCount} items · ${library.mediaType}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = 4.dp)
- )
- }
- }
-}
-
-@Composable
-private fun ItemCard(
- item: UnifiedItemResponse,
- isDownloading: Boolean,
- onClick: () -> Unit,
-) {
- Card(
- onClick = onClick,
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 6.dp),
- shape = MaterialTheme.shapes.large,
- colors = androidx.compose.material3.CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow
- )
- ) {
- Column(modifier = Modifier.padding(20.dp)) {
- Text(
- text = item.title,
- style = MaterialTheme.typography.titleMedium,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- if (item.author.isNotBlank()) {
- Text(
- text = item.author,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = 4.dp)
- )
- }
- Text(
- text = item.type,
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = 4.dp)
- )
- if (isDownloading) {
- LinearProgressIndicator(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 12.dp),
- )
- Text(
- "Downloading...",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(top = 4.dp)
- )
- }
- }
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt
deleted file mode 100644
index 072d823..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt
+++ /dev/null
@@ -1,176 +0,0 @@
-package org.dueattendant149.bookreader.bookshelf
-
-import android.app.Application
-import android.net.Uri
-import androidx.lifecycle.AndroidViewModel
-import androidx.lifecycle.viewModelScope
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
-import okhttp3.ResponseBody
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
-import java.io.File
-import javax.inject.Inject
-
-@HiltViewModel
-class BookshelfViewModel
- @Inject
- constructor(
- application: Application,
- private val repository: BookshelfApiRepository,
- ) : AndroidViewModel(application) {
-
- private val _uiState = MutableStateFlow(BookshelfUiState())
- val uiState: StateFlow = _uiState.asStateFlow()
-
- private val _downloadedFile = MutableSharedFlow(extraBufferCapacity = 1)
- val downloadedFile: SharedFlow = _downloadedFile.asSharedFlow()
-
- fun loadLibraries() {
- _uiState.value = _uiState.value.copy(isLoading = true, error = null)
- viewModelScope.launch {
- repository.getLibraries()
- .onSuccess { libraries ->
- _uiState.value = _uiState.value.copy(
- libraries = libraries,
- isLoading = false,
- error = null
- )
- }
- .onFailure { error ->
- _uiState.value = _uiState.value.copy(
- isLoading = false,
- error = error.message ?: "Failed to load libraries"
- )
- }
- }
- }
-
- fun selectLibrary(library: LibraryResponse) {
- _uiState.value = _uiState.value.copy(selectedLibrary = library, items = emptyList())
- loadItems(library.id)
- }
-
- fun loadItems(libraryId: String) {
- _uiState.value = _uiState.value.copy(isLoading = true, error = null)
- viewModelScope.launch {
- repository.getLibraryItems(libraryId)
- .onSuccess { response ->
- _uiState.value = _uiState.value.copy(
- items = response.items,
- isLoading = false,
- error = null
- )
- }
- .onFailure { error ->
- _uiState.value = _uiState.value.copy(
- isLoading = false,
- error = error.message ?: "Failed to load items"
- )
- }
- }
- }
-
- fun search(libraryId: String, query: String) {
- if (query.isBlank()) {
- loadItems(libraryId)
- return
- }
- _uiState.value = _uiState.value.copy(isLoading = true, error = null)
- viewModelScope.launch {
- repository.searchLibrary(libraryId, query)
- .onSuccess { searchItems ->
- val mapped = searchItems.map { bookItem ->
- UnifiedItemResponse(
- id = bookItem.id,
- title = bookItem.title,
- author = bookItem.author,
- type = bookItem.mediaType,
- mediaType = bookItem.mediaType,
- libraryId = bookItem.libraryId,
- coverUrl = bookItem.coverUrl
- )
- }
- _uiState.value = _uiState.value.copy(
- items = mapped,
- isLoading = false,
- error = null
- )
- }
- .onFailure { error ->
- _uiState.value = _uiState.value.copy(
- isLoading = false,
- error = error.message ?: "Search failed"
- )
- }
- }
- }
-
- fun downloadBook(item: UnifiedItemResponse) {
- _uiState.value = _uiState.value.copy(downloadingItem = item)
- viewModelScope.launch {
- repository.downloadEbook(item.id)
- .onSuccess { body ->
- val file = saveToCache(item, body)
- if (file != null) {
- _downloadedFile.tryEmit(Uri.fromFile(file))
- }
- _uiState.value = _uiState.value.copy(downloadingItem = null)
- }
- .onFailure { error ->
- _uiState.value = _uiState.value.copy(
- downloadingItem = null,
- error = error.message ?: "Download failed"
- )
- }
- }
- }
-
- private fun saveToCache(item: UnifiedItemResponse, body: ResponseBody): File? {
- return runCatching {
- val cacheDir = getApplication().cacheDir
- val dir = File(cacheDir, "bookshelf_downloads").apply { mkdirs() }
- val safeName = item.title.replace(Regex("[^A-Za-z0-9._-]"), "_").take(60)
- val ext = guessExtension(item)
- val file = File(dir, "${safeName}_${item.id}.$ext")
- file.outputStream().use { out -> body.byteStream().copyTo(out) }
- file
- }.getOrElse {
- _uiState.value = _uiState.value.copy(
- downloadingItem = null,
- error = "Failed to save file: ${it.message}"
- )
- null
- }
- }
-
- private fun guessExtension(item: UnifiedItemResponse): String = when {
- item.mediaType.contains("epub", ignoreCase = true) -> "epub"
- item.mediaType.contains("pdf", ignoreCase = true) -> "pdf"
- item.mediaType.contains("fb2", ignoreCase = true) -> "fb2"
- item.mediaType.contains("mobi", ignoreCase = true) -> "mobi"
- item.mediaType.contains("azw3", ignoreCase = true) -> "azw3"
- item.mediaType.contains("docx", ignoreCase = true) -> "docx"
- item.mediaType.contains("odt", ignoreCase = true) -> "odt"
- item.mediaType.contains("txt", ignoreCase = true) -> "txt"
- item.mediaType.contains("md", ignoreCase = true) -> "md"
- else -> "epub"
- }
- }
-
-
-data class BookshelfUiState(
- val libraries: List = emptyList(),
- val selectedLibrary: LibraryResponse? = null,
- val items: List = emptyList(),
- val isLoading: Boolean = false,
- val error: String? = null,
- val downloadingItem: UnifiedItemResponse? = null,
-)
\ No newline at end of file
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt
deleted file mode 100644
index db19620..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.audiobookshelf
-
-import android.util.Log
-import kotlinx.serialization.json.Json
-import okhttp3.MediaType.Companion.toMediaType
-import okhttp3.OkHttpClient
-import okhttp3.logging.HttpLoggingInterceptor
-import org.dueattendant149.bookreader.domain.util.fixUriScheme
-import retrofit2.Retrofit
-import retrofit2.converter.kotlinx.serialization.asConverterFactory
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Builds (and caches) a Retrofit-backed [AudiobookshelfApiService] for the configured
- * Audiobookshelf instance. All requests are authenticated with the provided bearer token.
- */
-@Singleton
-class AudiobookshelfApiClientFactory
- @Inject
- constructor(
- private val json: Json,
- private val okHttpClient: OkHttpClient,
- ) {
- private var cachedUrl: String? = null
- private var cachedToken: String? = null
- private var cachedClient: AudiobookshelfApiService? = null
-
- @Synchronized
- fun provideClient(
- url: String,
- token: String,
- ): AudiobookshelfApiService? {
- val fixedUrl = url.fixUriScheme()
- if (fixedUrl == cachedUrl && token == cachedToken && cachedClient != null) {
- return cachedClient
- }
-
- val authClient =
- okHttpClient
- .newBuilder()
- .addInterceptor { chain ->
- val request =
- chain
- .request()
- .newBuilder()
- .header("Authorization", "Bearer $token")
- .build()
- chain.proceed(request)
- }
- .addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
- .build()
-
- return runCatching {
- Retrofit
- .Builder()
- .client(authClient)
- .baseUrl(fixedUrl)
- .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
- .build()
- .create(AudiobookshelfApiService::class.java)
- }.onFailure {
- Log.e("AudiobookshelfApiFactory", "Failed to create client for $fixedUrl", it)
- }.getOrNull().also {
- cachedUrl = fixedUrl
- cachedToken = token
- cachedClient = it
- }
- }
-
- fun clearCache() {
- cachedUrl = null
- cachedToken = null
- cachedClient = null
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiService.kt
deleted file mode 100644
index 1322334..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiService.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.audiobookshelf
-
-import okhttp3.ResponseBody
-import org.dueattendant149.bookreader.data.remote.audiobookshelf.PlaybackProgressUpdateRequest
-import org.dueattendant149.bookreader.data.remote.audiobookshelf.model.AbsItemResponse
-import retrofit2.Response
-import retrofit2.http.Body
-import retrofit2.http.GET
-import retrofit2.http.POST
-import retrofit2.http.Path
-import retrofit2.http.Streaming
-
-/**
- * Direct Audiobookshelf API calls for audio files and covers.
- * bookshelf-api does not proxy audio streams, so the app talks to ABS directly.
- */
-interface AudiobookshelfApiService {
-
- @Streaming
- @GET("api/items/{itemId}/file/{fileId}")
- suspend fun downloadAudioFile(
- @Path("itemId") itemId: String,
- @Path("fileId") fileId: String,
- ): Response
-
- @Streaming
- @GET("api/items/{itemId}/download")
- suspend fun downloadBook(
- @Path("itemId") itemId: String,
- ): Response
-
- @GET("api/items/{itemId}/cover")
- suspend fun getCover(
- @Path("itemId") itemId: String,
- ): Response
-
- @GET("api/me/progress/{itemId}")
- suspend fun getProgress(
- @Path("itemId") itemId: String,
- ): Response
-
- @GET("api/items/{itemId}")
- suspend fun getItem(
- @Path("itemId") itemId: String,
- ): Response
-
- @POST("api/me/progress/{itemId}")
- suspend fun updateProgress(
- @Path("itemId") itemId: String,
- @Body request: PlaybackProgressUpdateRequest,
- ): Response
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfRepository.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfRepository.kt
deleted file mode 100644
index 266ab4d..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfRepository.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.audiobookshelf
-
-import okhttp3.ResponseBody
-import org.dueattendant149.bookreader.data.remote.audiobookshelf.model.AbsItemResponse
-import org.dueattendant149.bookreader.data.settings.ServerSettings
-import retrofit2.Response
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Thin repository over [AudiobookshelfApiService]. Direct ABS calls for audio,
- * cover and progress. bookshelf-api does not proxy audio streams.
- */
-@Singleton
-class AudiobookshelfRepository
- @Inject
- constructor(
- private val clientFactory: AudiobookshelfApiClientFactory,
- private val serverSettings: ServerSettings,
- ) {
- private suspend fun client() =
- serverSettings.getAbsUrl()?.let { url ->
- serverSettings.getAbsToken()?.let { token ->
- clientFactory.provideClient(url, token)
- }
- }
-
- suspend fun getItem(itemId: String): Result {
- val client = client() ?: return Result.failure(absNotConfigured())
- return safe { client.getItem(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
- }
-
- suspend fun getCover(itemId: String): Result {
- val client = client() ?: return Result.failure(absNotConfigured())
- return safe { client.getCover(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
- }
-
- suspend fun downloadBook(itemId: String): Result {
- val client = client() ?: return Result.failure(absNotConfigured())
- return safe { client.downloadBook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
- }
-
- suspend fun downloadAudioFile(
- itemId: String,
- fileId: String,
- ): Result {
- val client = client() ?: return Result.failure(absNotConfigured())
- return safe { client.downloadAudioFile(itemId, fileId).unwrapBody() ?: throw IllegalStateException("Empty body") }
- }
-
- suspend fun getProgress(itemId: String): Result {
- val client = client() ?: return Result.failure(absNotConfigured())
- return safe { client.getProgress(itemId).unwrapBody() ?: "" }
- }
-
- suspend fun updateProgress(
- itemId: String,
- request: PlaybackProgressUpdateRequest,
- ): Result {
- val client = client() ?: return Result.failure(absNotConfigured())
- return safe { client.updateProgress(itemId, request).unwrapBody() ?: "" }
- }
-
- private fun absNotConfigured() = IllegalStateException("Audiobookshelf URL/token not configured")
-
- private inline fun safe(block: () -> T): Result = runCatching(block)
-
- private fun Response.unwrapBody(): T? {
- if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}")
- return body()
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt
deleted file mode 100644
index 6367890..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.audiobookshelf
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-/**
- * Request body for updating media progress directly on an Audiobookshelf server.
- *
- * Field names follow the ABS `/api/me/progress/{itemId}` endpoint contract.
- */
-@Serializable
-data class PlaybackProgressUpdateRequest(
- @SerialName("currentTime")
- val currentTime: Double,
- @SerialName("duration")
- val duration: Double,
- @SerialName("progress")
- val progress: Double,
- @SerialName("episodeId")
- val episodeId: String? = null,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/model/AbsItemResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/model/AbsItemResponse.kt
deleted file mode 100644
index 3bbb58d..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/model/AbsItemResponse.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.audiobookshelf.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class AbsItemResponse(
- val id: String = "",
- val media: AbsMedia? = null,
-)
-
-@Serializable
-data class AbsMedia(
- val metadata: AbsMediaMetadata? = null,
- @SerialName("audioFiles")
- val audioFiles: List = emptyList(),
-)
-
-@Serializable
-data class AbsMediaMetadata(
- val title: String? = null,
-)
-
-@Serializable
-data class AbsAudioFile(
- val ino: String = "",
- val metadata: AbsAudioFileMetadata? = null,
- val duration: Double = 0.0,
-)
-
-@Serializable
-data class AbsAudioFileMetadata(
- val filename: String? = null,
- val ext: String? = null,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiClientFactory.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiClientFactory.kt
deleted file mode 100644
index 9a035ae..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiClientFactory.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.bookshelfapi
-
-import android.util.Log
-import kotlinx.serialization.json.Json
-import okhttp3.OkHttpClient
-import okhttp3.logging.HttpLoggingInterceptor
-import org.dueattendant149.bookreader.domain.util.fixUriScheme
-import okhttp3.MediaType.Companion.toMediaType
-import retrofit2.Retrofit
-import retrofit2.converter.kotlinx.serialization.asConverterFactory
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Builds (and caches) a Retrofit-backed [BookshelfApiService] for the configured
- * bookshelf-api instance.
- */
-@Singleton
-class BookshelfApiClientFactory
- @Inject
- constructor(
- private val json: Json,
- private val okHttpClient: OkHttpClient,
- ) {
- private var cachedUrl: String? = null
- private var cachedClient: BookshelfApiService? = null
-
- @Synchronized
- fun provideClient(url: String): BookshelfApiService? {
- val fixedUrl = url.fixUriScheme()
- if (fixedUrl == cachedUrl && cachedClient != null) {
- return cachedClient
- }
-
- return runCatching {
- Retrofit
- .Builder()
- .client(
- okHttpClient
- .newBuilder()
- .addInterceptor(HttpLoggingInterceptor().apply {
- level = HttpLoggingInterceptor.Level.BASIC
- })
- .build(),
- )
- .baseUrl(fixedUrl)
- .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
- .build()
- .create(BookshelfApiService::class.java)
- }.onFailure {
- Log.e("BookshelfApiClientFactory", "Failed to create BookshelfApiService for $fixedUrl", it)
- }.getOrNull().also {
- cachedUrl = fixedUrl
- cachedClient = it
- }
- }
-
- fun clearCache() {
- cachedUrl = null
- cachedClient = null
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt
deleted file mode 100644
index 050c12f..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt
+++ /dev/null
@@ -1,158 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.bookshelfapi
-
-import okhttp3.MultipartBody
-import okhttp3.RequestBody
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadRequest
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadsListResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryItemsResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchRequest
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobsResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UploadBookResponse
-import org.dueattendant149.bookreader.data.settings.ServerSettings
-import retrofit2.Response
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Thin repository over [BookshelfApiService]. Returns raw Retrofit responses
- * so callers can decide how to map them to local entities.
- */
-@Singleton
-class BookshelfApiRepository
- @Inject
- constructor(
- private val clientFactory: BookshelfApiClientFactory,
- private val serverSettings: ServerSettings,
- ) {
- private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
-
- suspend fun health(): Boolean {
- val client = client() ?: return false
- return runCatching { client.health().isSuccessful }.getOrDefault(false)
- }
-
- suspend fun getLibraries(): Result> {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.getLibraries().unwrapBody().orEmpty() }
- }
-
- suspend fun getLibraryItems(libraryId: String): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.getLibraryItems(libraryId).unwrapBody() ?: LibraryItemsResponse() }
- }
-
- suspend fun searchLibrary(
- libraryId: String,
- query: String,
- ): Result> {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.searchLibrary(libraryId, query).unwrapBody().orEmpty() }
- }
-
- suspend fun search(request: SearchRequest): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.search(request).unwrapBody() ?: SearchResponse() }
- }
-
- suspend fun getBook(itemId: String): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.getBook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
- }
-
- suspend fun downloadEbook(itemId: String): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.downloadEbook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
- }
-
- suspend fun getAudioTracks(itemId: String): Result> {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.getAudioTracks(itemId).unwrapBody().orEmpty() }
- }
-
- suspend fun downloadAudioFile(
- itemId: String,
- fileId: String,
- ): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.downloadAudioFile(itemId, fileId).unwrapBody() ?: throw IllegalStateException("Empty body") }
- }
-
- suspend fun updateReadingProgress(
- itemId: String,
- request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.ProgressUpdateRequest,
- ): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.updateReadingProgress(itemId, request).let {} }
- }
-
- suspend fun updatePlaybackProgress(
- itemId: String,
- request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest,
- ): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.updatePlaybackProgress(itemId, request).let {} }
- }
-
- suspend fun getTtsEngines(): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.getTtsEngines().unwrapBody() ?: TtsEnginesResponse() }
- }
-
- suspend fun getTtsVoices(engine: String? = null): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.getTtsVoices(engine).unwrapBody() ?: TtsVoicesResponse() }
- }
-
- suspend fun createTtsJob(request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.createTtsJob(request).unwrapBody() ?: throw IllegalStateException("Empty body") }
- }
-
- suspend fun getTtsJobStatus(jobId: String): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.getTtsJobStatus(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") }
- }
-
- suspend fun listTtsJobs(): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.listTtsJobs().unwrapBody() ?: TtsJobsResponse() }
- }
-
- suspend fun downloadTtsAudio(jobId: String): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.downloadTtsAudio(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") }
- }
-
- suspend fun startDownload(request: DownloadRequest): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.startDownload(request).unwrapBody() ?: DownloadResponse() }
- }
-
- suspend fun listDownloads(): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.listDownloads().unwrapBody() ?: DownloadsListResponse() }
- }
-
- suspend fun uploadBook(
- file: MultipartBody.Part,
- bookType: RequestBody,
- ): Result {
- val client = client() ?: return Result.failure(serverNotConfigured())
- return safe { client.uploadBook(file, bookType).unwrapBody() ?: UploadBookResponse(success = false) }
- }
-
- private fun serverNotConfigured() = IllegalStateException("Bookshelf API URL not configured")
-
- private inline fun safe(block: () -> T): Result = runCatching(block)
-
- private fun Response.unwrapBody(): T? {
- if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}")
- return body()
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt
deleted file mode 100644
index 1541afe..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt
+++ /dev/null
@@ -1,158 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.bookshelfapi
-
-import okhttp3.ResponseBody
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.AudioTrackResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadRequest
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadsListResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryItemsResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PodcastRequest
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PodcastResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.ProgressUpdateRequest
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchRequest
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobsResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UploadBookResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.YandexRequest
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.YandexResponse
-import okhttp3.MultipartBody
-import okhttp3.RequestBody
-import retrofit2.Response
-import retrofit2.http.Body
-import retrofit2.http.GET
-import retrofit2.http.Multipart
-import retrofit2.http.Part
-import retrofit2.http.Path
-import retrofit2.http.Query
-import retrofit2.http.Streaming
-import retrofit2.http.POST
-
-/**
- * Retrofit description of the Bookshelf API (bookshelf-api:8073).
- * All endpoints are relative to the configured base URL.
- */
-interface BookshelfApiService {
-
- // Health
- @GET("health")
- suspend fun health(): Response
-
- // Libraries / books
- @GET("api/v1/books/libraries")
- suspend fun getLibraries(): Response>
-
- @Streaming
- @GET("api/v1/books/{itemId}/ebook")
- suspend fun downloadEbook(
- @Path("itemId") itemId: String,
- ): Response
-
- @GET("api/v1/books/{itemId}/tracks")
- suspend fun getAudioTracks(
- @Path("itemId") itemId: String,
- ): Response>
-
- @Streaming
- @GET("api/v1/books/{itemId}/file/{fileId}")
- suspend fun downloadAudioFile(
- @Path("itemId") itemId: String,
- @Path("fileId") fileId: String,
- ): Response
-
- @Streaming
- @GET("api/v1/books/{itemId}")
- suspend fun getBook(
- @Path("itemId") itemId: String,
- ): Response
-
- @Streaming
- @POST("api/v1/books/{itemId}/progress")
- suspend fun updateReadingProgress(
- @Path("itemId") itemId: String,
- @Body request: ProgressUpdateRequest,
- ): Response
-
- @Streaming
- @POST("api/v1/books/{itemId}/playback-progress")
- suspend fun updatePlaybackProgress(
- @Path("itemId") itemId: String,
- @Body request: PlaybackProgressUpdateRequest,
- ): Response
-
- @GET("api/v1/books/library/{libraryId}/items")
- suspend fun getLibraryItems(
- @Path("libraryId") libraryId: String,
- ): Response
-
- @GET("api/v1/books/library/{libraryId}/search")
- suspend fun searchLibrary(
- @Path("libraryId") libraryId: String,
- @Query("q") query: String,
- ): Response>
-
- @POST("api/v1/search")
- suspend fun search(
- @Body request: SearchRequest,
- ): Response
-
- // TTS
- @GET("api/v1/tts/engines")
- suspend fun getTtsEngines(): Response
-
- @GET("api/v1/tts/voices")
- suspend fun getTtsVoices(
- @Query("engine") engine: String? = null,
- ): Response
-
- @POST("api/v1/tts")
- suspend fun createTtsJob(
- @Body request: TtsCreateRequest,
- ): Response
-
- @GET("api/v1/tts/{jobId}")
- suspend fun getTtsJobStatus(
- @Path("jobId") jobId: String,
- ): Response
-
- @GET("api/v1/tts/jobs/list")
- suspend fun listTtsJobs(): Response
-
- @Streaming
- @GET("api/v1/tts/{jobId}/download")
- suspend fun downloadTtsAudio(
- @Path("jobId") jobId: String,
- ): Response
-
- @Multipart
- @POST("api/v1/upload/book")
- suspend fun uploadBook(
- @Part file: MultipartBody.Part,
- @Part("book_type") bookType: RequestBody,
- ): Response
-
- // Downloads / sources
- @POST("api/v1/download")
- suspend fun startDownload(
- @Body request: DownloadRequest,
- ): Response
-
- @GET("api/v1/download/list")
- suspend fun listDownloads(): Response
-
- @POST("api/v1/podcasts")
- suspend fun downloadPodcast(
- @Body request: PodcastRequest,
- ): Response
-
- @POST("api/v1/sources/yandex")
- suspend fun downloadYandex(
- @Body request: YandexRequest,
- ): Response
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt
deleted file mode 100644
index fec67b8..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookreader.data.remote.bookshelfapi
-
-import android.util.Log
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
-import org.dueattendant149.bookreader.data.settings.ServerSettings
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class ServerStatusMonitor
- @Inject
- constructor(
- private val clientFactory: BookshelfApiClientFactory,
- private val serverSettings: ServerSettings,
- ) {
- private val _isOnline = MutableStateFlow(false)
- val isOnline = _isOnline.asStateFlow()
-
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
- private var pingJob: Job? = null
-
- fun start() {
- if (pingJob != null) return
- pingJob = scope.launch {
- while (true) {
- val online = checkHealth()
- _isOnline.value = online
- delay(30_000L)
- }
- }
- }
-
- fun stop() {
- pingJob?.cancel()
- pingJob = null
- }
-
- private suspend fun checkHealth(): Boolean {
- val url = serverSettings.getBookshelfUrl() ?: return false
- val client = clientFactory.provideClient(url) ?: return false
- return runCatching {
- val response = client.health()
- response.isSuccessful
- }.onFailure {
- Log.d("ServerStatusMonitor", "Health check failed: ${it.message}")
- }.getOrDefault(false)
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/AudioTrackResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/AudioTrackResponse.kt
deleted file mode 100644
index 64f8727..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/AudioTrackResponse.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class AudioTrackResponse(
- @SerialName("file_id")
- val fileId: String,
- val title: String = "",
- val duration: Double = 0.0,
- val size: Long = 0L,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/BookItemResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/BookItemResponse.kt
deleted file mode 100644
index bf2a1d1..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/BookItemResponse.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class BookItemResponse(
- val id: String,
- val title: String = "",
- val author: String = "",
- @SerialName("media_type")
- val mediaType: String = "",
- @SerialName("library_id")
- val libraryId: String = "",
- @SerialName("cover_url")
- val coverUrl: String = "",
- val duration: Double = 0.0,
- val size: Long = 0L,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/DownloadResponses.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/DownloadResponses.kt
deleted file mode 100644
index f7bb64e..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/DownloadResponses.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class DownloadRequest(
- val source: String = "",
- val title: String = "",
- val author: String = "",
- @SerialName("download_url")
- val downloadUrl: String? = null,
- @SerialName("magnet_url")
- val magnetUrl: String? = null,
- @SerialName("info_hash")
- val infoHash: String? = null,
- val md5: String? = null,
- val url: String? = null,
- @SerialName("media_type")
- val mediaType: String = "ebook",
- @SerialName("download_protocol")
- val downloadProtocol: String = "",
-)
-
-@Serializable
-data class DownloadResponse(
- val success: Boolean = false,
- val error: String? = null,
- @SerialName("download_id")
- val downloadId: String? = null,
-)
-
-@Serializable
-data class DownloadStatusResponse(
- val title: String = "",
- val status: String = "",
- val progress: Double = 0.0,
- val speed: String = "",
- val error: String? = null,
-)
-
-@Serializable
-data class DownloadsListResponse(
- val downloads: List = emptyList(),
-)
-
-@Serializable
-data class PodcastRequest(
- val url: String,
- val title: String? = null,
- val format: String = "m4a",
-)
-
-@Serializable
-data class PodcastResponse(
- val url: String = "",
- val title: String = "",
- val tracks: Int = 0,
- val files: List = emptyList(),
- @SerialName("abs_scan_triggered")
- val absScanTriggered: Boolean = false,
-)
-
-@Serializable
-data class YandexRequest(
- val bookid: String,
-)
-
-@Serializable
-data class YandexResponse(
- val bookid: String,
- val files: List = emptyList(),
- @SerialName("abs_scan_triggered")
- val absScanTriggered: Boolean = false,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/LibraryResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/LibraryResponse.kt
deleted file mode 100644
index af4ce25..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/LibraryResponse.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class LibraryResponse(
- val id: String,
- val name: String = "",
- @SerialName("media_type")
- val mediaType: String = "",
- @SerialName("item_count")
- val itemCount: Int = 0,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt
deleted file mode 100644
index 8776e6f..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-/**
- * Request body for updating the audio playback progress of a book on the bookshelf-api.
- */
-@Serializable
-data class PlaybackProgressUpdateRequest(
- val itemId: String,
- val libraryId: String,
- @SerialName("current_file")
- val currentFile: String,
- @SerialName("current_position")
- val currentPosition: Long,
- val duration: Long,
- @SerialName("updated_at")
- val updatedAt: Long,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt
deleted file mode 100644
index fb411a5..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-/**
- * Request body for updating the local reading progress of a book on the bookshelf-api.
- */
-@Serializable
-data class ProgressUpdateRequest(
- val itemId: String,
- val libraryId: String,
- val scrollIndex: Int,
- val scrollOffset: Int,
- val progress: Float,
- @SerialName("last_chapter")
- val lastChapter: String? = null,
- @SerialName("updated_at")
- val updatedAt: Long,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/SearchResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/SearchResponse.kt
deleted file mode 100644
index 6ecc8d4..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/SearchResponse.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class SearchResponse(
- val results: List = emptyList(),
- @SerialName("search_time_ms")
- val searchTimeMs: Int = 0,
- val total: Int = 0,
-)
-
-@Serializable
-data class SearchResultItemResponse(
- val source: String = "",
- val title: String = "",
- val author: String = "",
- val format: String = "",
- @SerialName("media_type")
- val mediaType: String = "",
- @SerialName("size_human")
- val sizeHuman: String = "",
- val seeders: Int? = null,
- val score: Double = 0.0,
- val guid: String = "",
- val md5: String = "",
- @SerialName("magnet_url")
- val magnetUrl: String = "",
- @SerialName("download_url")
- val downloadUrl: String = "",
- val url: String = "",
- @SerialName("cover_url")
- val coverUrl: String = "",
- @SerialName("info_hash")
- val infoHash: String = "",
- @SerialName("download_protocol")
- val downloadProtocol: String = "",
-)
-
-@Serializable
-data class SearchRequest(
- val query: String,
- val author: String? = null,
- @SerialName("media_type")
- val mediaType: String? = null,
- val format: String? = null,
- val language: String? = null,
- @SerialName("year_from")
- val yearFrom: Int? = null,
- @SerialName("year_to")
- val yearTo: Int? = null,
- val limit: Int = 50,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/TtsResponses.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/TtsResponses.kt
deleted file mode 100644
index 6f6cbe4..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/TtsResponses.kt
+++ /dev/null
@@ -1,91 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class TtsCreateRequest(
- @SerialName("book_id")
- val bookId: String,
- val engine: String = "silero",
- @SerialName("voice_id")
- val voiceId: String = "",
- val speed: Double = 1.0,
-)
-
-@Serializable
-data class TtsJobResponse(
- @SerialName("job_id")
- val jobId: String,
- @SerialName("book_id")
- val bookId: String,
- val title: String = "",
- val author: String = "",
- val engine: String = "",
- @SerialName("voice_id")
- val voiceId: String = "",
- val speed: Double = 1.0,
- val status: String = "",
- val progress: Double = 0.0,
- @SerialName("current_chapter")
- val currentChapter: String = "",
- @SerialName("total_chapters")
- val totalChapters: Int = 0,
- @SerialName("completed_chapters")
- val completedChapters: Int = 0,
- @SerialName("output_path")
- val outputPath: String = "",
- val error: String = "",
- @SerialName("created_at")
- val createdAt: Double = 0.0,
- @SerialName("started_at")
- val startedAt: Double = 0.0,
- @SerialName("completed_at")
- val completedAt: Double = 0.0,
-)
-
-@Serializable
-data class TtsJobsResponse(
- val jobs: List = emptyList(),
-)
-
-@Serializable
-data class TtsEnginesResponse(
- val engines: List = emptyList(),
-)
-
-@Serializable
-data class TtsVoicesResponse(
- val voices: List = emptyList(),
-)
-
-@Serializable
-data class TtsEngineResponse(
- val id: String,
- val name: String,
- val capabilities: TtsEngineCapabilitiesResponse,
-)
-
-@Serializable
-data class TtsEngineCapabilitiesResponse(
- @SerialName("supports_streaming")
- val supportsStreaming: Boolean = false,
- @SerialName("supports_cloning")
- val supportsCloning: Boolean = false,
- @SerialName("max_text_length")
- val maxTextLength: Int = 5000,
- @SerialName("needs_network")
- val needsNetwork: Boolean = false,
-)
-
-@Serializable
-data class TtsVoiceResponse(
- val id: String,
- val name: String,
- val language: String,
- val engine: String,
- val gender: String = "",
- val quality: String = "",
- @SerialName("requires_reference")
- val requiresReference: Boolean = false,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UnifiedItemResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UnifiedItemResponse.kt
deleted file mode 100644
index d157d2b..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UnifiedItemResponse.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class LibraryItemsResponse(
- val library: LibraryResponse = LibraryResponse(""),
- val items: List = emptyList(),
-)
-
-@Serializable
-data class UnifiedItemResponse(
- val id: String,
- val title: String = "",
- val authors: List = emptyList(),
- val author: String = "",
- val type: String = "",
- @SerialName("media_type")
- val mediaType: String = "",
- @SerialName("library_id")
- val libraryId: String = "",
- @SerialName("cover_url")
- val coverUrl: String = "",
- val duration: Double = 0.0,
- val size: Long = 0L,
- @SerialName("progress")
- val progress: UnifiedItemProgressResponse? = null,
-)
-
-@Serializable
-data class UnifiedItemProgressResponse(
- val reading: ReadingProgressResponse? = null,
- val playback: PlaybackProgressResponse? = null,
-)
-
-@Serializable
-data class ReadingProgressResponse(
- @SerialName("itemId")
- val itemId: String = "",
- @SerialName("libraryId")
- val libraryId: String = "",
- @SerialName("scrollIndex")
- val scrollIndex: Int = 0,
- @SerialName("scrollOffset")
- val scrollOffset: Int = 0,
- val progress: Float = 0f,
- @SerialName("last_chapter")
- val lastChapter: String? = null,
- @SerialName("updatedAt")
- val updatedAt: Long = 0L,
-)
-
-@Serializable
-data class PlaybackProgressResponse(
- @SerialName("itemId")
- val itemId: String = "",
- @SerialName("libraryId")
- val libraryId: String = "",
- @SerialName("current_file")
- val currentFile: String = "",
- @SerialName("current_position")
- val currentPosition: Long = 0L,
- val duration: Long = 0L,
- @SerialName("updatedAt")
- val updatedAt: Long = 0L,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UploadBookResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UploadBookResponse.kt
deleted file mode 100644
index 755c7e8..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UploadBookResponse.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class UploadBookResponse(
- val success: Boolean = false,
- val filename: String = "",
- val type: String = "",
- val path: String = "",
- @SerialName("error")
- val errorMessage: String? = null,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/settings/ServerSettings.kt b/app/src/main/java/org/dueattendant149/bookreader/data/settings/ServerSettings.kt
deleted file mode 100644
index 4bb5b4e..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/data/settings/ServerSettings.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package org.dueattendant149.bookreader.data.settings
-
-import android.content.Context
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import dagger.hilt.android.qualifiers.ApplicationContext
-import javax.inject.Inject
-import javax.inject.Singleton
-
-private const val PREFS_NAME = "book_reader_server_settings"
-private const val KEY_BOOKSHELF_URL = "bookshelf_url"
-private const val KEY_ABS_URL = "abs_url"
-private const val KEY_ABS_TOKEN = "abs_token"
-
-/**
- * Minimal server URL/token storage. Replaces the full DataStore-based settings
- * from Book's Story for Phase 2 backend scaffolding.
- */
-@Singleton
-class ServerSettings
- @Inject
- constructor(
- @ApplicationContext context: Context,
- ) {
- private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
-
- fun getBookshelfUrl(): String? = prefs.getString(KEY_BOOKSHELF_URL, null)
-
- fun setBookshelfUrl(url: String) {
- prefs.edit { putString(KEY_BOOKSHELF_URL, url) }
- }
-
- fun getAbsUrl(): String? = prefs.getString(KEY_ABS_URL, null)
-
- fun setAbsUrl(url: String) {
- prefs.edit { putString(KEY_ABS_URL, url) }
- }
-
- fun getAbsToken(): String? = prefs.getString(KEY_ABS_TOKEN, null)
-
- fun setAbsToken(token: String) {
- prefs.edit { putString(KEY_ABS_TOKEN, token) }
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt b/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt
deleted file mode 100644
index 9f8b78b..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package org.dueattendant149.bookreader.di
-
-import android.content.Context
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.components.SingletonComponent
-import kotlinx.serialization.json.Json
-import okhttp3.OkHttpClient
-import org.dueattendant149.bookreader.data.remote.audiobookshelf.AudiobookshelfApiClientFactory
-import org.dueattendant149.bookreader.data.remote.audiobookshelf.AudiobookshelfRepository
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiClientFactory
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
-import org.dueattendant149.bookreader.data.settings.ServerSettings
-import javax.inject.Singleton
-
-@Module
-@InstallIn(SingletonComponent::class)
-object BackendModule {
-
- @Provides
- @Singleton
- fun provideServerSettings(@ApplicationContext context: Context): ServerSettings = ServerSettings(context)
-
- @Provides
- @Singleton
- fun provideBookshelfApiClientFactory(
- json: Json,
- okHttpClient: OkHttpClient,
- ): BookshelfApiClientFactory = BookshelfApiClientFactory(json, okHttpClient)
-
- @Provides
- @Singleton
- fun provideBookshelfApiRepository(
- clientFactory: BookshelfApiClientFactory,
- serverSettings: ServerSettings,
- ): BookshelfApiRepository = BookshelfApiRepository(clientFactory, serverSettings)
-
- @Provides
- @Singleton
- fun provideAudiobookshelfApiClientFactory(
- json: Json,
- okHttpClient: OkHttpClient,
- ): AudiobookshelfApiClientFactory = AudiobookshelfApiClientFactory(json, okHttpClient)
-
- @Provides
- @Singleton
- fun provideAudiobookshelfRepository(
- clientFactory: AudiobookshelfApiClientFactory,
- serverSettings: ServerSettings,
- ): AudiobookshelfRepository = AudiobookshelfRepository(clientFactory, serverSettings)
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/di/NetworkModule.kt b/app/src/main/java/org/dueattendant149/bookreader/di/NetworkModule.kt
deleted file mode 100644
index 9398e1b..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/di/NetworkModule.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package org.dueattendant149.bookreader.di
-
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import kotlinx.serialization.json.Json
-import okhttp3.OkHttpClient
-import okhttp3.logging.HttpLoggingInterceptor
-import javax.inject.Singleton
-
-@Module
-@InstallIn(SingletonComponent::class)
-object NetworkModule {
-
- @Provides
- @Singleton
- fun provideJson(): Json = Json {
- ignoreUnknownKeys = true
- explicitNulls = false
- isLenient = true
- }
-
- @Provides
- @Singleton
- fun provideOkHttpClient(): OkHttpClient = OkHttpClient
- .Builder()
- .addInterceptor(
- HttpLoggingInterceptor().apply {
- level = HttpLoggingInterceptor.Level.BASIC
- }
- )
- .build()
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/di/PlaybackModule.kt b/app/src/main/java/org/dueattendant149/bookreader/di/PlaybackModule.kt
deleted file mode 100644
index 5434d28..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/di/PlaybackModule.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package org.dueattendant149.bookreader.di
-
-import android.content.Context
-import androidx.media3.exoplayer.ExoPlayer
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.components.SingletonComponent
-import javax.inject.Singleton
-
-@Module
-@InstallIn(SingletonComponent::class)
-object PlaybackModule {
-
- @Provides
- @Singleton
- fun provideExoPlayer(@ApplicationContext context: Context): ExoPlayer =
- ExoPlayer.Builder(context).build()
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/domain/util/UriUtils.kt b/app/src/main/java/org/dueattendant149/bookreader/domain/util/UriUtils.kt
deleted file mode 100644
index feedbf8..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/domain/util/UriUtils.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-package org.dueattendant149.bookreader.domain.util
-
-import android.net.Uri
-
-private val URL_SCHEME_REGEX = Regex("^[hH][tT][tT][pP][sS]?://")
-
-/**
- * Returns true if the string is non-blank, has an http/https scheme,
- * and can be parsed by [Uri.parse] into a host-bearing URI.
- */
-fun String.isValidUri(): Boolean {
- val trimmed = trim()
- if (trimmed.isBlank()) return false
- if (!trimmed.hasUriScheme()) return false
-
- val uri = runCatching { Uri.parse(trimmed) }.getOrNull() ?: return false
- return !uri.host.isNullOrBlank()
-}
-
-/**
- * Returns true if the string starts with http:// or https:// (case-insensitive).
- */
-fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this)
-
-private val IP_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$")
-
-/**
- * Adds https:// (or http:// for bare IP addresses) if the string has no scheme.
- */
-fun String.ensureUriScheme(): String {
- val trimmed = trim()
- if (trimmed.hasUriScheme()) return trimmed
- val hostPart = trimmed.substringBefore("/")
- val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://"
- return "$scheme$trimmed"
-}
-
-/**
- * Adds a trailing slash if missing.
- */
-fun String.ensureTrailingSlash(): String {
- val trimmed = trim()
- return if (trimmed.endsWith("/")) trimmed else "$trimmed/"
-}
-
-/**
- * Normalizes a URL for Retrofit: trims whitespace, adds a scheme if missing,
- * and ensures a trailing slash.
- */
-fun String.normalizeUri(): String = ensureUriScheme().ensureTrailingSlash()
-
-/**
- * Legacy alias for [normalizeUri].
- */
-fun String.fixUriScheme(): String = normalizeUri()
-
-/**
- * Derives a likely Audiobookshelf URL from a Bookshelf API URL.
- * Replaces a known bookshelf-api port (8073) with the default ABS port (13378),
- * or appends :13378 when no port is present. Returns null if the input is invalid.
- */
-fun String.deriveAbsUrl(): String? {
- if (!isValidUri()) return null
- val normalized = normalizeUri()
- val uri = Uri.parse(normalized)
- val host = uri.host ?: return null
- val port = uri.port
- val scheme = uri.scheme?.lowercase() ?: "https"
-
- val derivedPort = when (port) {
- 8073 -> 13378
- -1 -> 13378
- else -> port
- }
- return "$scheme://$host:$derivedPort/"
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/reader/ChapterDrawer.kt b/app/src/main/java/org/dueattendant149/bookreader/reader/ChapterDrawer.kt
deleted file mode 100644
index 7d8b08f..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/reader/ChapterDrawer.kt
+++ /dev/null
@@ -1,95 +0,0 @@
-package org.dueattendant149.bookreader.reader
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.rememberModalBottomSheetState
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-
-data class ChapterItem(
- val title: String,
- val pageIndex: Int,
- val progress: Float = 0f,
-)
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun ChapterDrawer(
- chapters: List,
- currentChapterIndex: Int,
- onChapterSelected: (Int) -> Unit,
- onDismiss: () -> Unit,
-) {
- val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
-
- ModalBottomSheet(
- onDismissRequest = onDismiss,
- sheetState = sheetState,
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Text(
- text = "Chapters",
- style = MaterialTheme.typography.titleLarge,
- modifier = Modifier.padding(bottom = 12.dp)
- )
-
- LazyColumn {
- itemsIndexed(chapters) { index, chapter ->
- val isCurrent = index == currentChapterIndex
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable { onChapterSelected(index) }
- .padding(vertical = 12.dp, horizontal = 4.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = chapter.title,
- style = if (isCurrent) MaterialTheme.typography.titleMedium
- else MaterialTheme.typography.bodyMedium,
- color = if (isCurrent) MaterialTheme.colorScheme.primary
- else MaterialTheme.colorScheme.onSurface,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- Text(
- text = "${(chapter.progress * 100).toInt()}%",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- if (isCurrent) {
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = "●",
- color = MaterialTheme.colorScheme.primary,
- style = MaterialTheme.typography.bodySmall
- )
- }
- }
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/dueattendant149/bookreader/reader/Checkpoint.kt b/app/src/main/java/org/dueattendant149/bookreader/reader/Checkpoint.kt
deleted file mode 100644
index 30c2016..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/reader/Checkpoint.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package org.dueattendant149.bookreader.reader
-
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class Checkpoint(
- val bookId: String,
- val chapterIndex: Int = 0,
- val pageIndex: Int = 0,
- val scrollOffset: Int = 0,
- val progress: Float = 0f,
- val timestamp: Long = System.currentTimeMillis(),
-)
diff --git a/app/src/main/java/org/dueattendant149/bookreader/rsvp/ReaderText.kt b/app/src/main/java/org/dueattendant149/bookreader/rsvp/ReaderText.kt
deleted file mode 100644
index c8399df..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/rsvp/ReaderText.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package org.dueattendant149.bookreader.rsvp
-
-sealed class ReaderText {
- data class Paragraph(val text: String) : ReaderText()
- data class Heading(val text: String) : ReaderText()
- data class Chapter(val title: String) : ReaderText()
- data class Separator(val text: String = "") : ReaderText()
- data class Image(val alt: String = "") : ReaderText()
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpEngine.kt b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpEngine.kt
deleted file mode 100644
index 8bf2271..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpEngine.kt
+++ /dev/null
@@ -1,173 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookreader.rsvp
-
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-
-/**
- * Drives RSVP playback for a fixed token stream.
- *
- * @param scope scope used for the playback loop (typically `viewModelScope`).
- */
-class RsvpEngine(private val scope: CoroutineScope) {
-
- data class State(
- val currentIndex: Int = 0,
- val isPlaying: Boolean = false,
- val tokens: List = emptyList(),
- val wpm: Int = 350,
- val pauseOnParagraphEnd: Boolean = true,
- val pauseOnChapterEnd: Boolean = true,
- val pauseOnLongWords: Boolean = true,
- ) {
- val progress: Float
- get() = if (tokens.isEmpty()) 0f
- else currentIndex.toFloat() / tokens.lastIndex.coerceAtLeast(1)
-
- val currentToken: RsvpToken?
- get() = tokens.getOrNull(currentIndex)
-
- val isAtEnd: Boolean
- get() = tokens.isNotEmpty() && currentIndex >= tokens.lastIndex
- }
-
- private val _state = MutableStateFlow(State())
- val state: StateFlow = _state.asStateFlow()
-
- private var playbackJob: Job? = null
-
- fun setTokens(tokens: List) {
- stop()
- _state.value = _state.value.copy(
- tokens = tokens,
- currentIndex = 0,
- isPlaying = false,
- )
- }
-
- fun setWpm(wpm: Int) {
- _state.value = _state.value.copy(wpm = wpm.coerceIn(50, 1500))
- }
-
- fun setPauseOnParagraphEnd(value: Boolean) {
- _state.value = _state.value.copy(pauseOnParagraphEnd = value)
- }
-
- fun setPauseOnChapterEnd(value: Boolean) {
- _state.value = _state.value.copy(pauseOnChapterEnd = value)
- }
-
- fun setPauseOnLongWords(value: Boolean) {
- _state.value = _state.value.copy(pauseOnLongWords = value)
- }
-
- fun seekToIndex(index: Int) {
- val s = _state.value
- if (s.tokens.isEmpty()) return
- val clamped = index.coerceIn(0, s.tokens.lastIndex)
- _state.value = s.copy(currentIndex = clamped)
- }
-
- fun seekToProgress(progress: Float) {
- val s = _state.value
- if (s.tokens.isEmpty()) return
- val clamped = progress.coerceIn(0f, 1f)
- val idx = (clamped * s.tokens.lastIndex).toInt()
- _state.value = s.copy(currentIndex = idx)
- }
-
- fun skipForward(count: Int = 10) {
- seekToIndex(_state.value.currentIndex + count)
- }
-
- fun skipBackward(count: Int = 10) {
- seekToIndex(_state.value.currentIndex - count)
- }
-
- fun play() {
- val s = _state.value
- if (s.tokens.isEmpty()) return
- if (s.isAtEnd) {
- seekToIndex(0)
- }
- if (_state.value.isPlaying) return
- _state.value = _state.value.copy(isPlaying = true)
- startPlaybackLoop()
- }
-
- fun pause() {
- _state.value = _state.value.copy(isPlaying = false)
- playbackJob?.cancel()
- playbackJob = null
- }
-
- fun toggle() {
- if (_state.value.isPlaying) pause() else play()
- }
-
- fun stop() {
- pause()
- }
-
- private fun startPlaybackLoop() {
- playbackJob?.cancel()
- playbackJob = scope.launch {
- while (isActive && _state.value.isPlaying) {
- val snapshot = _state.value
- if (snapshot.isAtEnd) {
- _state.value = snapshot.copy(isPlaying = false)
- return@launch
- }
-
- val token = snapshot.tokens[snapshot.currentIndex]
- delay(delayMsFor(token, snapshot))
-
- val after = _state.value
- if (!after.isPlaying) return@launch
- if (after.tokens.isEmpty()) return@launch
-
- _state.value = after.copy(
- currentIndex = (after.currentIndex + 1).coerceAtMost(after.tokens.lastIndex)
- )
- }
- }
- }
-
- /**
- * Compute the delay (in ms) before advancing past [token].
- *
- * Base = 60_000 / wpm, adjusted by:
- * - per-word multiplier (long words / digits)
- * - paragraph-end bonus (configurable, default +200 ms)
- * - chapter-end bonus (configurable, default +400 ms)
- */
- private fun delayMsFor(token: RsvpToken, snapshot: State): Long {
- val wpm = snapshot.wpm.coerceAtLeast(50)
- val baseMs = 60_000.0 / wpm
- var ms = baseMs * token.multiplier
-
- if (token.isParagraphEnd && snapshot.pauseOnParagraphEnd) {
- ms += 200.0
- }
- if (token.isChapterEnd && snapshot.pauseOnChapterEnd) {
- ms += 400.0
- }
- if (token.multiplier > 1.2f && snapshot.pauseOnLongWords) {
- // multiplier already applied above; keep it
- } else if (token.multiplier > 1.2f && !snapshot.pauseOnLongWords) {
- ms = baseMs
- }
- return ms.toLong().coerceAtLeast(20L)
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpToken.kt b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpToken.kt
deleted file mode 100644
index 69edbeb..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpToken.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookreader.rsvp
-
-import androidx.compose.runtime.Immutable
-
-/**
- * One word/segment in the RSVP stream.
- *
- * @param word the original word as it appears in the book.
- * @param pivotIndex index of the optimal recognition point within [word];
- * characters before are [prefix], the pivot is [pivot] char, after are [suffix].
- * @param isParagraphEnd true if the token is the last word of a paragraph (longer pause).
- * @param isChapterEnd true if the token follows a chapter heading.
- * @param multiplier duration multiplier (1.0 baseline) for per-word pacing.
- */
-@Immutable
-data class RsvpToken(
- val word: String,
- val pivotIndex: Int,
- val isParagraphEnd: Boolean,
- val isChapterEnd: Boolean,
- val multiplier: Float = 1f,
-) {
- val prefix: String
- get() = word.substring(0, pivotIndex)
-
- val pivot: String
- get() = word.substring(pivotIndex, pivotIndex + 1)
-
- val suffix: String
- get() = word.substring(pivotIndex + 1)
-}
-
-/**
- * Optimal recognition point (ORP) lookup for word lengths 1..13+.
- * Indices are 0-based positions of the focal character.
- * Values follow the heuristic table from Spritz/speed-reading literature:
- * 1→0, 2→0, 3→1, 4→1, 5→1, 6→2, 7→2, 8→2, 9→2, 10→3, 11→3, 12→3, 13→3.
- */
-object OrpTable {
- private val TABLE = intArrayOf(0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3)
-
- fun pivotFor(word: String): Int {
- val len = word.length
- if (len == 0) return 0
- if (len <= TABLE.size) return TABLE[len - 1]
- return (len * 0.3f).toInt().coerceIn(1, len - 1)
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpTokenizer.kt b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpTokenizer.kt
deleted file mode 100644
index b23d76b..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpTokenizer.kt
+++ /dev/null
@@ -1,136 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookreader.rsvp
-
-import org.dueattendant149.bookreader.rsvp.ReaderText
-
-/**
- * Converts a list of [ReaderText] blocks (as loaded by the Book's Story reader)
- * into a flat list of [RsvpToken]s suitable for RSVP playback.
- *
- * - Skips [ReaderText.Chapter], [ReaderText.Separator], [ReaderText.Image].
- * - Marks the last word of each paragraph (blank line) as `isParagraphEnd = true`.
- * - Marks the first word after a chapter heading as `isChapterEnd = true`.
- * - Strips punctuation-only tokens and empty whitespace tokens.
- */
-object RsvpTokenizer {
-
- /**
- * Tokenize a list of [ReaderText] blocks (as produced by the reader parser).
- */
- fun tokenizeReaderText(blocks: List): List {
- val tokens = ArrayList(blocks.size * 8)
- var lastWasParagraphEnd = false
- var pendingChapterEnd = false
-
- for (block in blocks) {
- when (block) {
- is ReaderText.Chapter -> {
- pendingChapterEnd = true
- lastWasParagraphEnd = false
- }
-
- is ReaderText.Paragraph -> {
- val raw = block.text
- if (raw.isBlank()) {
- lastWasParagraphEnd = true
- } else {
- tokenizeLine(
- line = raw,
- isParagraphEnd = lastWasParagraphEnd,
- isChapterEnd = pendingChapterEnd,
- out = tokens,
- )
- lastWasParagraphEnd = false
- pendingChapterEnd = false
- }
- }
-
- is ReaderText.Heading -> {
- val raw = block.text
- if (raw.isNotBlank()) {
- tokenizeLine(
- line = raw,
- isParagraphEnd = lastWasParagraphEnd,
- isChapterEnd = pendingChapterEnd,
- out = tokens,
- )
- lastWasParagraphEnd = false
- pendingChapterEnd = false
- }
- }
-
- is ReaderText.Separator -> lastWasParagraphEnd = true
- is ReaderText.Image -> Unit
- }
- }
-
- return tokens
- }
-
- private fun tokenizeLine(
- line: String,
- isParagraphEnd: Boolean,
- isChapterEnd: Boolean,
- out: MutableList,
- ) {
- val words = line.split(WORD_SPLIT_REGEX).filter { it.isNotBlank() }
- if (words.isEmpty()) return
-
- val lastIndex = words.lastIndex
- for ((idx, word) in words.withIndex()) {
- val cleaned = cleanWord(word)
- if (cleaned.isEmpty()) continue
- val pivot = OrpTable.pivotFor(cleaned)
- val paragraphEnd = isParagraphEnd && idx == lastIndex
- val multiplier = computeMultiplier(cleaned)
- out.add(
- RsvpToken(
- word = cleaned,
- pivotIndex = pivot,
- isParagraphEnd = paragraphEnd,
- isChapterEnd = isChapterEnd && idx == 0,
- multiplier = multiplier,
- )
- )
- }
- }
-
- private val WORD_SPLIT_REGEX = Regex("\\s+")
-
- private fun cleanWord(raw: String): String {
- val sb = StringBuilder(raw.length)
- var i = 0
- val len = raw.length
- while (i < len) {
- val c = raw[i]
- if (c.isLetterOrDigit() || c == '-' || c == '\'') {
- sb.append(c)
- }
- i++
- }
- return sb.toString()
- }
-
- /**
- * Per-word pacing multiplier.
- *
- * - Long words (>9 chars) get +30% time for recognition.
- * - Numbers/digits get +20% time.
- * - Baseline 1.0 for normal words.
- */
- private fun computeMultiplier(word: String): Float {
- val len = word.length
- val base = when {
- len > 9 -> 1.3f
- len > 6 -> 1.1f
- else -> 1.0f
- }
- val hasDigit = word.any { it.isDigit() }
- return if (hasDigit) base + 0.2f else base
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepository.kt b/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepository.kt
deleted file mode 100644
index 0f5c2e6..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepository.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.dueattendant149.bookreader.tts
-
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
-import okhttp3.ResponseBody
-
-interface RemoteTtsRepository {
- suspend fun fetchEngines(): Result
- suspend fun fetchVoices(engine: String? = null): Result
- suspend fun createJob(request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest): Result
- suspend fun getJob(jobId: String): Result
- suspend fun downloadAudio(jobId: String): Result
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepositoryImpl.kt
deleted file mode 100644
index ce836e0..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepositoryImpl.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package org.dueattendant149.bookreader.tts
-
-import okhttp3.ResponseBody
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
-import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class RemoteTtsRepositoryImpl
- @Inject
- constructor(
- private val bookshelfApi: BookshelfApiRepository,
- ) : RemoteTtsRepository {
- override suspend fun fetchEngines(): Result = bookshelfApi.getTtsEngines()
- override suspend fun fetchVoices(engine: String?): Result = bookshelfApi.getTtsVoices(engine)
- override suspend fun createJob(request: TtsCreateRequest): Result = bookshelfApi.createTtsJob(request)
- override suspend fun getJob(jobId: String): Result = bookshelfApi.getTtsJobStatus(jobId)
- override suspend fun downloadAudio(jobId: String): Result = bookshelfApi.downloadTtsAudio(jobId)
- }
diff --git a/app/src/main/java/org/dueattendant149/bookreader/tts/TtsModels.kt b/app/src/main/java/org/dueattendant149/bookreader/tts/TtsModels.kt
deleted file mode 100644
index a632e0f..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/tts/TtsModels.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookreader.tts
-
-data class TtsEngine(
- val id: String,
- val name: String,
- val supportsStreaming: Boolean,
- val supportsCloning: Boolean,
- val maxTextLength: Int,
- val needsNetwork: Boolean,
-)
-
-data class TtsVoice(
- val id: String,
- val name: String,
- val language: String,
- val engine: String,
- val gender: String,
- val quality: String,
- val requiresReference: Boolean,
-)
-
-data class TtsJob(
- val jobId: String,
- val bookId: String,
- val title: String,
- val author: String,
- val engine: String,
- val voiceId: String,
- val speed: Double,
- val status: String,
- val progress: Double,
- val currentChapter: String,
- val totalChapters: Int,
- val completedChapters: Int,
- val outputPath: String,
- val error: String,
- val createdAt: Double,
- val startedAt: Double,
- val completedAt: Double,
-) {
- val isCompleted: Boolean get() = status == "completed"
- val isFailed: Boolean get() = status == "failed" || status == "error"
- val isRunning: Boolean get() = status == "running" || status == "queued" || status == "pending"
-}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/work/Workers.kt b/app/src/main/java/org/dueattendant149/bookreader/work/Workers.kt
deleted file mode 100644
index b487f14..0000000
--- a/app/src/main/java/org/dueattendant149/bookreader/work/Workers.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package org.dueattendant149.bookreader.work
-
-import android.content.Context
-import androidx.work.CoroutineWorker
-import androidx.work.WorkerParameters
-
-/**
- * Stub for CacheDownloadWorker. No-op until full Book's Story data layer is ported.
- */
-class CacheDownloadWorker(
- appContext: Context,
- params: WorkerParameters,
-) : CoroutineWorker(appContext, params) {
- override suspend fun doWork(): Result = Result.success()
-}
-
-/**
- * Stub for ProgressSyncWorker. No-op until full Book's Story data layer is ported.
- */
-class ProgressSyncWorker(
- appContext: Context,
- params: WorkerParameters,
-) : CoroutineWorker(appContext, params) {
- override suspend fun doWork(): Result = Result.success()
-}
-
-/**
- * Stub for TtsDownloadWorker. No-op until full Book's Story data layer is ported.
- */
-class TtsDownloadWorker(
- appContext: Context,
- params: WorkerParameters,
-) : CoroutineWorker(appContext, params) {
- override suspend fun doWork(): Result = Result.success()
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.kt
deleted file mode 100644
index 21048f6..0000000
--- a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package org.dueattendant149.bookreader.shared
-
-fun currentTimestamp(): Long = System.currentTimeMillis()
diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt
deleted file mode 100644
index 7027816..0000000
--- a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package org.dueattendant149.bookreader.shared.reader
-
-import android.util.Log
-import org.dueattendant149.bookreader.BuildConfig
-
-const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics"
-const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
-const val SharedEpubCutoffDiagnosticsTag = "EpistemeEpubCutoff"
-
-val SharedReaderDiagnosticsEnabled: Boolean = BuildConfig.DEBUG
-
-fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean {
- if (!BuildConfig.DEBUG) return false
- return tag == SharedEpubCutoffDiagnosticsTag ||
- runCatching { Log.isLoggable(tag, Log.DEBUG) }.getOrDefault(false)
-}
-
-fun writeSharedReaderDiagnostic(tag: String, message: String) {
- if (!BuildConfig.DEBUG) return
- Log.d(tag, message)
-}
-
-inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) {
- if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) {
- writeSharedReaderDiagnostic(tag, message())
- }
-}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4db678c..f8c6274 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2066,6 +2066,4 @@
Reader text, highlights, and locations stay unchanged.
%1$s -> %2$s
- Audio Playback
- Audio playback controls
diff --git a/build.gradle.kts b/build.gradle.kts
index 90f1ee0..5568021 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -3,10 +3,9 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.compose) apply false
- alias(libs.plugins.kotlin.serialization) apply false
- alias(libs.plugins.kotlin.ksp) apply false
- alias(libs.plugins.hilt) apply false
+ alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.kover) apply false
}
@@ -19,6 +18,7 @@ subprojects {
val rootTest = rootProject.tasks.named("test")
tasks.matching {
it.name == "allTests" ||
+ it.name == "desktopTest" ||
it.name.endsWith("DebugUnitTest")
}.configureEach {
rootTest.configure {
@@ -29,14 +29,3 @@ subprojects {
maxHeapSize = "4g"
}
}
-
-allprojects {
- configurations.all {
- resolutionStrategy {
- force("org.jetbrains.kotlin:kotlin-stdlib:2.1.20")
- force("org.jetbrains.kotlin:kotlin-stdlib-common:2.1.20")
- force("org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.20")
- force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.20")
- }
- }
-}
diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts
new file mode 100644
index 0000000..e0cd987
--- /dev/null
+++ b/desktopApp/build.gradle.kts
@@ -0,0 +1,1621 @@
+import org.gradle.api.GradleException
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.MapProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.JavaExec
+import org.gradle.api.tasks.Exec
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputDirectory
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.Sync
+import org.gradle.api.tasks.TaskAction
+import org.gradle.api.tasks.bundling.Compression
+import org.gradle.api.tasks.bundling.Tar
+import org.gradle.jvm.tasks.Jar
+import org.gradle.process.ExecOperations
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+import org.gradle.work.DisableCachingByDefault
+import java.io.File
+import java.security.MessageDigest
+import java.awt.RenderingHints
+import java.awt.image.BufferedImage
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.util.Properties
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+import java.util.zip.ZipOutputStream
+import javax.imageio.ImageIO
+import javax.inject.Inject
+
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.compose.multiplatform)
+}
+
+@DisableCachingByDefault(because = "Verification task has no outputs.")
+abstract class CheckBundledPdfiumRuntimeTask : DefaultTask() {
+ @get:Input
+ abstract val bundleRootPath: Property
+
+ @get:Input
+ abstract val libraryPath: Property
+
+ @TaskAction
+ fun checkRuntime() {
+ val bundleRoot = File(bundleRootPath.get())
+ val library = bundleRoot.resolve(libraryPath.get())
+ if (!library.isFile) {
+ throw GradleException(
+ "Missing bundled Pdfium runtime at ${library.absolutePath}. " +
+ "Expected ${libraryPath.get()} inside ${bundleRoot.absolutePath}."
+ )
+ }
+ }
+}
+
+@DisableCachingByDefault(because = "Renames package output produced by jpackage.")
+abstract class RenameDesktopMsiOutputTask : DefaultTask() {
+ @get:Input
+ abstract val msiDirectoryPath: Property
+
+ @get:Input
+ abstract val packageName: Property
+
+ @get:Input
+ abstract val packageVersion: Property
+
+ @get:Input
+ abstract val architecture: Property
+
+ @TaskAction
+ fun renameOutput() {
+ val msiDirectory = File(msiDirectoryPath.get())
+ val outputPackageName = packageName.get()
+ val outputPackageVersion = packageVersion.get()
+ val source = msiDirectory.resolve("$outputPackageName-$outputPackageVersion.msi")
+ if (!source.isFile) return
+
+ val target = msiDirectory.resolve("$outputPackageName-$outputPackageVersion-${architecture.get()}.msi")
+ if (target.exists() && !target.delete()) {
+ throw GradleException("Could not replace existing MSI at ${target.absolutePath}.")
+ }
+ if (!source.renameTo(target)) {
+ throw GradleException("Could not rename MSI from ${source.absolutePath} to ${target.absolutePath}.")
+ }
+ }
+}
+
+@DisableCachingByDefault(because = "Generates an MSIX manifest from package metadata.")
+abstract class GenerateDesktopMsixManifestTask : DefaultTask() {
+ @get:Input
+ abstract val identityName: Property
+
+ @get:Input
+ abstract val publisher: Property
+
+ @get:Input
+ abstract val publisherDisplayName: Property
+
+ @get:Input
+ abstract val packageName: Property
+
+ @get:Input
+ abstract val packageDescription: Property
+
+ @get:Input
+ abstract val packageVersion: Property
+
+ @get:Input
+ abstract val architecture: Property
+
+ @get:Input
+ abstract val executablePath: Property
+
+ @get:OutputFile
+ abstract val outputFile: RegularFileProperty
+
+ @TaskAction
+ fun generate() {
+ fun xmlEscaped(value: String): String {
+ return value.replace("&", "&")
+ .replace("\"", """)
+ .replace("'", "'")
+ .replace("<", "<")
+ .replace(">", ">")
+ }
+
+ val file = outputFile.get().asFile
+ file.parentFile.mkdirs()
+ file.writeText(
+ """
+
+
+
+
+ ${xmlEscaped(packageName.get())}
+ ${xmlEscaped(publisherDisplayName.get())}
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """.trimIndent() + "\n",
+ Charsets.UTF_8
+ )
+ }
+}
+
+@DisableCachingByDefault(because = "Generates fixed-size MSIX logo assets from the desktop icon.")
+abstract class GenerateDesktopMsixAssetsTask : DefaultTask() {
+ @get:InputFile
+ @get:PathSensitive(PathSensitivity.NONE)
+ abstract val sourceIconFile: RegularFileProperty
+
+ @get:OutputDirectory
+ abstract val outputDirectory: DirectoryProperty
+
+ @TaskAction
+ fun generate() {
+ val source = ImageIO.read(sourceIconFile.get().asFile)
+ ?: throw GradleException("Could not read MSIX source icon ${sourceIconFile.get().asFile.absolutePath}.")
+ val output = outputDirectory.get().asFile
+ output.mkdirs()
+ writePng(source, output.resolve("Square44x44Logo.png"), 44)
+ writePng(source, output.resolve("Square150x150Logo.png"), 150)
+ writePng(source, output.resolve("StoreLogo.png"), 50)
+ }
+
+ private fun writePng(source: BufferedImage, target: File, size: Int) {
+ val image = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB)
+ val graphics = image.createGraphics()
+ try {
+ graphics.setRenderingHint(
+ RenderingHints.KEY_INTERPOLATION,
+ RenderingHints.VALUE_INTERPOLATION_BICUBIC
+ )
+ graphics.setRenderingHint(
+ RenderingHints.KEY_RENDERING,
+ RenderingHints.VALUE_RENDER_QUALITY
+ )
+ graphics.drawImage(source, 0, 0, size, size, null)
+ } finally {
+ graphics.dispose()
+ }
+ ImageIO.write(image, "png", target)
+ }
+}
+
+@DisableCachingByDefault(because = "Packages the staged MSIX app image with Windows SDK makeappx.")
+abstract class PackageDesktopMsixTask @Inject constructor(
+ private val execOperations: ExecOperations
+) : DefaultTask() {
+ @get:InputDirectory
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val packageRootDirectory: DirectoryProperty
+
+ @get:OutputFile
+ abstract val outputFile: RegularFileProperty
+
+ @get:Input
+ abstract val makeAppxPath: Property
+
+ @get:Input
+ abstract val hostOsId: Property
+
+ @get:Input
+ abstract val hostArchId: Property
+
+ @TaskAction
+ fun packageMsix() {
+ if (hostOsId.get() != "windows" || hostArchId.get() != "x64") {
+ throw GradleException(
+ "MSIX packaging requires a Windows x64 packaging host. " +
+ "Current host: ${hostOsId.get()} ${hostArchId.get()}."
+ )
+ }
+
+ val makeAppx = File(makeAppxPath.get())
+ if (!makeAppx.isFile) {
+ throw GradleException(
+ "Windows SDK makeappx.exe was not found at ${makeAppx.absolutePath}. " +
+ "Install the Windows SDK MSIX packaging tools or set " +
+ "-PdesktopMakeAppxPath=."
+ )
+ }
+
+ val output = outputFile.get().asFile
+ output.parentFile.mkdirs()
+ if (output.exists() && !output.delete()) {
+ throw GradleException("Could not replace existing MSIX at ${output.absolutePath}.")
+ }
+
+ execOperations.exec {
+ executable = makeAppx.absolutePath
+ args(
+ "pack",
+ "/d",
+ packageRootDirectory.get().asFile.absolutePath,
+ "/p",
+ output.absolutePath,
+ "/o"
+ )
+ }
+ }
+}
+
+@DisableCachingByDefault(because = "Signs the MSIX package with Windows SDK signtool.")
+abstract class SignDesktopMsixTask @Inject constructor(
+ private val execOperations: ExecOperations
+) : DefaultTask() {
+ @get:InputFile
+ @get:PathSensitive(PathSensitivity.NONE)
+ abstract val unsignedMsixFile: RegularFileProperty
+
+ @get:InputFile
+ @get:PathSensitive(PathSensitivity.NONE)
+ abstract val certificateFile: RegularFileProperty
+
+ @get:Input
+ abstract val signToolPath: Property
+
+ @get:Input
+ abstract val certificatePassword: Property
+
+ @get:Input
+ abstract val timestampUrl: Property
+
+ @TaskAction
+ fun signMsix() {
+ val signTool = File(signToolPath.get())
+ if (!signTool.isFile) {
+ throw GradleException(
+ "Windows SDK signtool.exe was not found at ${signTool.absolutePath}. " +
+ "Install the Windows SDK or set -PdesktopSignToolPath=."
+ )
+ }
+
+ val signArgs = mutableListOf(
+ "sign",
+ "/fd",
+ "SHA256",
+ "/f",
+ certificateFile.get().asFile.absolutePath
+ )
+ val password = certificatePassword.get().trim()
+ if (password.isNotEmpty()) {
+ signArgs += listOf("/p", password)
+ }
+ val timestamp = timestampUrl.get().trim()
+ if (timestamp.isNotEmpty()) {
+ signArgs += listOf("/tr", timestamp, "/td", "SHA256")
+ }
+ signArgs += unsignedMsixFile.get().asFile.absolutePath
+
+ execOperations.exec {
+ executable = signTool.absolutePath
+ args(signArgs)
+ }
+ }
+}
+
+@DisableCachingByDefault(because = "Generates local desktop service config for native packages.")
+abstract class GenerateDesktopCloudConfigTask : DefaultTask() {
+ @get:Input
+ abstract val configValues: MapProperty
+
+ @get:OutputFile
+ abstract val outputFile: RegularFileProperty
+
+ @TaskAction
+ fun generate() {
+ val file = outputFile.get().asFile
+ file.parentFile.mkdirs()
+ file.writeText(
+ configValues.get().entries.joinToString(separator = "\n", postfix = "\n") { (key, value) ->
+ "$key=${value.replace("\\", "\\\\").replace("\n", "")}"
+ }
+ )
+ }
+}
+
+@DisableCachingByDefault(because = "Verification task has no outputs.")
+abstract class VerifyDesktopNativePackagingTask : DefaultTask() {
+ @get:Input
+ abstract val supportedHost: Property
+
+ @get:Input
+ abstract val hostOsId: Property
+
+ @get:Input
+ abstract val hostArchId: Property
+
+ @get:Input
+ abstract val missingStandardServiceConfig: ListProperty
+
+ @TaskAction
+ fun verify() {
+ if (!supportedHost.get()) {
+ throw GradleException(
+ "Desktop native packaging is currently release-supported only on Windows x64 and Linux x64. " +
+ "Current host: ${hostOsId.get()} ${hostArchId.get()}."
+ )
+ }
+ val missing = missingStandardServiceConfig.get()
+ if (missing.isNotEmpty()) {
+ throw GradleException(
+ "Standard desktop packages require account/sync service config. Missing: " +
+ missing.joinToString(", ") + ". " +
+ "Set DESKTOP_FIREBASE_WEB_API_KEY and DESKTOP_GOOGLE_OAUTH_CLIENT_ID, " +
+ "use -PdesktopFlavor=oss for the offline build, or set " +
+ "-PdesktopAllowUnconfiguredStandardServices=true for a local non-GA package."
+ )
+ }
+ }
+}
+
+@DisableCachingByDefault(because = "Generates AUR package metadata from the local Linux distributable.")
+abstract class PrepareDesktopAurPackageTask : DefaultTask() {
+ @get:Input
+ abstract val aurPackageName: Property
+
+ @get:Input
+ abstract val providedPackageName: Property
+
+ @get:Input
+ abstract val packageVersion: Property
+
+ @get:Input
+ abstract val packageRelease: Property
+
+ @get:Input
+ abstract val packageDescription: Property
+
+ @get:Input
+ abstract val appDisplayName: Property
+
+ @get:Input
+ abstract val installDirectoryName: Property
+
+ @get:Input
+ abstract val launcherName: Property
+
+ @get:Input
+ abstract val executableName: Property
+
+ @get:Input
+ abstract val sourceUrl: Property
+
+ @get:Input
+ abstract val projectUrl: Property
+
+ @get:InputFile
+ @get:PathSensitive(PathSensitivity.NONE)
+ abstract val linuxTarFile: RegularFileProperty
+
+ @get:OutputDirectory
+ abstract val outputDirectory: DirectoryProperty
+
+ @TaskAction
+ fun prepare() {
+ val output = outputDirectory.get().asFile
+ val sourceTar = linuxTarFile.get().asFile
+ if (!sourceTar.isFile) {
+ throw GradleException("Missing Linux tarball for AUR packaging: ${sourceTar.absolutePath}")
+ }
+
+ output.deleteRecursively()
+ output.mkdirs()
+
+ val stagedTar = output.resolve(sourceTar.name)
+ sourceTar.copyTo(stagedTar, overwrite = true)
+ val sha256 = stagedTar.sha256()
+ val configuredSourceUrl = sourceUrl.get().trim()
+ val sourceEntry = if (configuredSourceUrl.isBlank()) {
+ stagedTar.name
+ } else {
+ "${stagedTar.name}::$configuredSourceUrl"
+ }
+
+ output.resolve("PKGBUILD").writeText(
+ aurPkgbuild(
+ pkgname = aurPackageName.get(),
+ providedPackage = providedPackageName.get(),
+ pkgver = packageVersion.get(),
+ pkgrel = packageRelease.get(),
+ pkgdesc = packageDescription.get(),
+ appName = appDisplayName.get(),
+ installDir = installDirectoryName.get(),
+ launcher = launcherName.get(),
+ executable = executableName.get(),
+ source = sourceEntry,
+ sha256 = sha256,
+ projectUrl = projectUrl.get()
+ )
+ )
+ output.resolve(".SRCINFO").writeText(
+ aurSrcInfo(
+ pkgname = aurPackageName.get(),
+ providedPackage = providedPackageName.get(),
+ pkgver = packageVersion.get(),
+ pkgrel = packageRelease.get(),
+ pkgdesc = packageDescription.get(),
+ source = sourceEntry,
+ sha256 = sha256,
+ projectUrl = projectUrl.get()
+ )
+ )
+ }
+
+ private fun File.sha256(): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ inputStream().use { input ->
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ while (true) {
+ val read = input.read(buffer)
+ if (read < 0) break
+ digest.update(buffer, 0, read)
+ }
+ }
+ return digest.digest().joinToString("") { "%02x".format(it) }
+ }
+
+ private fun shellSingleQuoted(value: String): String {
+ return "'" + value.replace("'", "'\"'\"'") + "'"
+ }
+
+ private fun archRuntimeDependencies(): List {
+ return listOf(
+ "alsa-lib",
+ "atk",
+ "cairo",
+ "dbus",
+ "expat",
+ "fontconfig",
+ "freetype2",
+ "gcc-libs",
+ "gdk-pixbuf2",
+ "glib2",
+ "glibc",
+ "gtk3",
+ "libcups",
+ "libarchive",
+ "libsecret",
+ "libx11",
+ "libxcomposite",
+ "libxdamage",
+ "libxext",
+ "libxi",
+ "libxrandr",
+ "libxrender",
+ "libxtst",
+ "nss",
+ "pango",
+ "zlib"
+ )
+ }
+
+ private fun aurPkgbuild(
+ pkgname: String,
+ providedPackage: String,
+ pkgver: String,
+ pkgrel: String,
+ pkgdesc: String,
+ appName: String,
+ installDir: String,
+ launcher: String,
+ executable: String,
+ source: String,
+ sha256: String,
+ projectUrl: String
+ ): String {
+ val desktopFile = "$providedPackage.desktop"
+ val iconName = providedPackage
+ val depends = archRuntimeDependencies()
+ val mimeTypes = archDesktopMimeTypes()
+ return """
+pkgname=${shellSingleQuoted(pkgname)}
+pkgver=${shellSingleQuoted(pkgver)}
+pkgrel=${shellSingleQuoted(pkgrel)}
+pkgdesc=${shellSingleQuoted(pkgdesc)}
+arch=('x86_64')
+url=${shellSingleQuoted(projectUrl)}
+license=('AGPL-3.0-only')
+depends=(${depends.joinToString(" ") { shellSingleQuoted(it) }})
+provides=(${shellSingleQuoted(providedPackage)})
+conflicts=(${shellSingleQuoted(providedPackage)})
+source=(${shellSingleQuoted(source)})
+sha256sums=(${shellSingleQuoted(sha256)})
+options=('!debug')
+
+package() {
+ install -dm755 "${'$'}pkgdir/opt/$installDir"
+ cp -a "$installDir/." "${'$'}pkgdir/opt/$installDir/"
+ chmod 755 "${'$'}pkgdir/opt/$installDir/bin/$executable"
+
+ install -dm755 "${'$'}pkgdir/usr/bin"
+ ln -sf "/opt/$installDir/bin/$executable" "${'$'}pkgdir/usr/bin/$launcher"
+
+ install -Dm644 "${'$'}pkgdir/opt/$installDir/share/licenses/LICENSE" "${'$'}pkgdir/usr/share/licenses/${'$'}pkgname/LICENSE"
+
+ local icon_path
+ icon_path="${'$'}(find "${'$'}pkgdir/opt/$installDir" -name 'episteme_icon.png' -print -quit)"
+ if [[ -n "${'$'}icon_path" ]]; then
+ install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/icons/hicolor/512x512/apps/$iconName.png"
+ install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/pixmaps/$iconName.png"
+ fi
+
+ install -Dm644 /dev/stdin "${'$'}pkgdir/usr/share/applications/$desktopFile" <<'EOF'
+[Desktop Entry]
+Type=Application
+Name=$appName
+Comment=$pkgdesc
+Exec=$launcher %F
+Icon=$iconName
+Terminal=false
+Categories=Office;Viewer;
+MimeType=${mimeTypes.joinToString(";")};
+EOF
+}
+""".trimIndent() + "\n"
+ }
+
+ private fun aurSrcInfo(
+ pkgname: String,
+ providedPackage: String,
+ pkgver: String,
+ pkgrel: String,
+ pkgdesc: String,
+ source: String,
+ sha256: String,
+ projectUrl: String
+ ): String {
+ val depends = archRuntimeDependencies()
+ return """
+pkgbase = $pkgname
+ pkgdesc = $pkgdesc
+ pkgver = $pkgver
+ pkgrel = $pkgrel
+ url = $projectUrl
+ arch = x86_64
+ license = AGPL-3.0-only
+${depends.joinToString("\n") { "\tdepends = $it" }}
+ provides = $providedPackage
+ conflicts = $providedPackage
+ source = $source
+ sha256sums = $sha256
+
+pkgname = $pkgname
+""".trimIndent() + "\n"
+ }
+
+ private fun archDesktopMimeTypes(): List {
+ return listOf(
+ "application/pdf",
+ "application/epub+zip",
+ "application/x-mobipocket-ebook",
+ "application/vnd.amazon.ebook",
+ "application/vnd.amazon.mobi8-ebook",
+ "text/markdown",
+ "text/x-markdown",
+ "text/plain",
+ "text/html",
+ "application/xhtml+xml",
+ "application/x-fictionbook+xml",
+ "application/x-zip-compressed-fb2",
+ "application/zip",
+ "application/vnd.comicbook+zip",
+ "application/x-cbz",
+ "application/vnd.comicbook-rar",
+ "application/x-cbr",
+ "application/x-rar-compressed",
+ "application/x-cb7",
+ "application/x-7z-compressed",
+ "application/vnd.comicbook+tar",
+ "application/x-cbt",
+ "application/x-tar",
+ "application/tar",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "application/vnd.oasis.opendocument.text",
+ "application/x-vnd.oasis.opendocument.text-flat-xml"
+ )
+ }
+}
+
+@DisableCachingByDefault(because = "Strips stale jar signatures in-place after ProGuard rewrites signed dependencies.")
+abstract class StripInvalidJarSignaturesTask : DefaultTask() {
+ @get:Input
+ abstract val jarDirectoryPath: Property
+
+ @TaskAction
+ fun stripSignatures() {
+ val jarDirectory = File(jarDirectoryPath.get())
+ if (!jarDirectory.isDirectory) return
+
+ var strippedJarCount = 0
+ jarDirectory.walkTopDown()
+ .filter { it.isFile && it.extension.equals("jar", ignoreCase = true) }
+ .forEach { jar ->
+ val strippedEntries = stripInvalidJarSignatures(jar)
+ if (strippedEntries > 0) {
+ strippedJarCount += 1
+ logger.lifecycle("Stripped $strippedEntries stale jar signature entr${if (strippedEntries == 1) "y" else "ies"} from ${jar.name}")
+ }
+ }
+
+ if (strippedJarCount > 0) {
+ logger.lifecycle("Stripped stale jar signatures from $strippedJarCount ProGuard output jar${if (strippedJarCount == 1) "" else "s"}.")
+ }
+ }
+
+ private fun stripInvalidJarSignatures(jar: File): Int {
+ val temp = Files.createTempFile(jar.parentFile.toPath(), "${jar.nameWithoutExtension}-unsigned-", ".jar")
+ var strippedEntries = 0
+
+ ZipFile(jar).use { source ->
+ ZipOutputStream(Files.newOutputStream(temp)).use { target ->
+ val seenEntries = mutableSetOf()
+ val entries = source.entries()
+ while (entries.hasMoreElements()) {
+ val sourceEntry = entries.nextElement()
+ val entryName = sourceEntry.name
+ if (!seenEntries.add(entryName)) continue
+ if (isJarSignatureResource(entryName)) {
+ strippedEntries += 1
+ continue
+ }
+
+ val targetEntry = ZipEntry(entryName)
+ if (sourceEntry.time >= 0) {
+ targetEntry.time = sourceEntry.time
+ }
+ target.putNextEntry(targetEntry)
+ if (!sourceEntry.isDirectory) {
+ source.getInputStream(sourceEntry).use { input ->
+ input.copyTo(target)
+ }
+ }
+ target.closeEntry()
+ }
+ }
+ }
+
+ if (strippedEntries > 0) {
+ try {
+ Files.move(temp, jar.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
+ } catch (_: AtomicMoveNotSupportedException) {
+ Files.move(temp, jar.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ }
+ } else {
+ Files.deleteIfExists(temp)
+ }
+
+ return strippedEntries
+ }
+
+ private fun isJarSignatureResource(entryName: String): Boolean {
+ val normalized = entryName.replace('\\', '/').uppercase()
+ if (!normalized.startsWith("META-INF/")) return false
+
+ val metaInfName = normalized.removePrefix("META-INF/")
+ if (metaInfName.contains("/")) return false
+
+ return metaInfName.startsWith("SIG-") ||
+ metaInfName.endsWith(".SF") ||
+ metaInfName.endsWith(".DSA") ||
+ metaInfName.endsWith(".RSA") ||
+ metaInfName.endsWith(".EC")
+ }
+}
+
+fun desktopOsId(osName: String = System.getProperty("os.name")): String {
+ val normalized = osName.lowercase()
+ return when {
+ normalized.startsWith("windows") -> "windows"
+ normalized == "linux" || normalized.contains("linux") -> "linux"
+ normalized.startsWith("mac") || normalized.contains("darwin") -> "macos"
+ else -> "other"
+ }
+}
+
+fun desktopArchId(osArch: String = System.getProperty("os.arch")): String {
+ return when (osArch.lowercase()) {
+ "amd64", "x86_64", "x64" -> "x64"
+ "aarch64", "arm64" -> "arm64"
+ "x86", "i386", "i686" -> "x86"
+ else -> "unknown"
+ }
+}
+
+fun desktopSwtArtifactId(
+ osName: String = System.getProperty("os.name"),
+ osArch: String = System.getProperty("os.arch")
+): String? {
+ return when (desktopOsId(osName)) {
+ "windows" -> when (desktopArchId(osArch)) {
+ "arm64" -> "org.eclipse.swt.win32.win32.aarch64"
+ else -> "org.eclipse.swt.win32.win32.x86_64"
+ }
+
+ "linux" -> when (desktopArchId(osArch)) {
+ "arm64" -> "org.eclipse.swt.gtk.linux.aarch64"
+ else -> "org.eclipse.swt.gtk.linux.x86_64"
+ }
+
+ "macos" -> when (desktopArchId(osArch)) {
+ "arm64" -> "org.eclipse.swt.cocoa.macosx.aarch64"
+ else -> "org.eclipse.swt.cocoa.macosx.x86_64"
+ }
+
+ else -> null
+ }
+}
+
+fun desktopPdfiumDirectoryName(
+ osName: String = System.getProperty("os.name"),
+ osArch: String = System.getProperty("os.arch")
+): String {
+ return when (desktopOsId(osName)) {
+ "windows" -> "win-${desktopArchId(osArch)}-v8"
+ "linux" -> "linux-${desktopArchId(osArch)}-v8"
+ "macos" -> "mac-${desktopArchId(osArch)}-v8"
+ else -> "${desktopArchId(osArch)}-v8"
+ }
+}
+
+fun desktopPdfiumLibraryPath(
+ osName: String = System.getProperty("os.name"),
+ osArch: String = System.getProperty("os.arch")
+): String {
+ return when (desktopOsId(osName)) {
+ "windows" -> "bin/pdfium.dll"
+ "linux" -> "lib/libpdfium.so"
+ "macos" -> "lib/libpdfium.dylib"
+ else -> "lib/pdfium"
+ }
+}
+
+fun desktopJdkToolName(toolName: String, osName: String = System.getProperty("os.name")): String {
+ return if (desktopOsId(osName) == "windows") "$toolName.exe" else toolName
+}
+
+fun File.asDesktopJdkHome(): File {
+ val absolute = absoluteFile
+ return when {
+ absolute.isFile && absolute.parentFile?.name?.equals("bin", ignoreCase = true) == true ->
+ absolute.parentFile?.parentFile ?: absolute
+
+ absolute.isDirectory && absolute.name.equals("bin", ignoreCase = true) ->
+ absolute.parentFile ?: absolute
+
+ absolute.resolve("Contents/Home/bin").isDirectory ->
+ absolute.resolve("Contents/Home")
+
+ else -> absolute
+ }
+}
+
+fun File.desktopJdkTool(toolName: String, osName: String = System.getProperty("os.name")): File {
+ return resolve("bin/${desktopJdkToolName(toolName, osName)}")
+}
+
+fun File.isDesktopPackagingJdk(osName: String = System.getProperty("os.name")): Boolean {
+ return desktopJdkTool("java", osName).isFile &&
+ desktopJdkTool("jlink", osName).isFile &&
+ desktopJdkTool("jpackage", osName).isFile
+}
+
+fun File.desktopJdkMajorVersion(): Int? {
+ val releaseFile = resolve("release")
+ if (!releaseFile.isFile) return null
+
+ val version = runCatching {
+ releaseFile.useLines { lines ->
+ lines.firstOrNull { it.startsWith("JAVA_VERSION=") }
+ ?.substringAfter("=")
+ ?.trim()
+ ?.trim('"')
+ }
+ }.getOrNull() ?: return null
+
+ return version.removePrefix("1.").substringBefore(".").toIntOrNull()
+}
+
+fun safeChildDirectories(root: File): List {
+ return runCatching {
+ root.listFiles()?.filter { it.isDirectory }.orEmpty()
+ }.getOrDefault(emptyList())
+}
+
+fun stableDesktopJdkCandidates(candidates: List, preferredMajorVersion: Int = 21): List {
+ return candidates
+ .map { it.asDesktopJdkHome() }
+ .distinctBy { runCatching { it.canonicalPath }.getOrElse { _ -> it.absolutePath }.lowercase() }
+ .sortedWith(
+ compareBy { if (it.desktopJdkMajorVersion() == preferredMajorVersion) 0 else 1 }
+ .thenBy { it.desktopJdkMajorVersion() ?: Int.MAX_VALUE }
+ .thenBy { it.absolutePath.lowercase() }
+ )
+}
+
+fun desktopPathJdkCandidates(osName: String = System.getProperty("os.name")): List {
+ return System.getenv("PATH")
+ ?.split(File.pathSeparator)
+ .orEmpty()
+ .asSequence()
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .map { File(it).resolve(desktopJdkToolName("jpackage", osName)) }
+ .filter { it.isFile }
+ .mapNotNull { it.parentFile?.parentFile }
+ .toList()
+}
+
+fun desktopGradleJdkCandidates(): List {
+ val userHome = System.getProperty("user.home")?.let(::File) ?: return emptyList()
+ return stableDesktopJdkCandidates(safeChildDirectories(userHome.resolve(".gradle/jdks")))
+}
+
+fun desktopPlatformJdkCandidates(osName: String = System.getProperty("os.name")): List {
+ val roots = when (desktopOsId(osName)) {
+ "windows" -> listOfNotNull(
+ System.getenv("ProgramFiles")?.let { File(it, "Java") },
+ System.getenv("ProgramFiles")?.let { File(it, "Eclipse Adoptium") },
+ System.getenv("ProgramFiles")?.let { File(it, "Microsoft") },
+ System.getenv("ProgramFiles(x86)")?.let { File(it, "Java") }
+ )
+
+ "linux" -> listOf(
+ File("/usr/lib/jvm"),
+ File("/usr/java"),
+ File("/opt/java"),
+ File("/opt/jdk")
+ )
+
+ "macos" -> listOfNotNull(
+ File("/Library/Java/JavaVirtualMachines"),
+ System.getProperty("user.home")?.let { File(it, "Library/Java/JavaVirtualMachines") }
+ )
+
+ else -> emptyList()
+ }
+
+ return stableDesktopJdkCandidates(roots + roots.flatMap(::safeChildDirectories))
+}
+
+fun findDesktopPackagingJavaHome(
+ explicitCandidates: List,
+ implicitCandidates: List,
+ osName: String = System.getProperty("os.name")
+): File? {
+ val explicitJavaHome = explicitCandidates.firstOrNull { it.isNotBlank() }
+ if (explicitJavaHome != null) {
+ val candidate = File(explicitJavaHome).asDesktopJdkHome()
+ if (!candidate.isDesktopPackagingJdk(osName)) {
+ throw GradleException(
+ "Desktop packaging JDK must include java, jlink, and jpackage under " +
+ "${candidate.resolve("bin").absolutePath}. " +
+ "Set -PdesktopPackagingJavaHome= or DESKTOP_PACKAGING_JAVA_HOME to a full JDK."
+ )
+ }
+ return candidate
+ }
+
+ return implicitCandidates
+ .map { it.asDesktopJdkHome() }
+ .distinctBy { runCatching { it.canonicalPath }.getOrElse { _ -> it.absolutePath }.lowercase() }
+ .firstOrNull { it.isDesktopPackagingJdk(osName) }
+}
+
+fun normalizeDesktopPackageVersion(rawVersion: String): String {
+ val coreVersion = rawVersion.trim()
+ .substringBefore("-")
+ .substringBefore("+")
+ .takeIf { it.isNotBlank() }
+ ?: "1.0.0"
+ val parts = coreVersion.split(".")
+ val numericParts = parts.map { it.toIntOrNull() }
+ val normalizedParts = when {
+ parts.size in 1..3 && numericParts.all { it != null } ->
+ numericParts.map { it ?: 0 } + List(3 - parts.size) { 0 }
+
+ else -> throw GradleException(
+ "desktopPackageVersion must be numeric MAJOR[.MINOR[.BUILD]], but was '$rawVersion'."
+ )
+ }
+ val (major, minor, build) = normalizedParts
+ if (major !in 0..255 || minor !in 0..255 || build !in 0..65535) {
+ throw GradleException(
+ "desktopPackageVersion '$rawVersion' is outside the Windows package version range. " +
+ "Expected MAJOR 0..255, MINOR 0..255, BUILD 0..65535."
+ )
+ }
+ return "$major.$minor.$build"
+}
+
+fun normalizeDesktopVersionName(rawVersion: String): String {
+ return rawVersion.trim().takeIf { it.isNotBlank() } ?: "1.0.0"
+}
+
+fun normalizeDesktopFlavor(rawFlavor: String): String {
+ val flavor = rawFlavor.trim().lowercase()
+ return when (flavor) {
+ "oss", "oss-offline", "episteme-oss" -> "oss-offline"
+ else -> "standard"
+ }
+}
+
+fun normalizeDesktopPackageArchitecture(osArch: String): String {
+ val normalizedArch = desktopArchId(osArch)
+ return if (normalizedArch != "unknown") {
+ normalizedArch
+ } else {
+ osArch.lowercase()
+ .replace(Regex("[^a-z0-9]+"), "-")
+ .trim('-')
+ .ifBlank { "unknown" }
+ }
+}
+
+fun desktopDefaultPackageFormats(osName: String = System.getProperty("os.name")): String {
+ return when (desktopOsId(osName)) {
+ "windows" -> "msi"
+ "linux" -> "deb,rpm"
+ "macos" -> "dmg"
+ else -> ""
+ }
+}
+
+fun desktopTargetFormatForId(format: String): TargetFormat {
+ return when (format.lowercase()) {
+ "exe" -> TargetFormat.Exe
+ "msi" -> TargetFormat.Msi
+ "deb" -> TargetFormat.Deb
+ "rpm" -> TargetFormat.Rpm
+ "dmg" -> TargetFormat.Dmg
+ "pkg" -> TargetFormat.Pkg
+ else -> throw GradleException(
+ "Unsupported desktopPackageFormats entry '$format'. " +
+ "Use one or more of: msi, exe, deb, rpm, dmg, pkg."
+ )
+ }
+}
+
+fun desktopPackageFormatId(format: TargetFormat): String {
+ return when (format) {
+ TargetFormat.Exe -> "exe"
+ TargetFormat.Msi -> "msi"
+ TargetFormat.Deb -> "deb"
+ TargetFormat.Rpm -> "rpm"
+ TargetFormat.Dmg -> "dmg"
+ TargetFormat.Pkg -> "pkg"
+ else -> format.name.lowercase()
+ }
+}
+
+fun desktopPackageFormatSupportedOnHost(
+ format: TargetFormat,
+ osName: String = System.getProperty("os.name")
+): Boolean {
+ return when (desktopOsId(osName)) {
+ "windows" -> format == TargetFormat.Msi || format == TargetFormat.Exe
+ "linux" -> format == TargetFormat.Deb || format == TargetFormat.Rpm
+ "macos" -> format == TargetFormat.Dmg || format == TargetFormat.Pkg
+ else -> false
+ }
+}
+
+fun normalizeDesktopPackageFormats(
+ rawFormats: String,
+ osName: String = System.getProperty("os.name")
+): List {
+ val formats = rawFormats
+ .split(',', ';', ' ', '\n', '\t')
+ .map { it.trim() }
+ .filter { it.isNotBlank() }
+ .map(::desktopTargetFormatForId)
+ .distinct()
+ if (formats.isEmpty()) {
+ throw GradleException(
+ "desktopPackageFormats resolved to no package formats for ${desktopOsId(osName)}. " +
+ "Set -PdesktopPackageFormats=msi on Windows or -PdesktopPackageFormats=deb,rpm on Linux."
+ )
+ }
+ val unsupported = formats.filterNot { desktopPackageFormatSupportedOnHost(it, osName) }
+ if (unsupported.isNotEmpty()) {
+ throw GradleException(
+ "desktopPackageFormats=${formats.joinToString(",") { desktopPackageFormatId(it) }} does not match " +
+ "the current packaging host ${desktopOsId(osName)}. Unsupported here: " +
+ unsupported.joinToString(",") { desktopPackageFormatId(it) } + "."
+ )
+ }
+ return formats
+}
+
+fun normalizeDesktopMsixVersion(rawVersion: String): String {
+ val parts = rawVersion.trim().split('.')
+ if (parts.size !in 3..4 || parts.any { it.isBlank() || it.all(Char::isDigit).not() }) {
+ throw GradleException(
+ "desktopMsixVersion must be a numeric Windows package version with three or four parts, " +
+ "for example 1.0.1 or 1.0.1.0."
+ )
+ }
+ val normalized = if (parts.size == 3) parts + "0" else parts
+ normalized.forEach { part ->
+ val value = part.toIntOrNull()
+ if (value == null || value !in 0..65535) {
+ throw GradleException("desktopMsixVersion part '$part' is outside the MSIX range 0..65535.")
+ }
+ }
+ return normalized.joinToString(".")
+}
+
+fun normalizeDesktopMsixIdentityName(rawName: String): String {
+ val normalized = rawName.trim()
+ if (!Regex("[A-Za-z0-9][A-Za-z0-9.-]{2,49}").matches(normalized)) {
+ throw GradleException(
+ "desktopMsixIdentityName must be 3-50 characters using letters, numbers, dots, or hyphens."
+ )
+ }
+ return normalized
+}
+
+fun desktopMsixArchitecture(osArch: String = System.getProperty("os.arch")): String {
+ return when (desktopArchId(osArch)) {
+ "x64" -> "x64"
+ "arm64" -> "arm64"
+ "x86" -> "x86"
+ else -> "neutral"
+ }
+}
+
+fun latestExistingFile(candidates: List): File? {
+ return candidates.filter { it.isFile }.maxByOrNull { it.absolutePath }
+}
+
+fun windowsSdkToolCandidates(toolName: String): List {
+ val roots = listOfNotNull(
+ System.getenv("WindowsSdkDir")?.let(::File),
+ File("C:/Program Files (x86)/Windows Kits/10"),
+ File("C:/Program Files/Windows Kits/10"),
+ File("C:/Program Files (x86)/Windows Kits/10/App Certification Kit"),
+ File("C:/Program Files/Windows Kits/10/App Certification Kit")
+ ).distinctBy { it.absolutePath.lowercase() }
+ val sdkBins = roots.flatMap { root ->
+ safeChildDirectories(root.resolve("bin")).flatMap { versionDir ->
+ listOf(
+ versionDir.resolve("x64/$toolName.exe"),
+ versionDir.resolve("x86/$toolName.exe"),
+ versionDir.resolve(toolName)
+ )
+ }
+ }
+ val directBins = roots.map { root -> root.resolve("$toolName.exe") }
+ val pathBins = (System.getenv("PATH") ?: "")
+ .split(File.pathSeparator)
+ .filter { it.isNotBlank() }
+ .map { File(it).resolve("$toolName.exe") }
+ return sdkBins + directBins + pathBins
+}
+
+fun findWindowsSdkTool(toolName: String, explicitPath: String?): File {
+ val explicit = explicitPath?.trim()?.takeIf { it.isNotEmpty() }?.let(::File)
+ if (explicit != null) return explicit
+ return latestExistingFile(windowsSdkToolCandidates(toolName))
+ ?: File(rootProject.projectDir, "__missing_windows_sdk_tool__/$toolName.exe")
+}
+
+val desktopVersionName = "1.0.1"
+val desktopFlavor = providers.gradleProperty("desktopFlavor")
+ .orElse("standard")
+ .map(::normalizeDesktopFlavor)
+ .get()
+val isOssOfflineDesktop = desktopFlavor == "oss-offline"
+val desktopDiagnostics = providers.gradleProperty("desktopDiagnostics")
+ .map { it.equals("true", ignoreCase = true) }
+ .orElse(false)
+val desktopDiagnosticTags = providers.gradleProperty("desktopDiagnosticTags")
+ .orElse("")
+val desktopResolvedVersionName = providers.gradleProperty("desktopVersionName")
+ .orElse(providers.gradleProperty("desktopVersion"))
+ .orElse(desktopVersionName)
+ .map(::normalizeDesktopVersionName)
+val desktopPackageVersion = providers.gradleProperty("desktopPackageVersion")
+ .orElse(desktopResolvedVersionName)
+ .map(::normalizeDesktopPackageVersion)
+val desktopPackageName = if (isOssOfflineDesktop) "Episteme oss" else "Episteme"
+val desktopLinuxPackageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme"
+val desktopPackageDescription = if (isOssOfflineDesktop) {
+ "Episteme oss offline desktop reader"
+} else {
+ "Episteme desktop reader"
+}
+val desktopVendor = providers.gradleProperty("desktopVendor").orElse("Aryan")
+val desktopVendorName = desktopVendor.get()
+val desktopProjectUrl = providers.gradleProperty("desktopProjectUrl")
+ .orElse("https://github.com/Aryan-Raj3112/episteme")
+val desktopOsName = System.getProperty("os.name")
+val desktopOsArch = System.getProperty("os.arch")
+val desktopPackageArchitecture = normalizeDesktopPackageArchitecture(desktopOsArch)
+val desktopAurPackageName = providers.gradleProperty("desktopAurPackageName")
+ .orElse(if (isOssOfflineDesktop) "episteme-oss-bin" else "episteme-bin")
+val desktopAurPackageRelease = providers.gradleProperty("desktopAurPackageRelease")
+ .orElse("1")
+val desktopAurSourceUrl = providers.gradleProperty("desktopAurSourceUrl")
+ .orElse("")
+val desktopPackageTargetFormats = providers.gradleProperty("desktopPackageFormats")
+ .orElse(desktopDefaultPackageFormats(desktopOsName))
+ .map { normalizeDesktopPackageFormats(it, desktopOsName) }
+ .get()
+val desktopMsixIdentityName = providers.gradleProperty("desktopMsixIdentityName")
+ .orElse(if (isOssOfflineDesktop) "Aryan.EpistemeOss" else "Aryan.Episteme")
+ .map(::normalizeDesktopMsixIdentityName)
+ .get()
+val desktopMsixPublisher = providers.gradleProperty("desktopMsixPublisher")
+ .orElse("CN=$desktopVendorName")
+val desktopMsixPublisherDisplayName = providers.gradleProperty("desktopMsixPublisherDisplayName")
+ .orElse(desktopVendor)
+val desktopMsixVersion = providers.gradleProperty("desktopMsixVersion")
+ .orElse(desktopPackageVersion)
+ .map(::normalizeDesktopMsixVersion)
+ .get()
+val desktopMsixArchitecture = desktopMsixArchitecture(desktopOsArch)
+val desktopMakeAppxPath = providers.gradleProperty("desktopMakeAppxPath").orNull
+val desktopSignToolPath = providers.gradleProperty("desktopSignToolPath").orNull
+val desktopMsixCertificatePath = providers.gradleProperty("desktopMsixCertificatePath").orNull
+val desktopMsixCertificatePassword = providers.gradleProperty("desktopMsixCertificatePassword")
+ .orElse("")
+val desktopMsixTimestampUrl = providers.gradleProperty("desktopMsixTimestampUrl")
+ .orElse("http://timestamp.digicert.com")
+val desktopNativePackageSupportedHost = desktopOsId(desktopOsName) in setOf("windows", "linux") &&
+ desktopArchId(desktopOsArch) == "x64"
+val desktopReleaseProguardEnabled = providers.gradleProperty("desktopReleaseProguard")
+ .map { it.equals("true", ignoreCase = true) }
+ .orElse(false)
+ .get()
+val desktopSwtVersion = "3.133.0"
+val desktopSwtDependency = desktopSwtArtifactId(desktopOsName, desktopOsArch)
+ ?.let { artifactId -> "org.eclipse.platform:$artifactId:$desktopSwtVersion" }
+val generatedDesktopResourcesDir = layout.buildDirectory.dir("generated/desktopAppResources")
+val generatedDesktopCloudConfigFile = layout.buildDirectory.file("generated/desktopCloudConfig/desktop-cloud.properties")
+val generatedDesktopStringResourcesDir = layout.buildDirectory.dir("generated/desktopStringResources")
+val rootLocalProperties = Properties()
+val rootLocalPropertiesFile = rootProject.file("local.properties")
+if (rootLocalPropertiesFile.exists()) {
+ rootLocalPropertiesFile.inputStream().use(rootLocalProperties::load)
+}
+fun desktopConfigValue(vararg keys: String): String {
+ return keys.firstNotNullOfOrNull { key ->
+ providers.gradleProperty(key).orNull
+ ?: rootLocalProperties.getProperty(key)
+ ?: System.getenv(key)
+ }?.trim().orEmpty()
+}
+val desktopCloudConfig = mapOf(
+ "AI_WORKER_URL" to desktopConfigValue("DESKTOP_AI_WORKER_URL", "AI_WORKER_URL"),
+ "TTS_WORKER_URL" to desktopConfigValue("DESKTOP_TTS_WORKER_URL", "TTS_WORKER_URL"),
+ "FIREBASE_WEB_API_KEY" to desktopConfigValue("DESKTOP_FIREBASE_WEB_API_KEY", "FIREBASE_WEB_API_KEY", "GOOGLE_API_KEY"),
+ "FIREBASE_PROJECT_ID" to desktopConfigValue("DESKTOP_FIREBASE_PROJECT_ID", "FIREBASE_PROJECT_ID").ifBlank { "reader-9fc469d7" },
+ "GOOGLE_OAUTH_CLIENT_ID" to desktopConfigValue("DESKTOP_GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_WEB_CLIENT_ID", "DEFAULT_WEB_CLIENT_ID"),
+ "GOOGLE_OAUTH_CLIENT_SECRET" to desktopConfigValue("DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_WEB_CLIENT_SECRET", "DEFAULT_WEB_CLIENT_SECRET")
+)
+val desktopAllowUnconfiguredStandardServices = providers.gradleProperty("desktopAllowUnconfiguredStandardServices")
+ .map { it.equals("true", ignoreCase = true) }
+ .orElse(false)
+ .get()
+val desktopMissingStandardServiceConfig = if (isOssOfflineDesktop || desktopAllowUnconfiguredStandardServices) {
+ emptyList()
+} else {
+ listOf("FIREBASE_WEB_API_KEY", "GOOGLE_OAUTH_CLIENT_ID")
+ .filter { key -> desktopCloudConfig[key].isNullOrBlank() }
+}
+val bundledPdfiumDir = layout.projectDirectory.dir(
+ "../third_party/pdfium/${desktopPdfiumDirectoryName(desktopOsName, desktopOsArch)}"
+)
+val bundledPdfiumLibraryPath = desktopPdfiumLibraryPath(desktopOsName, desktopOsArch)
+val desktopWindowsIconFile = layout.projectDirectory.file("src/desktopMain/resources/episteme.ico")
+val desktopLinuxIconFile = layout.projectDirectory.file("src/desktopMain/resources/episteme_icon.png")
+val desktopWindowsUpgradeUuid = if (isOssOfflineDesktop) {
+ "ca13b201-940a-420a-8a3f-16e7d83d12a8"
+} else {
+ "c04c5823-b25a-4f38-a1cf-0da7b02ac397"
+}
+val desktopPackagingJavaHome = findDesktopPackagingJavaHome(
+ explicitCandidates = listOfNotNull(
+ providers.gradleProperty("desktopPackagingJavaHome").orNull,
+ providers.environmentVariable("DESKTOP_PACKAGING_JAVA_HOME").orNull,
+ providers.environmentVariable("JPACKAGE_HOME").orNull
+ ),
+ implicitCandidates = buildList {
+ addAll(desktopGradleJdkCandidates())
+ providers.gradleProperty("org.gradle.java.home").orNull?.let { add(File(it)) }
+ providers.environmentVariable("GRADLE_LOCAL_JAVA_HOME").orNull?.let { add(File(it)) }
+ providers.environmentVariable("JAVA_HOME").orNull?.let { add(File(it)) }
+ providers.environmentVariable("JDK_HOME").orNull?.let { add(File(it)) }
+ add(File(System.getProperty("java.home")))
+ addAll(desktopPathJdkCandidates(desktopOsName))
+ addAll(desktopPlatformJdkCandidates(desktopOsName))
+ },
+ osName = desktopOsName
+)?.absolutePath
+
+val checkBundledPdfiumRuntime by tasks.registering(CheckBundledPdfiumRuntimeTask::class) {
+ bundleRootPath.set(bundledPdfiumDir.asFile.absolutePath)
+ libraryPath.set(bundledPdfiumLibraryPath)
+}
+
+val generateDesktopCloudConfig by tasks.registering(GenerateDesktopCloudConfigTask::class) {
+ configValues.set(desktopCloudConfig)
+ outputFile.set(generatedDesktopCloudConfigFile)
+}
+
+val prepareBundledDesktopResources by tasks.registering(Sync::class) {
+ dependsOn(checkBundledPdfiumRuntime, generateDesktopCloudConfig)
+ from(bundledPdfiumDir) {
+ into("common/third_party/pdfium/${desktopPdfiumDirectoryName(desktopOsName, desktopOsArch)}")
+ }
+ into("common") {
+ from(generatedDesktopCloudConfigFile)
+ }
+ into(generatedDesktopResourcesDir)
+}
+
+val prepareDesktopStringResources by tasks.registering(Sync::class) {
+ // Reuse Android string resources as the localization source for desktop.
+ from(rootProject.layout.projectDirectory.dir("app/src/main/res")) {
+ include("values*/strings.xml")
+ include("values*/plurals.xml")
+ into("desktop-android-res")
+ }
+ into(generatedDesktopStringResourcesDir)
+}
+
+val verifyDesktopNativePackaging by tasks.registering(VerifyDesktopNativePackagingTask::class) {
+ supportedHost.set(desktopNativePackageSupportedHost)
+ hostOsId.set(desktopOsId(desktopOsName))
+ hostArchId.set(desktopArchId(desktopOsArch))
+ missingStandardServiceConfig.set(desktopMissingStandardServiceConfig)
+}
+
+val desktopDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main/app/$desktopPackageName")
+val desktopReleaseDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main-release/app/$desktopPackageName")
+val desktopLinuxTarFileName = "${desktopLinuxPackageName}-${desktopPackageVersion.get()}-linux-$desktopPackageArchitecture.tar.gz"
+val desktopAurOutputDir = layout.buildDirectory.dir("aur/${desktopAurPackageName.get()}")
+val desktopMsixPackageDir = layout.buildDirectory.dir("msix/package")
+val desktopMsixAssetsDir = layout.buildDirectory.dir("msix/generated/assets")
+val desktopMsixManifestFile = layout.buildDirectory.file("msix/generated/AppxManifest.xml")
+val desktopMsixOutputFile = layout.buildDirectory.file(
+ "compose/binaries/main-release/msix/${desktopLinuxPackageName}-${desktopPackageVersion.get()}-windows-$desktopPackageArchitecture.msix"
+)
+
+val packageLinuxTar by tasks.registering(Tar::class) {
+ group = "distribution"
+ description = "Packages the Linux desktop distributable as a tar.gz for Arch/AUR packaging."
+ dependsOn("createDistributable")
+
+ archiveFileName.set(desktopLinuxTarFileName)
+ destinationDirectory.set(layout.buildDirectory.dir("compose/binaries/main/linux-tar"))
+ compression = Compression.GZIP
+
+ from(desktopDistributableAppDir) {
+ into(desktopLinuxPackageName)
+ }
+ from(desktopLinuxIconFile) {
+ into("$desktopLinuxPackageName/share")
+ }
+ from(rootProject.layout.projectDirectory.file("LICENSE")) {
+ into("$desktopLinuxPackageName/share/licenses")
+ }
+}
+
+val prepareAurPackage by tasks.registering(PrepareDesktopAurPackageTask::class) {
+ group = "distribution"
+ description = "Generates a local AUR package directory with PKGBUILD and .SRCINFO."
+ dependsOn(packageLinuxTar)
+
+ aurPackageName.set(desktopAurPackageName)
+ providedPackageName.set(desktopLinuxPackageName)
+ packageVersion.set(desktopPackageVersion)
+ packageRelease.set(desktopAurPackageRelease)
+ packageDescription.set(desktopPackageDescription)
+ appDisplayName.set(desktopPackageName)
+ installDirectoryName.set(desktopLinuxPackageName)
+ launcherName.set(desktopLinuxPackageName)
+ executableName.set(desktopPackageName)
+ sourceUrl.set(desktopAurSourceUrl)
+ projectUrl.set(desktopProjectUrl)
+ linuxTarFile.set(packageLinuxTar.flatMap { it.archiveFile })
+ outputDirectory.set(desktopAurOutputDir)
+}
+
+tasks.register("packageAur") {
+ group = "distribution"
+ description = "Builds the generated AUR package with makepkg. Run this on Arch Linux."
+ dependsOn(prepareAurPackage)
+
+ commandLine("makepkg", "-sf", "--cleanbuild")
+ workingDir = desktopAurOutputDir.get().asFile
+}
+
+val generateDesktopMsixManifest by tasks.registering(GenerateDesktopMsixManifestTask::class) {
+ identityName.set(desktopMsixIdentityName)
+ publisher.set(desktopMsixPublisher)
+ publisherDisplayName.set(desktopMsixPublisherDisplayName)
+ packageName.set(desktopPackageName)
+ packageDescription.set(desktopPackageDescription)
+ packageVersion.set(desktopMsixVersion)
+ architecture.set(desktopMsixArchitecture)
+ executablePath.set("$desktopPackageName.exe")
+ outputFile.set(desktopMsixManifestFile)
+}
+
+val generateDesktopMsixAssets by tasks.registering(GenerateDesktopMsixAssetsTask::class) {
+ sourceIconFile.set(desktopLinuxIconFile)
+ outputDirectory.set(desktopMsixAssetsDir)
+}
+
+val prepareReleaseMsixPackage by tasks.registering(Sync::class) {
+ group = "distribution"
+ description = "Stages the release Windows app image and MSIX metadata for makeappx."
+ dependsOn("createReleaseDistributable", generateDesktopMsixManifest, generateDesktopMsixAssets)
+
+ from(desktopReleaseDistributableAppDir)
+ from(desktopMsixManifestFile)
+ from(desktopMsixAssetsDir) {
+ into("Assets")
+ }
+ into(desktopMsixPackageDir)
+}
+
+val packageReleaseMsix by tasks.registering(PackageDesktopMsixTask::class) {
+ group = "distribution"
+ description = "Packages the release Windows app image as an MSIX using Windows SDK makeappx."
+ dependsOn(prepareReleaseMsixPackage)
+
+ val makeAppx = findWindowsSdkTool("makeappx", desktopMakeAppxPath)
+ packageRootDirectory.set(desktopMsixPackageDir)
+ outputFile.set(desktopMsixOutputFile)
+ makeAppxPath.set(makeAppx.absolutePath)
+ hostOsId.set(desktopOsId(desktopOsName))
+ hostArchId.set(desktopArchId(desktopOsArch))
+}
+
+val signReleaseMsix = desktopMsixCertificatePath?.trim()?.takeIf { it.isNotEmpty() }?.let { certificatePath ->
+ tasks.register("signReleaseMsix") {
+ group = "distribution"
+ description = "Signs the release MSIX with signtool when -PdesktopMsixCertificatePath is configured."
+ dependsOn(packageReleaseMsix)
+
+ val signTool = findWindowsSdkTool("signtool", desktopSignToolPath)
+ val resolvedCertificateFile = File(certificatePath).let { file ->
+ if (file.isAbsolute) file else project.file(certificatePath)
+ }
+ unsignedMsixFile.set(desktopMsixOutputFile)
+ certificateFile.set(resolvedCertificateFile)
+ signToolPath.set(signTool.absolutePath)
+ certificatePassword.set(desktopMsixCertificatePassword)
+ timestampUrl.set(desktopMsixTimestampUrl)
+ }
+}
+
+kotlin {
+ jvm("desktop")
+ jvmToolchain(21)
+
+ sourceSets {
+ val desktopMain by getting {
+ resources.srcDir(prepareDesktopStringResources)
+ dependencies {
+ implementation(project(":shared"))
+ implementation(compose.desktop.currentOs)
+ implementation(compose.material3)
+ desktopSwtDependency?.let { dependency ->
+ compileOnly(dependency)
+ runtimeOnly(dependency)
+ }
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+ implementation("net.java.dev.jna:jna:5.17.0")
+ implementation("org.apache.commons:commons-compress:1.28.0")
+ implementation("org.tukaani:xz:1.10")
+ implementation("com.twelvemonkeys.imageio:imageio-webp:3.13.1")
+ }
+ }
+ val desktopTest by getting {
+ dependencies {
+ implementation(kotlin("test"))
+ }
+ }
+ }
+}
+
+compose.desktop {
+ application {
+ mainClass = "org.dueattendant149.bookreader.desktop.LauncherKt"
+ desktopPackagingJavaHome?.let { javaHome = it }
+
+ jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
+ jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
+ jvmArgs("-Depisteme.desktop.flavor=$desktopFlavor")
+ jvmArgs("-Depisteme.desktop.diagnostics=${desktopDiagnostics.get()}")
+ jvmArgs("-Depisteme.desktop.diagnostics.tags=${desktopDiagnosticTags.get()}")
+ jvmArgs("-Depisteme.desktop.version=${desktopResolvedVersionName.get()}")
+
+ buildTypes.release.proguard {
+ // ProGuard still rewrites and shrinks release jars even when optimization and
+ // obfuscation are disabled. That has produced invalid stack-map frames in large
+ // Compose/PDF lambdas and stripped WebView bridge behavior in packaged MSIs.
+ isEnabled.set(desktopReleaseProguardEnabled)
+ obfuscate.set(false)
+ // Compose/Kotlin generated methods can produce very large stack-map frames.
+ // ProGuard optimization has emitted invalid frames for SharedAppTheme in release builds.
+ optimize.set(false)
+ configurationFiles.from(project.file("compose-desktop.pro"))
+ }
+
+ nativeDistributions {
+ targetFormats(*desktopPackageTargetFormats.toTypedArray())
+ modules(
+ "java.datatransfer",
+ "java.desktop",
+ "java.logging",
+ "java.management",
+ "java.net.http",
+ "jdk.charsets",
+ "jdk.httpserver",
+ "jdk.unsupported"
+ )
+ packageName = desktopPackageName
+ packageVersion = desktopPackageVersion.get()
+ description = desktopPackageDescription
+ vendor = desktopVendor.get()
+ appResourcesRootDir.set(generatedDesktopResourcesDir)
+ windows {
+ iconFile.set(desktopWindowsIconFile)
+ dirChooser = true
+ shortcut = true
+ menu = true
+ menuGroup = "Episteme"
+ perUserInstall = true
+ upgradeUuid = desktopWindowsUpgradeUuid
+ }
+ linux {
+ iconFile.set(desktopLinuxIconFile)
+ packageName = desktopLinuxPackageName
+ debMaintainer = "epistemereader@gmail.com"
+ menuGroup = "Office"
+ appCategory = "Office"
+ }
+ }
+ }
+}
+
+tasks.withType().configureEach {
+ jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
+ jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
+ jvmArgs("-Depisteme.desktop.flavor=$desktopFlavor")
+ jvmArgs("-Depisteme.desktop.diagnostics=${desktopDiagnostics.get()}")
+ jvmArgs("-Depisteme.desktop.diagnostics.tags=${desktopDiagnosticTags.get()}")
+ jvmArgs("-Depisteme.desktop.version=${desktopResolvedVersionName.get()}")
+ if (System.getProperty("os.name").contains("Mac")) {
+ jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED")
+ jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED")
+ }
+}
+
+tasks.withType().configureEach {
+ manifest {
+ attributes(
+ "Implementation-Title" to desktopPackageName,
+ "Implementation-Version" to desktopResolvedVersionName.get(),
+ "Implementation-Vendor" to desktopVendor.get()
+ )
+ }
+}
+
+val stripReleaseProguardJarSignatures = if (desktopReleaseProguardEnabled) {
+ tasks.registering(StripInvalidJarSignaturesTask::class) {
+ dependsOn("proguardReleaseJars")
+ jarDirectoryPath.set(layout.buildDirectory.dir("compose/tmp/main-release/proguard").map { it.asFile.absolutePath })
+ }
+} else {
+ null
+}
+
+tasks.matching {
+ it.name in setOf(
+ "createReleaseDistributable",
+ "packageReleaseDistributionForCurrentOS",
+ "packageReleaseExe",
+ "packageReleaseMsi",
+ "packageReleaseMsix",
+ "packageReleaseDeb",
+ "packageReleaseRpm",
+ "runReleaseDistributable"
+ )
+}.configureEach {
+ stripReleaseProguardJarSignatures?.let { dependsOn(it) }
+}
+
+mapOf(
+ "packageMsi" to "main",
+ "packageReleaseMsi" to "main-release"
+).forEach { (taskName, distributionName) ->
+ val renameTask = tasks.register("rename${taskName.replaceFirstChar(Char::titlecase)}Output") {
+ msiDirectoryPath.set(layout.buildDirectory.dir("compose/binaries/$distributionName/msi").get().asFile.absolutePath)
+ packageName.set(desktopPackageName)
+ packageVersion.set(desktopPackageVersion.get())
+ architecture.set(desktopPackageArchitecture)
+ }
+ tasks.matching { it.name == taskName }.configureEach {
+ finalizedBy(renameTask)
+ }
+}
+
+tasks.matching {
+ it.name in setOf(
+ "createDistributable",
+ "createReleaseDistributable",
+ "prepareAppResources",
+ "prepareReleaseAppResources",
+ "packageDistributionForCurrentOS",
+ "packageReleaseDistributionForCurrentOS",
+ "packageExe",
+ "packageReleaseExe",
+ "packageMsi",
+ "packageReleaseMsi",
+ "prepareReleaseMsixPackage",
+ "packageReleaseMsix",
+ "signReleaseMsix",
+ "packageDeb",
+ "packageReleaseDeb",
+ "packageRpm",
+ "packageReleaseRpm",
+ "packageLinuxTar",
+ "prepareAurPackage",
+ "packageAur",
+ "runDistributable",
+ "runReleaseDistributable"
+ )
+}.configureEach {
+ dependsOn(verifyDesktopNativePackaging)
+ dependsOn(prepareBundledDesktopResources)
+ inputs.dir(generatedDesktopResourcesDir)
+ .withPropertyName("bundledDesktopResources")
+ .withPathSensitivity(PathSensitivity.RELATIVE)
+}
diff --git a/desktopApp/compose-desktop.pro b/desktopApp/compose-desktop.pro
new file mode 100644
index 0000000..d3805a8
--- /dev/null
+++ b/desktopApp/compose-desktop.pro
@@ -0,0 +1,26 @@
+-keep class io.ktor.serialization.kotlinx.** { *; }
+-keep class io.ktor.serialization.kotlinx.json.** { *; }
+-keep class com.sun.jna.** { *; }
+-keep class * implements com.sun.jna.Library { *; }
+-keep class * extends com.sun.jna.Structure { *; }
+-keep class kotlinx.coroutines.swing.SwingDispatcherFactory
+
+# Desktop release shrinking sees optional integrations from JOGL, Commons Compress
+# Pack200, and OkHttp platform probes, so keep ProGuard from treating them as blockers.
+-dontwarn com.jetbrains.JBR
+-dontwarn com.jogamp.**
+-dontwarn jogamp.**
+-dontwarn org.apache.commons.compress.harmony.pack200.**
+-dontwarn org.objectweb.asm.**
+-dontwarn io.ktor.serialization.kotlinx.**
+-dontwarn com.sun.jna.**
+-dontwarn org.eclipse.swt.**
+-dontwarn javafx.**
+-dontwarn com.sun.javafx.**
+-dontwarn okhttp3.internal.platform.**
+-dontwarn org.bouncycastle.**
+-dontwarn org.conscrypt.**
+-dontwarn org.openjsse.**
+-dontwarn android.**
+-dontwarn com.github.luben.zstd.**
+-dontwarn org.brotli.dec.**
diff --git a/desktopApp/packaging/README.md b/desktopApp/packaging/README.md
new file mode 100644
index 0000000..65cd664
--- /dev/null
+++ b/desktopApp/packaging/README.md
@@ -0,0 +1,229 @@
+# Desktop package builds
+
+Build Linux packages on the matching distro VM when testing manually:
+
+```bash
+cd ~/Reader
+./gradlew :desktopApp:packageDeb -x test
+./gradlew :desktopApp:packageRpm -x test
+./gradlew :desktopApp:packageAur -x test
+```
+
+Build a Windows MSIX locally on Windows with the Windows SDK installed:
+
+```powershell
+cd C:\Users\aryan\Desktop\Reader
+.\gradlew.bat -PdesktopOnly=true -PdesktopAllowUnconfiguredStandardServices=true :desktopApp:packageReleaseMsix -x test
+```
+
+Copy the newest generated MSIX to your desktop:
+
+```powershell
+$msix = Get-ChildItem .\desktopApp\build\compose\binaries\main-release\msix -Filter *.msix | Sort-Object LastWriteTime -Descending | Select-Object -First 1
+Copy-Item -Force $msix.FullName "$env:USERPROFILE\Desktop\"
+```
+
+The MSIX task is separate from MSI packaging. It stages the release app image at
+`desktopApp/build/msix/package`, packages it with Windows SDK `makeappx.exe`, and
+writes the MSIX to:
+
+```text
+desktopApp/build/compose/binaries/main-release/msix
+```
+
+For Microsoft Store submission, set the package identity values from Partner
+Center so `AppxManifest.xml` matches the reserved app identity:
+
+```powershell
+.\gradlew.bat `
+ -PdesktopOnly=true `
+ -PdesktopMsixIdentityName= `
+ -PdesktopMsixPublisher= `
+ -PdesktopMsixPublisherDisplayName= `
+ :desktopApp:packageReleaseMsix -x test
+```
+
+If Windows SDK tools are not on `PATH`, pass them explicitly:
+
+```powershell
+.\gradlew.bat `
+ -PdesktopMakeAppxPath="C:\Program Files (x86)\Windows Kits\10\bin\\x64\makeappx.exe" `
+ :desktopApp:packageReleaseMsix -x test
+```
+
+Local signing is optional and separate:
+
+```powershell
+.\gradlew.bat `
+ -PdesktopMsixCertificatePath=C:\path\to\certificate.pfx `
+ -PdesktopMsixCertificatePassword= `
+ :desktopApp:signReleaseMsix -x test
+```
+
+Recommended VM split:
+
+- Ubuntu: `./gradlew :desktopApp:packageDeb -x test`
+- Fedora: `./gradlew :desktopApp:packageRpm -x test`
+- Arch: `./gradlew :desktopApp:packageAur -x test`
+
+Desktop-only Gradle invocations automatically skip the Android app module and the
+Android target in `:shared`, so desktop packaging does not require `sdk.dir`,
+Android SDK installation, or Android release signing values. You can force that
+mode for unusual command shapes with:
+
+```bash
+./gradlew -PdesktopOnly=true :desktopApp:packageDeb -x test
+```
+
+Desktop release values are centralized in `gradle.properties`:
+
+```properties
+desktopVersion=1.0.1
+desktopPackageVersion=1.0.1
+desktopAurPackageRelease=1
+```
+
+The AUR path is native Arch packaging. It does not wrap the `.deb` or `.rpm`.
+`packageAur` first creates a Linux app tarball, then generates an AUR worktree at:
+
+```text
+desktopApp/build/aur/episteme-bin
+```
+
+On Arch, install/test the generated package with:
+
+```bash
+sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-bin/*.pkg.tar.zst
+episteme
+```
+
+For the OSS/offline flavor:
+
+```bash
+./gradlew :desktopApp:packageAur -PdesktopFlavor=oss -x test
+sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-oss-bin/*.pkg.tar.zst
+episteme-oss
+```
+
+To inspect the AUR recipe manually instead:
+
+```bash
+./gradlew :desktopApp:prepareAurPackage -x test
+cd ~/Reader/desktopApp/build/aur/episteme-bin
+makepkg -si
+```
+
+For publish-ready AUR metadata, pass the release tarball URL:
+
+```bash
+./gradlew :desktopApp:prepareAurPackage \
+ -PdesktopAurSourceUrl=https://example.com/releases/episteme-1.0.1-linux-x64.tar.gz \
+ -x test
+```
+
+Then publish the generated `PKGBUILD` and `.SRCINFO` from the AUR directory.
+
+The generated AUR recipes use `license=('AGPL-3.0-only')` and install the root
+`LICENSE` file into `/usr/share/licenses/$pkgname/`.
+
+## AUR repository setup
+
+Create an account at:
+
+```text
+https://aur.archlinux.org/register/
+```
+
+Add your public SSH key in the account settings, then confirm SSH works:
+
+```bash
+ssh aur@aur.archlinux.org
+```
+
+The command should authenticate and print AUR help text. It will not open a
+normal shell.
+
+Create the package repos by cloning their not-yet-existing names:
+
+```bash
+git clone ssh://aur@aur.archlinux.org/episteme-bin.git
+git clone ssh://aur@aur.archlinux.org/episteme-oss-bin.git
+```
+
+If a name already exists, inspect it first. If it is abandoned, follow the AUR
+orphan/adoption process instead of creating a duplicate package name.
+
+For each release, extract the matching `aur--.tar.gz` metadata
+archive from the GitHub release, copy `PKGBUILD` and `.SRCINFO` into the matching
+AUR clone, then commit and push:
+
+```bash
+tar -xzf aur-episteme-bin-1.0.1.tar.gz -C episteme-bin
+cd episteme-bin
+git add PKGBUILD .SRCINFO
+git commit -m "Update to 1.0.1"
+git push
+```
+
+Repeat the same flow for `episteme-oss-bin`.
+
+## CI release workflow
+
+`Desktop release` in GitHub Actions builds desktop artifacts for standard and
+OSS flavors:
+
+- Windows MSI
+- Ubuntu/Debian DEB
+- Fedora RPM
+- Linux tarball used by AUR
+- Direct Arch `.pkg.tar.zst`
+- AUR metadata archives containing `PKGBUILD` and `.SRCINFO`
+- `SHA256SUMS.txt`
+
+Before running it, publish Pdfium once from a machine that has the ignored
+`third_party/pdfium` folders:
+
+```powershell
+.\scripts\desktop\publish-pdfium-release.ps1 `
+ -Repository Aryan-Raj3112/episteme `
+ -Tag pdfium-desktop-v1
+```
+
+That release must contain:
+
+```text
+pdfium-linux-x64-v8.zip
+pdfium-win-x64-v8.zip
+```
+
+The desktop release workflow downloads those assets with:
+
+```powershell
+.\scripts\desktop\download-pdfium.ps1 -Tag pdfium-desktop-v1
+```
+
+Required GitHub Secrets for standard desktop packages:
+
+```text
+DESKTOP_FIREBASE_PROJECT_ID
+DESKTOP_FIREBASE_WEB_API_KEY
+DESKTOP_GOOGLE_OAUTH_CLIENT_ID
+DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET
+```
+
+`MYAPP_RELEASE_STORE_FILE` is not used by desktop packaging. Android is skipped
+for `:desktopApp:*` tasks.
+
+AUR publishing still needs the two AUR repos:
+
+```text
+episteme-bin
+episteme-oss-bin
+```
+
+Upload the generated `PKGBUILD` and `.SRCINFO` from:
+
+```text
+aur-episteme-bin-.tar.gz
+aur-episteme-oss-bin-.tar.gz
+```
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt
new file mode 100644
index 0000000..03181c1
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt
@@ -0,0 +1,138 @@
+package org.dueattendant149.bookreader.desktop
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+import java.net.URLEncoder
+import java.util.Properties
+
+internal data class DesktopAccountProfile(
+ val isProUser: Boolean = false,
+ val credits: Int = 0,
+ val fetchedAtEpochMillis: Long = 0L
+)
+
+// Credits and Pro status are server-owned, so startup only trusts a recent snapshot.
+internal const val DesktopAccountProfileCacheTtlMillis: Long = 30L * 60L * 1000L
+
+internal fun DesktopAccountProfile.isFresh(
+ nowEpochMillis: Long = System.currentTimeMillis(),
+ ttlMillis: Long = DesktopAccountProfileCacheTtlMillis
+): Boolean {
+ if (fetchedAtEpochMillis <= 0L || ttlMillis <= 0L) return false
+ val ageMillis = nowEpochMillis - fetchedAtEpochMillis
+ return ageMillis in 0L..ttlMillis
+}
+
+internal class DesktopAccountProfileRepository(
+ private val config: DesktopCloudConfig,
+ private val store: DesktopAccountProfileStore = DesktopAccountProfileStore()
+) {
+ fun cachedProfile(
+ uid: String,
+ nowEpochMillis: Long = System.currentTimeMillis()
+ ): DesktopAccountProfile? {
+ return store.load(uid)?.takeIf { profile -> profile.isFresh(nowEpochMillis) }
+ }
+
+ fun saveFetchedProfile(uid: String, profile: DesktopAccountProfile) {
+ store.save(uid, profile)
+ }
+
+ fun clearCachedProfiles() {
+ store.clear()
+ }
+
+ suspend fun fetchProfile(uid: String, idToken: String): DesktopAccountProfile = withContext(Dispatchers.IO) {
+ if (uid.isBlank() || idToken.isBlank()) return@withContext DesktopAccountProfile()
+ val url = "https://firestore.googleapis.com/v1/projects/${urlEncode(config.firebaseProjectId)}/databases/(default)/documents/users/${urlEncode(uid)}"
+ val connection = (URL(url).openConnection() as HttpURLConnection).apply {
+ requestMethod = "GET"
+ setRequestProperty("Authorization", "Bearer $idToken")
+ setRequestProperty("Accept", "application/json")
+ connectTimeout = 12_000
+ readTimeout = 20_000
+ }
+ try {
+ if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
+ return@withContext DesktopAccountProfile(fetchedAtEpochMillis = System.currentTimeMillis())
+ }
+ val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream
+ val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
+ if (connection.responseCode !in 200..299) {
+ throw IllegalStateException("Could not check account status: HTTP ${connection.responseCode}")
+ }
+ val fields = DesktopAccountJson.parseToJsonElement(text).jsonObject["fields"].jsonObjectOrNull()
+ val profile = DesktopAccountProfile(
+ isProUser = fields?.booleanField("isPro") == true,
+ credits = fields?.numberField("credits")?.toInt() ?: 0,
+ fetchedAtEpochMillis = System.currentTimeMillis()
+ )
+ profile
+ } finally {
+ connection.disconnect()
+ }
+ }
+}
+
+internal class DesktopAccountProfileStore(
+ private val settingsFile: File = File(desktopUserConfigRoot(), "account_profile.properties")
+) {
+ fun load(uid: String): DesktopAccountProfile? {
+ if (uid.isBlank() || !settingsFile.isFile) return null
+ val properties = Properties()
+ return runCatching {
+ settingsFile.inputStream().use(properties::load)
+ if (properties.getProperty("uid", "") != uid) return null
+ DesktopAccountProfile(
+ isProUser = properties.getProperty("isProUser", "false").toBooleanStrictOrNull() ?: false,
+ credits = properties.getProperty("credits", "0").toIntOrNull() ?: 0,
+ fetchedAtEpochMillis = properties.getProperty("fetchedAtEpochMillis", "0").toLongOrNull() ?: 0L
+ )
+ }.getOrNull()
+ }
+
+ fun save(uid: String, profile: DesktopAccountProfile) {
+ if (uid.isBlank()) return
+ val properties = Properties().apply {
+ setProperty("uid", uid)
+ setProperty("isProUser", profile.isProUser.toString())
+ setProperty("credits", profile.credits.toString())
+ setProperty("fetchedAtEpochMillis", profile.fetchedAtEpochMillis.toString())
+ }
+ settingsFile.storePropertiesAtomically(properties, "Episteme desktop account profile")
+ }
+
+ fun clear() {
+ settingsFile.delete()
+ }
+}
+
+private val DesktopAccountJson = Json { ignoreUnknownKeys = true }
+
+private fun JsonObject?.booleanField(key: String): Boolean? {
+ return this?.get(key)
+ ?.jsonObjectOrNull()
+ ?.get("booleanValue")
+ ?.jsonPrimitive
+ ?.contentOrNull
+ ?.toBooleanStrictOrNull()
+}
+
+private fun JsonObject?.numberField(key: String): Double? {
+ val field = this?.get(key)?.jsonObjectOrNull() ?: return null
+ return field["integerValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
+ ?: field["doubleValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
+}
+
+private fun JsonElement?.jsonObjectOrNull(): JsonObject? = this as? JsonObject
+
+private fun urlEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt
new file mode 100644
index 0000000..5a5dfdf
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt
@@ -0,0 +1,1023 @@
+package org.dueattendant149.bookreader.desktop
+
+import org.dueattendant149.bookreader.shared.DEFAULT_CLOUD_TTS_SPEAKER_ID
+import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL_ID
+import org.dueattendant149.bookreader.shared.ReaderAiByokSettings
+import com.sun.jna.Library
+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
+import java.util.concurrent.TimeUnit
+
+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
+private const val LINUX_SECRET_SCHEMA_DONT_MATCH_NAME = 2
+private const val LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING = 0
+private const val LINUX_SECRET_SCHEMA_NAME = "org.dueattendant149.bookreader.Secret"
+private const val LINUX_SECRET_COLLECTION_DEFAULT = "default"
+private const val LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE = "application"
+private const val LINUX_SECRET_SERVICE_APPLICATION_VALUE = "Episteme.Reader"
+private const val LINUX_SECRET_SERVICE_KEY_ATTRIBUTE = "key"
+private const val LINUX_LIBSECRET_PREFIX = "linux-libsecret:"
+private const val LINUX_SECRET_TOOL_PREFIX = "secret-tool:"
+
+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 {
+ val settingsFileExists = settingsFile.exists()
+ logDesktopTts(
+ "settings_load_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " +
+ "exists=$settingsFileExists secureStorage=${if (settingsFileExists) "checking" else "skipped"}"
+ )
+ if (!settingsFileExists) {
+ logDesktopTts("settings_load_empty reason=file_missing")
+ return ReaderAiByokSettings()
+ }
+ val secureStorageAvailable = secretCodec.isAvailable
+ logDesktopTts("settings_load_secure_storage codec=${secretCodec.name} available=$secureStorageAvailable")
+ 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, secureStorageAvailable),
+ groqKey = loadSecret(properties, GroqKey, legacyGroqKey, secureStorageAvailable),
+ 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
+ }.toDesktopPersistableAiSettings()
+ if (secureStorageAvailable &&
+ (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.toDesktopPersistableAiSettings()
+ 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.storePropertiesAtomically(properties, "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,
+ secureStorageAvailable: Boolean
+ ): 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 { secureStorageAvailable }.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 {
+ return File(desktopUserConfigRoot(), "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 = when {
+ osName.startsWith("Windows", ignoreCase = true) -> WindowsSecretCodec
+ osName.contains("Linux", ignoreCase = true) -> LinuxSecretServiceCodec()
+ 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 = ""
+}
+
+internal data class DesktopSecretCommandResult(
+ val exitCode: Int,
+ val stdout: String,
+ val stderr: String
+) {
+ val isSuccess: Boolean get() = exitCode == 0
+ val errorSummary: String
+ get() = stderr.ifBlank { stdout }.desktopTtsPreview(240).ifBlank { "exit code $exitCode" }
+}
+
+internal interface DesktopSecretCommandRunner {
+ fun isExecutableAvailable(command: String): Boolean
+ fun run(command: List, input: String? = null, timeoutMillis: Long = 5_000L): DesktopSecretCommandResult
+}
+
+private object DesktopProcessSecretCommandRunner : DesktopSecretCommandRunner {
+ override fun isExecutableAvailable(command: String): Boolean {
+ val path = System.getenv("PATH").orEmpty()
+ return path.split(File.pathSeparator)
+ .asSequence()
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .any { directory ->
+ File(directory, command).let { it.isFile && it.canExecute() }
+ }
+ }
+
+ override fun run(command: List, input: String?, timeoutMillis: Long): DesktopSecretCommandResult {
+ require(command.isNotEmpty()) { "Secret command cannot be empty." }
+ val process = ProcessBuilder(command).start()
+ input?.let { value ->
+ process.outputStream.use { output ->
+ output.write(value.toByteArray(Charsets.UTF_8))
+ }
+ } ?: process.outputStream.close()
+
+ val completed = process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)
+ if (!completed) {
+ process.destroyForcibly()
+ throw IllegalStateException("Timed out waiting for ${command.first()} secure storage command.")
+ }
+ return DesktopSecretCommandResult(
+ exitCode = process.exitValue(),
+ stdout = process.inputStream.readBytes().toString(Charsets.UTF_8),
+ stderr = process.errorStream.readBytes().toString(Charsets.UTF_8)
+ )
+ }
+}
+
+internal class LinuxSecretServiceCodec(
+ private val libsecretCodec: DesktopSecretCodec = LinuxLibsecretCodec(),
+ private val secretToolCodec: DesktopSecretCodec = LinuxSecretToolCodec()
+) : DesktopSecretCodec {
+ private val codecs: List = listOf(libsecretCodec, secretToolCodec)
+
+ private val selectedCodec: DesktopSecretCodec? by lazy {
+ codecs.firstOrNull { codec ->
+ runCatching { codec.isAvailable }
+ .onFailure { error ->
+ logDesktopTts("settings_linux_secret_service_probe_failed codec=${codec.name} error=\"${error.desktopTtsSummary()}\"")
+ }
+ .getOrDefault(false)
+ }
+ }
+
+ override val name: String = "linux-secret-service"
+
+ override val isAvailable: Boolean
+ get() = selectedCodec != null
+
+ 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 codec = selectedCodec ?: throw IllegalStateException(
+ "Linux Secret Service is unavailable. Install gnome-keyring or another Secret Service provider."
+ )
+ return codec.protect(keyName, value)
+ }
+
+ override fun unprotect(keyName: String, value: String): String {
+ val orderedCodecs = when {
+ value.startsWith(LINUX_LIBSECRET_PREFIX) -> listOf(libsecretCodec, secretToolCodec)
+ value.startsWith(LINUX_SECRET_TOOL_PREFIX) -> listOf(libsecretCodec, secretToolCodec)
+ else -> codecs
+ }
+ for (codec in orderedCodecs) {
+ val secret = runCatching {
+ if (!codec.isAvailable) "" else codec.unprotect(keyName, value)
+ }.onFailure { error ->
+ logDesktopTts(
+ "settings_linux_secret_service_read_failed codec=${codec.name} key=$keyName " +
+ "error=\"${error.desktopTtsSummary()}\""
+ )
+ }.getOrDefault("")
+ if (secret.isNotBlank()) return secret
+ }
+ return ""
+ }
+
+ override fun delete(keyName: String) {
+ codecs.forEach { codec ->
+ runCatching { codec.delete(keyName) }
+ .onFailure { error ->
+ logDesktopTts(
+ "settings_linux_secret_service_delete_failed codec=${codec.name} key=$keyName " +
+ "error=\"${error.desktopTtsSummary()}\""
+ )
+ }
+ }
+ }
+}
+
+internal interface LinuxSecretServiceClient {
+ val isAvailable: Boolean
+ fun store(key: String, label: String, password: String)
+ fun lookup(key: String): String?
+ fun clear(key: String)
+}
+
+internal class LinuxLibsecretCodec(
+ private val client: LinuxSecretServiceClient = JnaLinuxSecretServiceClient
+) : DesktopSecretCodec {
+ override val name: String = "linux-libsecret"
+
+ override val isAvailable: Boolean by lazy {
+ val available = if (!client.isAvailable) {
+ false
+ } else {
+ val probeKey = linuxSecretKey("probe")
+ val probeSecret = "episteme-linux-libsecret-probe"
+ runCatching {
+ client.store(probeKey, "Episteme secure storage probe", probeSecret)
+ client.lookup(probeKey) == probeSecret
+ }.onFailure { error ->
+ logDesktopTts("settings_linux_libsecret_unavailable error=\"${error.desktopTtsSummary()}\"")
+ }.also {
+ runCatching { client.clear(probeKey) }
+ }.getOrDefault(false)
+ }
+ logDesktopTts("settings_linux_libsecret_available available=$available")
+ available
+ }
+
+ override fun protect(value: String): String {
+ return protect("secret", value)
+ }
+
+ override fun unprotect(value: String): String {
+ return unprotect("secret", value)
+ }
+
+ override fun protect(keyName: String, value: String): String {
+ if (!isAvailable) {
+ throw IllegalStateException(
+ "Linux Secret Service is unavailable. Install gnome-keyring or another Secret Service provider."
+ )
+ }
+ val key = linuxSecretKey(keyName)
+ logDesktopTts("settings_linux_libsecret_write_start key=$keyName valueChars=${value.length}")
+ client.store(key, "Episteme $keyName", value)
+ logDesktopTts("settings_linux_libsecret_write_result key=$keyName")
+ return LINUX_LIBSECRET_PREFIX + key
+ }
+
+ override fun unprotect(keyName: String, value: String): String {
+ if (!isAvailable) return ""
+ val key = linuxSecretReferenceKey(keyName, value)
+ logDesktopTts("settings_linux_libsecret_read_start key=$keyName")
+ val secret = client.lookup(key).orEmpty()
+ logDesktopTts("settings_linux_libsecret_read_result key=$keyName chars=${secret.length}")
+ return secret
+ }
+
+ override fun delete(keyName: String) {
+ if (!client.isAvailable) return
+ val key = linuxSecretKey(keyName)
+ runCatching { client.clear(key) }
+ .onFailure { error ->
+ logDesktopTts("settings_linux_libsecret_delete_failed key=$keyName error=\"${error.desktopTtsSummary()}\"")
+ }
+ }
+}
+
+private object JnaLinuxSecretServiceClient : LinuxSecretServiceClient {
+ override val isAvailable: Boolean by lazy {
+ runCatching {
+ LinuxLibsecretNative.INSTANCE
+ LinuxGlibNative.INSTANCE
+ true
+ }.onFailure { error ->
+ logDesktopTts("settings_linux_libsecret_load_failed error=\"${error.desktopTtsSummary()}\"")
+ }.getOrDefault(false)
+ }
+
+ private val schema: Pointer by lazy {
+ LinuxLibsecretNative.INSTANCE.secret_schema_new(
+ LINUX_SECRET_SCHEMA_NAME,
+ LINUX_SECRET_SCHEMA_DONT_MATCH_NAME,
+ LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
+ LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING,
+ LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
+ LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING,
+ null
+ ) ?: throw IllegalStateException("Linux Secret Service schema creation failed.")
+ }
+
+ override fun store(key: String, label: String, password: String) {
+ val error = PointerByReference()
+ val stored = LinuxLibsecretNative.INSTANCE.secret_password_store_sync(
+ schema,
+ LINUX_SECRET_COLLECTION_DEFAULT,
+ label,
+ password,
+ null,
+ error,
+ LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
+ LINUX_SECRET_SERVICE_APPLICATION_VALUE,
+ LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
+ key,
+ null
+ )
+ takeLibsecretError(error)?.let { message ->
+ throw IllegalStateException("Linux Secret Service write failed: $message")
+ }
+ if (!stored) {
+ throw IllegalStateException("Linux Secret Service write failed: libsecret returned false.")
+ }
+ }
+
+ override fun lookup(key: String): String? {
+ val error = PointerByReference()
+ val passwordPointer = LinuxLibsecretNative.INSTANCE.secret_password_lookup_sync(
+ schema,
+ null,
+ error,
+ LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
+ LINUX_SECRET_SERVICE_APPLICATION_VALUE,
+ LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
+ key,
+ null
+ )
+ val errorMessage = takeLibsecretError(error)
+ if (errorMessage != null) {
+ passwordPointer?.let { pointer -> LinuxLibsecretNative.INSTANCE.secret_password_free(pointer) }
+ throw IllegalStateException("Linux Secret Service read failed: $errorMessage")
+ }
+ return passwordPointer?.let { pointer ->
+ try {
+ pointer.getString(0, Charsets.UTF_8.name())
+ } finally {
+ LinuxLibsecretNative.INSTANCE.secret_password_free(pointer)
+ }
+ }
+ }
+
+ override fun clear(key: String) {
+ val error = PointerByReference()
+ LinuxLibsecretNative.INSTANCE.secret_password_clear_sync(
+ schema,
+ null,
+ error,
+ LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
+ LINUX_SECRET_SERVICE_APPLICATION_VALUE,
+ LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
+ key,
+ null
+ )
+ takeLibsecretError(error)?.let { message ->
+ throw IllegalStateException("Linux Secret Service delete failed: $message")
+ }
+ }
+
+ private fun takeLibsecretError(error: PointerByReference): String? {
+ val errorPointer = error.value ?: return null
+ return try {
+ LinuxGError(errorPointer).message
+ ?.getString(0, Charsets.UTF_8.name())
+ ?.ifBlank { null }
+ ?: "unknown libsecret error"
+ } finally {
+ LinuxGlibNative.INSTANCE.g_error_free(errorPointer)
+ }
+ }
+}
+
+private interface LinuxLibsecretNative : Library {
+ fun secret_schema_new(name: String, flags: Int, vararg attributes: Any?): Pointer?
+
+ fun secret_password_store_sync(
+ schema: Pointer,
+ collection: String,
+ label: String,
+ password: String,
+ cancellable: Pointer?,
+ error: PointerByReference,
+ vararg attributes: Any?
+ ): Boolean
+
+ fun secret_password_lookup_sync(
+ schema: Pointer,
+ cancellable: Pointer?,
+ error: PointerByReference,
+ vararg attributes: Any?
+ ): Pointer?
+
+ fun secret_password_clear_sync(
+ schema: Pointer,
+ cancellable: Pointer?,
+ error: PointerByReference,
+ vararg attributes: Any?
+ ): Boolean
+
+ fun secret_password_free(password: Pointer?)
+
+ companion object {
+ val INSTANCE: LinuxLibsecretNative by lazy {
+ loadLinuxNativeLibrary(
+ LinuxLibsecretNative::class.java,
+ "secret-1",
+ "libsecret-1.so.0"
+ )
+ }
+ }
+}
+
+private interface LinuxGlibNative : Library {
+ fun g_error_free(error: Pointer?)
+
+ companion object {
+ val INSTANCE: LinuxGlibNative by lazy {
+ loadLinuxNativeLibrary(
+ LinuxGlibNative::class.java,
+ "glib-2.0",
+ "libglib-2.0.so.0"
+ )
+ }
+ }
+}
+
+@Structure.FieldOrder("domain", "code", "message")
+internal class LinuxGError(pointer: Pointer) : Structure(pointer) {
+ @JvmField
+ var domain: Int = 0
+
+ @JvmField
+ var code: Int = 0
+
+ @JvmField
+ var message: Pointer? = null
+
+ init {
+ read()
+ }
+}
+
+private fun loadLinuxNativeLibrary(type: Class, vararg names: String): T {
+ var lastError: Throwable? = null
+ for (name in names) {
+ val loaded = runCatching { Native.load(name, type) as T }
+ .onFailure { error -> lastError = error }
+ .getOrNull()
+ if (loaded != null) return loaded
+ }
+ throw IllegalStateException("Could not load Linux native library ${names.joinToString(" or ")}.", lastError)
+}
+
+internal class LinuxSecretToolCodec(
+ private val commandRunner: DesktopSecretCommandRunner = DesktopProcessSecretCommandRunner
+) : DesktopSecretCodec {
+ override val name: String = "linux-secret-tool"
+
+ override val isAvailable: Boolean by lazy {
+ val available = commandRunner.isExecutableAvailable(SecretToolCommand) &&
+ runCatching {
+ commandRunner.run(listOf(SecretToolCommand, "--help"), timeoutMillis = 3_000L).isSuccess
+ }.getOrDefault(false)
+ logDesktopTts("settings_linux_secret_tool_available available=$available")
+ available
+ }
+
+ override fun protect(value: String): String {
+ return protect("secret", value)
+ }
+
+ override fun unprotect(value: String): String {
+ return unprotect("secret", value)
+ }
+
+ override fun protect(keyName: String, value: String): String {
+ if (!isAvailable) {
+ throw IllegalStateException(
+ "Linux Secret Service is unavailable. Install libsecret-tools and make sure a desktop keyring is running."
+ )
+ }
+ val key = linuxSecretKey(keyName)
+ logDesktopTts("settings_linux_secret_tool_write_start key=$keyName valueChars=${value.length}")
+ val result = commandRunner.run(
+ command = listOf(
+ SecretToolCommand,
+ "store",
+ "--label",
+ "Episteme $keyName",
+ LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
+ LINUX_SECRET_SERVICE_APPLICATION_VALUE,
+ LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
+ key
+ ),
+ input = value,
+ timeoutMillis = 15_000L
+ )
+ logDesktopTts("settings_linux_secret_tool_write_result key=$keyName exit=${result.exitCode}")
+ if (!result.isSuccess) {
+ throw IllegalStateException("Linux Secret Service write failed: ${result.errorSummary}")
+ }
+ return LINUX_SECRET_TOOL_PREFIX + key
+ }
+
+ override fun unprotect(keyName: String, value: String): String {
+ if (!isAvailable) return ""
+ val key = linuxSecretReferenceKey(keyName, value)
+ logDesktopTts("settings_linux_secret_tool_read_start key=$keyName")
+ val result = commandRunner.run(
+ command = listOf(
+ SecretToolCommand,
+ "lookup",
+ LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
+ LINUX_SECRET_SERVICE_APPLICATION_VALUE,
+ LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
+ key
+ ),
+ timeoutMillis = 8_000L
+ )
+ logDesktopTts("settings_linux_secret_tool_read_result key=$keyName exit=${result.exitCode} chars=${result.stdout.length}")
+ if (!result.isSuccess) {
+ throw IllegalStateException("Linux Secret Service read failed: ${result.errorSummary}")
+ }
+ return result.stdout.trimEnd('\r', '\n')
+ }
+
+ override fun delete(keyName: String) {
+ val key = linuxSecretKey(keyName)
+ runCatching {
+ commandRunner.run(
+ command = listOf(
+ SecretToolCommand,
+ "clear",
+ LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
+ LINUX_SECRET_SERVICE_APPLICATION_VALUE,
+ LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
+ key
+ ),
+ timeoutMillis = 8_000L
+ )
+ }.onFailure { error ->
+ logDesktopTts("settings_linux_secret_tool_delete_failed key=$keyName error=\"${error.desktopTtsSummary()}\"")
+ }
+ }
+
+ private companion object {
+ const val SecretToolCommand = "secret-tool"
+ }
+}
+
+private fun linuxSecretKey(keyName: String): String {
+ return "Episteme.Reader.$keyName"
+}
+
+private fun linuxSecretReferenceKey(keyName: String, reference: String): String {
+ return when {
+ reference.startsWith(LINUX_LIBSECRET_PREFIX) -> reference.removePrefix(LINUX_LIBSECRET_PREFIX)
+ reference.startsWith(LINUX_SECRET_TOOL_PREFIX) -> reference.removePrefix(LINUX_SECRET_TOOL_PREFIX)
+ else -> ""
+ }.ifBlank { linuxSecretKey(keyName) }
+}
+
+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
+ }
+ }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt
new file mode 100644
index 0000000..391979b
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt
@@ -0,0 +1,338 @@
+package org.dueattendant149.bookreader.desktop
+
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.Button
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ScrollableTabRow
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import org.dueattendant149.bookreader.shared.RecapResult
+import org.dueattendant149.bookreader.shared.SummarizationResult
+import org.dueattendant149.bookreader.shared.ui.SharedMarkdownText
+import org.dueattendant149.bookreader.shared.ui.readerString
+
+@Composable
+internal fun DesktopAiHubSheet(
+ bookKey: String,
+ bookTitle: String,
+ itemIndex: Int,
+ itemTitle: String,
+ summaryCacheStore: DesktopSummaryCacheStore,
+ summaryResult: SummarizationResult?,
+ isSummaryLoading: Boolean,
+ recapResult: RecapResult?,
+ isRecapLoading: Boolean,
+ recapProgressMessage: String?,
+ onGenerateSummary: (force: Boolean) -> Unit,
+ onClearSummary: () -> Unit,
+ onGenerateRecap: (() -> Unit)?,
+ onClearRecap: () -> Unit,
+ onDismiss: () -> Unit,
+ credits: Int,
+ showCredits: Boolean
+) {
+ var selectedTab by remember { mutableIntStateOf(0) }
+ var cacheRefresh by remember { mutableIntStateOf(0) }
+ val cachedSummary = remember(bookKey, itemIndex, cacheRefresh) {
+ summaryCacheStore.getSummary(bookKey, itemIndex)
+ }
+ val effectiveSummary = summaryResult ?: cachedSummary?.let {
+ SummarizationResult(summary = it, isCacheHit = true)
+ }
+ val summaryTab = readerString("label_summary", "Summary")
+ val recapTab = readerString("ai_tab_recap", "Recap")
+ val cacheTab = readerString("ai_tab_cache", "Cache")
+ val tabs = buildList {
+ add(summaryTab)
+ if (onGenerateRecap != null) add(recapTab)
+ add(cacheTab)
+ }
+ val selectedTabIndex = selectedTab.coerceIn(0, tabs.lastIndex)
+ val activeTab = tabs.getOrElse(selectedTabIndex) { summaryTab }
+
+ DesktopReaderBottomSheet(
+ title = readerString("desktop_ai_hub", "AI hub"),
+ onDismiss = onDismiss
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(bookTitle, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(itemTitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ if (showCredits) {
+ Surface(
+ color = MaterialTheme.colorScheme.tertiaryContainer,
+ shape = RoundedCornerShape(10.dp)
+ ) {
+ Text(
+ readerString("credits_count", "%1\$d credits", credits),
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ }
+ }
+ }
+
+ ScrollableTabRow(selectedTabIndex = selectedTabIndex, edgePadding = 0.dp) {
+ tabs.forEachIndexed { index, title ->
+ Tab(
+ selected = selectedTabIndex == index,
+ onClick = { selectedTab = index },
+ text = { Text(title) }
+ )
+ }
+ }
+
+ when (activeTab) {
+ summaryTab -> {
+ DesktopAiHubResultView(
+ title = itemTitle,
+ resultText = effectiveSummary?.summary,
+ errorText = effectiveSummary?.error,
+ cost = effectiveSummary?.cost,
+ freeRemaining = effectiveSummary?.freeRemaining,
+ isCacheHit = effectiveSummary?.isCacheHit == true,
+ isLoading = isSummaryLoading,
+ loadingLabel = readerString("generating_summary", "Generating summary..."),
+ emptyTitle = readerString("desktop_no_summary_cached_section", "No summary cached for this section."),
+ primaryActionLabel = readerString("desktop_generate_summary", "Generate summary"),
+ onPrimaryAction = { onGenerateSummary(false) },
+ onRegenerate = { onGenerateSummary(true) },
+ onClear = {
+ summaryCacheStore.deleteSummary(bookKey, itemIndex)
+ cacheRefresh++
+ onClearSummary()
+ }
+ )
+ }
+ recapTab -> {
+ DesktopAiHubResultView(
+ title = readerString("ai_story_recap", "Story recap"),
+ resultText = recapResult?.recap,
+ errorText = recapResult?.error,
+ cost = recapResult?.cost,
+ freeRemaining = recapResult?.freeRemaining,
+ isCacheHit = false,
+ isLoading = isRecapLoading,
+ loadingLabel = recapProgressMessage?.takeIf { it.isNotBlank() } ?: readerString("ai_generating_recap", "Generating recap..."),
+ emptyTitle = readerString("desktop_create_recap_current_position", "Create a recap up to your current position."),
+ primaryActionLabel = readerString("desktop_generate_recap", "Generate recap"),
+ onPrimaryAction = { onGenerateRecap?.invoke() },
+ onRegenerate = { onGenerateRecap?.invoke() },
+ onClear = onClearRecap
+ )
+ }
+ cacheTab -> {
+ DesktopSummaryCachePanel(
+ bookKey = bookKey,
+ summaryCacheStore = summaryCacheStore,
+ onCacheChanged = {
+ cacheRefresh++
+ onClearSummary()
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun DesktopAiHubResultView(
+ title: String,
+ resultText: String?,
+ errorText: String?,
+ cost: Double?,
+ freeRemaining: Int?,
+ isCacheHit: Boolean,
+ isLoading: Boolean,
+ loadingLabel: String,
+ emptyTitle: String,
+ primaryActionLabel: String,
+ onPrimaryAction: () -> Unit,
+ onRegenerate: () -> Unit,
+ onClear: () -> Unit
+) {
+ val clipboard = LocalClipboardManager.current
+ val hasText = !resultText.isNullOrBlank()
+ val hasError = !errorText.isNullOrBlank()
+ Column(
+ modifier = Modifier.fillMaxWidth().heightIn(min = 260.dp, max = 430.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(title, modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ DesktopAiUsageBadge(
+ isCacheHit = isCacheHit,
+ cost = cost,
+ freeRemaining = freeRemaining,
+ isLoading = isLoading
+ )
+ }
+ if (isLoading) {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ when {
+ hasText -> {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ TextButton(enabled = !isLoading, onClick = onRegenerate) {
+ Text(readerString("ai_regenerate", "Regenerate"))
+ }
+ TextButton(enabled = !isLoading, onClick = onClear) {
+ Text(readerString("action_clear", "Clear"))
+ }
+ Spacer(Modifier.weight(1f))
+ IconButton(onClick = { clipboard.setText(AnnotatedString(resultText.orEmpty())) }) {
+ Icon(Icons.Default.ContentCopy, contentDescription = readerString("action_copy", "Copy"))
+ }
+ }
+ HorizontalDivider()
+ Column(modifier = Modifier.weight(1f).heightIn(min = 120.dp).padding(end = 2.dp).verticalScroll(rememberScrollState())) {
+ SharedMarkdownText(resultText.orEmpty())
+ }
+ }
+ hasError -> {
+ Text(errorText.orEmpty(), color = MaterialTheme.colorScheme.error)
+ Button(onClick = onPrimaryAction, enabled = !isLoading) {
+ Text(primaryActionLabel)
+ }
+ }
+ !isLoading -> {
+ Text(emptyTitle, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Button(onClick = onPrimaryAction) {
+ Text(primaryActionLabel)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DesktopAiUsageBadge(
+ isCacheHit: Boolean,
+ cost: Double?,
+ freeRemaining: Int?,
+ isLoading: Boolean
+) {
+ val text = when {
+ isCacheHit -> readerString("desktop_cached", "Cached")
+ cost == 0.0 && freeRemaining != null -> readerString("desktop_free_remaining_format", "Free, %1\$d left", freeRemaining)
+ cost != null -> readerString("desktop_credits_decimal_format", "%1\$s credits", cost)
+ isLoading -> readerString("desktop_cost_calculating", "Cost calculating")
+ else -> null
+ } ?: return
+ Surface(
+ color = if (isCacheHit || cost == 0.0) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ MaterialTheme.colorScheme.primaryContainer
+ },
+ shape = RoundedCornerShape(10.dp)
+ ) {
+ Text(
+ text,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = if (isCacheHit || cost == 0.0) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ }
+ )
+ }
+}
+
+@Composable
+private fun DesktopSummaryCachePanel(
+ bookKey: String,
+ summaryCacheStore: DesktopSummaryCacheStore,
+ onCacheChanged: () -> Unit
+) {
+ var cachedItems by remember(bookKey) { mutableStateOf(summaryCacheStore.getAllSummaries(bookKey)) }
+ if (cachedItems.isEmpty()) {
+ Text(readerString("desktop_no_cached_summaries_book", "No cached summaries for this book yet."), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ return
+ }
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ cachedItems.forEach { item ->
+ var expanded by remember(item.index, item.summary) { mutableStateOf(false) }
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ shape = RoundedCornerShape(8.dp),
+ tonalElevation = 1.dp
+ ) {
+ Column(modifier = Modifier.fillMaxWidth().padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(item.title, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(readerString("desktop_cached_summary", "Cached summary"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ TextButton(onClick = { expanded = !expanded }) {
+ Text(if (expanded) readerString("desktop_hide", "Hide") else readerString("desktop_view", "View"))
+ }
+ IconButton(
+ onClick = {
+ summaryCacheStore.deleteSummary(bookKey, item.index)
+ cachedItems = summaryCacheStore.getAllSummaries(bookKey)
+ onCacheChanged()
+ }
+ ) {
+ Icon(Icons.Default.Delete, contentDescription = readerString("desktop_delete_summary", "Delete summary"), tint = MaterialTheme.colorScheme.error)
+ }
+ }
+ if (expanded) {
+ SharedMarkdownText(item.summary)
+ }
+ }
+ }
+ }
+ TextButton(
+ onClick = {
+ summaryCacheStore.clearBookCache(bookKey)
+ cachedItems = emptyList()
+ onCacheChanged()
+ },
+ modifier = Modifier.align(Alignment.End)
+ ) {
+ Text(readerString("clear_all", "Clear all"), color = MaterialTheme.colorScheme.error)
+ }
+ }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt
new file mode 100644
index 0000000..ac63f0b
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt
@@ -0,0 +1,703 @@
+package org.dueattendant149.bookreader.desktop
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.WindowPlacement
+import androidx.compose.ui.window.WindowPosition
+import androidx.compose.ui.window.WindowState
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
+import org.dueattendant149.bookreader.shared.AppContrastOption
+import org.dueattendant149.bookreader.shared.AppThemeMode
+import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
+import org.dueattendant149.bookreader.shared.reader.ReaderSettings
+import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoadSemanticMode
+import org.dueattendant149.bookreader.shared.ui.SharedAppTheme
+import org.dueattendant149.bookreader.shared.ui.readerString
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.withContext
+import java.awt.Component
+import java.awt.EventQueue
+import java.awt.Frame
+import java.awt.GraphicsDevice
+import java.awt.KeyboardFocusManager
+import java.awt.Rectangle
+import java.awt.Toolkit
+import java.awt.event.KeyEvent as AwtKeyEvent
+import java.util.concurrent.atomic.AtomicReference
+
+internal val DesktopDefaultAppSeedColor = Color(0xFFFFB300)
+
+private val DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis = longArrayOf(160L, 200L)
+
+internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) {
+ configureComposeSwingInterop()
+ application {
+ val windowDefaults = remember { epistemeDesktopWindowDefaults() }
+ val windowStateStore = remember { DesktopWindowStateStore() }
+ val restoredWindowState = remember { windowStateStore.load() }
+ val windowState = rememberWindowState(
+ placement = restoredWindowState?.toWindowPlacement()
+ ?: DesktopWindowStateSnapshot.default().toWindowPlacement(),
+ position = restoredWindowState?.toWindowPosition() ?: WindowPosition(Alignment.Center),
+ size = restoredWindowState?.toWindowSize(windowDefaults.defaultSize) ?: windowDefaults.defaultSize
+ )
+ var readerFullscreen by remember { mutableStateOf(false) }
+ DesktopWindowStatePersistenceEffect(
+ windowState = windowState,
+ store = windowStateStore,
+ enabled = !readerFullscreen
+ )
+ Window(
+ onCloseRequest = ::exitApplication,
+ title = windowDefaults.title,
+ state = windowState,
+ icon = painterResource(windowDefaults.iconResourcePath)
+ ) {
+ DisposableEffect(window, windowDefaults.minimumSize) {
+ window.minimumSize = windowDefaults.minimumSize
+ onDispose {
+ startupSplash?.close()
+ }
+ }
+ EpistemeDesktopStartupGate(
+ window = window,
+ startupSplash = startupSplash,
+ appWindowPlacement = windowState.placement,
+ readerFullscreen = readerFullscreen,
+ onReaderFullscreenChange = { readerFullscreen = it }
+ )
+ }
+ }
+}
+
+@Composable
+private fun EpistemeDesktopStartupGate(
+ window: Component?,
+ startupSplash: DesktopStartupSplash?,
+ appWindowPlacement: WindowPlacement,
+ readerFullscreen: Boolean,
+ onReaderFullscreenChange: (Boolean) -> Unit
+) {
+ var showApp by remember { mutableStateOf(false) }
+
+ DisposableEffect(startupSplash) {
+ onDispose {
+ startupSplash?.close()
+ }
+ }
+
+ if (showApp) {
+ EpistemeDesktopApp(
+ window = window,
+ appWindowPlacement = appWindowPlacement,
+ readerFullscreen = readerFullscreen,
+ onReaderFullscreenChange = onReaderFullscreenChange
+ )
+ } else {
+ EpistemeDesktopStartupScreen(window = window)
+ }
+
+ LaunchedEffect(Unit) {
+ withFrameNanos { }
+ startupSplash?.close()
+ delay(80L)
+ showApp = true
+ }
+}
+
+@Composable
+private fun EpistemeDesktopStartupScreen(window: Component?) {
+ val appTitle = remember { epistemeDesktopWindowDefaults().title }
+ SharedAppTheme(
+ appThemeMode = AppThemeMode.SYSTEM,
+ appContrastOption = AppContrastOption.STANDARD,
+ appTextDimFactorLight = 1.0f,
+ appTextDimFactorDark = 1.0f,
+ appSeedColor = DesktopDefaultAppSeedColor
+ ) {
+ EpistemeDesktopWindowChromeEffect(
+ window = window,
+ captionColor = MaterialTheme.colorScheme.surface,
+ textColor = MaterialTheme.colorScheme.onSurface,
+ borderColor = MaterialTheme.colorScheme.background
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ modifier = Modifier.padding(32.dp)
+ ) {
+ Image(
+ painter = painterResource(EpistemeDesktopWindowIconResource),
+ contentDescription = null,
+ modifier = Modifier.size(64.dp)
+ )
+ Text(
+ text = appTitle,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ Text(
+ text = readerString("desktop_opening_your_library", "Opening your library"),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ CircularProgressIndicator(
+ modifier = Modifier.size(28.dp),
+ strokeWidth = 3.dp
+ )
+ }
+ }
+ }
+}
+
+internal const val ComposeInteropBlendingProperty = "compose.interop.blending"
+internal const val ComposeInteropBlendingEnabled = "true"
+private const val DesktopWindowStatePersistDebounceMillis = 450L
+
+internal fun composeInteropBlendingDefault(
+ platform: DesktopPlatform = currentDesktopPlatform()
+): String? {
+ return if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) {
+ null
+ } else {
+ ComposeInteropBlendingEnabled
+ }
+}
+
+internal fun configureComposeSwingInterop(
+ platform: DesktopPlatform = currentDesktopPlatform()
+) {
+ // Must run before Compose creates the desktop window. Vertical EPUB embeds native SWT/AWT
+ // browser surfaces; the blending path can prevent those native children from painting.
+ if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) {
+ composeInteropBlendingDefault(platform)?.let { defaultValue ->
+ System.setProperty(ComposeInteropBlendingProperty, defaultValue)
+ }
+ }
+ logDesktopWebView2(
+ "compose_interop platform=${platform.os} blending=${System.getProperty(ComposeInteropBlendingProperty).orEmpty().ifBlank { "default" }}"
+ )
+}
+
+@Composable
+internal fun DesktopWindowStatePersistenceEffect(
+ windowState: WindowState,
+ store: DesktopWindowStateStore,
+ enabled: Boolean,
+ transformSnapshot: (DesktopWindowStateSnapshot) -> DesktopWindowStateSnapshot? = { it },
+ onSnapshotSaved: (DesktopWindowStateSnapshot) -> Unit = {}
+) {
+ val persistenceEnabled by rememberUpdatedState(enabled)
+ val latestTransformSnapshot by rememberUpdatedState(transformSnapshot)
+ val latestOnSnapshotSaved by rememberUpdatedState(onSnapshotSaved)
+ LaunchedEffect(windowState, store) {
+ snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) }
+ .distinctUntilChanged()
+ .collectLatest { snapshot ->
+ if (!persistenceEnabled || snapshot == null) return@collectLatest
+ val persistableSnapshot = latestTransformSnapshot(snapshot) ?: return@collectLatest
+ delay(DesktopWindowStatePersistDebounceMillis)
+ if (persistenceEnabled) {
+ withContext(Dispatchers.IO) {
+ store.save(persistableSnapshot)
+ }
+ latestOnSnapshotSaved(persistableSnapshot)
+ }
+ }
+ }
+ DisposableEffect(windowState, store) {
+ onDispose {
+ if (persistenceEnabled) {
+ DesktopWindowStateSnapshot.fromWindowState(windowState)
+ ?.let(latestTransformSnapshot)
+ ?.let { snapshot ->
+ runCatching { store.save(snapshot) }
+ latestOnSnapshotSaved(snapshot)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+internal fun DesktopReaderFullscreenEffect(
+ window: Component?,
+ enabled: Boolean
+) {
+ val awtWindow = window as? java.awt.Window ?: return
+ val fullscreenSnapshot = remember(awtWindow) {
+ AtomicReference()
+ }
+ val pendingExitSnapshot = remember(awtWindow) {
+ AtomicReference()
+ }
+
+ LaunchedEffect(awtWindow, enabled) {
+ if (!enabled && fullscreenSnapshot.get() == null) {
+ return@LaunchedEffect
+ }
+ if (enabled) {
+ EventQueue.invokeLater {
+ awtWindow.captureDesktopReaderFullscreenSnapshot(fullscreenSnapshot)
+ }
+ }
+ delay(if (enabled) 180L else 80L)
+ EventQueue.invokeLater {
+ if (enabled) {
+ pendingExitSnapshot.set(null)
+ awtWindow.enterDesktopReaderFullscreen(fullscreenSnapshot)
+ } else {
+ awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot, pendingExitSnapshot)
+ }
+ }
+ delay(120L)
+ EventQueue.invokeLater {
+ if (!enabled) {
+ awtWindow.restoreDesktopReaderFullscreenExitBounds(pendingExitSnapshot.getAndSet(null))
+ }
+ awtWindow.refreshDesktopReaderWindowFocus()
+ }
+ if (!enabled) {
+ for (delayMillis in DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis) {
+ delay(delayMillis)
+ EventQueue.invokeLater {
+ awtWindow.refreshDesktopReaderWindowFocus()
+ }
+ }
+ }
+ }
+
+ DisposableEffect(awtWindow) {
+ onDispose {
+ EventQueue.invokeLater {
+ awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot)
+ }
+ }
+ }
+}
+
+private data class DesktopReaderFullscreenSnapshot(
+ val device: GraphicsDevice?,
+ val frameState: Int?,
+ val frameBounds: Rectangle?,
+ val alwaysOnTop: Boolean
+)
+
+private fun java.awt.Window.enterDesktopReaderFullscreen(
+ snapshotRef: AtomicReference
+) {
+ if (!isDisplayable) return
+ focusableWindowState = true
+ captureDesktopReaderFullscreenSnapshot(snapshotRef)
+ applyDesktopReaderBorderlessFullscreen(snapshotRef.get())
+ refreshDesktopReaderWindowFocus()
+}
+
+private fun java.awt.Window.captureDesktopReaderFullscreenSnapshot(
+ snapshotRef: AtomicReference
+) {
+ snapshotRef.compareAndSet(
+ null,
+ DesktopReaderFullscreenSnapshot(
+ device = graphicsConfiguration?.device,
+ frameState = (this as? Frame)?.extendedState,
+ frameBounds = bounds.desktopReaderCopy(),
+ alwaysOnTop = isAlwaysOnTop
+ )
+ )
+}
+
+private fun java.awt.Window.applyDesktopReaderBorderlessFullscreen(snapshot: DesktopReaderFullscreenSnapshot?) {
+ if (!isDisplayable) return
+ val device = snapshot?.device ?: graphicsConfiguration?.device
+ val frame = this as? Frame
+ focusableWindowState = true
+ if (frame != null) {
+ frame.extendedState = frame.extendedState and Frame.ICONIFIED.inv() and Frame.MAXIMIZED_BOTH.inv()
+ frame.state = Frame.NORMAL
+ }
+ device?.let { fullscreenDevice ->
+ runCatching {
+ if (fullscreenDevice.fullScreenWindow == this) {
+ fullscreenDevice.fullScreenWindow = null
+ }
+ }
+ }
+ runCatching {
+ bounds = device?.desktopReaderScreenBounds() ?: graphicsConfiguration?.bounds?.desktopReaderCopy() ?: bounds
+ }
+ runCatching {
+ isAlwaysOnTop = true
+ }
+}
+
+private fun java.awt.Window.beginDesktopReaderFullscreenExit(
+ snapshotRef: AtomicReference,
+ pendingExitSnapshotRef: AtomicReference? = null
+) {
+ val snapshot = snapshotRef.getAndSet(null)
+ if (snapshot == null) return
+ pendingExitSnapshotRef?.set(snapshot)
+ val device = snapshot.device ?: graphicsConfiguration?.device
+ device?.let { fullscreenDevice ->
+ runCatching {
+ if (fullscreenDevice.fullScreenWindow == this) {
+ fullscreenDevice.fullScreenWindow = null
+ }
+ }
+ }
+ runCatching {
+ isAlwaysOnTop = snapshot.alwaysOnTop
+ }
+ if (!isVisible) {
+ isVisible = true
+ }
+ (this as? Frame)?.let { frame ->
+ frame.state = Frame.NORMAL
+ frame.extendedState = Frame.NORMAL
+ }
+}
+
+private fun java.awt.Window.restoreDesktopReaderFullscreenExitBounds(snapshot: DesktopReaderFullscreenSnapshot?) {
+ if (snapshot == null) return
+ runCatching {
+ isAlwaysOnTop = snapshot.alwaysOnTop
+ }
+ if (!isVisible) {
+ isVisible = true
+ }
+ val frame = this as? Frame
+ if (frame == null) {
+ bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device)
+ return
+ }
+ val restoreMaximized = snapshot.frameState?.let { state ->
+ state and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH
+ } == true
+ frame.extendedState = Frame.NORMAL
+ frame.state = Frame.NORMAL
+ frame.bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device)
+ if (restoreMaximized) {
+ frame.maximizedBounds = snapshot.device?.desktopReaderUsableBounds()
+ EventQueue.invokeLater {
+ if (frame.isDisplayable && frame.isShowing) {
+ frame.extendedState = Frame.MAXIMIZED_BOTH
+ }
+ }
+ }
+ frame.toFront()
+ frame.requestFocus()
+ frame.validate()
+}
+
+private fun GraphicsDevice.desktopReaderScreenBounds(): Rectangle {
+ return defaultConfiguration.bounds.desktopReaderCopy()
+}
+
+private fun GraphicsDevice.desktopReaderUsableBounds(): Rectangle? {
+ val configuration = defaultConfiguration ?: return null
+ return runCatching {
+ val bounds = configuration.bounds
+ val insets = Toolkit.getDefaultToolkit().getScreenInsets(configuration)
+ Rectangle(
+ bounds.x + insets.left,
+ bounds.y + insets.top,
+ (bounds.width - insets.left - insets.right).coerceAtLeast(1),
+ (bounds.height - insets.top - insets.bottom).coerceAtLeast(1)
+ )
+ }.getOrNull()
+}
+
+private fun Rectangle?.desktopReaderRestoreBounds(device: GraphicsDevice?): Rectangle {
+ val usableBounds = device?.desktopReaderUsableBounds()
+ ?: return this?.desktopReaderCopy() ?: Rectangle(80, 80, 1280, 820)
+ val source = this ?: usableBounds
+ val width = source.width.coerceIn(640, usableBounds.width.coerceAtLeast(640))
+ val height = source.height.coerceIn(480, usableBounds.height.coerceAtLeast(480))
+ val looksFullscreen = source.x <= usableBounds.x &&
+ source.y <= usableBounds.y &&
+ source.width >= usableBounds.width &&
+ source.height >= usableBounds.height
+ if (looksFullscreen) {
+ return usableBounds.desktopReaderCopy()
+ }
+ val maxX = (usableBounds.x + usableBounds.width - width).coerceAtLeast(usableBounds.x)
+ val maxY = (usableBounds.y + usableBounds.height - height).coerceAtLeast(usableBounds.y)
+ return Rectangle(
+ source.x.coerceIn(usableBounds.x, maxX),
+ source.y.coerceIn(usableBounds.y, maxY),
+ width,
+ height
+ )
+}
+
+private fun Rectangle.desktopReaderCopy(): Rectangle {
+ return Rectangle(x, y, width, height)
+}
+
+private fun java.awt.Window.refreshDesktopReaderWindowFocus() {
+ if (!isDisplayable) return
+ if (this is Frame && extendedState and Frame.ICONIFIED != 0) {
+ extendedState = extendedState and Frame.ICONIFIED.inv()
+ }
+ toFront()
+ requestFocus()
+ requestFocusInWindow()
+ focusOwner?.requestFocus()
+}
+
+@Composable
+internal fun DesktopReaderFullscreenKeyEffect(
+ enabled: Boolean,
+ onKeyPressed: (AwtKeyEvent) -> Boolean
+) {
+ DesktopReaderKeyDispatcherEffect(
+ enabled = enabled,
+ allowChromeModalWindows = false,
+ onKeyPressed = onKeyPressed
+ )
+}
+
+@Composable
+internal fun DesktopReaderKeyDispatcherEffect(
+ enabled: Boolean,
+ allowChromeModalWindows: Boolean = false,
+ allowPanelModalWindows: Boolean = false,
+ dispatchWhenOwnerWindowActive: Boolean = true,
+ onKeyPressed: (AwtKeyEvent) -> Boolean
+) {
+ val currentOnKeyPressed by rememberUpdatedState(onKeyPressed)
+ DisposableEffect(
+ enabled,
+ allowChromeModalWindows,
+ allowPanelModalWindows,
+ dispatchWhenOwnerWindowActive
+ ) {
+ if (!enabled) {
+ onDispose {}
+ } else {
+ val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
+ val dispatcher = java.awt.KeyEventDispatcher { event ->
+ val keyWindow = focusManager.focusedWindow ?: focusManager.activeWindow
+ val activeReaderModalKind = keyWindow?.desktopReaderModalWindowKind()
+ val activeWindowAllowed = desktopReaderKeyDispatchAllowedForActiveWindowKind(
+ activeReaderModalKind = activeReaderModalKind,
+ allowChromeModalWindows = allowChromeModalWindows,
+ allowPanelModalWindows = allowPanelModalWindows,
+ dispatchWhenOwnerWindowActive = dispatchWhenOwnerWindowActive
+ )
+ activeWindowAllowed && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event)
+ }
+ focusManager.addKeyEventDispatcher(dispatcher)
+ onDispose {
+ focusManager.removeKeyEventDispatcher(dispatcher)
+ }
+ }
+ }
+}
+
+internal enum class DesktopReaderModalWindowKind {
+ CHROME,
+ PANEL,
+ POPUP
+}
+
+internal fun desktopReaderKeyDispatchAllowedForActiveWindowKind(
+ activeReaderModalKind: DesktopReaderModalWindowKind?,
+ allowChromeModalWindows: Boolean,
+ allowPanelModalWindows: Boolean,
+ dispatchWhenOwnerWindowActive: Boolean
+): Boolean {
+ return when (activeReaderModalKind) {
+ null -> dispatchWhenOwnerWindowActive
+ DesktopReaderModalWindowKind.CHROME -> allowChromeModalWindows
+ DesktopReaderModalWindowKind.PANEL -> allowPanelModalWindows
+ DesktopReaderModalWindowKind.POPUP -> false
+ }
+}
+
+private fun java.awt.Window.desktopReaderModalWindowKind(): DesktopReaderModalWindowKind? {
+ val windowTitle = when (this) {
+ is java.awt.Dialog -> title
+ is Frame -> title
+ else -> ""
+ }
+ return desktopReaderModalWindowKind(
+ windowName = name.orEmpty(),
+ windowTitle = windowTitle
+ )
+}
+
+internal fun desktopReaderModalWindowKind(
+ windowName: String,
+ windowTitle: String
+): DesktopReaderModalWindowKind? {
+ return when {
+ windowName == "${DesktopReaderModalWindowNamePrefix}ChromeTop" ||
+ windowName == "${DesktopReaderModalWindowNamePrefix}ChromeBottom" ||
+ windowTitle.startsWith("Reader Chrome") -> DesktopReaderModalWindowKind.CHROME
+
+ windowName == "${DesktopReaderModalWindowNamePrefix}Panel" ||
+ windowName == "${DesktopReaderModalWindowNamePrefix}PanelLeft" ||
+ windowName == "${DesktopReaderModalWindowNamePrefix}PanelRight" ||
+ windowTitle.startsWith("Reader Panel") ||
+ windowTitle.startsWith("Reader Navigation") ||
+ windowTitle.startsWith("Reader Tools") -> DesktopReaderModalWindowKind.PANEL
+
+ windowName.startsWith(DesktopReaderModalWindowNamePrefix) ||
+ windowTitle.startsWith("Reader Popup") -> DesktopReaderModalWindowKind.POPUP
+
+ else -> null
+ }
+}
+
+internal const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:"
+
+internal data class DesktopWebViewRuntimeState(
+ val initialized: Boolean = false,
+ val restartRequired: Boolean = false,
+ val downloadProgress: Float = -1f,
+ val errorMessage: String? = null
+)
+
+internal enum class DesktopEpubWebViewBackend(
+ val logName: String,
+ val displayName: String
+) {
+ WINDOWS_WEBVIEW2("webview2", "Microsoft Edge WebView2"),
+ WEBKIT("webkit", "WebKit"),
+ UNSUPPORTED("unsupported", "native webview")
+}
+
+internal fun desktopEpubWebViewBackend(
+ platform: DesktopPlatform = currentDesktopPlatform()
+): DesktopEpubWebViewBackend {
+ return when (platform.os) {
+ DesktopOperatingSystem.WINDOWS -> DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2
+ DesktopOperatingSystem.LINUX,
+ DesktopOperatingSystem.MACOS -> DesktopEpubWebViewBackend.WEBKIT
+ DesktopOperatingSystem.OTHER -> DesktopEpubWebViewBackend.UNSUPPORTED
+ }
+}
+
+internal fun desktopEpubWebViewUsesNativeSwtBrowser(
+ platform: DesktopPlatform = currentDesktopPlatform()
+): Boolean {
+ return desktopEpubWebViewBackend(platform) != DesktopEpubWebViewBackend.UNSUPPORTED
+}
+
+internal fun desktopEpubWebViewUsesWebView2(
+ platform: DesktopPlatform = currentDesktopPlatform()
+): Boolean {
+ return desktopEpubWebViewBackend(platform) == DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2
+}
+
+internal fun desktopShouldUseNativeVerticalEpubReader(
+ platform: DesktopPlatform = currentDesktopPlatform()
+): Boolean {
+ return platform.os == DesktopOperatingSystem.LINUX
+}
+
+internal fun desktopEpubBookLoadSemanticMode(
+ settings: ReaderSettings,
+ platform: DesktopPlatform = currentDesktopPlatform()
+): SharedJvmBookLoadSemanticMode {
+ return if (
+ settings.readingMode == ReaderReadingMode.VERTICAL &&
+ !desktopShouldUseNativeVerticalEpubReader(platform)
+ ) {
+ SharedJvmBookLoadSemanticMode.SKIP
+ } else {
+ SharedJvmBookLoadSemanticMode.FULL
+ }
+}
+
+internal fun desktopEpubWebViewCanRender(
+ state: DesktopWebViewRuntimeState,
+ platform: DesktopPlatform = currentDesktopPlatform()
+): Boolean {
+ return desktopEpubWebViewUsesNativeSwtBrowser(platform)
+}
+
+@Composable
+internal fun DesktopWebViewRuntimeIndicator(
+ state: DesktopWebViewRuntimeState,
+ modifier: Modifier = Modifier
+) {
+ val platform = currentDesktopPlatform()
+ val message = when {
+ !desktopEpubWebViewUsesNativeSwtBrowser(platform) ->
+ readerString("desktop_webview_unsupported", "Embedded webview is unavailable on this desktop platform.")
+ state.errorMessage != null -> readerString("desktop_webview_start_error", "Embedded webview could not start: %1\$s", state.errorMessage)
+ state.restartRequired -> readerString("desktop_webview_restart_required", "Embedded webview installed. Restart Episteme to finish setup.")
+ state.downloadProgress >= 0f -> readerString("desktop_webview_preparing_progress", "Preparing bundled embedded webview %1\$d%%", state.downloadProgress.toInt())
+ else -> readerString("desktop_webview_preparing", "Preparing embedded webview...")
+ }
+
+ Box(
+ modifier = modifier.padding(32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ if (state.errorMessage == null && !state.restartRequired) {
+ if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) {
+ CircularProgressIndicator()
+ }
+ }
+ Text(
+ text = message,
+ color = if (state.errorMessage == null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center
+ )
+ if (state.downloadProgress in 0f..100f) {
+ LinearProgressIndicator(
+ progress = { state.downloadProgress / 100f },
+ modifier = Modifier.width(260.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt
new file mode 100644
index 0000000..686e4ae
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt
@@ -0,0 +1,159 @@
+package org.dueattendant149.bookreader.desktop
+
+import org.dueattendant149.bookreader.shared.BookShelfRef
+import org.dueattendant149.bookreader.shared.CustomFontItem
+import org.dueattendant149.bookreader.shared.SharedLibraryProjectionInput
+import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot
+import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector
+import org.dueattendant149.bookreader.shared.SharedReaderScreenState
+import org.dueattendant149.bookreader.shared.ShelfRecord
+import org.dueattendant149.bookreader.shared.reader.ReaderSettings
+import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
+import org.dueattendant149.bookreader.shared.reader.SharedEpubChapter
+import org.dueattendant149.bookreader.shared.ui.SharedAppTab
+
+internal val DesktopInitialAppTab = SharedAppTab.LIBRARY
+
+internal fun desktopEmptyReaderBook(): SharedEpubBook {
+ val noBookOpen = loadDesktopStringResolver().string("desktop_no_book_open", "No book open")
+ return SharedEpubBook(
+ id = "desktop_empty_reader",
+ fileName = "",
+ title = noBookOpen,
+ chapters = listOf(
+ SharedEpubChapter(
+ id = "empty",
+ title = noBookOpen,
+ plainText = ""
+ )
+ )
+ )
+}
+
+internal fun SharedLibrarySnapshot.withDesktopDefaults(): SharedLibrarySnapshot {
+ val shouldMigrateReaderDefaults = desktopReaderDefaultsVersion < DesktopReaderDefaultsVersion
+ val migratedTextDefaults = if (shouldMigrateReaderDefaults && readerDefaultSettings == ReaderSettings()) {
+ DesktopDefaultTextReaderSettings
+ } else {
+ readerDefaultSettings
+ }
+ val migratedPdfDefaults = if (shouldMigrateReaderDefaults && pdfReaderDefaultSettings == ReaderSettings(themeId = "no_theme")) {
+ DesktopDefaultPdfReaderSettings
+ } else {
+ pdfReaderDefaultSettings
+ }
+ val migratedBooks = if (shouldMigrateReaderDefaults) {
+ books.map { book ->
+ when {
+ book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.TEXT) &&
+ book.readerSettings == ReaderSettings() -> book.copy(readerSettings = migratedTextDefaults)
+ book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.PDF) &&
+ book.readerSettings == ReaderSettings(themeId = "no_theme") -> book.copy(readerSettings = migratedPdfDefaults)
+ else -> book
+ }
+ }
+ } else {
+ books
+ }
+ return copy(
+ books = migratedBooks,
+ appSeedColor = appSeedColor ?: DesktopDefaultAppSeedColor,
+ readerDefaultSettings = migratedTextDefaults,
+ pdfReaderDefaultSettings = migratedPdfDefaults,
+ desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion
+ )
+}
+
+internal fun SharedLibrarySnapshot.toDesktopReaderScreenState(): SharedReaderScreenState {
+ val readableBooks = books.filter { it.type in DesktopReadableFileTypes }
+ return SharedReaderScreenState(
+ rawLibraryBooks = readableBooks,
+ recentFilesLimit = recentFilesLimit,
+ allTags = tags.ifEmpty { readableBooks.collectTags() },
+ syncedFolders = syncedFolders,
+ isTabsEnabled = isTabsEnabled,
+ openTabIds = openTabIds,
+ activeTabBookId = activeTabBookId,
+ pinnedHomeBookIds = pinnedHomeBookIds,
+ pinnedLibraryBookIds = pinnedLibraryBookIds,
+ useStrictFileFilter = useStrictFileFilter,
+ appThemeMode = appThemeMode,
+ appContrastOption = appContrastOption,
+ appTextDimFactorLight = appTextDimFactorLight,
+ appTextDimFactorDark = appTextDimFactorDark,
+ appSeedColor = appSeedColor,
+ appFontPreference = appFontPreference,
+ customAppThemes = customAppThemes,
+ customReaderThemes = customReaderThemes,
+ readerDefaultSettings = readerDefaultSettings,
+ pdfReaderDefaultSettings = pdfReaderDefaultSettings,
+ readerToolbarPreferences = readerToolbarPreferences,
+ readerHighlightPalette = readerHighlightPalette,
+ pdfHighlighterPalette = pdfHighlighterPalette,
+ readerTtsReplacementPreferences = readerTtsReplacementPreferences
+ )
+}
+
+internal fun SharedLibraryStateProjector.projectDesktopLibraryState(
+ state: SharedReaderScreenState,
+ shelfRecords: List,
+ shelfRefs: List
+): SharedReaderScreenState {
+ val allBooks = state.rawLibraryBooks
+ val visibleBooks = allBooks.filterNot { isDesktopPdfReflowBookId(it.id) }
+ val projected = project(
+ SharedLibraryProjectionInput(
+ state = state,
+ booksFromStore = visibleBooks,
+ shelfRecords = shelfRecords,
+ shelfRefs = shelfRefs,
+ tags = state.allTags.ifEmpty { visibleBooks.collectTags() }
+ )
+ )
+ val booksById = allBooks.associateBy { it.id }
+ val openTabs = state.openTabIds.mapNotNull { booksById[it] }
+ val openTabIds = openTabs.map { it.id }
+ return projected.copy(
+ rawLibraryBooks = allBooks,
+ openTabs = openTabs,
+ openTabIds = openTabIds,
+ activeTabBookId = state.activeTabBookId?.takeIf { it in openTabIds }
+ )
+}
+
+internal fun SharedReaderScreenState.toDesktopLibrarySnapshot(
+ shelfRecords: List,
+ shelfRefs: List,
+ customFonts: List
+): SharedLibrarySnapshot {
+ return SharedLibrarySnapshot(
+ books = rawLibraryBooks,
+ shelfRecords = shelfRecords,
+ shelfRefs = shelfRefs,
+ tags = allTags,
+ customFonts = customFonts,
+ syncedFolders = syncedFolders,
+ recentFilesLimit = recentFilesLimit,
+ isTabsEnabled = isTabsEnabled,
+ openTabIds = openTabIds,
+ activeTabBookId = activeTabBookId,
+ pinnedHomeBookIds = pinnedHomeBookIds,
+ pinnedLibraryBookIds = pinnedLibraryBookIds,
+ useStrictFileFilter = useStrictFileFilter,
+ appThemeMode = appThemeMode,
+ appContrastOption = appContrastOption,
+ appTextDimFactorLight = appTextDimFactorLight,
+ appTextDimFactorDark = appTextDimFactorDark,
+ appSeedColor = appSeedColor,
+ appFontPreference = appFontPreference,
+ customAppThemes = customAppThemes,
+ customReaderThemes = customReaderThemes,
+ readerDefaultSettings = readerDefaultSettings,
+ pdfReaderDefaultSettings = pdfReaderDefaultSettings,
+ desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion,
+ readerToolbarPreferences = readerToolbarPreferences,
+ readerHighlightPalette = readerHighlightPalette,
+ pdfHighlighterPalette = pdfHighlighterPalette,
+ readerTtsReplacementPreferences = readerTtsReplacementPreferences
+ )
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt
new file mode 100644
index 0000000..c58f3df
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt
@@ -0,0 +1,56 @@
+package org.dueattendant149.bookreader.desktop
+
+import java.io.File
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.util.Properties
+
+internal fun File.writeTextAtomically(text: String) {
+ parentFile?.mkdirs()
+ val temp = createSiblingTempFile()
+ try {
+ temp.writeText(text)
+ moveReplacing(temp, this)
+ } finally {
+ runCatching { if (temp.exists()) temp.delete() }
+ }
+}
+
+internal fun File.storePropertiesAtomically(properties: Properties, comments: String) {
+ parentFile?.mkdirs()
+ val temp = createSiblingTempFile()
+ try {
+ temp.outputStream().use { output ->
+ properties.store(output, comments)
+ }
+ moveReplacing(temp, this)
+ } finally {
+ runCatching { if (temp.exists()) temp.delete() }
+ }
+}
+
+private fun File.createSiblingTempFile(): File {
+ val directory = parentFile ?: File(".")
+ directory.mkdirs()
+ val prefix = ".$name."
+ return Files.createTempFile(directory.toPath(), prefix, ".tmp").toFile()
+}
+
+private fun moveReplacing(source: File, target: File) {
+ target.parentFile?.mkdirs()
+ try {
+ Files.move(
+ source.toPath(),
+ target.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ StandardCopyOption.ATOMIC_MOVE
+ )
+ } catch (_: AtomicMoveNotSupportedException) {
+ Files.move(
+ source.toPath(),
+ target.toPath(),
+ StandardCopyOption.REPLACE_EXISTING
+ )
+ }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt
new file mode 100644
index 0000000..55268cf
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt
@@ -0,0 +1,109 @@
+package org.dueattendant149.bookreader.desktop
+
+import org.dueattendant149.bookreader.shared.ImportedBookFile
+import org.dueattendant149.bookreader.shared.ReaderPlatform
+import org.dueattendant149.bookreader.shared.SharedFileCapabilities
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.security.MessageDigest
+
+internal data class DesktopPreparedImport(
+ val files: List,
+ val failedCount: Int
+)
+
+internal class DesktopBookImporter(
+ private val booksDirectory: File = File(desktopUserDataRoot(), "books")
+) {
+ fun createBookFile(fileName: String): File {
+ booksDirectory.mkdirs()
+ return File(booksDirectory, fileName)
+ }
+
+ fun prepareImports(files: List): DesktopPreparedImport {
+ val preparedFiles = mutableListOf()
+ var failedCount = 0
+ booksDirectory.mkdirs()
+
+ files.forEach { file ->
+ val type = SharedFileCapabilities.fileTypeForName(file.name)
+ if (!SharedFileCapabilities.canOpen(type, ReaderPlatform.DESKTOP)) {
+ preparedFiles += file.copy(sourceFolder = null)
+ return@forEach
+ }
+
+ val source = file.localPath
+ ?.let(::File)
+ ?.takeIf { it.isFile }
+
+ if (source == null) {
+ failedCount += 1
+ return@forEach
+ }
+
+ val hashResult = runCatching { source.sha256() }
+ if (hashResult.isFailure) {
+ failedCount += 1
+ return@forEach
+ }
+ val hash = hashResult.getOrThrow()
+ val destination = File(booksDirectory, "$hash${file.storageSuffix(source)}")
+
+ val copyResult = runCatching {
+ copyIfNeeded(source, destination)
+ destination
+ }
+ if (copyResult.isFailure) {
+ failedCount += 1
+ return@forEach
+ }
+ val copied = copyResult.getOrThrow()
+
+ preparedFiles += ImportedBookFile(
+ name = file.name,
+ uriString = null,
+ localPath = copied.absolutePath,
+ size = copied.length(),
+ sourceFolder = null,
+ id = hash
+ )
+ }
+
+ return DesktopPreparedImport(
+ files = preparedFiles,
+ failedCount = failedCount
+ )
+ }
+
+ private fun copyIfNeeded(source: File, destination: File) {
+ val sourceFile = source.canonicalFile
+ val destinationFile = destination.canonicalFile
+ if (sourceFile == destinationFile) return
+ destination.parentFile?.mkdirs()
+ Files.copy(source.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ }
+
+ private fun ImportedBookFile.storageSuffix(source: File): String {
+ return SharedFileCapabilities.fileExtensionSuffixForName(name)
+ ?: source.extension.takeIf { it.isNotBlank() }?.let { ".$it" }
+ ?: ".book"
+ }
+}
+
+private fun File.sha256(): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ inputStream().use { input ->
+ val buffer = ByteArray(8 * 1024)
+ while (true) {
+ val read = input.read(buffer)
+ if (read == -1) break
+ digest.update(buffer, 0, read)
+ }
+ }
+ return digest.digest().toHexString()
+}
+
+private fun ByteArray.toHexString(): String {
+ return joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt
new file mode 100644
index 0000000..4055007
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt
@@ -0,0 +1,85 @@
+package org.dueattendant149.bookreader.desktop
+
+import org.dueattendant149.bookreader.shared.ReaderAiByokSettings
+import org.dueattendant149.bookreader.shared.SharedFeaturePolicy
+import org.dueattendant149.bookreader.shared.SharedLegalLinks
+import org.dueattendant149.bookreader.shared.SharedLegalProfile
+import org.dueattendant149.bookreader.shared.sharedLegalLinksForProfile
+
+internal const val DesktopFlavorProperty = "episteme.desktop.flavor"
+internal const val DesktopVersionProperty = "episteme.desktop.version"
+internal const val DesktopFlavorStandard = "standard"
+internal const val DesktopFlavorOssOffline = "oss-offline"
+internal const val EpistemeDesktopStandardAppName = "Episteme"
+internal const val EpistemeDesktopOssAppName = "Episteme oss"
+internal const val ComposeApplicationResourcesDirProperty = "compose.application.resources.dir"
+
+internal data class DesktopBuildProfile(
+ val flavor: String,
+ val appName: String,
+ val buildLabel: String,
+ val featurePolicy: SharedFeaturePolicy,
+ val legalProfile: SharedLegalProfile = if (featurePolicy.byokAi) {
+ SharedLegalProfile.OSS
+ } else {
+ SharedLegalProfile.STANDARD
+ }
+) {
+ val isOssOffline: Boolean get() = flavor == DesktopFlavorOssOffline
+ val aiKeySettingsAvailable: Boolean
+ get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && legalProfile != SharedLegalProfile.OSS
+ val byokAiAvailable: Boolean get() = featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess
+ val creditBackedCloudTtsControlsAvailable: Boolean
+ get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && !byokAiAvailable
+ val legalLinks: SharedLegalLinks
+ get() = sharedLegalLinksForProfile(legalProfile)
+}
+
+internal fun currentDesktopBuildProfile(): DesktopBuildProfile {
+ return desktopBuildProfileForFlavor(
+ System.getProperty(DesktopFlavorProperty, DesktopFlavorStandard)
+ )
+}
+
+internal fun desktopBuildProfileForFlavor(rawFlavor: String?): DesktopBuildProfile {
+ val flavor = normalizedDesktopFlavor(rawFlavor)
+ return when (flavor) {
+ DesktopFlavorOssOffline -> DesktopBuildProfile(
+ flavor = DesktopFlavorOssOffline,
+ appName = EpistemeDesktopOssAppName,
+ buildLabel = "Offline OSS edition",
+ featurePolicy = SharedFeaturePolicy.OssOffline,
+ legalProfile = SharedLegalProfile.OSS
+ )
+ else -> DesktopBuildProfile(
+ flavor = DesktopFlavorStandard,
+ appName = EpistemeDesktopStandardAppName,
+ buildLabel = "Standard edition",
+ featurePolicy = SharedFeaturePolicy.Standard,
+ legalProfile = SharedLegalProfile.STANDARD
+ )
+ }
+}
+
+private fun normalizedDesktopFlavor(rawFlavor: String?): String {
+ return when (rawFlavor?.trim()?.lowercase()) {
+ DesktopFlavorOssOffline,
+ "oss",
+ "episteme-oss" -> DesktopFlavorOssOffline
+ else -> DesktopFlavorStandard
+ }
+}
+
+internal fun ReaderAiByokSettings.withDesktopFeaturePolicy(
+ featurePolicy: SharedFeaturePolicy
+): ReaderAiByokSettings {
+ return if (featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess) {
+ toDesktopPersistableAiSettings()
+ } else {
+ ReaderAiByokSettings()
+ }
+}
+
+internal fun ReaderAiByokSettings.toDesktopPersistableAiSettings(): ReaderAiByokSettings {
+ return sanitized().copy(hideReaderAiFeatures = false)
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt
new file mode 100644
index 0000000..e51f5dc
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt
@@ -0,0 +1,342 @@
+package org.dueattendant149.bookreader.desktop
+
+import org.dueattendant149.bookreader.shared.AiAdapter
+import org.dueattendant149.bookreader.shared.AiDefinitionResult
+import org.dueattendant149.bookreader.shared.ReaderAiByokSettings
+import org.dueattendant149.bookreader.shared.ReaderAiFeature
+import org.dueattendant149.bookreader.shared.ReaderByokTextRequest
+import org.dueattendant149.bookreader.shared.ReaderByokTextRequestResult
+import org.dueattendant149.bookreader.shared.ReaderByokTextRequests
+import org.dueattendant149.bookreader.shared.RecapResult
+import org.dueattendant149.bookreader.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,
+ private val networkAccess: () -> Boolean = { true }
+) : AiAdapter {
+ override val isAvailable: Boolean
+ get() = networkAccess() && 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 defineStreaming(
+ text: String,
+ context: String?,
+ onUpdate: (String) -> Unit
+ ): AiDefinitionResult {
+ val result = callTextAi(ReaderAiFeature.DEFINE, text, context, onUpdate)
+ 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 summarizeStreaming(
+ text: String,
+ onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit,
+ onUpdate: (String) -> Unit
+ ): SummarizationResult {
+ val result = callTextAi(ReaderAiFeature.SUMMARIZE, text, onUpdate = onUpdate)
+ 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,
+ onUpdate: (String) -> Unit = {}
+ ): Result = withContext(Dispatchers.IO) {
+ if (!networkAccess()) return@withContext Result.failure(IllegalStateException("AI features are unavailable in this desktop build."))
+ 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(onUpdate)
+ }
+ }
+ }
+
+ private fun ReaderByokTextRequest.execute(onUpdate: (String) -> Unit): 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, onUpdate)
+ } else {
+ streamGeminiResponse(connection, onUpdate)
+ }.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, onUpdate: (String) -> Unit): 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()
+ val chunk = parsed.geminiTextChunk()
+ if (chunk.isNotEmpty()) {
+ output.append(chunk)
+ onUpdate(chunk)
+ }
+ if (parsed.geminiFinishReason() == "SAFETY") {
+ throw IllegalStateException("Blocked for safety reasons.")
+ }
+ }
+ }
+ }
+ return output.toString()
+ }
+
+ private fun streamGroqResponse(connection: HttpURLConnection, onUpdate: (String) -> Unit): 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("")
+ if (end == -1) {
+ if (thinkBuffer.length > 7) thinkBuffer = thinkBuffer.takeLast(7)
+ break
+ }
+ inThink = false
+ thinkBuffer = thinkBuffer.substring(end + 8)
+ } else {
+ val start = thinkBuffer.indexOf("")
+ 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()
+ val cleaned = cleanChunk(chunk)
+ if (cleaned.isNotEmpty()) {
+ output.append(cleaned)
+ onUpdate(cleaned)
+ }
+ }
+ }
+ if (!inThink && thinkBuffer.isNotBlank()) {
+ output.append(thinkBuffer)
+ onUpdate(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
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt
new file mode 100644
index 0000000..2ca06c6
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt
@@ -0,0 +1,85 @@
+package org.dueattendant149.bookreader.desktop
+
+import java.io.File
+import java.util.Properties
+
+internal data class DesktopCloudConfig(
+ val aiWorkerUrl: String,
+ val ttsWorkerUrl: String,
+ val firebaseWebApiKey: String,
+ val firebaseProjectId: String,
+ val googleOAuthClientId: String,
+ val googleOAuthClientSecret: String
+) {
+ val isAuthConfigured: Boolean
+ get() = firebaseWebApiKey.isNotBlank() &&
+ firebaseProjectId.isNotBlank() &&
+ googleOAuthClientId.isNotBlank()
+
+ val isAiWorkerConfigured: Boolean get() = aiWorkerUrl.isNotBlank()
+ val isTtsWorkerConfigured: Boolean get() = ttsWorkerUrl.isNotBlank()
+}
+
+internal fun loadDesktopCloudConfig(): DesktopCloudConfig {
+ return desktopCloudConfigFromProperties(
+ resourceProperties = loadDesktopCloudResourceProperties(),
+ localProperties = loadDesktopLocalProperties()
+ )
+}
+
+private fun loadDesktopCloudResourceProperties(): Properties {
+ return Properties().apply {
+ val classLoader = DesktopCloudConfig::class.java.classLoader
+ val stream = classLoader.getResourceAsStream("desktop-cloud.properties")
+ ?: classLoader.getResourceAsStream("common/desktop-cloud.properties")
+ ?: System.getProperty(ComposeApplicationResourcesDirProperty)
+ ?.let(::File)
+ ?.let { resourcesDir ->
+ listOf(
+ resourcesDir.resolve("desktop-cloud.properties"),
+ resourcesDir.resolve("common/desktop-cloud.properties")
+ ).firstOrNull { it.isFile }?.inputStream()
+ }
+ stream?.use { input -> load(input) }
+ }
+}
+
+private fun loadDesktopLocalProperties(file: File = File("local.properties")): Properties {
+ return Properties().apply {
+ file.takeIf { it.isFile }
+ ?.inputStream()
+ ?.use { input -> load(input) }
+ }
+}
+
+internal fun desktopCloudConfigFromProperties(
+ resourceProperties: Properties,
+ localProperties: Properties = Properties(),
+ systemProperty: (String) -> String? = { key -> System.getProperty("episteme.desktop.$key") },
+ environment: (String) -> String? = { key -> System.getenv(key) }
+): DesktopCloudConfig {
+ fun value(vararg keys: String): String {
+ return keys.firstNotNullOfOrNull { key ->
+ systemProperty(key)
+ ?: environment("EPISTEME_DESKTOP_${key.uppercase()}")
+ ?: environment(key)
+ ?: localProperties.getProperty("DESKTOP_$key")
+ ?: localProperties.getProperty(key)
+ ?: resourceProperties.getProperty(key)
+ }?.trim().orEmpty()
+ }
+
+ val aiWorkerUrl = value("AI_WORKER_URL").ifBlank {
+ "https://reader-ai.aryanrajttps.workers.dev"
+ }
+ val ttsWorkerUrl = value("TTS_WORKER_URL")
+
+ return DesktopCloudConfig(
+ aiWorkerUrl = aiWorkerUrl,
+ ttsWorkerUrl = ttsWorkerUrl,
+ firebaseWebApiKey = value("FIREBASE_WEB_API_KEY", "GOOGLE_API_KEY"),
+ firebaseProjectId = value("FIREBASE_PROJECT_ID").ifBlank { "reader-9fc469d7" },
+ googleOAuthClientId = value("GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_WEB_CLIENT_ID", "DEFAULT_WEB_CLIENT_ID"),
+ googleOAuthClientSecret = value("GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_WEB_CLIENT_SECRET", "DEFAULT_WEB_CLIENT_SECRET")
+ )
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt
new file mode 100644
index 0000000..5b3ea79
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt
@@ -0,0 +1,683 @@
+package org.dueattendant149.bookreader.desktop
+
+import org.dueattendant149.bookreader.shared.FileType
+import org.dueattendant149.bookreader.shared.SharedFileCapabilities
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.doubleOrNull
+import kotlinx.serialization.json.intOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.longOrNull
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.InputStream
+import java.io.SequenceInputStream
+import java.net.URI
+import java.net.URLEncoder
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.time.Duration
+import java.time.Instant
+import java.util.Collections
+import java.util.UUID
+
+internal data class DesktopCloudBookMetadata(
+ val bookId: String = "",
+ val title: String? = null,
+ val author: String? = null,
+ val displayName: String = "",
+ val type: String = "",
+ val lastPositionCfi: String? = null,
+ val lastChapterIndex: Int? = null,
+ val locatorBlockIndex: Int? = null,
+ val locatorCharOffset: Int? = null,
+ val lastPage: Int? = null,
+ val progressPercentage: Float? = null,
+ val isRecent: Boolean = true,
+ val isDeleted: Boolean = false,
+ val lastModifiedTimestamp: Long = 0L,
+ val readingPositionModifiedTimestamp: Long = 0L,
+ val annotationModifiedTimestamp: Long = 0L,
+ val bookmarksJson: String? = null,
+ val hasAnnotations: Boolean = false,
+ val fileContentModifiedTimestamp: Long = 0L,
+ val customName: String? = null,
+ val highlightsJson: String? = null,
+ val seriesName: String? = null,
+ val seriesIndex: Double? = null,
+ val description: String? = null,
+ val originalTitle: String? = null,
+ val originalAuthor: String? = null,
+ val originalSeriesName: String? = null,
+ val originalSeriesIndex: Double? = null,
+ val originalDescription: String? = null
+)
+
+internal data class DesktopCloudShelfMetadata(
+ val name: String = "",
+ val bookIds: List = emptyList(),
+ val lastModifiedTimestamp: Long = 0L,
+ val isDeleted: Boolean = false
+)
+
+internal data class DesktopCloudFontMetadata(
+ val id: String = "",
+ val displayName: String = "",
+ val fileName: String = "",
+ val fileExtension: String = "",
+ val timestamp: Long = 0L,
+ val isDeleted: Boolean = false
+)
+
+internal data class DesktopDriveFile(
+ val id: String,
+ val name: String,
+ val modifiedTimeMillis: Long = 0L
+)
+
+internal class DesktopFirestoreRepository(
+ private val config: DesktopCloudConfig,
+ private val client: HttpClient = defaultDesktopCloudHttpClient()
+) {
+ suspend fun getAllBooks(userId: String, idToken: String): List =
+ firestoreCollection(userId, "books", idToken).mapNotNull { document ->
+ document.fields?.toBookMetadata(document.id)
+ }
+
+ suspend fun getBookMetadata(userId: String, bookId: String, idToken: String): DesktopCloudBookMetadata? =
+ firestoreDocument(userId, "books", bookId, idToken)?.let { document ->
+ document.fields?.toBookMetadata(document.id)
+ }
+
+ suspend fun syncBookMetadata(
+ userId: String,
+ book: DesktopCloudBookMetadata,
+ originDeviceId: String,
+ idToken: String
+ ) {
+ val fields = book.toFirestoreFields() + ("originDeviceId" to firestoreString(originDeviceId))
+ writeFirestoreDocument(userId, "books", book.bookId, fields, idToken)
+ }
+
+ suspend fun getAllShelves(userId: String, idToken: String): List =
+ firestoreCollection(userId, "shelves", idToken).mapNotNull { document ->
+ document.fields?.toShelfMetadata(document.id)
+ }
+
+ suspend fun syncShelf(
+ userId: String,
+ shelf: DesktopCloudShelfMetadata,
+ originDeviceId: String,
+ idToken: String
+ ) {
+ val fields = shelf.toFirestoreFields() + ("originDeviceId" to firestoreString(originDeviceId))
+ writeFirestoreDocument(userId, "shelves", shelf.name, fields, idToken)
+ }
+
+ suspend fun getAllFonts(userId: String, idToken: String): List =
+ firestoreCollection(userId, "fonts", idToken).mapNotNull { document ->
+ document.fields?.toFontMetadata(document.id)
+ }
+
+ suspend fun syncFontMetadata(userId: String, font: DesktopCloudFontMetadata, idToken: String) {
+ writeFirestoreDocument(userId, "fonts", font.id, font.toFirestoreFields(), idToken)
+ }
+
+ suspend fun deleteFontMetadata(userId: String, fontId: String, idToken: String) {
+ deleteFirestoreDocument(userId, "fonts", fontId, idToken)
+ }
+
+ suspend fun deleteAllUserFirestoreData(userId: String, idToken: String) {
+ listOf("books", "shelves", "fonts").forEach { collection ->
+ firestoreCollection(userId, collection, idToken).forEach { document ->
+ deleteFirestoreDocument(userId, collection, document.id, idToken)
+ }
+ }
+ }
+
+ private suspend fun firestoreCollection(
+ userId: String,
+ collection: String,
+ idToken: String
+ ): List = withContext(Dispatchers.IO) {
+ val response = sendFirestore(
+ request = HttpRequest.newBuilder(firestoreCollectionUri(userId, collection))
+ .GET()
+ .build(),
+ idToken = idToken,
+ allowNotFound = true
+ ) ?: return@withContext emptyList()
+ val root = DesktopCloudJson.parseToJsonElement(response).jsonObject
+ root["documents"]?.jsonArrayOrNull().orEmpty().mapNotNull { element ->
+ val document = element.jsonObjectOrNull() ?: return@mapNotNull null
+ DesktopFirestoreDocument(
+ id = document.string("name")?.substringAfterLast('/').orEmpty(),
+ fields = document["fields"]?.jsonObjectOrNull()
+ )
+ }
+ }
+
+ private suspend fun firestoreDocument(
+ userId: String,
+ collection: String,
+ documentId: String,
+ idToken: String
+ ): DesktopFirestoreDocument? = withContext(Dispatchers.IO) {
+ val response = sendFirestore(
+ request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId))
+ .GET()
+ .build(),
+ idToken = idToken,
+ allowNotFound = true
+ ) ?: return@withContext null
+ val document = DesktopCloudJson.parseToJsonElement(response).jsonObject
+ DesktopFirestoreDocument(
+ id = document.string("name")?.substringAfterLast('/').orEmpty(),
+ fields = document["fields"]?.jsonObjectOrNull()
+ )
+ }
+
+ private suspend fun writeFirestoreDocument(
+ userId: String,
+ collection: String,
+ documentId: String,
+ fields: Map,
+ idToken: String
+ ) = withContext(Dispatchers.IO) {
+ val body = JsonObject(mapOf("fields" to JsonObject(fields)))
+ sendFirestore(
+ request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId))
+ .header("Content-Type", "application/json; charset=UTF-8")
+ .method("PATCH", HttpRequest.BodyPublishers.ofString(DesktopCloudJson.encodeToString(JsonElement.serializer(), body)))
+ .build(),
+ idToken = idToken
+ )
+ Unit
+ }
+
+ private suspend fun deleteFirestoreDocument(
+ userId: String,
+ collection: String,
+ documentId: String,
+ idToken: String
+ ) = withContext(Dispatchers.IO) {
+ sendFirestore(
+ request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId))
+ .DELETE()
+ .build(),
+ idToken = idToken,
+ allowNotFound = true
+ )
+ Unit
+ }
+
+ private fun firestoreCollectionUri(userId: String, collection: String): URI {
+ return URI.create("${firestoreBaseUrl()}/users/${pathEncode(userId)}/$collection")
+ }
+
+ private fun firestoreDocumentUri(userId: String, collection: String, documentId: String): URI {
+ return URI.create("${firestoreCollectionUri(userId, collection)}/${pathEncode(documentId)}")
+ }
+
+ private fun firestoreBaseUrl(): String {
+ return "https://firestore.googleapis.com/v1/projects/${pathEncode(config.firebaseProjectId)}/databases/(default)/documents"
+ }
+
+ private fun sendFirestore(
+ request: HttpRequest,
+ idToken: String,
+ allowNotFound: Boolean = false
+ ): String? {
+ val authed = HttpRequest.newBuilder(request.uri())
+ .timeout(Duration.ofSeconds(30))
+ .copyMethodAndBodyFrom(request)
+ .header("Authorization", "Bearer $idToken")
+ .header("Accept", "application/json")
+ .apply {
+ request.headers().map().forEach { (name, values) ->
+ values.forEach { value -> header(name, value) }
+ }
+ }
+ .build()
+ val response = client.send(authed, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
+ if (allowNotFound && response.statusCode() == 404) return null
+ if (response.statusCode() !in 200..299) {
+ throw IllegalStateException("Firestore HTTP ${response.statusCode()}: ${response.body().take(240)}")
+ }
+ return response.body()
+ }
+}
+
+internal class DesktopGoogleDriveRepository(
+ private val client: HttpClient = defaultDesktopCloudHttpClient()
+) {
+ suspend fun getFiles(accessToken: String): List = withContext(Dispatchers.IO) {
+ listFiles(accessToken = accessToken, query = null)
+ }
+
+ suspend fun getFileByName(accessToken: String, fileName: String): DesktopDriveFile? = withContext(Dispatchers.IO) {
+ listFiles(accessToken, "name = '${driveQueryStringValue(fileName)}' and trashed = false").firstOrNull()
+ }
+
+ suspend fun uploadFont(accessToken: String, fileName: String, file: File, extension: String): DesktopDriveFile? =
+ uploadNamedFile(
+ accessToken = accessToken,
+ fileName = fileName,
+ file = file,
+ contentType = when (extension.lowercase()) {
+ "ttf" -> "font/ttf"
+ "otf" -> "font/otf"
+ "woff2" -> "font/woff2"
+ else -> "application/octet-stream"
+ }
+ )
+
+ suspend fun uploadFile(accessToken: String, bookId: String, file: File, type: FileType): DesktopDriveFile? {
+ val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null
+ val mimeType = SharedFileCapabilities.mimeTypeFor(type) ?: return null
+ return uploadNamedFile(
+ accessToken = accessToken,
+ fileName = "$bookId.$extension",
+ file = file,
+ contentType = mimeType
+ )
+ }
+
+ suspend fun uploadAnnotationFile(accessToken: String, bookId: String, file: File): DesktopDriveFile? {
+ return uploadNamedFile(
+ accessToken = accessToken,
+ fileName = desktopCloudAnnotationDriveFileName(bookId),
+ file = file,
+ contentType = "application/json"
+ )
+ }
+
+ suspend fun downloadAnnotationFile(accessToken: String, bookId: String, destination: File): Boolean {
+ val driveFile = getFileByName(accessToken, desktopCloudAnnotationDriveFileName(bookId))
+ ?: return false
+ return downloadFile(accessToken, driveFile.id, destination).also { downloaded ->
+ if (downloaded && driveFile.modifiedTimeMillis > 0L) {
+ destination.setLastModified(driveFile.modifiedTimeMillis)
+ }
+ }
+ }
+
+ suspend fun downloadFile(accessToken: String, fileId: String, destination: File): Boolean = withContext(Dispatchers.IO) {
+ destination.parentFile?.mkdirs()
+ val temp = File(destination.parentFile ?: File("."), "${destination.name}.${System.nanoTime()}.tmp")
+ val request = HttpRequest.newBuilder(
+ URI.create("https://www.googleapis.com/drive/v3/files/${pathEncode(fileId)}?alt=media")
+ )
+ .timeout(Duration.ofMinutes(3))
+ .header("Authorization", "Bearer $accessToken")
+ .GET()
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofFile(temp.toPath()))
+ if (response.statusCode() !in 200..299) {
+ temp.delete()
+ return@withContext false
+ }
+ Files.move(temp.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ true
+ }
+
+ suspend fun deleteAllFiles(accessToken: String): Boolean = withContext(Dispatchers.IO) {
+ getFiles(accessToken).forEach { file ->
+ deleteDriveFile(accessToken, file.id)
+ }
+ true
+ }
+
+ suspend fun deleteDriveFile(accessToken: String, fileId: String): Boolean = withContext(Dispatchers.IO) {
+ val request = HttpRequest.newBuilder(
+ URI.create("https://www.googleapis.com/drive/v3/files/${pathEncode(fileId)}")
+ )
+ .timeout(Duration.ofSeconds(30))
+ .header("Authorization", "Bearer $accessToken")
+ .DELETE()
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
+ response.statusCode() in 200..299 || response.statusCode() == 404
+ }
+
+ private suspend fun uploadNamedFile(
+ accessToken: String,
+ fileName: String,
+ file: File,
+ contentType: String
+ ): DesktopDriveFile? = withContext(Dispatchers.IO) {
+ if (!file.isFile) return@withContext null
+ val existingFiles = listFiles(accessToken, "name = '${driveQueryStringValue(fileName)}' and trashed = false")
+ existingFiles.drop(1).forEach { duplicate -> deleteDriveFile(accessToken, duplicate.id) }
+ val existingFileId = existingFiles.firstOrNull()?.id
+ val boundary = "episteme_${UUID.randomUUID().toString().replace("-", "")}"
+ val metadata = buildJsonObject {
+ put("name", JsonPrimitive(fileName))
+ if (existingFileId == null) {
+ put("parents", JsonArray(listOf(JsonPrimitive("appDataFolder"))))
+ }
+ }
+ val prefix = buildString {
+ append("--")
+ append(boundary)
+ append("\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n")
+ append(DesktopCloudJson.encodeToString(JsonElement.serializer(), metadata))
+ append("\r\n--")
+ append(boundary)
+ append("\r\nContent-Type: ")
+ append(contentType)
+ append("\r\n\r\n")
+ }.toByteArray(Charsets.UTF_8)
+ val suffix = "\r\n--$boundary--\r\n".toByteArray(Charsets.UTF_8)
+ val uploadUri = if (existingFileId == null) {
+ URI.create("https://www.googleapis.com/upload/drive/v3/files?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}")
+ } else {
+ URI.create("https://www.googleapis.com/upload/drive/v3/files/${pathEncode(existingFileId)}?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}")
+ }
+ val request = HttpRequest.newBuilder(uploadUri)
+ .timeout(Duration.ofMinutes(5))
+ .header("Authorization", "Bearer $accessToken")
+ .header("Content-Type", "multipart/related; boundary=$boundary")
+ .method(
+ if (existingFileId == null) "POST" else "PATCH",
+ HttpRequest.BodyPublishers.ofInputStream {
+ sequenceInputStream(
+ ByteArrayInputStream(prefix),
+ file.inputStream(),
+ ByteArrayInputStream(suffix)
+ )
+ }
+ )
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
+ if (response.statusCode() !in 200..299) return@withContext null
+ val root = DesktopCloudJson.parseToJsonElement(response.body()).jsonObject
+ DesktopDriveFile(
+ id = root.string("id").orEmpty(),
+ name = root.string("name").orEmpty(),
+ modifiedTimeMillis = parseDriveModifiedTimeMillis(root.string("modifiedTime"))
+ )
+ }
+
+ private fun listFiles(accessToken: String, query: String?): List {
+ val params = buildList {
+ add("spaces" to "appDataFolder")
+ add("fields" to "files(id,name,modifiedTime)")
+ if (!query.isNullOrBlank()) add("q" to query)
+ }
+ val request = HttpRequest.newBuilder(
+ URI.create("https://www.googleapis.com/drive/v3/files?${query(params)}")
+ )
+ .timeout(Duration.ofSeconds(30))
+ .header("Authorization", "Bearer $accessToken")
+ .header("Accept", "application/json")
+ .GET()
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
+ if (response.statusCode() !in 200..299) {
+ throw IllegalStateException("Google Drive HTTP ${response.statusCode()}: ${response.body().take(240)}")
+ }
+ val root = DesktopCloudJson.parseToJsonElement(response.body()).jsonObject
+ return root["files"]?.jsonArrayOrNull().orEmpty().mapNotNull { element ->
+ val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
+ val id = obj.string("id") ?: return@mapNotNull null
+ val name = obj.string("name") ?: return@mapNotNull null
+ DesktopDriveFile(
+ id = id,
+ name = name,
+ modifiedTimeMillis = parseDriveModifiedTimeMillis(obj.string("modifiedTime"))
+ )
+ }
+ }
+}
+
+private fun parseDriveModifiedTimeMillis(value: String?): Long {
+ if (value.isNullOrBlank()) return 0L
+ return runCatching { Instant.parse(value).toEpochMilli() }.getOrDefault(0L)
+}
+
+private data class DesktopFirestoreDocument(
+ val id: String,
+ val fields: JsonObject?
+)
+
+private val DesktopCloudJson = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ prettyPrint = true
+}
+
+private fun defaultDesktopCloudHttpClient(): HttpClient {
+ return HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(20))
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build()
+}
+
+private fun DesktopCloudBookMetadata.toFirestoreFields(): Map = mapOf(
+ "bookId" to firestoreString(bookId),
+ "title" to firestoreNullableString(title),
+ "author" to firestoreNullableString(author),
+ "displayName" to firestoreString(displayName),
+ "type" to firestoreString(type),
+ "lastPositionCfi" to firestoreNullableString(lastPositionCfi),
+ "lastChapterIndex" to firestoreNullableInt(lastChapterIndex),
+ "locatorBlockIndex" to firestoreNullableInt(locatorBlockIndex),
+ "locatorCharOffset" to firestoreNullableInt(locatorCharOffset),
+ "lastPage" to firestoreNullableInt(lastPage),
+ "progressPercentage" to firestoreNullableFloat(progressPercentage),
+ "isRecent" to firestoreBoolean(isRecent),
+ "isDeleted" to firestoreBoolean(isDeleted),
+ "lastModifiedTimestamp" to firestoreLong(lastModifiedTimestamp),
+ "readingPositionModifiedTimestamp" to firestoreLong(readingPositionModifiedTimestamp),
+ "annotationModifiedTimestamp" to firestoreLong(annotationModifiedTimestamp),
+ "bookmarksJson" to firestoreNullableString(bookmarksJson),
+ "hasAnnotations" to firestoreBoolean(hasAnnotations),
+ "fileContentModifiedTimestamp" to firestoreLong(fileContentModifiedTimestamp),
+ "customName" to firestoreNullableString(customName),
+ "highlightsJson" to firestoreNullableString(highlightsJson),
+ "seriesName" to firestoreNullableString(seriesName),
+ "seriesIndex" to firestoreNullableDouble(seriesIndex),
+ "description" to firestoreNullableString(description),
+ "originalTitle" to firestoreNullableString(originalTitle),
+ "originalAuthor" to firestoreNullableString(originalAuthor),
+ "originalSeriesName" to firestoreNullableString(originalSeriesName),
+ "originalSeriesIndex" to firestoreNullableDouble(originalSeriesIndex),
+ "originalDescription" to firestoreNullableString(originalDescription)
+)
+
+private fun DesktopCloudShelfMetadata.toFirestoreFields(): Map = mapOf(
+ "name" to firestoreString(name),
+ "bookIds" to firestoreStringArray(bookIds),
+ "lastModifiedTimestamp" to firestoreLong(lastModifiedTimestamp),
+ "isDeleted" to firestoreBoolean(isDeleted)
+)
+
+private fun DesktopCloudFontMetadata.toFirestoreFields(): Map = mapOf(
+ "id" to firestoreString(id),
+ "displayName" to firestoreString(displayName),
+ "fileName" to firestoreString(fileName),
+ "fileExtension" to firestoreString(fileExtension),
+ "timestamp" to firestoreLong(timestamp),
+ "isDeleted" to firestoreBoolean(isDeleted)
+)
+
+private fun JsonObject.toBookMetadata(documentId: String): DesktopCloudBookMetadata? {
+ val bookId = stringField("bookId") ?: documentId.takeIf { it.isNotBlank() } ?: return null
+ return DesktopCloudBookMetadata(
+ bookId = bookId,
+ title = stringField("title"),
+ author = stringField("author"),
+ displayName = stringField("displayName").orEmpty(),
+ type = stringField("type").orEmpty(),
+ lastPositionCfi = stringField("lastPositionCfi"),
+ lastChapterIndex = intField("lastChapterIndex"),
+ locatorBlockIndex = intField("locatorBlockIndex"),
+ locatorCharOffset = intField("locatorCharOffset"),
+ lastPage = intField("lastPage"),
+ progressPercentage = doubleField("progressPercentage")?.toFloat(),
+ isRecent = booleanField("isRecent") ?: true,
+ isDeleted = booleanField("isDeleted") ?: false,
+ lastModifiedTimestamp = longField("lastModifiedTimestamp"),
+ readingPositionModifiedTimestamp = longField("readingPositionModifiedTimestamp"),
+ annotationModifiedTimestamp = longField("annotationModifiedTimestamp"),
+ bookmarksJson = stringField("bookmarksJson"),
+ hasAnnotations = booleanField("hasAnnotations") ?: false,
+ fileContentModifiedTimestamp = longField("fileContentModifiedTimestamp"),
+ customName = stringField("customName"),
+ highlightsJson = stringField("highlightsJson"),
+ seriesName = stringField("seriesName"),
+ seriesIndex = doubleField("seriesIndex"),
+ description = stringField("description"),
+ originalTitle = stringField("originalTitle"),
+ originalAuthor = stringField("originalAuthor"),
+ originalSeriesName = stringField("originalSeriesName"),
+ originalSeriesIndex = doubleField("originalSeriesIndex"),
+ originalDescription = stringField("originalDescription")
+ )
+}
+
+private fun JsonObject.toShelfMetadata(documentId: String): DesktopCloudShelfMetadata? {
+ val name = stringField("name") ?: documentId.takeIf { it.isNotBlank() } ?: return null
+ return DesktopCloudShelfMetadata(
+ name = name,
+ bookIds = stringArrayField("bookIds"),
+ lastModifiedTimestamp = longField("lastModifiedTimestamp"),
+ isDeleted = booleanField("isDeleted") ?: false
+ )
+}
+
+private fun JsonObject.toFontMetadata(documentId: String): DesktopCloudFontMetadata? {
+ val id = stringField("id") ?: documentId.takeIf { it.isNotBlank() } ?: return null
+ return DesktopCloudFontMetadata(
+ id = id,
+ displayName = stringField("displayName").orEmpty(),
+ fileName = stringField("fileName").orEmpty(),
+ fileExtension = stringField("fileExtension").orEmpty(),
+ timestamp = longField("timestamp"),
+ isDeleted = booleanField("isDeleted") ?: false
+ )
+}
+
+private fun firestoreString(value: String): JsonElement = JsonObject(mapOf("stringValue" to JsonPrimitive(value)))
+
+private fun firestoreNullableString(value: String?): JsonElement {
+ return value?.let(::firestoreString) ?: firestoreNull()
+}
+
+private fun firestoreNullableInt(value: Int?): JsonElement {
+ return value?.let { JsonObject(mapOf("integerValue" to JsonPrimitive(it.toString()))) } ?: firestoreNull()
+}
+
+private fun firestoreLong(value: Long): JsonElement = JsonObject(mapOf("integerValue" to JsonPrimitive(value.toString())))
+
+private fun firestoreNullableFloat(value: Float?): JsonElement {
+ return value?.let { JsonObject(mapOf("doubleValue" to JsonPrimitive(it.toDouble()))) } ?: firestoreNull()
+}
+
+private fun firestoreNullableDouble(value: Double?): JsonElement {
+ return value?.let { JsonObject(mapOf("doubleValue" to JsonPrimitive(it))) } ?: firestoreNull()
+}
+
+private fun firestoreBoolean(value: Boolean): JsonElement = JsonObject(mapOf("booleanValue" to JsonPrimitive(value)))
+
+private fun firestoreStringArray(values: List): JsonElement {
+ return JsonObject(
+ mapOf(
+ "arrayValue" to JsonObject(
+ mapOf("values" to JsonArray(values.map(::firestoreString)))
+ )
+ )
+ )
+}
+
+private fun firestoreNull(): JsonElement = JsonObject(mapOf("nullValue" to JsonPrimitive("NULL_VALUE")))
+
+private fun JsonObject.stringField(key: String): String? {
+ return field(key)?.get("stringValue")?.jsonPrimitive?.contentOrNull
+}
+
+private fun JsonObject.booleanField(key: String): Boolean? {
+ return field(key)?.get("booleanValue")?.jsonPrimitive?.booleanOrNull
+}
+
+private fun JsonObject.longField(key: String): Long {
+ val field = field(key) ?: return 0L
+ return field["integerValue"]?.jsonPrimitive?.longOrNull
+ ?: field["doubleValue"]?.jsonPrimitive?.doubleOrNull?.toLong()
+ ?: 0L
+}
+
+private fun JsonObject.intField(key: String): Int? {
+ val field = field(key) ?: return null
+ return field["integerValue"]?.jsonPrimitive?.intOrNull
+ ?: field["doubleValue"]?.jsonPrimitive?.doubleOrNull?.toInt()
+}
+
+private fun JsonObject.doubleField(key: String): Double? {
+ val field = field(key) ?: return null
+ return field["doubleValue"]?.jsonPrimitive?.doubleOrNull
+ ?: field["integerValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
+}
+
+private fun JsonObject.stringArrayField(key: String): List {
+ val values = field(key)
+ ?.get("arrayValue")
+ ?.jsonObjectOrNull()
+ ?.get("values")
+ ?.jsonArrayOrNull()
+ .orEmpty()
+ return values.mapNotNull { it.jsonObjectOrNull()?.get("stringValue")?.jsonPrimitive?.contentOrNull }
+}
+
+private fun JsonObject.field(key: String): JsonObject? = this[key]?.jsonObjectOrNull()
+
+private fun JsonElement?.jsonObjectOrNull(): JsonObject? {
+ if (this == null || this is JsonNull) return null
+ return runCatching { jsonObject }.getOrNull()
+}
+
+private fun JsonElement?.jsonArrayOrNull(): JsonArray? {
+ if (this == null || this is JsonNull) return null
+ return runCatching { jsonArray }.getOrNull()
+}
+
+private fun JsonObject.string(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull
+
+private fun query(vararg pairs: Pair): String = query(pairs.asIterable())
+
+private fun query(pairs: Iterable>): String {
+ return pairs.joinToString("&") { (key, value) -> "${formEncode(key)}=${formEncode(value)}" }
+}
+
+private fun formEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
+
+private fun pathEncode(value: String): String = formEncode(value).replace("+", "%20")
+
+private fun driveQueryStringValue(value: String): String {
+ return value.replace("\\", "\\\\").replace("'", "\\'")
+}
+
+private fun sequenceInputStream(vararg streams: InputStream): SequenceInputStream {
+ return SequenceInputStream(Collections.enumeration(streams.toList()))
+}
+
+private fun HttpRequest.Builder.copyMethodAndBodyFrom(source: HttpRequest): HttpRequest.Builder {
+ val publisher = source.bodyPublisher().orElse(HttpRequest.BodyPublishers.noBody())
+ return method(source.method(), publisher)
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSidecarSync.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSidecarSync.kt
new file mode 100644
index 0000000..31f86bb
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSidecarSync.kt
@@ -0,0 +1,311 @@
+package org.dueattendant149.bookreader.desktop
+
+import org.dueattendant149.bookreader.shared.BookItem
+import org.dueattendant149.bookreader.shared.FileType
+import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer
+import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec
+import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer
+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 java.io.File
+
+internal object DesktopCloudSidecarSync {
+ fun localAnnotationDebugSummary(book: BookItem): String {
+ val path = book.path?.takeIf { it.isNotBlank() } ?: return "path=null type=${book.type}"
+ if (book.type != FileType.PDF) return "path=${path.logPreview(140)} type=${book.type}"
+ val annotationFile = desktopPdfAnnotationFile(path)
+ val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path)
+ val bookmarkFile = desktopPdfBookmarkFile(path)
+ val richTextFile = desktopPdfRichTextFile(path)
+ return "path=${path.logPreview(140)} " +
+ "annotations{exists=${annotationFile.isFile} syncable=${annotationFile.hasSyncablePdfAnnotations()} " +
+ "bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFile()}} " +
+ "deletedAnnotations{exists=${deletedAnnotationFile.isFile} count=${deletedAnnotationFile.annotationDeletionCount()} " +
+ "bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModifiedIfFile()}} " +
+ "bookmarks{exists=${bookmarkFile.isFile} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModifiedIfFile()}} " +
+ "text{exists=${richTextFile.isFile} syncable=${richTextFile.hasSyncablePdfRichText()} " +
+ "bytes=${richTextFile.length()} ts=${richTextFile.lastModifiedIfFile()}} " +
+ "payloadTs=${localAnnotationPayloadTimestamp(book)} totalTs=${localAnnotationTimestamp(book)}"
+ }
+
+ fun hasLocalAnnotationData(book: BookItem): Boolean {
+ val path = book.path?.takeIf { it.isNotBlank() } ?: return false
+ if (book.type != FileType.PDF) return false
+ return desktopPdfAnnotationFile(path).hasSyncablePdfAnnotations() ||
+ desktopPdfAnnotationDeletionFile(path).hasSyncablePdfAnnotationDeletions() ||
+ desktopPdfBookmarkFile(path).isFile ||
+ desktopPdfRichTextFile(path).hasSyncablePdfRichText()
+ }
+
+ fun localAnnotationTimestamp(book: BookItem): Long {
+ val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L
+ if (book.type != FileType.PDF) return 0L
+ return maxOf(
+ localAnnotationPayloadTimestamp(path),
+ desktopPdfBookmarkFile(path).lastModifiedIfFile()
+ )
+ }
+
+ fun localAnnotationPayloadTimestamp(book: BookItem): Long {
+ val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L
+ if (book.type != FileType.PDF) return 0L
+ return localAnnotationPayloadTimestamp(path)
+ }
+
+ fun markAnnotationPayloadSynced(book: BookItem, timestamp: Long) {
+ val path = book.path?.takeIf { it.isNotBlank() } ?: return
+ if (book.type != FileType.PDF || timestamp <= 0L) return
+ listOf(desktopPdfAnnotationFile(path), desktopPdfAnnotationDeletionFile(path), desktopPdfRichTextFile(path))
+ .filter { it.isFile }
+ .forEach { it.setLastModified(timestamp) }
+ }
+
+ fun recordAnnotationDeletions(book: BookItem, annotationIds: Collection) {
+ val path = book.path?.takeIf { it.isNotBlank() } ?: return
+ if (book.type != FileType.PDF) return
+ recordAnnotationDeletions(path, book.id, annotationIds)
+ }
+
+ fun recordAnnotationDeletions(documentPath: String, logBookId: String, annotationIds: Collection) {
+ val ids = annotationIds.mapNotNull { it.takeIf(String::isNotBlank) }.toSet()
+ if (ids.isEmpty()) return
+ val file = desktopPdfAnnotationDeletionFile(documentPath)
+ val existing = if (file.isFile) {
+ SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText())
+ } else {
+ emptyMap()
+ }
+ val now = System.currentTimeMillis()
+ val next = existing.toMutableMap()
+ ids.forEach { id -> next[id] = maxOf(next[id] ?: 0L, now) }
+ val nextJson = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(next)
+ if (file.isFile && file.readText() == nextJson) return
+ file.parentFile?.mkdirs()
+ file.writeText(nextJson)
+ logDesktopCloudAnnotations {
+ "desktop.local.mark_deleted_annotations book=$logBookId ids=${ids.sorted()} " +
+ "bytes=${file.length()} ts=${file.lastModified()}"
+ }
+ }
+
+ fun exportAnnotationBundle(book: BookItem): File? {
+ val path = book.path?.takeIf { it.isNotBlank() } ?: return null
+ if (book.type != FileType.PDF) return null
+ val annotationFile = desktopPdfAnnotationFile(path)
+ val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path)
+ val richTextFile = desktopPdfRichTextFile(path)
+ logDesktopCloudAnnotations {
+ "desktop.export.inspect book=${book.id} ${localAnnotationDebugSummary(book)}"
+ }
+ val data = buildMap {
+ if (annotationFile.isFile) {
+ desktopPdfAnnotationElementForSync(annotationFile.readText())?.let { annotations ->
+ put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations)
+ }
+ }
+ if (deletedAnnotationFile.hasSyncablePdfAnnotationDeletions()) {
+ val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(deletedAnnotationFile.readText())
+ put(
+ SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS,
+ SharedPdfAnnotationSidecarCodec.encodeAnnotationDeletionsElement(deletions)
+ )
+ }
+ if (richTextFile.isFile) {
+ desktopPdfRichTextElementForSync(richTextFile.readText())?.let { put("text", it) }
+ }
+ }
+ if (data.isEmpty()) {
+ logDesktopCloudAnnotations { "desktop.export.skip book=${book.id} reason=no_syncable_payload" }
+ return null
+ }
+ val payload = JsonObject(mapOf("version" to JsonPrimitive(2)) + data)
+ val canonical = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(
+ cloudSidecarJson.encodeToString(JsonElement.serializer(), payload)
+ )
+ val tempFile = File(
+ desktopUserCacheRoot(),
+ "sync_bundle_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json"
+ )
+ tempFile.parentFile?.mkdirs()
+ tempFile.writeText(canonical)
+ logDesktopCloudAnnotations {
+ "desktop.export.bundle_ready book=${book.id} keys=${data.keys.toList()} " +
+ "canonicalBytes=${canonical.length} fileBytes=${tempFile.length()} temp=${tempFile.name}"
+ }
+ return tempFile
+ }
+
+ fun importAnnotationBundle(book: BookItem, rawJson: String, timestamp: Long): Boolean {
+ val path = book.path?.takeIf { it.isNotBlank() } ?: run {
+ logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=missing_path bytes=${rawJson.length}" }
+ return false
+ }
+ if (book.type != FileType.PDF) {
+ logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=not_pdf type=${book.type} bytes=${rawJson.length}" }
+ return false
+ }
+ val root = cloudSidecarJson.parseElementOrNull(rawJson)?.jsonObjectOrNull() ?: run {
+ logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=parse_failed bytes=${rawJson.length}" }
+ return false
+ }
+ val data = root["data"]?.jsonObjectOrNull() ?: root
+ val canonicalData = SharedPdfAnnotationSidecarCodec.withCanonicalAnnotations(data)
+ val annotationFile = desktopPdfAnnotationFile(path)
+ val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path)
+ val bookmarkFile = desktopPdfBookmarkFile(path)
+ val richTextFile = desktopPdfRichTextFile(path)
+ logDesktopCloudAnnotations {
+ "desktop.import.inspect book=${book.id} remoteTs=$timestamp rawBytes=${rawJson.length} " +
+ "rawKeys=${data.keys.toList()} canonicalKeys=${canonicalData.keys.toList()} " +
+ localAnnotationDebugSummary(book)
+ }
+
+ if (canonicalData.hasPdfAnnotationPayload()) {
+ val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(canonicalData)
+ if (annotations.isEmpty()) {
+ if (annotationFile.isFile) {
+ val deleted = annotationFile.delete()
+ logDesktopCloudAnnotations { "desktop.import.delete_annotations book=${book.id} deleted=$deleted" }
+ } else {
+ logDesktopCloudAnnotations { "desktop.import.annotations_empty book=${book.id} existing=false" }
+ }
+ } else {
+ annotationFile.parentFile?.mkdirs()
+ annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
+ annotationFile.setLastModified(timestamp)
+ logDesktopCloudAnnotations {
+ "desktop.import.write_annotations book=${book.id} count=${annotations.size} " +
+ "bytes=${annotationFile.length()} ts=${annotationFile.lastModified()}"
+ }
+ }
+ } else if (annotationFile.isFile) {
+ val deleted = annotationFile.delete()
+ logDesktopCloudAnnotations { "desktop.import.delete_annotations_missing_payload book=${book.id} deleted=$deleted" }
+ } else {
+ logDesktopCloudAnnotations { "desktop.import.no_annotation_payload book=${book.id} existing=false" }
+ }
+
+ val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromData(canonicalData)
+ if (deletions.isEmpty()) {
+ if (deletedAnnotationFile.isFile) {
+ val deleted = deletedAnnotationFile.delete()
+ logDesktopCloudAnnotations { "desktop.import.delete_annotation_tombstones book=${book.id} deleted=$deleted" }
+ }
+ } else {
+ deletedAnnotationFile.parentFile?.mkdirs()
+ deletedAnnotationFile.writeText(SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(deletions))
+ deletedAnnotationFile.setLastModified(timestamp)
+ logDesktopCloudAnnotations {
+ "desktop.import.write_annotation_tombstones book=${book.id} count=${deletions.size} " +
+ "bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModified()}"
+ }
+ }
+
+ canonicalData["bookmarks"]?.let { bookmarks ->
+ bookmarkFile.parentFile?.mkdirs()
+ bookmarkFile.writeText(cloudSidecarJson.encodeToString(JsonElement.serializer(), bookmarks))
+ bookmarkFile.setLastModified(timestamp)
+ logDesktopCloudAnnotations {
+ "desktop.import.write_bookmarks book=${book.id} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModified()}"
+ }
+ }
+
+ canonicalData["text"]?.let { richText ->
+ val richDocument = SharedPdfRichTextSerializer.decodeElement(richText)
+ if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) {
+ if (richTextFile.isFile) {
+ val deleted = richTextFile.delete()
+ logDesktopCloudAnnotations { "desktop.import.delete_text_empty book=${book.id} deleted=$deleted" }
+ } else {
+ logDesktopCloudAnnotations { "desktop.import.text_empty book=${book.id} existing=false" }
+ }
+ } else {
+ richTextFile.parentFile?.mkdirs()
+ richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
+ richTextFile.setLastModified(timestamp)
+ logDesktopCloudAnnotations {
+ "desktop.import.write_text book=${book.id} textChars=${richDocument.text.length} " +
+ "spans=${richDocument.spans.size} bytes=${richTextFile.length()} ts=${richTextFile.lastModified()}"
+ }
+ }
+ } ?: run {
+ if (richTextFile.isFile) {
+ val deleted = richTextFile.delete()
+ logDesktopCloudAnnotations { "desktop.import.delete_text_missing book=${book.id} deleted=$deleted" }
+ } else {
+ logDesktopCloudAnnotations { "desktop.import.no_text_payload book=${book.id} existing=false" }
+ }
+ }
+ logDesktopCloudAnnotations {
+ "desktop.import.done book=${book.id} remoteTs=$timestamp ${localAnnotationDebugSummary(book)}"
+ }
+ return true
+ }
+}
+
+private fun localAnnotationPayloadTimestamp(path: String): Long {
+ return maxOf(
+ desktopPdfAnnotationFile(path).lastModifiedIfSyncableAnnotations(),
+ desktopPdfAnnotationDeletionFile(path).lastModifiedIfSyncableAnnotationDeletions(),
+ desktopPdfRichTextFile(path).lastModifiedIfSyncableRichText()
+ )
+}
+
+private val cloudSidecarJson = Json {
+ ignoreUnknownKeys = true
+ prettyPrint = true
+ encodeDefaults = true
+}
+
+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.lastModifiedIfFile(): Long {
+ return if (isFile) lastModified() else 0L
+}
+
+private fun File.hasSyncablePdfAnnotations(): Boolean {
+ return isFile && desktopPdfAnnotationElementForSync(readText()) != null
+}
+
+private fun File.lastModifiedIfSyncableAnnotations(): Long {
+ return if (hasSyncablePdfAnnotations()) lastModified() else 0L
+}
+
+private fun File.annotationDeletionCount(): Int {
+ return if (isFile) SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(readText()).size else 0
+}
+
+private fun File.hasSyncablePdfAnnotationDeletions(): Boolean {
+ return annotationDeletionCount() > 0
+}
+
+private fun File.lastModifiedIfSyncableAnnotationDeletions(): Long {
+ return if (hasSyncablePdfAnnotationDeletions()) lastModified() else 0L
+}
+
+private fun File.hasSyncablePdfRichText(): Boolean {
+ return isFile && desktopPdfRichTextElementForSync(readText()) != null
+}
+
+private fun File.lastModifiedIfSyncableRichText(): Long {
+ return if (hasSyncablePdfRichText()) lastModified() else 0L
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt
new file mode 100644
index 0000000..382ac97
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt
@@ -0,0 +1,1204 @@
+package org.dueattendant149.bookreader.desktop
+
+import org.dueattendant149.bookreader.shared.BookItem
+import org.dueattendant149.bookreader.shared.BookShelfRef
+import org.dueattendant149.bookreader.shared.CustomFontItem
+import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
+import org.dueattendant149.bookreader.shared.EpubBookmark
+import org.dueattendant149.bookreader.shared.FileType
+import org.dueattendant149.bookreader.shared.ReaderLocator
+import org.dueattendant149.bookreader.shared.SharedCloudBookMetadataWinner
+import org.dueattendant149.bookreader.shared.SharedFileCapabilities
+import org.dueattendant149.bookreader.shared.SharedReaderScreenState
+import org.dueattendant149.bookreader.shared.ShelfRecord
+import org.dueattendant149.bookreader.shared.sharedCloudBookMetadataWinner
+import org.dueattendant149.bookreader.shared.shouldDownloadRemoteCloudBookContent
+import org.dueattendant149.bookreader.shared.shouldUploadLocalCloudBookContent
+import org.dueattendant149.bookreader.shared.sharedCloudBookContentFileName
+import org.dueattendant149.bookreader.shared.toStablePositionCfi
+import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec
+import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport
+import org.dueattendant149.bookreader.shared.reader.ReaderBookmark
+import java.io.File
+
+internal data class DesktopCloudSyncInput(
+ val userId: String,
+ val idToken: String,
+ val driveAccessToken: String,
+ val deviceId: String,
+ val state: SharedReaderScreenState,
+ val shelfRecords: List,
+ val shelfRefs: List,
+ val customFonts: List,
+ val includeFolderBooks: Boolean
+)
+
+internal data class DesktopCloudSyncResult(
+ val state: SharedReaderScreenState,
+ val shelfRecords: List,
+ val shelfRefs: List,
+ val customFonts: List,
+ val uploadedBooks: Int = 0,
+ val downloadedBooks: Int = 0,
+ val pendingContentDownloads: Int = 0
+)
+
+internal class DesktopCloudSync(
+ private val firestoreRepository: DesktopFirestoreRepository,
+ private val driveRepository: DesktopGoogleDriveRepository,
+ private val bookImporter: DesktopBookImporter,
+ private val customFontStore: DesktopCustomFontStore
+) {
+ suspend fun sync(input: DesktopCloudSyncInput): DesktopCloudSyncResult {
+ var state = input.state
+ var shelfRecords = input.shelfRecords
+ var shelfRefs = input.shelfRefs
+ var customFonts = input.customFonts
+ var uploadedBooks = 0
+ var downloadedBooks = 0
+ var pendingContentDownloads = 0
+
+ logDesktopCloudSync {
+ "desktop.engine.full_sync.start user=${input.userId} device=${input.deviceId} " +
+ "localBooks=${input.state.rawLibraryBooks.size} includeFolderBooks=${input.includeFolderBooks}"
+ }
+ val remoteBooks = firestoreRepository.getAllBooks(input.userId, input.idToken)
+ .filterNot { isDesktopPdfReflowBookId(it.bookId) }
+ .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) }
+ val remoteShelves = firestoreRepository.getAllShelves(input.userId, input.idToken)
+ val remoteFonts = firestoreRepository.getAllFonts(input.userId, input.idToken)
+ var driveFiles = driveRepository.getFiles(input.driveAccessToken).associateBy { it.name }
+ logDesktopCloudSync {
+ "desktop.engine.full_sync.loaded user=${input.userId} remoteBooks=${remoteBooks.size} " +
+ "remoteShelves=${remoteShelves.size} remoteFonts=${remoteFonts.size} driveFiles=${driveFiles.size}"
+ }
+
+ val localBooks = state.rawLibraryBooks
+ .filterNot { isDesktopPdfReflowBookId(it.id) }
+ .filter { input.includeFolderBooks || it.sourceFolder == null }
+ .filterNot { it.path?.startsWith("opds-pse") == true }
+ .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) }
+ val localBooksMap = localBooks.associateBy { it.id }
+ val remoteBooksMap = remoteBooks.associateBy { it.bookId }
+ val allBookIds = (localBooksMap.keys + remoteBooksMap.keys).distinct()
+
+ allBookIds.forEach { bookId ->
+ val local = localBooksMap[bookId]
+ val remote = remoteBooksMap[bookId]
+ if (local?.sourceFolder != null) return@forEach
+
+ when {
+ local != null && remote == null -> {
+ logDesktopCloudSync { "desktop.engine.book_decision action=upload_new ${local.desktopCloudSyncSummary()}" }
+ uploadBookAndMetadata(input, local, uploadContent = true)?.let { synced ->
+ state = state.upsertCloudBook(synced)
+ uploadedBooks += 1
+ }
+ }
+
+ local == null && remote != null -> {
+ if (remote.isDeleted) {
+ logDesktopCloudSync { "desktop.engine.book_decision action=skip_deleted_remote_only ${remote.desktopCloudSyncSummary()}" }
+ return@forEach
+ }
+ logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_new ${remote.desktopCloudSyncSummary()}" }
+ val downloaded = downloadRemoteBook(input.driveAccessToken, remote, null, driveFiles)
+ if (downloaded == null) {
+ pendingContentDownloads += 1
+ logDesktopCloudSync {
+ "desktop.engine.book_decision action=defer_remote_new_pending_content " +
+ remote.desktopCloudSyncSummary()
+ }
+ return@forEach
+ }
+ val remoteBook = downloaded
+ state = state.upsertCloudBook(remoteBook)
+ downloadedBooks += 1
+ importDesktopPdfBookmarksMetadata(remoteBook, remote.bookmarksJson, remote.lastModifiedTimestamp)
+ if (remote.hasAnnotations) {
+ val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp(
+ remoteAnnotationDriveFileTimestamp(remote.bookId, driveFiles)
+ )
+ downloadAnnotations(input.driveAccessToken, remoteBook, remoteAnnotationTimestamp)
+ }
+ }
+
+ local != null && remote != null -> {
+ val remoteBook = remote.toDesktopBookItem(existing = local)
+ val shouldDownloadContent = shouldDownloadRemoteBookContent(local, remote)
+ val downloaded = if (shouldDownloadContent) {
+ downloadRemoteBook(input.driveAccessToken, remote, local, driveFiles)
+ } else {
+ null
+ }
+ if (shouldDownloadContent && downloaded == null) {
+ pendingContentDownloads += 1
+ }
+ val localContentAvailable = local.path?.let(::File)?.isFile == true
+ if (shouldDownloadContent && downloaded == null && !localContentAvailable) {
+ logDesktopCloudSync {
+ "desktop.engine.book_decision action=defer_existing_pending_content book=$bookId " +
+ local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary()
+ }
+ state = state.removeCloudBook(bookId)
+ return@forEach
+ }
+ val localSidecarTimestampBeforeMerge = DesktopCloudSidecarSync.localAnnotationTimestamp(local)
+ val metadataWinner = sharedCloudBookMetadataWinner(
+ localModifiedTimestamp = local.timestamp,
+ remoteModifiedTimestamp = remote.lastModifiedTimestamp
+ )
+ val localMetadataWins = metadataWinner == SharedCloudBookMetadataWinner.LOCAL
+ val localReadingTimestamp = local.effectiveCloudReadingPositionModifiedTimestamp()
+ val remoteReadingTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp()
+ val remoteAnnotationDriveTimestamp = remoteAnnotationDriveFileTimestamp(bookId, driveFiles)
+ val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp(remoteAnnotationDriveTimestamp)
+ val localReadingPositionShouldUpload = localReadingTimestamp > remoteReadingTimestamp
+ val localAnnotationsShouldUpload = shouldUploadLocalAnnotations(
+ local = local,
+ remote = remote,
+ remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp,
+ localSidecarTimestamp = localSidecarTimestampBeforeMerge
+ )
+ logDesktopCloudAnnotations {
+ "desktop.sync.inspect book=$bookId remoteHas=${remote.hasAnnotations} " +
+ "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " +
+ "remoteDriveAnnTs=$remoteAnnotationDriveTimestamp localTs=${local.timestamp} " +
+ "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " +
+ "localShouldUpload=$localAnnotationsShouldUpload " +
+ DesktopCloudSidecarSync.localAnnotationDebugSummary(local)
+ }
+ logDesktopCloudSync {
+ "desktop.engine.book_compare book=$bookId winner=$metadataWinner shouldDownloadContent=$shouldDownloadContent " +
+ "downloadedContent=${downloaded != null} sidecarTs=$localSidecarTimestampBeforeMerge " +
+ "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " +
+ local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary()
+ }
+
+ if (remote.isDeleted) {
+ if (localMetadataWins) {
+ logDesktopCloudSync { "desktop.engine.book_decision action=resurrect_upload_local book=$bookId" }
+ uploadBookAndMetadata(
+ input = input,
+ book = local,
+ uploadContent = shouldUploadLocalBookContent(local, null),
+ uploadAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(local)
+ )?.let { synced ->
+ state = state.upsertCloudBook(synced)
+ uploadedBooks += 1
+ }
+ } else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE) {
+ logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_delete book=$bookId" }
+ state = state.removeCloudBook(bookId)
+ } else {
+ logDesktopCloudSync { "desktop.engine.book_decision action=skip_equal_delete book=$bookId" }
+ }
+ return@forEach
+ }
+
+ if (localMetadataWins) {
+ logDesktopCloudSync {
+ "desktop.engine.book_decision action=upload_local book=$bookId " +
+ "uploadContent=${shouldUploadLocalBookContent(local, remote)} " +
+ "uploadAnnotations=$localAnnotationsShouldUpload " +
+ "preserveRemoteReading=${remoteReadingTimestamp > localReadingTimestamp}"
+ }
+ val localForMetadata = if (remoteReadingTimestamp > localReadingTimestamp) {
+ local.withCloudReadingPosition(remote)
+ } else {
+ local
+ }
+ val bookForMetadata = localForMetadata.withDownloadedCloudContent(downloaded, replacePath = false)
+ uploadBookAndMetadata(
+ input = input,
+ book = bookForMetadata,
+ uploadContent = shouldUploadLocalBookContent(local, remote),
+ uploadAnnotations = localAnnotationsShouldUpload,
+ remoteHasAnnotations = remote.hasAnnotations,
+ remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp,
+ remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp
+ )?.let { synced ->
+ state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded))
+ uploadedBooks += 1
+ }
+ } else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE || downloaded != null) {
+ logDesktopCloudSync {
+ "desktop.engine.book_decision action=apply_remote book=$bookId " +
+ "metadataWinner=$metadataWinner downloadedContent=${downloaded != null}"
+ }
+ val mergedBook = downloaded ?: remoteBook
+ state = state.upsertCloudBook(mergedBook)
+ importDesktopPdfBookmarksMetadata(mergedBook, remote.bookmarksJson, remote.lastModifiedTimestamp)
+ }
+
+ if (!localMetadataWins && (localAnnotationsShouldUpload || localReadingPositionShouldUpload)) {
+ val metadataBook = state.rawLibraryBooks.firstOrNull { it.id == bookId }
+ ?: remoteBook
+ logDesktopCloudAnnotations {
+ "desktop.sync.upload_local_supplement book=$bookId winner=$metadataWinner " +
+ "remoteHas=${remote.hasAnnotations} remoteTs=${remote.lastModifiedTimestamp} " +
+ "remoteAnnTs=$remoteAnnotationTimestamp " +
+ "localSidecarTs=$localSidecarTimestampBeforeMerge " +
+ "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " +
+ "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp"
+ }
+ logDesktopCloudSync {
+ "desktop.engine.book_decision action=upload_local_supplement book=$bookId " +
+ "metadataWinner=$metadataWinner sidecarTs=$localSidecarTimestampBeforeMerge " +
+ "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " +
+ metadataBook.desktopCloudSyncSummary()
+ }
+ uploadBookAndMetadata(
+ input = input,
+ book = metadataBook,
+ uploadContent = false,
+ uploadAnnotations = localAnnotationsShouldUpload,
+ remoteHasAnnotations = remote.hasAnnotations,
+ remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp,
+ remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp
+ )?.let { synced ->
+ state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded))
+ uploadedBooks += 1
+ }
+ }
+
+ val localSidecarTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(downloaded ?: local)
+ val needsAnnotationDownload = !localMetadataWins &&
+ !localAnnotationsShouldUpload &&
+ remote.hasAnnotations &&
+ (remoteAnnotationTimestamp > localSidecarTimestamp || localSidecarTimestamp == 0L)
+ if (needsAnnotationDownload) {
+ logDesktopCloudAnnotations {
+ "desktop.sync.download_remote_annotations book=$bookId remoteTs=${remote.lastModifiedTimestamp} " +
+ "remoteAnnTs=$remoteAnnotationTimestamp " +
+ "localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " +
+ "localShouldUpload=$localAnnotationsShouldUpload"
+ }
+ logDesktopCloudSync {
+ "desktop.engine.sidecar_download_start book=$bookId remoteTs=${remote.lastModifiedTimestamp} " +
+ "remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins"
+ }
+ val targetBook = downloaded ?: state.rawLibraryBooks.firstOrNull { it.id == bookId } ?: local
+ downloadAnnotations(input.driveAccessToken, targetBook, remoteAnnotationTimestamp)
+ } else {
+ logDesktopCloudAnnotations {
+ "desktop.sync.skip_remote_annotations book=$bookId remoteHas=${remote.hasAnnotations} " +
+ "remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " +
+ "localShouldUpload=$localAnnotationsShouldUpload"
+ }
+ logDesktopCloudSync {
+ "desktop.engine.sidecar_download_skip book=$bookId remoteHasAnnotations=${remote.hasAnnotations} " +
+ "localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins"
+ }
+ }
+ }
+ }
+ }
+
+ driveFiles = driveRepository.getFiles(input.driveAccessToken).associateBy { it.name }
+ state.rawLibraryBooks
+ .filterNot { isDesktopPdfReflowBookId(it.id) }
+ .filter { it.sourceFolder == null }
+ .filterNot { it.path?.startsWith("opds-pse") == true }
+ .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) }
+ .forEach { book ->
+ val driveName = desktopCloudBookDriveFileName(book.id, book.type) ?: return@forEach
+ val localFile = book.path?.let(::File)
+ when {
+ localFile?.isFile == true && driveFiles[driveName] == null -> {
+ val remote = remoteBooksMap[book.id]
+ if (remote == null || shouldUploadLocalBookContent(book, remote)) {
+ logDesktopCloudSync { "desktop.engine.content_upload_missing_remote book=${book.id} driveName=$driveName" }
+ uploadBookAndMetadata(
+ input = input,
+ book = book,
+ uploadContent = true,
+ uploadAnnotations = false,
+ remoteHasAnnotations = remote?.hasAnnotations == true,
+ remoteAnnotationModifiedTimestamp = remote?.effectiveCloudAnnotationModifiedTimestamp(
+ remoteAnnotationDriveFileTimestamp(book.id, driveFiles)
+ ) ?: 0L,
+ remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp
+ )?.let { synced ->
+ state = state.upsertCloudBook(synced)
+ uploadedBooks += 1
+ }
+ } else {
+ pendingContentDownloads += 1
+ logDesktopCloudSync {
+ "desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName " +
+ "localContentTs=${book.fileContentModifiedTimestamp} remoteContentTs=${remote.fileContentModifiedTimestamp}"
+ }
+ }
+ }
+
+ (localFile == null || !localFile.isFile) && driveFiles[driveName] != null -> {
+ val remote = remoteBooksMap[book.id] ?: return@forEach
+ logDesktopCloudSync { "desktop.engine.content_download_missing_local book=${book.id} driveName=$driveName" }
+ val downloaded = downloadRemoteBook(input.driveAccessToken, remote, book, driveFiles)
+ if (downloaded != null) {
+ state = state.upsertCloudBook(downloaded)
+ downloadedBooks += 1
+ } else {
+ pendingContentDownloads += 1
+ }
+ }
+
+ (localFile == null || !localFile.isFile) && driveFiles[driveName] == null -> {
+ pendingContentDownloads += 1
+ logDesktopCloudSync { "desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName" }
+ state = state.removeCloudBook(book.id)
+ }
+ }
+ }
+
+ val shelfSync = syncShelves(
+ userId = input.userId,
+ idToken = input.idToken,
+ deviceId = input.deviceId,
+ shelfRecords = shelfRecords,
+ shelfRefs = shelfRefs,
+ syncableBookIds = state.rawLibraryBooks
+ .filterNot { isDesktopPdfReflowBookId(it.id) }
+ .mapTo(mutableSetOf()) { it.id },
+ remoteShelves = remoteShelves
+ )
+ shelfRecords = shelfSync.records
+ shelfRefs = shelfSync.refs
+
+ customFonts = syncFonts(
+ userId = input.userId,
+ idToken = input.idToken,
+ accessToken = input.driveAccessToken,
+ localFonts = customFonts,
+ remoteFonts = remoteFonts
+ )
+
+ logDesktopCloudSync {
+ "desktop.engine.full_sync.complete user=${input.userId} uploaded=$uploadedBooks downloaded=$downloadedBooks " +
+ "pendingContent=$pendingContentDownloads books=${state.rawLibraryBooks.size}"
+ }
+ return DesktopCloudSyncResult(
+ state = state,
+ shelfRecords = shelfRecords,
+ shelfRefs = shelfRefs,
+ customFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() },
+ uploadedBooks = uploadedBooks,
+ downloadedBooks = downloadedBooks,
+ pendingContentDownloads = pendingContentDownloads
+ )
+ }
+
+ suspend fun uploadBookAndMetadata(
+ input: DesktopCloudSyncInput,
+ book: BookItem,
+ uploadContent: Boolean,
+ uploadAnnotations: Boolean = true,
+ remoteHasAnnotations: Boolean = false,
+ remoteAnnotationModifiedTimestamp: Long = 0L,
+ remoteContentModifiedTimestamp: Long? = null
+ ): BookItem? {
+ if (isDesktopPdfReflowBookId(book.id)) {
+ logDesktopCloudSync { "desktop.upload.skip reason=reflow ${book.desktopCloudSyncSummary()}" }
+ return null
+ }
+ if (book.sourceFolder != null) {
+ logDesktopCloudSync { "desktop.upload.skip reason=folder_book ${book.desktopCloudSyncSummary()}" }
+ return null
+ }
+ if (book.path?.startsWith("opds-pse") == true) {
+ logDesktopCloudSync { "desktop.upload.skip reason=opds_stream ${book.desktopCloudSyncSummary()}" }
+ return null
+ }
+ if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) {
+ logDesktopCloudSync { "desktop.upload.skip reason=manual_only ${book.desktopCloudSyncSummary()}" }
+ return null
+ }
+ logDesktopCloudSync {
+ "desktop.upload.start uploadContent=$uploadContent uploadAnnotations=$uploadAnnotations " +
+ "remoteHasAnnotations=$remoteHasAnnotations ${book.desktopCloudSyncSummary()}"
+ }
+ if (uploadContent) {
+ val source = book.path?.let(::File)?.takeIf { it.isFile }
+ if (source != null && driveRepository.uploadFile(input.driveAccessToken, book.id, source, book.type) == null) {
+ logDesktopCloudSync { "desktop.upload.content_failed book=${book.id} path=${source.absolutePath}" }
+ return null
+ }
+ logDesktopCloudSync { "desktop.upload.content_success book=${book.id} path=${source?.absolutePath ?: "none"}" }
+ }
+
+ val hasLocalAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(book)
+ val shouldUploadAnnotations = uploadAnnotations || (!remoteHasAnnotations && hasLocalAnnotations)
+ val bundle = if (shouldUploadAnnotations) DesktopCloudSidecarSync.exportAnnotationBundle(book) else null
+ var uploadedAnnotationTimestamp = 0L
+ logDesktopCloudAnnotations {
+ "desktop.upload.annotation_decision book=${book.id} uploadAnnotations=$uploadAnnotations " +
+ "remoteHas=$remoteHasAnnotations hasLocal=$hasLocalAnnotations shouldUpload=$shouldUploadAnnotations " +
+ "bundleBytes=${bundle?.length() ?: 0L} " + DesktopCloudSidecarSync.localAnnotationDebugSummary(book)
+ }
+ try {
+ if (bundle != null) {
+ val mergedRemoteIntoUpload = mergeRemoteAnnotationsIntoUploadBundle(
+ accessToken = input.driveAccessToken,
+ book = book,
+ bundle = bundle,
+ remoteHasAnnotations = remoteHasAnnotations
+ )
+ val uploadedAnnotationFile = driveRepository.uploadAnnotationFile(input.driveAccessToken, book.id, bundle)
+ if (uploadedAnnotationFile == null) {
+ logDesktopCloudAnnotations { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" }
+ logDesktopCloudSync { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" }
+ return null
+ }
+ uploadedAnnotationTimestamp = uploadedAnnotationFile.modifiedTimeMillis
+ if (mergedRemoteIntoUpload) {
+ val appliedMergedLocal = DesktopCloudSidecarSync.importAnnotationBundle(
+ book = book,
+ rawJson = bundle.readText(),
+ timestamp = uploadedAnnotationTimestamp
+ )
+ logDesktopCloudAnnotations {
+ "desktop.upload.local_apply_merged book=${book.id} applied=$appliedMergedLocal " +
+ "driveTs=$uploadedAnnotationTimestamp bytes=${bundle.length()}"
+ }
+ }
+ DesktopCloudSidecarSync.markAnnotationPayloadSynced(book, uploadedAnnotationTimestamp)
+ }
+ if (bundle != null) {
+ logDesktopCloudAnnotations {
+ "desktop.upload.sidecar_success book=${book.id} bytes=${bundle.length()} driveTs=$uploadedAnnotationTimestamp"
+ }
+ } else {
+ logDesktopCloudAnnotations {
+ "desktop.upload.sidecar_skipped book=${book.id} shouldUpload=$shouldUploadAnnotations hasLocal=$hasLocalAnnotations"
+ }
+ }
+ logDesktopCloudSync {
+ "desktop.upload.sidecar_decision book=${book.id} hasLocal=$hasLocalAnnotations " +
+ "shouldUpload=$shouldUploadAnnotations uploaded=${bundle != null} bytes=${bundle?.length() ?: 0L}"
+ }
+ } finally {
+ bundle?.delete()
+ }
+
+ val now = System.currentTimeMillis()
+ val syncedBook = book.copy(
+ timestamp = now,
+ readingPositionModifiedTimestamp = book.effectiveCloudReadingPositionModifiedTimestamp()
+ )
+ val localAnnotationTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(book)
+ val syncedAnnotationTimestamp = if (bundle != null) {
+ uploadedAnnotationTimestamp.takeIf { it > 0L } ?: maxOf(localAnnotationTimestamp, now)
+ } else if (remoteHasAnnotations) {
+ remoteAnnotationModifiedTimestamp
+ } else {
+ 0L
+ }
+ val syncedHasAnnotations = if (uploadAnnotations) {
+ syncedAnnotationTimestamp > 0L || (bundle != null && hasLocalAnnotations)
+ } else {
+ remoteHasAnnotations || syncedAnnotationTimestamp > 0L || bundle != null || hasLocalAnnotations
+ }
+ firestoreRepository.syncBookMetadata(
+ userId = input.userId,
+ book = syncedBook.toDesktopCloudBookMetadata(
+ hasAnnotations = syncedHasAnnotations,
+ timestamp = now,
+ annotationModifiedTimestamp = syncedAnnotationTimestamp,
+ contentTimestampOverride = if (uploadContent) null else remoteContentModifiedTimestamp
+ ),
+ originDeviceId = input.deviceId,
+ idToken = input.idToken
+ )
+ logDesktopCloudSync {
+ "desktop.upload.metadata_success user=${input.userId} device=${input.deviceId} " +
+ "oldTs=${book.timestamp} newTs=$now hasAnnotations=$syncedHasAnnotations " +
+ syncedBook.desktopCloudSyncSummary("synced")
+ }
+ logDesktopCloudAnnotations {
+ "desktop.upload.metadata_success book=${book.id} oldTs=${book.timestamp} newTs=$now " +
+ "readTs=${syncedBook.effectiveCloudReadingPositionModifiedTimestamp()} " +
+ "annTs=$syncedAnnotationTimestamp hasAnnotations=$syncedHasAnnotations"
+ }
+ return syncedBook
+ }
+
+ private suspend fun mergeRemoteAnnotationsIntoUploadBundle(
+ accessToken: String,
+ book: BookItem,
+ bundle: File,
+ remoteHasAnnotations: Boolean
+ ): Boolean {
+ if (!remoteHasAnnotations || !bundle.isFile) return false
+ val remoteTemp = File(desktopUserCacheRoot(), "remote_annotation_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json")
+ try {
+ val didDownload = driveRepository.downloadAnnotationFile(accessToken, book.id, remoteTemp)
+ if (!didDownload || !remoteTemp.isFile) {
+ logDesktopCloudAnnotations {
+ "desktop.upload.merge_remote_missing book=${book.id} didDownload=$didDownload " +
+ "tempExists=${remoteTemp.exists()} localBytes=${bundle.length()}"
+ }
+ return false
+ }
+ val localRaw = bundle.readText()
+ val remoteRaw = remoteTemp.readText()
+ val mergedRaw = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson(
+ localDataJson = localRaw,
+ remoteDataJson = remoteRaw,
+ preferRemoteOnConflict = false
+ )
+ val localCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(localRaw)
+ val remoteCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(remoteRaw)
+ val mergedCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(mergedRaw)
+ if (mergedRaw != localRaw) {
+ bundle.writeText(mergedRaw)
+ logDesktopCloudAnnotations {
+ "desktop.upload.merge_remote_applied book=${book.id} localCount=$localCount " +
+ "remoteCount=$remoteCount mergedCount=$mergedCount mergedBytes=${bundle.length()}"
+ }
+ return true
+ } else {
+ logDesktopCloudAnnotations {
+ "desktop.upload.merge_remote_noop book=${book.id} localCount=$localCount " +
+ "remoteCount=$remoteCount mergedCount=$mergedCount"
+ }
+ }
+ } catch (error: Exception) {
+ logDesktopCloudAnnotations {
+ "desktop.upload.merge_remote_failed book=${book.id} error=${error.message.orEmpty().logPreview(240)}"
+ }
+ } finally {
+ remoteTemp.delete()
+ }
+ return false
+ }
+
+ suspend fun deleteBooksFromCloud(
+ userId: String,
+ idToken: String,
+ accessToken: String,
+ deviceId: String,
+ books: List
+ ) {
+ val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name }
+ books
+ .filterNot { isDesktopPdfReflowBookId(it.id) }
+ .filter { it.sourceFolder == null }
+ .filterNot { it.path?.startsWith("opds-pse") == true }
+ .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) }
+ .forEach { book ->
+ firestoreRepository.syncBookMetadata(
+ userId = userId,
+ book = book.toDesktopCloudBookMetadata(
+ hasAnnotations = false,
+ timestamp = System.currentTimeMillis()
+ ).copy(isDeleted = true),
+ originDeviceId = deviceId,
+ idToken = idToken
+ )
+ desktopCloudBookDriveFileName(book.id, book.type)
+ ?.let { driveFiles[it]?.id }
+ ?.let { driveRepository.deleteDriveFile(accessToken, it) }
+ driveFiles[desktopCloudAnnotationDriveFileName(book.id)]?.id
+ ?.let { driveRepository.deleteDriveFile(accessToken, it) }
+ }
+ }
+
+ suspend fun syncShelfChange(
+ userId: String,
+ idToken: String,
+ deviceId: String,
+ record: ShelfRecord,
+ refs: List,
+ isDeleted: Boolean = false
+ ) {
+ if (record.isSmart) return
+ firestoreRepository.syncShelf(
+ userId = userId,
+ shelf = DesktopCloudShelfMetadata(
+ name = record.name,
+ bookIds = refs.filter { it.shelfId == record.id }.map { it.bookId }.distinct(),
+ lastModifiedTimestamp = System.currentTimeMillis(),
+ isDeleted = isDeleted
+ ),
+ originDeviceId = deviceId,
+ idToken = idToken
+ )
+ }
+
+ suspend fun clearCloudData(userId: String, idToken: String, accessToken: String) {
+ driveRepository.deleteAllFiles(accessToken)
+ firestoreRepository.deleteAllUserFirestoreData(userId, idToken)
+ }
+
+ suspend fun deleteFontFromCloud(
+ userId: String,
+ idToken: String,
+ accessToken: String,
+ font: CustomFontItem
+ ) {
+ val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name }
+ driveFiles[font.fileName]?.id?.let { driveRepository.deleteDriveFile(accessToken, it) }
+ firestoreRepository.deleteFontMetadata(userId, font.id, idToken)
+ }
+
+ private suspend fun downloadAnnotations(accessToken: String, book: BookItem, timestamp: Long): Boolean {
+ val temp = File(desktopUserCacheRoot(), "temp_download_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json")
+ return try {
+ logDesktopCloudAnnotations {
+ "desktop.download.start book=${book.id} remoteTs=$timestamp temp=${temp.name} " +
+ DesktopCloudSidecarSync.localAnnotationDebugSummary(book)
+ }
+ logDesktopCloudSync { "desktop.sidecar_download.start book=${book.id} remoteTs=$timestamp temp=${temp.name}" }
+ if (!driveRepository.downloadAnnotationFile(accessToken, book.id, temp) || !temp.isFile) {
+ logDesktopCloudAnnotations {
+ "desktop.download.missing book=${book.id} remoteTs=$timestamp tempExists=${temp.exists()} tempBytes=${temp.length()}"
+ }
+ logDesktopCloudSync { "desktop.sidecar_download.missing book=${book.id} remoteTs=$timestamp" }
+ return false
+ }
+ val raw = temp.readText()
+ logDesktopCloudAnnotations {
+ "desktop.download.success book=${book.id} remoteTs=$timestamp bytes=${raw.length}"
+ }
+ val appliedTimestamp = timestamp.takeIf { it > 0L } ?: temp.lastModified().takeIf { it > 0L } ?: 0L
+ val applied = DesktopCloudSidecarSync.importAnnotationBundle(book, raw, appliedTimestamp)
+ logDesktopCloudAnnotations {
+ "desktop.download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp applied=$applied " +
+ DesktopCloudSidecarSync.localAnnotationDebugSummary(book)
+ }
+ logDesktopCloudSync {
+ "desktop.sidecar_download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp bytes=${temp.length()} applied=$applied"
+ }
+ applied
+ } finally {
+ temp.delete()
+ }
+ }
+
+ private suspend fun downloadRemoteBook(
+ accessToken: String,
+ remote: DesktopCloudBookMetadata,
+ existing: BookItem?,
+ driveFiles: Map
+ ): BookItem? {
+ val type = remote.fileType()
+ val driveName = desktopCloudBookDriveFileName(remote.bookId, type) ?: return null
+ val driveFile = driveFiles[driveName] ?: return null
+ val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null
+ val destination = bookImporter.createBookFile("${remote.bookId.toDesktopSafeFileName()}.$extension")
+ logDesktopCloudSync { "desktop.content_download.start book=${remote.bookId} driveName=$driveName remoteContentTs=${remote.fileContentModifiedTimestamp}" }
+ if (!driveRepository.downloadFile(accessToken, driveFile.id, destination)) {
+ destination.delete()
+ logDesktopCloudSync { "desktop.content_download.failed book=${remote.bookId} driveName=$driveName" }
+ return null
+ }
+ val contentTimestamp = remote.fileContentModifiedTimestamp.takeIf { it > 0L } ?: destination.lastModified()
+ if (contentTimestamp > 0L) destination.setLastModified(contentTimestamp)
+ val downloaded = remote.toDesktopBookItem(existing = existing, downloadedPath = destination.absolutePath).copy(
+ fileSize = destination.length(),
+ fileContentModifiedTimestamp = contentTimestamp
+ )
+ logDesktopCloudSync {
+ "desktop.content_download.success book=${remote.bookId} bytes=${destination.length()} contentTs=$contentTimestamp " +
+ downloaded.desktopCloudSyncSummary("downloaded")
+ }
+ return downloaded
+ }
+
+ private suspend fun syncFonts(
+ userId: String,
+ idToken: String,
+ accessToken: String,
+ localFonts: List,
+ remoteFonts: List
+ ): List {
+ val localFontsMap = localFonts.associateBy { it.id }
+ val remoteFontsMap = remoteFonts.associateBy { it.id }
+ val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name }
+ val nextFonts = localFonts.toMutableList()
+
+ (localFontsMap.keys + remoteFontsMap.keys).forEach { fontId ->
+ val local = localFontsMap[fontId]
+ val remote = remoteFontsMap[fontId]
+ when {
+ local != null && remote == null -> {
+ firestoreRepository.syncFontMetadata(userId, local.toDesktopCloudFontMetadata(), idToken)
+ }
+
+ local == null && remote != null && !remote.isDeleted -> {
+ val target = customFontStore.getFontFile(remote.fileName)
+ driveFiles[remote.fileName]?.id?.let { driveRepository.downloadFile(accessToken, it, target) }
+ nextFonts += customFontStore.syncedFontItem(remote)
+ }
+
+ local != null && remote != null -> {
+ when {
+ local.isDeleted && !remote.isDeleted -> {
+ firestoreRepository.syncFontMetadata(userId, remote.copy(isDeleted = true), idToken)
+ }
+
+ !local.isDeleted && remote.isDeleted -> {
+ customFontStore.deleteFont(local)
+ nextFonts.removeAll { it.id == local.id }
+ }
+ }
+ }
+ }
+ }
+
+ nextFonts.toList().forEach { font ->
+ val localFile = File(font.path)
+ if (!font.isDeleted && localFile.isFile && driveFiles[font.fileName] == null) {
+ driveRepository.uploadFont(accessToken, font.fileName, localFile, font.fileExtension)
+ } else if (!font.isDeleted && !localFile.isFile) {
+ driveFiles[font.fileName]?.id?.let { driveRepository.downloadFile(accessToken, it, localFile) }
+ }
+ }
+ return nextFonts.distinctBy { it.id }
+ }
+
+ private suspend fun syncShelves(
+ userId: String,
+ idToken: String,
+ deviceId: String,
+ shelfRecords: List,
+ shelfRefs: List,
+ syncableBookIds: Set,
+ remoteShelves: List
+ ): ShelfSyncResult {
+ val localShelves = shelfRecords
+ .filterNot { it.isSmart }
+ .map { record ->
+ DesktopCloudShelfRecord(
+ record = record,
+ metadata = DesktopCloudShelfMetadata(
+ name = record.name,
+ bookIds = shelfRefs.filter { it.shelfId == record.id }
+ .map { it.bookId }
+ .filter { it in syncableBookIds }
+ .distinct(),
+ lastModifiedTimestamp = desktopShelfTimestamp(record, shelfRefs),
+ isDeleted = false
+ )
+ )
+ }
+ val localShelvesByName = localShelves.associateBy { it.metadata.name }
+ val remoteShelvesByName = remoteShelves.associateBy { it.name }
+ var records = shelfRecords
+ var refs = shelfRefs
+
+ (localShelvesByName.keys + remoteShelvesByName.keys).forEach { shelfName ->
+ val local = localShelvesByName[shelfName]
+ val remote = remoteShelvesByName[shelfName]
+ when {
+ local != null && remote == null -> {
+ firestoreRepository.syncShelf(userId, local.metadata, deviceId, idToken)
+ }
+
+ local == null && remote != null -> {
+ if (!remote.isDeleted) {
+ val record = ShelfRecord(id = "shelf_${remote.lastModifiedTimestamp}_${shelfName.hashCode()}", name = remote.name)
+ records += record
+ refs = refs.filterNot { it.shelfId == record.id } +
+ remote.bookIds.filter { it in syncableBookIds }.map { bookId ->
+ BookShelfRef(bookId, record.id, remote.lastModifiedTimestamp)
+ }
+ }
+ }
+
+ local != null && remote != null -> {
+ if (local.metadata.lastModifiedTimestamp > remote.lastModifiedTimestamp) {
+ firestoreRepository.syncShelf(userId, local.metadata, deviceId, idToken)
+ } else if (remote.lastModifiedTimestamp > local.metadata.lastModifiedTimestamp) {
+ if (remote.isDeleted) {
+ records = records.filterNot { it.id == local.record.id }
+ refs = refs.filterNot { it.shelfId == local.record.id }
+ } else {
+ refs = refs.filterNot { it.shelfId == local.record.id } +
+ remote.bookIds.filter { it in syncableBookIds }.map { bookId ->
+ BookShelfRef(bookId, local.record.id, remote.lastModifiedTimestamp)
+ }
+ }
+ }
+ }
+ }
+ }
+ return ShelfSyncResult(records, refs)
+ }
+}
+
+internal fun BookItem.toDesktopCloudBookMetadata(
+ hasAnnotations: Boolean,
+ timestamp: Long = this.timestamp,
+ annotationModifiedTimestamp: Long = 0L,
+ contentTimestampOverride: Long? = null
+): DesktopCloudBookMetadata {
+ val position = readerPosition.takeIf { type.usesCloudLocatorMetadata() }
+ val supportsReaderAnnotations = type.usesCloudLocatorMetadata()
+ val bookmarksJson = desktopPdfBookmarksMetadataJson(this)
+ ?: if (supportsReaderAnnotations) {
+ readerBookmarks
+ .mapNotNull { it.toDesktopCloudEpubBookmarkOrNull() }
+ .let(EpubAnnotationSerializer::bookmarksToJson)
+ } else {
+ null
+ }
+ val highlightsJson = if (supportsReaderAnnotations) {
+ EpubAnnotationSerializer.highlightsToJson(readerHighlights)
+ } else {
+ null
+ }
+ val localFile = path?.let(::File)
+ val contentTimestamp = contentTimestampOverride
+ ?: fileContentModifiedTimestamp.takeIf { it > 0L }
+ ?: localFile?.takeIf { it.isFile }?.lastModified()
+ ?: 0L
+ return DesktopCloudBookMetadata(
+ bookId = id,
+ title = title,
+ author = author,
+ displayName = displayName,
+ type = type.name,
+ lastPositionCfi = position?.cloudPositionCfi(),
+ lastChapterIndex = position?.chapterIndex,
+ locatorBlockIndex = position?.blockIndex,
+ locatorCharOffset = position?.charOffset,
+ lastPage = if (type.usesCloudLocatorMetadata()) position?.pageIndex ?: lastPageIndex else lastPageIndex,
+ progressPercentage = progressPercentage,
+ isRecent = isRecent,
+ isDeleted = false,
+ lastModifiedTimestamp = timestamp,
+ readingPositionModifiedTimestamp = effectiveCloudReadingPositionModifiedTimestamp(),
+ annotationModifiedTimestamp = annotationModifiedTimestamp,
+ bookmarksJson = bookmarksJson,
+ hasAnnotations = hasAnnotations,
+ fileContentModifiedTimestamp = contentTimestamp,
+ customName = null,
+ highlightsJson = highlightsJson,
+ seriesName = seriesName,
+ seriesIndex = seriesIndex,
+ description = description,
+ originalTitle = originalTitle ?: title,
+ originalAuthor = originalAuthor ?: author,
+ originalSeriesName = originalSeriesName ?: seriesName,
+ originalSeriesIndex = originalSeriesIndex ?: seriesIndex,
+ originalDescription = originalDescription ?: description
+ )
+}
+
+internal fun DesktopCloudBookMetadata.toDesktopBookItem(
+ existing: BookItem? = null,
+ downloadedPath: String? = null
+): BookItem {
+ val type = fileType()
+ val pageIndex = lastPage
+ val locator = if (type.usesCloudLocatorMetadata()) {
+ ReaderLocator.fromLegacy(
+ chapterIndex = lastChapterIndex,
+ cfi = lastPositionCfi,
+ pageIndex = pageIndex
+ ).withFallbacks(
+ blockIndex = locatorBlockIndex,
+ charOffset = locatorCharOffset
+ )
+ } else {
+ null
+ }
+ val remoteReadingTimestamp = effectiveCloudReadingPositionModifiedTimestamp()
+ val localReadingTimestamp = existing?.effectiveCloudReadingPositionModifiedTimestamp() ?: 0L
+ val useRemoteReadingPosition = existing == null ||
+ remoteReadingTimestamp > localReadingTimestamp ||
+ (localReadingTimestamp == 0L && hasCloudReadingPosition())
+ val restoredPageIndex = if (useRemoteReadingPosition) pageIndex ?: existing?.lastPageIndex else existing?.lastPageIndex
+ val restoredReaderPosition = if (type.usesCloudLocatorMetadata()) {
+ if (useRemoteReadingPosition) {
+ locator?.takeIf {
+ it.chapterIndex != null ||
+ it.pageIndex != null ||
+ it.cfi != null ||
+ it.startOffset != null ||
+ it.blockIndex != null
+ } ?: existing?.readerPosition
+ } else {
+ existing?.readerPosition
+ }
+ } else {
+ null
+ }
+ return BookItem(
+ id = bookId,
+ path = downloadedPath ?: existing?.path,
+ type = type,
+ displayName = displayName.ifBlank { existing?.displayName ?: bookId },
+ timestamp = lastModifiedTimestamp,
+ coverImagePath = existing?.coverImagePath,
+ title = title ?: existing?.title,
+ author = author ?: existing?.author,
+ description = description ?: existing?.description,
+ originalTitle = originalTitle ?: existing?.originalTitle,
+ originalAuthor = originalAuthor ?: existing?.originalAuthor,
+ originalSeriesName = originalSeriesName ?: existing?.originalSeriesName,
+ originalSeriesIndex = originalSeriesIndex ?: existing?.originalSeriesIndex,
+ originalDescription = originalDescription ?: existing?.originalDescription,
+ progressPercentage = if (useRemoteReadingPosition) progressPercentage ?: existing?.progressPercentage else existing?.progressPercentage,
+ isRecent = isRecent,
+ fileSize = existing?.fileSize ?: 0L,
+ fileContentModifiedTimestamp = fileContentModifiedTimestamp.takeIf { it > 0L }
+ ?: existing?.fileContentModifiedTimestamp
+ ?: 0L,
+ sourceFolder = null,
+ folderTextMetadataParsed = existing?.folderTextMetadataParsed ?: false,
+ seriesName = seriesName ?: existing?.seriesName,
+ seriesIndex = seriesIndex ?: existing?.seriesIndex,
+ tags = existing?.tags.orEmpty(),
+ lastPageIndex = restoredPageIndex,
+ readerPosition = restoredReaderPosition,
+ readerSettings = existing?.readerSettings,
+ readerBookmarks = if (type == FileType.PDF || bookmarksJson.isNullOrBlank()) {
+ existing?.readerBookmarks.orEmpty()
+ } else {
+ EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson).map { bookmark ->
+ ReaderBookmark(
+ id = "${bookmark.chapterIndex}:${bookmark.cfi}",
+ pageIndex = bookmark.pageInChapter?.minus(1) ?: bookmark.locator.pageIndex ?: 0,
+ chapterTitle = bookmark.chapterTitle,
+ preview = bookmark.snippet,
+ locator = bookmark.locator
+ )
+ }
+ },
+ readerHighlights = if (highlightsJson.isNullOrBlank()) {
+ existing?.readerHighlights.orEmpty()
+ } else {
+ EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
+ },
+ pdfReaderViewport = if (useRemoteReadingPosition) remotePdfViewport(existing, pageIndex) else existing?.pdfReaderViewport,
+ readingPositionModifiedTimestamp = if (useRemoteReadingPosition) remoteReadingTimestamp else localReadingTimestamp
+ )
+}
+
+internal fun BookItem.withCloudReadingPosition(remote: DesktopCloudBookMetadata): BookItem {
+ val remoteType = remote.fileType()
+ val pageIndex = remote.lastPage
+ val locator = if (remoteType.usesCloudLocatorMetadata()) {
+ ReaderLocator.fromLegacy(
+ chapterIndex = remote.lastChapterIndex,
+ cfi = remote.lastPositionCfi,
+ pageIndex = pageIndex
+ ).withFallbacks(
+ blockIndex = remote.locatorBlockIndex,
+ charOffset = remote.locatorCharOffset
+ ).takeIf {
+ it.chapterIndex != null ||
+ it.pageIndex != null ||
+ it.cfi != null ||
+ it.startOffset != null ||
+ it.blockIndex != null
+ }
+ } else {
+ null
+ }
+ return copy(
+ lastPageIndex = pageIndex ?: lastPageIndex,
+ readerPosition = if (remoteType.usesCloudLocatorMetadata()) locator ?: readerPosition else null,
+ progressPercentage = remote.progressPercentage ?: progressPercentage,
+ pdfReaderViewport = if (remoteType.usesCloudLocatorMetadata()) {
+ pdfReaderViewport
+ } else {
+ remote.remotePdfViewport(this, pageIndex)
+ },
+ readingPositionModifiedTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp()
+ )
+}
+
+private fun DesktopCloudBookMetadata.remotePdfViewport(
+ existing: BookItem?,
+ pageIndex: Int?
+): SharedPdfReaderViewport? {
+ if (fileType().usesCloudLocatorMetadata() || pageIndex == null) return existing?.pdfReaderViewport
+ val base = existing?.pdfReaderViewport ?: SharedPdfReaderViewport()
+ return base.copy(
+ pageIndex = pageIndex,
+ horizontalScrollOffset = 0,
+ paginatedVerticalScrollOffset = 0,
+ verticalFirstPageIndex = pageIndex,
+ verticalFirstPageScrollOffset = 0
+ )
+}
+
+private fun FileType.usesCloudLocatorMetadata(): Boolean {
+ return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this)
+}
+
+internal fun BookItem.hasCloudReadingPosition(): Boolean {
+ return lastPageIndex != null ||
+ readerPosition != null ||
+ (progressPercentage ?: 0f) > 0f
+}
+
+internal fun BookItem.effectiveCloudReadingPositionModifiedTimestamp(): Long {
+ return readingPositionModifiedTimestamp.takeIf { it > 0L }
+ ?: timestamp.takeIf { hasCloudReadingPosition() }
+ ?: 0L
+}
+
+internal fun DesktopCloudBookMetadata.hasCloudReadingPosition(): Boolean {
+ return lastChapterIndex != null ||
+ lastPage != null ||
+ !lastPositionCfi.isNullOrBlank() ||
+ locatorBlockIndex != null ||
+ locatorCharOffset != null ||
+ (progressPercentage ?: 0f) > 0f
+}
+
+internal fun DesktopCloudBookMetadata.effectiveCloudReadingPositionModifiedTimestamp(): Long {
+ return readingPositionModifiedTimestamp.takeIf { it > 0L }
+ ?: lastModifiedTimestamp.takeIf { hasCloudReadingPosition() }
+ ?: 0L
+}
+
+internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(): Long {
+ return annotationModifiedTimestamp.takeIf { it > 0L }
+ ?: 0L
+}
+
+internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(sidecarModifiedTimestamp: Long): Long {
+ return sidecarModifiedTimestamp.takeIf { it > 0L }
+ ?: effectiveCloudAnnotationModifiedTimestamp()
+}
+
+internal fun CustomFontItem.toDesktopCloudFontMetadata(): DesktopCloudFontMetadata {
+ return DesktopCloudFontMetadata(
+ id = id,
+ displayName = displayName,
+ fileName = fileName,
+ fileExtension = fileExtension,
+ timestamp = timestamp,
+ isDeleted = isDeleted
+ )
+}
+
+internal fun desktopCloudBookDriveFileName(bookId: String, type: FileType): String? {
+ return sharedCloudBookContentFileName(bookId, type)
+}
+
+private data class DesktopCloudShelfRecord(
+ val record: ShelfRecord,
+ val metadata: DesktopCloudShelfMetadata
+)
+
+private data class ShelfSyncResult(
+ val records: List,
+ val refs: List
+)
+
+private fun DesktopCloudBookMetadata.fileType(): FileType {
+ return runCatching { FileType.valueOf(type) }.getOrDefault(FileType.EPUB)
+}
+
+private fun SharedReaderScreenState.upsertCloudBook(book: BookItem): SharedReaderScreenState {
+ val existing = rawLibraryBooks.any { it.id == book.id }
+ val nextBooks = if (existing) {
+ rawLibraryBooks.map { if (it.id == book.id) book else it }
+ } else {
+ listOf(book) + rawLibraryBooks
+ }
+ return copy(rawLibraryBooks = nextBooks)
+}
+
+private fun SharedReaderScreenState.removeCloudBook(bookId: String): SharedReaderScreenState {
+ return copy(
+ rawLibraryBooks = rawLibraryBooks.filterNot { it.id == bookId },
+ selectedBookIds = selectedBookIds - bookId,
+ pinnedHomeBookIds = pinnedHomeBookIds - bookId,
+ pinnedLibraryBookIds = pinnedLibraryBookIds - bookId,
+ openTabIds = openTabIds.filterNot { it == bookId },
+ activeTabBookId = activeTabBookId?.takeUnless { it == bookId }
+ )
+}
+
+private fun shouldDownloadRemoteBookContent(local: BookItem, remote: DesktopCloudBookMetadata): Boolean {
+ val localFile = local.path?.let(::File)
+ val localTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L }
+ ?: localFile?.takeIf { it.isFile }?.lastModified()
+ ?: 0L
+ return local.sourceFolder == null &&
+ remote.fileType() == local.type &&
+ shouldDownloadRemoteCloudBookContent(
+ localFileAvailable = localFile?.isFile == true,
+ localContentModifiedTimestamp = localTimestamp,
+ remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp,
+ remoteDeleted = remote.isDeleted
+ )
+}
+
+private fun shouldUploadLocalBookContent(local: BookItem, remote: DesktopCloudBookMetadata?): Boolean {
+ val localFile = local.path?.let(::File)?.takeIf { it.isFile } ?: return false
+ val localTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } ?: localFile.lastModified()
+ return local.sourceFolder == null &&
+ shouldUploadLocalCloudBookContent(
+ localFileAvailable = true,
+ localContentModifiedTimestamp = localTimestamp,
+ remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp
+ )
+}
+
+private fun shouldUploadLocalAnnotations(
+ local: BookItem,
+ remote: DesktopCloudBookMetadata?,
+ remoteAnnotationModifiedTimestamp: Long = remote?.effectiveCloudAnnotationModifiedTimestamp() ?: 0L,
+ localSidecarTimestamp: Long = DesktopCloudSidecarSync.localAnnotationTimestamp(local)
+): Boolean {
+ return DesktopCloudSidecarSync.hasLocalAnnotationData(local) &&
+ (remote == null || !remote.hasAnnotations || localSidecarTimestamp > remoteAnnotationModifiedTimestamp)
+}
+
+private fun remoteAnnotationDriveFileTimestamp(
+ bookId: String,
+ driveFiles: Map
+): Long {
+ return driveFiles[desktopCloudAnnotationDriveFileName(bookId)]?.modifiedTimeMillis ?: 0L
+}
+
+internal fun desktopCloudAnnotationDriveFileName(bookId: String): String = "annotation_$bookId.json"
+
+private fun BookItem.withDownloadedCloudContent(downloaded: BookItem?, replacePath: Boolean = true): BookItem {
+ if (downloaded == null) return this
+ return copy(
+ path = if (replacePath) downloaded.path ?: path else path,
+ fileSize = downloaded.fileSize.takeIf { it > 0L } ?: fileSize,
+ fileContentModifiedTimestamp = downloaded.fileContentModifiedTimestamp.takeIf { it > 0L }
+ ?: fileContentModifiedTimestamp
+ )
+}
+
+private fun desktopShelfTimestamp(record: ShelfRecord, refs: List): Long {
+ val idTimestamp = record.id.split('_').firstNotNullOfOrNull { it.toLongOrNull() }
+ val refsTimestamp = refs.filter { it.shelfId == record.id }.maxOfOrNull { it.addedAt }
+ return maxOf(idTimestamp ?: 0L, refsTimestamp ?: 0L)
+}
+
+private fun ReaderLocator.cloudPositionCfi(): String? {
+ return toStablePositionCfi()
+}
+
+private fun ReaderBookmark.toDesktopCloudEpubBookmarkOrNull(): EpubBookmark? {
+ val chapterIndex = locator.chapterIndex ?: 0
+ val cfi = locator.cloudPositionCfi() ?: "desktop:$chapterIndex:$pageIndex"
+ return EpubBookmark(
+ cfi = cfi,
+ chapterTitle = chapterTitle,
+ label = null,
+ snippet = preview,
+ pageInChapter = pageIndex + 1,
+ totalPagesInChapter = null,
+ chapterIndex = chapterIndex,
+ locator = locator.withFallbacks(
+ chapterIndex = chapterIndex,
+ cfi = cfi,
+ pageIndex = pageIndex,
+ textQuote = preview
+ )
+ )
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt
new file mode 100644
index 0000000..4ac30f5
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt
@@ -0,0 +1,82 @@
+package org.dueattendant149.bookreader.desktop
+
+import org.dueattendant149.bookreader.shared.BookItem
+import org.dueattendant149.bookreader.shared.FileType
+import org.dueattendant149.bookreader.shared.SharedFileCapabilities
+
+internal const val DesktopCloudSyncLogTag = "EpistemeCloudSync"
+internal const val DesktopCloudAnnotationSyncLogTag = "EpistemeCloudAnnotations"
+
+internal fun logDesktopCloudSync(message: () -> String) {
+ logDesktopDiagnostic(DesktopCloudSyncLogTag, message)
+}
+
+internal fun logDesktopCloudAnnotations(message: () -> String) {
+ logDesktopDiagnostic(DesktopCloudAnnotationSyncLogTag, message)
+}
+
+internal fun BookItem.desktopCloudSyncSummary(prefix: String = "local"): String {
+ val position = readerPosition
+ val page = if (type.usesCloudLocatorForDiagnostics()) {
+ position?.pageIndex ?: lastPageIndex
+ } else {
+ lastPageIndex
+ }
+ return "$prefix{id=$id type=$type ts=$timestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " +
+ "contentTs=$fileContentModifiedTimestamp " +
+ "page=$page chapter=${position?.chapterIndex} " +
+ "block=${position?.blockIndex} char=${position?.charOffset} progress=$progressPercentage " +
+ "cfi=${position?.cfi.cloudSyncPreview()} sourceFolder=${sourceFolder != null} " +
+ "bookmarks=${readerBookmarks.size} highlights=${readerHighlights.size}}"
+}
+
+internal fun DesktopCloudBookMetadata.desktopCloudSyncSummary(prefix: String = "remote"): String {
+ return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " +
+ "annTs=${effectiveCloudAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
+ "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
+ "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted " +
+ "recent=$isRecent hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
+ "highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
+}
+
+internal fun BookItem.hasSameCloudReaderPosition(other: BookItem): Boolean {
+ val thisPage = if (type.usesCloudLocatorForDiagnostics()) readerPosition?.pageIndex ?: lastPageIndex else lastPageIndex
+ val otherPage = if (other.type.usesCloudLocatorForDiagnostics()) {
+ other.readerPosition?.pageIndex ?: other.lastPageIndex
+ } else {
+ other.lastPageIndex
+ }
+ val thisProgress = progressPercentage
+ val otherProgress = other.progressPercentage
+ val progressMatches = when {
+ thisProgress == null && otherProgress == null -> true
+ thisProgress != null && otherProgress != null -> kotlin.math.abs(thisProgress - otherProgress) < 0.001f
+ else -> false
+ }
+ val locatorMatches = if (type.usesCloudLocatorForDiagnostics() || other.type.usesCloudLocatorForDiagnostics()) {
+ readerPosition == other.readerPosition
+ } else {
+ true
+ }
+ return thisPage == otherPage &&
+ locatorMatches &&
+ progressMatches
+}
+
+private fun org.dueattendant149.bookreader.shared.FileType.usesCloudLocatorForDiagnostics(): Boolean {
+ return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this)
+}
+
+private fun String?.cloudSyncPreview(maxLength: Int = 80): String {
+ val value = this ?: return "null"
+ return if (value.length <= maxLength) value else value.take(maxLength) + "..."
+}
+
+private fun String?.cloudSyncAnnotationSummary(): String {
+ val value = this?.trim() ?: return "null"
+ return when {
+ value.isEmpty() -> "blank"
+ value == "[]" -> "empty"
+ else -> "present(${value.length})"
+ }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt
new file mode 100644
index 0000000..0b2b0e9
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt
@@ -0,0 +1,60 @@
+package org.dueattendant149.bookreader.desktop
+
+import java.io.File
+import java.util.Properties
+import java.util.UUID
+
+internal data class DesktopCloudSyncSettings(
+ val isSyncEnabled: Boolean = false,
+ val isFolderSyncEnabled: Boolean = false
+)
+
+internal class DesktopCloudSyncSettingsStore(
+ private val settingsFile: File = File(desktopUserConfigRoot(), "cloud-sync.properties")
+) {
+ fun load(): DesktopCloudSyncSettings {
+ if (!settingsFile.isFile) return DesktopCloudSyncSettings()
+ val properties = Properties()
+ return runCatching {
+ settingsFile.inputStream().use(properties::load)
+ DesktopCloudSyncSettings(
+ isSyncEnabled = properties.getProperty("syncEnabled", "false").toBooleanStrictOrNull() == true,
+ isFolderSyncEnabled = properties.getProperty("folderSyncEnabled", "false").toBooleanStrictOrNull() == true
+ )
+ }.getOrDefault(DesktopCloudSyncSettings())
+ }
+
+ fun save(settings: DesktopCloudSyncSettings) {
+ settingsFile.parentFile?.mkdirs()
+ val properties = Properties().apply {
+ setProperty("syncEnabled", settings.isSyncEnabled.toString())
+ setProperty("folderSyncEnabled", settings.isFolderSyncEnabled.toString())
+ }
+ settingsFile.outputStream().use { output ->
+ properties.store(output, "Episteme desktop cloud sync")
+ }
+ }
+}
+
+internal class DesktopInstallationIdStore(
+ private val settingsFile: File = File(desktopUserConfigRoot(), "installation.properties")
+) {
+ fun getOrCreateId(): String {
+ val properties = Properties()
+ val existing = runCatching {
+ if (!settingsFile.isFile) return@runCatching null
+ settingsFile.inputStream().use(properties::load)
+ properties.getProperty("installationId")?.takeIf { it.isNotBlank() }
+ }.getOrNull()
+ if (existing != null) return existing
+
+ val generated = UUID.randomUUID().toString()
+ settingsFile.parentFile?.mkdirs()
+ settingsFile.outputStream().use { output ->
+ Properties().apply {
+ setProperty("installationId", generated)
+ }.store(output, "Episteme desktop installation")
+ }
+ return generated
+ }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt
new file mode 100644
index 0000000..ab7ad33
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt
@@ -0,0 +1,614 @@
+package org.dueattendant149.bookreader.desktop
+
+import org.dueattendant149.bookreader.shared.FileType
+import org.dueattendant149.bookreader.shared.SharedFileCapabilities
+import org.dueattendant149.bookreader.shared.opds.OpdsCatalog
+import org.dueattendant149.bookreader.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 org.apache.commons.compress.archivers.tar.TarArchiveInputStream
+import java.awt.Color
+import java.awt.Font
+import java.awt.RenderingHints
+import java.awt.image.BufferedImage
+import java.io.ByteArrayInputStream
+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 = SharedFileCapabilities.comicArchiveTypes
+ 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)
+ FileType.CBT -> loadTar(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 libarchive for this desktop platform, or keep tar/bsdtar available on PATH. " +
+ "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()
+ @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 loadTar(file: File): DesktopComicDocument {
+ val tempDir = Files.createTempDirectory("reader-comic-").toFile()
+ return try {
+ val extracted = mutableListOf()
+ TarArchiveInputStream(file.inputStream().buffered()).use { archive ->
+ var entry = archive.nextEntry
+ while (entry != null) {
+ val name = entry.name.orEmpty()
+ if (!entry.isDirectory && name.isComicImageName()) {
+ val target = File(tempDir, "page_${extracted.size}.${name.imageExtension()}")
+ target.outputStream().use { output ->
+ archive.copyTo(output)
+ }
+ extracted += ExtractedComicPage(name = name, file = target)
+ }
+ entry = archive.nextEntry
+ }
+ }
+ documentFromExtracted(file, extracted, tempDir)
+ } catch (throwable: Throwable) {
+ runCatching { tempDir.deleteRecursively() }
+ throw throwable
+ }
+ }
+
+ private fun loadWithArchiveCommand(file: File): DesktopComicDocument {
+ val tempDir = Files.createTempDirectory("reader-comic-").toFile()
+ return try {
+ 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,
+ 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? {
+ 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,
+ private val closeAction: () -> Unit
+) {
+ private val pages = pages.toList()
+
+ val pageCount: Int = pages.size
+ val pageSizes: List = 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
+ ): List {
+ 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 {
+ 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
+ ): List {
+ 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()
+ 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 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
+ }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt
new file mode 100644
index 0000000..54a86b9
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt
@@ -0,0 +1,162 @@
+package org.dueattendant149.bookreader.desktop
+
+import org.dueattendant149.bookreader.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 val googleFontsDownloadAvailable: () -> Boolean = { true }
+) {
+ private var googleFontsCache: List? = null
+
+ fun importFont(source: File, displayNameOverride: String? = null): Result {
+ 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 getFontFile(fileName: String): File {
+ fontsDir.mkdirs()
+ return File(fontsDir, fileName)
+ }
+
+ internal fun syncedFontItem(metadata: DesktopCloudFontMetadata): CustomFontItem {
+ return CustomFontItem(
+ id = metadata.id,
+ displayName = metadata.displayName,
+ fileName = metadata.fileName,
+ fileExtension = metadata.fileExtension,
+ path = getFontFile(metadata.fileName).absolutePath,
+ timestamp = metadata.timestamp,
+ isDeleted = metadata.isDeleted
+ )
+ }
+
+ fun loadGoogleFontsList(): List {
+ 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 {
+ if (!googleFontsDownloadAvailable()) {
+ return Result.failure(IllegalStateException("Google Fonts download is unavailable in this desktop build."))
+ }
+ 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 {
+ return File(desktopUserDataRoot(), "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 {
+ return Json.parseToJsonElement(rawJson)
+ .jsonArray
+ .mapNotNull { element ->
+ runCatching { element.jsonPrimitive.content.trim().takeIf { it.isNotBlank() } }.getOrNull()
+ }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt
new file mode 100644
index 0000000..ded1895
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt
@@ -0,0 +1,40 @@
+package org.dueattendant149.bookreader.desktop
+
+internal const val DesktopDiagnosticsProperty = "episteme.desktop.diagnostics"
+private const val DesktopDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
+private const val DesktopDiagnosticsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS"
+private const val DesktopDiagnosticsTagsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS_TAGS"
+
+private val DesktopDiagnosticTags: Set =
+ listOfNotNull(
+ System.getProperty(DesktopDiagnosticsTagsProperty),
+ System.getenv(DesktopDiagnosticsTagsEnv)
+ )
+ .joinToString(" ")
+ .split(',', ';', ' ', '\t', '\n')
+ .mapNotNull { rawTag ->
+ rawTag.trim()
+ .takeIf { it.isNotBlank() }
+ ?.lowercase()
+ }
+ .toSet()
+
+internal val DesktopDiagnosticsEnabled: Boolean =
+ desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty)) ||
+ desktopDiagnosticsFlag(System.getenv(DesktopDiagnosticsEnv)) ||
+ DesktopDiagnosticTags.isNotEmpty()
+
+internal fun desktopDiagnosticsFlag(rawValue: String?): Boolean {
+ return rawValue?.trim()?.equals("true", ignoreCase = true) == true
+}
+
+private fun isDesktopDiagnosticTagEnabled(tag: String): Boolean {
+ if (DesktopDiagnosticTags.isEmpty()) return true
+ return "*" in DesktopDiagnosticTags || tag.lowercase() in DesktopDiagnosticTags
+}
+
+internal fun logDesktopDiagnostic(tag: String, message: () -> String) {
+ if (DesktopDiagnosticsEnabled && isDesktopDiagnosticTagEnabled(tag)) {
+ println("$tag ${message()}")
+ }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt
new file mode 100644
index 0000000..9884b1e
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt
@@ -0,0 +1,373 @@
+package org.dueattendant149.bookreader.desktop
+
+import org.dueattendant149.bookreader.shared.ReaderLocator
+import org.dueattendant149.bookreader.shared.toStableReaderPositionCfi
+import org.dueattendant149.bookreader.shared.ui.SharedNativeReaderLinkClick
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.intOrNull
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import java.awt.event.KeyEvent as AwtKeyEvent
+import java.net.URLDecoder
+
+internal data class DesktopReaderPosition(
+ val pageIndex: Int,
+ val locator: ReaderLocator?
+)
+
+internal data class DesktopReaderHighlightClick(
+ val highlightId: String
+)
+
+internal data class DesktopEpubLinkClick(
+ val href: String,
+ val chapterIndex: Int?,
+ val text: String? = null,
+ val chapterId: String? = null,
+ val chapterHref: String? = null,
+ val source: String = "bridge"
+)
+
+internal fun SharedNativeReaderLinkClick.toDesktopEpubLinkClick(): DesktopEpubLinkClick {
+ return DesktopEpubLinkClick(
+ href = href,
+ chapterIndex = chapterIndex,
+ text = text,
+ source = "native"
+ )
+}
+
+internal data class DesktopEpubHandledLink(
+ val href: String,
+ val handledAtMs: Long
+)
+
+internal enum class DesktopReaderSelectionAction {
+ DEFINE,
+ SPEAK,
+ SEARCH,
+ PALETTE
+}
+
+internal enum class DesktopReaderKeyNavigation {
+ NEXT,
+ PREVIOUS,
+ FIRST,
+ LAST,
+ SEARCH,
+ NEXT_SEARCH,
+ EXIT_FULLSCREEN
+}
+
+internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(
+ fullscreen: Boolean,
+ rightToLeftPagination: Boolean = false
+): DesktopReaderKeyNavigation? {
+ if (id != AwtKeyEvent.KEY_PRESSED) return null
+ if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) {
+ return DesktopReaderKeyNavigation.EXIT_FULLSCREEN
+ }
+ if (isControlDown && keyCode == AwtKeyEvent.VK_F) {
+ return DesktopReaderKeyNavigation.SEARCH
+ }
+ if (isControlDown && keyCode == AwtKeyEvent.VK_G) {
+ return DesktopReaderKeyNavigation.NEXT_SEARCH
+ }
+ return when (keyCode) {
+ AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) {
+ DesktopReaderKeyNavigation.PREVIOUS
+ } else {
+ DesktopReaderKeyNavigation.NEXT
+ }
+ AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) {
+ DesktopReaderKeyNavigation.NEXT
+ } else {
+ DesktopReaderKeyNavigation.PREVIOUS
+ }
+ AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT
+ AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS
+ AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST
+ AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST
+ else -> null
+ }
+}
+
+internal data class DesktopReaderSelectionActionPayload(
+ val action: DesktopReaderSelectionAction,
+ val text: String,
+ val locator: ReaderLocator? = null
+)
+
+internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? {
+ fun parse(rawJson: String): DesktopReaderHighlightClick? = runCatching {
+ val obj = Json.parseToJsonElement(rawJson).jsonObject
+ val highlightId = obj["id"]
+ ?.takeUnless { it is JsonNull }
+ ?.jsonPrimitive
+ ?.contentOrNull
+ ?.takeIf { it.isNotBlank() }
+ ?: obj["highlightId"]
+ ?.takeUnless { it is JsonNull }
+ ?.jsonPrimitive
+ ?.contentOrNull
+ ?.takeIf { it.isNotBlank() }
+ ?: return@runCatching null
+ DesktopReaderHighlightClick(highlightId)
+ }.getOrNull()
+
+ parse(this)?.let { return it }
+ return runCatching {
+ Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
+ }.getOrNull()?.let { parse(it) }
+}
+
+internal fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPayload? {
+ fun parse(rawJson: String): DesktopReaderSelectionActionPayload? = runCatching {
+ val obj = Json.parseToJsonElement(rawJson).jsonObject
+ val text = obj["text"]
+ ?.takeUnless { it is JsonNull }
+ ?.jsonPrimitive
+ ?.contentOrNull
+ ?.takeIf { it.isNotBlank() }
+ ?: return@runCatching null
+ val action = when (
+ obj["action"]
+ ?.takeUnless { it is JsonNull }
+ ?.jsonPrimitive
+ ?.contentOrNull
+ ?.lowercase()
+ ) {
+ "define" -> DesktopReaderSelectionAction.DEFINE
+ "speak" -> DesktopReaderSelectionAction.SPEAK
+ "web-search", "search" -> DesktopReaderSelectionAction.SEARCH
+ "palette" -> DesktopReaderSelectionAction.PALETTE
+ else -> return@runCatching null
+ }
+ val locator = obj["locator"]
+ ?.takeUnless { it is JsonNull }
+ ?.jsonObject
+ ?.let { locatorObj ->
+ ReaderLocator(
+ chapterIndex = locatorObj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
+ chapterId = locatorObj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
+ href = locatorObj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
+ pageIndex = locatorObj["pageIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
+ startOffset = locatorObj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
+ endOffset = locatorObj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
+ blockIndex = locatorObj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
+ charOffset = locatorObj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
+ textQuote = locatorObj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
+ cfi = locatorObj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi()
+ )
+ }
+ DesktopReaderSelectionActionPayload(action, text, locator)
+ }.getOrNull()
+
+ parse(this)?.let { return it }
+ return runCatching {
+ Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
+ }.getOrNull()?.let { parse(it) }
+}
+
+internal fun String.readerSelectionDebugMessageOrNull(): String? {
+ fun parse(rawJson: String): String? = runCatching {
+ Json.parseToJsonElement(rawJson)
+ .jsonObject["message"]
+ ?.takeUnless { it is JsonNull }
+ ?.jsonPrimitive
+ ?.contentOrNull
+ ?.takeIf { it.isNotBlank() }
+ }.getOrNull()
+
+ parse(this)?.let { return it }
+ return runCatching {
+ Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
+ }.getOrNull()?.let { parse(it) }
+}
+
+internal fun String.readerPaginationLogMessageOrNull(): String? {
+ fun parse(rawJson: String): String? = runCatching {
+ Json.parseToJsonElement(rawJson)
+ .jsonObject["message"]
+ ?.takeUnless { it is JsonNull }
+ ?.jsonPrimitive
+ ?.contentOrNull
+ ?.takeIf { it.isNotBlank() }
+ }.getOrNull()
+
+ parse(this)?.let { return it }
+ return runCatching {
+ Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
+ }.getOrNull()?.let { parse(it) }
+}
+
+internal fun String.readerPositionOrNull(): DesktopReaderPosition? {
+ fun parse(rawJson: String): DesktopReaderPosition? = runCatching {
+ val obj = Json.parseToJsonElement(rawJson).jsonObject
+ val pageIndex = obj["pageIndex"]
+ ?.takeUnless { it is JsonNull }
+ ?.jsonPrimitive
+ ?.intOrNull
+ ?: return@runCatching null
+ val locator = ReaderLocator(
+ chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
+ chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
+ href = obj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
+ pageIndex = pageIndex,
+ startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
+ endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
+ blockIndex = obj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
+ charOffset = obj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
+ textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
+ cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi()
+ )
+ DesktopReaderPosition(pageIndex, locator)
+ }.getOrNull()
+
+ parse(this)?.let { return it }
+ return runCatching {
+ Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
+ }.getOrNull()?.let { parse(it) }
+}
+
+internal fun String.readerKeyNavigationOrNull(): DesktopReaderKeyNavigation? {
+ fun parse(rawJson: String): DesktopReaderKeyNavigation? = runCatching {
+ val action = Json.parseToJsonElement(rawJson)
+ .jsonObject["action"]
+ ?.takeUnless { it is JsonNull }
+ ?.jsonPrimitive
+ ?.contentOrNull
+ ?: return@runCatching null
+ when (action) {
+ "next" -> DesktopReaderKeyNavigation.NEXT
+ "previous" -> DesktopReaderKeyNavigation.PREVIOUS
+ "first" -> DesktopReaderKeyNavigation.FIRST
+ "last" -> DesktopReaderKeyNavigation.LAST
+ "search" -> DesktopReaderKeyNavigation.SEARCH
+ "nextSearch" -> DesktopReaderKeyNavigation.NEXT_SEARCH
+ "exitFullscreen" -> DesktopReaderKeyNavigation.EXIT_FULLSCREEN
+ else -> null
+ }
+ }.getOrNull()
+
+ parse(this)?.let { return it }
+ return runCatching {
+ Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
+ }.getOrNull()?.let { parse(it) }
+}
+
+internal fun String.readerLinkClickOrNull(): DesktopEpubLinkClick? {
+ fun parse(rawJson: String): DesktopEpubLinkClick? = runCatching {
+ val obj = Json.parseToJsonElement(rawJson).jsonObject
+ val href = obj["href"]
+ ?.takeUnless { it is JsonNull }
+ ?.jsonPrimitive
+ ?.contentOrNull
+ ?.takeIf { it.isNotBlank() }
+ ?: return@runCatching null
+ DesktopEpubLinkClick(
+ href = href,
+ chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
+ text = obj["text"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
+ chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
+ chapterHref = obj["chapterHref"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
+ )
+ }.getOrNull()
+
+ parse(this)?.let { return it }
+ return runCatching {
+ Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
+ }.getOrNull()?.let { parse(it) }
+}
+
+internal fun String.readerLinkClickFromIntercept(): DesktopEpubLinkClick? {
+ val trimmed = trim()
+ if (trimmed.startsWith("readerlink:", ignoreCase = true)) {
+ logEpubLink("request_intercept_readerlink raw=\"${trimmed.logPreview()}\"")
+ val payload = trimmed.substringAfter("?", missingDelimiterValue = "")
+ .split('&')
+ .firstOrNull { it.substringBefore("=").equals("payload", ignoreCase = true) }
+ ?.substringAfter("=", missingDelimiterValue = "")
+ ?.takeIf { it.isNotBlank() }
+ if (payload == null) {
+ logEpubLink("request_intercept_readerlink_ignored reason=missing_payload")
+ return null
+ }
+ val decoded = runCatching {
+ URLDecoder.decode(payload, Charsets.UTF_8.name())
+ }.getOrElse {
+ logEpubLink("request_intercept_payload_decode_failed error=\"${it.message.orEmpty().logPreview()}\"")
+ return null
+ }
+ val link = decoded.readerLinkClickOrNull()?.copy(source = "request")
+ if (link == null) {
+ logEpubLink("request_intercept_readerlink_ignored reason=parse_failed payload=\"${decoded.logPreview()}\"")
+ }
+ return link
+ }
+ return readerHrefFromIntercept()?.let { href ->
+ DesktopEpubLinkClick(
+ href = href,
+ chapterIndex = null,
+ source = "request"
+ )
+ }
+}
+
+private fun String.readerHrefFromIntercept(): String? {
+ val trimmed = trim()
+ if (trimmed.isBlank()) return null
+ if (trimmed.equals("about:blank", ignoreCase = true)) return null
+ if (trimmed.startsWith("file:/", ignoreCase = true)) return null
+ if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}"
+ if (trimmed.startsWith("data:", ignoreCase = true)) return null
+ if (trimmed.startsWith("blob:", ignoreCase = true)) return null
+ return trimmed
+}
+
+internal fun ReaderLocator.toReaderLocatorJson(): String {
+ return buildString {
+ append("{")
+ val values = buildList {
+ chapterIndex?.let { add("\"chapterIndex\":$it") }
+ chapterId?.let { add("\"chapterId\":${it.toJsonStringLiteral()}") }
+ href?.let { add("\"href\":${it.toJsonStringLiteral()}") }
+ pageIndex?.let { add("\"pageIndex\":$it") }
+ startOffset?.let { add("\"startOffset\":$it") }
+ endOffset?.let { add("\"endOffset\":$it") }
+ blockIndex?.let { add("\"blockIndex\":$it") }
+ charOffset?.let { add("\"charOffset\":$it") }
+ cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") }
+ textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") }
+ }
+ append(values.joinToString(","))
+ append("}")
+ }
+}
+
+private fun String.toJsonStringLiteral(): String {
+ val builder = StringBuilder("\"")
+ forEach { char ->
+ when (char) {
+ '\\' -> builder.append("\\\\")
+ '"' -> builder.append("\\\"")
+ '\n' -> builder.append("\\n")
+ '\r' -> builder.append("\\r")
+ '\t' -> builder.append("\\t")
+ '\b' -> builder.append("\\b")
+ '\u000C' -> builder.append("\\f")
+ else -> {
+ if (char.code < 0x20) {
+ builder.append("\\u")
+ builder.append(char.code.toString(16).padStart(4, '0'))
+ } else {
+ builder.append(char)
+ }
+ }
+ }
+ }
+ builder.append('"')
+ return builder.toString()
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt
new file mode 100644
index 0000000..e5e9e62
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt
@@ -0,0 +1,12 @@
+package org.dueattendant149.bookreader.desktop
+
+import org.dueattendant149.bookreader.shared.FileType
+import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
+import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoader
+import java.io.File
+
+object DesktopEpubLoader {
+ fun load(file: File): SharedEpubBook {
+ return SharedJvmBookLoader.load(file, FileType.EPUB)
+ }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt
new file mode 100644
index 0000000..7d7b5de
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt
@@ -0,0 +1,105 @@
+package org.dueattendant149.bookreader.desktop
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.dueattendant149.bookreader.shared.reader.ReaderLayoutSignature
+import org.dueattendant149.bookreader.shared.reader.ReaderPage
+import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
+import org.dueattendant149.bookreader.shared.reader.ReaderViewportSpec
+import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
+
+internal data class DesktopEpubPaginationRequest(
+ val bookId: String,
+ val chapterSignature: Int,
+ val layoutSignature: ReaderLayoutSignature,
+ val viewport: ReaderViewportSpec,
+ val density: DesktopEpubPaginationDensity,
+ val cacheGeneration: Int
+)
+
+internal data class DesktopEpubPaginationDensity(
+ val density: Float,
+ val fontScale: Float
+)
+
+internal fun desktopMeasuredPaginationReady(
+ request: DesktopEpubPaginationRequest?,
+ completedRequest: DesktopEpubPaginationRequest?,
+ currentPages: List,
+ measuredPages: List
+): Boolean {
+ return request != null &&
+ completedRequest == request &&
+ measuredPages.isNotEmpty() &&
+ currentPages.samePageLayoutAs(measuredPages)
+}
+
+internal fun desktopPaginatedLayoutReadyForDisplay(
+ readingMode: ReaderReadingMode,
+ measuredPagesApplied: Boolean
+): Boolean {
+ return readingMode != ReaderReadingMode.PAGINATED || measuredPagesApplied
+}
+
+internal fun desktopPagesWithMeasuredChapter(
+ currentPages: List,
+ chapterIndex: Int,
+ measuredChapterPages: List
+): List {
+ if (currentPages.isEmpty() || measuredChapterPages.isEmpty()) return currentPages
+ val firstChapterPage = currentPages.indexOfFirst { it.chapterIndex == chapterIndex }
+ if (firstChapterPage < 0) return currentPages
+ val lastChapterPage = currentPages.indexOfLast { it.chapterIndex == chapterIndex }
+ val combined = currentPages.take(firstChapterPage) +
+ measuredChapterPages +
+ currentPages.drop(lastChapterPage + 1)
+ return combined.mapIndexed { index, page -> page.copy(pageIndex = index) }
+}
+
+internal fun List.firstPageIndexForChapter(chapterIndex: Int): Int? {
+ return indexOfFirst { it.chapterIndex == chapterIndex }.takeIf { it >= 0 }
+}
+
+internal fun SharedEpubBook.desktopPaginationContentSignature(): Int {
+ return chapters.fold(31 * id.hashCode() + css.hashCode()) { acc, chapter ->
+ 31 * acc +
+ chapter.id.hashCode() +
+ chapter.plainText.length +
+ chapter.plainText.hashCode() +
+ chapter.semanticBlocks.hashCode() +
+ chapter.htmlContent.length +
+ chapter.htmlContent.hashCode() +
+ chapter.baseHref.orEmpty().hashCode()
+ }
+}
+
+@Composable
+internal fun DesktopEpubPaginationPreparing(
+ active: Boolean,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ CircularProgressIndicator()
+ Text(
+ if (active) "Preparing pages" else "Measuring reader layout",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt
new file mode 100644
index 0000000..8013732
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt
@@ -0,0 +1,279 @@
+package org.dueattendant149.bookreader.desktop
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
+import org.dueattendant149.bookreader.shared.ReaderLocator
+import org.dueattendant149.bookreader.shared.UserHighlight
+import org.dueattendant149.bookreader.shared.ui.ReaderContentNavigationTarget
+import kotlinx.coroutines.launch
+
+@Composable
+internal fun DesktopEpubWebView(
+ html: String,
+ appearanceScript: String,
+ highlightPaletteScript: String,
+ navigationTarget: ReaderContentNavigationTarget,
+ highlights: List,
+ onHighlightCreated: (UserHighlight) -> Unit,
+ onHighlightSelected: (String) -> Unit,
+ isFullscreen: Boolean,
+ onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit,
+ onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit,
+ onLinkClicked: (DesktopEpubLinkClick) -> Unit,
+ onVisiblePageChanged: (Int, ReaderLocator?) -> Unit,
+ onPointerActivity: () -> Unit = {},
+ networkAccessEnabled: Boolean,
+ backgroundColor: Color,
+ modifier: Modifier = Modifier
+) {
+ val backend = desktopEpubWebViewBackend()
+ LaunchedEffect(html, networkAccessEnabled, highlights.size, navigationTarget.readingMode, backend) {
+ logDesktopWebView2(
+ "backend_selected backend=${backend.logName} htmlChars=${html.length} htmlHash=${html.hashCode()} " +
+ "network=$networkAccessEnabled highlights=${highlights.size} navMode=${navigationTarget.readingMode}"
+ )
+ logDesktopReaderOpenTrace {
+ "event=desktop_webview_selected backend=${backend.logName} htmlChars=${html.length} " +
+ "htmlHash=${html.hashCode()} network=$networkAccessEnabled highlights=${highlights.size} " +
+ "navMode=${navigationTarget.readingMode}"
+ }
+ }
+ DesktopNativeSwtEpubWebView(
+ html = html,
+ appearanceScript = appearanceScript,
+ highlightPaletteScript = highlightPaletteScript,
+ navigationTarget = navigationTarget,
+ highlights = highlights,
+ onHighlightCreated = onHighlightCreated,
+ onHighlightSelected = onHighlightSelected,
+ isFullscreen = isFullscreen,
+ onKeyboardNavigation = onKeyboardNavigation,
+ onSelectionAction = onSelectionAction,
+ onLinkClicked = onLinkClicked,
+ onVisiblePageChanged = onVisiblePageChanged,
+ onPointerActivity = onPointerActivity,
+ networkAccessEnabled = networkAccessEnabled,
+ backgroundColor = backgroundColor,
+ modifier = modifier
+ )
+}
+
+internal data class DesktopEpubBridgeHandler(
+ val methodName: String,
+ val onMessage: (String) -> Unit
+)
+
+@Composable
+internal fun rememberDesktopEpubBridgeHandlers(
+ onHighlightCreated: (UserHighlight) -> Unit,
+ onHighlightSelected: (String) -> Unit,
+ onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit,
+ onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit,
+ onLinkClicked: (DesktopEpubLinkClick) -> Unit,
+ onVisiblePageChanged: (Int, ReaderLocator?) -> Unit,
+ onPointerActivity: () -> Unit
+): List