diff --git a/AGENTS.md b/AGENTS.md
index 6f0bc26..600d948 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -62,15 +62,39 @@
## 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 — 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
+- 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
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0c234e9..cbfa2a8 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -11,6 +11,7 @@ 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)
}
@@ -46,7 +47,9 @@ fun configuredAppLocaleTags(): Set {
}
kotlin {
- jvmToolchain(21)
+ jvmToolchain {
+ languageVersion.set(JavaLanguageVersion.of(21))
+ }
}
android {
@@ -64,11 +67,13 @@ android {
.map { it.toAndroidResourceConfiguration() }
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+/*
externalNativeBuild {
cmake {
cppFlags += ""
}
}
+*/
buildConfigField("boolean", "IS_PRO", "false")
buildConfigField("boolean", "IS_OFFLINE", "false")
}
@@ -162,12 +167,14 @@ android {
singleVariant("release") {
}
}
+/*
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
+*/
testOptions {
unitTests.isReturnDefaultValues = true
unitTests.all {
@@ -211,8 +218,6 @@ kover {
//noinspection UseTomlInstead
dependencies {
- implementation(project(":shared"))
-
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
@@ -224,6 +229,16 @@ 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 dd9963e..8429f96 100644
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt
@@ -52,6 +52,8 @@ 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
@@ -59,6 +61,7 @@ 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
@@ -79,6 +82,8 @@ 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(
@@ -427,6 +432,34 @@ 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
deleted file mode 100644
index 198c806..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-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
new file mode 100644
index 0000000..5cd4c5f
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTraceStub.kt
@@ -0,0 +1,37 @@
+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
deleted file mode 100644
index e4e67cd..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt
+++ /dev/null
@@ -1,773 +0,0 @@
-/*
- * 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
new file mode 100644
index 0000000..0e2cd6a
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorkerStub.kt
@@ -0,0 +1,28 @@
+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 fa79d21..aec1d24 100644
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt
@@ -80,6 +80,7 @@ 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
@@ -143,14 +144,7 @@ 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
@@ -192,9 +186,6 @@ 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(
@@ -378,17 +369,10 @@ fun LibraryScreen(
viewModel.showBanner(context.getString(R.string.banner_downloaded, title))
viewModel.onFileSelected(uri, isFromRecent = false)
},
- onStreamOpdsBook = { entry, catalog ->
- viewModel.streamOpdsBook(
- bookId = entry.id,
- title = entry.title,
- urlTemplate = entry.pseUrlTemplate!!,
- pageCount = entry.pseCount!!,
- catalogId = catalog?.id
- )
- },
- onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog,
+ onStreamOpdsBook = { _, _ -> },
+ onDeleteCatalogStreams = { },
onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) },
+ onBookshelfClick = { navController.navigate(AppDestinations.BOOKSHELF_LIBRARY_ROUTE) },
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
)
@@ -660,9 +644,10 @@ fun LibraryScreenContent(
onRemoveFolderClick: (SyncedFolder) -> Unit,
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onOpdsBookDownloaded: (Uri, String) -> Unit,
- onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
+ onStreamOpdsBook: (Any, Any?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit,
onSettingsClick: () -> Unit,
+ onBookshelfClick: () -> Unit,
usePdfFileNameAsDisplayName: Boolean,
) {
val isBookContextualModeActive = selectedItems.isNotEmpty()
@@ -804,6 +789,9 @@ 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))
}
@@ -959,30 +947,6 @@ 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
- )
- }
- }
}
}
}
@@ -2015,395 +1979,7 @@ 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,
@@ -2531,889 +2107,3 @@ 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 20d419c..56d19a3 100644
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt
@@ -24,9 +24,11 @@ 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
deleted file mode 100644
index 2f53581..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-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
deleted file mode 100644
index d8af46f..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-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
deleted file mode 100644
index b19d280..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt
+++ /dev/null
@@ -1,214 +0,0 @@
-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
deleted file mode 100644
index 55388c2..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt
+++ /dev/null
@@ -1,172 +0,0 @@
-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 94c0e7d..912a2d4 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,20 +783,7 @@ class OpdsStreamDocumentWrapper(
) : ReaderDocument {
private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() }
- 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 val client = okhttp3.OkHttpClient.Builder().build()
private fun createErrorPageBytes(): ByteArray {
val bitmap = createBitmap(800, 1200)
@@ -827,18 +814,7 @@ class OpdsStreamDocumentWrapper(
}
}
- 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 finalUrlTemplate = 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
new file mode 100644
index 0000000..a7e4dda
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Shapes.kt
@@ -0,0 +1,16 @@
+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 080c31c..daf31e4 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,6 +178,7 @@ 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
new file mode 100644
index 0000000..a668bc2
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsScreen.kt
@@ -0,0 +1,144 @@
+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
new file mode 100644
index 0000000..eaaf09a
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsViewModel.kt
@@ -0,0 +1,58 @@
+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
new file mode 100644
index 0000000..c3b4432
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlaybackService.kt
@@ -0,0 +1,118 @@
+/*
+ * 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
new file mode 100644
index 0000000..bcf6258
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlayerProgress.kt
@@ -0,0 +1,29 @@
+/*
+ * 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
new file mode 100644
index 0000000..62de39f
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioTrack.kt
@@ -0,0 +1,8 @@
+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
new file mode 100644
index 0000000..381f7be
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/audio/Book.kt
@@ -0,0 +1,18 @@
+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
new file mode 100644
index 0000000..aa8b896
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt
@@ -0,0 +1,222 @@
+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
new file mode 100644
index 0000000..072d823
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt
@@ -0,0 +1,176 @@
+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
new file mode 100644
index 0000000..db19620
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt
@@ -0,0 +1,76 @@
+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
new file mode 100644
index 0000000..1322334
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiService.kt
@@ -0,0 +1,52 @@
+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
new file mode 100644
index 0000000..266ab4d
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfRepository.kt
@@ -0,0 +1,72 @@
+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
new file mode 100644
index 0000000..6367890
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt
@@ -0,0 +1,21 @@
+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
new file mode 100644
index 0000000..3bbb58d
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/model/AbsItemResponse.kt
@@ -0,0 +1,35 @@
+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
new file mode 100644
index 0000000..9a035ae
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiClientFactory.kt
@@ -0,0 +1,62 @@
+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
new file mode 100644
index 0000000..050c12f
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt
@@ -0,0 +1,158 @@
+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
new file mode 100644
index 0000000..1541afe
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt
@@ -0,0 +1,158 @@
+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
new file mode 100644
index 0000000..fec67b8
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt
@@ -0,0 +1,61 @@
+/*
+ * 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
new file mode 100644
index 0000000..64f8727
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/AudioTrackResponse.kt
@@ -0,0 +1,13 @@
+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
new file mode 100644
index 0000000..bf2a1d1
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/BookItemResponse.kt
@@ -0,0 +1,19 @@
+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
new file mode 100644
index 0000000..f7bb64e
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/DownloadResponses.kt
@@ -0,0 +1,75 @@
+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
new file mode 100644
index 0000000..af4ce25
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/LibraryResponse.kt
@@ -0,0 +1,14 @@
+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
new file mode 100644
index 0000000..8776e6f
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt
@@ -0,0 +1,20 @@
+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
new file mode 100644
index 0000000..fb411a5
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt
@@ -0,0 +1,20 @@
+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
new file mode 100644
index 0000000..6ecc8d4
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/SearchResponse.kt
@@ -0,0 +1,54 @@
+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
new file mode 100644
index 0000000..6f6cbe4
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/TtsResponses.kt
@@ -0,0 +1,91 @@
+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
new file mode 100644
index 0000000..d157d2b
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UnifiedItemResponse.kt
@@ -0,0 +1,67 @@
+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
new file mode 100644
index 0000000..755c7e8
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UploadBookResponse.kt
@@ -0,0 +1,14 @@
+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
new file mode 100644
index 0000000..4bb5b4e
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/data/settings/ServerSettings.kt
@@ -0,0 +1,44 @@
+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
new file mode 100644
index 0000000..9f8b78b
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt
@@ -0,0 +1,53 @@
+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
new file mode 100644
index 0000000..9398e1b
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/di/NetworkModule.kt
@@ -0,0 +1,34 @@
+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
new file mode 100644
index 0000000..5434d28
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/di/PlaybackModule.kt
@@ -0,0 +1,20 @@
+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
new file mode 100644
index 0000000..feedbf8
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/domain/util/UriUtils.kt
@@ -0,0 +1,76 @@
+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
new file mode 100644
index 0000000..7d8b08f
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/reader/ChapterDrawer.kt
@@ -0,0 +1,95 @@
+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
new file mode 100644
index 0000000..30c2016
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/reader/Checkpoint.kt
@@ -0,0 +1,13 @@
+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
new file mode 100644
index 0000000..c8399df
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/rsvp/ReaderText.kt
@@ -0,0 +1,9 @@
+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
new file mode 100644
index 0000000..8bf2271
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpEngine.kt
@@ -0,0 +1,173 @@
+/*
+ * 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
new file mode 100644
index 0000000..69edbeb
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpToken.kt
@@ -0,0 +1,54 @@
+/*
+ * 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
new file mode 100644
index 0000000..b23d76b
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpTokenizer.kt
@@ -0,0 +1,136 @@
+/*
+ * 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
new file mode 100644
index 0000000..0f5c2e6
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepository.kt
@@ -0,0 +1,14 @@
+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
new file mode 100644
index 0000000..ce836e0
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepositoryImpl.kt
@@ -0,0 +1,23 @@
+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
new file mode 100644
index 0000000..a632e0f
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/tts/TtsModels.kt
@@ -0,0 +1,50 @@
+/*
+ * 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
new file mode 100644
index 0000000..b487f14
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookreader/work/Workers.kt
@@ -0,0 +1,35 @@
+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/shared/src/commonMain/kotlin/androidx/compose/material/icons/Icons.kt b/app/src/main/kotlin/androidx/compose/material/icons/Icons.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/androidx/compose/material/icons/Icons.kt
rename to app/src/main/kotlin/androidx/compose/material/icons/Icons.kt
diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt b/app/src/main/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt
rename to app/src/main/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt
diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt b/app/src/main/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt
rename to app/src/main/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt
diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt b/app/src/main/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt
rename to app/src/main/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt
diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParser.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParser.kt
similarity index 100%
rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParser.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParser.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt
similarity index 99%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt
index ddff77e..2ea3920 100644
--- a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt
+++ b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt
@@ -160,8 +160,6 @@ private fun ImportedFile.toImportedBookFile(): ImportedBookFile {
)
}
-expect fun currentTimestamp(): Long
-
fun String.toFileType(): FileType {
return SharedFileCapabilities.fileTypeForName(this)
}
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt
similarity index 99%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt
index 3cceb68..42c244e 100644
--- a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt
+++ b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt
@@ -19,7 +19,10 @@ const val LOCAL_FOLDER_SYNC_DATA_DIR = "EpistemeSyncData"
const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations"
const val LOCAL_FOLDER_SIDECAR_HASH_PREFIX = "book_"
-internal expect fun localFolderSyncSha256ShortHex(value: String): String
+fun localFolderSyncSha256ShortHex(value: String): String {
+ val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
+ return bytes.joinToString("") { "%02x".format(it) }.take(12)
+}
fun localFolderSyncSidecarStem(bookId: String): String {
return LOCAL_FOLDER_SIDECAR_HASH_PREFIX + localFolderSyncSha256ShortHex(bookId)
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt
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
new file mode 100644
index 0000000..21048f6
--- /dev/null
+++ b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.kt
@@ -0,0 +1,3 @@
+package org.dueattendant149.bookreader.shared
+
+fun currentTimestamp(): Long = System.currentTimeMillis()
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt
diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManager.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManager.kt
similarity index 100%
rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManager.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManager.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderWordReplacements.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderWordReplacements.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderWordReplacements.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderWordReplacements.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt
diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParser.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParser.kt
similarity index 100%
rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParser.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParser.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt
diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/pptx/SharedPptxDocument.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pptx/SharedPptxDocument.kt
similarity index 100%
rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/pptx/SharedPptxDocument.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pptx/SharedPptxDocument.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModels.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistory.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistory.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistory.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistory.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderModels.kt
diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditor.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditor.kt
similarity index 100%
rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditor.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditor.kt
diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCache.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCache.kt
similarity index 100%
rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCache.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCache.kt
diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCache.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCache.kt
similarity index 100%
rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCache.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCache.kt
diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt
similarity index 100%
rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt
diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt
similarity index 100%
rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt
diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt
similarity index 100%
rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt
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
new file mode 100644
index 0000000..7027816
--- /dev/null
+++ b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt
@@ -0,0 +1,27 @@
+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/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderTextAlignment.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderTextAlignment.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderTextAlignment.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderTextAlignment.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedTextBookFactory.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedTextBookFactory.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedTextBookFactory.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedTextBookFactory.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SimplePaginator.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SimplePaginator.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SimplePaginator.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SimplePaginator.kt
diff --git a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.android.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt
similarity index 95%
rename from shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.android.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt
index bea2290..035461c 100644
--- a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.android.kt
+++ b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt
@@ -9,7 +9,7 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
@Composable
-internal actual fun LocalBookCoverImage(
+internal fun LocalBookCoverImage(
path: String,
contentDescription: String?,
modifier: Modifier
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModels.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderScreens.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderScreens.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderScreens.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderScreens.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderContentRenderPlan.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderContentRenderPlan.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderContentRenderPlan.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderContentRenderPlan.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSlider.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSlider.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSlider.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSlider.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderTooltips.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderTooltips.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderTooltips.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderTooltips.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModels.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModels.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModels.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceShell.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceShell.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceShell.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceShell.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppShell.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppShell.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppShell.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppShell.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeSettings.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeSettings.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeSettings.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeSettings.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedLibraryDialogs.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedLibraryDialogs.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedLibraryDialogs.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedLibraryDialogs.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedMarkdownText.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedMarkdownText.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedMarkdownText.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedMarkdownText.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReader.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReader.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReader.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReader.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedOpdsScreen.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedOpdsScreen.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedOpdsScreen.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedOpdsScreen.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUi.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUi.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUi.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUi.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfRichTextUi.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfRichTextUi.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfRichTextUi.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfRichTextUi.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderChrome.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderChrome.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderChrome.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderChrome.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt
similarity index 79%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt
index b1730a5..8038072 100644
--- a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt
+++ b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt
@@ -2,6 +2,8 @@ package org.dueattendant149.bookreader.shared.ui
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.Composable
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -40,20 +42,32 @@ fun sharedReaderPopupWidth(
return (availableWidth * widthFraction.coerceIn(0f, 1f)).coerceIn(lowerBound, upperBound)
}
+
@Composable
-internal expect fun SharedReaderModalLayer(
+internal fun SharedReaderModalLayer(
onDismiss: () -> Unit,
level: SharedReaderModalLevel = SharedReaderModalLevel.Popup,
content: @Composable () -> Unit
-)
+) {
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ content()
+ }
+}
-internal expect fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean
+internal fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean {
+ return false
+}
@Composable
-expect fun SharedReaderModalOwnerWindowProvider(
+fun SharedReaderModalOwnerWindowProvider(
ownerWindow: Any?,
content: @Composable () -> Unit
-)
+) {
+ content()
+}
@Composable
fun SharedReaderPopupLayer(
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderScrollbars.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderScrollbars.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderScrollbars.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderScrollbars.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderTtsOverlayControls.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderTtsOverlayControls.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderTtsOverlayControls.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderTtsOverlayControls.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacement.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacement.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacement.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacement.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSettingsHub.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSettingsHub.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSettingsHub.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSettingsHub.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStableTextFields.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStableTextFields.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStableTextFields.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStableTextFields.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStrings.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStrings.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStrings.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStrings.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUiTokens.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUiTokens.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUiTokens.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUiTokens.kt
diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUtilityScreens.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUtilityScreens.kt
similarity index 100%
rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUtilityScreens.kt
rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUtilityScreens.kt
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f8c6274..4db678c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2066,4 +2066,6 @@
Reader text, highlights, and locations stay unchanged.
%1$s -> %2$s
+ Audio Playback
+ Audio playback controls
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisionsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisionsTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisionsTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisionsTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/EpubAnnotationSerializerTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/EpubAnnotationSerializerTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/EpubAnnotationSerializerTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/EpubAnnotationSerializerTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilitiesTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilitiesTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilitiesTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilitiesTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInferenceTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInferenceTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInferenceTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInferenceTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSyncEngineTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSyncEngineTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSyncEngineTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSyncEngineTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderActionReducerTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderActionReducerTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderActionReducerTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderActionReducerTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModelsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModelsTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModelsTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModelsTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderBookReplacementEngineTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderBookReplacementEngineTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderBookReplacementEngineTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderBookReplacementEngineTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderDefaultSettingsStateTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderDefaultSettingsStateTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderDefaultSettingsStateTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderDefaultSettingsStateTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModelsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModelsTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModelsTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModelsTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownParserTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownParserTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownParserTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownParserTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarPreferencesTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarPreferencesTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarPreferencesTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarPreferencesTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacementEngineTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacementEngineTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacementEngineTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacementEngineTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModelsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModelsTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModelsTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModelsTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedAppThemeReducerTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedAppThemeReducerTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedAppThemeReducerTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedAppThemeReducerTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedImportPlannerTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedImportPlannerTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedImportPlannerTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedImportPlannerTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinksTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinksTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinksTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinksTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryEditorTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryEditorTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryEditorTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryEditorTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryProjectorTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryProjectorTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryProjectorTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryProjectorTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshotJsonTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshotJsonTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshotJsonTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshotJsonTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducersTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducersTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducersTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducersTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngineTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngineTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngineTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngineTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogsTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogsTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogsTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsControllerTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsControllerTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsControllerTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsControllerTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSessionTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSessionTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSessionTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSessionTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometryTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometryTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometryTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometryTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayoutTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayoutTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayoutTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayoutTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapperTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapperTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapperTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapperTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRenderingTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRenderingTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRenderingTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRenderingTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflowTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflowTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflowTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflowTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichTextTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichTextTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichTextTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichTextTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngineTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngineTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngineTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngineTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModelsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModelsTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModelsTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModelsTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistoryTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistoryTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistoryTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistoryTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderSpreadLayoutTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderSpreadLayoutTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderSpreadLayoutTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderSpreadLayoutTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModelsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModelsTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModelsTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModelsTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSliderTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSliderTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSliderTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSliderTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModelsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModelsTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModelsTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModelsTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeColorMathTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeColorMathTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeColorMathTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeColorMathTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUiTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUiTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUiTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUiTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalSizingTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalSizingTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalSizingTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalSizingTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacementTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacementTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacementTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacementTest.kt
diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStringsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStringsTest.kt
similarity index 100%
rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStringsTest.kt
rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStringsTest.kt
diff --git a/build.gradle.kts b/build.gradle.kts
index 5568021..90f1ee0 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -3,9 +3,10 @@ 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.compose.multiplatform) 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.kover) apply false
}
@@ -18,7 +19,6 @@ subprojects {
val rootTest = rootProject.tasks.named("test")
tasks.matching {
it.name == "allTests" ||
- it.name == "desktopTest" ||
it.name.endsWith("DebugUnitTest")
}.configureEach {
rootTest.configure {
@@ -29,3 +29,14 @@ 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
deleted file mode 100644
index e0cd987..0000000
--- a/desktopApp/build.gradle.kts
+++ /dev/null
@@ -1,1621 +0,0 @@
-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
deleted file mode 100644
index d3805a8..0000000
--- a/desktopApp/compose-desktop.pro
+++ /dev/null
@@ -1,26 +0,0 @@
--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
deleted file mode 100644
index 65cd664..0000000
--- a/desktopApp/packaging/README.md
+++ /dev/null
@@ -1,229 +0,0 @@
-# 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
deleted file mode 100644
index 03181c1..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt
+++ /dev/null
@@ -1,138 +0,0 @@
-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
deleted file mode 100644
index 5a5dfdf..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt
+++ /dev/null
@@ -1,1023 +0,0 @@
-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
deleted file mode 100644
index 391979b..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt
+++ /dev/null
@@ -1,338 +0,0 @@
-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
deleted file mode 100644
index ac63f0b..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt
+++ /dev/null
@@ -1,703 +0,0 @@
-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
deleted file mode 100644
index 686e4ae..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt
+++ /dev/null
@@ -1,159 +0,0 @@
-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
deleted file mode 100644
index c58f3df..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-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
deleted file mode 100644
index 55268cf..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt
+++ /dev/null
@@ -1,109 +0,0 @@
-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
deleted file mode 100644
index 4055007..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt
+++ /dev/null
@@ -1,85 +0,0 @@
-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
deleted file mode 100644
index e51f5dc..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt
+++ /dev/null
@@ -1,342 +0,0 @@
-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
deleted file mode 100644
index 2ca06c6..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt
+++ /dev/null
@@ -1,85 +0,0 @@
-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
deleted file mode 100644
index 5b3ea79..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt
+++ /dev/null
@@ -1,683 +0,0 @@
-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
deleted file mode 100644
index 31f86bb..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSidecarSync.kt
+++ /dev/null
@@ -1,311 +0,0 @@
-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
deleted file mode 100644
index 382ac97..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt
+++ /dev/null
@@ -1,1204 +0,0 @@
-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
deleted file mode 100644
index 4ac30f5..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-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
deleted file mode 100644
index 0b2b0e9..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-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
deleted file mode 100644
index ab7ad33..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt
+++ /dev/null
@@ -1,614 +0,0 @@
-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
deleted file mode 100644
index 54a86b9..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt
+++ /dev/null
@@ -1,162 +0,0 @@
-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
deleted file mode 100644
index ded1895..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-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
deleted file mode 100644
index 9884b1e..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt
+++ /dev/null
@@ -1,373 +0,0 @@
-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
deleted file mode 100644
index e5e9e62..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-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
deleted file mode 100644
index 7d7b5de..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-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
deleted file mode 100644
index 8013732..0000000
--- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt
+++ /dev/null
@@ -1,279 +0,0 @@
-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