Compare commits

..

No commits in common. "8e94eb3850a959cbb0bfd3cd25ebba1a6dc5e08b" and "5f64f3d722e635ac8ae50c5b24e9994fe25128b7" have entirely different histories.

385 changed files with 53192 additions and 2964 deletions

View file

@ -62,39 +62,15 @@
## Build ## Build
```bash ```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 ./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 ## Status
- Phase 0: clone + package rename — completed - Phase 0: clone + package rename — completed
- Phase 1: KMP → Android-only — completed - Phase 1: KMP → Android-only — pending
- Phase 2: backend swap (bookshelf-api + ABS) — completed - Phase 2: backend swap (bookshelf-api + ABS) — pending
- Phase 2.1: bookshelf-api + ABS API services/models, Hilt DI, Retrofit/OkHttp - Phase 3: UI redesign (Myne-style) — pending
- Phase 2.2: BookshelfViewModel + BookshelfLibraryScreen + nav entry - Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — pending
- Phase 2.3: ServerSettingsScreen (URL/token config) - Phase 5: reader engine integration — pending
- Phase 2.4: download/open ebook from server in reader - Phase 6: build, test, deploy — pending
- 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

View file

@ -11,7 +11,6 @@ plugins {
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.ksp) alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.hilt)
id("com.diffplug.spotless") version "8.2.1" id("com.diffplug.spotless") version "8.2.1"
alias(libs.plugins.kover) alias(libs.plugins.kover)
} }
@ -47,9 +46,7 @@ fun configuredAppLocaleTags(): Set<String> {
} }
kotlin { kotlin {
jvmToolchain { jvmToolchain(21)
languageVersion.set(JavaLanguageVersion.of(21))
}
} }
android { android {
@ -67,13 +64,11 @@ android {
.map { it.toAndroidResourceConfiguration() } .map { it.toAndroidResourceConfiguration() }
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
/*
externalNativeBuild { externalNativeBuild {
cmake { cmake {
cppFlags += "" cppFlags += ""
} }
} }
*/
buildConfigField("boolean", "IS_PRO", "false") buildConfigField("boolean", "IS_PRO", "false")
buildConfigField("boolean", "IS_OFFLINE", "false") buildConfigField("boolean", "IS_OFFLINE", "false")
} }
@ -167,14 +162,12 @@ android {
singleVariant("release") { singleVariant("release") {
} }
} }
/*
externalNativeBuild { externalNativeBuild {
cmake { cmake {
path = file("src/main/cpp/CMakeLists.txt") path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1" version = "3.22.1"
} }
} }
*/
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
unitTests.all { unitTests.all {
@ -218,6 +211,8 @@ kover {
//noinspection UseTomlInstead //noinspection UseTomlInstead
dependencies { dependencies {
implementation(project(":shared"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@ -229,16 +224,6 @@ dependencies {
implementation(libs.androidx.material3.window.size.class1.android) implementation(libs.androidx.material3.window.size.class1.android)
implementation(libs.androidx.credentials) 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.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))

View file

@ -52,8 +52,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
@ -61,7 +59,6 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import org.dueattendant149.bookreader.bookshelf.BookshelfLibraryScreen
import org.dueattendant149.bookreader.epubreader.EpubReaderScreen import org.dueattendant149.bookreader.epubreader.EpubReaderScreen
import org.dueattendant149.bookreader.feedback.FeedbackScreen import org.dueattendant149.bookreader.feedback.FeedbackScreen
import org.dueattendant149.bookreader.feedback.SupportProjectScreen import org.dueattendant149.bookreader.feedback.SupportProjectScreen
@ -82,8 +79,6 @@ object AppDestinations {
const val FONTS_SCREEN_ROUTE = "fonts_screen_route" const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route" const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
const val SETTINGS_SCREEN_ROUTE = "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( fun shouldInterceptAppNavBack(
@ -432,34 +427,6 @@ fun AppNavigation(
onBackClick = { navController.popBackStackIfReady() } 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( AnimatedVisibility(

View file

@ -0,0 +1,70 @@
package org.dueattendant149.bookreader
import android.util.Log
import org.dueattendant149.bookreader.data.BookMetadata
import org.dueattendant149.bookreader.data.RecentFileItem
import org.dueattendant149.bookreader.data.effectiveAnnotationModifiedTimestamp
import org.dueattendant149.bookreader.data.effectiveReadingPositionModifiedTimestamp
import timber.log.Timber
internal const val CloudSyncTraceTag = "EpistemeCloudSync"
internal const val CloudAnnotationSyncTraceTag = "EpistemeCloudAnnotations"
internal fun logCloudSyncTrace(message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.d(CloudSyncTraceTag, text)
Timber.tag(CloudSyncTraceTag).d(text)
}
internal fun logCloudSyncError(error: Throwable, message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.e(CloudSyncTraceTag, text, error)
Timber.tag(CloudSyncTraceTag).e(error, text)
}
internal fun logCloudAnnotationSyncTrace(message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.d(CloudAnnotationSyncTraceTag, text)
Timber.tag(CloudAnnotationSyncTraceTag).d(text)
}
internal fun logCloudAnnotationSyncError(error: Throwable, message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.e(CloudAnnotationSyncTraceTag, text, error)
Timber.tag(CloudAnnotationSyncTraceTag).e(error, text)
}
internal fun RecentFileItem.cloudSyncTraceSummary(prefix: String = "local"): String {
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
"contentTs=$fileContentModifiedTimestamp " +
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
"bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
}
internal fun BookMetadata.cloudSyncTraceSummary(prefix: String = "remote"): String {
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
"annTs=${effectiveAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
"hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
"highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
}
internal fun String?.cloudSyncPreview(maxLength: Int = 80): String {
val value = this ?: return "null"
return if (value.length <= maxLength) value else value.take(maxLength) + "..."
}
internal fun String?.cloudSyncAnnotationSummary(): String {
val value = this?.trim() ?: return "null"
return when {
value.isEmpty() -> "blank"
value == "[]" -> "empty"
else -> "present(${value.length})"
}
}

View file

@ -1,37 +0,0 @@
package org.dueattendant149.bookreader
/**
* Stubs for removed CloudSyncTrace.kt. All calls are no-ops.
*/
internal inline fun logCloudSyncTrace(crossinline message: () -> String) {
// no-op: cloud sync removed
}
internal inline fun logCloudSyncError(throwable: Throwable, crossinline message: () -> String) {
// no-op: cloud sync removed
}
internal inline fun logCloudSyncError(crossinline message: () -> String) {
// no-op: cloud sync removed
}
internal inline fun logCloudAnnotationSyncTrace(crossinline message: () -> String) {
// no-op: cloud annotation sync removed
}
internal inline fun logCloudAnnotationSyncError(throwable: Throwable, crossinline message: () -> String) {
// no-op: cloud annotation sync removed
}
internal fun Any?.cloudSyncTraceSummary(label: String = ""): String = ""
internal fun Any?.cloudSyncAnnotationSummary(): String = ""
internal fun Any?.cloudSyncPreview(): String = this?.toString() ?: ""
internal fun String.cloudSyncPreview(): String = this
internal fun String.cloudSyncPreview(maxLen: Int): String = take(maxLen)
internal fun String?.cloudSyncPreviewOrEmpty(): String = this ?: ""

View file

@ -0,0 +1,773 @@
/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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<MetadataExtractionWorker>()
.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<RecentFileItem>,
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<SharedFolderScannedFile> = emptyList(),
val dirsScanned: Int = 0,
val filesSeen: Int = 0,
val stoppedForUnlinkedFolder: Boolean = false
)
private fun scanFolderFiles(
folderUri: android.net.Uri,
folderUriString: String,
allowedFileTypes: Set<FileType>
): 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<String>()
val scannedFiles = mutableListOf<SharedFolderScannedFile>()
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<ReaderBookmark> {
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<ReaderBookmark>.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<String, FolderBookMetadata>,
preloadedSidecars: MutableMap<String, Pair<Long, String>>,
existingItemsMap: MutableMap<String, RecentFileItem>
) {
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
}
}
}

View file

@ -1,28 +0,0 @@
package org.dueattendant149.bookreader
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
/**
* Stub for removed FolderSyncWorker. Keeps constants and a no-op CoroutineWorker
* so existing WorkManager enqueue calls compile.
*/
const val KEY_METADATA_ONLY = "metadata_only"
const val KEY_TARGET_FOLDER_URI = "target_folder_uri"
const val KEY_TRIGGER_REASON = "trigger_reason"
class FolderSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = Result.success()
companion object {
const val WORK_NAME = "folder_sync_work"
const val WORK_NAME_ONETIME = "folder_sync_work_onetime"
const val KEY_METADATA_ONLY = "metadata_only"
const val KEY_TARGET_FOLDER_URI = "target_folder_uri"
const val KEY_TRIGGER_REASON = "trigger_reason"
}
}

View file

@ -24,11 +24,9 @@ import android.webkit.WebView
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import dagger.hilt.android.HiltAndroidApp
import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher
import timber.log.Timber // Add this import timber.log.Timber // Add this
@HiltAndroidApp
class MyApplication : Application(), ImageLoaderFactory { class MyApplication : Application(), ImageLoaderFactory {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()

View file

@ -0,0 +1,10 @@
package org.dueattendant149.bookreader.opds
typealias OpdsCatalog = org.dueattendant149.bookreader.shared.opds.OpdsCatalog
typealias OpdsFacet = org.dueattendant149.bookreader.shared.opds.OpdsFacet
typealias OpdsFeed = org.dueattendant149.bookreader.shared.opds.OpdsFeed
typealias OpdsAuthor = org.dueattendant149.bookreader.shared.opds.OpdsAuthor
typealias OpdsAcquisition = org.dueattendant149.bookreader.shared.opds.OpdsAcquisition
typealias OpdsEntry = org.dueattendant149.bookreader.shared.opds.OpdsEntry
typealias OpdsDownloadState = org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadState
typealias OpdsScreenState = org.dueattendant149.bookreader.shared.opds.SharedOpdsScreenState

View file

@ -0,0 +1,3 @@
package org.dueattendant149.bookreader.opds
typealias OpdsParser = org.dueattendant149.bookreader.shared.opds.SharedOpdsParser

View file

@ -0,0 +1,214 @@
package org.dueattendant149.bookreader.opds
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import org.dueattendant149.bookreader.shared.opds.SharedOpdsCatalogs
import org.dueattendant149.bookreader.shared.opds.SharedOpdsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import java.security.MessageDigest
import java.util.UUID
class OpdsRepository(context: Context) : SharedOpdsRepository {
private val prefs: SharedPreferences = context.getSharedPreferences("reader_opds_prefs", Context.MODE_PRIVATE)
private val parser = OpdsParser()
companion object {
private const val KEY_CATALOGS_JSON = "opds_catalogs_json"
val sharedHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor { chain ->
val originalRequest = chain.request()
val requestWithUserAgent = originalRequest.newBuilder()
.header("User-Agent", "EpistemeReader/1.0 (Android)")
.build()
chain.proceed(requestWithUserAgent)
}
.build()
}
}
private val httpClient = sharedHttpClient
override fun loadCatalogs(): List<OpdsCatalog> {
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<OpdsCatalog> = 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<OpdsCatalog>) {
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<OpdsFeed> = 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)
}
}
}

View file

@ -0,0 +1,172 @@
package org.dueattendant149.bookreader.opds
import android.app.Application
import android.content.Context
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import org.dueattendant149.bookreader.R
import org.dueattendant149.bookreader.shared.opds.SharedOpdsController
import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadNamer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Request
import okhttp3.Response
import timber.log.Timber
import java.io.File
import java.util.UUID
class OpdsViewModel(application: Application) : AndroidViewModel(application) {
private val repository = OpdsRepository(application)
private val controller = SharedOpdsController(
repository = repository,
feedLoadErrorMessage = { error ->
application.getString(R.string.opds_error_load_feed, error.message.orEmpty())
},
idFactory = { UUID.randomUUID().toString() }
)
private val _uiState = MutableStateFlow(controller.state)
val uiState: StateFlow<OpdsScreenState> = _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)
}

View file

@ -783,7 +783,20 @@ class OpdsStreamDocumentWrapper(
) : ReaderDocument { ) : ReaderDocument {
private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() } private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() }
private val client = okhttp3.OkHttpClient.Builder().build() private val catalog = catalogId?.let {
org.dueattendant149.bookreader.opds.OpdsRepository(context).getCatalogs().find { c -> c.id == it }
}
private val client = org.dueattendant149.bookreader.opds.OpdsRepository.sharedHttpClient.newBuilder()
.apply {
val streamCatalog = catalog
val username = streamCatalog?.username
val password = streamCatalog?.password
if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
authenticator(org.dueattendant149.bookreader.opds.OpdsRepository.OpdsAuthenticator(username, password))
}
}
.build()
private fun createErrorPageBytes(): ByteArray { private fun createErrorPageBytes(): ByteArray {
val bitmap = createBitmap(800, 1200) val bitmap = createBitmap(800, 1200)
@ -814,7 +827,18 @@ class OpdsStreamDocumentWrapper(
} }
} }
val finalUrlTemplate = urlTemplate val streamCatalog = catalog
val finalUrlTemplate = if (streamCatalog != null && urlTemplate.startsWith("http")) {
try {
val oldUrl = java.net.URL(urlTemplate)
val newUrl = java.net.URL(streamCatalog.url)
val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
val newBase = "${newUrl.protocol}://${newUrl.authority}"
urlTemplate.replace(oldBase, newBase)
} catch (_: Exception) {
urlTemplate
}
} else urlTemplate
val url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString()) val url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString())
.replace("{maxWidth}", "1600") .replace("{maxWidth}", "1600")

View file

@ -1,16 +0,0 @@
package org.dueattendant149.bookreader.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
/**
* Myne-style Material You 3 shape scheme: generous rounded corners.
*/
val AppShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp),
extraLarge = RoundedCornerShape(32.dp),
)

View file

@ -178,7 +178,6 @@ fun AppTheme(
MaterialTheme( MaterialTheme(
colorScheme = finalColorScheme, colorScheme = finalColorScheme,
typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography, typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography,
shapes = AppShapes,
content = content content = content
) )
} }

View file

@ -1,144 +0,0 @@
package org.dueattendant149.bookreader.bookshelf
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServerSettingsScreen(
viewModel: ServerSettingsViewModel,
onBackClick: () -> Unit,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var bookshelfUrl by remember(uiState.bookshelfUrl) { mutableStateOf(uiState.bookshelfUrl) }
var absUrl by remember(uiState.absUrl) { mutableStateOf(uiState.absUrl) }
var absToken by remember(uiState.absToken) { mutableStateOf(uiState.absToken) }
Scaffold(
topBar = {
androidx.compose.material3.CenterAlignedTopAppBar(
title = { Text("Server settings") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp, vertical = 8.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
SettingsSectionCard(title = "Bookshelf API") {
OutlinedTextField(
value = bookshelfUrl,
onValueChange = {
bookshelfUrl = it
viewModel.updateBookshelfUrl(it)
},
label = { Text("https://books.dueattendant149.org/") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
SettingsSectionCard(title = "Audiobookshelf") {
OutlinedTextField(
value = absUrl,
onValueChange = {
absUrl = it
viewModel.updateAbsUrl(it)
},
label = { Text("https://abs.dueattendant149.org/") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = absToken,
onValueChange = {
absToken = it
viewModel.updateAbsToken(it)
},
label = { Text("Bearer token") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
}
Button(
onClick = viewModel::save,
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large
) {
Text("Save")
}
if (uiState.saved) {
Text(
"Saved",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
@Composable
private fun SettingsSectionCard(
title: String,
content: @Composable () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 12.dp)
)
content()
}
}
}

View file

@ -1,58 +0,0 @@
package org.dueattendant149.bookreader.bookshelf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.dueattendant149.bookreader.data.settings.ServerSettings
import javax.inject.Inject
data class ServerSettingsUiState(
val bookshelfUrl: String = "",
val absUrl: String = "",
val absToken: String = "",
val saved: Boolean = false,
)
@HiltViewModel
class ServerSettingsViewModel
@Inject
constructor(
private val serverSettings: ServerSettings,
) : ViewModel() {
private val _uiState = MutableStateFlow(ServerSettingsUiState())
val uiState: StateFlow<ServerSettingsUiState> = _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)
}
}
}

View file

@ -1,118 +0,0 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookreader.audio
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import dagger.hilt.android.AndroidEntryPoint
import org.dueattendant149.bookreader.R
import org.dueattendant149.bookreader.audio.Book
import org.dueattendant149.bookreader.MainActivity
import javax.inject.Inject
@AndroidEntryPoint
@OptIn(UnstableApi::class)
class AudioPlaybackService : MediaSessionService() {
@Inject
lateinit var exoPlayer: ExoPlayer
private var mediaSession: MediaSession? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val sessionActivity = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
mediaSession = MediaSession.Builder(this, exoPlayer)
.setSessionActivity(sessionActivity)
.build()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val book = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra(EXTRA_BOOK, Book::class.java)
} else {
@Suppress("DEPRECATION")
intent?.getParcelableExtra(EXTRA_BOOK)
}
val notification = buildNotification(book)
startForeground(NOTIFICATION_ID, notification)
return super.onStartCommand(intent, flags, startId)
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
return mediaSession
}
override fun onTaskRemoved(rootIntent: Intent?) {
val player = mediaSession?.player ?: exoPlayer
if (!player.playWhenReady || player.playbackState == ExoPlayer.STATE_ENDED) {
stopSelf()
}
}
override fun onDestroy() {
mediaSession?.run {
player.release()
release()
}
mediaSession = null
super.onDestroy()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.audio_playback_channel),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.audio_playback_channel_description)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
}
private fun buildNotification(book: Book?): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(book?.title ?: getString(R.string.app_name))
.setContentText(book?.author ?: "")
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.setSilent(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build()
}
companion object {
const val EXTRA_BOOK = "extra_book"
private const val CHANNEL_ID = "audio_playback"
private const val NOTIFICATION_ID = 1
}
}

View file

@ -1,29 +0,0 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookreader.audio
/**
* Placeholder for the audio player progress state.
*
* TODO: When implementing the ExoPlayer/MediaSession audio player,
* hook [org.dueattendant149.bookreader.domain.use_case.remote.SyncPlaybackProgressUseCase]
* into the playback position update flow, e.g.:
*
* ```
* val book = ...
* val currentFile = player.currentMediaItem?.mediaId ?: book.audioCurrentFile
* val position = player.currentPosition.coerceAtLeast(0L)
* val duration = player.duration.coerceAtLeast(book.audioDuration)
* syncPlaybackProgressUseCase(book, currentFile, position, duration)
* ```
*/
data class AudioPlayerProgress(
val bookId: Int,
val currentFile: String,
val position: Long,
val duration: Long,
)

View file

@ -1,8 +0,0 @@
package org.dueattendant149.bookreader.audio
data class AudioTrack(
val fileId: String,
val title: String,
val durationMs: Long,
val order: Int,
)

View file

@ -1,18 +0,0 @@
package org.dueattendant149.bookreader.audio
/**
* Minimal Book model stub for AudioPlaybackService.
* Ported from Book's Story domain model.
*/
data class Book(
val id: Long = 0,
val title: String = "",
val author: String = "",
val remoteId: String = "",
val libraryId: String = "",
val hasAudio: Boolean = false,
val audioDuration: Long = 0L,
val audioCurrentFile: String = "",
val audioCurrentPosition: Long = 0L,
val coverUrl: String = "",
)

View file

@ -1,222 +0,0 @@
package org.dueattendant149.bookreader.bookshelf
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
import timber.log.Timber
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookshelfLibraryScreen(
viewModel: BookshelfViewModel,
onItemClick: (UnifiedItemResponse) -> Unit,
onOpenSettings: () -> Unit = {},
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
if (uiState.libraries.isEmpty()) {
viewModel.loadLibraries()
}
}
LaunchedEffect(viewModel) {
viewModel.downloadedFile.collect { uri ->
Timber.d("Downloaded book to $uri, opening in reader")
}
}
Scaffold(
topBar = {
androidx.compose.material3.CenterAlignedTopAppBar(
title = { Text("Server Library") },
actions = {
IconButton(onClick = onOpenSettings) {
Icon(Icons.Default.Settings, contentDescription = "Server settings")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (uiState.isLoading && uiState.libraries.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return@Scaffold
}
uiState.error?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)
)
}
val selected = uiState.selectedLibrary
if (selected == null) {
Text(
text = "Select a library",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
LazyColumn(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxWidth()
) {
items(uiState.libraries) { library ->
LibraryCard(
library = library,
onClick = { viewModel.selectLibrary(library) }
)
}
}
} else {
Text(
text = selected.name,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
if (uiState.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (uiState.downloadingItem != null) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
LazyColumn(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize()
) {
items(uiState.items) { item ->
ItemCard(
item = item,
isDownloading = uiState.downloadingItem?.id == item.id,
onClick = {
viewModel.downloadBook(item)
onItemClick(item)
}
)
}
}
}
}
}
}
@Composable
private fun LibraryCard(
library: LibraryResponse,
onClick: () -> Unit,
) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = library.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${library.itemCount} items · ${library.mediaType}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
@Composable
private fun ItemCard(
item: UnifiedItemResponse,
isDownloading: Boolean,
onClick: () -> Unit,
) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = item.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (item.author.isNotBlank()) {
Text(
text = item.author,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
Text(
text = item.type,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
if (isDownloading) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
Text(
"Downloading...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}

View file

@ -1,176 +0,0 @@
package org.dueattendant149.bookreader.bookshelf
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.ResponseBody
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
import java.io.File
import javax.inject.Inject
@HiltViewModel
class BookshelfViewModel
@Inject
constructor(
application: Application,
private val repository: BookshelfApiRepository,
) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(BookshelfUiState())
val uiState: StateFlow<BookshelfUiState> = _uiState.asStateFlow()
private val _downloadedFile = MutableSharedFlow<Uri>(extraBufferCapacity = 1)
val downloadedFile: SharedFlow<Uri> = _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<Application>().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<LibraryResponse> = emptyList(),
val selectedLibrary: LibraryResponse? = null,
val items: List<UnifiedItemResponse> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val downloadingItem: UnifiedItemResponse? = null,
)

View file

@ -1,76 +0,0 @@
package org.dueattendant149.bookreader.data.remote.audiobookshelf
import android.util.Log
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.dueattendant149.bookreader.domain.util.fixUriScheme
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds (and caches) a Retrofit-backed [AudiobookshelfApiService] for the configured
* Audiobookshelf instance. All requests are authenticated with the provided bearer token.
*/
@Singleton
class AudiobookshelfApiClientFactory
@Inject
constructor(
private val json: Json,
private val okHttpClient: OkHttpClient,
) {
private var cachedUrl: String? = null
private var cachedToken: String? = null
private var cachedClient: AudiobookshelfApiService? = null
@Synchronized
fun provideClient(
url: String,
token: String,
): AudiobookshelfApiService? {
val fixedUrl = url.fixUriScheme()
if (fixedUrl == cachedUrl && token == cachedToken && cachedClient != null) {
return cachedClient
}
val authClient =
okHttpClient
.newBuilder()
.addInterceptor { chain ->
val request =
chain
.request()
.newBuilder()
.header("Authorization", "Bearer $token")
.build()
chain.proceed(request)
}
.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
.build()
return runCatching {
Retrofit
.Builder()
.client(authClient)
.baseUrl(fixedUrl)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(AudiobookshelfApiService::class.java)
}.onFailure {
Log.e("AudiobookshelfApiFactory", "Failed to create client for $fixedUrl", it)
}.getOrNull().also {
cachedUrl = fixedUrl
cachedToken = token
cachedClient = it
}
}
fun clearCache() {
cachedUrl = null
cachedToken = null
cachedClient = null
}
}

View file

@ -1,52 +0,0 @@
package org.dueattendant149.bookreader.data.remote.audiobookshelf
import okhttp3.ResponseBody
import org.dueattendant149.bookreader.data.remote.audiobookshelf.PlaybackProgressUpdateRequest
import org.dueattendant149.bookreader.data.remote.audiobookshelf.model.AbsItemResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Streaming
/**
* Direct Audiobookshelf API calls for audio files and covers.
* bookshelf-api does not proxy audio streams, so the app talks to ABS directly.
*/
interface AudiobookshelfApiService {
@Streaming
@GET("api/items/{itemId}/file/{fileId}")
suspend fun downloadAudioFile(
@Path("itemId") itemId: String,
@Path("fileId") fileId: String,
): Response<ResponseBody>
@Streaming
@GET("api/items/{itemId}/download")
suspend fun downloadBook(
@Path("itemId") itemId: String,
): Response<ResponseBody>
@GET("api/items/{itemId}/cover")
suspend fun getCover(
@Path("itemId") itemId: String,
): Response<ResponseBody>
@GET("api/me/progress/{itemId}")
suspend fun getProgress(
@Path("itemId") itemId: String,
): Response<String>
@GET("api/items/{itemId}")
suspend fun getItem(
@Path("itemId") itemId: String,
): Response<AbsItemResponse>
@POST("api/me/progress/{itemId}")
suspend fun updateProgress(
@Path("itemId") itemId: String,
@Body request: PlaybackProgressUpdateRequest,
): Response<String>
}

View file

@ -1,72 +0,0 @@
package org.dueattendant149.bookreader.data.remote.audiobookshelf
import okhttp3.ResponseBody
import org.dueattendant149.bookreader.data.remote.audiobookshelf.model.AbsItemResponse
import org.dueattendant149.bookreader.data.settings.ServerSettings
import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Thin repository over [AudiobookshelfApiService]. Direct ABS calls for audio,
* cover and progress. bookshelf-api does not proxy audio streams.
*/
@Singleton
class AudiobookshelfRepository
@Inject
constructor(
private val clientFactory: AudiobookshelfApiClientFactory,
private val serverSettings: ServerSettings,
) {
private suspend fun client() =
serverSettings.getAbsUrl()?.let { url ->
serverSettings.getAbsToken()?.let { token ->
clientFactory.provideClient(url, token)
}
}
suspend fun getItem(itemId: String): Result<AbsItemResponse> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.getItem(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun getCover(itemId: String): Result<ResponseBody> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.getCover(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun downloadBook(itemId: String): Result<ResponseBody> {
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<ResponseBody> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.downloadAudioFile(itemId, fileId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun getProgress(itemId: String): Result<String> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.getProgress(itemId).unwrapBody() ?: "" }
}
suspend fun updateProgress(
itemId: String,
request: PlaybackProgressUpdateRequest,
): Result<String> {
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 <T> safe(block: () -> T): Result<T> = runCatching(block)
private fun <T> Response<T>.unwrapBody(): T? {
if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}")
return body()
}
}

View file

@ -1,21 +0,0 @@
package org.dueattendant149.bookreader.data.remote.audiobookshelf
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for updating media progress directly on an Audiobookshelf server.
*
* Field names follow the ABS `/api/me/progress/{itemId}` endpoint contract.
*/
@Serializable
data class PlaybackProgressUpdateRequest(
@SerialName("currentTime")
val currentTime: Double,
@SerialName("duration")
val duration: Double,
@SerialName("progress")
val progress: Double,
@SerialName("episodeId")
val episodeId: String? = null,
)

View file

@ -1,35 +0,0 @@
package org.dueattendant149.bookreader.data.remote.audiobookshelf.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AbsItemResponse(
val id: String = "",
val media: AbsMedia? = null,
)
@Serializable
data class AbsMedia(
val metadata: AbsMediaMetadata? = null,
@SerialName("audioFiles")
val audioFiles: List<AbsAudioFile> = 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,
)

View file

@ -1,62 +0,0 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi
import android.util.Log
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.dueattendant149.bookreader.domain.util.fixUriScheme
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds (and caches) a Retrofit-backed [BookshelfApiService] for the configured
* bookshelf-api instance.
*/
@Singleton
class BookshelfApiClientFactory
@Inject
constructor(
private val json: Json,
private val okHttpClient: OkHttpClient,
) {
private var cachedUrl: String? = null
private var cachedClient: BookshelfApiService? = null
@Synchronized
fun provideClient(url: String): BookshelfApiService? {
val fixedUrl = url.fixUriScheme()
if (fixedUrl == cachedUrl && cachedClient != null) {
return cachedClient
}
return runCatching {
Retrofit
.Builder()
.client(
okHttpClient
.newBuilder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
})
.build(),
)
.baseUrl(fixedUrl)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(BookshelfApiService::class.java)
}.onFailure {
Log.e("BookshelfApiClientFactory", "Failed to create BookshelfApiService for $fixedUrl", it)
}.getOrNull().also {
cachedUrl = fixedUrl
cachedClient = it
}
}
fun clearCache() {
cachedUrl = null
cachedClient = null
}
}

View file

@ -1,158 +0,0 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi
import okhttp3.MultipartBody
import okhttp3.RequestBody
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadsListResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryItemsResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobsResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UploadBookResponse
import org.dueattendant149.bookreader.data.settings.ServerSettings
import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Thin repository over [BookshelfApiService]. Returns raw Retrofit responses
* so callers can decide how to map them to local entities.
*/
@Singleton
class BookshelfApiRepository
@Inject
constructor(
private val clientFactory: BookshelfApiClientFactory,
private val serverSettings: ServerSettings,
) {
private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
suspend fun health(): Boolean {
val client = client() ?: return false
return runCatching { client.health().isSuccessful }.getOrDefault(false)
}
suspend fun getLibraries(): Result<List<LibraryResponse>> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getLibraries().unwrapBody().orEmpty() }
}
suspend fun getLibraryItems(libraryId: String): Result<LibraryItemsResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getLibraryItems(libraryId).unwrapBody() ?: LibraryItemsResponse() }
}
suspend fun searchLibrary(
libraryId: String,
query: String,
): Result<List<org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse>> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.searchLibrary(libraryId, query).unwrapBody().orEmpty() }
}
suspend fun search(request: SearchRequest): Result<SearchResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.search(request).unwrapBody() ?: SearchResponse() }
}
suspend fun getBook(itemId: String): Result<okhttp3.ResponseBody> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getBook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun downloadEbook(itemId: String): Result<okhttp3.ResponseBody> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.downloadEbook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun getAudioTracks(itemId: String): Result<List<org.dueattendant149.bookreader.data.remote.bookshelfapi.model.AudioTrackResponse>> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getAudioTracks(itemId).unwrapBody().orEmpty() }
}
suspend fun downloadAudioFile(
itemId: String,
fileId: String,
): Result<okhttp3.ResponseBody> {
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<Unit> {
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<Unit> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.updatePlaybackProgress(itemId, request).let {} }
}
suspend fun getTtsEngines(): Result<TtsEnginesResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getTtsEngines().unwrapBody() ?: TtsEnginesResponse() }
}
suspend fun getTtsVoices(engine: String? = null): Result<TtsVoicesResponse> {
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<TtsJobResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.createTtsJob(request).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun getTtsJobStatus(jobId: String): Result<TtsJobResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getTtsJobStatus(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun listTtsJobs(): Result<TtsJobsResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.listTtsJobs().unwrapBody() ?: TtsJobsResponse() }
}
suspend fun downloadTtsAudio(jobId: String): Result<okhttp3.ResponseBody> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.downloadTtsAudio(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun startDownload(request: DownloadRequest): Result<DownloadResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.startDownload(request).unwrapBody() ?: DownloadResponse() }
}
suspend fun listDownloads(): Result<DownloadsListResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.listDownloads().unwrapBody() ?: DownloadsListResponse() }
}
suspend fun uploadBook(
file: MultipartBody.Part,
bookType: RequestBody,
): Result<UploadBookResponse> {
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 <T> safe(block: () -> T): Result<T> = runCatching(block)
private fun <T> Response<T>.unwrapBody(): T? {
if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}")
return body()
}
}

View file

@ -1,158 +0,0 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi
import okhttp3.ResponseBody
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.AudioTrackResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadsListResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryItemsResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PodcastRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PodcastResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.ProgressUpdateRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobsResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UploadBookResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.YandexRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.YandexResponse
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
import retrofit2.http.POST
/**
* Retrofit description of the Bookshelf API (bookshelf-api:8073).
* All endpoints are relative to the configured base URL.
*/
interface BookshelfApiService {
// Health
@GET("health")
suspend fun health(): Response<Unit>
// Libraries / books
@GET("api/v1/books/libraries")
suspend fun getLibraries(): Response<List<LibraryResponse>>
@Streaming
@GET("api/v1/books/{itemId}/ebook")
suspend fun downloadEbook(
@Path("itemId") itemId: String,
): Response<ResponseBody>
@GET("api/v1/books/{itemId}/tracks")
suspend fun getAudioTracks(
@Path("itemId") itemId: String,
): Response<List<AudioTrackResponse>>
@Streaming
@GET("api/v1/books/{itemId}/file/{fileId}")
suspend fun downloadAudioFile(
@Path("itemId") itemId: String,
@Path("fileId") fileId: String,
): Response<ResponseBody>
@Streaming
@GET("api/v1/books/{itemId}")
suspend fun getBook(
@Path("itemId") itemId: String,
): Response<ResponseBody>
@Streaming
@POST("api/v1/books/{itemId}/progress")
suspend fun updateReadingProgress(
@Path("itemId") itemId: String,
@Body request: ProgressUpdateRequest,
): Response<ResponseBody>
@Streaming
@POST("api/v1/books/{itemId}/playback-progress")
suspend fun updatePlaybackProgress(
@Path("itemId") itemId: String,
@Body request: PlaybackProgressUpdateRequest,
): Response<ResponseBody>
@GET("api/v1/books/library/{libraryId}/items")
suspend fun getLibraryItems(
@Path("libraryId") libraryId: String,
): Response<LibraryItemsResponse>
@GET("api/v1/books/library/{libraryId}/search")
suspend fun searchLibrary(
@Path("libraryId") libraryId: String,
@Query("q") query: String,
): Response<List<BookItemResponse>>
@POST("api/v1/search")
suspend fun search(
@Body request: SearchRequest,
): Response<SearchResponse>
// TTS
@GET("api/v1/tts/engines")
suspend fun getTtsEngines(): Response<TtsEnginesResponse>
@GET("api/v1/tts/voices")
suspend fun getTtsVoices(
@Query("engine") engine: String? = null,
): Response<TtsVoicesResponse>
@POST("api/v1/tts")
suspend fun createTtsJob(
@Body request: TtsCreateRequest,
): Response<TtsJobResponse>
@GET("api/v1/tts/{jobId}")
suspend fun getTtsJobStatus(
@Path("jobId") jobId: String,
): Response<TtsJobResponse>
@GET("api/v1/tts/jobs/list")
suspend fun listTtsJobs(): Response<TtsJobsResponse>
@Streaming
@GET("api/v1/tts/{jobId}/download")
suspend fun downloadTtsAudio(
@Path("jobId") jobId: String,
): Response<ResponseBody>
@Multipart
@POST("api/v1/upload/book")
suspend fun uploadBook(
@Part file: MultipartBody.Part,
@Part("book_type") bookType: RequestBody,
): Response<UploadBookResponse>
// Downloads / sources
@POST("api/v1/download")
suspend fun startDownload(
@Body request: DownloadRequest,
): Response<DownloadResponse>
@GET("api/v1/download/list")
suspend fun listDownloads(): Response<DownloadsListResponse>
@POST("api/v1/podcasts")
suspend fun downloadPodcast(
@Body request: PodcastRequest,
): Response<PodcastResponse>
@POST("api/v1/sources/yandex")
suspend fun downloadYandex(
@Body request: YandexRequest,
): Response<YandexResponse>
}

View file

@ -1,61 +0,0 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookreader.data.remote.bookshelfapi
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.dueattendant149.bookreader.data.settings.ServerSettings
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ServerStatusMonitor
@Inject
constructor(
private val clientFactory: BookshelfApiClientFactory,
private val serverSettings: ServerSettings,
) {
private val _isOnline = MutableStateFlow(false)
val isOnline = _isOnline.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var pingJob: Job? = null
fun start() {
if (pingJob != null) return
pingJob = scope.launch {
while (true) {
val online = checkHealth()
_isOnline.value = online
delay(30_000L)
}
}
}
fun stop() {
pingJob?.cancel()
pingJob = null
}
private suspend fun checkHealth(): Boolean {
val url = serverSettings.getBookshelfUrl() ?: return false
val client = clientFactory.provideClient(url) ?: return false
return runCatching {
val response = client.health()
response.isSuccessful
}.onFailure {
Log.d("ServerStatusMonitor", "Health check failed: ${it.message}")
}.getOrDefault(false)
}
}

View file

@ -1,13 +0,0 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AudioTrackResponse(
@SerialName("file_id")
val fileId: String,
val title: String = "",
val duration: Double = 0.0,
val size: Long = 0L,
)

View file

@ -1,19 +0,0 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class BookItemResponse(
val id: String,
val title: String = "",
val author: String = "",
@SerialName("media_type")
val mediaType: String = "",
@SerialName("library_id")
val libraryId: String = "",
@SerialName("cover_url")
val coverUrl: String = "",
val duration: Double = 0.0,
val size: Long = 0L,
)

View file

@ -1,75 +0,0 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class DownloadRequest(
val source: String = "",
val title: String = "",
val author: String = "",
@SerialName("download_url")
val downloadUrl: String? = null,
@SerialName("magnet_url")
val magnetUrl: String? = null,
@SerialName("info_hash")
val infoHash: String? = null,
val md5: String? = null,
val url: String? = null,
@SerialName("media_type")
val mediaType: String = "ebook",
@SerialName("download_protocol")
val downloadProtocol: String = "",
)
@Serializable
data class DownloadResponse(
val success: Boolean = false,
val error: String? = null,
@SerialName("download_id")
val downloadId: String? = null,
)
@Serializable
data class DownloadStatusResponse(
val title: String = "",
val status: String = "",
val progress: Double = 0.0,
val speed: String = "",
val error: String? = null,
)
@Serializable
data class DownloadsListResponse(
val downloads: List<DownloadStatusResponse> = 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<String> = 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<String> = emptyList(),
@SerialName("abs_scan_triggered")
val absScanTriggered: Boolean = false,
)

View file

@ -1,14 +0,0 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LibraryResponse(
val id: String,
val name: String = "",
@SerialName("media_type")
val mediaType: String = "",
@SerialName("item_count")
val itemCount: Int = 0,
)

View file

@ -1,20 +0,0 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for updating the audio playback progress of a book on the bookshelf-api.
*/
@Serializable
data class PlaybackProgressUpdateRequest(
val itemId: String,
val libraryId: String,
@SerialName("current_file")
val currentFile: String,
@SerialName("current_position")
val currentPosition: Long,
val duration: Long,
@SerialName("updated_at")
val updatedAt: Long,
)

View file

@ -1,20 +0,0 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for updating the local reading progress of a book on the bookshelf-api.
*/
@Serializable
data class ProgressUpdateRequest(
val itemId: String,
val libraryId: String,
val scrollIndex: Int,
val scrollOffset: Int,
val progress: Float,
@SerialName("last_chapter")
val lastChapter: String? = null,
@SerialName("updated_at")
val updatedAt: Long,
)

View file

@ -1,54 +0,0 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SearchResponse(
val results: List<SearchResultItemResponse> = 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,
)

View file

@ -1,91 +0,0 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class TtsCreateRequest(
@SerialName("book_id")
val bookId: String,
val engine: String = "silero",
@SerialName("voice_id")
val voiceId: String = "",
val speed: Double = 1.0,
)
@Serializable
data class TtsJobResponse(
@SerialName("job_id")
val jobId: String,
@SerialName("book_id")
val bookId: String,
val title: String = "",
val author: String = "",
val engine: String = "",
@SerialName("voice_id")
val voiceId: String = "",
val speed: Double = 1.0,
val status: String = "",
val progress: Double = 0.0,
@SerialName("current_chapter")
val currentChapter: String = "",
@SerialName("total_chapters")
val totalChapters: Int = 0,
@SerialName("completed_chapters")
val completedChapters: Int = 0,
@SerialName("output_path")
val outputPath: String = "",
val error: String = "",
@SerialName("created_at")
val createdAt: Double = 0.0,
@SerialName("started_at")
val startedAt: Double = 0.0,
@SerialName("completed_at")
val completedAt: Double = 0.0,
)
@Serializable
data class TtsJobsResponse(
val jobs: List<TtsJobResponse> = emptyList(),
)
@Serializable
data class TtsEnginesResponse(
val engines: List<TtsEngineResponse> = emptyList(),
)
@Serializable
data class TtsVoicesResponse(
val voices: List<TtsVoiceResponse> = 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,
)

View file

@ -1,67 +0,0 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LibraryItemsResponse(
val library: LibraryResponse = LibraryResponse(""),
val items: List<UnifiedItemResponse> = emptyList(),
)
@Serializable
data class UnifiedItemResponse(
val id: String,
val title: String = "",
val authors: List<String> = 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,
)

View file

@ -1,14 +0,0 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UploadBookResponse(
val success: Boolean = false,
val filename: String = "",
val type: String = "",
val path: String = "",
@SerialName("error")
val errorMessage: String? = null,
)

View file

@ -1,44 +0,0 @@
package org.dueattendant149.bookreader.data.settings
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
private const val PREFS_NAME = "book_reader_server_settings"
private const val KEY_BOOKSHELF_URL = "bookshelf_url"
private const val KEY_ABS_URL = "abs_url"
private const val KEY_ABS_TOKEN = "abs_token"
/**
* Minimal server URL/token storage. Replaces the full DataStore-based settings
* from Book's Story for Phase 2 backend scaffolding.
*/
@Singleton
class ServerSettings
@Inject
constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun getBookshelfUrl(): String? = prefs.getString(KEY_BOOKSHELF_URL, null)
fun setBookshelfUrl(url: String) {
prefs.edit { putString(KEY_BOOKSHELF_URL, url) }
}
fun getAbsUrl(): String? = prefs.getString(KEY_ABS_URL, null)
fun setAbsUrl(url: String) {
prefs.edit { putString(KEY_ABS_URL, url) }
}
fun getAbsToken(): String? = prefs.getString(KEY_ABS_TOKEN, null)
fun setAbsToken(token: String) {
prefs.edit { putString(KEY_ABS_TOKEN, token) }
}
}

View file

@ -1,53 +0,0 @@
package org.dueattendant149.bookreader.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import org.dueattendant149.bookreader.data.remote.audiobookshelf.AudiobookshelfApiClientFactory
import org.dueattendant149.bookreader.data.remote.audiobookshelf.AudiobookshelfRepository
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiClientFactory
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
import org.dueattendant149.bookreader.data.settings.ServerSettings
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object BackendModule {
@Provides
@Singleton
fun provideServerSettings(@ApplicationContext context: Context): ServerSettings = ServerSettings(context)
@Provides
@Singleton
fun provideBookshelfApiClientFactory(
json: Json,
okHttpClient: OkHttpClient,
): BookshelfApiClientFactory = BookshelfApiClientFactory(json, okHttpClient)
@Provides
@Singleton
fun provideBookshelfApiRepository(
clientFactory: BookshelfApiClientFactory,
serverSettings: ServerSettings,
): BookshelfApiRepository = BookshelfApiRepository(clientFactory, serverSettings)
@Provides
@Singleton
fun provideAudiobookshelfApiClientFactory(
json: Json,
okHttpClient: OkHttpClient,
): AudiobookshelfApiClientFactory = AudiobookshelfApiClientFactory(json, okHttpClient)
@Provides
@Singleton
fun provideAudiobookshelfRepository(
clientFactory: AudiobookshelfApiClientFactory,
serverSettings: ServerSettings,
): AudiobookshelfRepository = AudiobookshelfRepository(clientFactory, serverSettings)
}

View file

@ -1,34 +0,0 @@
package org.dueattendant149.bookreader.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideJson(): Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
isLenient = true
}
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient
.Builder()
.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
}
)
.build()
}

View file

@ -1,20 +0,0 @@
package org.dueattendant149.bookreader.di
import android.content.Context
import androidx.media3.exoplayer.ExoPlayer
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PlaybackModule {
@Provides
@Singleton
fun provideExoPlayer(@ApplicationContext context: Context): ExoPlayer =
ExoPlayer.Builder(context).build()
}

View file

@ -1,76 +0,0 @@
package org.dueattendant149.bookreader.domain.util
import android.net.Uri
private val URL_SCHEME_REGEX = Regex("^[hH][tT][tT][pP][sS]?://")
/**
* Returns true if the string is non-blank, has an http/https scheme,
* and can be parsed by [Uri.parse] into a host-bearing URI.
*/
fun String.isValidUri(): Boolean {
val trimmed = trim()
if (trimmed.isBlank()) return false
if (!trimmed.hasUriScheme()) return false
val uri = runCatching { Uri.parse(trimmed) }.getOrNull() ?: return false
return !uri.host.isNullOrBlank()
}
/**
* Returns true if the string starts with http:// or https:// (case-insensitive).
*/
fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this)
private val IP_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$")
/**
* Adds https:// (or http:// for bare IP addresses) if the string has no scheme.
*/
fun String.ensureUriScheme(): String {
val trimmed = trim()
if (trimmed.hasUriScheme()) return trimmed
val hostPart = trimmed.substringBefore("/")
val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://"
return "$scheme$trimmed"
}
/**
* Adds a trailing slash if missing.
*/
fun String.ensureTrailingSlash(): String {
val trimmed = trim()
return if (trimmed.endsWith("/")) trimmed else "$trimmed/"
}
/**
* Normalizes a URL for Retrofit: trims whitespace, adds a scheme if missing,
* and ensures a trailing slash.
*/
fun String.normalizeUri(): String = ensureUriScheme().ensureTrailingSlash()
/**
* Legacy alias for [normalizeUri].
*/
fun String.fixUriScheme(): String = normalizeUri()
/**
* Derives a likely Audiobookshelf URL from a Bookshelf API URL.
* Replaces a known bookshelf-api port (8073) with the default ABS port (13378),
* or appends :13378 when no port is present. Returns null if the input is invalid.
*/
fun String.deriveAbsUrl(): String? {
if (!isValidUri()) return null
val normalized = normalizeUri()
val uri = Uri.parse(normalized)
val host = uri.host ?: return null
val port = uri.port
val scheme = uri.scheme?.lowercase() ?: "https"
val derivedPort = when (port) {
8073 -> 13378
-1 -> 13378
else -> port
}
return "$scheme://$host:$derivedPort/"
}

View file

@ -1,95 +0,0 @@
package org.dueattendant149.bookreader.reader
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
data class ChapterItem(
val title: String,
val pageIndex: Int,
val progress: Float = 0f,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChapterDrawer(
chapters: List<ChapterItem>,
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
)
}
}
}
}
}
}
}

View file

@ -1,13 +0,0 @@
package org.dueattendant149.bookreader.reader
import kotlinx.serialization.Serializable
@Serializable
data class Checkpoint(
val bookId: String,
val chapterIndex: Int = 0,
val pageIndex: Int = 0,
val scrollOffset: Int = 0,
val progress: Float = 0f,
val timestamp: Long = System.currentTimeMillis(),
)

View file

@ -1,9 +0,0 @@
package org.dueattendant149.bookreader.rsvp
sealed class ReaderText {
data class Paragraph(val text: String) : ReaderText()
data class Heading(val text: String) : ReaderText()
data class Chapter(val title: String) : ReaderText()
data class Separator(val text: String = "") : ReaderText()
data class Image(val alt: String = "") : ReaderText()
}

View file

@ -1,173 +0,0 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookreader.rsvp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Drives RSVP playback for a fixed token stream.
*
* @param scope scope used for the playback loop (typically `viewModelScope`).
*/
class RsvpEngine(private val scope: CoroutineScope) {
data class State(
val currentIndex: Int = 0,
val isPlaying: Boolean = false,
val tokens: List<RsvpToken> = 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> = _state.asStateFlow()
private var playbackJob: Job? = null
fun setTokens(tokens: List<RsvpToken>) {
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)
}
}

View file

@ -1,54 +0,0 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookreader.rsvp
import androidx.compose.runtime.Immutable
/**
* One word/segment in the RSVP stream.
*
* @param word the original word as it appears in the book.
* @param pivotIndex index of the optimal recognition point within [word];
* characters before are [prefix], the pivot is [pivot] char, after are [suffix].
* @param isParagraphEnd true if the token is the last word of a paragraph (longer pause).
* @param isChapterEnd true if the token follows a chapter heading.
* @param multiplier duration multiplier (1.0 baseline) for per-word pacing.
*/
@Immutable
data class RsvpToken(
val word: String,
val pivotIndex: Int,
val isParagraphEnd: Boolean,
val isChapterEnd: Boolean,
val multiplier: Float = 1f,
) {
val prefix: String
get() = word.substring(0, pivotIndex)
val pivot: String
get() = word.substring(pivotIndex, pivotIndex + 1)
val suffix: String
get() = word.substring(pivotIndex + 1)
}
/**
* Optimal recognition point (ORP) lookup for word lengths 1..13+.
* Indices are 0-based positions of the focal character.
* Values follow the heuristic table from Spritz/speed-reading literature:
* 10, 20, 31, 41, 51, 62, 72, 82, 92, 103, 113, 123, 133.
*/
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)
}
}

View file

@ -1,136 +0,0 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookreader.rsvp
import org.dueattendant149.bookreader.rsvp.ReaderText
/**
* Converts a list of [ReaderText] blocks (as loaded by the Book's Story reader)
* into a flat list of [RsvpToken]s suitable for RSVP playback.
*
* - Skips [ReaderText.Chapter], [ReaderText.Separator], [ReaderText.Image].
* - Marks the last word of each paragraph (blank line) as `isParagraphEnd = true`.
* - Marks the first word after a chapter heading as `isChapterEnd = true`.
* - Strips punctuation-only tokens and empty whitespace tokens.
*/
object RsvpTokenizer {
/**
* Tokenize a list of [ReaderText] blocks (as produced by the reader parser).
*/
fun tokenizeReaderText(blocks: List<ReaderText>): List<RsvpToken> {
val tokens = ArrayList<RsvpToken>(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<RsvpToken>,
) {
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
}
}

View file

@ -1,14 +0,0 @@
package org.dueattendant149.bookreader.tts
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
import okhttp3.ResponseBody
interface RemoteTtsRepository {
suspend fun fetchEngines(): Result<TtsEnginesResponse>
suspend fun fetchVoices(engine: String? = null): Result<TtsVoicesResponse>
suspend fun createJob(request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest): Result<TtsJobResponse>
suspend fun getJob(jobId: String): Result<TtsJobResponse>
suspend fun downloadAudio(jobId: String): Result<ResponseBody>
}

View file

@ -1,23 +0,0 @@
package org.dueattendant149.bookreader.tts
import okhttp3.ResponseBody
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RemoteTtsRepositoryImpl
@Inject
constructor(
private val bookshelfApi: BookshelfApiRepository,
) : RemoteTtsRepository {
override suspend fun fetchEngines(): Result<TtsEnginesResponse> = bookshelfApi.getTtsEngines()
override suspend fun fetchVoices(engine: String?): Result<TtsVoicesResponse> = bookshelfApi.getTtsVoices(engine)
override suspend fun createJob(request: TtsCreateRequest): Result<TtsJobResponse> = bookshelfApi.createTtsJob(request)
override suspend fun getJob(jobId: String): Result<TtsJobResponse> = bookshelfApi.getTtsJobStatus(jobId)
override suspend fun downloadAudio(jobId: String): Result<ResponseBody> = bookshelfApi.downloadTtsAudio(jobId)
}

View file

@ -1,50 +0,0 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookreader.tts
data class TtsEngine(
val id: String,
val name: String,
val supportsStreaming: Boolean,
val supportsCloning: Boolean,
val maxTextLength: Int,
val needsNetwork: Boolean,
)
data class TtsVoice(
val id: String,
val name: String,
val language: String,
val engine: String,
val gender: String,
val quality: String,
val requiresReference: Boolean,
)
data class TtsJob(
val jobId: String,
val bookId: String,
val title: String,
val author: String,
val engine: String,
val voiceId: String,
val speed: Double,
val status: String,
val progress: Double,
val currentChapter: String,
val totalChapters: Int,
val completedChapters: Int,
val outputPath: String,
val error: String,
val createdAt: Double,
val startedAt: Double,
val completedAt: Double,
) {
val isCompleted: Boolean get() = status == "completed"
val isFailed: Boolean get() = status == "failed" || status == "error"
val isRunning: Boolean get() = status == "running" || status == "queued" || status == "pending"
}

View file

@ -1,35 +0,0 @@
package org.dueattendant149.bookreader.work
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
/**
* Stub for CacheDownloadWorker. No-op until full Book's Story data layer is ported.
*/
class CacheDownloadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = Result.success()
}
/**
* Stub for ProgressSyncWorker. No-op until full Book's Story data layer is ported.
*/
class ProgressSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = Result.success()
}
/**
* Stub for TtsDownloadWorker. No-op until full Book's Story data layer is ported.
*/
class TtsDownloadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = Result.success()
}

View file

@ -1,3 +0,0 @@
package org.dueattendant149.bookreader.shared
fun currentTimestamp(): Long = System.currentTimeMillis()

View file

@ -1,27 +0,0 @@
package org.dueattendant149.bookreader.shared.reader
import android.util.Log
import org.dueattendant149.bookreader.BuildConfig
const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics"
const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
const val SharedEpubCutoffDiagnosticsTag = "EpistemeEpubCutoff"
val SharedReaderDiagnosticsEnabled: Boolean = BuildConfig.DEBUG
fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean {
if (!BuildConfig.DEBUG) return false
return tag == SharedEpubCutoffDiagnosticsTag ||
runCatching { Log.isLoggable(tag, Log.DEBUG) }.getOrDefault(false)
}
fun writeSharedReaderDiagnostic(tag: String, message: String) {
if (!BuildConfig.DEBUG) return
Log.d(tag, message)
}
inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) {
if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) {
writeSharedReaderDiagnostic(tag, message())
}
}

View file

@ -2066,6 +2066,4 @@
<string name="tts_replacements_replace_only_spoken_desc">Reader text, highlights, and locations stay unchanged.</string> <string name="tts_replacements_replace_only_spoken_desc">Reader text, highlights, and locations stay unchanged.</string>
<!-- TTS replacement summary. %1$s = replacement source; %2$s = spoken replacement. Example: "Dr. -> Doctor". --> <!-- TTS replacement summary. %1$s = replacement source; %2$s = spoken replacement. Example: "Dr. -> Doctor". -->
<string name="tts_replacements_summary_format">%1$s -&gt; %2$s</string> <string name="tts_replacements_summary_format">%1$s -&gt; %2$s</string>
<string name="audio_playback_channel">Audio Playback</string>
<string name="audio_playback_channel_description">Audio playback controls</string>
</resources> </resources>

View file

@ -3,10 +3,9 @@ plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.kotlin.ksp) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.kover) apply false alias(libs.plugins.kover) apply false
} }
@ -19,6 +18,7 @@ subprojects {
val rootTest = rootProject.tasks.named("test") val rootTest = rootProject.tasks.named("test")
tasks.matching { tasks.matching {
it.name == "allTests" || it.name == "allTests" ||
it.name == "desktopTest" ||
it.name.endsWith("DebugUnitTest") it.name.endsWith("DebugUnitTest")
}.configureEach { }.configureEach {
rootTest.configure { rootTest.configure {
@ -29,14 +29,3 @@ subprojects {
maxHeapSize = "4g" 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")
}
}
}

1621
desktopApp/build.gradle.kts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
-keep class io.ktor.serialization.kotlinx.** { *; }
-keep class io.ktor.serialization.kotlinx.json.** { *; }
-keep class com.sun.jna.** { *; }
-keep class * implements com.sun.jna.Library { *; }
-keep class * extends com.sun.jna.Structure { *; }
-keep class kotlinx.coroutines.swing.SwingDispatcherFactory
# Desktop release shrinking sees optional integrations from JOGL, Commons Compress
# Pack200, and OkHttp platform probes, so keep ProGuard from treating them as blockers.
-dontwarn com.jetbrains.JBR
-dontwarn com.jogamp.**
-dontwarn jogamp.**
-dontwarn org.apache.commons.compress.harmony.pack200.**
-dontwarn org.objectweb.asm.**
-dontwarn io.ktor.serialization.kotlinx.**
-dontwarn com.sun.jna.**
-dontwarn org.eclipse.swt.**
-dontwarn javafx.**
-dontwarn com.sun.javafx.**
-dontwarn okhttp3.internal.platform.**
-dontwarn org.bouncycastle.**
-dontwarn org.conscrypt.**
-dontwarn org.openjsse.**
-dontwarn android.**
-dontwarn com.github.luben.zstd.**
-dontwarn org.brotli.dec.**

View file

@ -0,0 +1,229 @@
# Desktop package builds
Build Linux packages on the matching distro VM when testing manually:
```bash
cd ~/Reader
./gradlew :desktopApp:packageDeb -x test
./gradlew :desktopApp:packageRpm -x test
./gradlew :desktopApp:packageAur -x test
```
Build a Windows MSIX locally on Windows with the Windows SDK installed:
```powershell
cd C:\Users\aryan\Desktop\Reader
.\gradlew.bat -PdesktopOnly=true -PdesktopAllowUnconfiguredStandardServices=true :desktopApp:packageReleaseMsix -x test
```
Copy the newest generated MSIX to your desktop:
```powershell
$msix = Get-ChildItem .\desktopApp\build\compose\binaries\main-release\msix -Filter *.msix | Sort-Object LastWriteTime -Descending | Select-Object -First 1
Copy-Item -Force $msix.FullName "$env:USERPROFILE\Desktop\"
```
The MSIX task is separate from MSI packaging. It stages the release app image at
`desktopApp/build/msix/package`, packages it with Windows SDK `makeappx.exe`, and
writes the MSIX to:
```text
desktopApp/build/compose/binaries/main-release/msix
```
For Microsoft Store submission, set the package identity values from Partner
Center so `AppxManifest.xml` matches the reserved app identity:
```powershell
.\gradlew.bat `
-PdesktopOnly=true `
-PdesktopMsixIdentityName=<Partner Center package identity name> `
-PdesktopMsixPublisher=<Partner Center publisher CN> `
-PdesktopMsixPublisherDisplayName=<Publisher display name> `
: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\<sdk-version>\x64\makeappx.exe" `
:desktopApp:packageReleaseMsix -x test
```
Local signing is optional and separate:
```powershell
.\gradlew.bat `
-PdesktopMsixCertificatePath=C:\path\to\certificate.pfx `
-PdesktopMsixCertificatePassword=<password> `
: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-<package>-<version>.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-<version>.tar.gz
aur-episteme-oss-bin-<version>.tar.gz
```

View file

@ -0,0 +1,138 @@
package org.dueattendant149.bookreader.desktop
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.util.Properties
internal data class DesktopAccountProfile(
val isProUser: Boolean = false,
val credits: Int = 0,
val fetchedAtEpochMillis: Long = 0L
)
// Credits and Pro status are server-owned, so startup only trusts a recent snapshot.
internal const val DesktopAccountProfileCacheTtlMillis: Long = 30L * 60L * 1000L
internal fun DesktopAccountProfile.isFresh(
nowEpochMillis: Long = System.currentTimeMillis(),
ttlMillis: Long = DesktopAccountProfileCacheTtlMillis
): Boolean {
if (fetchedAtEpochMillis <= 0L || ttlMillis <= 0L) return false
val ageMillis = nowEpochMillis - fetchedAtEpochMillis
return ageMillis in 0L..ttlMillis
}
internal class DesktopAccountProfileRepository(
private val config: DesktopCloudConfig,
private val store: DesktopAccountProfileStore = DesktopAccountProfileStore()
) {
fun cachedProfile(
uid: String,
nowEpochMillis: Long = System.currentTimeMillis()
): DesktopAccountProfile? {
return store.load(uid)?.takeIf { profile -> profile.isFresh(nowEpochMillis) }
}
fun saveFetchedProfile(uid: String, profile: DesktopAccountProfile) {
store.save(uid, profile)
}
fun clearCachedProfiles() {
store.clear()
}
suspend fun fetchProfile(uid: String, idToken: String): DesktopAccountProfile = withContext(Dispatchers.IO) {
if (uid.isBlank() || idToken.isBlank()) return@withContext DesktopAccountProfile()
val url = "https://firestore.googleapis.com/v1/projects/${urlEncode(config.firebaseProjectId)}/databases/(default)/documents/users/${urlEncode(uid)}"
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
setRequestProperty("Authorization", "Bearer $idToken")
setRequestProperty("Accept", "application/json")
connectTimeout = 12_000
readTimeout = 20_000
}
try {
if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
return@withContext DesktopAccountProfile(fetchedAtEpochMillis = System.currentTimeMillis())
}
val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (connection.responseCode !in 200..299) {
throw IllegalStateException("Could not check account status: HTTP ${connection.responseCode}")
}
val fields = DesktopAccountJson.parseToJsonElement(text).jsonObject["fields"].jsonObjectOrNull()
val profile = DesktopAccountProfile(
isProUser = fields?.booleanField("isPro") == true,
credits = fields?.numberField("credits")?.toInt() ?: 0,
fetchedAtEpochMillis = System.currentTimeMillis()
)
profile
} finally {
connection.disconnect()
}
}
}
internal class DesktopAccountProfileStore(
private val settingsFile: File = File(desktopUserConfigRoot(), "account_profile.properties")
) {
fun load(uid: String): DesktopAccountProfile? {
if (uid.isBlank() || !settingsFile.isFile) return null
val properties = Properties()
return runCatching {
settingsFile.inputStream().use(properties::load)
if (properties.getProperty("uid", "") != uid) return null
DesktopAccountProfile(
isProUser = properties.getProperty("isProUser", "false").toBooleanStrictOrNull() ?: false,
credits = properties.getProperty("credits", "0").toIntOrNull() ?: 0,
fetchedAtEpochMillis = properties.getProperty("fetchedAtEpochMillis", "0").toLongOrNull() ?: 0L
)
}.getOrNull()
}
fun save(uid: String, profile: DesktopAccountProfile) {
if (uid.isBlank()) return
val properties = Properties().apply {
setProperty("uid", uid)
setProperty("isProUser", profile.isProUser.toString())
setProperty("credits", profile.credits.toString())
setProperty("fetchedAtEpochMillis", profile.fetchedAtEpochMillis.toString())
}
settingsFile.storePropertiesAtomically(properties, "Episteme desktop account profile")
}
fun clear() {
settingsFile.delete()
}
}
private val DesktopAccountJson = Json { ignoreUnknownKeys = true }
private fun JsonObject?.booleanField(key: String): Boolean? {
return this?.get(key)
?.jsonObjectOrNull()
?.get("booleanValue")
?.jsonPrimitive
?.contentOrNull
?.toBooleanStrictOrNull()
}
private fun JsonObject?.numberField(key: String): Double? {
val field = this?.get(key)?.jsonObjectOrNull() ?: return null
return field["integerValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
?: field["doubleValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
}
private fun JsonElement?.jsonObjectOrNull(): JsonObject? = this as? JsonObject
private fun urlEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())

View file

@ -0,0 +1,338 @@
package org.dueattendant149.bookreader.desktop
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.dueattendant149.bookreader.shared.RecapResult
import org.dueattendant149.bookreader.shared.SummarizationResult
import org.dueattendant149.bookreader.shared.ui.SharedMarkdownText
import org.dueattendant149.bookreader.shared.ui.readerString
@Composable
internal fun DesktopAiHubSheet(
bookKey: String,
bookTitle: String,
itemIndex: Int,
itemTitle: String,
summaryCacheStore: DesktopSummaryCacheStore,
summaryResult: SummarizationResult?,
isSummaryLoading: Boolean,
recapResult: RecapResult?,
isRecapLoading: Boolean,
recapProgressMessage: String?,
onGenerateSummary: (force: Boolean) -> Unit,
onClearSummary: () -> Unit,
onGenerateRecap: (() -> Unit)?,
onClearRecap: () -> Unit,
onDismiss: () -> Unit,
credits: Int,
showCredits: Boolean
) {
var selectedTab by remember { mutableIntStateOf(0) }
var cacheRefresh by remember { mutableIntStateOf(0) }
val cachedSummary = remember(bookKey, itemIndex, cacheRefresh) {
summaryCacheStore.getSummary(bookKey, itemIndex)
}
val effectiveSummary = summaryResult ?: cachedSummary?.let {
SummarizationResult(summary = it, isCacheHit = true)
}
val summaryTab = readerString("label_summary", "Summary")
val recapTab = readerString("ai_tab_recap", "Recap")
val cacheTab = readerString("ai_tab_cache", "Cache")
val tabs = buildList {
add(summaryTab)
if (onGenerateRecap != null) add(recapTab)
add(cacheTab)
}
val selectedTabIndex = selectedTab.coerceIn(0, tabs.lastIndex)
val activeTab = tabs.getOrElse(selectedTabIndex) { summaryTab }
DesktopReaderBottomSheet(
title = readerString("desktop_ai_hub", "AI hub"),
onDismiss = onDismiss
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(bookTitle, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(itemTitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (showCredits) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(10.dp)
) {
Text(
readerString("credits_count", "%1\$d credits", credits),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
ScrollableTabRow(selectedTabIndex = selectedTabIndex, edgePadding = 0.dp) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTabIndex == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
}
}
when (activeTab) {
summaryTab -> {
DesktopAiHubResultView(
title = itemTitle,
resultText = effectiveSummary?.summary,
errorText = effectiveSummary?.error,
cost = effectiveSummary?.cost,
freeRemaining = effectiveSummary?.freeRemaining,
isCacheHit = effectiveSummary?.isCacheHit == true,
isLoading = isSummaryLoading,
loadingLabel = readerString("generating_summary", "Generating summary..."),
emptyTitle = readerString("desktop_no_summary_cached_section", "No summary cached for this section."),
primaryActionLabel = readerString("desktop_generate_summary", "Generate summary"),
onPrimaryAction = { onGenerateSummary(false) },
onRegenerate = { onGenerateSummary(true) },
onClear = {
summaryCacheStore.deleteSummary(bookKey, itemIndex)
cacheRefresh++
onClearSummary()
}
)
}
recapTab -> {
DesktopAiHubResultView(
title = readerString("ai_story_recap", "Story recap"),
resultText = recapResult?.recap,
errorText = recapResult?.error,
cost = recapResult?.cost,
freeRemaining = recapResult?.freeRemaining,
isCacheHit = false,
isLoading = isRecapLoading,
loadingLabel = recapProgressMessage?.takeIf { it.isNotBlank() } ?: readerString("ai_generating_recap", "Generating recap..."),
emptyTitle = readerString("desktop_create_recap_current_position", "Create a recap up to your current position."),
primaryActionLabel = readerString("desktop_generate_recap", "Generate recap"),
onPrimaryAction = { onGenerateRecap?.invoke() },
onRegenerate = { onGenerateRecap?.invoke() },
onClear = onClearRecap
)
}
cacheTab -> {
DesktopSummaryCachePanel(
bookKey = bookKey,
summaryCacheStore = summaryCacheStore,
onCacheChanged = {
cacheRefresh++
onClearSummary()
}
)
}
}
}
}
@Composable
private fun DesktopAiHubResultView(
title: String,
resultText: String?,
errorText: String?,
cost: Double?,
freeRemaining: Int?,
isCacheHit: Boolean,
isLoading: Boolean,
loadingLabel: String,
emptyTitle: String,
primaryActionLabel: String,
onPrimaryAction: () -> Unit,
onRegenerate: () -> Unit,
onClear: () -> Unit
) {
val clipboard = LocalClipboardManager.current
val hasText = !resultText.isNullOrBlank()
val hasError = !errorText.isNullOrBlank()
Column(
modifier = Modifier.fillMaxWidth().heightIn(min = 260.dp, max = 430.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(title, modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
DesktopAiUsageBadge(
isCacheHit = isCacheHit,
cost = cost,
freeRemaining = freeRemaining,
isLoading = isLoading
)
}
if (isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
when {
hasText -> {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
TextButton(enabled = !isLoading, onClick = onRegenerate) {
Text(readerString("ai_regenerate", "Regenerate"))
}
TextButton(enabled = !isLoading, onClick = onClear) {
Text(readerString("action_clear", "Clear"))
}
Spacer(Modifier.weight(1f))
IconButton(onClick = { clipboard.setText(AnnotatedString(resultText.orEmpty())) }) {
Icon(Icons.Default.ContentCopy, contentDescription = readerString("action_copy", "Copy"))
}
}
HorizontalDivider()
Column(modifier = Modifier.weight(1f).heightIn(min = 120.dp).padding(end = 2.dp).verticalScroll(rememberScrollState())) {
SharedMarkdownText(resultText.orEmpty())
}
}
hasError -> {
Text(errorText.orEmpty(), color = MaterialTheme.colorScheme.error)
Button(onClick = onPrimaryAction, enabled = !isLoading) {
Text(primaryActionLabel)
}
}
!isLoading -> {
Text(emptyTitle, color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = onPrimaryAction) {
Text(primaryActionLabel)
}
}
}
}
}
@Composable
private fun DesktopAiUsageBadge(
isCacheHit: Boolean,
cost: Double?,
freeRemaining: Int?,
isLoading: Boolean
) {
val text = when {
isCacheHit -> readerString("desktop_cached", "Cached")
cost == 0.0 && freeRemaining != null -> readerString("desktop_free_remaining_format", "Free, %1\$d left", freeRemaining)
cost != null -> readerString("desktop_credits_decimal_format", "%1\$s credits", cost)
isLoading -> readerString("desktop_cost_calculating", "Cost calculating")
else -> null
} ?: return
Surface(
color = if (isCacheHit || cost == 0.0) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.primaryContainer
},
shape = RoundedCornerShape(10.dp)
) {
Text(
text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = if (isCacheHit || cost == 0.0) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onPrimaryContainer
}
)
}
}
@Composable
private fun DesktopSummaryCachePanel(
bookKey: String,
summaryCacheStore: DesktopSummaryCacheStore,
onCacheChanged: () -> Unit
) {
var cachedItems by remember(bookKey) { mutableStateOf(summaryCacheStore.getAllSummaries(bookKey)) }
if (cachedItems.isEmpty()) {
Text(readerString("desktop_no_cached_summaries_book", "No cached summaries for this book yet."), color = MaterialTheme.colorScheme.onSurfaceVariant)
return
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
cachedItems.forEach { item ->
var expanded by remember(item.index, item.summary) { mutableStateOf(false) }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(8.dp),
tonalElevation = 1.dp
) {
Column(modifier = Modifier.fillMaxWidth().padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(item.title, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(readerString("desktop_cached_summary", "Cached summary"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
TextButton(onClick = { expanded = !expanded }) {
Text(if (expanded) readerString("desktop_hide", "Hide") else readerString("desktop_view", "View"))
}
IconButton(
onClick = {
summaryCacheStore.deleteSummary(bookKey, item.index)
cachedItems = summaryCacheStore.getAllSummaries(bookKey)
onCacheChanged()
}
) {
Icon(Icons.Default.Delete, contentDescription = readerString("desktop_delete_summary", "Delete summary"), tint = MaterialTheme.colorScheme.error)
}
}
if (expanded) {
SharedMarkdownText(item.summary)
}
}
}
}
TextButton(
onClick = {
summaryCacheStore.clearBookCache(bookKey)
cachedItems = emptyList()
onCacheChanged()
},
modifier = Modifier.align(Alignment.End)
) {
Text(readerString("clear_all", "Clear all"), color = MaterialTheme.colorScheme.error)
}
}
}

View file

@ -0,0 +1,703 @@
package org.dueattendant149.bookreader.desktop
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import org.dueattendant149.bookreader.shared.AppContrastOption
import org.dueattendant149.bookreader.shared.AppThemeMode
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoadSemanticMode
import org.dueattendant149.bookreader.shared.ui.SharedAppTheme
import org.dueattendant149.bookreader.shared.ui.readerString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.withContext
import java.awt.Component
import java.awt.EventQueue
import java.awt.Frame
import java.awt.GraphicsDevice
import java.awt.KeyboardFocusManager
import java.awt.Rectangle
import java.awt.Toolkit
import java.awt.event.KeyEvent as AwtKeyEvent
import java.util.concurrent.atomic.AtomicReference
internal val DesktopDefaultAppSeedColor = Color(0xFFFFB300)
private val DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis = longArrayOf(160L, 200L)
internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) {
configureComposeSwingInterop()
application {
val windowDefaults = remember { epistemeDesktopWindowDefaults() }
val windowStateStore = remember { DesktopWindowStateStore() }
val restoredWindowState = remember { windowStateStore.load() }
val windowState = rememberWindowState(
placement = restoredWindowState?.toWindowPlacement()
?: DesktopWindowStateSnapshot.default().toWindowPlacement(),
position = restoredWindowState?.toWindowPosition() ?: WindowPosition(Alignment.Center),
size = restoredWindowState?.toWindowSize(windowDefaults.defaultSize) ?: windowDefaults.defaultSize
)
var readerFullscreen by remember { mutableStateOf(false) }
DesktopWindowStatePersistenceEffect(
windowState = windowState,
store = windowStateStore,
enabled = !readerFullscreen
)
Window(
onCloseRequest = ::exitApplication,
title = windowDefaults.title,
state = windowState,
icon = painterResource(windowDefaults.iconResourcePath)
) {
DisposableEffect(window, windowDefaults.minimumSize) {
window.minimumSize = windowDefaults.minimumSize
onDispose {
startupSplash?.close()
}
}
EpistemeDesktopStartupGate(
window = window,
startupSplash = startupSplash,
appWindowPlacement = windowState.placement,
readerFullscreen = readerFullscreen,
onReaderFullscreenChange = { readerFullscreen = it }
)
}
}
}
@Composable
private fun EpistemeDesktopStartupGate(
window: Component?,
startupSplash: DesktopStartupSplash?,
appWindowPlacement: WindowPlacement,
readerFullscreen: Boolean,
onReaderFullscreenChange: (Boolean) -> Unit
) {
var showApp by remember { mutableStateOf(false) }
DisposableEffect(startupSplash) {
onDispose {
startupSplash?.close()
}
}
if (showApp) {
EpistemeDesktopApp(
window = window,
appWindowPlacement = appWindowPlacement,
readerFullscreen = readerFullscreen,
onReaderFullscreenChange = onReaderFullscreenChange
)
} else {
EpistemeDesktopStartupScreen(window = window)
}
LaunchedEffect(Unit) {
withFrameNanos { }
startupSplash?.close()
delay(80L)
showApp = true
}
}
@Composable
private fun EpistemeDesktopStartupScreen(window: Component?) {
val appTitle = remember { epistemeDesktopWindowDefaults().title }
SharedAppTheme(
appThemeMode = AppThemeMode.SYSTEM,
appContrastOption = AppContrastOption.STANDARD,
appTextDimFactorLight = 1.0f,
appTextDimFactorDark = 1.0f,
appSeedColor = DesktopDefaultAppSeedColor
) {
EpistemeDesktopWindowChromeEffect(
window = window,
captionColor = MaterialTheme.colorScheme.surface,
textColor = MaterialTheme.colorScheme.onSurface,
borderColor = MaterialTheme.colorScheme.background
)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(32.dp)
) {
Image(
painter = painterResource(EpistemeDesktopWindowIconResource),
contentDescription = null,
modifier = Modifier.size(64.dp)
)
Text(
text = appTitle,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = readerString("desktop_opening_your_library", "Opening your library"),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
strokeWidth = 3.dp
)
}
}
}
}
internal const val ComposeInteropBlendingProperty = "compose.interop.blending"
internal const val ComposeInteropBlendingEnabled = "true"
private const val DesktopWindowStatePersistDebounceMillis = 450L
internal fun composeInteropBlendingDefault(
platform: DesktopPlatform = currentDesktopPlatform()
): String? {
return if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) {
null
} else {
ComposeInteropBlendingEnabled
}
}
internal fun configureComposeSwingInterop(
platform: DesktopPlatform = currentDesktopPlatform()
) {
// Must run before Compose creates the desktop window. Vertical EPUB embeds native SWT/AWT
// browser surfaces; the blending path can prevent those native children from painting.
if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) {
composeInteropBlendingDefault(platform)?.let { defaultValue ->
System.setProperty(ComposeInteropBlendingProperty, defaultValue)
}
}
logDesktopWebView2(
"compose_interop platform=${platform.os} blending=${System.getProperty(ComposeInteropBlendingProperty).orEmpty().ifBlank { "default" }}"
)
}
@Composable
internal fun DesktopWindowStatePersistenceEffect(
windowState: WindowState,
store: DesktopWindowStateStore,
enabled: Boolean,
transformSnapshot: (DesktopWindowStateSnapshot) -> DesktopWindowStateSnapshot? = { it },
onSnapshotSaved: (DesktopWindowStateSnapshot) -> Unit = {}
) {
val persistenceEnabled by rememberUpdatedState(enabled)
val latestTransformSnapshot by rememberUpdatedState(transformSnapshot)
val latestOnSnapshotSaved by rememberUpdatedState(onSnapshotSaved)
LaunchedEffect(windowState, store) {
snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) }
.distinctUntilChanged()
.collectLatest { snapshot ->
if (!persistenceEnabled || snapshot == null) return@collectLatest
val persistableSnapshot = latestTransformSnapshot(snapshot) ?: return@collectLatest
delay(DesktopWindowStatePersistDebounceMillis)
if (persistenceEnabled) {
withContext(Dispatchers.IO) {
store.save(persistableSnapshot)
}
latestOnSnapshotSaved(persistableSnapshot)
}
}
}
DisposableEffect(windowState, store) {
onDispose {
if (persistenceEnabled) {
DesktopWindowStateSnapshot.fromWindowState(windowState)
?.let(latestTransformSnapshot)
?.let { snapshot ->
runCatching { store.save(snapshot) }
latestOnSnapshotSaved(snapshot)
}
}
}
}
}
@Composable
internal fun DesktopReaderFullscreenEffect(
window: Component?,
enabled: Boolean
) {
val awtWindow = window as? java.awt.Window ?: return
val fullscreenSnapshot = remember(awtWindow) {
AtomicReference<DesktopReaderFullscreenSnapshot?>()
}
val pendingExitSnapshot = remember(awtWindow) {
AtomicReference<DesktopReaderFullscreenSnapshot?>()
}
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<DesktopReaderFullscreenSnapshot?>
) {
if (!isDisplayable) return
focusableWindowState = true
captureDesktopReaderFullscreenSnapshot(snapshotRef)
applyDesktopReaderBorderlessFullscreen(snapshotRef.get())
refreshDesktopReaderWindowFocus()
}
private fun java.awt.Window.captureDesktopReaderFullscreenSnapshot(
snapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>
) {
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<DesktopReaderFullscreenSnapshot?>,
pendingExitSnapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>? = 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)
)
}
}
}
}

View file

@ -0,0 +1,159 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.BookShelfRef
import org.dueattendant149.bookreader.shared.CustomFontItem
import org.dueattendant149.bookreader.shared.SharedLibraryProjectionInput
import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot
import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
import org.dueattendant149.bookreader.shared.ShelfRecord
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
import org.dueattendant149.bookreader.shared.reader.SharedEpubChapter
import org.dueattendant149.bookreader.shared.ui.SharedAppTab
internal val DesktopInitialAppTab = SharedAppTab.LIBRARY
internal fun desktopEmptyReaderBook(): SharedEpubBook {
val noBookOpen = loadDesktopStringResolver().string("desktop_no_book_open", "No book open")
return SharedEpubBook(
id = "desktop_empty_reader",
fileName = "",
title = noBookOpen,
chapters = listOf(
SharedEpubChapter(
id = "empty",
title = noBookOpen,
plainText = ""
)
)
)
}
internal fun SharedLibrarySnapshot.withDesktopDefaults(): SharedLibrarySnapshot {
val shouldMigrateReaderDefaults = desktopReaderDefaultsVersion < DesktopReaderDefaultsVersion
val migratedTextDefaults = if (shouldMigrateReaderDefaults && readerDefaultSettings == ReaderSettings()) {
DesktopDefaultTextReaderSettings
} else {
readerDefaultSettings
}
val migratedPdfDefaults = if (shouldMigrateReaderDefaults && pdfReaderDefaultSettings == ReaderSettings(themeId = "no_theme")) {
DesktopDefaultPdfReaderSettings
} else {
pdfReaderDefaultSettings
}
val migratedBooks = if (shouldMigrateReaderDefaults) {
books.map { book ->
when {
book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.TEXT) &&
book.readerSettings == ReaderSettings() -> book.copy(readerSettings = migratedTextDefaults)
book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.PDF) &&
book.readerSettings == ReaderSettings(themeId = "no_theme") -> book.copy(readerSettings = migratedPdfDefaults)
else -> book
}
}
} else {
books
}
return copy(
books = migratedBooks,
appSeedColor = appSeedColor ?: DesktopDefaultAppSeedColor,
readerDefaultSettings = migratedTextDefaults,
pdfReaderDefaultSettings = migratedPdfDefaults,
desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion
)
}
internal fun SharedLibrarySnapshot.toDesktopReaderScreenState(): SharedReaderScreenState {
val readableBooks = books.filter { it.type in DesktopReadableFileTypes }
return SharedReaderScreenState(
rawLibraryBooks = readableBooks,
recentFilesLimit = recentFilesLimit,
allTags = tags.ifEmpty { readableBooks.collectTags() },
syncedFolders = syncedFolders,
isTabsEnabled = isTabsEnabled,
openTabIds = openTabIds,
activeTabBookId = activeTabBookId,
pinnedHomeBookIds = pinnedHomeBookIds,
pinnedLibraryBookIds = pinnedLibraryBookIds,
useStrictFileFilter = useStrictFileFilter,
appThemeMode = appThemeMode,
appContrastOption = appContrastOption,
appTextDimFactorLight = appTextDimFactorLight,
appTextDimFactorDark = appTextDimFactorDark,
appSeedColor = appSeedColor,
appFontPreference = appFontPreference,
customAppThemes = customAppThemes,
customReaderThemes = customReaderThemes,
readerDefaultSettings = readerDefaultSettings,
pdfReaderDefaultSettings = pdfReaderDefaultSettings,
readerToolbarPreferences = readerToolbarPreferences,
readerHighlightPalette = readerHighlightPalette,
pdfHighlighterPalette = pdfHighlighterPalette,
readerTtsReplacementPreferences = readerTtsReplacementPreferences
)
}
internal fun SharedLibraryStateProjector.projectDesktopLibraryState(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>
): 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<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
customFonts: List<CustomFontItem>
): 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
)
}

View file

@ -0,0 +1,56 @@
package org.dueattendant149.bookreader.desktop
import java.io.File
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.Properties
internal fun File.writeTextAtomically(text: String) {
parentFile?.mkdirs()
val temp = createSiblingTempFile()
try {
temp.writeText(text)
moveReplacing(temp, this)
} finally {
runCatching { if (temp.exists()) temp.delete() }
}
}
internal fun File.storePropertiesAtomically(properties: Properties, comments: String) {
parentFile?.mkdirs()
val temp = createSiblingTempFile()
try {
temp.outputStream().use { output ->
properties.store(output, comments)
}
moveReplacing(temp, this)
} finally {
runCatching { if (temp.exists()) temp.delete() }
}
}
private fun File.createSiblingTempFile(): File {
val directory = parentFile ?: File(".")
directory.mkdirs()
val prefix = ".$name."
return Files.createTempFile(directory.toPath(), prefix, ".tmp").toFile()
}
private fun moveReplacing(source: File, target: File) {
target.parentFile?.mkdirs()
try {
Files.move(
source.toPath(),
target.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
source.toPath(),
target.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
}
}

View file

@ -0,0 +1,109 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.ImportedBookFile
import org.dueattendant149.bookreader.shared.ReaderPlatform
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
internal data class DesktopPreparedImport(
val files: List<ImportedBookFile>,
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<ImportedBookFile>): DesktopPreparedImport {
val preparedFiles = mutableListOf<ImportedBookFile>()
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) }
}

View file

@ -0,0 +1,85 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.ReaderAiByokSettings
import org.dueattendant149.bookreader.shared.SharedFeaturePolicy
import org.dueattendant149.bookreader.shared.SharedLegalLinks
import org.dueattendant149.bookreader.shared.SharedLegalProfile
import org.dueattendant149.bookreader.shared.sharedLegalLinksForProfile
internal const val DesktopFlavorProperty = "episteme.desktop.flavor"
internal const val DesktopVersionProperty = "episteme.desktop.version"
internal const val DesktopFlavorStandard = "standard"
internal const val DesktopFlavorOssOffline = "oss-offline"
internal const val EpistemeDesktopStandardAppName = "Episteme"
internal const val EpistemeDesktopOssAppName = "Episteme oss"
internal const val ComposeApplicationResourcesDirProperty = "compose.application.resources.dir"
internal data class DesktopBuildProfile(
val flavor: String,
val appName: String,
val buildLabel: String,
val featurePolicy: SharedFeaturePolicy,
val legalProfile: SharedLegalProfile = if (featurePolicy.byokAi) {
SharedLegalProfile.OSS
} else {
SharedLegalProfile.STANDARD
}
) {
val isOssOffline: Boolean get() = flavor == DesktopFlavorOssOffline
val aiKeySettingsAvailable: Boolean
get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && legalProfile != SharedLegalProfile.OSS
val byokAiAvailable: Boolean get() = featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess
val creditBackedCloudTtsControlsAvailable: Boolean
get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && !byokAiAvailable
val legalLinks: SharedLegalLinks
get() = sharedLegalLinksForProfile(legalProfile)
}
internal fun currentDesktopBuildProfile(): DesktopBuildProfile {
return desktopBuildProfileForFlavor(
System.getProperty(DesktopFlavorProperty, DesktopFlavorStandard)
)
}
internal fun desktopBuildProfileForFlavor(rawFlavor: String?): DesktopBuildProfile {
val flavor = normalizedDesktopFlavor(rawFlavor)
return when (flavor) {
DesktopFlavorOssOffline -> DesktopBuildProfile(
flavor = DesktopFlavorOssOffline,
appName = EpistemeDesktopOssAppName,
buildLabel = "Offline OSS edition",
featurePolicy = SharedFeaturePolicy.OssOffline,
legalProfile = SharedLegalProfile.OSS
)
else -> DesktopBuildProfile(
flavor = DesktopFlavorStandard,
appName = EpistemeDesktopStandardAppName,
buildLabel = "Standard edition",
featurePolicy = SharedFeaturePolicy.Standard,
legalProfile = SharedLegalProfile.STANDARD
)
}
}
private fun normalizedDesktopFlavor(rawFlavor: String?): String {
return when (rawFlavor?.trim()?.lowercase()) {
DesktopFlavorOssOffline,
"oss",
"episteme-oss" -> DesktopFlavorOssOffline
else -> DesktopFlavorStandard
}
}
internal fun ReaderAiByokSettings.withDesktopFeaturePolicy(
featurePolicy: SharedFeaturePolicy
): ReaderAiByokSettings {
return if (featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess) {
toDesktopPersistableAiSettings()
} else {
ReaderAiByokSettings()
}
}
internal fun ReaderAiByokSettings.toDesktopPersistableAiSettings(): ReaderAiByokSettings {
return sanitized().copy(hideReaderAiFeatures = false)
}

View file

@ -0,0 +1,342 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.AiAdapter
import org.dueattendant149.bookreader.shared.AiDefinitionResult
import org.dueattendant149.bookreader.shared.ReaderAiByokSettings
import org.dueattendant149.bookreader.shared.ReaderAiFeature
import org.dueattendant149.bookreader.shared.ReaderByokTextRequest
import org.dueattendant149.bookreader.shared.ReaderByokTextRequestResult
import org.dueattendant149.bookreader.shared.ReaderByokTextRequests
import org.dueattendant149.bookreader.shared.RecapResult
import org.dueattendant149.bookreader.shared.SummarizationResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.net.HttpURLConnection
import java.net.URL
class DesktopByokAiAdapter(
private val settingsProvider: () -> ReaderAiByokSettings,
private val networkAccess: () -> Boolean = { true }
) : AiAdapter {
override val isAvailable: Boolean
get() = networkAccess() && settingsProvider().sanitized().areReaderAiFeaturesAvailable
override suspend fun define(text: String, context: String?): AiDefinitionResult {
val result = callTextAi(ReaderAiFeature.DEFINE, text, context)
return AiDefinitionResult(definition = result.getOrNull(), error = result.exceptionOrNull()?.message)
}
override suspend fun defineStreaming(
text: String,
context: String?,
onUpdate: (String) -> Unit
): AiDefinitionResult {
val result = callTextAi(ReaderAiFeature.DEFINE, text, context, onUpdate)
return AiDefinitionResult(definition = result.getOrNull(), error = result.exceptionOrNull()?.message)
}
override suspend fun summarize(text: String): SummarizationResult {
val result = callTextAi(ReaderAiFeature.SUMMARIZE, text)
return SummarizationResult(summary = result.getOrNull(), error = result.exceptionOrNull()?.message)
}
override suspend fun summarizeStreaming(
text: String,
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit,
onUpdate: (String) -> Unit
): SummarizationResult {
val result = callTextAi(ReaderAiFeature.SUMMARIZE, text, onUpdate = onUpdate)
return SummarizationResult(summary = result.getOrNull(), error = result.exceptionOrNull()?.message)
}
override suspend fun recap(textBeforeCurrentLocation: String): RecapResult {
val result = callTextAi(ReaderAiFeature.RECAP, textBeforeCurrentLocation)
return RecapResult(recap = result.getOrNull(), error = result.exceptionOrNull()?.message)
}
suspend fun callTextAi(
feature: ReaderAiFeature,
text: String,
context: String? = null,
onUpdate: (String) -> Unit = {}
): Result<String> = 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("</think>")
if (end == -1) {
if (thinkBuffer.length > 7) thinkBuffer = thinkBuffer.takeLast(7)
break
}
inThink = false
thinkBuffer = thinkBuffer.substring(end + 8)
} else {
val start = thinkBuffer.indexOf("<think>")
if (start == -1) {
if (thinkBuffer.length > 6) {
cleaned.append(thinkBuffer.dropLast(6))
thinkBuffer = thinkBuffer.takeLast(6)
}
break
}
cleaned.append(thinkBuffer.substring(0, start))
inThink = true
thinkBuffer = thinkBuffer.substring(start + 7)
}
}
return cleaned.toString()
}
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
val trimmed = line!!.trim()
if (!trimmed.startsWith("data: ")) continue
val data = trimmed.removePrefix("data: ").trim()
if (data == "[DONE]") continue
val chunk = runCatching {
DesktopAiJson.parseToJsonElement(data).jsonObject["choices"]
?.jsonArray
?.firstOrNull()
?.jsonObject
?.get("delta")
?.jsonObject
?.get("content")
?.jsonPrimitive
?.contentOrNull
}.getOrNull().orEmpty()
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

View file

@ -0,0 +1,85 @@
package org.dueattendant149.bookreader.desktop
import java.io.File
import java.util.Properties
internal data class DesktopCloudConfig(
val aiWorkerUrl: String,
val ttsWorkerUrl: String,
val firebaseWebApiKey: String,
val firebaseProjectId: String,
val googleOAuthClientId: String,
val googleOAuthClientSecret: String
) {
val isAuthConfigured: Boolean
get() = firebaseWebApiKey.isNotBlank() &&
firebaseProjectId.isNotBlank() &&
googleOAuthClientId.isNotBlank()
val isAiWorkerConfigured: Boolean get() = aiWorkerUrl.isNotBlank()
val isTtsWorkerConfigured: Boolean get() = ttsWorkerUrl.isNotBlank()
}
internal fun loadDesktopCloudConfig(): DesktopCloudConfig {
return desktopCloudConfigFromProperties(
resourceProperties = loadDesktopCloudResourceProperties(),
localProperties = loadDesktopLocalProperties()
)
}
private fun loadDesktopCloudResourceProperties(): Properties {
return Properties().apply {
val classLoader = DesktopCloudConfig::class.java.classLoader
val stream = classLoader.getResourceAsStream("desktop-cloud.properties")
?: classLoader.getResourceAsStream("common/desktop-cloud.properties")
?: System.getProperty(ComposeApplicationResourcesDirProperty)
?.let(::File)
?.let { resourcesDir ->
listOf(
resourcesDir.resolve("desktop-cloud.properties"),
resourcesDir.resolve("common/desktop-cloud.properties")
).firstOrNull { it.isFile }?.inputStream()
}
stream?.use { input -> load(input) }
}
}
private fun loadDesktopLocalProperties(file: File = File("local.properties")): Properties {
return Properties().apply {
file.takeIf { it.isFile }
?.inputStream()
?.use { input -> load(input) }
}
}
internal fun desktopCloudConfigFromProperties(
resourceProperties: Properties,
localProperties: Properties = Properties(),
systemProperty: (String) -> String? = { key -> System.getProperty("episteme.desktop.$key") },
environment: (String) -> String? = { key -> System.getenv(key) }
): DesktopCloudConfig {
fun value(vararg keys: String): String {
return keys.firstNotNullOfOrNull { key ->
systemProperty(key)
?: environment("EPISTEME_DESKTOP_${key.uppercase()}")
?: environment(key)
?: localProperties.getProperty("DESKTOP_$key")
?: localProperties.getProperty(key)
?: resourceProperties.getProperty(key)
}?.trim().orEmpty()
}
val aiWorkerUrl = value("AI_WORKER_URL").ifBlank {
"https://reader-ai.aryanrajttps.workers.dev"
}
val ttsWorkerUrl = value("TTS_WORKER_URL")
return DesktopCloudConfig(
aiWorkerUrl = aiWorkerUrl,
ttsWorkerUrl = ttsWorkerUrl,
firebaseWebApiKey = value("FIREBASE_WEB_API_KEY", "GOOGLE_API_KEY"),
firebaseProjectId = value("FIREBASE_PROJECT_ID").ifBlank { "reader-9fc469d7" },
googleOAuthClientId = value("GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_WEB_CLIENT_ID", "DEFAULT_WEB_CLIENT_ID"),
googleOAuthClientSecret = value("GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_WEB_CLIENT_SECRET", "DEFAULT_WEB_CLIENT_SECRET")
)
}

View file

@ -0,0 +1,683 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.FileType
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import java.io.ByteArrayInputStream
import java.io.File
import java.io.InputStream
import java.io.SequenceInputStream
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Duration
import java.time.Instant
import java.util.Collections
import java.util.UUID
internal data class DesktopCloudBookMetadata(
val bookId: String = "",
val title: String? = null,
val author: String? = null,
val displayName: String = "",
val type: String = "",
val lastPositionCfi: String? = null,
val lastChapterIndex: Int? = null,
val locatorBlockIndex: Int? = null,
val locatorCharOffset: Int? = null,
val lastPage: Int? = null,
val progressPercentage: Float? = null,
val isRecent: Boolean = true,
val isDeleted: Boolean = false,
val lastModifiedTimestamp: Long = 0L,
val readingPositionModifiedTimestamp: Long = 0L,
val annotationModifiedTimestamp: Long = 0L,
val bookmarksJson: String? = null,
val hasAnnotations: Boolean = false,
val fileContentModifiedTimestamp: Long = 0L,
val customName: String? = null,
val highlightsJson: String? = null,
val seriesName: String? = null,
val seriesIndex: Double? = null,
val description: String? = null,
val originalTitle: String? = null,
val originalAuthor: String? = null,
val originalSeriesName: String? = null,
val originalSeriesIndex: Double? = null,
val originalDescription: String? = null
)
internal data class DesktopCloudShelfMetadata(
val name: String = "",
val bookIds: List<String> = 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<DesktopCloudBookMetadata> =
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<DesktopCloudShelfMetadata> =
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<DesktopCloudFontMetadata> =
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<DesktopFirestoreDocument> = 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<String, JsonElement>,
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<DesktopDriveFile> = 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<DesktopDriveFile> {
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<String, JsonElement> = 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<String, JsonElement> = mapOf(
"name" to firestoreString(name),
"bookIds" to firestoreStringArray(bookIds),
"lastModifiedTimestamp" to firestoreLong(lastModifiedTimestamp),
"isDeleted" to firestoreBoolean(isDeleted)
)
private fun DesktopCloudFontMetadata.toFirestoreFields(): Map<String, JsonElement> = 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<String>): 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<String> {
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, String>): String = query(pairs.asIterable())
private fun query(pairs: Iterable<Pair<String, String>>): 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)
}

View file

@ -0,0 +1,311 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.BookItem
import org.dueattendant149.bookreader.shared.FileType
import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer
import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec
import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import java.io.File
internal object DesktopCloudSidecarSync {
fun localAnnotationDebugSummary(book: BookItem): String {
val path = book.path?.takeIf { it.isNotBlank() } ?: return "path=null type=${book.type}"
if (book.type != FileType.PDF) return "path=${path.logPreview(140)} type=${book.type}"
val annotationFile = desktopPdfAnnotationFile(path)
val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path)
val bookmarkFile = desktopPdfBookmarkFile(path)
val richTextFile = desktopPdfRichTextFile(path)
return "path=${path.logPreview(140)} " +
"annotations{exists=${annotationFile.isFile} syncable=${annotationFile.hasSyncablePdfAnnotations()} " +
"bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFile()}} " +
"deletedAnnotations{exists=${deletedAnnotationFile.isFile} count=${deletedAnnotationFile.annotationDeletionCount()} " +
"bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModifiedIfFile()}} " +
"bookmarks{exists=${bookmarkFile.isFile} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModifiedIfFile()}} " +
"text{exists=${richTextFile.isFile} syncable=${richTextFile.hasSyncablePdfRichText()} " +
"bytes=${richTextFile.length()} ts=${richTextFile.lastModifiedIfFile()}} " +
"payloadTs=${localAnnotationPayloadTimestamp(book)} totalTs=${localAnnotationTimestamp(book)}"
}
fun hasLocalAnnotationData(book: BookItem): Boolean {
val path = book.path?.takeIf { it.isNotBlank() } ?: return false
if (book.type != FileType.PDF) return false
return desktopPdfAnnotationFile(path).hasSyncablePdfAnnotations() ||
desktopPdfAnnotationDeletionFile(path).hasSyncablePdfAnnotationDeletions() ||
desktopPdfBookmarkFile(path).isFile ||
desktopPdfRichTextFile(path).hasSyncablePdfRichText()
}
fun localAnnotationTimestamp(book: BookItem): Long {
val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L
if (book.type != FileType.PDF) return 0L
return maxOf(
localAnnotationPayloadTimestamp(path),
desktopPdfBookmarkFile(path).lastModifiedIfFile()
)
}
fun localAnnotationPayloadTimestamp(book: BookItem): Long {
val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L
if (book.type != FileType.PDF) return 0L
return localAnnotationPayloadTimestamp(path)
}
fun markAnnotationPayloadSynced(book: BookItem, timestamp: Long) {
val path = book.path?.takeIf { it.isNotBlank() } ?: return
if (book.type != FileType.PDF || timestamp <= 0L) return
listOf(desktopPdfAnnotationFile(path), desktopPdfAnnotationDeletionFile(path), desktopPdfRichTextFile(path))
.filter { it.isFile }
.forEach { it.setLastModified(timestamp) }
}
fun recordAnnotationDeletions(book: BookItem, annotationIds: Collection<String>) {
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<String>) {
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
}

View file

@ -0,0 +1,82 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.BookItem
import org.dueattendant149.bookreader.shared.FileType
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
internal const val DesktopCloudSyncLogTag = "EpistemeCloudSync"
internal const val DesktopCloudAnnotationSyncLogTag = "EpistemeCloudAnnotations"
internal fun logDesktopCloudSync(message: () -> String) {
logDesktopDiagnostic(DesktopCloudSyncLogTag, message)
}
internal fun logDesktopCloudAnnotations(message: () -> String) {
logDesktopDiagnostic(DesktopCloudAnnotationSyncLogTag, message)
}
internal fun BookItem.desktopCloudSyncSummary(prefix: String = "local"): String {
val position = readerPosition
val page = if (type.usesCloudLocatorForDiagnostics()) {
position?.pageIndex ?: lastPageIndex
} else {
lastPageIndex
}
return "$prefix{id=$id type=$type ts=$timestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " +
"contentTs=$fileContentModifiedTimestamp " +
"page=$page chapter=${position?.chapterIndex} " +
"block=${position?.blockIndex} char=${position?.charOffset} progress=$progressPercentage " +
"cfi=${position?.cfi.cloudSyncPreview()} sourceFolder=${sourceFolder != null} " +
"bookmarks=${readerBookmarks.size} highlights=${readerHighlights.size}}"
}
internal fun DesktopCloudBookMetadata.desktopCloudSyncSummary(prefix: String = "remote"): String {
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " +
"annTs=${effectiveCloudAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted " +
"recent=$isRecent hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
"highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
}
internal fun BookItem.hasSameCloudReaderPosition(other: BookItem): Boolean {
val thisPage = if (type.usesCloudLocatorForDiagnostics()) readerPosition?.pageIndex ?: lastPageIndex else lastPageIndex
val otherPage = if (other.type.usesCloudLocatorForDiagnostics()) {
other.readerPosition?.pageIndex ?: other.lastPageIndex
} else {
other.lastPageIndex
}
val thisProgress = progressPercentage
val otherProgress = other.progressPercentage
val progressMatches = when {
thisProgress == null && otherProgress == null -> true
thisProgress != null && otherProgress != null -> kotlin.math.abs(thisProgress - otherProgress) < 0.001f
else -> false
}
val locatorMatches = if (type.usesCloudLocatorForDiagnostics() || other.type.usesCloudLocatorForDiagnostics()) {
readerPosition == other.readerPosition
} else {
true
}
return thisPage == otherPage &&
locatorMatches &&
progressMatches
}
private fun org.dueattendant149.bookreader.shared.FileType.usesCloudLocatorForDiagnostics(): Boolean {
return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this)
}
private fun String?.cloudSyncPreview(maxLength: Int = 80): String {
val value = this ?: return "null"
return if (value.length <= maxLength) value else value.take(maxLength) + "..."
}
private fun String?.cloudSyncAnnotationSummary(): String {
val value = this?.trim() ?: return "null"
return when {
value.isEmpty() -> "blank"
value == "[]" -> "empty"
else -> "present(${value.length})"
}
}

View file

@ -0,0 +1,60 @@
package org.dueattendant149.bookreader.desktop
import java.io.File
import java.util.Properties
import java.util.UUID
internal data class DesktopCloudSyncSettings(
val isSyncEnabled: Boolean = false,
val isFolderSyncEnabled: Boolean = false
)
internal class DesktopCloudSyncSettingsStore(
private val settingsFile: File = File(desktopUserConfigRoot(), "cloud-sync.properties")
) {
fun load(): DesktopCloudSyncSettings {
if (!settingsFile.isFile) return DesktopCloudSyncSettings()
val properties = Properties()
return runCatching {
settingsFile.inputStream().use(properties::load)
DesktopCloudSyncSettings(
isSyncEnabled = properties.getProperty("syncEnabled", "false").toBooleanStrictOrNull() == true,
isFolderSyncEnabled = properties.getProperty("folderSyncEnabled", "false").toBooleanStrictOrNull() == true
)
}.getOrDefault(DesktopCloudSyncSettings())
}
fun save(settings: DesktopCloudSyncSettings) {
settingsFile.parentFile?.mkdirs()
val properties = Properties().apply {
setProperty("syncEnabled", settings.isSyncEnabled.toString())
setProperty("folderSyncEnabled", settings.isFolderSyncEnabled.toString())
}
settingsFile.outputStream().use { output ->
properties.store(output, "Episteme desktop cloud sync")
}
}
}
internal class DesktopInstallationIdStore(
private val settingsFile: File = File(desktopUserConfigRoot(), "installation.properties")
) {
fun getOrCreateId(): String {
val properties = Properties()
val existing = runCatching {
if (!settingsFile.isFile) return@runCatching null
settingsFile.inputStream().use(properties::load)
properties.getProperty("installationId")?.takeIf { it.isNotBlank() }
}.getOrNull()
if (existing != null) return existing
val generated = UUID.randomUUID().toString()
settingsFile.parentFile?.mkdirs()
settingsFile.outputStream().use { output ->
Properties().apply {
setProperty("installationId", generated)
}.store(output, "Episteme desktop installation")
}
return generated
}
}

View file

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

View file

@ -0,0 +1,162 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.CustomFontItem
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
import java.net.HttpURLConnection
import java.net.URLEncoder
import java.net.URL
import java.util.UUID
class DesktopCustomFontStore(
private val fontsDir: File = defaultFontsDir(),
private val googleFontsDownloadAvailable: () -> Boolean = { true }
) {
private var googleFontsCache: List<String>? = null
fun importFont(source: File, displayNameOverride: String? = null): Result<CustomFontItem> {
if (!source.isFile) {
return Result.failure(IllegalArgumentException("Choose a font file."))
}
val extension = source.extension.lowercase()
if (extension !in SupportedFontExtensions) {
return Result.failure(IllegalArgumentException("Unsupported font format. Use TTF, OTF, or WOFF2."))
}
return runCatching {
fontsDir.mkdirs()
val fontId = UUID.randomUUID().toString()
val fileName = "font_$fontId.$extension"
val destination = File(fontsDir, fileName)
source.inputStream().use { input ->
destination.outputStream().use { output -> input.copyTo(output) }
}
CustomFontItem(
id = fontId,
displayName = displayNameOverride?.takeIf { it.isNotBlank() }
?: source.nameWithoutExtension.ifBlank { "Imported font" },
fileName = fileName,
fileExtension = extension,
path = destination.absolutePath,
timestamp = System.currentTimeMillis()
)
}
}
fun deleteFont(font: CustomFontItem): Boolean {
val target = runCatching { File(font.path).canonicalFile }.getOrNull() ?: return false
val root = runCatching { fontsDir.canonicalFile }.getOrNull() ?: return false
val insideFontStore = generateSequence(target) { it.parentFile }.any { it == root }
if (!insideFontStore) return false
return !target.exists() || target.delete()
}
fun 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<String> {
googleFontsCache?.let { return it }
val loaded = runCatching {
val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(GoogleFontsResource)
?: DesktopCustomFontStore::class.java.classLoader?.getResourceAsStream(GoogleFontsResource)
?: return@runCatching emptyList()
stream.bufferedReader(Charsets.UTF_8).use { reader ->
googleFontsFromJson(reader.readText())
}
}.getOrDefault(emptyList())
googleFontsCache = loaded
return loaded
}
fun downloadGoogleFont(fontName: String): Result<CustomFontItem> {
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<String> {
return Json.parseToJsonElement(rawJson)
.jsonArray
.mapNotNull { element ->
runCatching { element.jsonPrimitive.content.trim().takeIf { it.isNotBlank() } }.getOrNull()
}
}

View file

@ -0,0 +1,40 @@
package org.dueattendant149.bookreader.desktop
internal const val DesktopDiagnosticsProperty = "episteme.desktop.diagnostics"
private const val DesktopDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
private const val DesktopDiagnosticsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS"
private const val DesktopDiagnosticsTagsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS_TAGS"
private val DesktopDiagnosticTags: Set<String> =
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()}")
}
}

View file

@ -0,0 +1,373 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.ReaderLocator
import org.dueattendant149.bookreader.shared.toStableReaderPositionCfi
import org.dueattendant149.bookreader.shared.ui.SharedNativeReaderLinkClick
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.awt.event.KeyEvent as AwtKeyEvent
import java.net.URLDecoder
internal data class DesktopReaderPosition(
val pageIndex: Int,
val locator: ReaderLocator?
)
internal data class DesktopReaderHighlightClick(
val highlightId: String
)
internal data class DesktopEpubLinkClick(
val href: String,
val chapterIndex: Int?,
val text: String? = null,
val chapterId: String? = null,
val chapterHref: String? = null,
val source: String = "bridge"
)
internal fun SharedNativeReaderLinkClick.toDesktopEpubLinkClick(): DesktopEpubLinkClick {
return DesktopEpubLinkClick(
href = href,
chapterIndex = chapterIndex,
text = text,
source = "native"
)
}
internal data class DesktopEpubHandledLink(
val href: String,
val handledAtMs: Long
)
internal enum class DesktopReaderSelectionAction {
DEFINE,
SPEAK,
SEARCH,
PALETTE
}
internal enum class DesktopReaderKeyNavigation {
NEXT,
PREVIOUS,
FIRST,
LAST,
SEARCH,
NEXT_SEARCH,
EXIT_FULLSCREEN
}
internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(
fullscreen: Boolean,
rightToLeftPagination: Boolean = false
): DesktopReaderKeyNavigation? {
if (id != AwtKeyEvent.KEY_PRESSED) return null
if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) {
return DesktopReaderKeyNavigation.EXIT_FULLSCREEN
}
if (isControlDown && keyCode == AwtKeyEvent.VK_F) {
return DesktopReaderKeyNavigation.SEARCH
}
if (isControlDown && keyCode == AwtKeyEvent.VK_G) {
return DesktopReaderKeyNavigation.NEXT_SEARCH
}
return when (keyCode) {
AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) {
DesktopReaderKeyNavigation.PREVIOUS
} else {
DesktopReaderKeyNavigation.NEXT
}
AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) {
DesktopReaderKeyNavigation.NEXT
} else {
DesktopReaderKeyNavigation.PREVIOUS
}
AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT
AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS
AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST
AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST
else -> null
}
}
internal data class DesktopReaderSelectionActionPayload(
val action: DesktopReaderSelectionAction,
val text: String,
val locator: ReaderLocator? = null
)
internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? {
fun parse(rawJson: String): DesktopReaderHighlightClick? = runCatching {
val obj = Json.parseToJsonElement(rawJson).jsonObject
val highlightId = obj["id"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
?: obj["highlightId"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
?: return@runCatching null
DesktopReaderHighlightClick(highlightId)
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPayload? {
fun parse(rawJson: String): DesktopReaderSelectionActionPayload? = runCatching {
val obj = Json.parseToJsonElement(rawJson).jsonObject
val text = obj["text"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
?: return@runCatching null
val action = when (
obj["action"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.lowercase()
) {
"define" -> DesktopReaderSelectionAction.DEFINE
"speak" -> DesktopReaderSelectionAction.SPEAK
"web-search", "search" -> DesktopReaderSelectionAction.SEARCH
"palette" -> DesktopReaderSelectionAction.PALETTE
else -> return@runCatching null
}
val locator = obj["locator"]
?.takeUnless { it is JsonNull }
?.jsonObject
?.let { locatorObj ->
ReaderLocator(
chapterIndex = locatorObj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
chapterId = locatorObj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
href = locatorObj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
pageIndex = locatorObj["pageIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
startOffset = locatorObj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
endOffset = locatorObj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
blockIndex = locatorObj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
charOffset = locatorObj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
textQuote = locatorObj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
cfi = locatorObj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi()
)
}
DesktopReaderSelectionActionPayload(action, text, locator)
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerSelectionDebugMessageOrNull(): String? {
fun parse(rawJson: String): String? = runCatching {
Json.parseToJsonElement(rawJson)
.jsonObject["message"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerPaginationLogMessageOrNull(): String? {
fun parse(rawJson: String): String? = runCatching {
Json.parseToJsonElement(rawJson)
.jsonObject["message"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerPositionOrNull(): DesktopReaderPosition? {
fun parse(rawJson: String): DesktopReaderPosition? = runCatching {
val obj = Json.parseToJsonElement(rawJson).jsonObject
val pageIndex = obj["pageIndex"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.intOrNull
?: return@runCatching null
val locator = ReaderLocator(
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
href = obj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
pageIndex = pageIndex,
startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
blockIndex = obj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
charOffset = obj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi()
)
DesktopReaderPosition(pageIndex, locator)
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerKeyNavigationOrNull(): DesktopReaderKeyNavigation? {
fun parse(rawJson: String): DesktopReaderKeyNavigation? = runCatching {
val action = Json.parseToJsonElement(rawJson)
.jsonObject["action"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?: return@runCatching null
when (action) {
"next" -> DesktopReaderKeyNavigation.NEXT
"previous" -> DesktopReaderKeyNavigation.PREVIOUS
"first" -> DesktopReaderKeyNavigation.FIRST
"last" -> DesktopReaderKeyNavigation.LAST
"search" -> DesktopReaderKeyNavigation.SEARCH
"nextSearch" -> DesktopReaderKeyNavigation.NEXT_SEARCH
"exitFullscreen" -> DesktopReaderKeyNavigation.EXIT_FULLSCREEN
else -> null
}
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerLinkClickOrNull(): DesktopEpubLinkClick? {
fun parse(rawJson: String): DesktopEpubLinkClick? = runCatching {
val obj = Json.parseToJsonElement(rawJson).jsonObject
val href = obj["href"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
?: return@runCatching null
DesktopEpubLinkClick(
href = href,
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
text = obj["text"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
chapterHref = obj["chapterHref"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
)
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerLinkClickFromIntercept(): DesktopEpubLinkClick? {
val trimmed = trim()
if (trimmed.startsWith("readerlink:", ignoreCase = true)) {
logEpubLink("request_intercept_readerlink raw=\"${trimmed.logPreview()}\"")
val payload = trimmed.substringAfter("?", missingDelimiterValue = "")
.split('&')
.firstOrNull { it.substringBefore("=").equals("payload", ignoreCase = true) }
?.substringAfter("=", missingDelimiterValue = "")
?.takeIf { it.isNotBlank() }
if (payload == null) {
logEpubLink("request_intercept_readerlink_ignored reason=missing_payload")
return null
}
val decoded = runCatching {
URLDecoder.decode(payload, Charsets.UTF_8.name())
}.getOrElse {
logEpubLink("request_intercept_payload_decode_failed error=\"${it.message.orEmpty().logPreview()}\"")
return null
}
val link = decoded.readerLinkClickOrNull()?.copy(source = "request")
if (link == null) {
logEpubLink("request_intercept_readerlink_ignored reason=parse_failed payload=\"${decoded.logPreview()}\"")
}
return link
}
return readerHrefFromIntercept()?.let { href ->
DesktopEpubLinkClick(
href = href,
chapterIndex = null,
source = "request"
)
}
}
private fun String.readerHrefFromIntercept(): String? {
val trimmed = trim()
if (trimmed.isBlank()) return null
if (trimmed.equals("about:blank", ignoreCase = true)) return null
if (trimmed.startsWith("file:/", ignoreCase = true)) return null
if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}"
if (trimmed.startsWith("data:", ignoreCase = true)) return null
if (trimmed.startsWith("blob:", ignoreCase = true)) return null
return trimmed
}
internal fun ReaderLocator.toReaderLocatorJson(): String {
return buildString {
append("{")
val values = buildList {
chapterIndex?.let { add("\"chapterIndex\":$it") }
chapterId?.let { add("\"chapterId\":${it.toJsonStringLiteral()}") }
href?.let { add("\"href\":${it.toJsonStringLiteral()}") }
pageIndex?.let { add("\"pageIndex\":$it") }
startOffset?.let { add("\"startOffset\":$it") }
endOffset?.let { add("\"endOffset\":$it") }
blockIndex?.let { add("\"blockIndex\":$it") }
charOffset?.let { add("\"charOffset\":$it") }
cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") }
textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") }
}
append(values.joinToString(","))
append("}")
}
}
private fun String.toJsonStringLiteral(): String {
val builder = StringBuilder("\"")
forEach { char ->
when (char) {
'\\' -> builder.append("\\\\")
'"' -> builder.append("\\\"")
'\n' -> builder.append("\\n")
'\r' -> builder.append("\\r")
'\t' -> builder.append("\\t")
'\b' -> builder.append("\\b")
'\u000C' -> builder.append("\\f")
else -> {
if (char.code < 0x20) {
builder.append("\\u")
builder.append(char.code.toString(16).padStart(4, '0'))
} else {
builder.append(char)
}
}
}
}
builder.append('"')
return builder.toString()
}

View file

@ -0,0 +1,12 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.FileType
import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoader
import java.io.File
object DesktopEpubLoader {
fun load(file: File): SharedEpubBook {
return SharedJvmBookLoader.load(file, FileType.EPUB)
}
}

View file

@ -0,0 +1,105 @@
package org.dueattendant149.bookreader.desktop
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.dueattendant149.bookreader.shared.reader.ReaderLayoutSignature
import org.dueattendant149.bookreader.shared.reader.ReaderPage
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
import org.dueattendant149.bookreader.shared.reader.ReaderViewportSpec
import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
internal data class DesktopEpubPaginationRequest(
val bookId: String,
val chapterSignature: Int,
val layoutSignature: ReaderLayoutSignature,
val viewport: ReaderViewportSpec,
val density: DesktopEpubPaginationDensity,
val cacheGeneration: Int
)
internal data class DesktopEpubPaginationDensity(
val density: Float,
val fontScale: Float
)
internal fun desktopMeasuredPaginationReady(
request: DesktopEpubPaginationRequest?,
completedRequest: DesktopEpubPaginationRequest?,
currentPages: List<ReaderPage>,
measuredPages: List<ReaderPage>
): 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<ReaderPage>,
chapterIndex: Int,
measuredChapterPages: List<ReaderPage>
): List<ReaderPage> {
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<ReaderPage>.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
)
}
}
}

View file

@ -0,0 +1,279 @@
package org.dueattendant149.bookreader.desktop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
import org.dueattendant149.bookreader.shared.ReaderLocator
import org.dueattendant149.bookreader.shared.UserHighlight
import org.dueattendant149.bookreader.shared.ui.ReaderContentNavigationTarget
import kotlinx.coroutines.launch
@Composable
internal fun DesktopEpubWebView(
html: String,
appearanceScript: String,
highlightPaletteScript: String,
navigationTarget: ReaderContentNavigationTarget,
highlights: List<UserHighlight>,
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<DesktopEpubBridgeHandler> {
val latestOnHighlightCreated by rememberUpdatedState(onHighlightCreated)
val latestOnHighlightSelected by rememberUpdatedState(onHighlightSelected)
val latestOnKeyboardNavigation by rememberUpdatedState(onKeyboardNavigation)
val latestOnSelectionAction by rememberUpdatedState(onSelectionAction)
val latestOnLinkClicked by rememberUpdatedState(onLinkClicked)
val latestOnVisiblePageChanged by rememberUpdatedState(onVisiblePageChanged)
val latestOnPointerActivity by rememberUpdatedState(onPointerActivity)
val scope = rememberCoroutineScope()
return remember(scope) {
listOf(
DesktopEpubBridgeHandler("readerHighlightCreated") { params ->
logEpubHighlightFlow("bridge_received method=readerHighlightCreated params=\"${params.logPreview(900)}\"")
val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(params)
if (highlight == null) {
logEpubHighlightFlow("bridge_parse_failed method=readerHighlightCreated")
logEpubSelectionDebug("highlight_parse_failed params=${params.logPreview(900)}")
} else {
logEpubHighlightFlow(
"bridge_parse_success id=${highlight.id} color=${highlight.color.id} " +
"chapter=${highlight.chapterIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " +
"page=${highlight.locator.pageIndex} textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\""
)
logDesktopHighlightMap(
"bridge_highlight_created id=${highlight.id} color=${highlight.color.id} " +
"chapter=${highlight.chapterIndex} locatorChapter=${highlight.locator.chapterIndex} " +
"page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " +
"block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " +
"chapterId=${highlight.locator.chapterId.orEmpty().logPreview()} href=${highlight.locator.href.orEmpty().logPreview()} " +
"textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\""
)
scope.launch { latestOnHighlightCreated(highlight) }
}
},
DesktopEpubBridgeHandler("readerHighlightClicked") { params ->
params.readerHighlightClickOrNull()?.let { highlightClick ->
scope.launch { latestOnHighlightSelected(highlightClick.highlightId) }
}
},
DesktopEpubBridgeHandler("readerPositionChanged") { params ->
params.readerPositionOrNull()?.let { position ->
logDesktopPositionTrace(
"event=bridge_position_changed page=${position.pageIndex} " +
"locator=${position.locator.desktopPositionTraceSummary()}"
)
logDesktopHighlightMap(
"bridge_position_changed page=${position.pageIndex} chapter=${position.locator?.chapterIndex} " +
"offsets=${position.locator?.startOffset}..${position.locator?.endOffset} " +
"block=${position.locator?.blockIndex} char=${position.locator?.charOffset} " +
"chapterId=${position.locator?.chapterId.orEmpty().logPreview()} href=${position.locator?.href.orEmpty().logPreview()} " +
"text=\"${position.locator?.textQuote.orEmpty().logPreview(120)}\" " +
"cfi=\"${position.locator?.cfi.orEmpty().logPreview(160)}\""
)
logDesktopTtsStartTrace {
"event=bridge_position_changed page=${position.pageIndex} " +
"locator=${position.locator.desktopPositionTraceSummary(160)}"
}
scope.launch { latestOnVisiblePageChanged(position.pageIndex, position.locator) }
}
},
DesktopEpubBridgeHandler("readerDesktopPositionTraceLog") { params ->
logDesktopPositionTrace(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
},
DesktopEpubBridgeHandler("readerTtsStartTraceLog") { params ->
logDesktopTtsStartTrace { params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900) }
},
DesktopEpubBridgeHandler("readerSelectionAction") { params ->
val selectionAction = params.readerSelectionActionOrNull()
if (selectionAction != null) {
scope.launch { latestOnSelectionAction(selectionAction) }
}
},
DesktopEpubBridgeHandler("readerKeyNavigation") { params ->
params.readerKeyNavigationOrNull()?.let { action ->
scope.launch { latestOnKeyboardNavigation(action) }
}
},
DesktopEpubBridgeHandler("readerPointerActivity") {
scope.launch { latestOnPointerActivity() }
},
DesktopEpubBridgeHandler("readerTtsHighlightLog") { params ->
logDesktopTts("epub_highlight_js ${params.logPreview(500)}")
},
DesktopEpubBridgeHandler("readerSelectionDebugLog") { params ->
logEpubSelectionDebug(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
},
DesktopEpubBridgeHandler("readerHighlightFlowLog") { params ->
logEpubHighlightFlow(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
},
DesktopEpubBridgeHandler("readerDesktopHighlightMapLog") { params ->
logDesktopHighlightMap(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
},
DesktopEpubBridgeHandler("readerPaginationLayoutLog") { params ->
logEpubPagination(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900))
},
DesktopEpubBridgeHandler("readerGapLayoutLog") { params ->
logReaderGap(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900))
},
DesktopEpubBridgeHandler("readerLinkClicked") { params ->
logEpubLink("bridge_message params=\"${params.logPreview()}\"")
val link = params.readerLinkClickOrNull()
if (link == null) {
logEpubLink("bridge_message_ignored reason=parse_failed")
} else {
logEpubLink(
"bridge_message_parsed href=\"${link.href.logPreview()}\" " +
"chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\""
)
scope.launch { latestOnLinkClicked(link) }
}
}
)
}
}
internal val DesktopEpubKeyNavigationScript = """
(function () {
if (!window.readerDesktopChromeTapInstalled) {
window.readerDesktopChromeTapInstalled = true;
var chromeTapStart = null;
var lastChromeTapNotifiedAt = 0;
function notifyChromeTap() {
if (!window.kmpJsBridge || !window.kmpJsBridge.callNative) return;
window.kmpJsBridge.callNative('readerPointerActivity', '{}');
lastChromeTapNotifiedAt = Date.now();
}
function chromeTapIgnored(target) {
if (!target || !target.closest) return false;
return !!target.closest(
'a[href], button, input, textarea, select, [contenteditable="true"], #reader-selection-menu, .reader-selection-handle'
);
}
function hasActiveReaderSelection() {
var selection = window.getSelection && window.getSelection();
return !!selection && selection.toString().trim().length > 0;
}
function beginChromeTap(event) {
if (event.button !== undefined && event.button !== 0) return;
if (chromeTapIgnored(event.target)) {
chromeTapStart = null;
return;
}
chromeTapStart = {
pointerId: event.pointerId,
x: event.clientX || 0,
y: event.clientY || 0,
at: Date.now()
};
}
function finishChromeTap(event) {
if (!chromeTapStart) return;
if (event.pointerId !== undefined && chromeTapStart.pointerId !== undefined && event.pointerId !== chromeTapStart.pointerId) return;
var dx = (event.clientX || 0) - chromeTapStart.x;
var dy = (event.clientY || 0) - chromeTapStart.y;
var elapsed = Date.now() - chromeTapStart.at;
chromeTapStart = null;
if ((dx * dx + dy * dy) > 64 || elapsed > 650) return;
if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return;
notifyChromeTap();
}
function maybeNotifyChromeTapFromClick(event) {
if (Date.now() - lastChromeTapNotifiedAt < 250) return;
if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return;
notifyChromeTap();
}
document.addEventListener('pointerdown', beginChromeTap, true);
document.addEventListener('pointerup', finishChromeTap, true);
document.addEventListener('pointercancel', function () { chromeTapStart = null; }, true);
document.addEventListener('click', function (event) {
if (window.PointerEvent) {
maybeNotifyChromeTapFromClick(event);
return;
}
beginChromeTap(event);
finishChromeTap(event);
}, true);
}
if (window.readerDesktopKeyNavigationInstalled) return;
window.readerDesktopKeyNavigationInstalled = true;
document.addEventListener('keydown', function (event) {
var target = event.target;
var tag = target && target.tagName ? target.tagName.toLowerCase() : '';
if (target && (target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select')) return;
var action = null;
if (event.ctrlKey && (event.key === 'f' || event.key === 'F')) action = 'search';
else if (event.ctrlKey && (event.key === 'g' || event.key === 'G')) action = 'nextSearch';
else if (event.key === 'ArrowRight' || event.key === 'PageDown') action = 'next';
else if (event.key === 'ArrowLeft' || event.key === 'PageUp') action = 'previous';
else if (event.key === 'Home') action = 'first';
else if (event.key === 'End') action = 'last';
else if (event.key === 'Escape' && window.readerDesktopFullscreen) action = 'exitFullscreen';
if (!action || !window.kmpJsBridge || !window.kmpJsBridge.callNative) return;
event.preventDefault();
event.stopPropagation();
window.kmpJsBridge.callNative('readerKeyNavigation', JSON.stringify({ action: action }));
}, true);
})();
""".trimIndent()

View file

@ -0,0 +1,159 @@
package org.dueattendant149.bookreader.desktop
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.unit.dp
import org.dueattendant149.bookreader.shared.ui.readerString
import java.awt.Desktop
import java.net.URI
import java.net.URLEncoder
internal const val EpistemeSourceUrl = "https://github.com/Aryan-Raj3112/episteme"
internal const val EpistemeIssuesUrl = "https://github.com/Aryan-Raj3112/episteme/issues"
internal const val EpistemeGitHubSponsorsUrl = "https://github.com/sponsors/Aryan-Raj3112"
internal const val EpistemePatreonUrl = "https://www.patreon.com/c/epistemereader"
internal const val EpistemeSupportEmail = "epistemereader@gmail.com"
private const val ExternalLinkLogTag = "EpistemeExternalLink"
internal fun desktopFeedbackSubject(profile: DesktopBuildProfile): String {
return "Feedback: ${profile.appName}"
}
internal fun desktopAppVersionName(): String {
val version = System.getProperty(DesktopVersionProperty)
?.takeIf { it.isNotBlank() }
?: EpistemeDesktopAppVersion::class.java.getPackage()?.implementationVersion
?.takeIf { it.isNotBlank() }
return version?.let { "Version $it" } ?: "Version unavailable"
}
private object EpistemeDesktopAppVersion
@Composable
internal fun DesktopExternalLinkDialog(
url: String?,
onDismiss: () -> Unit
) {
if (url == null) return
val clipboardManager = LocalClipboardManager.current
LaunchedEffect(url) {
logExternalLink("dialog_show url=\"${url.logPreview()}\"")
}
fun dismiss() {
logExternalLink("dialog_dismiss url=\"${url.logPreview()}\"")
onDismiss()
}
DesktopReaderBottomSheet(
title = readerString("dialog_external_link_title", "External link"),
onDismiss = ::dismiss
) {
Text(
readerString("desktop_external_link_desc", "You clicked an external link."),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Text(
url,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = ::dismiss) {
Text(readerString("action_cancel", "Cancel"))
}
TextButton(
onClick = {
logExternalLink("dialog_copy url=\"${url.logPreview()}\"")
clipboardManager.setText(AnnotatedString(url))
onDismiss()
}
) {
Text(readerString("action_copy", "Copy"))
}
TextButton(
onClick = {
logExternalLink("dialog_open url=\"${url.logPreview()}\"")
openExternalUrl(url)
onDismiss()
}
) {
Text(readerString("action_open", "Open"))
}
}
}
}
internal fun openExternalUrl(url: String) {
if (!currentDesktopBuildProfile().featurePolicy.projectLinks) {
logExternalLink("open_blocked_offline url=\"${url.logPreview()}\"")
return
}
val normalizedUrl = url.normalizedExternalUrl()
runCatching {
if (Desktop.isDesktopSupported()) {
val desktop = Desktop.getDesktop()
if (normalizedUrl.startsWith("mailto:", ignoreCase = true)) {
desktop.mail(URI(normalizedUrl))
} else {
desktop.browse(URI(normalizedUrl))
}
logExternalLink("open_system_browser_success url=\"${normalizedUrl.logPreview()}\"")
} else {
logExternalLink("open_system_browser_unavailable url=\"${normalizedUrl.logPreview()}\"")
}
}.onFailure { throwable ->
logExternalLink("open_system_browser_failed url=\"${normalizedUrl.logPreview()}\" error=\"${throwable.message.orEmpty().logPreview()}\"")
}
}
internal fun String.normalizedExternalUrl(): String {
val trimmed = trim()
return if (trimmed.startsWith("www.", ignoreCase = true)) {
"https://$trimmed"
} else {
trimmed
}
}
internal fun String.isRemoteNetworkUrl(): Boolean {
val trimmed = trim()
return trimmed.startsWith("http://", ignoreCase = true) ||
trimmed.startsWith("https://", ignoreCase = true) ||
trimmed.startsWith("ws://", ignoreCase = true) ||
trimmed.startsWith("wss://", ignoreCase = true)
}
internal fun String.urlEncode(): String {
return URLEncoder.encode(this, Charsets.UTF_8.name())
}
private fun logExternalLink(message: String) {
logDesktopDiagnostic(ExternalLinkLogTag) { message }
}

View file

@ -0,0 +1,13 @@
package org.dueattendant149.bookreader.desktop
internal data class DesktopFeatureNoticePlacement(
val readerWindowId: String? = null
) {
fun rendersInMainWindow(): Boolean = readerWindowId == null
fun rendersInReaderWindow(windowId: String): Boolean = readerWindowId == windowId
}
internal fun desktopFeatureNoticePlacement(readerWindowId: String?): DesktopFeatureNoticePlacement {
return DesktopFeatureNoticePlacement(readerWindowId = readerWindowId?.takeIf { it.isNotBlank() })
}

View file

@ -0,0 +1,119 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.FileType
import org.dueattendant149.bookreader.shared.ImportedBookFile
import org.dueattendant149.bookreader.shared.ReaderPlatform
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import javax.swing.JFileChooser
internal val DesktopReadableFileTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP)
internal val DesktopSyncableFileTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
internal val DesktopBookFileTypes = DesktopReadableFileTypes
private val DesktopBookFileDialogPattern = SharedFileCapabilities.all
.filter { it.type in DesktopBookFileTypes }
.flatMap { capability -> capability.extensions.map { extension -> "*.$extension" } }
.joinToString(";")
internal fun desktopBookFileTypesForDialog(): Set<FileType> = DesktopBookFileTypes
internal fun chooseFiles(): List<ImportedBookFile> {
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_import_books", "Import books"), FileDialog.LOAD).apply {
isMultipleMode = true
isVisible = true
}
return dialog.files.orEmpty().map { it.toDesktopImportedBookFile() }
}
internal fun chooseBookFile(): File? {
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_open_book", "Open Book"), FileDialog.LOAD).apply {
file = DesktopBookFileDialogPattern
isVisible = true
}
val directory = dialog.directory ?: return null
val file = dialog.file ?: return null
return File(directory, file)
}
internal fun choosePdfFile(): File? {
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_open_pdf", "Open PDF"), FileDialog.LOAD).apply {
file = "*.pdf"
isVisible = true
}
val directory = dialog.directory ?: return null
val file = dialog.file ?: return null
return File(directory, file)
}
internal fun chooseFontFile(): File? {
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_choose_font", "Choose font"), FileDialog.LOAD).apply {
file = "*.ttf;*.otf;*.woff2"
isVisible = true
}
val directory = dialog.directory ?: return null
val file = dialog.file ?: return null
return File(directory, file)
}
internal fun chooseReaderTextureFile(): File? {
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_choose_reader_texture", "Choose reader texture"), FileDialog.LOAD).apply {
file = "*.png;*.jpg;*.jpeg;*.webp;*.gif;*.bmp"
isVisible = true
}
val directory = dialog.directory ?: return null
val file = dialog.file ?: return null
return File(directory, file)
}
internal fun chooseSaveImageFile(defaultFileName: String): File? {
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_save_image", "Save image"), FileDialog.SAVE).apply {
file = defaultFileName
isVisible = true
}
val directory = dialog.directory ?: return null
val file = dialog.file ?: return null
return File(directory, file)
}
internal fun chooseSaveBookFile(defaultFileName: String): File? {
val dialog = FileDialog(null as Frame?, desktopDialogString("action_save_copy_to_device", "Save copy to device"), FileDialog.SAVE).apply {
file = defaultFileName
isVisible = true
}
val directory = dialog.directory ?: return null
val file = dialog.file ?: return null
return File(directory, file)
}
internal fun chooseFolder(): File? {
val chooser = JFileChooser().apply {
dialogTitle = desktopDialogString("desktop_import_folder", "Import folder")
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
isAcceptAllFileFilterUsed = false
}
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
chooser.selectedFile
} else {
null
}
}
private fun desktopDialogString(name: String, fallback: String): String {
return loadDesktopStringResolver().string(name, fallback)
}
internal fun ImportedBookFile.desktopFileType(): FileType {
return SharedFileCapabilities.fileTypeForName(name)
}
internal fun File.toDesktopImportedBookFile(sourceFolder: String? = null): ImportedBookFile {
return ImportedBookFile(
name = name,
uriString = null,
localPath = absolutePath,
size = length(),
sourceFolder = sourceFolder
)
}

View file

@ -0,0 +1,242 @@
package org.dueattendant149.bookreader.desktop
import androidx.compose.foundation.BorderStroke
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.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import org.dueattendant149.bookreader.shared.ImportedBookFile
import org.dueattendant149.bookreader.shared.ReaderPlatform
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
import org.dueattendant149.bookreader.shared.ui.readerQuantityString
import org.dueattendant149.bookreader.shared.ui.readerString
import java.awt.Component
import java.awt.Container
import java.awt.EventQueue
import java.awt.datatransfer.DataFlavor
import java.awt.dnd.DnDConstants
import java.awt.dnd.DropTarget
import java.awt.dnd.DropTargetAdapter
import java.awt.dnd.DropTargetDragEvent
import java.awt.dnd.DropTargetDropEvent
import java.awt.dnd.DropTargetEvent
import java.io.File
internal data class DesktopDropImportState(
val active: Boolean = false,
val supportedCount: Int = 0,
val totalFileCount: Int = 0,
val hasFilePayload: Boolean = false
)
@Composable
internal fun DesktopFileDropTarget(
window: Component?,
onFilesDropped: (List<ImportedBookFile>) -> Unit,
onDragStateChange: (DesktopDropImportState) -> Unit
) {
val onFilesDroppedState = rememberUpdatedState(onFilesDropped)
val onDragStateChangeState = rememberUpdatedState(onDragStateChange)
DisposableEffect(window) {
if (window == null) {
onDispose { }
} else {
val installedTargets = mutableListOf<InstalledDropTarget>()
var disposed = false
var lastDragState = DesktopDropImportState()
fun publishDragState(state: DesktopDropImportState) {
if (state == lastDragState) return
lastDragState = state
onDragStateChangeState.value(state)
}
val listener = object : DropTargetAdapter() {
override fun dragEnter(event: DropTargetDragEvent) {
handleDrag(event)
}
override fun dragOver(event: DropTargetDragEvent) {
handleDrag(event)
}
override fun dragExit(event: DropTargetEvent) {
publishDragState(DesktopDropImportState())
}
override fun drop(event: DropTargetDropEvent) {
if (!event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
event.rejectDrop()
publishDragState(DesktopDropImportState())
return
}
event.acceptDrop(DnDConstants.ACTION_COPY)
val files = event.transferable.localDraggedFiles().filter { it.isFile }
if (files.isEmpty()) {
event.dropComplete(false)
publishDragState(DesktopDropImportState())
return
}
onFilesDroppedState.value(files.map { it.toDesktopImportedBookFile() })
event.dropComplete(true)
publishDragState(DesktopDropImportState())
}
private fun handleDrag(event: DropTargetDragEvent) {
val hasFilePayload = event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)
publishDragState(
DesktopDropImportState(
active = true,
hasFilePayload = hasFilePayload
)
)
if (hasFilePayload) {
event.acceptDrag(DnDConstants.ACTION_COPY)
} else {
event.rejectDrag()
}
}
}
window.installDropTargets(listener, installedTargets)
EventQueue.invokeLater {
if (!disposed) {
window.installDropTargets(listener, installedTargets)
}
}
onDispose {
disposed = true
installedTargets.forEach { installed ->
runCatching { installed.dropTarget.removeDropTargetListener(listener) }
installed.component.dropTarget = installed.previous
}
publishDragState(DesktopDropImportState())
}
}
}
}
private data class InstalledDropTarget(
val component: Component,
val previous: DropTarget?,
val dropTarget: DropTarget
)
private fun Component.installDropTargets(
listener: DropTargetAdapter,
installedTargets: MutableList<InstalledDropTarget>
) {
collectDropTargetComponents()
.distinct()
.filterNot { component -> installedTargets.any { it.component == component } }
.forEach { component ->
val previous = component.dropTarget
val target = DropTarget(component, DnDConstants.ACTION_COPY, listener, true)
installedTargets += InstalledDropTarget(component, previous, target)
}
}
private fun Component.collectDropTargetComponents(): List<Component> {
val collected = mutableListOf<Component>()
fun visit(component: Component) {
collected += component
if (component is Container) {
component.components.forEach(::visit)
}
}
visit(this)
return collected
}
@Composable
internal fun DesktopDropImportOverlay(state: DesktopDropImportState) {
if (!state.active) return
val hasSupportedFiles = state.supportedCount > 0
val title = when {
hasSupportedFiles -> readerQuantityString(
"desktop_drop_import_file_count",
state.supportedCount,
"Drop to import %1\$d file",
"Drop to import %1\$d files",
state.supportedCount
)
state.hasFilePayload -> readerString("desktop_drop_supported_files_to_import", "Drop supported files to import")
else -> readerString("desktop_drop_files_to_import", "Drop files to import")
}
val body = if (hasSupportedFiles) {
val skipped = state.totalFileCount - state.supportedCount
if (skipped > 0) {
readerQuantityString(
"desktop_unsupported_import_file_count",
skipped,
"%1\$d unsupported file will be skipped.",
"%1\$d unsupported files will be skipped.",
skipped
)
} else {
readerString("desktop_release_add_library", "Release to add to your library.")
}
} else {
SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)
}
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(20f)
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.36f)),
contentAlignment = Alignment.Center
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 8.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.55f))
) {
Column(
modifier = Modifier.padding(horizontal = 30.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(
body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
}
private fun java.awt.datatransfer.Transferable.localDraggedFiles(): List<File> {
if (!isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return emptyList()
return runCatching {
@Suppress("UNCHECKED_CAST")
(getTransferData(DataFlavor.javaFileListFlavor) as? List<*>)
.orEmpty()
.filterIsInstance<File>()
}.getOrDefault(emptyList())
}

View file

@ -0,0 +1,503 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.UserData
import com.sun.net.httpserver.HttpServer
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.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
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.InetAddress
import java.net.InetSocketAddress
import java.net.URL
import java.net.URLEncoder
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
import java.util.Properties
import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
internal data class DesktopAuthSession(
val user: UserData,
val idToken: String,
val refreshToken: String,
val expiresAtEpochMillis: Long,
val googleAccessToken: String = "",
val googleRefreshToken: String = "",
val googleAccessTokenExpiresAtEpochMillis: Long = 0L
) {
val isFresh: Boolean get() = idToken.isNotBlank() && expiresAtEpochMillis - System.currentTimeMillis() > 60_000L
val isGoogleAccessTokenFresh: Boolean
get() = googleAccessToken.isNotBlank() &&
googleAccessTokenExpiresAtEpochMillis - System.currentTimeMillis() > 60_000L
}
internal class DesktopFirebaseAuthRepository(
private val config: DesktopCloudConfig,
private val store: DesktopAuthStore = DesktopAuthStore()
) {
private var session: DesktopAuthSession? = store.load()
fun currentSession(): DesktopAuthSession? = session
suspend fun restoreSavedSession(): DesktopAuthSession? {
val restored = session ?: store.load()?.also { session = it }
return restored?.let { refreshSessionIfNeeded(it) }
}
suspend fun signIn(openUrl: (String) -> Unit): DesktopAuthSession {
if (!config.isAuthConfigured) {
throw IllegalStateException("Desktop Google sign-in is not configured.")
}
val oauthCode = requestGoogleOAuthCode(openUrl)
val googleTokens = exchangeCodeForGoogleTokens(oauthCode.code, oauthCode.redirectUri, oauthCode.codeVerifier)
val existingGoogleRefreshToken = session?.googleRefreshToken.orEmpty()
val nextSession = signInWithFirebase(googleTokens.idToken).copy(
googleAccessToken = googleTokens.accessToken,
googleRefreshToken = googleTokens.refreshToken.ifBlank { existingGoogleRefreshToken },
googleAccessTokenExpiresAtEpochMillis = googleTokens.expiresAtEpochMillis
)
session = nextSession
persistSession(nextSession)
return nextSession
}
fun signOut() {
session = null
store.clear()
}
suspend fun freshIdToken(): String? {
val current = session ?: store.load()?.also { session = it } ?: return null
return refreshSessionIfNeeded(current)?.idToken
}
suspend fun freshGoogleAccessToken(): String? {
val current = session ?: store.load()?.also { session = it } ?: return null
if (current.isGoogleAccessTokenFresh) return current.googleAccessToken
if (current.googleRefreshToken.isBlank()) return null
val refreshed = runCatching {
refreshGoogleAccessToken(current)
}.getOrNull() ?: return null
session = refreshed
persistSession(refreshed)
return refreshed.googleAccessToken
}
private suspend fun refreshSessionIfNeeded(current: DesktopAuthSession): DesktopAuthSession? {
if (current.isFresh) return current
val refreshed = runCatching {
refreshFirebaseSession(current)
}.onFailure {
signOut()
}.getOrNull() ?: return null
session = refreshed
persistSession(refreshed)
return refreshed
}
private suspend fun persistSession(session: DesktopAuthSession) {
withContext(Dispatchers.IO) {
store.save(session)
}
}
private suspend fun requestGoogleOAuthCode(openUrl: (String) -> Unit): DesktopOAuthCode = withContext(Dispatchers.IO) {
val codeVerifier = randomUrlToken(64)
val state = UUID.randomUUID().toString()
val server = HttpServer.create(InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0), 0)
val callback = CompletableFuture<Result<String>>()
val redirectUri = "http://127.0.0.1:${server.address.port}/callback"
server.executor = Executors.newSingleThreadExecutor()
server.createContext("/callback") { exchange ->
val params = exchange.requestURI.rawQuery.orEmpty().split("&")
.mapNotNull { part ->
val key = part.substringBefore("=", "")
val value = part.substringAfter("=", "")
key.takeIf { it.isNotBlank() }?.let { it to java.net.URLDecoder.decode(value, Charsets.UTF_8.name()) }
}
.toMap()
val (title, message, result) = if (params["state"] != state) {
Triple(
"Google sign-in failed",
"Google sign-in could not be completed. Return to Episteme and try again.",
Result.failure(IllegalStateException("Google sign-in returned an invalid state."))
)
} else if (params["error"].isNullOrBlank().not()) {
Triple(
"Google sign-in failed",
"Google sign-in was cancelled or failed. Return to Episteme and try again.",
Result.failure(IllegalStateException(params["error"] ?: "Google sign-in failed."))
)
} else {
val code = params["code"].orEmpty()
if (code.isBlank()) {
Triple(
"Google sign-in failed",
"Google sign-in could not be completed. Return to Episteme and try again.",
Result.failure(IllegalStateException("Google sign-in did not return an authorization code."))
)
} else {
Triple(
"Google sign-in complete",
"Return to Episteme to continue.",
Result.success(code)
)
}
}
val body = googleOAuthCallbackPage(title, message).toByteArray(Charsets.UTF_8)
try {
exchange.responseHeaders.add("Content-Type", "text/html; charset=UTF-8")
exchange.sendResponseHeaders(200, body.size.toLong())
exchange.responseBody.use { it.write(body) }
} finally {
callback.complete(result)
}
}
server.start()
try {
val authUrl = buildGoogleAuthUrl(
redirectUri = redirectUri,
codeVerifier = codeVerifier,
state = state
)
openUrl(authUrl)
val code = runCatching { callback.get(120, TimeUnit.SECONDS) }
.getOrElse { throw IllegalStateException("Google sign-in timed out.") }
.getOrThrow()
DesktopOAuthCode(code = code, redirectUri = redirectUri, codeVerifier = codeVerifier)
} finally {
server.stop(0)
(server.executor as? java.util.concurrent.ExecutorService)?.shutdownNow()
}
}
private fun buildGoogleAuthUrl(
redirectUri: String,
codeVerifier: String,
state: String
): String {
val codeChallenge = Base64.getUrlEncoder().withoutPadding()
.encodeToString(MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray(Charsets.US_ASCII)))
return "https://accounts.google.com/o/oauth2/v2/auth?" + formEncode(
"client_id" to config.googleOAuthClientId,
"redirect_uri" to redirectUri,
"response_type" to "code",
"scope" to DesktopGoogleOAuthScopes,
"code_challenge" to codeChallenge,
"code_challenge_method" to "S256",
"state" to state,
"access_type" to "offline",
"prompt" to "consent select_account"
)
}
private suspend fun exchangeCodeForGoogleTokens(
code: String,
redirectUri: String,
codeVerifier: String
): DesktopGoogleTokens = withContext(Dispatchers.IO) {
val tokenRequest = listOfNotNull(
"client_id" to config.googleOAuthClientId,
config.googleOAuthClientSecret.takeIf { it.isNotBlank() }?.let { "client_secret" to it },
"code" to code,
"code_verifier" to codeVerifier,
"grant_type" to "authorization_code",
"redirect_uri" to redirectUri
)
val response = postForm(
url = "https://oauth2.googleapis.com/token",
body = formEncode(tokenRequest)
)
val parsed = DesktopAuthJson.parseToJsonElement(response).jsonObject
val idToken = parsed.string("id_token")
?: throw IllegalStateException(parsed.string("error_description") ?: "Google sign-in did not return an ID token.")
val accessToken = parsed.string("access_token")
?: throw IllegalStateException(parsed.string("error_description") ?: "Google sign-in did not return a Drive access token.")
DesktopGoogleTokens(
idToken = idToken,
accessToken = accessToken,
refreshToken = parsed.string("refresh_token").orEmpty(),
expiresAtEpochMillis = System.currentTimeMillis() + ((parsed.string("expires_in")?.toLongOrNull() ?: 3600L) * 1000L)
)
}
private suspend fun signInWithFirebase(googleIdToken: String): DesktopAuthSession = withContext(Dispatchers.IO) {
val payload = buildJsonObject {
put("postBody", JsonPrimitive("id_token=${urlEncode(googleIdToken)}&providerId=google.com"))
put("requestUri", JsonPrimitive("http://localhost"))
put("returnIdpCredential", JsonPrimitive(true))
put("returnSecureToken", JsonPrimitive(true))
}.toString()
val parsed = postJson(
url = "https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=${urlEncode(config.firebaseWebApiKey)}",
body = payload
).let { DesktopAuthJson.parseToJsonElement(it).jsonObject }
val idToken = parsed.string("idToken")
?: throw IllegalStateException(parsed.errorMessage() ?: "Firebase sign-in failed.")
val refreshToken = parsed.string("refreshToken")
?: throw IllegalStateException("Firebase sign-in did not return a refresh token.")
val expiresAt = System.currentTimeMillis() + ((parsed.string("expiresIn")?.toLongOrNull() ?: 3600L) * 1000L)
val user = UserData(
uid = parsed.string("localId").orEmpty(),
displayName = parsed.string("displayName"),
photoUrl = parsed.string("photoUrl"),
email = parsed.string("email")
)
DesktopAuthSession(user = user, idToken = idToken, refreshToken = refreshToken, expiresAtEpochMillis = expiresAt)
}
private suspend fun refreshFirebaseSession(current: DesktopAuthSession): DesktopAuthSession = withContext(Dispatchers.IO) {
val parsed = postForm(
url = "https://securetoken.googleapis.com/v1/token?key=${urlEncode(config.firebaseWebApiKey)}",
body = formEncode(
"grant_type" to "refresh_token",
"refresh_token" to current.refreshToken
)
).let { DesktopAuthJson.parseToJsonElement(it).jsonObject }
val idToken = parsed.string("id_token")
?: throw IllegalStateException(parsed.errorMessage() ?: "Could not refresh Google account session.")
val refreshToken = parsed.string("refresh_token") ?: current.refreshToken
val expiresAt = System.currentTimeMillis() + ((parsed.string("expires_in")?.toLongOrNull() ?: 3600L) * 1000L)
current.copy(
idToken = idToken,
refreshToken = refreshToken,
expiresAtEpochMillis = expiresAt
)
}
private suspend fun refreshGoogleAccessToken(current: DesktopAuthSession): DesktopAuthSession = withContext(Dispatchers.IO) {
val tokenRequest = listOfNotNull(
"client_id" to config.googleOAuthClientId,
config.googleOAuthClientSecret.takeIf { it.isNotBlank() }?.let { "client_secret" to it },
"refresh_token" to current.googleRefreshToken,
"grant_type" to "refresh_token"
)
val parsed = postForm(
url = "https://oauth2.googleapis.com/token",
body = formEncode(tokenRequest)
).let { DesktopAuthJson.parseToJsonElement(it).jsonObject }
val accessToken = parsed.string("access_token")
?: throw IllegalStateException(parsed.string("error_description") ?: "Could not refresh Google Drive access.")
val expiresAt = System.currentTimeMillis() + ((parsed.string("expires_in")?.toLongOrNull() ?: 3600L) * 1000L)
current.copy(
googleAccessToken = accessToken,
googleAccessTokenExpiresAtEpochMillis = expiresAt
)
}
private data class DesktopOAuthCode(
val code: String,
val redirectUri: String,
val codeVerifier: String
)
private data class DesktopGoogleTokens(
val idToken: String,
val accessToken: String,
val refreshToken: String,
val expiresAtEpochMillis: Long
)
private fun googleOAuthCallbackPage(title: String, message: String): String {
return """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title.escapeHtml()}</title>
<style>
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f7f4ef; color: #1f1b16; }
main { max-width: 34rem; padding: 2rem; text-align: center; }
h1 { margin: 0 0 0.75rem; font-size: 1.75rem; }
p { margin: 0; font-size: 1rem; line-height: 1.5; color: #5b5349; }
</style>
</head>
<body>
<main>
<h1>${title.escapeHtml()}</h1>
<p>${message.escapeHtml()}</p>
</main>
</body>
</html>
""".trimIndent()
}
}
internal class DesktopAuthStore(
private val settingsFile: File = File(desktopUserConfigRoot(), "auth.properties"),
private val secretCodec: DesktopSecretCodec = DesktopSecretCodec.platform()
) {
fun load(): DesktopAuthSession? {
if (!settingsFile.isFile) return null
val properties = Properties()
return runCatching {
settingsFile.inputStream().use(properties::load)
val refreshTokenRef = properties.getProperty(RefreshTokenKey, "")
val refreshToken = refreshTokenRef.takeIf { it.isNotBlank() }
?.let { secretCodec.unprotect(RefreshTokenKey, it) }
.orEmpty()
val googleRefreshTokenRef = properties.getProperty(GoogleRefreshTokenKey, "")
val googleRefreshToken = googleRefreshTokenRef.takeIf { it.isNotBlank() }
?.let { secretCodec.unprotect(GoogleRefreshTokenKey, it) }
.orEmpty()
if (refreshToken.isBlank()) return null
DesktopAuthSession(
user = UserData(
uid = properties.getProperty("uid", ""),
displayName = properties.getProperty("displayName", "").takeIf { it.isNotBlank() },
photoUrl = properties.getProperty("photoUrl", "").takeIf { it.isNotBlank() },
email = properties.getProperty("email", "").takeIf { it.isNotBlank() }
),
idToken = "",
refreshToken = refreshToken,
expiresAtEpochMillis = 0L,
googleRefreshToken = googleRefreshToken
)
}.getOrNull()
}
fun save(session: DesktopAuthSession) {
if (session.refreshToken.isBlank()) {
throw IllegalArgumentException("Cannot save a desktop account without a refresh token.")
}
val protectedTokens = runCatching {
ProtectedDesktopAuthTokens(
refreshToken = protectRequired(RefreshTokenKey, session.refreshToken),
googleRefreshToken = session.googleRefreshToken
.takeIf { it.isNotBlank() }
?.let { protectRequired(GoogleRefreshTokenKey, it) }
)
}.getOrElse { error ->
if (secretCodec.isAvailable) {
throw error
}
logDesktopCloudSync {
"desktop.auth.persist_skipped reason=secure_storage_unavailable codec=${secretCodec.name} " +
"error=\"${error.desktopTtsSummary()}\""
}
clear()
return
}
val properties = Properties().apply {
setProperty("uid", session.user.uid)
setProperty("displayName", session.user.displayName.orEmpty())
setProperty("photoUrl", session.user.photoUrl.orEmpty())
setProperty("email", session.user.email.orEmpty())
setProperty(RefreshTokenKey, protectedTokens.refreshToken)
protectedTokens.googleRefreshToken?.let { setProperty(GoogleRefreshTokenKey, it) }
}
settingsFile.storePropertiesAtomically(properties, "Episteme desktop account")
}
fun clear() {
secretCodec.delete(RefreshTokenKey)
secretCodec.delete(GoogleRefreshTokenKey)
settingsFile.delete()
}
private companion object {
const val RefreshTokenKey = "firebaseRefreshTokenProtected"
const val GoogleRefreshTokenKey = "googleRefreshTokenProtected"
}
private data class ProtectedDesktopAuthTokens(
val refreshToken: String,
val googleRefreshToken: String?
)
private fun protectRequired(keyName: String, value: String): String {
val protectedValue = secretCodec.protect(keyName, value)
if (protectedValue.isBlank()) {
throw IllegalStateException("Desktop secure key storage returned an empty value for $keyName.")
}
return protectedValue
}
}
private val DesktopAuthJson = Json { ignoreUnknownKeys = true }
private const val DesktopGoogleOAuthScopes = "openid email profile https://www.googleapis.com/auth/drive.appdata"
private fun JsonObject.string(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull
private fun JsonObject.errorMessage(): String? {
val error = this["error"].jsonObjectOrNull() ?: return null
return error.string("message")
}
private fun randomUrlToken(length: Int): String {
val bytes = ByteArray(length)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun postForm(url: String, body: String): String {
return postBody(url, body, "application/x-www-form-urlencoded")
}
private fun postJson(url: String, body: String): String {
return postBody(url, body, "application/json; charset=UTF-8")
}
private fun postBody(url: String, body: String, contentType: String): String {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Content-Type", contentType)
setRequestProperty("Accept", "application/json")
connectTimeout = 15_000
readTimeout = 30_000
doOutput = true
doInput = true
}
return try {
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
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) {
val message = runCatching {
DesktopAuthJson.parseToJsonElement(text).jsonObject.errorMessage()
}.getOrNull()
throw IllegalStateException(message ?: "HTTP ${connection.responseCode}: ${text.take(240)}")
}
text
} finally {
connection.disconnect()
}
}
private fun formEncode(vararg pairs: Pair<String, String>): String {
return formEncode(pairs.asIterable())
}
private fun formEncode(pairs: Iterable<Pair<String, String>>): String {
return pairs.joinToString("&") { (key, value) -> "${urlEncode(key)}=${urlEncode(value)}" }
}
private fun urlEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
private fun JsonElement?.jsonObjectOrNull(): JsonObject? = this as? JsonObject
private fun String.escapeHtml(): String = buildString(length) {
this@escapeHtml.forEach { char ->
when (char) {
'&' -> append("&amp;")
'<' -> append("&lt;")
'>' -> append("&gt;")
'"' -> append("&quot;")
'\'' -> append("&#39;")
else -> append(char)
}
}
}

View file

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

View file

@ -0,0 +1,16 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
internal fun desktopFolderSyncCompletedState(
state: SharedReaderScreenState,
message: String,
failedFolderCount: Int,
showBanner: Boolean
): SharedReaderScreenState {
return if (showBanner) {
state.withBanner(message, isError = failedFolderCount > 0)
} else {
state
}
}

View file

@ -0,0 +1,19 @@
package org.dueattendant149.bookreader.desktop
private const val DesktopFolderSyncLogTag = "EpistemeFolderSync"
internal fun logDesktopFolderSync(message: String) {
logDesktopDiagnostic(DesktopFolderSyncLogTag) { message }
}
internal fun Throwable.folderSyncSummary(): String {
val type = this::class.java.simpleName.ifBlank { "Throwable" }
return "$type: ${message.orEmpty().folderSyncPreview(220)}"
}
internal fun String.folderSyncPreview(maxLength: Int = 160): String {
return replace(Regex("\\s+"), " ")
.trim()
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
.replace("\"", "\\\"")
}

View file

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

View file

@ -0,0 +1,163 @@
package org.dueattendant149.bookreader.desktop
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.dueattendant149.bookreader.shared.ui.readerString
import java.io.File
import java.util.Properties
internal data class DesktopLanguageSettings(
val languageTag: String? = null
)
internal data class DesktopLanguageOption(
val languageTag: String?,
val labelKey: String,
val fallbackLabel: String
) {
val normalizedTag: String? = normalizeDesktopLanguageTag(languageTag)
}
internal val DesktopLanguageOptions = listOf(
DesktopLanguageOption(null, "language_system_default", "System default"),
DesktopLanguageOption("en", "language_english_default", "English (Default)"),
DesktopLanguageOption("ar", "language_arabic", "Arabic"),
DesktopLanguageOption("de", "language_german", "German"),
DesktopLanguageOption("tr", "language_turkish", "Turkish"),
DesktopLanguageOption("fr", "language_french", "French"),
DesktopLanguageOption("ru", "language_russian", "Russian"),
DesktopLanguageOption("be", "language_belarusian", "Belarusian"),
DesktopLanguageOption("es", "language_spanish", "Spanish"),
DesktopLanguageOption("pt-BR", "language_portuguese_brazilian", "Portuguese (Brazil)"),
DesktopLanguageOption("it", "language_italian", "Italian"),
DesktopLanguageOption("pl", "language_polish", "Polish"),
DesktopLanguageOption("vi", "language_vietnamese", "Vietnamese"),
DesktopLanguageOption("ja", "language_japanese", "Japanese"),
DesktopLanguageOption("ko", "language_korean", "Korean"),
DesktopLanguageOption("hi", "language_hindi", "Hindi"),
DesktopLanguageOption("zh-CN", "language_chinese_simplified", "Chinese, Simplified"),
DesktopLanguageOption("nl", "language_dutch", "Dutch"),
DesktopLanguageOption("uk", "language_ukrainian", "Ukrainian"),
DesktopLanguageOption("id", "language_indonesian", "Indonesian"),
DesktopLanguageOption("et", "language_estonian", "Estonian")
)
internal fun selectedDesktopLanguageOption(languageTag: String?): DesktopLanguageOption {
val normalized = normalizeDesktopLanguageTag(languageTag)
return DesktopLanguageOptions.firstOrNull { it.normalizedTag == normalized }
?: DesktopLanguageOptions.first()
}
internal class DesktopLanguageSettingsStore(
private val settingsFile: File = File(desktopUserConfigRoot(), "language.properties")
) {
fun load(): DesktopLanguageSettings {
if (!settingsFile.isFile) return DesktopLanguageSettings()
val properties = Properties()
return runCatching {
settingsFile.inputStream().use(properties::load)
DesktopLanguageSettings(
languageTag = normalizeDesktopLanguageTag(properties.getProperty(LanguageTag))
)
}.getOrDefault(DesktopLanguageSettings())
}
fun save(settings: DesktopLanguageSettings) {
settingsFile.parentFile?.mkdirs()
val properties = Properties().apply {
normalizeDesktopLanguageTag(settings.languageTag)?.let { languageTag ->
setProperty(LanguageTag, languageTag)
}
}
settingsFile.outputStream().use { output ->
properties.store(output, "Episteme desktop language")
}
}
private companion object {
const val LanguageTag = "languageTag"
}
}
@Composable
internal fun DesktopLanguageDialog(
selectedLanguageTag: String?,
onLanguageSelected: (String?) -> Unit,
onDismiss: () -> Unit
) {
val selectedOption = selectedDesktopLanguageOption(selectedLanguageTag)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(readerString("options_language", "Language"), fontWeight = FontWeight.Bold) },
text = {
LazyColumn(
modifier = Modifier.heightIn(max = 520.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(DesktopLanguageOptions, key = { it.languageTag ?: "system" }) { option ->
val selected = option.normalizedTag == selectedOption.normalizedTag
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = if (selected) {
BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
} else {
null
},
onClick = {
onLanguageSelected(option.normalizedTag)
onDismiss()
}
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
RadioButton(
selected = selected,
onClick = {
onLanguageSelected(option.normalizedTag)
onDismiss()
}
)
Column(Modifier.weight(1f)) {
Text(readerString(option.labelKey, option.fallbackLabel))
}
}
}
if (option != DesktopLanguageOptions.last()) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f))
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(readerString("action_cancel", "Cancel"))
}
}
)
}

View file

@ -0,0 +1,47 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot
import org.dueattendant149.bookreader.shared.SharedLibrarySnapshotJson
import java.io.File
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
class DesktopLibraryDatabase(
private val databaseFile: File = defaultDatabaseFile()
) {
fun load(): SharedLibrarySnapshot {
return loadFile(databaseFile)
?: loadFile(backupFile())
?: SharedLibrarySnapshot()
}
fun save(snapshot: SharedLibrarySnapshot) {
val encoded = SharedLibrarySnapshotJson.encode(snapshot)
databaseFile.writeTextAtomically(encoded)
runCatching {
backupFile().writeTextAtomically(encoded)
}
}
private fun loadFile(file: File): SharedLibrarySnapshot? {
if (!file.isFile) return null
val raw = runCatching { file.readText() }.getOrNull() ?: return null
val isJsonObject = runCatching {
libraryDatabaseJson.parseToJsonElement(raw).jsonObject
}.isSuccess
if (!isJsonObject) return null
return SharedLibrarySnapshotJson.decodeOrEmpty(raw)
}
private fun backupFile(): File {
return File(databaseFile.parentFile ?: File("."), "${databaseFile.name}.bak")
}
companion object {
fun defaultDatabaseFile(): File {
return File(desktopUserDataRoot(), "library.json")
}
}
}
private val libraryDatabaseJson = Json { ignoreUnknownKeys = true }

View file

@ -0,0 +1,493 @@
package org.dueattendant149.bookreader.desktop
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.dueattendant149.bookreader.shared.AppAction
import org.dueattendant149.bookreader.shared.BannerMessage
import org.dueattendant149.bookreader.shared.BookItem
import org.dueattendant149.bookreader.shared.ReaderPlatform
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
import org.dueattendant149.bookreader.shared.SharedFolderPathResolver
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
import org.dueattendant149.bookreader.shared.Shelf
import org.dueattendant149.bookreader.shared.SmartCollectionDefinition
import org.dueattendant149.bookreader.shared.SmartField
import org.dueattendant149.bookreader.shared.SmartOperator
import org.dueattendant149.bookreader.shared.SmartRule
import org.dueattendant149.bookreader.shared.Tag
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
import org.dueattendant149.bookreader.shared.reduce
import org.dueattendant149.bookreader.shared.ui.NonReaderLibraryTab
import org.dueattendant149.bookreader.shared.ui.SharedLibraryScreen
import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField
import org.dueattendant149.bookreader.shared.ui.readerString
import java.io.File
internal fun BookItem.hasEmbeddedMetadataChange(updated: BookItem): Boolean {
return title != updated.title ||
author != updated.author ||
description != updated.description ||
seriesName != updated.seriesName ||
seriesIndex != updated.seriesIndex
}
internal fun String.toDesktopSafeFileName(): String {
return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" }
}
internal fun BookItem.desktopSuggestedOriginalFileName(): String {
val extension = path
?.let(::File)
?.extension
?.takeIf { it.isNotBlank() }
?: SharedFileCapabilities.primaryExtensionFor(type)
val safeName = displayName
.takeIf { it.isNotBlank() }
?: title?.takeIf { it.isNotBlank() }
?: "book"
val sanitized = safeName.toDesktopSafeFileName()
return if (extension != null && !sanitized.endsWith(".$extension", ignoreCase = true)) {
"$sanitized.$extension"
} else {
sanitized
}
}
internal fun BookItem.withDesktopImportMetadata(
enriched: BookItem,
original: BookItem?
): BookItem {
fun shouldApplyText(current: String?, originalValue: String?): Boolean {
return current.isNullOrBlank() || current == originalValue
}
return copy(
title = if (shouldApplyText(title, original?.title)) {
enriched.title ?: title
} else {
title
},
author = if (shouldApplyText(author, original?.author)) {
enriched.author ?: author
} else {
author
},
description = if (shouldApplyText(description, original?.description)) {
enriched.description ?: description
} else {
description
},
seriesName = if (shouldApplyText(seriesName, original?.seriesName)) {
enriched.seriesName ?: seriesName
} else {
seriesName
},
seriesIndex = if (seriesIndex == null || seriesIndex == original?.seriesIndex) {
enriched.seriesIndex ?: seriesIndex
} else {
seriesIndex
},
originalTitle = originalTitle ?: enriched.originalTitle ?: enriched.title,
originalAuthor = originalAuthor ?: enriched.originalAuthor ?: enriched.author,
originalSeriesName = originalSeriesName ?: enriched.originalSeriesName ?: enriched.seriesName,
originalSeriesIndex = originalSeriesIndex ?: enriched.originalSeriesIndex ?: enriched.seriesIndex,
originalDescription = originalDescription ?: enriched.originalDescription ?: enriched.description,
fileSize = enriched.fileSize.takeIf { it > 0L } ?: fileSize,
fileContentModifiedTimestamp = enriched.fileContentModifiedTimestamp.takeIf { it > 0L }
?: fileContentModifiedTimestamp,
coverImagePath = coverImagePath?.takeIf { File(it).isFile } ?: enriched.coverImagePath,
folderTextMetadataParsed = folderTextMetadataParsed || enriched.folderTextMetadataParsed
)
}
internal fun resolvedDesktopReaderSettings(
book: BookItem,
readerDefaultSettings: ReaderSettings
): ReaderSettings {
return book.readerSettings ?: readerDefaultSettings
}
@Composable
internal fun DesktopReaderOpeningScreen(
opening: DesktopReaderOpening,
readerSettings: ReaderSettings? = null
) {
LaunchedEffect(opening.requestId) {
logDesktopReaderOpenTrace {
opening.openTracePrefix("desktop_opening_screen_composed")
}
}
val background = readerSettings?.desktopOpeningBackgroundColor() ?: MaterialTheme.colorScheme.background
val foreground = readerSettings?.desktopOpeningForegroundColor() ?: MaterialTheme.colorScheme.onBackground
Box(
modifier = Modifier
.fillMaxSize()
.background(background)
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CircularProgressIndicator(color = foreground)
Text(
text = readerString("desktop_opening_title", "Opening %1\$s", opening.title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = foreground,
textAlign = TextAlign.Center
)
Text(
text = opening.formatLabel,
style = MaterialTheme.typography.bodyMedium,
color = foreground.copy(alpha = 0.72f),
textAlign = TextAlign.Center
)
}
}
}
private fun ReaderSettings.desktopOpeningBackgroundColor(): Color {
return backgroundColorArgb?.toDesktopOpeningComposeColor()
?: if (darkMode) Color(0xFF171A17) else Color(0xFFFFFCF5)
}
private fun ReaderSettings.desktopOpeningForegroundColor(): Color {
return textColorArgb?.toDesktopOpeningComposeColor()
?: if (darkMode) Color(0xFFE7E3D8) else Color(0xFF24231F)
}
private fun Long.toDesktopOpeningComposeColor(): Color {
val value = this and 0xFFFFFFFFL
val alpha = ((value shr 24) and 0xFF) / 255f
val red = ((value shr 16) and 0xFF) / 255f
val green = ((value shr 8) and 0xFF) / 255f
val blue = (value and 0xFF) / 255f
return Color(red = red, green = green, blue = blue, alpha = alpha.takeIf { it > 0f } ?: 1f)
}
@Composable
internal fun LibraryScreen(
state: SharedReaderScreenState,
selectedLibraryTab: NonReaderLibraryTab,
onLibraryTabChange: (NonReaderLibraryTab) -> Unit,
onStateChange: (SharedReaderScreenState) -> Unit,
onImportBooks: () -> Unit,
onRead: (BookItem) -> Unit,
onSelect: (String) -> Unit,
onClearSelection: () -> Unit,
onRemoveSelected: () -> Unit,
onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit,
onCreateShelf: () -> Unit,
onCreateShelfWithBooks: (String, Set<String>) -> Unit,
onCreateSmartShelf: () -> Unit,
onRenameShelf: (Shelf) -> Unit,
onDeleteShelf: (Shelf) -> Unit,
onRemoveFolder: (Shelf) -> Unit,
onTagSelectedBooks: () -> Unit,
onAddSelectedBooksToShelf: () -> Unit,
onAddBooksToShelf: (Set<String>) -> Unit,
onManageShelfBooks: (Shelf) -> Unit,
onImportFolder: () -> Unit,
onSyncFolderMetadata: () -> Unit,
onScanFolders: () -> Unit,
onTogglePinned: (BookItem) -> Unit,
onSaveOriginalFile: (BookItem) -> Unit = {}
) {
SharedLibraryScreen(
state = state,
selectedTab = selectedLibraryTab,
onTabChange = onLibraryTabChange,
onStateChange = onStateChange,
onImportBooks = onImportBooks,
onOpenBook = onRead,
onToggleSelection = onSelect,
onClearSelection = onClearSelection,
onRemoveSelected = onRemoveSelected,
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onCreateShelf = onCreateShelf,
onCreateShelfWithBooks = onCreateShelfWithBooks,
onCreateSmartShelf = onCreateSmartShelf,
onRenameShelf = onRenameShelf,
onDeleteShelf = onDeleteShelf,
onRemoveFolder = onRemoveFolder,
onTagSelectedBooks = onTagSelectedBooks,
onAddSelectedBooksToShelf = onAddSelectedBooksToShelf,
onAddBooksToShelf = onAddBooksToShelf,
onManageShelfBooks = onManageShelfBooks,
onImportFolder = onImportFolder,
onSyncFolderMetadata = onSyncFolderMetadata,
onScanFolders = onScanFolders,
onTogglePinned = onTogglePinned,
onSaveOriginalFile = onSaveOriginalFile,
platform = ReaderPlatform.DESKTOP,
useImportEmptyStateWhenLibraryEmpty = true
)
}
private data class DesktopSmartRuleDraft(
val field: SmartField = SmartField.TITLE,
val operator: SmartOperator = SmartOperator.CONTAINS,
val value: String = ""
) {
fun toRule(): SmartRule? {
val trimmed = value.trim()
if (trimmed.isBlank()) return null
return SmartRule(field = field, operator = operator, value = trimmed)
}
}
@Composable
internal fun SmartShelfDialog(
onDismiss: () -> Unit,
onConfirm: (String, SmartCollectionDefinition) -> Unit
) {
var name by remember { mutableStateOf("") }
var matchAll by remember { mutableStateOf(true) }
var rules by remember { mutableStateOf(listOf(DesktopSmartRuleDraft())) }
val validRules = rules.mapNotNull { it.toRule() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(readerString("desktop_create_smart_shelf", "Create smart shelf")) },
text = {
Column(
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
SharedStableOutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(readerString("shelf_name_hint", "Shelf name")) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
FilterChip(
selected = matchAll,
onClick = { matchAll = true },
label = { Text(readerString("filter_all", "All")) }
)
FilterChip(
selected = !matchAll,
onClick = { matchAll = false },
label = { Text(readerString("desktop_match_any", "Any")) }
)
Spacer(Modifier.weight(1f))
TextButton(
onClick = { rules = rules + DesktopSmartRuleDraft() },
enabled = rules.size < 4
) {
Text(readerString("tts_replacements_add_rule", "Add rule"))
}
}
rules.forEachIndexed { index, draft ->
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
SmartRuleDropdown(
label = readerString("desktop_field", "Field"),
selected = draft.field,
options = SmartField.entries.toList(),
optionLabel = { it.localizedLabel() },
onSelected = { field ->
rules = rules.updateAt(index) {
val operator = smartOperatorsFor(field).first()
copy(field = field, operator = operator, value = "")
}
}
)
SmartRuleDropdown(
label = readerString("desktop_operator", "Operator"),
selected = draft.operator,
options = smartOperatorsFor(draft.field),
optionLabel = { it.localizedLabel() },
onSelected = { operator ->
rules = rules.updateAt(index) { copy(operator = operator) }
}
)
if (rules.size > 1) {
TextButton(onClick = { rules = rules.filterIndexed { i, _ -> i != index } }) {
Text(readerString("action_remove", "Remove"))
}
}
}
SharedStableOutlinedTextField(
value = draft.value,
onValueChange = { value -> rules = rules.updateAt(index) { copy(value = value) } },
label = { Text(draft.field.localizedValueLabel()) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
selectionKey = index
)
}
}
}
},
confirmButton = {
TextButton(
onClick = {
onConfirm(name, SmartCollectionDefinition(matchAll = matchAll, rules = validRules))
},
enabled = name.isNotBlank() && validRules.isNotEmpty()
) {
Text(readerString("action_create", "Create"))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(readerString("action_cancel", "Cancel"))
}
}
)
}
@Composable
private fun <T> SmartRuleDropdown(
label: String,
selected: T,
options: List<T>,
optionLabel: @Composable (T) -> String,
onSelected: (T) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Box {
TextButton(onClick = { expanded = true }) {
val selectedLabel = optionLabel(selected)
Text(readerString("filter_facet", "%1\$s: %2\$s", label, selectedLabel))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(optionLabel(option)) },
onClick = {
expanded = false
onSelected(option)
}
)
}
}
}
}
private fun smartOperatorsFor(field: SmartField): List<SmartOperator> {
return when (field) {
SmartField.PROGRESS -> listOf(SmartOperator.GREATER_THAN, SmartOperator.LESS_THAN, SmartOperator.EQUALS)
else -> listOf(SmartOperator.CONTAINS, SmartOperator.EQUALS)
}
}
@Composable
private fun SmartField.localizedLabel(): String {
return when (this) {
SmartField.TITLE -> readerString("label_title", "Title")
SmartField.AUTHOR -> readerString("author", "Author")
SmartField.PROGRESS -> readerString("desktop_progress", "Progress")
SmartField.FILE_TYPE -> readerString("filter_file_type", "File type")
SmartField.FOLDER -> readerString("desktop_smart_field_folder", "Folder")
SmartField.TAG -> readerString("content_desc_tag", "Tag")
}
}
@Composable
private fun SmartField.localizedValueLabel(): String {
return when (this) {
SmartField.PROGRESS -> readerString("desktop_percent", "Percent")
SmartField.FILE_TYPE -> readerString("desktop_type_example_pdf", "Type, e.g. PDF")
SmartField.FOLDER -> readerString("desktop_folder_path", "Folder path")
SmartField.TAG -> readerString("desktop_tag_name", "Tag name")
SmartField.TITLE -> readerString("desktop_title_text", "Title text")
SmartField.AUTHOR -> readerString("desktop_author_text", "Author text")
}
}
@Composable
private fun SmartOperator.localizedLabel(): String {
return when (this) {
SmartOperator.EQUALS -> readerString("desktop_equals", "Equals")
SmartOperator.CONTAINS -> readerString("desktop_contains", "Contains")
SmartOperator.GREATER_THAN -> readerString("desktop_greater_than", "Greater than")
SmartOperator.LESS_THAN -> readerString("desktop_less_than", "Less than")
}
}
private inline fun List<DesktopSmartRuleDraft>.updateAt(
index: Int,
transform: DesktopSmartRuleDraft.() -> DesktopSmartRuleDraft
): List<DesktopSmartRuleDraft> {
return mapIndexed { i, draft -> if (i == index) draft.transform() else draft }
}
internal fun SharedReaderScreenState.withBanner(message: String, isError: Boolean = false): SharedReaderScreenState {
return reduce(AppAction.BannerShown(BannerMessage(message, isError = isError)))
}
internal object DesktopFolderPathResolver : SharedFolderPathResolver {
override fun relativeFolderSegments(item: BookItem): List<String> {
val sourceFolder = item.sourceFolder ?: return emptyList()
val bookPath = item.path ?: return emptyList()
val parentFile = File(bookPath).parentFile ?: return emptyList()
val paths = runCatching {
File(sourceFolder).toPath().toAbsolutePath().normalize() to
parentFile.toPath().toAbsolutePath().normalize()
}.getOrNull() ?: return emptyList()
val (root, parent) = paths
if (!parent.startsWith(root) || parent == root) return emptyList()
return root.relativize(parent).map { it.toString() }.filter { it.isNotBlank() }
}
}
internal fun List<BookItem>.collectTags(): List<Tag> {
return flatMap { it.tags }.distinctBy { it.id }.sortedBy { it.name.lowercase() }
}
internal fun BookItem.cardTitleForMessage(): String {
return title?.takeIf { it.isNotBlank() } ?: displayName
}
internal fun Long.toReadableSize(): String {
if (this <= 0L) return "Unknown"
val units = listOf("B", "KB", "MB", "GB", "TB")
var value = this.toDouble()
var unitIndex = 0
while (value >= 1024.0 && unitIndex < units.lastIndex) {
value /= 1024.0
unitIndex += 1
}
return if (unitIndex == 0) {
"$this ${units[unitIndex]}"
} else {
"${String.format("%.1f", value)} ${units[unitIndex]}"
}
}

View file

@ -0,0 +1,951 @@
package org.dueattendant149.bookreader.desktop
import org.dueattendant149.bookreader.shared.BookItem
import org.dueattendant149.bookreader.shared.BookShelfRef
import org.dueattendant149.bookreader.shared.FileType
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_ANNOTATION_SUFFIX
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
import org.dueattendant149.bookreader.shared.LocalFolderSyncEngine
import org.dueattendant149.bookreader.shared.LocalFolderSyncStats
import org.dueattendant149.bookreader.shared.ReaderPlatform
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
import org.dueattendant149.bookreader.shared.SharedFolderBookMetadata
import org.dueattendant149.bookreader.shared.SharedFolderScannedFile
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
import org.dueattendant149.bookreader.shared.SyncedFolder
import org.dueattendant149.bookreader.shared.localFolderSyncAnnotationFileName
import org.dueattendant149.bookreader.shared.localFolderSyncAnnotationTempFileName
import org.dueattendant149.bookreader.shared.localFolderSyncMetadataFileName
import org.dueattendant149.bookreader.shared.localFolderSyncMetadataTempFileName
import org.dueattendant149.bookreader.shared.localFolderSyncSidecarStem
import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer
import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec
import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextLog
import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer
import org.dueattendant149.bookreader.shared.toSharedFolderBookMetadata
import org.dueattendant149.bookreader.shared.toStablePositionCfi
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.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import java.io.File
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
data class DesktopLocalFolderSyncResult(
val state: SharedReaderScreenState,
val shelfRefs: List<BookShelfRef>,
val stats: LocalFolderSyncStats,
val metadataStats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats(),
val idMigrations: Map<String, String> = emptyMap(),
val removedBookIds: Set<String> = emptySet(),
val failedFolders: List<String> = emptyList(),
val processedFolderUris: List<String> = emptyList()
)
object DesktopLocalFolderSync {
private val desktopSyncableTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
fun hasSupportedFiles(folder: File): Boolean {
if (!folder.isDirectory) return false
return folder.walkTopDown()
.onEnter { it == folder || it.shouldEnterSyncedFolder() }
.onFail { file, error ->
logDesktopFolderSync(
"folder.supportedFiles.skipInaccessible path=\"${file.absolutePath.folderSyncPreview()}\" " +
"error=${error.folderSyncSummary()}"
)
}
.any { file ->
runCatching {
file.isFile &&
file.shouldSyncBookFile() &&
SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes
}.getOrDefault(false)
}
}
fun sync(
state: SharedReaderScreenState,
shelfRefs: List<BookShelfRef>,
targetFolder: File? = null,
nowMillis: Long = System.currentTimeMillis(),
metadataOnly: Boolean = false,
extractMetadata: Boolean = true
): DesktopLocalFolderSyncResult {
val requestedFolders = foldersToSync(state, targetFolder, nowMillis)
.filter { it.localSyncEnabled }
val mode = if (metadataOnly) "metadata" else "full"
logDesktopFolderSync(
"sync.start mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " +
"requestedFolders=${requestedFolders.size} linkedFolders=${state.syncedFolders.size} " +
"books=${state.rawLibraryBooks.size}"
)
var nextState = state
var nextShelfRefs = shelfRefs
var totalStats = LocalFolderSyncStats()
var totalMetadataStats = DesktopFolderMetadataExtractionStats()
val allMigrations = linkedMapOf<String, String>()
val allRemovedBookIds = linkedSetOf<String>()
val failedFolders = mutableListOf<String>()
val processedFolderUris = mutableListOf<String>()
requestedFolders.forEach { folder ->
val root = File(folder.uriString)
if (!root.isDirectory) {
logDesktopFolderSync(
"folder.skipMissing mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
"root=\"${root.absolutePath.folderSyncPreview()}\""
)
failedFolders += folder.name
return@forEach
}
processedFolderUris += folder.uriString
logDesktopFolderSync(
"folder.start mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
"root=\"${root.absolutePath.folderSyncPreview()}\" allowed=${folder.allowedFileTypes.sortedBy { it.name }}"
)
val scannedFiles = if (metadataOnly) {
emptyList()
} else {
scanFolder(root = root, sourceFolder = folder.uriString)
}
val remoteMetadata = readAllMetadata(root)
logDesktopFolderSync(
"folder.inputs mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
"scanned=${scannedFiles.size} supported=${scannedFiles.count { it.type in folder.allowedFileTypes }} " +
"remoteMetadata=${remoteMetadata.size}"
)
val syncResult = LocalFolderSyncEngine.syncFolder(
state = nextState,
folder = folder,
files = scannedFiles,
remoteMetadata = remoteMetadata,
nowMillis = nowMillis,
metadataOnly = metadataOnly
)
nextState = syncResult.state
nextShelfRefs = LocalFolderSyncEngine.applyIdMigrationsToShelfRefs(
nextShelfRefs,
syncResult.idMigrations
).filterNot { it.bookId in syncResult.removedBookIds }
allMigrations += syncResult.idMigrations
allRemovedBookIds += syncResult.removedBookIds
totalStats += syncResult.stats
logDesktopFolderSync(
"folder.engine mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
"new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " +
"remoteUpdates=${syncResult.stats.remoteMetadataUpdates} removed=${syncResult.stats.removedBooks} " +
"migrated=${syncResult.stats.migratedBooks} idMigrations=${syncResult.idMigrations.size}"
)
var syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
logDesktopFolderSync(
"folder.sidecars.importCheck mode=$mode name=\"${folder.name.folderSyncPreview()}\" books=${syncedBooks.size}"
)
runCatching {
importAnnotationSidecars(root, syncedBooks)
}.onFailure { error ->
logDesktopFolderSync(
"annotation.import.failed mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
"root=\"${root.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}"
)
}
syncedBooks.forEach { book ->
remoteMetadata[book.id]?.let { metadata ->
runCatching {
importDesktopPdfBookmarksMetadata(book, metadata.bookmarksJson, metadata.lastModifiedTimestamp)
}.onFailure { error ->
logDesktopFolderSync(
"metadata.bookmarks.importFailed book=${book.id} " +
"error=${error.folderSyncSummary()}"
)
}
}
}
if (!metadataOnly && extractMetadata) {
val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks(
books = nextState.rawLibraryBooks,
sourceFolder = folder.uriString
)
if (metadataResult.stats.updatedBooks > 0) {
nextState = nextState.copy(rawLibraryBooks = metadataResult.books)
syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
}
totalMetadataStats += metadataResult.stats
logDesktopFolderSync(
"folder.metadataExtraction name=\"${folder.name.folderSyncPreview()}\" " +
"updated=${metadataResult.stats.updatedBooks} covers=${metadataResult.stats.coversUpdated}"
)
}
syncedBooks.forEach { book ->
saveBookMetadata(book)
if (!metadataOnly) {
savePdfAnnotationSidecar(book)
}
}
logDesktopFolderSync(
"folder.done mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
"savedCandidates=${syncedBooks.size}"
)
}
val result = DesktopLocalFolderSyncResult(
state = nextState,
shelfRefs = nextShelfRefs,
stats = totalStats,
metadataStats = totalMetadataStats,
idMigrations = allMigrations,
removedBookIds = allRemovedBookIds,
failedFolders = failedFolders,
processedFolderUris = processedFolderUris
)
logDesktopFolderSync(
"sync.done mode=$mode failed=${failedFolders.size} new=${totalStats.newBooks} " +
"updated=${totalStats.updatedBooks} remoteUpdates=${totalStats.remoteMetadataUpdates} " +
"removed=${totalStats.removedBooks} metadataExtracted=${totalMetadataStats.updatedBooks}"
)
return result
}
fun saveBookSidecars(book: BookItem) {
saveBookMetadata(book)
savePdfAnnotationSidecar(book)
}
fun deleteSyncDataFolder(root: File): Boolean {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
return !syncDir.exists() || syncDir.isDirectory && syncDir.deleteRecursively()
}
fun saveBookMetadata(book: BookItem) {
val metadata = book.toDesktopFolderBookMetadata()
if (metadata == null) {
logDesktopFolderSync(
"metadata.export.skipClean book=${book.id} title=\"${book.title.orEmpty().folderSyncPreview()}\" " +
"progress=${book.progressPercentage} recent=${book.isRecent} bookmarks=${book.readerBookmarks.size} " +
"highlights=${book.readerHighlights.size}"
)
return
}
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory }
if (root == null) {
logDesktopFolderSync(
"metadata.export.skipNoFolder book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\""
)
return
}
logDesktopFolderSync(
"metadata.export.request book=${book.id} timestamp=${metadata.lastModifiedTimestamp} " +
"progress=${metadata.progressPercentage} recent=${metadata.isRecent} " +
"root=\"${root.absolutePath.folderSyncPreview()}\""
)
saveMetadataToFolder(root, metadata)
}
fun savePdfAnnotationSidecar(book: BookItem) {
val path = book.path?.takeIf { it.isNotBlank() }
if (path == null) {
logDesktopFolderSync("annotation.export.skipNoPath book=${book.id}")
return
}
if (book.type != FileType.PDF) {
logDesktopFolderSync("annotation.export.skipNonPdf book=${book.id} type=${book.type}")
return
}
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory }
if (root == null) {
logDesktopFolderSync(
"annotation.export.skipNoFolder book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\""
)
return
}
val annotationFile = desktopPdfAnnotationFile(path)
val bookmarkFile = desktopPdfBookmarkFile(path)
val richTextFile = desktopPdfRichTextFile(path)
logDesktopFolderSync(
"annotation.export.check book=${book.id} root=\"${root.absolutePath.folderSyncPreview()}\" " +
"pdfPath=\"${path.folderSyncPreview()}\" hasAnnotations=${annotationFile.isFile} " +
"hasBookmarks=${bookmarkFile.isFile} hasText=${richTextFile.isFile} " +
"localTs=${maxOf(annotationFile.lastModifiedIfFile(), bookmarkFile.lastModifiedIfFile(), richTextFile.lastModifiedIfFile())}"
)
val data = buildMap {
if (annotationFile.isFile) {
val annotationJson = annotationFile.readText().trim()
desktopPdfAnnotationElementForSync(annotationJson)?.let { annotations ->
put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations)
}
}
if (bookmarkFile.isFile) {
val bookmarksJson = bookmarkFile.readText().trim()
desktopFolderSyncJson.parseElementOrNull(bookmarksJson)?.let { put("bookmarks", it) }
}
if (richTextFile.isFile) {
val richTextJson = richTextFile.readText().trim()
val richTextElement = desktopFolderSyncJson.parseElementOrNull(richTextJson)
if (richTextElement == null) {
SharedPdfRichTextLog.d(
"desktop.sync.exportRichTextParseFailed book=${book.id} " +
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length}"
)
} else {
val richTextDocument = SharedPdfRichTextSerializer.decodeElement(richTextElement)
SharedPdfRichTextLog.d(
"desktop.sync.exportRichText book=${book.id} " +
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length} " +
"textLen=${richTextDocument.text.length} spans=${richTextDocument.spans.size}"
)
desktopPdfRichTextElementForSync(richTextJson)?.let { put("text", it) }
}
}
}
if (data.isEmpty()) {
logDesktopFolderSync(
"annotation.export.skipNoLocalData book=${book.id} pdfPath=\"${path.folderSyncPreview()}\""
)
SharedPdfRichTextLog.d("desktop.sync.exportSkipNoSidecarData book=${book.id} pdfPath=\"${path.richSyncPreview()}\"")
return
}
val timestamp = maxOf(
annotationFile.lastModifiedIfSyncableAnnotations(),
bookmarkFile.lastModifiedIfFile(),
richTextFile.lastModifiedIfSyncableRichText(),
System.currentTimeMillis()
)
val dataJson = desktopFolderSyncJson.encodeToString(
JsonElement.serializer(),
JsonObject(data)
)
logDesktopFolderSync(
"annotation.export.request book=${book.id} timestamp=$timestamp keys=${data.keys.sorted()} " +
"root=\"${root.absolutePath.folderSyncPreview()}\""
)
if (data.containsKey("text")) {
SharedPdfRichTextLog.d(
"desktop.sync.exportSidecar book=${book.id} timestamp=$timestamp " +
"keys=${data.keys.sorted()} root=\"${root.absolutePath.richSyncPreview()}\""
)
}
saveAnnotationSidecar(
root = root,
bookId = book.id,
jsonPayload = dataJson,
timestamp = timestamp
)
}
private fun foldersToSync(
state: SharedReaderScreenState,
targetFolder: File?,
nowMillis: Long
): List<SyncedFolder> {
if (targetFolder == null) return state.syncedFolders
val root = targetFolder.canonicalOrAbsolute()
val rootPath = root.absolutePath
val existing = state.syncedFolders.firstOrNull { File(it.uriString).canonicalOrAbsolute() == root }
return listOf(
existing ?: SyncedFolder(
uriString = rootPath,
name = root.name.takeIf { it.isNotBlank() } ?: rootPath,
lastScanTime = nowMillis,
allowedFileTypes = desktopSyncableTypes
)
)
}
private fun scanFolder(root: File, sourceFolder: String): List<SharedFolderScannedFile> {
val rootPath = root.toPath().toAbsolutePath().normalize()
return root.walkTopDown()
.onEnter { it == root || it.shouldEnterSyncedFolder() }
.onFail { file, error ->
logDesktopFolderSync(
"folder.scan.skipInaccessible root=\"${root.absolutePath.folderSyncPreview()}\" " +
"path=\"${file.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}"
)
}
.filter { file ->
runCatching { file.isFile && file.shouldSyncBookFile() }.getOrDefault(false)
}
.mapNotNull { file ->
val type = SharedFileCapabilities.fileTypeForName(file.name)
.takeIf { it in desktopSyncableTypes }
?: return@mapNotNull null
val relativePath = runCatching {
rootPath.relativize(file.toPath().toAbsolutePath().normalize())
.joinToString("/")
}.getOrNull() ?: file.name
SharedFolderScannedFile(
name = file.name,
path = file.absolutePath,
sourceFolder = sourceFolder,
relativePath = relativePath,
type = type,
size = runCatching { file.length() }.getOrDefault(0L),
lastModified = runCatching { file.lastModified() }.getOrDefault(0L)
)
}
.toList()
}
private fun readAllMetadata(root: File): Map<String, SharedFolderBookMetadata> {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
if (!syncDir.isDirectory) {
logDesktopFolderSync("metadata.read.noSyncDir root=\"${root.absolutePath.folderSyncPreview()}\"")
return emptyMap()
}
var candidates = 0
var parsed = 0
var failed = 0
val result = syncDir.listFiles().orEmpty()
.asSequence()
.filter { it.isFile && it.isMetadataSidecarCandidate() }
.mapNotNull { file ->
candidates++
runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }
.onSuccess { parsed++ }
.onFailure { error ->
failed++
logDesktopFolderSync(
"metadata.read.parseFailed file=\"${file.absolutePath.folderSyncPreview()}\" " +
"error=${error.folderSyncSummary()}"
)
}
.getOrNull()
}
.groupBy { it.bookId }
.mapValues { (_, metadata) -> metadata.maxBy { it.lastModifiedTimestamp } }
.toMap()
logDesktopFolderSync(
"metadata.read.done root=\"${root.absolutePath.folderSyncPreview()}\" " +
"candidates=$candidates parsed=$parsed failed=$failed winners=${result.size}"
)
return result
}
private fun saveMetadataToFolder(root: File, metadata: SharedFolderBookMetadata) {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
val existing = resolveMetadataConflicts(syncDir, metadata.bookId, cleanup = true)
if (existing != null && existing.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
logDesktopFolderSync(
"metadata.save.skipNewerRemote book=${metadata.bookId} existingTs=${existing.lastModifiedTimestamp} " +
"candidateTs=${metadata.lastModifiedTimestamp} root=\"${root.absolutePath.folderSyncPreview()}\""
)
return
}
val target = File(syncDir, localFolderSyncMetadataFileName(metadata.bookId))
val temp = File(syncDir, uniqueFolderSyncTempName(localFolderSyncMetadataTempFileName(metadata.bookId)))
runCatching {
temp.writeText(metadata.toJsonString())
moveReplacing(temp, target)
logDesktopFolderSync(
"metadata.save.done book=${metadata.bookId} timestamp=${metadata.lastModifiedTimestamp} " +
"target=\"${target.absolutePath.folderSyncPreview()}\" bytes=${target.length()}"
)
}.onFailure {
logDesktopFolderSync(
"metadata.save.failed book=${metadata.bookId} timestamp=${metadata.lastModifiedTimestamp} " +
"target=\"${target.absolutePath.folderSyncPreview()}\" error=${it.folderSyncSummary()}"
)
runCatching { temp.delete() }
}
}
private fun resolveMetadataConflicts(
syncDir: File,
bookId: String,
cleanup: Boolean
): SharedFolderBookMetadata? {
val hashedStem = localFolderSyncSidecarStem(bookId)
val candidates = syncDir.listFiles().orEmpty().filter { file ->
val normalized = file.normalizedSidecarName()
file.isFile &&
file.isMetadataSidecarCandidate() &&
(
normalized.matchesJsonSidecarStem(hashedStem) ||
normalized.matchesJsonSidecarStem(bookId)
)
}
if (candidates.isEmpty()) return null
if (candidates.size > 1) {
logDesktopFolderSync(
"metadata.conflicts book=$bookId candidates=${candidates.size} " +
"dir=\"${syncDir.absolutePath.folderSyncPreview()}\" cleanup=$cleanup"
)
}
val parsed = candidates.mapNotNull { file ->
val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }
.onFailure { error ->
logDesktopFolderSync(
"metadata.conflict.parseFailed book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\" " +
"error=${error.folderSyncSummary()}"
)
}
.getOrNull()
metadata?.takeIf { it.bookId == bookId }?.let { file to it }
}
val winner = parsed.maxByOrNull { it.second.lastModifiedTimestamp } ?: return null
if (cleanup) {
candidates
.filterNot { it == winner.first }
.forEach { file ->
runCatching { file.delete() }
logDesktopFolderSync(
"metadata.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\""
)
}
val correctName = localFolderSyncMetadataFileName(bookId)
if (winner.first.name != correctName) {
val target = File(syncDir, correctName)
runCatching { moveReplacing(winner.first, target) }
.onSuccess {
logDesktopFolderSync(
"metadata.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\""
)
}
}
}
return winner.second
}
private fun preloadAnnotationSidecars(root: File): Map<String, AnnotationSidecar> {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
if (!syncDir.isDirectory) {
logDesktopFolderSync("annotation.read.noSyncDir root=\"${root.absolutePath.folderSyncPreview()}\"")
return emptyMap()
}
var candidates = 0
var parsed = 0
val result = syncDir.listFiles().orEmpty()
.asSequence()
.filter { it.isFile && it.isAnnotationSidecarCandidate() }
.mapNotNull { file ->
candidates++
file.readAnnotationSidecarOrNull(fallbackBookId = file.legacyAnnotationBookIdOrNull())
?.also { parsed++ }
}
.groupBy { it.bookId }
.mapValues { (_, sidecars) -> sidecars.maxBy { it.timestamp } }
.toMap()
logDesktopFolderSync(
"annotation.read.done root=\"${root.absolutePath.folderSyncPreview()}\" " +
"candidates=$candidates parsed=$parsed winners=${result.size}"
)
return result
}
private fun importAnnotationSidecars(root: File, books: List<BookItem>) {
if (books.isEmpty()) {
logDesktopFolderSync("annotation.import.skipNoBooks root=\"${root.absolutePath.folderSyncPreview()}\"")
return
}
val sidecars = preloadAnnotationSidecars(root)
if (sidecars.isEmpty()) {
logDesktopFolderSync(
"annotation.import.skipNoSidecars root=\"${root.absolutePath.folderSyncPreview()}\" books=${books.size}"
)
return
}
books.forEach { book ->
val path = book.path?.takeIf { it.isNotBlank() }
if (path == null) {
logDesktopFolderSync("annotation.import.skipNoPath book=${book.id}")
return@forEach
}
if (book.type != FileType.PDF) {
logDesktopFolderSync("annotation.import.skipNonPdf book=${book.id} type=${book.type}")
return@forEach
}
val sidecar = sidecars[book.id]
if (sidecar == null) {
logDesktopFolderSync(
"annotation.import.skipNoMatchingSidecar book=${book.id} available=${sidecars.keys.size} " +
"root=\"${root.absolutePath.folderSyncPreview()}\""
)
return@forEach
}
val annotationFile = desktopPdfAnnotationFile(path)
val bookmarkFile = desktopPdfBookmarkFile(path)
val richTextFile = desktopPdfRichTextFile(path)
val localTimestamp = maxOf(
annotationFile.lastModifiedIfFile(),
bookmarkFile.lastModifiedIfFile(),
richTextFile.lastModifiedIfFile()
)
logDesktopFolderSync(
"annotation.import.compare book=${book.id} remoteTs=${sidecar.timestamp} localTs=$localTimestamp " +
"keys=${sidecar.data.keys.sorted()}"
)
if (sidecar.timestamp <= localTimestamp + 1000L) {
logDesktopFolderSync(
"annotation.import.skipOlder book=${book.id} remoteTs=${sidecar.timestamp} localTs=$localTimestamp"
)
if (sidecar.data.containsKey("text") || richTextFile.isFile) {
SharedPdfRichTextLog.d(
"desktop.sync.importSkipOlder book=${book.id} sidecarTs=${sidecar.timestamp} " +
"localTs=$localTimestamp hasSidecarText=${sidecar.data.containsKey("text")} " +
"richFile=\"${richTextFile.absolutePath.richSyncPreview()}\""
)
}
return@forEach
}
if (sidecar.data.hasPdfAnnotationPayload()) {
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(sidecar.data)
if (annotations.isEmpty()) {
if (annotationFile.isFile) annotationFile.delete()
logDesktopFolderSync("annotation.import.deleteEmptyAnnotations book=${book.id}")
} else {
annotationFile.parentFile?.mkdirs()
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
annotationFile.setLastModified(sidecar.timestamp)
logDesktopFolderSync(
"annotation.import.writeAnnotations book=${book.id} count=${annotations.size} " +
"file=\"${annotationFile.absolutePath.folderSyncPreview()}\""
)
}
}
sidecar.data["bookmarks"]?.let { bookmarks ->
bookmarkFile.parentFile?.mkdirs()
bookmarkFile.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), bookmarks))
bookmarkFile.setLastModified(sidecar.timestamp)
logDesktopFolderSync(
"annotation.import.writeBookmarks book=${book.id} file=\"${bookmarkFile.absolutePath.folderSyncPreview()}\""
)
}
sidecar.data["text"]?.let { richText ->
val richDocument = SharedPdfRichTextSerializer.decodeElement(richText)
SharedPdfRichTextLog.d(
"desktop.sync.importRichText book=${book.id} timestamp=${sidecar.timestamp} " +
"textLen=${richDocument.text.length} spans=${richDocument.spans.size} " +
"file=\"${richTextFile.absolutePath.richSyncPreview()}\""
)
if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) {
if (richTextFile.isFile) richTextFile.delete()
logDesktopFolderSync("annotation.import.deleteEmptyText book=${book.id}")
} else {
richTextFile.parentFile?.mkdirs()
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
richTextFile.setLastModified(sidecar.timestamp)
logDesktopFolderSync(
"annotation.import.writeText book=${book.id} textLen=${richDocument.text.length} " +
"spans=${richDocument.spans.size} file=\"${richTextFile.absolutePath.folderSyncPreview()}\""
)
}
}
}
}
private fun saveAnnotationSidecar(
root: File,
bookId: String,
jsonPayload: String,
timestamp: Long
) {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
val data = desktopFolderSyncJson.parseElementOrNull(jsonPayload)?.jsonObjectOrNull()
if (data == null) {
logDesktopFolderSync(
"annotation.save.skipInvalidPayload book=$bookId timestamp=$timestamp " +
"root=\"${root.absolutePath.folderSyncPreview()}\" payloadLen=${jsonPayload.length}"
)
return
}
val existing = resolveAnnotationConflicts(syncDir, bookId, cleanup = true)
if (existing != null && existing.timestamp >= timestamp) {
logDesktopFolderSync(
"annotation.save.skipNewerExisting book=$bookId existingTs=${existing.timestamp} " +
"candidateTs=$timestamp root=\"${root.absolutePath.folderSyncPreview()}\" keys=${data.keys.sorted()}"
)
if (data.containsKey("text")) {
SharedPdfRichTextLog.d(
"desktop.sync.saveSidecarSkipExisting book=$bookId existingTs=${existing.timestamp} " +
"candidateTs=$timestamp targetRoot=\"${root.absolutePath.richSyncPreview()}\""
)
}
return
}
val wrapper = JsonObject(
mapOf(
"version" to JsonPrimitive(1),
"bookId" to JsonPrimitive(bookId),
"timestamp" to JsonPrimitive(timestamp),
"data" to data
)
)
val target = File(syncDir, localFolderSyncAnnotationFileName(bookId))
val temp = File(syncDir, uniqueFolderSyncTempName(localFolderSyncAnnotationTempFileName(bookId)))
runCatching {
temp.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), wrapper))
moveReplacing(temp, target)
logDesktopFolderSync(
"annotation.save.done book=$bookId timestamp=$timestamp keys=${data.keys.sorted()} " +
"target=\"${target.absolutePath.folderSyncPreview()}\" bytes=${target.length()}"
)
if (data.containsKey("text")) {
SharedPdfRichTextLog.d(
"desktop.sync.saveSidecar book=$bookId timestamp=$timestamp " +
"target=\"${target.absolutePath.richSyncPreview()}\""
)
}
}.onFailure {
logDesktopFolderSync(
"annotation.save.failed book=$bookId timestamp=$timestamp keys=${data.keys.sorted()} " +
"target=\"${target.absolutePath.folderSyncPreview()}\" error=${it.folderSyncSummary()}"
)
if (data.containsKey("text")) {
SharedPdfRichTextLog.d(
"desktop.sync.saveSidecarFailed book=$bookId timestamp=$timestamp " +
"target=\"${target.absolutePath.richSyncPreview()}\" error=${it.message}"
)
}
runCatching { temp.delete() }
}
}
private fun resolveAnnotationConflicts(
syncDir: File,
bookId: String,
cleanup: Boolean
): AnnotationSidecar? {
val parsed = syncDir.listFiles().orEmpty()
.filter { file -> file.isFile && file.isAnnotationSidecarCandidate() }
.mapNotNull { file ->
file.readAnnotationSidecarOrNull(fallbackBookId = file.legacyAnnotationBookIdOrNull())
?.takeIf { it.bookId == bookId }
?.let { file to it }
}
if (parsed.isEmpty()) return null
if (parsed.size > 1) {
logDesktopFolderSync(
"annotation.conflicts book=$bookId candidates=${parsed.size} " +
"dir=\"${syncDir.absolutePath.folderSyncPreview()}\" cleanup=$cleanup"
)
}
val winner = parsed.maxByOrNull { it.second.timestamp } ?: return null
if (cleanup) {
parsed.map { it.first }
.filterNot { it == winner.first }
.forEach { file ->
runCatching { file.delete() }
logDesktopFolderSync(
"annotation.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\""
)
}
val correctName = localFolderSyncAnnotationFileName(bookId)
if (winner.first.name != correctName) {
val target = File(syncDir, correctName)
runCatching { moveReplacing(winner.first, target) }
.onSuccess {
logDesktopFolderSync(
"annotation.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\""
)
}
}
}
return winner.second
}
}
private data class AnnotationSidecar(
val bookId: String,
val timestamp: Long,
val data: JsonObject
)
private val desktopFolderSyncJson = Json {
ignoreUnknownKeys = true
prettyPrint = true
encodeDefaults = true
}
private fun File.shouldEnterSyncedFolder(): Boolean {
if (!isDirectory) return false
if (name == LOCAL_FOLDER_SYNC_DATA_DIR) return false
if (name.startsWith(".")) return false
return runCatching { !isHidden }.getOrDefault(true)
}
private fun File.shouldSyncBookFile(): Boolean {
if (name.startsWith(".")) return false
if (extension.equals("json", ignoreCase = true)) return false
return parentFile?.name != LOCAL_FOLDER_SYNC_DATA_DIR
}
private fun File.isMetadataSidecarCandidate(): Boolean {
val fileName = name
if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return false
if (fileName.contains(".tmp") || fileName.contains(".syncthing.")) return false
return fileName.endsWith(".json") || fileName.contains(".sync-conflict")
}
private fun File.isAnnotationSidecarCandidate(): Boolean {
val fileName = name
if (!fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return false
if (fileName.contains(".tmp") || fileName.contains(".syncthing.")) return false
return fileName.endsWith(".json") || fileName.contains(".sync-conflict")
}
private fun File.legacyAnnotationBookIdOrNull(): String? {
var candidate = name
if (!isAnnotationSidecarCandidate()) return null
if (candidate.contains(".sync-conflict")) {
candidate = candidate.substringBefore(".sync-conflict")
}
candidate = candidate.substringBeforeLast(".json")
if (candidate.endsWith(LOCAL_FOLDER_ANNOTATION_SUFFIX)) {
candidate = candidate.substring(0, candidate.length - LOCAL_FOLDER_ANNOTATION_SUFFIX.length)
}
val normalized = candidate.removePrefix(".")
if (normalized.startsWith(LOCAL_FOLDER_SIDECAR_HASH_PREFIX)) return null
return normalized.takeIf { it.isNotBlank() }
}
private fun File.normalizedSidecarName(): String {
return name.removePrefix(".")
}
private fun String.matchesJsonSidecarStem(stem: String): Boolean {
return this == "$stem.json" ||
startsWith("$stem.sync-conflict") ||
startsWith("$stem.json.sync-conflict")
}
private fun File.readAnnotationSidecarOrNull(fallbackBookId: String? = null): AnnotationSidecar? {
return runCatching {
val root = desktopFolderSyncJson.parseToJsonElement(readText()).jsonObject
val bookId = root["bookId"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?: fallbackBookId
?: error("Missing annotation sidecar bookId")
val timestamp = root["timestamp"]?.jsonPrimitive?.longOrNull ?: 0L
val data = root["data"]?.jsonObjectOrNull() ?: error("Missing annotation sidecar data")
AnnotationSidecar(bookId = bookId, timestamp = timestamp, data = data)
}.onFailure { error ->
logDesktopFolderSync(
"annotation.read.parseFailed file=\"${absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}"
)
}.getOrNull()
}
private fun Json.parseElementOrNull(raw: String): JsonElement? {
return runCatching { parseToJsonElement(raw) }.getOrNull()
}
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
if (this is JsonNull) return null
return runCatching { jsonObject }.getOrNull()
}
private fun JsonObject.hasPdfAnnotationPayload(): Boolean {
return containsKey(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) ||
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_INK) ||
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_TEXT_BOXES) ||
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_HIGHLIGHTS)
}
private fun File.canonicalOrAbsolute(): File {
return runCatching { canonicalFile }.getOrElse { absoluteFile }
}
private fun File.lastModifiedIfFile(): Long {
return if (isFile) lastModified() else 0L
}
private fun File.hasSyncablePdfAnnotations(): Boolean {
return isFile && desktopPdfAnnotationElementForSync(readText()) != null
}
private fun File.lastModifiedIfSyncableAnnotations(): Long {
return if (hasSyncablePdfAnnotations()) lastModified() else 0L
}
private fun File.hasSyncablePdfRichText(): Boolean {
return isFile && desktopPdfRichTextElementForSync(readText()) != null
}
private fun File.lastModifiedIfSyncableRichText(): Long {
return if (hasSyncablePdfRichText()) lastModified() else 0L
}
private fun BookItem.toDesktopFolderBookMetadata(): SharedFolderBookMetadata? {
val base = toSharedFolderBookMetadata()
val pdfBookmarksJson = desktopPdfBookmarksMetadataJson(this)
if (base == null && pdfBookmarksJson == null) return null
val timestamp = maxOf(
base?.lastModifiedTimestamp ?: 0L,
desktopPdfBookmarkMetadataTimestamp(this),
this.timestamp
)
return (base ?: SharedFolderBookMetadata(
bookId = id,
title = null,
author = null,
displayName = displayName,
type = type.name,
lastChapterIndex = readerPosition?.chapterIndex,
lastPage = readerPosition?.pageIndex ?: lastPageIndex,
lastPositionCfi = readerPosition?.toStablePositionCfi(),
progressPercentage = progressPercentage ?: 0f,
isRecent = isRecent,
lastModifiedTimestamp = timestamp,
bookmarksJson = null,
locatorBlockIndex = readerPosition?.blockIndex,
locatorCharOffset = readerPosition?.charOffset,
customName = null,
highlightsJson = null
)).copy(
lastModifiedTimestamp = timestamp,
bookmarksJson = pdfBookmarksJson ?: base?.bookmarksJson
)
}
private fun uniqueFolderSyncTempName(baseName: String): String {
val stem = baseName.removeSuffix(".tmp")
val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}"
return "$stem.$nonce.tmp"
}
private fun String.richSyncPreview(maxLength: Int = 160): String {
return replace(Regex("\\s+"), " ")
.trim()
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
.replace("\"", "\\\"")
}
private fun moveReplacing(source: File, target: File) {
target.parentFile?.mkdirs()
try {
Files.move(
source.toPath(),
target.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
source.toPath(),
target.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
}
}

Some files were not shown because too many files have changed in this diff Show more