Compare commits
No commits in common. "8e94eb3850a959cbb0bfd3cd25ebba1a6dc5e08b" and "5f64f3d722e635ac8ae50c5b24e9994fe25128b7" have entirely different histories.
8e94eb3850
...
5f64f3d722
385 changed files with 53192 additions and 2964 deletions
36
AGENTS.md
36
AGENTS.md
|
|
@ -62,39 +62,15 @@
|
|||
## Build
|
||||
|
||||
```bash
|
||||
# Required env on this server (proxy + Android SDK)
|
||||
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
|
||||
export ANDROID_HOME=/home/dueattendant149/android-sdk
|
||||
export ANDROID_SDK_ROOT=/home/dueattendant149/android-sdk
|
||||
./gradlew :app:assembleOssDebug
|
||||
```
|
||||
|
||||
**Known build workarounds (Phase 1 baseline):**
|
||||
- Gradle wrapper pinned to 8.13 (8.11.1 unavailable behind proxy; 9.5.1 incompatible with AGP 8.9).
|
||||
- `externalNativeBuild` temporarily disabled because NDK 27.0.12077973 is requested but only NDK 28.2.x is installed.
|
||||
- Network proxy is configured in `gradle.properties` (`systemProp.http[s].proxyHost/Port`).
|
||||
|
||||
## Status
|
||||
|
||||
- Phase 0: clone + package rename — completed
|
||||
- Phase 1: KMP → Android-only — completed
|
||||
- Phase 2: backend swap (bookshelf-api + ABS) — completed
|
||||
- Phase 2.1: bookshelf-api + ABS API services/models, Hilt DI, Retrofit/OkHttp
|
||||
- Phase 2.2: BookshelfViewModel + BookshelfLibraryScreen + nav entry
|
||||
- Phase 2.3: ServerSettingsScreen (URL/token config)
|
||||
- Phase 2.4: download/open ebook from server in reader
|
||||
- Phase 2.5: removed OPDS/Gutenberg/LocalFolder/CloudSync code + stubs
|
||||
- Phase 3: UI redesign (Myne-style) — completed
|
||||
- Material You 3 shape scheme (round cards 8-32dp)
|
||||
- Dynamic color (Android 12+ dynamicColorScheme + materialkolor seed)
|
||||
- BookshelfLibraryScreen + ServerSettingsScreen redesigned with round cards, CenterAlignedTopAppBar
|
||||
- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — completed
|
||||
- RSVP engine (RsvpEngine, RsvpTokenizer, RsvpToken, ReaderText)
|
||||
- Audio player (AudioPlaybackService, ExoPlayer, MediaSession, PlaybackModule)
|
||||
- TTS repository (RemoteTtsRepository + impl wrapping BookshelfApiRepository)
|
||||
- Workers (no-op stubs for CacheDownload, ProgressSync, TtsDownload)
|
||||
- Phase 5: reader engine integration — completed
|
||||
- Checkpoint model (serializable reading position)
|
||||
- ChapterDrawer (ModalBottomSheet chapter list)
|
||||
- Volume keys + chapter navigation already present
|
||||
- Phase 6: build, test, deploy — in progress
|
||||
- Phase 1: KMP → Android-only — pending
|
||||
- Phase 2: backend swap (bookshelf-api + ABS) — pending
|
||||
- Phase 3: UI redesign (Myne-style) — pending
|
||||
- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — pending
|
||||
- Phase 5: reader engine integration — pending
|
||||
- Phase 6: build, test, deploy — pending
|
||||
|
|
@ -11,7 +11,6 @@ plugins {
|
|||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kotlin.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
id("com.diffplug.spotless") version "8.2.1"
|
||||
alias(libs.plugins.kover)
|
||||
}
|
||||
|
|
@ -47,9 +46,7 @@ fun configuredAppLocaleTags(): Set<String> {
|
|||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
}
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
@ -67,13 +64,11 @@ android {
|
|||
.map { it.toAndroidResourceConfiguration() }
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
/*
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags += ""
|
||||
}
|
||||
}
|
||||
*/
|
||||
buildConfigField("boolean", "IS_PRO", "false")
|
||||
buildConfigField("boolean", "IS_OFFLINE", "false")
|
||||
}
|
||||
|
|
@ -167,14 +162,12 @@ android {
|
|||
singleVariant("release") {
|
||||
}
|
||||
}
|
||||
/*
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
}
|
||||
*/
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
unitTests.all {
|
||||
|
|
@ -218,6 +211,8 @@ kover {
|
|||
//noinspection UseTomlInstead
|
||||
dependencies {
|
||||
|
||||
implementation(project(":shared"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
|
@ -229,16 +224,6 @@ dependencies {
|
|||
implementation(libs.androidx.material3.window.size.class1.android)
|
||||
implementation(libs.androidx.credentials)
|
||||
|
||||
// Hilt
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
|
||||
// Networking
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.kotlinx.serialization)
|
||||
implementation(libs.okhttp.logging)
|
||||
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
|
|
|
|||
|
|
@ -52,8 +52,6 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
|
|
@ -61,7 +59,6 @@ import androidx.navigation.NavHostController
|
|||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import org.dueattendant149.bookreader.bookshelf.BookshelfLibraryScreen
|
||||
import org.dueattendant149.bookreader.epubreader.EpubReaderScreen
|
||||
import org.dueattendant149.bookreader.feedback.FeedbackScreen
|
||||
import org.dueattendant149.bookreader.feedback.SupportProjectScreen
|
||||
|
|
@ -82,8 +79,6 @@ object AppDestinations {
|
|||
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
|
||||
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
|
||||
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
|
||||
const val BOOKSHELF_LIBRARY_ROUTE = "bookshelf_library"
|
||||
const val SERVER_SETTINGS_ROUTE = "server_settings"
|
||||
}
|
||||
|
||||
fun shouldInterceptAppNavBack(
|
||||
|
|
@ -432,34 +427,6 @@ fun AppNavigation(
|
|||
onBackClick = { navController.popBackStackIfReady() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = AppDestinations.BOOKSHELF_LIBRARY_ROUTE) {
|
||||
val bookshelfViewModel: org.dueattendant149.bookreader.bookshelf.BookshelfViewModel = hiltViewModel()
|
||||
LaunchedEffect(bookshelfViewModel) {
|
||||
bookshelfViewModel.downloadedFile.collect { uri ->
|
||||
Timber.d("Downloaded book $uri, opening in reader")
|
||||
viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = false)
|
||||
}
|
||||
}
|
||||
BookshelfLibraryScreen(
|
||||
viewModel = bookshelfViewModel,
|
||||
onItemClick = { item ->
|
||||
Timber.d("Bookshelf item selected: ${item.id}")
|
||||
bookshelfViewModel.downloadBook(item)
|
||||
},
|
||||
onOpenSettings = {
|
||||
navController.navigateIfReady(AppDestinations.SERVER_SETTINGS_ROUTE)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = AppDestinations.SERVER_SETTINGS_ROUTE) {
|
||||
val settingsViewModel: org.dueattendant149.bookreader.bookshelf.ServerSettingsViewModel = hiltViewModel()
|
||||
org.dueattendant149.bookreader.bookshelf.ServerSettingsScreen(
|
||||
viewModel = settingsViewModel,
|
||||
onBackClick = { navController.popBackStackIfReady() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?: ""
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -24,11 +24,9 @@ import android.webkit.WebView
|
|||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.SvgDecoder
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher
|
||||
import timber.log.Timber // Add this
|
||||
|
||||
@HiltAndroidApp
|
||||
class MyApplication : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.dueattendant149.bookreader.opds
|
||||
|
||||
typealias OpdsParser = org.dueattendant149.bookreader.shared.opds.SharedOpdsParser
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -783,7 +783,20 @@ class OpdsStreamDocumentWrapper(
|
|||
) : ReaderDocument {
|
||||
private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() }
|
||||
|
||||
private val client = okhttp3.OkHttpClient.Builder().build()
|
||||
private val catalog = catalogId?.let {
|
||||
org.dueattendant149.bookreader.opds.OpdsRepository(context).getCatalogs().find { c -> c.id == it }
|
||||
}
|
||||
|
||||
private val client = org.dueattendant149.bookreader.opds.OpdsRepository.sharedHttpClient.newBuilder()
|
||||
.apply {
|
||||
val streamCatalog = catalog
|
||||
val username = streamCatalog?.username
|
||||
val password = streamCatalog?.password
|
||||
if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
|
||||
authenticator(org.dueattendant149.bookreader.opds.OpdsRepository.OpdsAuthenticator(username, password))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
private fun createErrorPageBytes(): ByteArray {
|
||||
val bitmap = createBitmap(800, 1200)
|
||||
|
|
@ -814,7 +827,18 @@ class OpdsStreamDocumentWrapper(
|
|||
}
|
||||
}
|
||||
|
||||
val finalUrlTemplate = urlTemplate
|
||||
val streamCatalog = catalog
|
||||
val finalUrlTemplate = if (streamCatalog != null && urlTemplate.startsWith("http")) {
|
||||
try {
|
||||
val oldUrl = java.net.URL(urlTemplate)
|
||||
val newUrl = java.net.URL(streamCatalog.url)
|
||||
val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
|
||||
val newBase = "${newUrl.protocol}://${newUrl.authority}"
|
||||
urlTemplate.replace(oldBase, newBase)
|
||||
} catch (_: Exception) {
|
||||
urlTemplate
|
||||
}
|
||||
} else urlTemplate
|
||||
|
||||
val url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString())
|
||||
.replace("{maxWidth}", "1600")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -178,7 +178,6 @@ fun AppTheme(
|
|||
MaterialTheme(
|
||||
colorScheme = finalColorScheme,
|
||||
typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography,
|
||||
shapes = AppShapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
package org.dueattendant149.bookreader.audio
|
||||
|
||||
data class AudioTrack(
|
||||
val fileId: String,
|
||||
val title: String,
|
||||
val durationMs: Long,
|
||||
val order: Int,
|
||||
)
|
||||
|
|
@ -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 = "",
|
||||
)
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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/"
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookreader.rsvp
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* One word/segment in the RSVP stream.
|
||||
*
|
||||
* @param word the original word as it appears in the book.
|
||||
* @param pivotIndex index of the optimal recognition point within [word];
|
||||
* characters before are [prefix], the pivot is [pivot] char, after are [suffix].
|
||||
* @param isParagraphEnd true if the token is the last word of a paragraph (longer pause).
|
||||
* @param isChapterEnd true if the token follows a chapter heading.
|
||||
* @param multiplier duration multiplier (1.0 baseline) for per-word pacing.
|
||||
*/
|
||||
@Immutable
|
||||
data class RsvpToken(
|
||||
val word: String,
|
||||
val pivotIndex: Int,
|
||||
val isParagraphEnd: Boolean,
|
||||
val isChapterEnd: Boolean,
|
||||
val multiplier: Float = 1f,
|
||||
) {
|
||||
val prefix: String
|
||||
get() = word.substring(0, pivotIndex)
|
||||
|
||||
val pivot: String
|
||||
get() = word.substring(pivotIndex, pivotIndex + 1)
|
||||
|
||||
val suffix: String
|
||||
get() = word.substring(pivotIndex + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimal recognition point (ORP) lookup for word lengths 1..13+.
|
||||
* Indices are 0-based positions of the focal character.
|
||||
* Values follow the heuristic table from Spritz/speed-reading literature:
|
||||
* 1→0, 2→0, 3→1, 4→1, 5→1, 6→2, 7→2, 8→2, 9→2, 10→3, 11→3, 12→3, 13→3.
|
||||
*/
|
||||
object OrpTable {
|
||||
private val TABLE = intArrayOf(0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3)
|
||||
|
||||
fun pivotFor(word: String): Int {
|
||||
val len = word.length
|
||||
if (len == 0) return 0
|
||||
if (len <= TABLE.size) return TABLE[len - 1]
|
||||
return (len * 0.3f).toInt().coerceIn(1, len - 1)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
fun currentTimestamp(): Long = System.currentTimeMillis()
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -2066,6 +2066,4 @@
|
|||
<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". -->
|
||||
<string name="tts_replacements_summary_format">%1$s -> %2$s</string>
|
||||
<string name="audio_playback_channel">Audio Playback</string>
|
||||
<string name="audio_playback_channel_description">Audio playback controls</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@ plugins {
|
|||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.android.library) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.kotlin.ksp) apply false
|
||||
alias(libs.plugins.hilt) apply false
|
||||
alias(libs.plugins.compose.multiplatform) apply false
|
||||
alias(libs.plugins.kover) apply false
|
||||
}
|
||||
|
||||
|
|
@ -19,6 +18,7 @@ subprojects {
|
|||
val rootTest = rootProject.tasks.named("test")
|
||||
tasks.matching {
|
||||
it.name == "allTests" ||
|
||||
it.name == "desktopTest" ||
|
||||
it.name.endsWith("DebugUnitTest")
|
||||
}.configureEach {
|
||||
rootTest.configure {
|
||||
|
|
@ -29,14 +29,3 @@ subprojects {
|
|||
maxHeapSize = "4g"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
force("org.jetbrains.kotlin:kotlin-stdlib:2.1.20")
|
||||
force("org.jetbrains.kotlin:kotlin-stdlib-common:2.1.20")
|
||||
force("org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.20")
|
||||
force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.20")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1621
desktopApp/build.gradle.kts
Normal file
1621
desktopApp/build.gradle.kts
Normal file
File diff suppressed because it is too large
Load diff
26
desktopApp/compose-desktop.pro
Normal file
26
desktopApp/compose-desktop.pro
Normal 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.**
|
||||
229
desktopApp/packaging/README.md
Normal file
229
desktopApp/packaging/README.md
Normal 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
|
||||
```
|
||||
|
|
@ -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())
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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})"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()}")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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() })
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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("&")
|
||||
'<' -> append("<")
|
||||
'>' -> append(">")
|
||||
'"' -> append(""")
|
||||
'\'' -> append("'")
|
||||
else -> append(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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("\"", "\\\"")
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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]}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue